forked from templates/template-go-backend
Добавить приоритетную очередь, аутентификацию и администрирование
- Приоритетная очередь для контроля параллельных запросов - Аутентификация по API-ключу из URL (/auth/<key>/v1/...) - Роли пользователей с белым списком моделей и ограничением контекста (num_ctx) - Sliding window rate limiting - Admin API для горячей перезагрузки users.json без перезапуска прокси - Graceful shutdown с таймаутом завершения активных запросов - Маскировка API-ключа в логах - Подробная инструкция по установке для Windows и Linux (SETUP_WIN_SERVER.md)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
||||
.env
|
||||
.vscode/
|
||||
.DS_Store
|
||||
.claude/
|
||||
# конфиг пользователей (содержит API-ключи)
|
||||
users.json
|
||||
|
||||
# собранные бинарники
|
||||
*.exe
|
||||
|
||||
482
SETUP_WIN_SERVER.md
Normal file
482
SETUP_WIN_SERVER.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Настройка Ollama Proxy
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
```
|
||||
Codex (клиентский ноутбук)
|
||||
↓ HTTP-запрос с API-ключом в URL
|
||||
http://<IP_СЕРВЕРА>:8080/auth/<ключ>/v1
|
||||
↓
|
||||
Go Proxy (сервер, порт 8080) ← единственная точка входа снаружи
|
||||
│ ├── извлечение API-ключа из URL
|
||||
│ ├── аутентификация пользователя
|
||||
│ ├── проверка доступа к модели
|
||||
│ ├── rate limiting
|
||||
│ └── приоритетная очередь (VIP первый)
|
||||
↓
|
||||
Ollama (сервер, localhost:11434) ← недоступна снаружи напрямую
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Часть 1: Настройка сервера
|
||||
|
||||
### 1. Установить Ollama
|
||||
|
||||
**Windows:**
|
||||
|
||||
Скачать установщик с [ollama.com](https://ollama.com) и установить.
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://ollama.com/install.sh | sh
|
||||
```
|
||||
|
||||
После установки скачать нужные модели (полный список смотрите на сайте ollama):
|
||||
|
||||
```bash
|
||||
ollama pull qwen2.5:1.5b
|
||||
ollama pull qwen3:8b
|
||||
```
|
||||
|
||||
Проверить что модели скачались:
|
||||
|
||||
```bash
|
||||
ollama list
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Настроить переменные Ollama
|
||||
|
||||
По умолчанию Ollama слушает `0.0.0.0` — это значит, что к ней можно подключиться напрямую из сети, минуя прокси. После настройки прокси Ollama должна слушать только `localhost`.
|
||||
|
||||
**Windows** (PowerShell от имени администратора):
|
||||
|
||||
```powershell
|
||||
# Ollama слушает только localhost. Внешние подключения идут через прокси
|
||||
setx OLLAMA_HOST "127.0.0.1" /M
|
||||
|
||||
# Модель остаётся в памяти 30 минут после последнего запроса.
|
||||
# Без этого каждый запрос ждёт повторной загрузки модели (15-30 сек)
|
||||
setx OLLAMA_KEEP_ALIVE "30m" /M
|
||||
|
||||
# Количество параллельных запросов.
|
||||
# ВАЖНО: должно совпадать с MAX_PARALLEL в .env прокси
|
||||
setx OLLAMA_NUM_PARALLEL "2" /M
|
||||
|
||||
# Ускорение инференса на GPU (если поддерживается)
|
||||
setx OLLAMA_FLASH_ATTENTION "1" /M
|
||||
```
|
||||
|
||||
После `setx /M` закрой и открой PowerShell заново — иначе переменные не подхватятся.
|
||||
|
||||
Запустить Ollama в новом PowerShell:
|
||||
|
||||
```powershell
|
||||
ollama serve
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
sudo systemctl edit ollama
|
||||
```
|
||||
|
||||
Необходимо **между** строками `Anything between here and the comment below...` и `Edits below this comment will be discarded` вставить:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment="OLLAMA_HOST=127.0.0.1"
|
||||
Environment="OLLAMA_KEEP_ALIVE=30m"
|
||||
Environment="OLLAMA_NUM_PARALLEL=2"
|
||||
Environment="OLLAMA_FLASH_ATTENTION=1"
|
||||
```
|
||||
|
||||
Сохранить: `Ctrl+O` → `Enter` → `Ctrl+X`. Применить:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ollama
|
||||
```
|
||||
|
||||
Проверить что Ollama запущена:
|
||||
|
||||
```bash
|
||||
curl http://localhost:11434/api/tags
|
||||
```
|
||||
|
||||
Должен вернуть JSON со списком установленных моделей.
|
||||
|
||||
---
|
||||
|
||||
### 3. Установить Go (только для сборки)
|
||||
|
||||
Go нужен один раз — чтобы собрать бинарник прокси.
|
||||
|
||||
**Windows:**
|
||||
|
||||
Скачать установщик `goX.XX.X.windows-amd64.msi` с [go.dev/dl](https://go.dev/dl/) и установить.
|
||||
|
||||
Проверить в новом PowerShell:
|
||||
|
||||
```powershell
|
||||
go version
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
wget https://go.dev/dl/go1.24.4.linux-amd64.tar.gz
|
||||
sudo tar -C /usr/local -xzf go1.24.4.linux-amd64.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
|
||||
source ~/.bashrc
|
||||
go version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Скопировать проект и собрать прокси
|
||||
|
||||
```bash
|
||||
git clone <url_репозитория>
|
||||
cd Proxy_for_codex
|
||||
|
||||
# Скачать зависимости
|
||||
go mod download
|
||||
|
||||
# Собрать бинарник
|
||||
# Windows:
|
||||
go build -o proxy.exe ./src
|
||||
|
||||
# Linux:
|
||||
go build -o service ./src
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Создать конфиг пользователей
|
||||
|
||||
Скопировать шаблон:
|
||||
|
||||
```bash
|
||||
# Windows:
|
||||
copy users.example.json users.json
|
||||
|
||||
# Linux:
|
||||
cp users.example.json users.json
|
||||
```
|
||||
|
||||
Открыть `users.json` и заполнить:
|
||||
|
||||
```json
|
||||
{
|
||||
"roles": {
|
||||
"vip": {
|
||||
"priority": 100,
|
||||
"allowed_models": ["qwen3:8b", "qwen2.5:1.5b"],
|
||||
"max_context_length": 32768,
|
||||
"rate_limit": { "requests": 60, "window": "1m" }
|
||||
},
|
||||
"regular": {
|
||||
"priority": 10,
|
||||
"allowed_models": ["qwen2.5:1.5b"],
|
||||
"max_context_length": 8192,
|
||||
"rate_limit": { "requests": 20, "window": "1m" }
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"ivanov-key-abc123": { "name": "Иванов", "role": "vip", "enabled": true },
|
||||
"petrov-key-xyz789": { "name": "Петров", "role": "regular", "enabled": true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Поля конфигурации:**
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `priority` | Приоритет в очереди. Когда все слоты заняты — VIP (100) обслуживается раньше regular (10) |
|
||||
| `allowed_models` | Белый список моделей. Пустой массив `[]` — доступны все модели |
|
||||
| `max_context_length` | Максимальный размер контекста (`num_ctx`). Прокси урежет запрос если превышен |
|
||||
| `rate_limit.requests` | Максимум запросов за окно |
|
||||
| `rate_limit.window` | Длительность окна: `"1m"`, `"30s"`, `"5m"` и т.д. |
|
||||
| `enabled` | `false` — пользователь заблокирован, все его запросы отклоняются с кодом 401 |
|
||||
|
||||
**API-ключи** — любые строки, которые придумывает администратор. Каждый сотрудник получает свой уникальный ключ. Например: `ivanov-2024-abc`, `petrov-key-001`.
|
||||
|
||||
---
|
||||
|
||||
### 6. Создать файл .env
|
||||
|
||||
Создать файл `.env` рядом с бинарником прокси:
|
||||
|
||||
```
|
||||
# Адрес и порт, на котором прокси принимает входящие подключения.
|
||||
# :8080 = все сетевые интерфейсы, порт 8080
|
||||
LISTEN_ADDR=:8080
|
||||
|
||||
# URL бэкенда Ollama. Должен быть доступен только локально
|
||||
OLLAMA_BACKEND=http://localhost:11434
|
||||
|
||||
# Количество параллельных запросов к Ollama.
|
||||
# ОБЯЗАТЕЛЬНО должно совпадать с OLLAMA_NUM_PARALLEL
|
||||
MAX_PARALLEL=2
|
||||
|
||||
# Максимальный размер очереди ожидания. При переполнении — 503
|
||||
MAX_QUEUE_SIZE=100
|
||||
|
||||
# Путь к файлу пользователей.
|
||||
# Можно указать абсолютный путь, если запускаете прокси из другой папки
|
||||
USERS_CONFIG_PATH=users.json
|
||||
|
||||
# Ключ для admin API (чтобы менять users.json без перезапуска прокси).
|
||||
# Придумайте длинную случайную строку
|
||||
ADMIN_API_KEY=my-secret-admin-key
|
||||
|
||||
# Уровень логирования: debug, info, warn, error
|
||||
LOG_LEVEL=info
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Запустить прокси
|
||||
|
||||
**Windows:**
|
||||
|
||||
```powershell
|
||||
cd d:\путь\к\Proxy_for_codex
|
||||
.\proxy.exe
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
cd ~/Proxy_for_codex
|
||||
./service
|
||||
```
|
||||
|
||||
При успешном запуске:
|
||||
|
||||
```
|
||||
level=INFO msg="Starting Ollama Proxy version 2.0.0"
|
||||
level=INFO msg="Конфигурация пользователей загружена" path=users.json active_users=2 total_users=2
|
||||
level=INFO msg="Прокси запущен" addr=:8080 backend=http://localhost:11434 max_parallel=2 max_queue=100 auth_enabled=true
|
||||
```
|
||||
|
||||
> Если `auth_enabled=false` — значит `users.json` не найден или содержит ошибку. Прокси работает, но без аутентификации.
|
||||
|
||||
---
|
||||
|
||||
### 8. Открыть порт в файрволе
|
||||
|
||||
**Windows** (PowerShell от имени администратора):
|
||||
|
||||
```powershell
|
||||
New-NetFirewallRule -DisplayName "Ollama Proxy" -Direction Inbound -Port 8080 -Protocol TCP -Action Allow
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
# Проверить статус файрвола
|
||||
sudo ufw status
|
||||
|
||||
# Если Status: active — открыть порт
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
Если `Status: inactive` — файрвол выключен, ничего делать не нужно.
|
||||
|
||||
---
|
||||
|
||||
## Часть 2: Настройка клиента
|
||||
|
||||
### 1. Установить Codex
|
||||
|
||||
**Windows:**
|
||||
|
||||
Установить [Node.js](https://nodejs.org), затем в PowerShell:
|
||||
|
||||
```powershell
|
||||
npm install -g @openai/codex
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
npm install -g @openai/codex
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Настроить конфиг Codex
|
||||
|
||||
Codex по умолчанию обращается к серверам OpenAI. Чтобы перенаправить его на наш прокси — нужно создать файл конфигурации и указать адрес прокси и API-ключ.
|
||||
|
||||
#### Где находится файл конфига
|
||||
|
||||
**Linux:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.codex
|
||||
nano ~/.codex/config.toml
|
||||
```
|
||||
|
||||
**Windows** — найти файл по пути:
|
||||
|
||||
```
|
||||
C:\Users\<ваш_логин>\.codex\config.toml
|
||||
```
|
||||
|
||||
#### Содержимое конфига
|
||||
|
||||
Вставить следующее, **заменив два значения** (объяснение ниже):
|
||||
|
||||
```toml
|
||||
# Модель по умолчанию, ставьте какую хотите и какая скачана
|
||||
model = "qwen2.5:1.5b"
|
||||
model_provider = "ollama_proxy"
|
||||
|
||||
[model_providers.ollama_proxy]
|
||||
name = "Ollama (прокси)"
|
||||
base_url = "http://<IP_СЕРВЕРА>:8080/auth/<ключ>/v1"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
**Что нужно заменить:**
|
||||
|
||||
**`<IP_СЕРВЕРА>`** — IP-адрес машины, где запущен прокси.
|
||||
|
||||
Узнать IP сервера:
|
||||
- На сервере Windows: `ipconfig` → строка `IPv4-адрес`
|
||||
- На сервере Linux: `hostname -I` → первое число
|
||||
|
||||
**`<ключ>`** — персональный API-ключ сотрудника из `users.json` на сервере. Администратор выдаёт каждому сотруднику свой ключ.
|
||||
|
||||
**Пример готового конфига для Иванова** с ключом `ivanov-key-abc123` и сервером `10.111.111.40`:
|
||||
|
||||
```toml
|
||||
model = "qwen2.5:1.5b"
|
||||
model_provider = "ollama_proxy"
|
||||
|
||||
[model_providers.ollama_proxy]
|
||||
name = "Ollama (прокси)"
|
||||
base_url = "http://10.111.111.40:8080/auth/ivanov-key-abc123/v1"
|
||||
wire_api = "responses"
|
||||
```
|
||||
|
||||
> Поле `model` — модель по умолчанию, которая используется при обычном запуске `codex`. Прокси проверит, разрешена ли эта модель для роли пользователя. Если нет — вернёт ошибку 403.
|
||||
|
||||
---
|
||||
|
||||
### 3. Запуск
|
||||
|
||||
Запустить Codex с моделью по умолчанию (из конфига):
|
||||
|
||||
```bash
|
||||
codex
|
||||
```
|
||||
|
||||
Запустить с другой моделью (если роль позволяет):
|
||||
|
||||
```bash
|
||||
codex -m qwen3:8b #или любая другая модель
|
||||
```
|
||||
|
||||
Codex отправит запрос на прокси → прокси проверит ключ → проверит, разрешена ли модель → поставит в очередь если все слоты заняты → Ollama сгенерирует ответ.
|
||||
|
||||
**Если что-то не работает** — попросить администратора сервера посмотреть логи прокси: там будет видно, отклонён ли запрос и по какой причине (неверный ключ, запрещённая модель, превышен rate limit).
|
||||
|
||||
---
|
||||
|
||||
## Часть 3: Администрирование
|
||||
|
||||
### Изменение users.json без перезапуска прокси
|
||||
|
||||
Можно менть положение клиентов и их роли во время работы прокси без его перезапуска. Достаточно в `users.json` изменить что хотите и тогда после любых изменений в `users.json` — добавления пользователя, смены роли, блокировки — отправить команду reload:
|
||||
|
||||
```bash
|
||||
# Linux / Windows (в Git Bash или WSL):
|
||||
curl -X POST -H "Authorization: Bearer my-secret-admin-key" http://localhost:8080/admin/reload
|
||||
|
||||
# Windows PowerShell:
|
||||
curl -Method POST -Headers @{"Authorization"="Bearer my-secret-admin-key"} http://localhost:8080/admin/reload
|
||||
```
|
||||
|
||||
Ответ: `{"status":"reloaded"}`
|
||||
|
||||
> `my-secret-admin-key` — заменить на значение `ADMIN_API_KEY` из файла `.env` на сервере.
|
||||
|
||||
---
|
||||
|
||||
### Заблокировать пользователя
|
||||
|
||||
В `users.json` поставить `"enabled": false` и выполнить reload:
|
||||
|
||||
```json
|
||||
"petrov-key-xyz789": { "name": "Петров", "role": "regular", "enabled": false }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Сменить роль пользователя
|
||||
|
||||
В `users.json` поменять `"role"` и выполнить reload:
|
||||
|
||||
```json
|
||||
"petrov-key-xyz789": { "name": "Петров", "role": "vip", "enabled": true }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Мониторинг логов
|
||||
|
||||
**Windows** — логи выводятся в терминал, где запущен `proxy.exe`.
|
||||
|
||||
**Linux (systemd):**
|
||||
|
||||
```bash
|
||||
# Следить за логами в реальном времени
|
||||
sudo journalctl -u ollama-proxy -f
|
||||
|
||||
# Последние 50 строк
|
||||
sudo journalctl -u ollama-proxy -n 50
|
||||
```
|
||||
|
||||
Примеры записей:
|
||||
|
||||
```
|
||||
level=INFO msg="запрос" method=POST path=/v1/responses status=200 duration=3.456s
|
||||
level=INFO msg="запрос вышел из очереди" priority=100 in_flight=1 queued=0
|
||||
level=WARN msg="Запрос к запрещённой модели" user=Петров role=regular model=qwen3:8b allowed=[qwen2.5:1.5b]
|
||||
level=WARN msg="Превышен rate limit" user=Петров role=regular limit=20 window=1m0s
|
||||
level=WARN msg="Невалидный API-ключ" key_prefix=unknown-***
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP-коды ошибок
|
||||
|
||||
| Код | Причина | Решение |
|
||||
|---|---|---|
|
||||
| 401 | Нет ключа или невалидный | Проверить `base_url` в config.toml — ключ должен быть между `/auth/` и `/v1` |
|
||||
| 403 | Модель запрещена для роли | Добавить модель в `allowed_models` роли в `users.json` |
|
||||
| 429 | Превышен rate limit | Подождать или увеличить лимит в роли |
|
||||
| 502 | Ollama не отвечает | Проверить что Ollama запущена на сервере |
|
||||
| 503 | Очередь переполнена | Увеличить `MAX_QUEUE_SIZE` или `MAX_PARALLEL` в `.env` |
|
||||
|
||||
---
|
||||
|
||||
## Важно: MAX_PARALLEL и OLLAMA_NUM_PARALLEL
|
||||
|
||||
Эти два значения **обязаны совпадать**:
|
||||
|
||||
- `MAX_PARALLEL` в `.env` прокси — сколько запросов прокси одновременно пропускает к Ollama
|
||||
- `OLLAMA_NUM_PARALLEL` в переменных среды сервера — сколько запросов Ollama обрабатывает параллельно
|
||||
|
||||
Если значения расходятся (например, прокси=3, Ollama=2) — третий запрос попадёт во внутреннюю очередь Ollama, минуя приоритетную очередь прокси. VIP не получит преимущества.
|
||||
69
src/internal/admin/handler.go
Normal file
69
src/internal/admin/handler.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// Пакет admin реализует административный API
|
||||
// для управления прокси без перезапуска.
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"backend/src/internal/auth"
|
||||
)
|
||||
|
||||
// Handler обрабатывает административные HTTP-эндпоинты.
|
||||
// Доступ защищён отдельным admin API-ключом.
|
||||
type Handler struct {
|
||||
logger *slog.Logger
|
||||
store auth.UserStore
|
||||
adminAPIKey string
|
||||
}
|
||||
|
||||
// NewHandler создаёт обработчик административного API.
|
||||
func NewHandler(logger *slog.Logger, store auth.UserStore, adminAPIKey string) *Handler {
|
||||
return &Handler{
|
||||
logger: logger,
|
||||
store: store,
|
||||
adminAPIKey: adminAPIKey,
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadHandler возвращает обработчик POST /admin/reload,
|
||||
// который перечитывает users.json без перезапуска прокси.
|
||||
func (h *Handler) ReloadHandler() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
jsonResponse(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.adminAPIKey == "" {
|
||||
jsonResponse(w, http.StatusForbidden, map[string]string{"error": "admin API not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
header := r.Header.Get("Authorization")
|
||||
key := strings.TrimPrefix(header, "Bearer ")
|
||||
if key != h.adminAPIKey {
|
||||
jsonResponse(w, http.StatusUnauthorized, map[string]string{"error": "invalid admin key"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.Reload(); err != nil {
|
||||
h.logger.Error("Ошибка перезагрузки конфигурации пользователей", "error", err)
|
||||
jsonResponse(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Info("Конфигурация пользователей перезагружена через admin API")
|
||||
jsonResponse(w, http.StatusOK, map[string]string{"status": "reloaded"})
|
||||
}
|
||||
}
|
||||
|
||||
// jsonResponse формирует HTTP-ответ в формате JSON.
|
||||
func jsonResponse(w http.ResponseWriter, code int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
162
src/internal/auth/middleware.go
Normal file
162
src/internal/auth/middleware.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Пакет auth реализует аутентификацию по API-ключу,
|
||||
// проверку доступа к моделям и ограничение размера контекста.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
// UserContextKey — ключ контекста для хранения аутентифицированного пользователя.
|
||||
const UserContextKey ctxKey = "resolved_user"
|
||||
|
||||
// UserFromContext извлекает ResolvedUser из контекста запроса.
|
||||
// Возвращает false, если пользователь не был аутентифицирован.
|
||||
func UserFromContext(ctx context.Context) (ResolvedUser, bool) {
|
||||
u, ok := ctx.Value(UserContextKey).(ResolvedUser)
|
||||
return u, ok
|
||||
}
|
||||
|
||||
// AuthMiddleware проверяет заголовок Authorization: Bearer <key>,
|
||||
// находит пользователя в store и сохраняет ResolvedUser в контексте запроса.
|
||||
func AuthMiddleware(logger *slog.Logger, store UserStoreReader, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
jsonError(w, http.StatusUnauthorized, "missing API key")
|
||||
return
|
||||
}
|
||||
|
||||
key := strings.TrimPrefix(header, "Bearer ")
|
||||
if key == header {
|
||||
jsonError(w, http.StatusUnauthorized, "invalid authorization format, expected Bearer <key>")
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := store.LookupByKey(key)
|
||||
if !ok {
|
||||
logger.Warn("Невалидный API-ключ", "key_prefix", safePrefix(key))
|
||||
jsonError(w, http.StatusUnauthorized, "invalid or disabled API key")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Пользователь аутентифицирован", "user", user.Name, "role", user.RoleKey)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserContextKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Пути Ollama native API, где поддерживается параметр options.num_ctx.
|
||||
var ollamaNativePaths = []string{
|
||||
"/api/chat",
|
||||
"/api/generate",
|
||||
}
|
||||
|
||||
// Пути, содержащие поле "model" в теле запроса.
|
||||
// Включает как Ollama native, так и OpenAI-совместимые эндпоинты.
|
||||
var modelCheckPaths = []string{
|
||||
"/api/chat",
|
||||
"/api/generate",
|
||||
"/v1/chat/completions",
|
||||
"/v1/responses",
|
||||
}
|
||||
|
||||
// ModelCheckMiddleware проверяет запрашиваемую модель на соответствие
|
||||
// белому списку роли и ограничивает num_ctx для Ollama native API.
|
||||
// Применяется только к POST-запросам на эндпоинты генерации.
|
||||
func ModelCheckMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || !slices.Contains(modelCheckPaths, r.URL.Path) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(r.Context())
|
||||
if !ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
r.Body.Close()
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "cannot read request body")
|
||||
return
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
// Невалидный JSON передаётся как есть — Ollama вернёт свою ошибку.
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Проверка модели по белому списку роли.
|
||||
if model, ok := payload["model"].(string); ok {
|
||||
if len(user.Role.AllowedModels) > 0 && !slices.Contains(user.Role.AllowedModels, model) {
|
||||
logger.Warn("Запрос к запрещённой модели",
|
||||
"user", user.Name, "role", user.RoleKey,
|
||||
"model", model, "allowed", user.Role.AllowedModels,
|
||||
)
|
||||
jsonError(w, http.StatusForbidden, "model not allowed for your role")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ограничение num_ctx только для Ollama native API.
|
||||
// OpenAI-совместимые эндпоинты (/v1/...) не используют options.num_ctx.
|
||||
if user.Role.MaxContextLength > 0 && slices.Contains(ollamaNativePaths, r.URL.Path) {
|
||||
limitNumCtx(payload, user.Role.MaxContextLength)
|
||||
}
|
||||
|
||||
newBody, _ := json.Marshal(payload)
|
||||
r.Body = io.NopCloser(bytes.NewReader(newBody))
|
||||
r.ContentLength = int64(len(newBody))
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// limitNumCtx ограничивает значение options.num_ctx в теле запроса.
|
||||
// Если num_ctx не задан или превышает maxCtx — устанавливается maxCtx.
|
||||
func limitNumCtx(payload map[string]any, maxCtx int) {
|
||||
opts, _ := payload["options"].(map[string]any)
|
||||
if opts == nil {
|
||||
opts = make(map[string]any)
|
||||
payload["options"] = opts
|
||||
}
|
||||
|
||||
if numCtx, ok := opts["num_ctx"].(float64); ok {
|
||||
if int(numCtx) > maxCtx {
|
||||
opts["num_ctx"] = maxCtx
|
||||
}
|
||||
} else {
|
||||
opts["num_ctx"] = maxCtx
|
||||
}
|
||||
}
|
||||
|
||||
// jsonError отправляет JSON-ответ с ошибкой.
|
||||
func jsonError(w http.ResponseWriter, code int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// safePrefix возвращает первые 8 символов строки с маскировкой остатка.
|
||||
// Используется для безопасного логирования API-ключей.
|
||||
func safePrefix(key string) string {
|
||||
if len(key) <= 8 {
|
||||
return key + "***"
|
||||
}
|
||||
return key[:8] + "***"
|
||||
}
|
||||
49
src/internal/auth/models.go
Normal file
49
src/internal/auth/models.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package auth
|
||||
|
||||
import "time"
|
||||
|
||||
// RateLimitConfig определяет ограничение частоты запросов для роли.
|
||||
type RateLimitConfig struct {
|
||||
Requests int `json:"requests"` // максимальное количество запросов за окно
|
||||
Window string `json:"window"` // длительность окна: "1m", "30s" и т.д.
|
||||
}
|
||||
|
||||
// WindowDuration преобразует строковое значение Window в time.Duration.
|
||||
// При ошибке парсинга возвращает 1 минуту как значение по умолчанию.
|
||||
func (r RateLimitConfig) WindowDuration() time.Duration {
|
||||
d, err := time.ParseDuration(r.Window)
|
||||
if err != nil {
|
||||
return time.Minute
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Role описывает роль пользователя с настройками доступа и приоритета.
|
||||
type Role struct {
|
||||
Priority int `json:"priority"` // приоритет в очереди (больше = выше)
|
||||
AllowedModels []string `json:"allowed_models"` // разрешённые модели (пустой = все)
|
||||
MaxContextLength int `json:"max_context_length"` // максимальный num_ctx
|
||||
RateLimit RateLimitConfig `json:"rate_limit"`
|
||||
}
|
||||
|
||||
// User описывает пользователя в конфигурационном файле.
|
||||
type User struct {
|
||||
Name string `json:"name"`
|
||||
RoleKey string `json:"role"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// UsersConfig — корневая структура файла users.json.
|
||||
type UsersConfig struct {
|
||||
Roles map[string]Role `json:"roles"`
|
||||
Users map[string]User `json:"users"` // ключ map — API-ключ пользователя
|
||||
}
|
||||
|
||||
// ResolvedUser — пользователь с развёрнутой ролью,
|
||||
// полученный после поиска по API-ключу.
|
||||
type ResolvedUser struct {
|
||||
APIKey string
|
||||
Name string
|
||||
Role Role
|
||||
RoleKey string
|
||||
}
|
||||
93
src/internal/auth/store.go
Normal file
93
src/internal/auth/store.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// UserStoreReader предоставляет доступ к хранилищу пользователей на чтение.
|
||||
type UserStoreReader interface {
|
||||
LookupByKey(apiKey string) (ResolvedUser, bool)
|
||||
}
|
||||
|
||||
// UserStore расширяет UserStoreReader возможностью горячей перезагрузки.
|
||||
type UserStore interface {
|
||||
UserStoreReader
|
||||
Reload() error
|
||||
}
|
||||
|
||||
// JSONFileStore загружает и хранит конфигурацию пользователей из JSON-файла.
|
||||
// Поддерживает потокобезопасный доступ через sync.RWMutex
|
||||
// и горячую перезагрузку без остановки сервера.
|
||||
type JSONFileStore struct {
|
||||
path string
|
||||
logger *slog.Logger
|
||||
mu sync.RWMutex
|
||||
users map[string]ResolvedUser // ключ map — API-ключ
|
||||
}
|
||||
|
||||
// NewJSONFileStore создаёт хранилище и загружает данные из файла.
|
||||
// Возвращает ошибку, если файл не найден или содержит невалидную конфигурацию.
|
||||
func NewJSONFileStore(path string, logger *slog.Logger) (*JSONFileStore, error) {
|
||||
s := &JSONFileStore{
|
||||
path: path,
|
||||
logger: logger,
|
||||
}
|
||||
if err := s.Reload(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Reload перечитывает JSON-файл, валидирует ссылки на роли
|
||||
// и атомарно обновляет lookup-map.
|
||||
func (s *JSONFileStore) Reload() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("невозможно прочитать %q: %w", s.path, err)
|
||||
}
|
||||
|
||||
var cfg UsersConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return fmt.Errorf("невозможно распарсить %q: %w", s.path, err)
|
||||
}
|
||||
|
||||
resolved := make(map[string]ResolvedUser, len(cfg.Users))
|
||||
for apiKey, u := range cfg.Users {
|
||||
role, ok := cfg.Roles[u.RoleKey]
|
||||
if !ok {
|
||||
return fmt.Errorf("пользователь %q ссылается на несуществующую роль %q", u.Name, u.RoleKey)
|
||||
}
|
||||
if !u.Enabled {
|
||||
continue
|
||||
}
|
||||
resolved[apiKey] = ResolvedUser{
|
||||
APIKey: apiKey,
|
||||
Name: u.Name,
|
||||
Role: role,
|
||||
RoleKey: u.RoleKey,
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.users = resolved
|
||||
s.mu.Unlock()
|
||||
|
||||
s.logger.Info("Конфигурация пользователей загружена",
|
||||
"path", s.path,
|
||||
"active_users", len(resolved),
|
||||
"total_users", len(cfg.Users),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LookupByKey находит активного пользователя по API-ключу
|
||||
func (s *JSONFileStore) LookupByKey(apiKey string) (ResolvedUser, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
u, ok := s.users[apiKey]
|
||||
return u, ok
|
||||
}
|
||||
52
src/internal/auth/urlkey.go
Normal file
52
src/internal/auth/urlkey.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// URLKeyMiddleware извлекает API-ключ из URL-пути вида /auth/<key>/...
|
||||
// и преобразует его в стандартный заголовок Authorization: Bearer <key>.
|
||||
//
|
||||
// Необходим для клиентов (Codex), которые не поддерживают
|
||||
// передачу API-ключа через HTTP-заголовки.
|
||||
//
|
||||
// Пример преобразования:
|
||||
//
|
||||
// /auth/fedya-key-1234/v1/chat/completions
|
||||
// → путь: /v1/chat/completions
|
||||
// → заголовок: Authorization: Bearer fedya-key-1234
|
||||
func URLKeyMiddleware(next http.Handler) http.Handler {
|
||||
const prefix = "/auth/"
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, prefix) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rest := r.URL.Path[len(prefix):]
|
||||
slash := strings.IndexByte(rest, '/')
|
||||
if slash <= 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
key := rest[:slash]
|
||||
newPath := rest[slash:]
|
||||
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = newPath
|
||||
if r2.URL.RawPath != "" {
|
||||
r2.URL.RawPath = newPath
|
||||
}
|
||||
|
||||
// Заголовок устанавливается только если клиент не передал его явно.
|
||||
if r2.Header.Get("Authorization") == "" {
|
||||
r2.Header = r2.Header.Clone()
|
||||
r2.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// LoadConfig загружает конфиг из .env (если есть) и окружения.
|
||||
// LoadConfig загружает конфигурацию из файла .env (при наличии)
|
||||
// и переменных окружения. Переменные окружения имеют приоритет.
|
||||
func LoadConfig(logger *slog.Logger) (*Config, error) {
|
||||
_ = godotenv.Load()
|
||||
|
||||
@@ -19,6 +20,10 @@ func LoadConfig(logger *slog.Logger) (*Config, error) {
|
||||
|
||||
cfg.ListenAddr = GetEnvAs("LISTEN_ADDR", ":8080", ParseString)
|
||||
cfg.BackendURL = GetEnvAs("OLLAMA_BACKEND", "http://localhost:11434", ParseString)
|
||||
cfg.MaxParallel = GetEnvAs("MAX_PARALLEL", 2, ParseInt)
|
||||
cfg.MaxQueueSize = GetEnvAs("MAX_QUEUE_SIZE", 100, ParseInt)
|
||||
cfg.UsersConfigPath = GetEnvAs("USERS_CONFIG_PATH", "users.json", ParseString)
|
||||
cfg.AdminAPIKey = GetEnvAs("ADMIN_API_KEY", "", ParseString)
|
||||
|
||||
cfg.LoggingConfig.Instance = logger
|
||||
cfg.LoggingConfig.Level = GetEnvAs("LOG_LEVEL", "info", ParseString)
|
||||
@@ -30,7 +35,8 @@ func LoadConfig(logger *slog.Logger) (*Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Print выводит конфигурацию в виде таблички "KEY - VALUE".
|
||||
// Print выводит текущую конфигурацию в виде таблицы.
|
||||
// Используется для отладки при LOG_SHOW_DUMP=true.
|
||||
func (c *Config) Print() {
|
||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(w, "Loaded configuration:")
|
||||
@@ -46,11 +52,9 @@ func (c *Config) Print() {
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
fieldName := t.Field(i).Name
|
||||
fieldValue := v.Field(i).Interface()
|
||||
|
||||
if d, ok := fieldValue.(time.Duration); ok {
|
||||
fieldValue = d.String()
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "%s:\t%v\n", fieldName, fieldValue)
|
||||
}
|
||||
fmt.Fprintln(w, "----\t-----")
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Универсальная функция получения значения из окружения с парсером.
|
||||
// GetEnvAs загружает переменную окружения key, парсит её функцией parse
|
||||
// и возвращает результат. При отсутствии переменной или ошибке парсинга
|
||||
// возвращает defaultVal.
|
||||
func GetEnvAs[T any](key string, defaultVal T, parse func(string) (T, error)) T {
|
||||
raw, ok := os.LookupEnv(key)
|
||||
if !ok || strings.TrimSpace(raw) == "" {
|
||||
@@ -18,23 +20,20 @@ func GetEnvAs[T any](key string, defaultVal T, parse func(string) (T, error)) T
|
||||
}
|
||||
v, err := parse(raw)
|
||||
if err != nil {
|
||||
// одна строка лога достаточно — не дублируем
|
||||
slog.Warn(fmt.Sprintf("cannot parse env %q, using default", key),
|
||||
slog.Warn(fmt.Sprintf("Некорректное значение переменной %q, используется значение по умолчанию", key),
|
||||
"key", key, "raw", raw, "default", defaultVal, "error", err)
|
||||
return defaultVal
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/* ====== БАЗОВЫЕ АДАПТЕРЫ ====== */
|
||||
// --- Парсеры для базовых типов ---
|
||||
|
||||
func ParseString(s string) (string, error) { // просто возвращаем trimmed string
|
||||
return strings.TrimSpace(s), nil
|
||||
}
|
||||
|
||||
func ParseBool(s string) (bool, error) {
|
||||
return strconv.ParseBool(strings.TrimSpace(s))
|
||||
}
|
||||
func ParseString(s string) (string, error) { return strings.TrimSpace(s), nil }
|
||||
func ParseBool(s string) (bool, error) { return strconv.ParseBool(strings.TrimSpace(s)) }
|
||||
func ParseFloat64(s string) (float64, error) { return strconv.ParseFloat(strings.TrimSpace(s), 64) }
|
||||
func ParseURL(s string) (*url.URL, error) { return url.Parse(strings.TrimSpace(s)) }
|
||||
func ParseTimeRFC3339(s string) (time.Time, error) { return time.Parse(time.RFC3339, strings.TrimSpace(s)) }
|
||||
|
||||
func ParseInt(s string) (int, error) {
|
||||
i64, err := strconv.ParseInt(strings.TrimSpace(s), 10, 0)
|
||||
@@ -50,26 +49,14 @@ func ParseUint(s string) (uint, error) {
|
||||
return uint(u64), err
|
||||
}
|
||||
|
||||
func ParseFloat64(s string) (float64, error) {
|
||||
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||
}
|
||||
|
||||
// ParseDuration парсит строку длительности ("150ms", "2s", "1m", "24h").
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
// поддерживает "150ms", "2s", "1m", "24h"
|
||||
return time.ParseDuration(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func ParseTimeRFC3339(s string) (time.Time, error) {
|
||||
return time.Parse(time.RFC3339, strings.TrimSpace(s))
|
||||
}
|
||||
// --- Парсеры для списков ---
|
||||
|
||||
func ParseURL(s string) (*url.URL, error) {
|
||||
return url.Parse(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
/* ====== СПИСКИ (CSV/SEPARATOR) ====== */
|
||||
|
||||
// Универсальный адаптер для списков с произвольным парсером элемента.
|
||||
// MakeListParser создаёт парсер для строки-списка с заданным разделителем.
|
||||
func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(string) ([]T, error) {
|
||||
return func(s string) ([]T, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
@@ -82,7 +69,7 @@ func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(
|
||||
for _, p := range parts {
|
||||
v, err := itemParser(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot parse list item %q: %w", p, err)
|
||||
return nil, fmt.Errorf("ошибка парсинга элемента %q: %w", p, err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
@@ -90,7 +77,8 @@ func MakeListParser[T any](sep string, itemParser func(string) (T, error)) func(
|
||||
}
|
||||
}
|
||||
|
||||
// Частые случаи:
|
||||
var ParseCSVStrings = MakeListParser[string](",", ParseString)
|
||||
var ParseCSVInts = MakeListParser[int](",", ParseInt)
|
||||
var ParseCSVFloat64 = MakeListParser[float64](",", ParseFloat64)
|
||||
var (
|
||||
ParseCSVStrings = MakeListParser[string](",", ParseString)
|
||||
ParseCSVInts = MakeListParser[int](",", ParseInt)
|
||||
ParseCSVFloat64 = MakeListParser[float64](",", ParseFloat64)
|
||||
)
|
||||
|
||||
@@ -2,15 +2,22 @@ package config
|
||||
|
||||
import "log/slog"
|
||||
|
||||
// LoggingConfig содержит настройки логирования.
|
||||
type LoggingConfig struct {
|
||||
Instance *slog.Logger
|
||||
Level string
|
||||
ShowCfgDump bool
|
||||
}
|
||||
|
||||
// Config — корневая структура конфигурации приложения.
|
||||
// Все поля загружаются из переменных окружения (или .env файла).
|
||||
type Config struct {
|
||||
ListenAddr string // env: LISTEN_ADDR, по умолчанию ":8080"
|
||||
BackendURL string // env: OLLAMA_BACKEND, по умолчанию "http://localhost:11434"
|
||||
ListenAddr string // LISTEN_ADDR — адрес и порт прокси (":8080")
|
||||
BackendURL string // OLLAMA_BACKEND — URL бэкенда Ollama ("http://localhost:11434")
|
||||
MaxParallel int // MAX_PARALLEL — количество параллельных слотов (= OLLAMA_NUM_PARALLEL)
|
||||
MaxQueueSize int // MAX_QUEUE_SIZE — максимальный размер очереди ожидания
|
||||
UsersConfigPath string // USERS_CONFIG_PATH — путь к файлу конфигурации пользователей
|
||||
AdminAPIKey string // ADMIN_API_KEY — ключ доступа к admin API
|
||||
|
||||
LoggingConfig
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Пакет logging предоставляет фабрику для создания структурированного логгера
|
||||
// на основе log/slog.
|
||||
package logging
|
||||
|
||||
import (
|
||||
@@ -6,11 +8,16 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// New создаёт slog.Logger с текстовым форматом и заданным уровнем.
|
||||
func New(level string) *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: ParseLevel(level)}))
|
||||
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: ParseLevel(level),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
// ParseLevel преобразует строковое название уровня в slog.Level.
|
||||
// Поддерживаемые значения: "debug", "info", "warn"/"warning", "error".
|
||||
// При невалидном значении возвращает slog.LevelInfo.
|
||||
func ParseLevel(lvl string) slog.Level {
|
||||
switch strings.ToLower(lvl) {
|
||||
case "debug":
|
||||
|
||||
53
src/internal/queue/heap.go
Normal file
53
src/internal/queue/heap.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// Пакет queue реализует приоритетную очередь запросов
|
||||
// с контролем количества параллельных слотов.
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// QueueItem — элемент приоритетной очереди.
|
||||
type QueueItem struct {
|
||||
Priority int // числовой приоритет (больше = выше)
|
||||
EnqueuedAt time.Time // время постановки в очередь (для FIFO при равном приоритете)
|
||||
Ready chan struct{} // канал сигнала о получении слота
|
||||
Ctx context.Context // контекст для отмены ожидания
|
||||
index int // текущая позиция в heap, управляется container/heap
|
||||
}
|
||||
|
||||
// PriorityQueue реализует интерфейс heap.Interface.
|
||||
// Элементы извлекаются в порядке убывания приоритета;
|
||||
// при равном приоритете — в порядке постановки (FIFO).
|
||||
type PriorityQueue []*QueueItem
|
||||
|
||||
func (pq PriorityQueue) Len() int { return len(pq) }
|
||||
|
||||
func (pq PriorityQueue) Less(i, j int) bool {
|
||||
if pq[i].Priority != pq[j].Priority {
|
||||
return pq[i].Priority > pq[j].Priority
|
||||
}
|
||||
return pq[i].EnqueuedAt.Before(pq[j].EnqueuedAt)
|
||||
}
|
||||
|
||||
func (pq PriorityQueue) Swap(i, j int) {
|
||||
pq[i], pq[j] = pq[j], pq[i]
|
||||
pq[i].index = i
|
||||
pq[j].index = j
|
||||
}
|
||||
|
||||
func (pq *PriorityQueue) Push(x any) {
|
||||
item := x.(*QueueItem)
|
||||
item.index = len(*pq)
|
||||
*pq = append(*pq, item)
|
||||
}
|
||||
|
||||
func (pq *PriorityQueue) Pop() any {
|
||||
old := *pq
|
||||
n := len(old)
|
||||
item := old[n-1]
|
||||
old[n-1] = nil // предотвращение утечки памяти
|
||||
item.index = -1
|
||||
*pq = old[:n-1]
|
||||
return item
|
||||
}
|
||||
66
src/internal/queue/middleware.go
Normal file
66
src/internal/queue/middleware.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// PriorityFunc определяет приоритет запроса для постановки в очередь.
|
||||
// Вызывается middleware перед Acquire. Возвращает целое число:
|
||||
// чем больше значение — тем выше приоритет.
|
||||
type PriorityFunc func(r *http.Request) int
|
||||
|
||||
// Middleware ограничивает количество параллельных запросов к бэкенду.
|
||||
// Запросы, не получившие слот, ожидают в приоритетной очереди.
|
||||
// priorityFn определяет приоритет каждого запроса; при nil приоритет = 1.
|
||||
func Middleware(logger *slog.Logger, scheduler *Scheduler, priorityFn PriorityFunc, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
priority := 1
|
||||
if priorityFn != nil {
|
||||
priority = priorityFn(r)
|
||||
}
|
||||
|
||||
waited, err := scheduler.Acquire(r.Context(), priority)
|
||||
if err != nil {
|
||||
handleAcquireError(logger, w, r, err, scheduler)
|
||||
return
|
||||
}
|
||||
if waited {
|
||||
inFlight, queued := scheduler.Stats()
|
||||
logger.Info("запрос вышел из очереди",
|
||||
"method", r.Method, "path", r.URL.Path,
|
||||
"priority", priority, "in_flight", inFlight, "queued", queued,
|
||||
)
|
||||
}
|
||||
defer scheduler.Release()
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleAcquireError формирует HTTP-ответ при ошибке получения слота.
|
||||
func handleAcquireError(logger *slog.Logger, w http.ResponseWriter, r *http.Request, err error, scheduler *Scheduler) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch err {
|
||||
case ErrQueueFull:
|
||||
inFlight, queued := scheduler.Stats()
|
||||
logger.Warn("очередь переполнена",
|
||||
"method", r.Method, "path", r.URL.Path,
|
||||
"in_flight", inFlight, "queued", queued,
|
||||
)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "server overloaded, queue full",
|
||||
})
|
||||
case ErrRequestCancelled:
|
||||
logger.Debug("клиент отключился в очереди",
|
||||
"method", r.Method, "path", r.URL.Path,
|
||||
)
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "request cancelled while queued",
|
||||
})
|
||||
}
|
||||
}
|
||||
110
src/internal/queue/scheduler.go
Normal file
110
src/internal/queue/scheduler.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrQueueFull = errors.New("queue is full")
|
||||
ErrRequestCancelled = errors.New("request cancelled while queued")
|
||||
)
|
||||
|
||||
// Scheduler управляет слотами параллельных запросов и очередью ожидания.
|
||||
// Количество слотов должно соответствовать OLLAMA_NUM_PARALLEL на сервере Ollama.
|
||||
type Scheduler struct {
|
||||
mu sync.Mutex
|
||||
maxSlots int
|
||||
inFlight int
|
||||
waiting PriorityQueue
|
||||
maxQueue int
|
||||
}
|
||||
|
||||
// NewScheduler создаёт планировщик с заданным количеством слотов и размером очереди.
|
||||
func NewScheduler(maxSlots, maxQueue int) *Scheduler {
|
||||
s := &Scheduler{
|
||||
maxSlots: maxSlots,
|
||||
maxQueue: maxQueue,
|
||||
}
|
||||
heap.Init(&s.waiting)
|
||||
return s
|
||||
}
|
||||
|
||||
// Acquire запрашивает слот для выполнения запроса.
|
||||
//
|
||||
// Быстрый путь: если есть свободный слот — возвращает (false, nil) немедленно.
|
||||
// Медленный путь: запрос ставится в приоритетную очередь и блокируется
|
||||
// до получения слота или отмены контекста.
|
||||
//
|
||||
// Возвращаемое значение waited=true означает, что запрос ожидал в очереди.
|
||||
func (s *Scheduler) Acquire(ctx context.Context, priority int) (waited bool, err error) {
|
||||
s.mu.Lock()
|
||||
|
||||
if s.inFlight < s.maxSlots {
|
||||
s.inFlight++
|
||||
s.mu.Unlock()
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if s.waiting.Len() >= s.maxQueue {
|
||||
s.mu.Unlock()
|
||||
return false, ErrQueueFull
|
||||
}
|
||||
|
||||
item := &QueueItem{
|
||||
Priority: priority,
|
||||
EnqueuedAt: time.Now(),
|
||||
Ready: make(chan struct{}, 1),
|
||||
Ctx: ctx,
|
||||
}
|
||||
heap.Push(&s.waiting, item)
|
||||
s.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-item.Ready:
|
||||
return true, nil
|
||||
case <-ctx.Done():
|
||||
s.mu.Lock()
|
||||
if item.index >= 0 {
|
||||
heap.Remove(&s.waiting, item.index)
|
||||
s.mu.Unlock()
|
||||
return true, ErrRequestCancelled
|
||||
}
|
||||
// Элемент уже извлечён из очереди — слот передан этому запросу.
|
||||
s.mu.Unlock()
|
||||
// Канал Ready читается для предотвращения утечки горутины.
|
||||
select {
|
||||
case <-item.Ready:
|
||||
default:
|
||||
}
|
||||
return true, ErrRequestCancelled
|
||||
}
|
||||
}
|
||||
|
||||
// Release освобождает слот и передаёт его следующему ожидающему запросу.
|
||||
// Отменённые запросы (контекст закрыт) пропускаются.
|
||||
func (s *Scheduler) Release() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for s.waiting.Len() > 0 {
|
||||
next := heap.Pop(&s.waiting).(*QueueItem)
|
||||
if next.Ctx.Err() != nil {
|
||||
continue
|
||||
}
|
||||
next.Ready <- struct{}{}
|
||||
return
|
||||
}
|
||||
|
||||
s.inFlight--
|
||||
}
|
||||
|
||||
// Stats возвращает количество активных запросов и длину очереди.
|
||||
func (s *Scheduler) Stats() (inFlight, queued int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.inFlight, s.waiting.Len()
|
||||
}
|
||||
88
src/internal/ratelimit/limiter.go
Normal file
88
src/internal/ratelimit/limiter.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// Пакет ratelimit реализует ограничение частоты запросов
|
||||
// по алгоритму скользящего окна (sliding window) для каждого API-ключа.
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Limiter хранит историю запросов по ключам
|
||||
// и определяет, разрешён ли очередной запрос.
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
windows map[string][]time.Time
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// New создаёт Limiter и запускает фоновую горутину очистки устаревших записей.
|
||||
// Для корректного завершения необходимо вызвать Stop().
|
||||
func New() *Limiter {
|
||||
l := &Limiter{
|
||||
windows: make(map[string][]time.Time),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
go l.cleanup()
|
||||
return l
|
||||
}
|
||||
|
||||
// Allow проверяет, не превышен ли лимит maxReqs за период window для данного ключа.
|
||||
// Если запрос разрешён — регистрирует его и возвращает true.
|
||||
func (l *Limiter) Allow(key string, maxReqs int, window time.Duration) bool {
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-window)
|
||||
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
timestamps := l.windows[key]
|
||||
|
||||
// Удаление записей, выпавших за пределы окна.
|
||||
start := 0
|
||||
for start < len(timestamps) && timestamps[start].Before(cutoff) {
|
||||
start++
|
||||
}
|
||||
timestamps = timestamps[start:]
|
||||
|
||||
if len(timestamps) >= maxReqs {
|
||||
l.windows[key] = timestamps
|
||||
return false
|
||||
}
|
||||
|
||||
l.windows[key] = append(timestamps, now)
|
||||
return true
|
||||
}
|
||||
|
||||
// Stop останавливает фоновую горутину очистки.
|
||||
func (l *Limiter) Stop() {
|
||||
close(l.stopCh)
|
||||
}
|
||||
|
||||
// cleanup периодически удаляет устаревшие записи из всех окон.
|
||||
// Интервал очистки — 5 минут, записи старше 10 минут удаляются.
|
||||
func (l *Limiter) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
l.mu.Lock()
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
for key, timestamps := range l.windows {
|
||||
start := 0
|
||||
for start < len(timestamps) && timestamps[start].Before(cutoff) {
|
||||
start++
|
||||
}
|
||||
if start == len(timestamps) {
|
||||
delete(l.windows, key)
|
||||
} else {
|
||||
l.windows[key] = timestamps[start:]
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
case <-l.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
48
src/internal/ratelimit/middleware.go
Normal file
48
src/internal/ratelimit/middleware.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
|
||||
"backend/src/internal/auth"
|
||||
)
|
||||
|
||||
// Middleware ограничивает частоту запросов на основе RateLimitConfig роли пользователя.
|
||||
// Для неаутентифицированных запросов и ролей без заданного лимита — пропускает.
|
||||
// При превышении лимита возвращает 429 Too Many Requests с заголовком Retry-After.
|
||||
func Middleware(logger *slog.Logger, limiter *Limiter, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
rl := user.Role.RateLimit
|
||||
if rl.Requests <= 0 {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
window := rl.WindowDuration()
|
||||
if !limiter.Allow(user.APIKey, rl.Requests, window) {
|
||||
retryAfter := int(math.Ceil(window.Seconds()))
|
||||
logger.Warn("Превышен rate limit",
|
||||
"user", user.Name, "role", user.RoleKey,
|
||||
"limit", rl.Requests, "window", window.String(),
|
||||
)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", retryAfter))
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"error": "rate limit exceeded",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
156
src/main.go
156
src/main.go
@@ -1,20 +1,35 @@
|
||||
// Точка входа Ollama Proxy — обратного прокси-сервера для Ollama
|
||||
// с приоритетной очередью, аутентификацией, rate limiting
|
||||
// и контролем моделей/контекста.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"backend/src/internal/admin"
|
||||
"backend/src/internal/auth"
|
||||
"backend/src/internal/config"
|
||||
"backend/src/internal/logging"
|
||||
"backend/src/internal/queue"
|
||||
"backend/src/internal/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
AppName = "Ollama Proxy"
|
||||
AppVersion = "1.0.0"
|
||||
AppVersion = "2.0.0"
|
||||
|
||||
// Таймаут ожидания завершения активных запросов при остановке сервера.
|
||||
shutdownTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -28,9 +43,10 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
// Пересоздание логгера с уровнем из конфигурации.
|
||||
level := logging.ParseLevel(cfg.LoggingConfig.Level)
|
||||
if level != slog.LevelInfo {
|
||||
logger.Info("Уровень логирования из env", "level", level.String())
|
||||
logger.Info("Уровень логирования из конфигурации", "level", level.String())
|
||||
}
|
||||
logger = logging.New(cfg.LoggingConfig.Level)
|
||||
slog.SetDefault(logger)
|
||||
@@ -38,29 +54,120 @@ func main() {
|
||||
|
||||
target, err := url.Parse(cfg.BackendURL)
|
||||
if err != nil {
|
||||
logger.Error("Неверный URL бэкенда", "error", err)
|
||||
logger.Error("Некорректный URL бэкенда", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Загрузка конфигурации пользователей.
|
||||
// При отсутствии файла прокси работает без аутентификации.
|
||||
store, err := auth.NewJSONFileStore(cfg.UsersConfigPath, logger)
|
||||
if err != nil {
|
||||
logger.Warn("Конфигурация пользователей не загружена — аутентификация отключена", "error", err)
|
||||
store = nil
|
||||
}
|
||||
|
||||
scheduler := queue.NewScheduler(cfg.MaxParallel, cfg.MaxQueueSize)
|
||||
limiter := ratelimit.New()
|
||||
defer limiter.Stop()
|
||||
|
||||
proxy := &httputil.ReverseProxy{
|
||||
Rewrite: func(r *httputil.ProxyRequest) {
|
||||
r.SetURL(target)
|
||||
r.Out.Host = target.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
logger.Error("Ошибка прокси", "method", r.Method, "path", r.URL.Path, "error", err)
|
||||
logger.Error("Ошибка проксирования", "method", r.Method, "path", r.URL.Path, "error", err)
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
},
|
||||
}
|
||||
|
||||
handler := loggingMiddleware(logger, proxy)
|
||||
handler := buildMiddlewareChain(proxy, store, logger, scheduler, limiter)
|
||||
|
||||
logger.Info("Прокси запущен", "addr", cfg.ListenAddr, "backend", cfg.BackendURL)
|
||||
if err := http.ListenAndServe(cfg.ListenAddr, handler); err != nil {
|
||||
mux := http.NewServeMux()
|
||||
if store != nil && cfg.AdminAPIKey != "" {
|
||||
adminHandler := admin.NewHandler(logger, store, cfg.AdminAPIKey)
|
||||
mux.Handle("/admin/reload", adminHandler.ReloadHandler())
|
||||
}
|
||||
mux.Handle("/", handler)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
}
|
||||
|
||||
logger.Info("Прокси запущен",
|
||||
"addr", cfg.ListenAddr,
|
||||
"backend", cfg.BackendURL,
|
||||
"max_parallel", cfg.MaxParallel,
|
||||
"max_queue", cfg.MaxQueueSize,
|
||||
"auth_enabled", store != nil,
|
||||
)
|
||||
|
||||
// Запуск в отдельной горутине для поддержки graceful shutdown.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.ListenAndServe()
|
||||
}()
|
||||
|
||||
// Ожидание сигнала завершения (Ctrl+C, systemd stop).
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case sig := <-quit:
|
||||
logger.Info("Получен сигнал завершения", "signal", sig.String())
|
||||
case err := <-errCh:
|
||||
logger.Error("Ошибка сервера", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Graceful shutdown: завершение активных запросов с таймаутом.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Error("Ошибка при остановке сервера", "error", err)
|
||||
} else {
|
||||
logger.Info("Сервер остановлен")
|
||||
}
|
||||
}
|
||||
|
||||
// buildMiddlewareChain собирает цепочку middleware.
|
||||
// Порядок обработки запроса:
|
||||
//
|
||||
// logging → urlkey → auth → modelcheck → ratelimit → queue → proxy
|
||||
func buildMiddlewareChain(
|
||||
proxy http.Handler,
|
||||
store *auth.JSONFileStore,
|
||||
logger *slog.Logger,
|
||||
scheduler *queue.Scheduler,
|
||||
limiter *ratelimit.Limiter,
|
||||
) http.Handler {
|
||||
// Приоритет запроса определяется ролью аутентифицированного пользователя.
|
||||
// Без аутентификации все запросы имеют одинаковый приоритет.
|
||||
priorityFn := queue.PriorityFunc(func(r *http.Request) int {
|
||||
if user, ok := auth.UserFromContext(r.Context()); ok {
|
||||
return user.Role.Priority
|
||||
}
|
||||
return 1
|
||||
})
|
||||
|
||||
var handler http.Handler = proxy
|
||||
handler = queue.Middleware(logger, scheduler, priorityFn, handler)
|
||||
|
||||
if store != nil {
|
||||
handler = ratelimit.Middleware(logger, limiter, handler)
|
||||
handler = auth.ModelCheckMiddleware(logger, handler)
|
||||
handler = auth.AuthMiddleware(logger, store, handler)
|
||||
handler = auth.URLKeyMiddleware(handler)
|
||||
}
|
||||
|
||||
handler = loggingMiddleware(logger, handler)
|
||||
return handler
|
||||
}
|
||||
|
||||
// loggingMiddleware записывает метод, путь, статус и время обработки каждого запроса.
|
||||
// Ключ в URL вида /auth/<key>/... маскируется перед записью в лог.
|
||||
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
@@ -68,13 +175,31 @@ func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
|
||||
next.ServeHTTP(rw, r)
|
||||
logger.Info("запрос",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"path", maskAuthPath(r.URL.Path),
|
||||
"status", rw.status,
|
||||
"duration", time.Since(start).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// maskAuthPath маскирует API-ключ в путях вида /auth/<key>/...
|
||||
// Пример: /auth/petrov-key-5678/v1/chat → /auth/***/v1/chat
|
||||
func maskAuthPath(path string) string {
|
||||
const prefix = "/auth/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return path
|
||||
}
|
||||
rest := path[len(prefix):]
|
||||
slash := strings.IndexByte(rest, '/')
|
||||
if slash <= 0 {
|
||||
return prefix + "***"
|
||||
}
|
||||
return prefix + "***" + rest[slash:]
|
||||
}
|
||||
|
||||
// responseWriter оборачивает http.ResponseWriter для перехвата HTTP-статуса.
|
||||
// Реализует http.Flusher для поддержки streaming (SSE) и
|
||||
// Unwrap() для совместимости с http.ResponseController (Go 1.20+).
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
@@ -84,3 +209,18 @@ func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.status = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
// Flush передаёт буферизированные данные клиенту.
|
||||
// Необходим для корректной работы streaming-ответов от Ollama.
|
||||
func (rw *responseWriter) Flush() {
|
||||
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap возвращает оригинальный ResponseWriter.
|
||||
// Требуется для http.ResponseController (Go 1.20+),
|
||||
// который использует Unwrap для доступа к нижележащим интерфейсам.
|
||||
func (rw *responseWriter) Unwrap() http.ResponseWriter {
|
||||
return rw.ResponseWriter
|
||||
}
|
||||
|
||||
20
users.example.json
Normal file
20
users.example.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"roles": {
|
||||
"vip": {
|
||||
"priority": 100,
|
||||
"allowed_models": ["qwen3:32b", "qwen3:0.6b"],
|
||||
"max_context_length": 32768,
|
||||
"rate_limit": { "requests": 60, "window": "1m" }
|
||||
},
|
||||
"regular": {
|
||||
"priority": 10,
|
||||
"allowed_models": ["qwen3:0.6b"],
|
||||
"max_context_length": 8192,
|
||||
"rate_limit": { "requests": 20, "window": "1m" }
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"key-abc123": { "name": "Иванов", "role": "vip", "enabled": true },
|
||||
"key-xyz789": { "name": "Петров", "role": "regular", "enabled": true }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user