commit a4088214106231eb9b79592326cb0cf8aa6c609e Author: Beyhan Ogur Date: Mon May 11 15:08:50 2026 +0300 first commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..ed479c5 --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..76594a7 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,8 @@ +Herhangi bir kod değişikliği yaptığında veya yeni bir özellik eklediğinde (endpoint, model, servis): + +0. TÜM yapılandırma değerlerini (host, port, API URL, db bilgileri vb.) `.env` dosyasından oku. Kesinlikle varsayılan `localhost:8080` veya sabit IP adresi kullanma. `.env` de ne yazıyorsa onu kullan. Veritabanı motoru `DB_MODE` ile seçilir: `DB_MODE=sqlite` (dosya tabanlı, `DB_PATH` ile yol belirtilir, geliştirme/test için) veya `DB_MODE=pgs` (PostgreSQL, `POSTGRES_DSN`/`DATABASE_DSN` ile bağlanır, production). `DB_MODE` tanımlı değilse PostgreSQL varsayılandır. +1. MUTLAKA `/docs/wiki/` klasöründeki Obsidian markdown belgelerini (`wiki_schema.md` kurallarına göre) güncelle. +2. İşlemi bitirmeden önce `swag init` komutunu çalıştırarak Swagger dokümantasyonunu yenile. +3. `/docs/wiki/Index.md` dosyasının yeni mimariyi (yeni tablo/kavram/endpoint) yansıttığından emin ol. +4. Swager i mutlaka çalıstır +Bu adımlar bir görevi "tamamlanmış" saymak için ZORUNLUDUR. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aaa9162 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +PORT=8000 +OPENAI_BACKEND=https://api.deepseek.com +OPENAI_KEY=your_openai_key_here +DATABASE_DSN=host=localhost user=app password=pass dbname=app port=5432 sslmode=disable TimeZone=UTC +REQUEST_TIMEOUT_SECONDS=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..74b4da6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Binaries +gateway +main +tmp/ + +# Env +.env + +# OS +.DS_Store +Thumbs.db +tmp/build-errors.log +tmp/main +tmp/build-errors.log diff --git a/PROJE_PROMOSIYON_PROMPT.md b/PROJE_PROMOSIYON_PROMPT.md new file mode 100644 index 0000000..3f02f91 --- /dev/null +++ b/PROJE_PROMOSIYON_PROMPT.md @@ -0,0 +1,228 @@ +# LLM Gateway / Proxy Projesi - Geliştirici İş Emri (Türkçe) + +Bu doküman, bir geliştiriciye veya yapay zeka asistanına verilip projeyi tamamen uygulattıracak detaylı iş emridir. Hedef: Go + Fiber v3 tabanlı bir servis geliştirmek; OpenAI-compat ve Anthropic-compat proxy/gateway, Bifrost mapping, PostgreSQL (GORM) entegrasyonu, Swagger dokümanları ve Docker destekli dağıtım. + +--- + +## Özet Hedefler +- OpenAI-uyumlu istekleri alın, hedef backend: `https://api.deepseek.com`. +- Anthropic-uyumlu istekleri alın, hedef backend: `https://api.deepseek.com/anthropic` veya (konfigürasyona bağlı olarak) Bifrost (`http://10.80.80.70:8080/v1`). +- Lokalde Anthropic endpoint'i: `http://localhost:8000/anthropic` (Bifrost mapping). +- Kullanılacak kütüphaneler: + - `github.com/gofiber/fiber/v3` + - `gorm.io/gorm` + - `gorm.io/driver/postgres` + +--- + +## Çalışma Ortamı / Gereksinimler +- Go modül yapısı (go1.20+ önerilir). +- PostgreSQL veritabanı (docker-compose ile sağlanabilir). +- Docker & docker-compose (opsiyonel ama önerilir). +- İstenen `go get` komutları (README'de gösterilecek): + - `go mod download` (tüm bağımlılıklar go.mod'da tanımlı) + - `go get -u gorm.io/gorm` + - `go get -u gorm.io/driver/postgres` + +--- + +## Konfigürasyon (Environment Variables) +- `PORT` (default: `8000`) +- `OPENAI_BACKEND` (default: `https://api.deepseek.com`) ##Örnek +- `ANTHROPIC_BACKEND` (default: `https://api.deepseek.com/anthropic`)##Örnek +- `USE_BIFROST_FOR_ANTHROPIC` (`true`/`false`) — `true` ise Anthropic çağrıları `BIFROST_URL`'e yönlendirilir +- `BIFROST_URL` (örnek: `http://10.80.80.70:8080/v1`)##Örnek +- `POSTGRES_DSN` (örnek: `host=localhost user=app password=pass dbname=app port=5432 sslmode=disable TimeZone=UTC`)##Örnek +- `REQUEST_TIMEOUT_SECONDS` (ör: `30`)##Örnek + +Örnek `.env.example`: +```env +PORT=8000 +OPENAI_BACKEND=https://api.deepseek.com +ANTHROPIC_BACKEND=https://api.deepseek.com/anthropic +BIFROST_URL=http://10.80.80.70:8080/v1 +USE_BIFROST_FOR_ANTHROPIC=true +POSTGRES_DSN=host=postgres user=app password=pass dbname=app port=5432 sslmode=disable TimeZone=UTC +REQUEST_TIMEOUT_SECONDS=30 +``` + +--- + +## API Endpoint Gereksinimleri + +### OpenAI-uyumlu +- `POST /v1/chat/completions` -> forward to `${OPENAI_BACKEND}/v1/chat/completions` +- `POST /v1/completions` -> forward to `${OPENAI_BACKEND}/v1/completions` +- Tüm `/v1/*` OpenAI endpoint'leri generic olarak hedef backend'e yönlendirilmeli (method korunacak). + +Kurallar: +- Authorization header (Bearer ...) ve diğer önemli header'lar hedefe iletilecek. +- Body genelde olduğu gibi POST edilecek. +- Hedeften gelen status code ve body olduğu gibi client'a iletilecek. +- Hedefe ulaşılamazsa 502 döndürülecek. + +### Anthropic-uyumlu +- `POST /anthropic/*` (ör. `/anthropic/complete`) -> eğer `USE_BIFROST_FOR_ANTHROPIC=false` ise `${ANTHROPIC_BACKEND}`'e, `true` ise `${BIFROST_URL}`'e yönlendir. +- Eğer Bifrost formatı ile Anthropic formatı farklıysa, minimal bir dönüşüm fonksiyonu yazılmalı (request/response mapping). + +Hata durumları: +- Hedefe ulaşılamaz veya hata alınırsa uygun HTTP kodu döndürülecek (502/504/500 gibi). + +### Sağlık ve Dokümantasyon +- `GET /health` -> servis, DB ve opsiyonel hedef backendlere temel sağlık bilgisi dönecek. +- Swagger UI: `/swagger/*` (Fiber v3 native HTML + CDN Swagger UI ile sunulacak). + +--- + +## İstek / Yanıt İşleme +- Timeout yönetimi (env'den): `REQUEST_TIMEOUT_SECONDS`. +- Retries (opsiyonel): tercih halinde `github.com/hashicorp/go-retryablehttp` veya benzeri kullanılabilir. +- İsteklerin DB'ye loglanması (Postgres + GORM) — minimal kayıt: + - timestamp, endpoint, method, client IP, model (varsa), tokens estimate (opsiyonel), response status, latency +- Büyük request body'leri truncation ile kaydedilecek (ör. 2000 karakter). +- Sensitive veriler (Authorization vb.) maskelenmeli, düz kaydedilmemeli. + +--- + +## Veri Modeli (Minimal) +- `RequestLog`: + - ID (UUID / auto increment) + - CreatedAt + - Endpoint (string) + - Method (string) + - ClientIP (string) + - RequestBody (text, truncate) + - ResponseStatus (int) + - LatencyMs (int) +- GORM AutoMigrate ile migration sağlanacak. + +--- + +## Proje Yapısı (Öneri) +- `main.go` (Fiber app, config yükleme, route register) +- `handlers/openai.go` +- `handlers/anthropic.go` +- `internal/proxy/proxy.go` (generic forwarder) +- `internal/transform/anthropic_bifrost.go` (dönüşüm fonksiyonları) +- `models/request_log.go` +- `config/config.go` (env yükleme) +- `docker/Dockerfile` +- `docker/docker-compose.yml` +- `README.md` +- `tests/*` (unit test örnekleri) +- `go.mod`, `go.sum` + +--- + +## Swagger +- Fiber v3 native HTML handler + CDN Swagger UI ile `/swagger/*` altında UI sunulacak. +- En az OpenAI-compatible `chat/completions` ve Anthropic endpoint'leri için örnek request/response gösterilecek. + +--- + +## Docker & docker-compose +- Multi-stage `Dockerfile` (build + runtime). +- `docker-compose.yml`: + - `app` servisi (build) + - `postgres` servisi (resmi image, örnek env) +- `docker-compose` ile test sağlanabilecek (izole ortam). + +--- + +## Testler +- Unit test: OpenAI proxy handler için en az iki test: + - başarılı forward (mock backend ile) + - hedef hata verdiğinde uygun hata dönüşü +- Integration test (opsiyonel): docker-compose altında mocked backend'e karşı örnek. + +--- + +## Komutlar / Kurulum (README'de belirtilecek) +- `go mod init github.com//` +- `go mod download` (fiber v3 ve diğer bağımlılıklar go.mod'da) +- `go get -u gorm.io/gorm` +- `go get -u gorm.io/driver/postgres` +- `go build ./...` +- `docker build -t llm-gateway:latest .` +- `docker-compose up -d` + +--- + +## Örnek curl Çağrıları + +OpenAI compatible: +```bash +curl -X POST "http://localhost:8000/v1/chat/completions" \ + -H "Authorization: Bearer $OPENAI_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4","messages":[{"role":"user","content":"Hello"}]}' +``` + +Anthropic (lokal mapped to Bifrost veya deepseek): +```bash +curl -X POST "http://localhost:8000/anthropic/complete" \ + -H "Authorization: Bearer $ANTHROPIC_KEY" \ + -H "Content-Type: application/json" \ + -d '{"prompt":"Say hello", "max_tokens": 64}' +``` + +--- + +## Teslim Edilecekler (Repository içinde) +- `main.go` +- `handlers/openai.go` +- `handlers/anthropic.go` +- `internal/proxy/proxy.go` +- `internal/transform/anthropic_bifrost.go` +- `models/request_log.go` +- `config/config.go` +- `docker/Dockerfile` +- `docker/docker-compose.yml` +- `README.md` (kurulum, env örnekleri, docker-compose, curl örnekleri) +- `tests/*` (unit testler) +- `go.mod`, `go.sum` + +--- + +## Kabul Kriterleri (Acceptance Criteria) +- `/v1/*` istekleri `OPENAI_BACKEND`'e başarılı yönleniyor. +- `/anthropic/*` istekleri `USE_BIFROST_FOR_ANTHROPIC` konfigürasyonuna göre `BIFROST_URL` veya `ANTHROPIC_BACKEND`'e gidiyor. +- İstekler DB'ye loglanıyor (Postgres çalışırken). +- Swagger UI erişilebilir (`/swagger/*`). +- README yönergelerine göre proje çalıştırılabiliyor. +- En az 1 birim testi geçiyor (`go test ./...`). + +--- + +## Teknik Notlar / İpuçları +- HTTP client: `net/http` + `context` (timeout) veya tercih edilirse fiber'ın client'ı. +- Retries: isteğe bağlı; `go-retryablehttp` önerilir. +- Büyük body'leri DB'ye kaydederken truncate et (ör. 2000 char). +- Sensitive alanlar maskelenmeli. +- Anthropic <-> Bifrost dönüşümleri karmaşıksa, minimal mapping uygula ve README'de açıkla. +- Environment swap ile Deepseek <-> Bifrost kolayca değiştirilebilmeli. + +--- + +## Geliştirme Adımları (Öneri Sırayla) +1. Repo skeleton oluştur (go mod init, klasör yapısı). +2. `config/config.go`: env yükleme ve default ayarlar. +3. `main.go`: Fiber app init, DB bağlantısı (GORM), AutoMigrate, route register. +4. `internal/proxy/proxy.go`: generic forwarder implementasyonu (timeout, header forwarding). +5. `handlers/openai.go` & `handlers/anthropic.go`: handler'ları yaz, DB logging çağrılarını ekle. +6. Basit Swagger dokümantasyonu ekle. +7. Dockerfile & docker-compose ekle. +8. Unit testleri yaz ve çalıştır. +9. README güncelle, örnek env ve curl'leri ekle. + +--- + +## İletişim / Teslim +- Repo URL veya zip ile teslim. +- README ile çalıştırma adımları. +- Örnek curl komutları ve `.env.example`. +- (Opsiyonel) Dönüşümlere dair kısa açıklama ve input/output örnekleri. + +--- + +Eğer bu prompt'u doğrudan bir geliştiriciye/assistant'e vereceksen bu dosyayı kullan. İstersen ben bu prompt'u kullanarak ilk adımda repository skeleton'ını ve `main.go` + temel proxy handler'ı oluşturmaya başlayabilirim — başlamak istersen sadece "başla" de. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dfe1ec3 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +# optoant — LLM Gateway + +Go + Fiber v3 tabanlı OpenAI-uyumlu ve Anthropic-uyumlu LLM proxy/gateway. DeepSeek gibi tek upstream backend üzerinden hem OpenAI (`/v1/*`) hem Anthropic (`/anthropic/*`) API sunar. Anthropic↔OpenAI dönüşümü built-in. PostgreSQL request loglama, Swagger UI ve Docker desteği ile birlikte gelir. + +## Özellikler + +- **OpenAI-uyumlu proxy**: `/v1/*` → `OPENAI_BACKEND` (direct passthrough) +- **Anthropic-uyumlu proxy**: `/anthropic/*` → Anthropic↔OpenAI dönüşümü → `OPENAI_BACKEND` +- **Built-in dönüşüm**: Anthropic Messages ↔ OpenAI Chat Completions format çevrimi +- **PostgreSQL loglama**: Her isteği GORM ile otomatik loglar +- **Swagger UI**: `/swagger/*` adresinde erişilebilir +- **Docker**: Multi-stage build + docker-compose +- **Konfigürasyon**: Sadece 4 env değişkeni + +--- + +## Hızlı Başlangıç + +### 1. Bağımlılıkları Kur + +```bash +go mod download +``` + +### 2. Ortam Değişkenlerini Ayarla + +```bash +cp .env.example .env +# .env dosyasını düzenle +``` + +Minimum `.env`: +```env +PORT=8000 +OPENAI_BACKEND=https://api.deepseek.com +DATABASE_DSN=postgres://user:pass@localhost:5432/optoant?sslmode=disable +REQUEST_TIMEOUT_SECONDS=30 +``` + +### 3. Çalıştır + +```bash +go run main.go +``` + +veya derleyip çalıştır: + +```bash +go build -o gateway ./main.go +./gateway +``` + +--- + +## Docker ile Çalıştırma + +```bash +# Sadece uygulamayı derle +docker build -t optoant-gateway:latest -f docker/Dockerfile . + +# Postgres dahil tüm stack'i ayağa kaldır +cd docker +docker-compose up -d +``` + +--- + +## Konfigürasyon Tablosu + +| Değişken | Varsayılan | Açıklama | +|----------|-----------|----------| +| `PORT` | `8000` | Dinleme portu | +| `OPENAI_BACKEND` | `https://api.deepseek.com` | Upstream backend (OpenAI ve Anthropic için ortak) | +| `DATABASE_DSN` veya `POSTGRES_DSN` | — | PostgreSQL bağlantı string'i | +| `REQUEST_TIMEOUT_SECONDS` | `30` | Upstream timeout (saniye) | + +--- + +## Endpoint'ler + +| Endpoint | Method | Açıklama | +|----------|--------|----------| +| `/v1/*` | ANY | OpenAI-uyumlu proxy | +| `/anthropic/*` | ANY | Anthropic-uyumlu proxy (Anthropic↔OpenAI dönüşümlü) | +| `/health` | GET | Sağlık kontrolü | +| `/swagger/*` | GET | Swagger UI | + +--- + +## Örnek curl Çağrıları + +### OpenAI-uyumlu (DeepSeek / Bifrost OpenAI) + +```bash +curl -X POST "http://localhost:8000/v1/chat/completions" \ + -H "Authorization: Bearer $OPENAI_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "gpt-4", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +### Anthropic (Anthropic↔OpenAI dönüşümlü) + +```bash +curl -X POST "http://localhost:8000/anthropic/v1/messages" \ + -H "Authorization: Bearer $ANTHROPIC_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-3-5-sonnet-20241022", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +### Sağlık kontrolü + +```bash +curl http://localhost:8000/health +``` + +--- + +## Testler + +```bash +go test ./tests/... +``` + +--- + +## Proje Yapısı + +``` +opantoantro/ +├── main.go # Fiber app, DB, route kayıt +├── config/config.go # Env yükleme +├── handlers/ +│ ├── openai.go # /v1/* handler +│ ├── anthropic.go # /anthropic/* handler +│ └── health.go # /health handler +├── internal/ +│ ├── proxy/proxy.go # Generic HTTP forwarder +│ └── transform/anthropic_bifrost.go # Format dönüşümleri +├── models/request_log.go # GORM modeli +├── docs/ +│ ├── swagger.json # Swagger spec +│ └── wiki/ # Obsidian knowledge graph +│ ├── Index.md +│ ├── Config.md +│ ├── Proxy.md +│ ├── Handlers.md +│ └── Models_Transform.md +├── tests/openai_test.go # Unit testler +├── docker/ +│ ├── Dockerfile # Multi-stage build +│ └── docker-compose.yml # Servis tanımı +├── .env # Aktif env (git'e ekleme!) +└── .env.example # Örnek env +``` + +--- + +## Anthropic ↔ OpenAI Dönüşümü + +Anthropic `/anthropic/*` istekleri otomatik olarak OpenAI formatına çevrilip `OPENAI_BACKEND`'e gönderilir, yanıt tekrar Anthropic formatına dönüştürülür: + +**Request dönüşümü (Anthropic → OpenAI):** +```json +// Anthropic giriş +{ "model": "claude-3", "max_tokens": 100, "system": "You are helpful.", "messages": [{"role": "user", "content": "Hi"}] } + +// Bifrost çıkış +{ "model": "claude-3", "max_tokens": 100, "messages": [{"role": "system", "content": "You are helpful."}, {"role": "user", "content": "Hi"}] } +``` + +**Response dönüşümü (OpenAI → Anthropic):** +```json +// Bifrost giriş +{ "choices": [{"message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}] } + +// Anthropic çıkış +{ "type": "message", "role": "assistant", "content": [{"type": "text", "text": "Hello!"}], "stop_reason": "stop" } +``` + +--- + +## Lisans + +MIT diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..157a0bb --- /dev/null +++ b/build.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +BINARY="${BINARY:-gateway}" +ENTRYPOINT="${ENTRYPOINT:-./main.go}" +LDFLAGS="${LDFLAGS:--w -s}" +GOOS="${GOOS:-linux}" + +echo "=> Building ${BINARY} (CGO_ENABLED=0 GOOS=${GOOS})" + +CGO_ENABLED=0 GOOS="${GOOS}" go build \ + -ldflags="${LDFLAGS}" \ + -o "${BINARY}" \ + "${ENTRYPOINT}" + +echo "=> Done: ${BINARY}" diff --git a/cc-remote.sh b/cc-remote.sh new file mode 100755 index 0000000..b5d59c6 --- /dev/null +++ b/cc-remote.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ────────────────────────────────────────────── +# optoant Remote Gateway → Claude Code Launcher +# ────────────────────────────────────────────── +# Tüm yapılandırma .env dosyasından okunur. +# GATEWAY_URL tanımlandıysa parametresiz çalışır. +# +# .env'de olması gerekenler: +# GATEWAY_URL=http://sunucu-ip:8000 (zorunlu) +# OPENAI_KEY=... (API anahtarı) +# +# Opsiyonel override: +# ./cc-remote.sh http://10.0.0.5:8000 (URL override) +# ./cc-remote.sh --print (sadece env'leri göster) +# ────────────────────────────────────────────── + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + +# ── .env'yi yükle (API key için) ── +if [[ -f "$SCRIPT_DIR/.env" ]]; then + set -a + source "$SCRIPT_DIR/.env" + set +a +fi + +# ── Gateway URL: ilk argüman (-- ile başlamıyorsa) ya da GATEWAY_URL env ── +GATEWAY_URL="${GATEWAY_URL:-}" +if [[ "${1:-}" != "" && "$1" != -* ]]; then + GATEWAY_URL="$1" + shift +fi +if [[ -z "$GATEWAY_URL" ]]; then + echo "❌ GATEWAY_URL tanımlı değil." + echo " .env dosyasına ekleyin: GATEWAY_URL=http://sunucu-ip:8000" + echo " Veya parametreyle: ./cc-remote.sh http://sunucu-ip:8000" + exit 1 +fi + +ANTHROPIC_ENDPOINT="${ANTHROPIC_BASE_URL:-${GATEWAY_URL}/anthropic}" +ANTHROPIC_KEY="${ANTHROPIC_API_KEY:-${OPENAI_KEY:-}}" + +# ── Gateway health check ── +echo "🔍 Gateway kontrol ediliyor: ${GATEWAY_URL}/health" +if curl -sf "${GATEWAY_URL}/health" > /dev/null 2>&1; then + echo "✅ Gateway erişilebilir" +else + echo "⚠️ Gateway'e ulaşılamadı, yine de bağlanmayı deneyeceğim" +fi + +# ── Print mode ── +case "${1:-}" in + --print|-p) + echo "" + echo "┌─ optoant Remote → Claude Code Environment" + echo "│ ANTHROPIC_BASE_URL=${ANTHROPIC_ENDPOINT}" + echo "│ ANTHROPIC_API_KEY=${ANTHROPIC_KEY:0:20}... (masked)" + echo "│ Gateway: ${GATEWAY_URL}/health" + echo "└─" + exit 0 + ;; +esac + +# ── Claude Code'u başlat ── +echo "┌─ Claude Code başlatılıyor (remote gateway)..." +echo "│ Gateway: ${ANTHROPIC_ENDPOINT}" +echo "└─" +echo "" + +ANTHROPIC_BASE_URL="${ANTHROPIC_ENDPOINT}" \ +ANTHROPIC_API_KEY="${ANTHROPIC_KEY}" \ +claude "$@" diff --git a/cc.sh b/cc.sh new file mode 100755 index 0000000..b2783b9 --- /dev/null +++ b/cc.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Çağrıldığı dizini kaydet — Claude Code burada açılacak +ORIG_DIR="$PWD" +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + +# ────────────────────────────────────────────── +# optoant LLM Gateway → Claude Code Launcher +# ────────────────────────────────────────────── +# Usage: ./cc.sh [claude-args...] +# ./cc.sh # Claude'u gateway'e bağlı başlatır +# ./cc.sh --print # Sadece env'leri göster, Claude'u başlatma +# ./cc.sh --restart # Gateway'i yeniden başlat (Air kill + up) +# ./cc.sh --logs # Gateway loglarını canlı izle (tail -f) +# ANTHROPIC_BASE_URL="http://localhost:8000/anthropic" ./cc.sh +# ────────────────────────────────────────────── + +# Gateway ve .env için script'in kendi dizinine geç +cd "$SCRIPT_DIR" + +GATEWAY_PORT="${PORT:-8000}" +GATEWAY_URL="http://localhost:${GATEWAY_PORT}" +ANTHROPIC_ENDPOINT="${ANTHROPIC_BASE_URL:-${GATEWAY_URL}/anthropic}" + +# ── .env'yi yükle (varsa) ── +if [[ -f .env ]]; then + set -a + source .env + set +a +fi + +# ── Air ile gateway çalışıyor mu? ── +check_gateway() { + if curl -sf "${GATEWAY_URL}/health" > /dev/null 2>&1; then + return 0 + fi + return 1 +} + +# ── Gateway'i Air ile başlat ── +start_gateway() { + echo "🚀 Gateway başlatılıyor (air)..." + air > /tmp/optoant-air.log 2>&1 & + AIR_PID=$! + echo " Air PID: $AIR_PID | Log: /tmp/optoant-air.log" + echo -n " Health check bekleniyor" + for i in $(seq 1 15); do + if check_gateway; then + echo " OK" + echo " 💡 Logları canlı izlemek için: tail -f /tmp/optoant-air.log" + return 0 + fi + echo -n "." + sleep 1 + done + echo " TIMEOUT" + echo "❌ Gateway başlatılamadı. Son log satırları:" + tail -20 /tmp/optoant-air.log + return 1 +} + +# ── Sadece env'leri göster ── +print_env() { + echo "┌─ optoant → Claude Code Environment" + echo "│ ANTHROPIC_BASE_URL=${ANTHROPIC_ENDPOINT}" + echo "│ ANTHROPIC_API_KEY=${OPENAI_KEY:0:20}... (masked)" + echo "│ OPENAI_BACKEND=${OPENAI_BACKEND:-http://10.80.80.70:8080}" + echo "│ OPENAI_MODEL=${OPENAI_MODEL:-deepseek/deepseek-v4-pro}" + echo "│ Gateway: ${GATEWAY_URL}/health" + echo "└─" +} + +# ── Ana mantık ── + +case "${1:-}" in + --print|-p) + print_env + exit 0 + ;; + --restart|-r) + echo "♻️ Gateway yeniden başlatılıyor..." + pkill -f "air" 2>/dev/null || true + pkill -f "tmp/main" 2>/dev/null || true + sleep 2 + start_gateway + print_env + echo "✅ Gateway hazır. ./cc.sh ile Claude'u başlat." + exit 0 + ;; + --logs|-l) + echo "📋 Gateway logları (tail -f /tmp/optoant-air.log):" + echo "──────────────────────────────────────────────" + tail -f /tmp/optoant-air.log + exit 0 + ;; + --help|-h) + sed -n '2,10p' "$0" + exit 0 + ;; +esac + +# ── Gateway kontrol et, yoksa başlat ── +if ! check_gateway; then + echo "⚠️ Gateway çalışmıyor." + start_gateway +fi + +print_env + +# ── Claude Code'u başlat ── +echo "┌─ Claude Code başlatılıyor..." +echo "│ Not: Streaming desteklenmiyor. ~/.claude/settings.json'a" +echo '│ "stream": false eklendi.' +echo "│ Çıkmak için: exit veya Ctrl+C" +echo "└─" +echo "" + +if [[ $EUID -eq 0 ]]; then + # ── Root: non-root kullanıcı oluştur, projeyi kopyala, su ile başlat ── + CLAUD_USER="optoant-claude" + if ! id "$CLAUD_USER" &>/dev/null; then + echo "👤 Non-root kullanıcı oluşturuluyor: $CLAUD_USER" + useradd -m -s /bin/bash "$CLAUD_USER" + if [[ -d /root/.claude ]]; then + cp -r /root/.claude "/home/${CLAUD_USER}/" + chown -R "${CLAUD_USER}:${CLAUD_USER}" "/home/${CLAUD_USER}/.claude" + fi + fi + echo "👤 Claude Code '$CLAUD_USER' kullanıcısı ile başlatılıyor..." + + CLAUD_HOME="/home/${CLAUD_USER}" + CLAUD_PROJECT="${CLAUD_HOME}/opantoantro" + if [[ ! -d "${CLAUD_PROJECT}" ]]; then + echo "📁 Proje kopyalanıyor: ${CLAUD_PROJECT}" + cp -r /root/opantoantro "${CLAUD_PROJECT}" + chown -R "${CLAUD_USER}:${CLAUD_USER}" "${CLAUD_PROJECT}" + fi + + CLAUD_BIN="/usr/local/bin/claude" + if [[ ! -x "$CLAUD_BIN" ]]; then + SRC=$(find /root/.nvm -name "claude" -o -name "claude.exe" -type f 2>/dev/null | head -1) + if [[ -z "$SRC" ]]; then + echo "❌ claude binary bulunamadı. Önce: npm install -g @anthropic-ai/claude-code" + exit 1 + fi + cp "$SRC" "$CLAUD_BIN" + chmod 755 "$CLAUD_BIN" + echo "📦 Claude Code kopyalandı: ${CLAUD_BIN}" + fi + + CLAUD_RUNNER="/tmp/cc-run-${CLAUD_USER}.sh" + cat > "$CLAUD_RUNNER" <<- WRAPPER +#!/usr/bin/env bash +cd "${ORIG_DIR}" 2>/dev/null || cd "${CLAUD_PROJECT}" +export ANTHROPIC_BASE_URL="${ANTHROPIC_ENDPOINT}" +export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-${OPENAI_KEY:-}}" +exec "${CLAUD_BIN}" "\$@" +WRAPPER + chmod +x "$CLAUD_RUNNER" + + SAFE_ARGS="" + for arg in "$@"; do + SAFE_ARGS="${SAFE_ARGS} $(printf '%q' "$arg")" + done + + su - "${CLAUD_USER}" -c "${CLAUD_RUNNER}${SAFE_ARGS}" + rm -f "$CLAUD_RUNNER" +else + # ── Non-root: doğrudan başlat ── + cd "$ORIG_DIR" + ANTHROPIC_BASE_URL="${ANTHROPIC_ENDPOINT}" \ + ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-${OPENAI_KEY:-}}" \ + claude "$@" +fi diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..96b1855 --- /dev/null +++ b/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "os" + "strconv" +) + +// Config holds all runtime configuration loaded from environment variables. +type Config struct { + Port string + OpenAIBackend string + OpenAIApiKey string + PostgresDSN string + DBPath string // SQLite database file path (when DB_MODE=sqlite) + DBMode string // "sqlite" or "pgs" — selects database driver + LogLevel string // "debug", "info", or "warn" — console log verbosity + RequestTimeoutSeconds int + OpenAIModel string // default model when client omits it +} + +// Load reads environment variables and returns a populated Config. +// All values MUST come from env — no hardcoded IPs or ports. +func Load() *Config { + timeoutSec := 30 + if v := os.Getenv("REQUEST_TIMEOUT_SECONDS"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + timeoutSec = parsed + } + } + + dbMode := os.Getenv("DB_MODE") + dbPath := os.Getenv("DB_PATH") + logLevel := os.Getenv("LOG_LEVEL") + + // Support both POSTGRES_DSN and DATABASE_DSN env keys + postgresDSN := os.Getenv("POSTGRES_DSN") + if postgresDSN == "" { + postgresDSN = os.Getenv("DATABASE_DSN") + } + + port := os.Getenv("PORT") + if port == "" { + port = "8000" + } + + openAIBackend := os.Getenv("OPENAI_BACKEND") + if openAIBackend == "" { + openAIBackend = "https://api.deepseek.com" + } + + openAIApiKey := os.Getenv("OPENAI_KEY") + openAIModel := os.Getenv("OPENAI_MODEL") + + return &Config{ + Port: port, + OpenAIBackend: openAIBackend, + OpenAIApiKey: openAIApiKey, + PostgresDSN: postgresDSN, + DBPath: dbPath, + DBMode: dbMode, + LogLevel: logLevel, + RequestTimeoutSeconds: timeoutSec, + OpenAIModel: openAIModel, + } +} diff --git a/data/app.db b/data/app.db new file mode 100644 index 0000000..04e0db1 Binary files /dev/null and b/data/app.db differ diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..8df8b7d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Copy dependency files first for layer caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Build binary +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /gateway ./main.go + +# ───────────────────────────────────────────── +# Runtime stage +FROM alpine:3.21 + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /app + +# Copy binary and swagger docs +COPY --from=builder /gateway . +COPY --from=builder /app/docs ./docs + +# Non-root user for security +RUN addgroup -S gateway && adduser -S gateway -G gateway +USER gateway + +EXPOSE 8000 + +ENTRYPOINT ["/app/gateway"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..c966133 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,38 @@ +version: "3.9" + +services: + app: + build: + context: .. + dockerfile: docker/Dockerfile + ports: + - "${PORT:-8000}:8000" + env_file: + - ../.env + environment: + PORT: "8000" + OPENAI_BACKEND: "${OPENAI_BACKEND:-https://api.deepseek.com}" + DATABASE_DSN: "host=postgres user=app password=pass dbname=app port=5432 sslmode=disable TimeZone=UTC" + REQUEST_TIMEOUT_SECONDS: "${REQUEST_TIMEOUT_SECONDS:-30}" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: app + POSTGRES_PASSWORD: pass + POSTGRES_DB: app + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + pgdata: diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..d290c3b --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,189 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "optoant", + "email": "admin@optoant.local" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/anthropic/{path}": { + "post": { + "description": "Converts Anthropic Messages API requests to OpenAI format, forwards to OPENAI_BACKEND, and converts the response back to Anthropic format.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "anthropic" + ], + "summary": "Anthropic-compatible proxy", + "parameters": [ + { + "type": "string", + "description": "Anthropic API path (e.g. v1/messages)", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns service health status, database connectivity, and active configuration.", + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.HealthResponse" + } + } + } + } + }, + "/v1/{path}": { + "post": { + "description": "Forwards any /v1/* request to the configured OpenAI-compatible backend (e.g. DeepSeek).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openai" + ], + "summary": "OpenAI-compatible proxy", + "parameters": [ + { + "type": "string", + "description": "OpenAI API path (e.g. chat/completions)", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "handlers.HealthResponse": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "openai_backend": { + "type": "string" + }, + "port": { + "type": "string" + } + } + }, + "database": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8000", + BasePath: "/", + Schemes: []string{}, + Title: "LLM Gateway API", + Description: "OpenAI-compatible and Anthropic-compatible LLM proxy/gateway with Bifrost mapping.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..24b63d7 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,165 @@ +{ + "swagger": "2.0", + "info": { + "description": "OpenAI-compatible and Anthropic-compatible LLM proxy/gateway with Bifrost mapping.", + "title": "LLM Gateway API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "optoant", + "email": "admin@optoant.local" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "1.0" + }, + "host": "localhost:8000", + "basePath": "/", + "paths": { + "/anthropic/{path}": { + "post": { + "description": "Converts Anthropic Messages API requests to OpenAI format, forwards to OPENAI_BACKEND, and converts the response back to Anthropic format.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "anthropic" + ], + "summary": "Anthropic-compatible proxy", + "parameters": [ + { + "type": "string", + "description": "Anthropic API path (e.g. v1/messages)", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/health": { + "get": { + "description": "Returns service health status, database connectivity, and active configuration.", + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handlers.HealthResponse" + } + } + } + } + }, + "/v1/{path}": { + "post": { + "description": "Forwards any /v1/* request to the configured OpenAI-compatible backend (e.g. DeepSeek).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "openai" + ], + "summary": "OpenAI-compatible proxy", + "parameters": [ + { + "type": "string", + "description": "OpenAI API path (e.g. chat/completions)", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "502": { + "description": "Bad Gateway", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "handlers.HealthResponse": { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "openai_backend": { + "type": "string" + }, + "port": { + "type": "string" + } + } + }, + "database": { + "type": "string" + }, + "status": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.json.bak b/docs/swagger.json.bak new file mode 100644 index 0000000..a0184b0 --- /dev/null +++ b/docs/swagger.json.bak @@ -0,0 +1,204 @@ +{ + "swagger": "2.0", + "info": { + "description": "OpenAI-compatible and Anthropic-compatible LLM proxy/gateway with Bifrost mapping. All configuration is loaded from environment variables.", + "title": "LLM Gateway API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "optoant", + "email": "admin@optoant.local" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "1.0" + }, + "host": "localhost:8000", + "basePath": "/", + "paths": { + "/health": { + "get": { + "description": "Returns service health status, database connectivity, and active configuration.", + "produces": ["application/json"], + "tags": ["health"], + "summary": "Health check", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/HealthResponse" + } + } + } + } + }, + "/v1/{path}": { + "post": { + "security": [{"BearerAuth": []}], + "description": "Forwards any /v1/* request to the configured OpenAI-compatible backend (e.g. DeepSeek). The Authorization header and all standard headers are passed through.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["openai"], + "summary": "OpenAI-compatible proxy", + "parameters": [ + { + "type": "string", + "description": "OpenAI API path (e.g. chat/completions)", + "name": "path", + "in": "path", + "required": true + }, + { + "description": "OpenAI chat completions request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/OpenAIChatRequest" + } + } + ], + "responses": { + "200": { + "description": "Upstream response forwarded as-is" + }, + "502": { + "description": "Bad Gateway — upstream unreachable", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + }, + "/anthropic/{path}": { + "post": { + "security": [{"BearerAuth": []}], + "description": "Converts Anthropic Messages API requests to OpenAI format, forwards to OPENAI_BACKEND, and converts the response back to Anthropic format.", + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["anthropic"], + "summary": "Anthropic-compatible proxy", + "parameters": [ + { + "type": "string", + "description": "Anthropic API path (e.g. v1/messages)", + "name": "path", + "in": "path", + "required": true + }, + { + "description": "Anthropic Messages API request", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AnthropicRequest" + } + } + ], + "responses": { + "200": { + "description": "Response in Anthropic format", + "schema": { + "$ref": "#/definitions/AnthropicResponse" + } + }, + "502": { + "description": "Bad Gateway — upstream unreachable", + "schema": { + "$ref": "#/definitions/ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "HealthResponse": { + "type": "object", + "properties": { + "status": {"type": "string", "example": "ok"}, + "database": {"type": "string", "example": "ok"}, + "config": { + "type": "object", + "properties": { + "openai_backend": {"type": "string"}, + "port": {"type": "string"} + } + } + } + }, + "OpenAIChatRequest": { + "type": "object", + "required": ["model", "messages"], + "properties": { + "model": {"type": "string", "example": "gpt-4"}, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": {"type": "string", "example": "user"}, + "content": {"type": "string", "example": "Hello!"} + } + } + }, + "stream": {"type": "boolean", "example": false} + } + }, + "AnthropicRequest": { + "type": "object", + "required": ["model", "max_tokens", "messages"], + "properties": { + "model": {"type": "string", "example": "claude-3-5-sonnet-20241022"}, + "max_tokens": {"type": "integer", "example": 1024}, + "system": {"type": "string", "example": "You are a helpful assistant."}, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": {"type": "string", "example": "user"}, + "content": {"type": "string", "example": "Hello!"} + } + } + } + } + }, + "AnthropicResponse": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "type": {"type": "string", "example": "message"}, + "role": {"type": "string", "example": "assistant"}, + "model": {"type": "string"}, + "stop_reason": {"type": "string", "example": "end_turn"}, + "content": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": {"type": "string", "example": "text"}, + "text": {"type": "string"} + } + } + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "error": {"type": "string", "example": "upstream error: connection refused"} + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..cc39e69 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,114 @@ +basePath: / +definitions: + handlers.HealthResponse: + properties: + config: + properties: + openai_backend: + type: string + port: + type: string + type: object + database: + type: string + status: + type: string + type: object +host: localhost:8000 +info: + contact: + email: admin@optoant.local + name: optoant + description: OpenAI-compatible and Anthropic-compatible LLM proxy/gateway with Bifrost + mapping. + license: + name: MIT + url: https://opensource.org/licenses/MIT + termsOfService: http://swagger.io/terms/ + title: LLM Gateway API + version: "1.0" +paths: + /anthropic/{path}: + post: + consumes: + - application/json + description: Converts Anthropic Messages API requests to OpenAI format, forwards + to OPENAI_BACKEND, and converts the response back to Anthropic format. + parameters: + - description: Anthropic API path (e.g. v1/messages) + in: path + name: path + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: + type: string + type: object + summary: Anthropic-compatible proxy + tags: + - anthropic + /health: + get: + description: Returns service health status, database connectivity, and active + configuration. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handlers.HealthResponse' + summary: Health check + tags: + - health + /v1/{path}: + post: + consumes: + - application/json + description: Forwards any /v1/* request to the configured OpenAI-compatible + backend (e.g. DeepSeek). + parameters: + - description: OpenAI API path (e.g. chat/completions) + in: path + name: path + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "502": + description: Bad Gateway + schema: + additionalProperties: + type: string + type: object + summary: OpenAI-compatible proxy + tags: + - openai +securityDefinitions: + BearerAuth: + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/docs/wiki/AnthropicHandler.md b/docs/wiki/AnthropicHandler.md new file mode 100644 index 0000000..d9c60b2 --- /dev/null +++ b/docs/wiki/AnthropicHandler.md @@ -0,0 +1,49 @@ +# AnthropicHandler + +**Özet:** `/anthropic/*` route'unu karşılar (`handlers/anthropic.go:75`). Anthropic Messages API formatını OpenAI Chat Completions formatına çevirir (Bifrost), upstream'e iletir ve yanıtı geri Anthropic formatına dönüştürür. + +**Kütüphaneler:** Fiber v3, GORM, Go `encoding/json` + +**Bağlantılar:** [[Main]], [[BifrostTransform]], [[ProxyEngine]], [[RequestLog]], [[Config]], [[Anthropic Flow]] + +## Davranış + +1. `GET /v1/models` → [[AnthropicHandler#modelsList|modelleri listele]] +2. `HEAD` → 404 (DeepSeek uyumluluğu) +3. Empty body → varsayılan "optoant gateway ready" yanıtı +4. `x-api-key` → `Authorization: Bearer` dönüşümü (Claude Code uyumluluğu) +5. Gerekirse `OPENAI_KEY`'den auto-inject +6. [[BifrostTransform#AnthropicToBifrost|AnthropicToBifrost()]] ile dönüşüm +7. [[ProxyEngine]] ile `OPENAI_BACKEND/v1/chat/completions`'a forward +8. Başarılıysa (200): [[BifrostTransform#BifrostToAnthropic|BifrostToAnthropic()]] → Anthropic format +9. Hata durumunda: raw passthrough +10. DB log (fire-and-forget) + +## Endpoint + +``` +POST /anthropic/v1/messages +x-api-key: +# veya +Authorization: Bearer +``` + +## Modeller (`modelsList()`) + +| ID | Display Name | +|---|---| +| `deepseek-v4-flash` | DeepSeek V4 Flash | +| `deepseek-v4-pro` | DeepSeek V4 Pro | + +## Ek Fonksiyonlar + +| Fonksiyon | Açıklama | +|---|---| +| `infoPage()` | `GET /anthropic` (boş path) için HTML bilgi sayfası | +| `modelsList()` | `GET /anthropic/v1/models` için model listesi | + +## Edge Case'ler + +- Geçersiz Anthropic formatı → uyarı log'u + raw passthrough +- Upstream hatası → `502 Bad Gateway` +- Yanıt dönüşüm hatası → raw yanıtı forward et diff --git a/docs/wiki/Anthropic_Flow.md b/docs/wiki/Anthropic_Flow.md new file mode 100644 index 0000000..702162a --- /dev/null +++ b/docs/wiki/Anthropic_Flow.md @@ -0,0 +1,46 @@ +# Anthropic Flow + +**Özet:** Anthropic Messages API formatındaki isteklerin gateway üzerinden Bifrost dönüşümüyle OpenAI formatına çevrilip upstream'e iletilmesi ve yanıtın geri Anthropic formatına dönüştürülmesi. + +**Kütüphaneler:** Fiber v3, Go `net/http`, `encoding/json` + +**Bağlantılar:** [[AnthropicHandler]], [[BifrostTransform]], [[ProxyEngine]], [[RequestLog]], [[Index]] + +## Akış Diyagramı + +```mermaid +sequenceDiagram + participant Client + participant Gateway + participant Bifrost as BifrostTransform + participant Proxy + participant Upstream + participant DB + + Client->>Gateway: POST /anthropic/v1/messages + Note over Gateway: AnthropicHandler + Gateway->>Gateway: x-api-key → Authorization: Bearer + Gateway->>Gateway: API key auto-inject (gerekirse) + Gateway->>Bifrost: AnthropicToBifrost() + Bifrost-->>Gateway: OpenAI Chat Completions format + Gateway->>Proxy: Forward() to /v1/chat/completions + Proxy->>Upstream: POST /v1/chat/completions + Upstream-->>Proxy: 200 OK + OpenAI format response + Proxy-->>Gateway: ForwardResult + Gateway->>Bifrost: BifrostToAnthropic() + Bifrost-->>Gateway: Anthropic Messages format + Gateway->>DB: go db.Create(logEntry) + Gateway-->>Client: 200 OK (Anthropic format) +``` + +## Edge Case'ler + +| Senaryo | Davranış | +|---|---| +| **HEAD isteği** | 404 (DeepSeek uyumluluğu) | +| **Empty body** | Varsayılan "optoant gateway ready" yanıtı | +| **Geçersiz Anthropic formatı** | Raw passthrough + uyarı log'u | +| **Yanıt dönüşüm hatası** | Raw yanıtı forward et (passthrough) | +| **GET /v1/models** | DeepSeek model listesi (Anthropic formatında) | +| **Boş path /anthropic** | HTML info sayfası (`infoPage()`) | +| **Upstream hatası** | 502 Bad Gateway | diff --git a/docs/wiki/BifrostTransform.md b/docs/wiki/BifrostTransform.md new file mode 100644 index 0000000..516202f --- /dev/null +++ b/docs/wiki/BifrostTransform.md @@ -0,0 +1,61 @@ +# BifrostTransform + +**Özet:** Anthropic Messages API formatı ile OpenAI Chat Completions formatı arasında çift yönlü dönüşüm yapan katman (`internal/transform/anthropic_bifrost.go:74`). "Bifrost" (İskandinav mitolojisindeki köprü) adı, two-way dönüşümü simgeler. + +**Kütüphaneler:** Go `encoding/json`, `strings` + +**Bağlantılar:** [[AnthropicHandler]], [[Index]] + +## Struct'lar + +### Anthropic Format (Giriş/Çıkış) + +| Struct | Alanlar | +|---|---| +| `AnthropicRequest` | `Model`, `MaxTokens`, `Messages []AnthropicMessage`, `System`, `Stream` | +| `AnthropicMessage` | `Role`, `Content` | +| `AnthropicResponse` | `ID`, `Type`, `Role`, `Content []AnthropicContent`, `Model`, `StopReason`, `StopSequence` | +| `AnthropicContent` | `Type`, `Text` | + +### OpenAI / Bifrost Format (Ara Katman) + +| Struct | Alanlar | +|---|---| +| `BifrostRequest` | `Model`, `Messages []BifrostMessage`, `MaxTokens`, `Stream` | +| `BifrostMessage` | `Role`, `Content` | +| `BifrostResponse` | `ID`, `Object`, `Created`, `Model`, `Choices[{Message{Role,Content}, FinishReason, Index}]` | + +## Dönüşüm Detayları + +### Anthropic → OpenAI (`AnthropicToBifrost()`) + +``` +Anthropic Request OpenAI Request +───────────────── ───────────── +model: "claude-3-5-sonnet" → model: "Anthropic/claude-3-5-sonnet" +system: "Be helpful" → messages: [{role:"system", ...}, ...] +messages: [{role, content}] → messages: [{role, content}] +max_tokens: 1024 → max_tokens: 1024 +stream: true → stream: true +``` + +### OpenAI → Anthropic (`BifrostToAnthropic()`) + +``` +OpenAI Response Anthropic Response +─────────────── ───────────────── +id: "chatcmpl-xxx" → id: "chatcmpl-xxx" +choices[].message.content → content: [{type:"text", text:"..."}] +choices[].finish_reason → stop_reason: "stop"/"end_turn" +model → model (same) +``` + +## Model Prefix Tahmini (`guessProvider()`) + +| Model içerir | Prefix | Örnek | +|---|---|---| +| `deepseek` | `DeepSeek/` | `deepseek-v4` → `DeepSeek/deepseek-v4` | +| `gpt`, `openai` | `OpenAI/` | `gpt-4` → `OpenAI/gpt-4` | +| `claude`, `anthropic` | `Anthropic/` | `claude-3` → `Anthropic/claude-3` | +| `gemini` | `Google/` | `gemini-pro` → `Google/gemini-pro` | +| diğer | `DeepSeek/` (varsayılan) | — | diff --git a/docs/wiki/ClaudeCode_Setup.md b/docs/wiki/ClaudeCode_Setup.md new file mode 100644 index 0000000..4b9f2cf --- /dev/null +++ b/docs/wiki/ClaudeCode_Setup.md @@ -0,0 +1,95 @@ +# ClaudeCode_Setup + +**Özet:** Claude Code CLI'ı bu LLM Gateway üzerinden kullanmak için gerekli yapılandırma. Claude Code, Anthropic Messages API formatında konuşur; gateway `/anthropic/*` endpoint'i ile bunu OpenAI formatına çevirip herhangi bir OpenAI-compatible backend'e (DeepSeek, vb.) iletir. + +**Kütüphaneler:** Claude Code CLI, Go Fiber v3 + +**Bağlantılar:** [[AnthropicHandler]], [[BifrostTransform]], [[Anthropic Flow]], [[Config]], [[Index]] + +## Nasıl Çalışır + +``` +Claude Code → x-api-key → Gateway /anthropic/v1/messages → AnthropicToBifrost() + ↓ + OpenAI Chat Completions + ↓ + OPENAI_BACKEND/v1/chat/completions + ↓ + BifrostToAnthropic() + ↓ +Claude Code ← Anthropic Messages API ← Gateway ← ← ← ← ← ← +``` + +## Claude Code Yapılandırması + +Claude Code'u gateway'e yönlendirmek için iki yol var: + +### 1. Environment Variable (Önerilen) + +```bash +export ANTHROPIC_BASE_URL="http://localhost:8000/anthropic" +export ANTHROPIC_API_KEY="" +claude +``` + +### 2. Claude Code Settings JSON + +`~/.claude/settings.json`: + +```json +{ + "anthropicBaseUrl": "http://localhost:8000/anthropic", + "apiKey": "" +} +``` + +## Gateway Konfigürasyonu (`.env`) + +```env +PORT=8000 +OPENAI_BACKEND=https://api.deepseek.com +OPENAI_KEY=sk-your-deepseek-api-key +REQUEST_TIMEOUT_SECONDS=30 +``` + +| Değişken | Değer | Açıklama | +|---|---|---| +| `OPENAI_BACKEND` | `https://api.deepseek.com` | Upstream LLM API (OpenAI-compatible) | +| `OPENAI_KEY` | `sk-...` | Upstream API anahtarı (auto-inject) | + +## Test + +Gateway çalışırken Claude Code'un gönderdiği formatta test: + +```bash +curl -X POST "http://localhost:8000/anthropic/v1/messages" \ + -H "Content-Type: application/json" \ + -H "x-api-key: sk-test-key" \ + -H "anthropic-version: 2023-06-01" \ + -d '{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 256, + "messages": [{"role": "user", "content": "Merhaba, 2+2 nedir?"}] + }' +``` + +Başarılı yanıt formatı: + +```json +{ + "id": "chatcmpl-xxx", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "4"}], + "model": "DeepSeek/deepseek-v4", + "stop_reason": "end_turn" +} +``` + +## Önemli Notlar + +- Claude Code `x-api-key` header'ı ile authentication yapar → Gateway bunu `Authorization: Bearer`'a çevirir +- `anthropic-version` header'ı olduğu gibi upstream'e iletilir +- Model adı otomatik prefix alır: `claude-sonnet-4-20250514` → `Anthropic/claude-sonnet-4-20250514` +- **Streaming (`stream: true`) henüz desteklenmez** — response tamamlanana kadar bekler +- Claude Code `--no-stream` flag'i ile streaming'i kapatarak kullanılabilir: `claude --no-stream` diff --git a/docs/wiki/Config.md b/docs/wiki/Config.md new file mode 100644 index 0000000..386d190 --- /dev/null +++ b/docs/wiki/Config.md @@ -0,0 +1,30 @@ +# Config + +**Özet:** Tüm runtime konfigürasyonu `.env` dosyasından okur (`config/config.go:9`). Hiçbir varsayılan IP veya port sabit kodlanmamıştır — tüm değerler `os.Getenv()` ile okunur. + +**Kütüphaneler:** Go `os`, `strconv` + +**Bağlantılar:** [[Main]], [[Index]] + +## Parametreler + +| Değişken | Varsayılan | Config Alanı | Açıklama | +|---|---|---|---| +| `PORT` | `8000` | `Port` | HTTP dinleme portu | +| `OPENAI_BACKEND` | `https://api.deepseek.com` | `OpenAIBackend` | Upstream LLM backend base URL | +| `OPENAI_KEY` | `""` | `OpenAIApiKey` | Opsiyonel API anahtarı (otomatik enjekte) | +| `DB_MODE` | `pgs` | `DBMode` | Veritabanı motoru: `sqlite` veya `pgs` | +| `DB_PATH` | `data/gateway.db` | `DBPath` | SQLite veritabanı dosya yolu (`DB_MODE=sqlite` iken) | +| `LOG_LEVEL` | `info` | `LogLevel` | Konsol log seviyesi: `debug` (her şey), `info` (özet), `warn` (sadece hatalar) | +| `POSTGRES_DSN` / `DATABASE_DSN` | `""` | `PostgresDSN` | PostgreSQL bağlantı DSN (`DB_MODE=pgs` iken) | +| `REQUEST_TIMEOUT_SECONDS` | `30` | `RequestTimeoutSeconds` | Upstream istek zaman aşımı (saniye) | +| `OPENAI_MODEL` | `""` | `OpenAIModel` | Varsayılan model adı (istekte model yoksa enjekte edilir) | + +## Önemli Detaylar + +- `DB_MODE=sqlite` → `DB_PATH` değeri SQLite dosya yolu olarak kullanılır (varsayılan: `data/gateway.db`) +- `DB_MODE=pgs` veya tanımsız → PostgreSQL bağlantısı (mevcut davranış) +- `POSTGRES_DSN` ve `DATABASE_DSN` çifti desteklenir (birincil: `POSTGRES_DSN`) +- `OPENAI_KEY` tanımlanmışsa, client `Authorization` header'ı göndermediğinde otomatik enjekte edilir +- Tüm değerler `os.Getenv()` ile okunur +- Config struct'ı main.go'da bir kez yüklenir ve tüm handler'lara pointer olarak geçilir diff --git a/docs/wiki/DockerSetup.md b/docs/wiki/DockerSetup.md new file mode 100644 index 0000000..7b0cb25 --- /dev/null +++ b/docs/wiki/DockerSetup.md @@ -0,0 +1,60 @@ +# DockerSetup + +**Özet:** Multi-stage Docker build ile optimize edilmiş imaj üretimi ve docker-compose ile PostgreSQL + Gateway orchestration'ı. + +**Kütüphaneler:** Docker, docker-compose, Alpine Linux + +**Bağlantılar:** [[Main]], [[Config]], [[Index]] + +## Dockerfile (`docker/Dockerfile`) + +**Stage 1 — Build:** `golang:1.24-alpine` +- Dependency caching için önce `go.mod` + `go.sum` kopyalanır +- `CGO_ENABLED=0` ile statik binary +- `-ldflags="-w -s"` ile boyut optimizasyonu + +**Stage 2 — Runtime:** `alpine:3.21` +- Sadece binary + `ca-certificates` + `tzdata` +- Non-root `gateway` kullanıcısı (güvenlik) +- Port 8000 expose + +## docker-compose (`docker/docker-compose.yml`) + +| Servis | İmaj/Rol | Özellikler | +|---|---|---| +| `app` | LLM Gateway (build) | `.env`'den config, postgres'e bağımlı | +| `postgres` | `postgres:16-alpine` | Healthcheck, persistent volume `pgdata` | + +### Servis Detayları (app) + +- Port mapping: `${PORT:-8000}:8000` +- Environment: `PORT`, `OPENAI_BACKEND`, `DATABASE_DSN`, `REQUEST_TIMEOUT_SECONDS` +- `depends_on` ile postgres healthcheck bekler +- `restart: unless-stopped` + +## Kullanım + +```bash +# Tüm stack'i başlat +docker compose -f docker/docker-compose.yml up -d + +# Sadece PostgreSQL (geliştirme) +docker compose -f docker/docker-compose.yml up -d postgres + +# Port override +PORT=9000 docker compose -f docker/docker-compose.yml up -d + +# Sadece imaj build +docker build -t optoant-gateway:latest -f docker/Dockerfile . +``` + +## Alternatif Derleme + +```bash +# build.sh ile production binary +./build.sh +# Çıktı: ./gateway + +# Veya direkt Go build +CGO_ENABLED=0 go build -ldflags="-w -s" -o gateway ./main.go +``` diff --git a/docs/wiki/HealthHandler.md b/docs/wiki/HealthHandler.md new file mode 100644 index 0000000..ae16150 --- /dev/null +++ b/docs/wiki/HealthHandler.md @@ -0,0 +1,29 @@ +# HealthHandler + +**Özet:** `/health` endpoint'i ile servis durumu, veritabanı bağlantısı ve aktif konfigürasyon bilgilerini döndürür (`handlers/health.go:27`). + +**Kütüphaneler:** Fiber v3, GORM + +**Bağlantılar:** [[Main]], [[Config]], [[Index]] + +## Yanıt Formatı + +```json +{ + "status": "ok", + "database": "ok" | "unreachable" | "disabled", + "config": { + "openai_backend": "https://api.deepseek.com", + "port": "8000" + } +} +``` + +## Davranış + +| Durum | `database` değeri | +|---|---| +| DB bağlantısı yok (DSN boş) | `"disabled"` | +| DB var, ping başarılı | `"ok"` | +| DB var, ping başarısız | `"unreachable"` | +| Her durumda `status` | `"ok"` (servis ayakta) | diff --git a/docs/wiki/Index.md b/docs/wiki/Index.md new file mode 100644 index 0000000..a1cb51d --- /dev/null +++ b/docs/wiki/Index.md @@ -0,0 +1,86 @@ +# LLM Gateway — Mimari Haritası + +**Özet:** Bu proje, birden fazla LLM sağlayıcısını (OpenAI-compatible, Anthropic) tek bir gateway üzerinden proxy'leyen, istekleri dönüştüren ve loglayan bir Go hizmetidir. Go 1.26 + Fiber v3 + GORM + PostgreSQL / SQLite (`DB_MODE` ile seçimli) teknolojileriyle inşa edilmiştir. + +**Kütüphaneler:** Go 1.26, Fiber v3, GORM, PostgreSQL, SQLite, godotenv, swaggo + +**Bağlantılar:** [[Main]], [[Config]], [[HealthHandler]], [[OpenAIHandler]], [[AnthropicHandler]], [[ProxyEngine]], [[BifrostTransform]], [[RequestLog]], [[SwaggerUI]], [[Testing]], [[DockerSetup]], [[OpenAI_Flow]], [[Anthropic_Flow]], [[ClaudeCode_Setup]] + +--- + +## Katman Mimarisi + +| Katman | Dosya | Açıklama | +|--------|-------|----------| +| **Giriş** | `main.go:38` | Fiber v3 app başlatma, DB bağlantısı, route tanımları | +| **Konfigürasyon** | `config/config.go:9` | `.env` tabanlı runtime config (port, backend, DSN) | +| **Handler** | `handlers/*.go` | HTTP handler fonksiyonları (health, openai, anthropic) | +| **Proxy** | `internal/proxy/proxy.go:14` | Generic HTTP forward motoru | +| **Transform** | `internal/transform/anthropic_bifrost.go:10` | Anthropic ↔ OpenAI format dönüştürücü | +| **Model** | `models/request_log.go:13` | GORM veritabanı modeli | + +## API Endpoint'leri + +| Method | Path | Handler | Açıklama | +|--------|------|---------|----------| +| `GET` | `/health` | [[HealthHandler]] | Servis durumu, DB sağlığı, config bilgisi | +| `ALL` | `/v1/*` | [[OpenAIHandler]] | OpenAI-compatible direct passthrough proxy | +| `ALL` | `/anthropic`, `/anthropic/*` | [[AnthropicHandler]] | Anthropic Messages API → OpenAI dönüşüm proxy | +| `GET` | `/swagger`, `/swagger/*` | Inline Fiber | CDN'den yüklenen Swagger UI | + +## Veri Akışları + +- [[OpenAI Flow]] — `/v1/*` isteklerinin upstream'e direkt iletilmesi +- [[Anthropic Flow]] — `/anthropic/*` isteklerinin Bifrost dönüşümüyle upstream'e iletilmesi + +## Claude Code Entegrasyonu + +- [[ClaudeCode_Setup]] — Claude Code CLI'ı gateway üzerinden kullanma + +## Altyapı ve Geliştirme + +- [[DockerSetup]] — Multi-stage Docker build + docker-compose (app + postgres) +- [[SwaggerUI]] — `/swagger/` adresinde OpenAPI dokümantasyonu +- [[Testing]] — `httptest.Server` ile mock upstream testleri +- `.air.toml` — Air hot-reload (geliştirme) +- `build.sh` — Production binary derleme scripti +- [[RequestLog]] — GORM + PostgreSQL/SQLite istek loglama + +## Veritabanı Şeması + +| Tablo | Model | Amaç | +|-------|-------|------| +| `request_logs` | [[RequestLog]] | Tüm proxy isteklerinin log kaydı | + +## Dönüşüm Katmanı + +| Yön | Fonksiyon | Açıklama | +|-----|-----------|----------| +| Anthropic → OpenAI | `AnthropicToBifrost()` | Mesaj API → Chat Completions dönüşümü | +| OpenAI → Anthropic | `BifrostToAnthropic()` | Chat Completions → Mesaj API dönüşümü | + +Desteklenen model prefix'leri: `DeepSeek/`, `OpenAI/`, `Anthropic/`, `Google/` + +## Proje Haritası + +``` +. +├── main.go # Giriş noktası +├── config/config.go # Konfigürasyon +├── models/request_log.go # Veritabanı modeli +├── handlers/ +│ ├── health.go # Health check +│ ├── openai.go # OpenAI proxy +│ └── anthropic.go # Anthropic proxy +├── internal/ +│ ├── proxy/proxy.go # Forward motoru +│ └── transform/anthropic_bifrost.go # Format dönüştürücü +├── tests/openai_test.go # Unit testler +├── docker/ +│ ├── Dockerfile # Multi-stage build +│ └── docker-compose.yml # Orchestration +├── build.sh # Derleme scripti +└── docs/ + ├── swagger.json/yaml # Swagger spec + └── wiki/ # Bu wiki (Obsidian Knowledge Graph) +``` diff --git a/docs/wiki/Main.md b/docs/wiki/Main.md new file mode 100644 index 0000000..a5bd0da --- /dev/null +++ b/docs/wiki/Main.md @@ -0,0 +1,36 @@ +# Main + +**Özet:** Uygulamanın giriş noktası (`main.go:38`). Fiber v3 web framework'ünü başlatır, `DB_MODE`'a göre PostgreSQL veya SQLite bağlantısını kurar, AutoMigrate çalıştırır ve tüm route'ları tanımlar. + +**Kütüphaneler:** Fiber v3, GORM, PostgreSQL, SQLite, godotenv + +**Bağlantılar:** [[Index]], [[Config]], [[RequestLog]], [[OpenAIHandler]], [[AnthropicHandler]], [[HealthHandler]], [[SwaggerUI]], [[DockerSetup]] + +## Başlangıç Adımları + +1. `.env` dosyası yüklenir (`godotenv.Load()`) +2. [[Config]] okunur (`config.Load()`) +3. `DB_MODE` kontrol edilir — PostgreSQL DSN veya SQLite bağlantısı + AutoMigrate (`RequestLog`) +4. Fiber v3 app oluşturulur (`fiber.Config{AppName: "LLM Gateway v1.0", ServerHeader: "optoant-gateway"}`) +5. Route'lar tanımlanır: + - `GET /health` → [[HealthHandler]] + - `GET /swagger/swagger.json` → statik dosya serve + - `GET /swagger` → `/swagger/` redirect + - `GET /swagger/*` → Swagger UI HTML (CDN) + - `ALL /v1/*` → [[OpenAIHandler]] (direct passthrough) + - `ALL /anthropic`, `/anthropic/*` → [[AnthropicHandler]] (Bifrost dönüşümü) +6. `app.Listen(addr)` ile server başlatılır + +## Önemli Detaylar + +- DB bağlantısı başarısız olursa uygulama **devam eder** (DB'siz çalışabilir) +- Tüm backend istekleri `.env`'deki `OPENAI_BACKEND` adresine gider +- OpenAI endpoint'i direkt passthrough yaparken, Anthropic endpoint'i format dönüşümü yapar +- Swagger UI **CDN'den** yüklenir (offline çalışmaz) +- API key auto-injection: `OPENAI_KEY` env'i tanımlıysa, client Authorization göndermezse otomatik eklenir + +## Geliştirme + +- **Air hot-reload:** `.air.toml` ile canlı yeniden derleme (`air`) +- **Production build:** `build.sh` ile `CGO_ENABLED=0` optimize binary +- **Docker:** `docker/Dockerfile` multi-stage + `docker-compose.yml` diff --git a/docs/wiki/OpenAIHandler.md b/docs/wiki/OpenAIHandler.md new file mode 100644 index 0000000..eec8d00 --- /dev/null +++ b/docs/wiki/OpenAIHandler.md @@ -0,0 +1,36 @@ +# OpenAIHandler + +**Özet:** `/v1/*` route'unu karşılar (`handlers/openai.go:29`), gelen istekleri olduğu gibi `OPENAI_BACKEND` adresine iletir. OpenAI-compatible bir proxy'dir. + +**Kütüphaneler:** Fiber v3, GORM, Go `net/http` + +**Bağlantılar:** [[Main]], [[ProxyEngine]], [[RequestLog]], [[Config]], [[OpenAI Flow]] + +## Davranış + +1. Gelen isteği alır, header'ları `net/http.Header` formatına çevirir (`fiberToHTTPHeaders()`) +2. Eğer `OPENAI_KEY` tanımlanmışsa ve client Authorization göndermemişse, otomatik ekler +3. [[ProxyEngine]] ile isteği `OPENAI_BACKEND + originalURL` adresine forward eder +4. Response header'larını kopyalar (`copyResponseHeaders()` — Transfer-Encoding, Content-Length, Connection atlanır) +5. Yanıtı olduğu gibi client'a döndürür +6. [[RequestLog]]'a fire-and-forget goroutine ile kayıt ekler + +## Endpoint + +``` +ANY /v1/chat/completions +ANY /v1/models +Authorization: Bearer +``` + +## Yardımcı Fonksiyonlar + +| Fonksiyon | Açıklama | +|---|---| +| `fiberToHTTPHeaders(c fiber.Ctx) http.Header` | Fiber header'larını `net/http` formatına çevirir | +| `copyResponseHeaders(c fiber.Ctx, headers http.Header)` | Upstream response header'larını Fiber response'a kopyalar (hop-by-hop atlanır) | + +## Hata Durumu + +- Upstream hatasında → `502 Bad Gateway` + hata mesajı +- Loglama hatası → ana isteği etkilemez (fire-and-forget) diff --git a/docs/wiki/OpenAI_Flow.md b/docs/wiki/OpenAI_Flow.md new file mode 100644 index 0000000..a7c6681 --- /dev/null +++ b/docs/wiki/OpenAI_Flow.md @@ -0,0 +1,38 @@ +# OpenAI Flow + +**Özet:** OpenAI-compatible isteklerin gateway üzerinden direkt passthrough akışı. + +**Kütüphaneler:** Fiber v3, Go `net/http` + +**Bağlantılar:** [[OpenAIHandler]], [[ProxyEngine]], [[RequestLog]], [[Index]] + +## Akış Diyagramı + +```mermaid +sequenceDiagram + participant Client + participant Gateway + participant Proxy + participant Upstream + participant DB + + Client->>Gateway: POST /v1/chat/completions + Note over Gateway: OpenAIHandler + Gateway->>Gateway: fiberToHTTPHeaders() + Gateway->>Gateway: API key auto-inject (gerekirse) + Gateway->>Gateway: copyResponseHeaders() + Gateway->>Proxy: Forward(method, targetURL, headers, body, timeout) + Proxy->>Upstream: POST /v1/chat/completions + Upstream-->>Proxy: 200 OK + response body + Proxy-->>Gateway: ForwardResult{StatusCode, Body, Headers} + Gateway->>Gateway: copyResponseHeaders() (skip hop-by-hop) + Gateway->>DB: go db.Create(logEntry) — fire-and-forget + Gateway-->>Client: 200 OK + original response body +``` + +## Önemli Noktalar + +- İstek body'si **olduğu gibi** iletilir (dönüşüm yok) +- Response header'ları kopyalanırken `Transfer-Encoding`, `Content-Length`, `Connection` atlanır +- Upstream hatasında client `502 Bad Gateway` alır +- DB loglama bir goroutine içinde çalışır, asla ana isteği bloke etmez diff --git a/docs/wiki/ProxyEngine.md b/docs/wiki/ProxyEngine.md new file mode 100644 index 0000000..294cd5f --- /dev/null +++ b/docs/wiki/ProxyEngine.md @@ -0,0 +1,41 @@ +# ProxyEngine + +**Özet:** HTTP isteklerini upstream backend'e ileten merkezi proxy motoru (`internal/proxy/proxy.go:24`). Tüm header'ları korur, hassas header'ları log'da maskeler, hop-by-hop header'ları otomatik atlar. + +**Kütüphaneler:** Go `net/http`, `context`, `io` + +**Bağlantılar:** [[OpenAIHandler]], [[AnthropicHandler]], [[Index]] + +## Struct + +```go +type ForwardResult struct { + StatusCode int + Body []byte + Headers http.Header +} +``` + +## Fonksiyonlar + +### `Forward(ctx, method, targetURL, headers, body, timeoutSec) *ForwardResult` +- HTTP isteğini hedef URL'ye iletir +- Context üzerinden timeout yönetimi (`time.Duration(timeoutSec) * time.Second`) +- Hop-by-hop header'ları atlar: `Connection`, `TE`, `Trailers`, `Transfer-Encoding`, `Upgrade` +- Body'yi log için okur, sonra tekrar sarar +- `ForwardResult{StatusCode, Body, Headers}` döndürür + +### `MaskSensitiveHeaders(headers) http.Header` +- Header'ları clone'lar +- `Authorization` → `Bearer [REDACTED]` +- `X-Api-Key` → `[REDACTED]` +- Sadece loglama amaçlı kullanılır; upstream'e orijinal header gider + +### `truncateForLog(s, maxLen) string` +- Uzun string'leri `maxLen` (2000) karakterde kırpar, `...[TRUNCATED]` ekler + +## Güvenlik + +- Hassas header'lar upstream'e **olduğu gibi** iletilir ama **log'da maskelenir** +- Hop-by-hop header'lar otomatik atlanır (proxy zincirini bozmamak için) +- Sensitive header masking sadece log çıktısı içindir diff --git a/docs/wiki/RequestLog.md b/docs/wiki/RequestLog.md new file mode 100644 index 0000000..82f40db --- /dev/null +++ b/docs/wiki/RequestLog.md @@ -0,0 +1,33 @@ +# RequestLog + +**Özet:** Her proxy'lenen isteğin kaydını tutan GORM modeli (`models/request_log.go:13`). Hassas header'lar (Authorization) asla düz metin olarak saklanmaz. Loglama fire-and-forget goroutine ile yapılır. + +**Kütüphaneler:** GORM, PostgreSQL, Go `time` + +**Bağlantılar:** [[Main]], [[OpenAIHandler]], [[AnthropicHandler]], [[Index]] + +## Tablo: `request_logs` + +| Column | GORM Tipi | JSON | Açıklama | +|--------|-----------|------|----------| +| `id` | `uint` (PK) | `id` | Otomatik artan | +| `created_at` | `time.Time` | `created_at` | Oluşturulma zamanı | +| `updated_at` | `time.Time` | `updated_at` | Güncellenme zamanı | +| `deleted_at` | `gorm.DeletedAt` | (gizli) | Soft delete index | +| `endpoint` | `string(512)` | `endpoint` | İstek yolu | +| `method` | `string(16)` | `method` | HTTP metodu | +| `client_ip` | `string(64)` | `client_ip` | İstemci IP | +| `request_body` | `text` | `request_body` | Body (2000 char limit) | +| `response_status` | `int` | `response_status` | HTTP yanıt kodu | +| `latency_ms` | `int64` | `latency_ms` | İşlem süresi (ms) | + +## Sabitler + +- `maxBodyLength = 2000` — `TruncateBody()` ile body bu limite kırpılır + +## Önemli Detaylar + +- `TruncateBody()` body'yi 2000 karakterde kırpar, sonuna `…[truncated]` ekler +- Loglama **fire-and-forget** (`go db.Create()`): loglama hatası ana isteği etkilemez +- Hassas header'lar (Authorization, X-Api-Key) veritabanına yazılmaz +- DB bağlantısı yoksa loglama tamamen atlanır diff --git a/docs/wiki/SwaggerUI.md b/docs/wiki/SwaggerUI.md new file mode 100644 index 0000000..74f8616 --- /dev/null +++ b/docs/wiki/SwaggerUI.md @@ -0,0 +1,40 @@ +# SwaggerUI + +**Özet:** Fiber v3 native Swagger UI entegrasyonu (`main.go:79`). OpenAPI spec `docs/swagger.json` dosyasından statik olarak servis edilir, UI CDN'den (unpkg) yüklenir. + +**Kütüphaneler:** swaggo/swag, Swagger UI 5.x (CDN) + +**Bağlantılar:** [[Main]], [[Index]] + +## Route'lar + +| Path | Açıklama | +|---|---| +| `GET /swagger` | `/swagger/`'a redirect | +| `GET /swagger/` | Swagger UI HTML (CDN) | +| `GET /swagger/swagger.json` | Statik OpenAPI spec dosyası | + +## Kullanım + +```bash +# Swagger spec'i yenile +swag init + +# Swagger UI'a tarayıcıdan eriş +open http://localhost:8000/swagger/ +``` + +## Swagger Annotation'ları + +- `main.go:1-19` — Genel API bilgisi (title, version, host, security) +- `handlers/health.go:21-27` — `/health` endpoint +- `handlers/openai.go:20-28` — `/v1/{path}` endpoint +- `handlers/anthropic.go:65-73` — `/anthropic/{path}` endpoint + +## Önemli Detaylar + +- Swagger UI **unpkg CDN'inden** yüklenir (offline çalışmaz) +- Spec statik dosyadır: `docs/swagger.json` +- `docs/swagger.yaml` da mevcuttur (aynı spec) +- `swag init` her endpoint/değişiklik sonrası çalıştırılmalıdır +- API key: `Authorization: Bearer` (Swagger UI'da "Authorize" butonu) diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md new file mode 100644 index 0000000..bb5d2e0 --- /dev/null +++ b/docs/wiki/Testing.md @@ -0,0 +1,71 @@ +# Testing + +**Özet:** Fiber v3 test framework'ü ile mock upstream sunucuları kullanarak proxy mantığının test edilmesi. Transform katmanı unit testleri `internal/transform`, entegrasyon testleri `tests/` paketinde. + +**Kütüphaneler:** Go `testing`, `net/http/httptest`, Fiber v3 test (`app.Test()`), `encoding/json` + +**Bağlantılar:** [[OpenAIHandler]], [[AnthropicHandler]], [[BifrostTransform]], [[ProxyEngine]], [[Index]] + +## Test Stratejisi + +- `mockUpstream(t, status, body)` — `httptest.Server` ile sabit yanıt dönen test sunucusu +- `newTestApp(cfg)` — OpenAI handler bağlı Fiber test uygulaması (DB'siz) +- `newAnthropicTestApp(cfg)` — Anthropic handler bağlı Fiber test uygulaması (DB'siz) +- Gerçek upstream'e ihtiyaç yok, mock sunucu kullanılır +- `app.Test(req, fiber.TestConfig{Timeout: -1})` ile Fiber native HTTP test + +## Transform Unit Testleri (`internal/transform`) + +### Anthropic → OpenAI (`AnthropicToBifrost`) + +| Test | Açıklama | +|---|---| +| `TestAnthropicToBifrost_Basic` | Temel dönüşüm, model prefix + max_tokens + messages | +| `TestAnthropicToBifrost_WithSystem` | `system` alanının `messages[0]`'a eklenmesi | +| `TestAnthropicToBifrost_ModelPrefix` | 6 model için prefix tahmini (claude→Anthropic/, deepseek→DeepSeek/, vb.) | +| `TestAnthropicToBifrost_StreamPassthrough` | `stream: true` korunur | +| `TestAnthropicToBifrost_InvalidJSON` | Geçersiz JSON → hata döner | + +### OpenAI → Anthropic (`BifrostToAnthropic`) + +| Test | Açıklama | +|---|---| +| `TestBifrostToAnthropic_Basic` | Temel dönüşüm, content/text/stop_reason kontrolü | +| `TestBifrostToAnthropic_EmptyFinishReason` | Boş `finish_reason` → `end_turn` | +| `TestBifrostToAnthropic_MultipleChoices` | 2 choice'ın 2 content block'a dönüşümü | +| `TestBifrostToAnthropic_InvalidJSON` | Geçersiz JSON → hata döner | + +## Entegrasyon Testleri (`tests/anthropic_test.go`, `tests/openai_test.go`) + +| Test | Açıklama | +|---|---| +| `TestAnthropicProxy_Success` | Full Bifrost döngüsü: Anthropic→OpenAI→forward→OpenAI→Anthropic | +| `TestAnthropicProxy_InvalidFormatPassthrough` | Geçersiz Anthropic formatı → raw passthrough | +| `TestAnthropicProxy_UpstreamError` | Upstream kapalı → 502 Bad Gateway | +| `TestAnthropicProxy_ModelsList` | `GET /anthropic/v1/models` → DeepSeek model listesi | +| `TestAnthropicProxy_EmptyBody` | Boş body → varsayılan "ready" yanıtı | +| `TestAnthropicProxy_HEAD` | `HEAD` → 404 | +| `TestAnthropicProxy_xApiKeyConversion` | `x-api-key` header'ının `Authorization: Bearer`'a dönüşümü | +| `TestAnthropicProxy_DefaultModelInjection` | İstekte model yoksa `OPENAI_MODEL` enjekte edilir (zaten "/" var, prefix eklenmez) | +| `TestAnthropicProxy_DefaultModelInjection_NopWhenModelExists` | İstekte model varsa `OPENAI_MODEL` override etmez | +| `TestAnthropicProxy_TransformErrorFallsbackToPassthrough` | Upstream yanıtı bozuksa raw passthrough | +| `TestOpenAIProxy_Success` | OpenAI handler başarılı proxy | +| `TestOpenAIProxy_DefaultModelInjection` | OpenAI handler'da model yoksa enjekte | +| `TestOpenAIProxy_DefaultModelInjection_ExistingModel` | OpenAI handler'da var olan model korunur | +| `TestOpenAIProxy_UpstreamError` | OpenAI handler upstream hatasında 502 | + +## Çalıştırma + +```bash +# Tüm testler +go test ./tests/ -v + +# Sadece transform unit testleri +go test ./internal/transform/ -v +``` + +## Notlar + +- Tüm testler DB'siz çalışır (`db = nil`) — loglama katmanı test edilmez +- Fiber v3'ün `app.Test()` metodu ile entegre test +- Mock upstream her test için ayrı `httptest.Server` oluşturur (izolasyon) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a0abb17 --- /dev/null +++ b/go.mod @@ -0,0 +1,55 @@ +module optoant + +go 1.26.3 + +require ( + github.com/gofiber/fiber/v3 v3.2.0 + github.com/joho/godotenv v1.5.1 + github.com/swaggo/swag v1.16.6 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect + github.com/gofiber/schema v1.7.1 // indirect + github.com/gofiber/utils/v2 v2.0.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.9.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.18.6 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.22 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.71.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ce547f --- /dev/null +++ b/go.sum @@ -0,0 +1,140 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= +github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/gofiber/fiber/v3 v3.2.0 h1:g9+09D320foINPpCnR3ibQ5oBEFHjAWRRfDG1te54u8= +github.com/gofiber/fiber/v3 v3.2.0/go.mod h1:FHOsc2Db7HhHpsE62QAaJlXVV1pNkbZEptZ4jtti7m4= +github.com/gofiber/schema v1.7.1 h1:oSJBKdgP8JeIME4TQSAqlNKTU2iBB+2RNmKi8Nsc+TI= +github.com/gofiber/schema v1.7.1/go.mod h1:A/X5Ffyru4p9eBdp99qu+nzviHzQiZ7odLT+TwxWhbk= +github.com/gofiber/utils/v2 v2.0.4 h1:WwAxUA7L4MW2DjdEHF234lfqvBqd2vYYuBtA9TJq2ec= +github.com/gofiber/utils/v2 v2.0.4/go.mod h1:GGERKU3Vhj5z6hS8YKvxL99A54DjOvTFZ0cjZnG4Lj4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= +github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shamaton/msgpack/v3 v3.1.0 h1:jsk0vEAqVvvS9+fTZ5/EcQ9tz860c9pWxJ4Iwecz8gU= +github.com/shamaton/msgpack/v3 v3.1.0/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= +github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/handlers/anthropic.go b/handlers/anthropic.go new file mode 100644 index 0000000..c889cc5 --- /dev/null +++ b/handlers/anthropic.go @@ -0,0 +1,352 @@ +package handlers + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" + + "optoant/config" + "optoant/internal/logger" + "optoant/internal/proxy" + "optoant/internal/transform" + "optoant/models" +) + +// infoPage returns an HTML page explaining the Anthropic endpoint. +func infoPage(c fiber.Ctx, cfg *config.Config) error { + html := fmt.Sprintf(` + + + + Anthropic API — optoant + + + +

Anthropic API — aktif

+

Bu endpoint Anthropic Messages API formatını kabul eder, OpenAI formatına çevirir ve upstream'e iletir.

+ +

Endpoint

+ POST http://%s/anthropic/v1/messages + +

Upstream

+ %s/v1/chat/completions + +

Örnek curl

+
curl -X POST "http://%s/anthropic/v1/messages" \
+  -H "Authorization: Bearer $KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "model": "claude-3-5-sonnet-20241022",
+    "max_tokens": 1024,
+    "messages": [{"role": "user", "content": "Hello!"}]
+  }'
+ +

Swagger UI: /swagger/ | Health: /health

+ +`, c.Hostname(), strings.TrimRight(cfg.OpenAIBackend, "/"), c.Hostname()) + return c.Type("html").SendString(html) +} + +// AnthropicHandler handles all /anthropic/* requests. +// Always converts the Anthropic request to OpenAI format, forwards to OPENAI_BACKEND, +// and converts the OpenAI response back to Anthropic format. +// +// @Summary Anthropic-compatible proxy +// @Description Converts Anthropic Messages API requests to OpenAI format, forwards to OPENAI_BACKEND, and converts the response back to Anthropic format. +// @Tags anthropic +// @Accept json +// @Produce json +// @Param path path string true "Anthropic API path (e.g. v1/messages)" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 502 {object} map[string]string +// @Router /anthropic/{path} [post] +func AnthropicHandler(cfg *config.Config, db *gorm.DB) fiber.Handler { + return func(c fiber.Ctx) error { + // ── Handle Anthropic Models API ── + if c.Method() == "GET" && strings.HasSuffix(c.OriginalURL(), "/v1/models") { + return modelsList(c, cfg) + } + + bodyBytes := c.Body() + + // HEAD → 404 (matching DeepSeek behavior) + if c.Method() == "HEAD" { + return c.Status(fiber.StatusNotFound).SendString("") + } + // Empty body → API status + if len(bodyBytes) == 0 { + return c.Status(fiber.StatusOK).JSON(fiber.Map{ + "type": "message", + "role": "assistant", + "content": []fiber.Map{{"type": "text", "text": "optoant gateway ready"}}, + "stop_reason": "end_turn", + }) + } + + // Inject default model from config if request body has no model field + if cfg.OpenAIModel != "" { + bodyBytes = injectDefaultModel(bodyBytes, cfg.OpenAIModel) + } + + start := time.Now() + reqHeaders := fiberToHTTPHeaders(c) + + // Convert x-api-key → Authorization: Bearer (Claude Code compatibility) + if reqHeaders.Get("Authorization") == "" && reqHeaders.Get("X-Api-Key") != "" { + reqHeaders.Set("Authorization", "Bearer "+reqHeaders.Get("X-Api-Key")) + logger.Debug("│ 🔑 Converted x-api-key → Authorization: Bearer") + } + + // Auto-inject API key from .env if client didn't provide one + if cfg.OpenAIApiKey != "" && reqHeaders.Get("Authorization") == "" { + reqHeaders.Set("Authorization", "Bearer "+cfg.OpenAIApiKey) + logger.Debug("│ 🔑 Auto-injected API key from OPENAI_KEY env") + } + + // ── LOG: Incoming Anthropic Request ── + logger.Info("┌─ [ANTHROPIC] >>> %s %s | IP: %s", c.Method(), c.OriginalURL(), c.IP()) + logger.Debug("│ Headers: %v", proxy.MaskSensitiveHeaders(reqHeaders)) + logger.Debug("│ Body: %s", string(bodyBytes)) + + // Try Anthropic→Bifrost transform; if it fails, forward raw body to Bifrost + converted, err := transform.AnthropicToBifrost(bodyBytes) + useConverted := err == nil + if !useConverted { + logger.Debug("│ ⚠️ Not valid Anthropic format (%v) → passthrough", err) + converted = bodyBytes + } else { + // ── LOG: Converted Bifrost Request ── + logger.Debug("│ ---> CONVERTED TO OPENAI/BIFROST:") + logger.Debug("│ %s", string(converted)) + } + + // Forward to Bifrost — always use /v1/chat/completions, Bifrost handles routing + targetURL := strings.TrimRight(cfg.OpenAIBackend, "/") + "/v1/chat/completions" + logger.Debug("│ 🚀 Forwarding to: %s", targetURL) + + // Streaming path: if original request had stream=true, handle SSE transform + if isStreamRequest(bodyBytes) { + return handleStreaming(c, targetURL, reqHeaders, converted, cfg, db, bodyBytes, start) + } + + result, err := proxy.Forward( + c.Context(), + c.Method(), + targetURL, + reqHeaders, + bytes.NewReader(converted), + cfg.RequestTimeoutSeconds, + ) + + latency := time.Since(start).Milliseconds() + statusCode := 502 + if err == nil { + statusCode = result.StatusCode + } + + // DB logging + if db != nil { + logEntry := &models.RequestLog{ + Endpoint: c.OriginalURL(), + Method: c.Method(), + ClientIP: c.IP(), + RequestBody: models.TruncateBody(string(bodyBytes)), + ResponseStatus: statusCode, + LatencyMs: latency, + } + go db.Create(logEntry) //nolint:errcheck + } + + if err != nil { + logger.Warn("│ ❌ UPSTREAM ERROR: %v", err) + logger.Warn("└─ [ANTHROPIC] <<< 502 (%dms)", latency) + return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{ + "error": fmt.Sprintf("upstream error: %v", err), + }) + } + + // ── LOG: Raw Bifrost Response ── + logger.Debug("│ <--- BIFROST RESPONSE (%d bytes, status %d):", len(result.Body), result.StatusCode) + logger.Debug("│ %s", string(result.Body)) + + // If request was Anthropic format, convert response back to Anthropic + if useConverted && result.StatusCode == 200 { + antBody, terr := transform.BifrostToAnthropic(result.Body) + if terr == nil { + // ── LOG: Final Anthropic Response ── + logger.Debug("│ <<< CONVERTED TO ANTHROPIC:") + logger.Debug("│ %s", string(antBody)) + logger.Info("└─ [ANTHROPIC] <<< 200 OK (%dms)", latency) + + c.Set("Content-Type", "application/json") + return c.Status(result.StatusCode).Send(antBody) + } + logger.Warn("│ ⚠️ RESPONSE TRANSFORM FAILED: %v (forwarding raw)", terr) + } + + logger.Info("└─ [ANTHROPIC] <<< %d (passthrough, %dms)", result.StatusCode, latency) + copyResponseHeaders(c, result.Headers) + return c.Status(result.StatusCode).Send(result.Body) + } +} + +// modelsList returns the Anthropic-compatible models list, including the +// configured OPENAI_MODEL if set. +func modelsList(c fiber.Ctx, cfg *config.Config) error { + type ModelData struct { + ID string `json:"id"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + CreatedAt string `json:"created_at"` + } + models := []ModelData{} + if cfg.OpenAIModel != "" { + models = append(models, ModelData{ + ID: cfg.OpenAIModel, + Type: "model", + DisplayName: cfg.OpenAIModel, + CreatedAt: "2026-01-01T00:00:00Z", + }) + } + // Add fallback models if none configured + if len(models) == 0 { + models = []ModelData{ + {ID: "deepseek-v4-flash", Type: "model", DisplayName: "DeepSeek V4 Flash", CreatedAt: "2026-01-01T00:00:00Z"}, + {ID: "deepseek-v4-pro", Type: "model", DisplayName: "DeepSeek V4 Pro", CreatedAt: "2026-01-01T00:00:00Z"}, + } + } + response := map[string]interface{}{ + "data": models, + "has_more": false, + "first_id": models[0].ID, + "last_id": models[len(models)-1].ID, + } + body, _ := json.Marshal(response) + logger.Info("[ANTHROPIC] GET /v1/models -> 200 OK") + return c.Type("json").Send(body) +} + +// isStreamRequest checks if the original body has "stream": true. +func isStreamRequest(body []byte) bool { + var m map[string]interface{} + if json.Unmarshal(body, &m) != nil { + return false + } + if v, ok := m["stream"]; ok { + if b, ok := v.(bool); ok { + return b + } + } + return false +} + +// handleStreaming forwards a request to upstream with streaming enabled, +// transforms OpenAI SSE chunks into Anthropic SSE events, and streams +// the result back to the client via Fiber. +func handleStreaming( + c fiber.Ctx, + targetURL string, + headers http.Header, + body []byte, + cfg *config.Config, + db *gorm.DB, + originalBody []byte, + start time.Time, +) error { + // Enable streaming in the request body + var reqMap map[string]interface{} + if err := json.Unmarshal(body, &reqMap); err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("invalid body") + } + reqMap["stream"] = true + streamBody, _ := json.Marshal(reqMap) + + // Direct HTTP request to upstream (bypass proxy.Forward for streaming) + ctx, cancel := context.WithTimeout(c.Context(), time.Duration(cfg.RequestTimeoutSeconds)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(streamBody)) + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("build request failed") + } + for key, vals := range headers { + for _, v := range vals { + req.Header.Add(key, v) + } + } + req.Header.Set("Accept", "text/event-stream") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + logger.Warn("[ANTHROPIC] Stream error: %v", err) + return c.Status(fiber.StatusBadGateway).SendString("upstream error") + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + logger.Warn("[ANTHROPIC] Stream upstream %d: %s", resp.StatusCode, string(body)) + return c.Status(resp.StatusCode).Send(body) + } + + // Stream the SSE response + transformer := transform.NewStreamTransformer("", "") + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("Connection", "keep-alive") + + c.Response().SetBodyStreamWriter(func(w *bufio.Writer) { + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + chunk := []byte(strings.TrimPrefix(line, "data: ")) + event := transformer.TransformChunk(chunk) + if event != "" { + w.WriteString(event) + w.Flush() + } + } + // Ensure final events are sent + final := transformer.TransformChunk([]byte("[DONE]")) + if final != "" { + w.WriteString(final) + w.Flush() + } + }) + + // DB logging + latency := time.Since(start).Milliseconds() + if db != nil { + logEntry := &models.RequestLog{ + Endpoint: c.OriginalURL(), + Method: c.Method(), + ClientIP: c.IP(), + RequestBody: models.TruncateBody(string(originalBody)), + ResponseStatus: 200, + LatencyMs: latency, + } + go db.Create(logEntry) + } + logger.Info("└─ [ANTHROPIC] <<< 200 streaming (%dms)", latency) + return nil +} diff --git a/handlers/health.go b/handlers/health.go new file mode 100644 index 0000000..413b7a9 --- /dev/null +++ b/handlers/health.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" + + "optoant/config" +) + +// HealthResponse is the structure returned by the /health endpoint. +type HealthResponse struct { + Status string `json:"status"` + Database string `json:"database"` + Config struct { + OpenAIBackend string `json:"openai_backend"` + Port string `json:"port"` + } `json:"config"` +} + +// HealthHandler returns service status and basic config info. +// @Summary Health check +// @Description Returns service health status, database connectivity, and active configuration. +// @Tags health +// @Produce json +// @Success 200 {object} HealthResponse +// @Router /health [get] +func HealthHandler(cfg *config.Config, db *gorm.DB) fiber.Handler { + return func(c fiber.Ctx) error { + resp := HealthResponse{ + Status: "ok", + } + resp.Config.OpenAIBackend = cfg.OpenAIBackend + resp.Config.Port = cfg.Port + + // Check DB connectivity + if db != nil { + sqlDB, err := db.DB() + if err != nil || sqlDB.Ping() != nil { + resp.Database = "unreachable" + } else { + resp.Database = "ok" + } + } else { + resp.Database = "disabled" + } + + return c.JSON(resp) + } +} diff --git a/handlers/openai.go b/handlers/openai.go new file mode 100644 index 0000000..2fd9dea --- /dev/null +++ b/handlers/openai.go @@ -0,0 +1,156 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gofiber/fiber/v3" + "gorm.io/gorm" + + "optoant/config" + "optoant/internal/logger" + "optoant/internal/proxy" + "optoant/models" +) + +// OpenAIHandler handles all /v1/* requests and forwards them to OPENAI_BACKEND. +// @Summary OpenAI-compatible proxy +// @Description Forwards any /v1/* request to the configured OpenAI-compatible backend (e.g. DeepSeek). +// @Tags openai +// @Accept json +// @Produce json +// @Param path path string true "OpenAI API path (e.g. chat/completions)" +// @Success 200 {object} map[string]interface{} +// @Failure 502 {object} map[string]string +// @Router /v1/{path} [post] +func OpenAIHandler(cfg *config.Config, db *gorm.DB) fiber.Handler { + return func(c fiber.Ctx) error { + start := time.Now() + + // Build target URL: OPENAI_BACKEND + full original path + targetURL := strings.TrimRight(cfg.OpenAIBackend, "/") + c.OriginalURL() + + // Read body + bodyBytes := c.Body() + + // Inject default model from config if request body has no model field + if cfg.OpenAIModel != "" { + bodyBytes = injectDefaultModel(bodyBytes, cfg.OpenAIModel) + } + + // Convert fiber headers to net/http.Header + reqHeaders := fiberToHTTPHeaders(c) + + // Auto-inject API key from .env if client didn't provide one + if cfg.OpenAIApiKey != "" && reqHeaders.Get("Authorization") == "" { + reqHeaders.Set("Authorization", "Bearer "+cfg.OpenAIApiKey) + logger.Debug("│ 🔑 Auto-injected API key from OPENAI_KEY env") + } + + // ── LOG: Incoming OpenAI Request ── + logger.Info("┌─ [OPENAI] >>> %s %s | IP: %s", c.Method(), c.OriginalURL(), c.IP()) + logger.Debug("│ Headers: %v", proxy.MaskSensitiveHeaders(reqHeaders)) + logger.Debug("│ Body: %s", string(bodyBytes)) + logger.Debug("│ 🚀 Forwarding to: %s", targetURL) + + result, err := proxy.Forward( + c.Context(), + c.Method(), + targetURL, + reqHeaders, + bytes.NewReader(bodyBytes), + cfg.RequestTimeoutSeconds, + ) + + latency := time.Since(start).Milliseconds() + status := http.StatusBadGateway + if err == nil { + status = result.StatusCode + } + + // Log to DB (fire-and-forget style; don't fail the request if logging fails) + if db != nil { + logEntry := &models.RequestLog{ + Endpoint: c.OriginalURL(), + Method: c.Method(), + ClientIP: c.IP(), + RequestBody: models.TruncateBody(string(bodyBytes)), + ResponseStatus: status, + LatencyMs: latency, + } + go db.Create(logEntry) //nolint:errcheck + } + + if err != nil { + logger.Warn("│ ❌ UPSTREAM ERROR: %v", err) + logger.Warn("└─ [OPENAI] <<< 502 (%dms)", latency) + return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{ + "error": fmt.Sprintf("upstream error: %v", err), + }) + } + + // ── LOG: Upstream Response ── + logger.Debug("│ <--- UPSTREAM RESPONSE (%d bytes, status %d):", len(result.Body), result.StatusCode) + logger.Debug("│ %s", string(result.Body)) + logger.Info("└─ [OPENAI] <<< %d (%dms)", result.StatusCode, latency) + + // Copy response headers + copyResponseHeaders(c, result.Headers) + + return c.Status(result.StatusCode).Send(result.Body) + } +} + +// fiberToHTTPHeaders converts Fiber request headers to stdlib net/http.Header. +func fiberToHTTPHeaders(c fiber.Ctx) http.Header { + h := make(http.Header) + c.Request().Header.VisitAll(func(k, v []byte) { + h.Add(string(k), string(v)) + }) + return h +} + +// copyResponseHeaders writes upstream response headers back to the Fiber response. +func copyResponseHeaders(c fiber.Ctx, headers http.Header) { + skipHeaders := map[string]bool{ + "Transfer-Encoding": true, + "Content-Length": true, + "Connection": true, + } + for key, vals := range headers { + if skipHeaders[key] { + continue + } + for _, v := range vals { + c.Set(key, v) + } + } +} + +// injectDefaultModel forces the model field in a JSON body to the configured +// default model. This ensures Claude Code's model selection is overridden by +// the upstream model from .env (OPENAI_MODEL). +func injectDefaultModel(body []byte, defaultModel string) []byte { + if defaultModel == "" { + return body + } + var req map[string]interface{} + if err := json.Unmarshal(body, &req); err != nil { + return body + } + if req["model"] == defaultModel { + return body + } + oldModel := req["model"] + req["model"] = defaultModel + modified, err := json.Marshal(req) + if err != nil { + return body + } + logger.Info("│ 🔧 Model override: %v → %s", oldModel, defaultModel) + return modified +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..b26f93d --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,73 @@ +package logger + +import ( + "fmt" + "log" + "strings" +) + +// Level represents the logging verbosity level. +type Level int + +const ( + LevelDebug Level = iota + LevelInfo + LevelWarn +) + +var currentLevel = LevelInfo + +// SetLevel sets the global log level from a string: "debug", "info", or "warn". +func SetLevel(s string) { + switch strings.ToLower(s) { + case "debug": + currentLevel = LevelDebug + case "info": + currentLevel = LevelInfo + case "warn": + currentLevel = LevelWarn + default: + currentLevel = LevelInfo + } +} + +// Debug logs a message only when LOG_LEVEL=debug. +func Debug(format string, args ...interface{}) { + if currentLevel <= LevelDebug { + log.Printf(format, args...) + } +} + +// Info logs a message when LOG_LEVEL is debug or info. +func Info(format string, args ...interface{}) { + if currentLevel <= LevelInfo { + log.Printf(format, args...) + } +} + +// Warn logs a message at any log level (always visible). +func Warn(format string, args ...interface{}) { + if currentLevel <= LevelWarn { + log.Printf(format, args...) + } +} + +// Fatal logs and exits. Always visible regardless of level. +func Fatal(format string, args ...interface{}) { + log.Fatalf(format, args...) +} + +// formatKV formats key=value pairs for structured console output. +func formatKV(pairs ...interface{}) string { + if len(pairs)%2 != 0 { + return fmt.Sprint(pairs...) + } + var b strings.Builder + for i := 0; i < len(pairs); i += 2 { + if i > 0 { + b.WriteString(" | ") + } + b.WriteString(fmt.Sprintf("%v=%v", pairs[i], pairs[i+1])) + } + return b.String() +} diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go new file mode 100644 index 0000000..43af806 --- /dev/null +++ b/internal/proxy/proxy.go @@ -0,0 +1,103 @@ +package proxy + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "optoant/internal/logger" +) + +// ForwardResult holds the result of a forwarded request. +type ForwardResult struct { + StatusCode int + Body []byte + Headers http.Header +} + +// Forward proxies an HTTP request to targetURL, preserving method, headers, +// and body. Timeout is applied via context. Sensitive headers are forwarded +// but never logged. +func Forward(ctx context.Context, method, targetURL string, headers http.Header, body io.Reader, timeoutSec int) (*ForwardResult, error) { + if timeoutSec <= 0 { + timeoutSec = 30 + } + + ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, method, targetURL, body) + if err != nil { + return nil, fmt.Errorf("proxy: build request: %w", err) + } + + // Copy allowed headers — never modify; just forward as-is. + for key, vals := range headers { + lk := strings.ToLower(key) + // Skip hop-by-hop headers that would break the proxied connection. + switch lk { + case "connection", "te", "trailers", "transfer-encoding", "upgrade": + continue + } + for _, v := range vals { + req.Header.Add(key, v) + } + } + + // Read body for logging (then re-wrap for actual request) + bodyBytes, _ := io.ReadAll(body) + if len(bodyBytes) > 0 { + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + req.ContentLength = int64(len(bodyBytes)) + } + + logHeader := MaskSensitiveHeaders(req.Header) + logger.Debug("[PROXY] --> %s %s | Headers: %v | Body: %s", + method, targetURL, logHeader, truncateForLog(string(bodyBytes), 2000)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + logger.Warn("[PROXY] <-- ERROR from %s: %v", targetURL, err) + return nil, fmt.Errorf("proxy: upstream error: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("proxy: read response body: %w", err) + } + + logger.Debug("[PROXY] <-- RESPONSE %d from %s (%d bytes) | Body: %s", + resp.StatusCode, targetURL, len(respBody), truncateForLog(string(respBody), 2000)) + + return &ForwardResult{ + StatusCode: resp.StatusCode, + Body: respBody, + Headers: resp.Header, + }, nil +} + +// MaskSensitiveHeaders returns a copy of headers with Authorization values masked. +// Use this copy only for logging purposes. +func MaskSensitiveHeaders(h http.Header) http.Header { + masked := h.Clone() + if masked.Get("Authorization") != "" { + masked.Set("Authorization", "Bearer [REDACTED]") + } + if masked.Get("X-Api-Key") != "" { + masked.Set("X-Api-Key", "[REDACTED]") + } + return masked +} + +func truncateForLog(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "...[TRUNCATED]" +} diff --git a/internal/transform/anthropic_bifrost.go b/internal/transform/anthropic_bifrost.go new file mode 100644 index 0000000..bbd28a3 --- /dev/null +++ b/internal/transform/anthropic_bifrost.go @@ -0,0 +1,345 @@ +package transform + +import ( + "encoding/json" + "fmt" + "log" + "strings" +) + +// AnthropicRequest represents a standard Anthropic Messages API request. +type AnthropicRequest struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []AnthropicMessage `json:"messages"` + System interface{} `json:"system,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// AnthropicMessage is a single message in Anthropic format. +// Content can be a plain string or an array of content blocks. +type AnthropicMessage struct { + Role string `json:"role"` + Content interface{} `json:"content"` +} + +// extractTextContent converts the message content (string or array) to a plain string. +func extractTextContent(content interface{}) string { + if content == nil { + return "" + } + switch v := content.(type) { + case string: + return v + case []interface{}: + var parts []string + for _, block := range v { + if b, ok := block.(map[string]interface{}); ok { + if t, ok := b["text"].(string); ok { + parts = append(parts, t) + } + } + } + return strings.Join(parts, "\n") + default: + return fmt.Sprintf("%v", v) + } +} + +// BifrostRequest is the OpenAI-compatible format that Bifrost expects. +type BifrostRequest struct { + Model string `json:"model"` + Messages []BifrostMessage `json:"messages"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +// BifrostMessage mirrors OpenAI chat message format. +type BifrostMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// BifrostResponse is the OpenAI-compatible response from Bifrost. +type BifrostResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Choices []struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + Reasoning string `json:"reasoning"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + Index int `json:"index"` + } `json:"choices"` +} + +// getContent returns the message content, falling back to reasoning field +// when content is empty (DeepSeek V4 reasoning/thinking mode). +func (b BifrostResponse) getContent(idx int) string { + if idx < len(b.Choices) { + c := b.Choices[idx].Message.Content + if c != "" { + return c + } + return b.Choices[idx].Message.Reasoning + } + return "" +} + +// AnthropicResponse is returned to Anthropic-format clients. +type AnthropicResponse struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []AnthropicContent `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence,omitempty"` + Usage AnthropicUsage `json:"usage"` +} + +// AnthropicUsage mirrors the Anthropic usage block. +type AnthropicUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` +} + +// AnthropicContent is a content block in Anthropic response format. +type AnthropicContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// AnthropicToBifrost converts an Anthropic Messages API request body (raw JSON) +// to Bifrost / OpenAI-compatible format. +func AnthropicToBifrost(rawBody []byte) ([]byte, error) { + var antReq AnthropicRequest + if err := json.Unmarshal(rawBody, &antReq); err != nil { + return nil, fmt.Errorf("transform: parse anthropic request: %w", err) + } + + bfMessages := make([]BifrostMessage, 0, len(antReq.Messages)+1) + if sysContent := extractTextContent(antReq.System); sysContent != "" { + bfMessages = append(bfMessages, BifrostMessage{Role: "system", Content: sysContent}) + } + for _, m := range antReq.Messages { + bfMessages = append(bfMessages, BifrostMessage{Role: m.Role, Content: extractTextContent(m.Content)}) + } + + // Auto-prefix model with provider if missing provider/model format + model := antReq.Model + if !strings.Contains(model, "/") { + prefix := guessProvider(model) + model = prefix + "/" + model + log.Printf("[TRANSFORM] Model auto-prefixed: %s -> %s", antReq.Model, model) + } + + // Force stream=false — streaming SSE responses can't be transformed back to Anthropic format. + if antReq.Stream { + log.Printf("[TRANSFORM] Stream forced to false (SSE not supported for transform)") + } + + bfReq := BifrostRequest{ + Model: model, + Messages: bfMessages, + MaxTokens: antReq.MaxTokens, + Stream: false, + } + return json.Marshal(bfReq) +} + +// guessProvider maps model names to their likely provider prefix for Bifrost. +func guessProvider(model string) string { + lower := strings.ToLower(model) + switch { + case strings.Contains(lower, "deepseek"): + return "DeepSeek" + case strings.Contains(lower, "gpt") || strings.Contains(lower, "openai"): + return "OpenAI" + case strings.Contains(lower, "claude") || strings.Contains(lower, "anthropic"): + return "Anthropic" + case strings.Contains(lower, "gemini"): + return "Google" + default: + return "DeepSeek" + } +} + +// BifrostToAnthropic converts a Bifrost / OpenAI-compatible response body (raw JSON) +// back to Anthropic Messages API format. +func BifrostToAnthropic(rawBody []byte) ([]byte, error) { + var bfResp BifrostResponse + if err := json.Unmarshal(rawBody, &bfResp); err != nil { + return nil, fmt.Errorf("transform: parse bifrost response: %w", err) + } + + contents := make([]AnthropicContent, 0, len(bfResp.Choices)) + stopReason := "end_turn" + for i := range bfResp.Choices { + ch := bfResp.Choices[i] + contents = append(contents, AnthropicContent{Type: "text", Text: bfResp.getContent(i)}) + if ch.FinishReason != "" { + stopReason = ch.FinishReason + } + } + + antResp := AnthropicResponse{ + ID: bfResp.ID, + Type: "message", + Role: "assistant", + Content: contents, + Model: bfResp.Model, + StopReason: stopReason, + Usage: AnthropicUsage{ + InputTokens: bfResp.Usage.PromptTokens, + OutputTokens: bfResp.Usage.CompletionTokens, + }, + } + return json.Marshal(antResp) +} + +// ────────────────────────────────────────────────────────────── +// Streaming SSE transform: OpenAI chunks → Anthropic events +// ────────────────────────────────────────────────────────────── + +// StreamTransformer converts OpenAI streaming chunks to Anthropic SSE events. +type StreamTransformer struct { + msgID string + model string + started bool + blockOpened bool + finishSent bool +} + +// NewStreamTransformer creates a new streaming transformer. +func NewStreamTransformer(msgID, model string) *StreamTransformer { + return &StreamTransformer{msgID: msgID, model: model} +} + +// TransformChunk converts a single OpenAI SSE data chunk (JSON bytes) into +// one or more Anthropic SSE event strings. Returns empty string if the +// chunk produced no output. On [DONE], emits final events. +func (s *StreamTransformer) TransformChunk(raw []byte) string { + if string(raw) == "[DONE]" { + return s.finish() + } + + var chunk struct { + ID string `json:"id"` + Model string `json:"model"` + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason *string `json:"finish_reason"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &chunk); err != nil { + return "" + } + if len(chunk.Choices) == 0 { + return "" + } + + if chunk.ID != "" && chunk.Model != "" { + s.msgID = chunk.ID + s.model = chunk.Model + } + + var out strings.Builder + + // message_start on first chunk + if !s.started { + s.started = true + s.writeEvent(&out, "message_start", map[string]interface{}{ + "type": "message_start", + "message": map[string]interface{}{ + "id": s.msgID, + "type": "message", + "role": "assistant", + "model": s.model, + "content": []interface{}{}, + "usage": map[string]int{ + "input_tokens": 0, + "output_tokens": 0, + }, + }, + }) + } + + delta := chunk.Choices[0].Delta.Content + finish := chunk.Choices[0].FinishReason + + if delta != "" { + if !s.blockOpened { + s.blockOpened = true + s.writeEvent(&out, "content_block_start", map[string]interface{}{ + "type": "content_block_start", + "index": 0, + "content_block": map[string]interface{}{"type": "text", "text": ""}, + }) + } + s.writeEvent(&out, "content_block_delta", map[string]interface{}{ + "type": "content_block_delta", + "index": 0, + "delta": map[string]interface{}{"type": "text_delta", "text": delta}, + }) + } + + if finish != nil { + if s.blockOpened { + s.writeEvent(&out, "content_block_stop", map[string]interface{}{ + "type": "content_block_stop", + "index": 0, + }) + } + out.WriteString(s.finish()) + s.finishSent = true + } + + return out.String() +} + +func (s *StreamTransformer) finish() string { + if s.finishSent { + return "" + } + var out strings.Builder + if s.blockOpened { + s.writeEvent(&out, "content_block_stop", map[string]interface{}{ + "type": "content_block_stop", + "index": 0, + }) + } + s.writeEvent(&out, "message_delta", map[string]interface{}{ + "type": "message_delta", + "delta": map[string]interface{}{ + "stop_reason": "end_turn", + "stop_sequence": nil, + }, + "usage": map[string]int{"output_tokens": 0}, + }) + s.writeEvent(&out, "message_stop", map[string]interface{}{ + "type": "message_stop", + }) + s.finishSent = true + return out.String() +} + +func (s *StreamTransformer) writeEvent(out *strings.Builder, event string, data interface{}) { + out.WriteString("event: ") + out.WriteString(event) + out.WriteString("\ndata: ") + body, _ := json.Marshal(data) + out.Write(body) + out.WriteString("\n\n") +} diff --git a/log.md b/log.md new file mode 100644 index 0000000..df8d280 --- /dev/null +++ b/log.md @@ -0,0 +1,11 @@ + Üç seviye var: + + ┌───────────────────┬──────────────────────────────────────────────────┐ + │ LOG_LEVEL │ Gösterilen │ + ├───────────────────┼──────────────────────────────────────────────────┤ + │ debug │ Her şey — full body, headers, dönüşüm detayları │ + ├───────────────────┼──────────────────────────────────────────────────┤ + │ info (varsayılan) │ Özet — method, path, status, latency (tek satır) │ + ├───────────────────┼──────────────────────────────────────────────────┤ + │ warn │ Sadece hatalar │ + └───────────────────┴──────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..ed3ef84 --- /dev/null +++ b/main.go @@ -0,0 +1,168 @@ +// Package main is the entry point for the LLM Gateway / Proxy service. +// +// @title LLM Gateway API +// @version 1.0 +// @description OpenAI-compatible and Anthropic-compatible LLM proxy/gateway with Bifrost mapping. +// @termsOfService http://swagger.io/terms/ +// +// @contact.name optoant +// @contact.email admin@optoant.local +// +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT +// +// @host localhost:8000 +// @BasePath / +// +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +package main + +import ( + "fmt" + "log" + "os" + "path/filepath" + + "github.com/gofiber/fiber/v3" + "github.com/joho/godotenv" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "optoant/config" + "optoant/handlers" + "optoant/internal/logger" + "optoant/models" +) + +func main() { + // Load .env file if present (silently ignore if missing) + _ = godotenv.Load() + + cfg := config.Load() + + // Logger verbosity from LOG_LEVEL env (debug, info, warn) + logger.SetLevel(cfg.LogLevel) + + // ------------------------------------------------------------------ + // Database setup (GORM — PostgreSQL or SQLite based on DB_MODE) + // ------------------------------------------------------------------ + var db *gorm.DB + if cfg.PostgresDSN != "" || cfg.DBMode == "sqlite" { + var dialector gorm.Dialector + dbEngine := "postgres" + + switch cfg.DBMode { + case "sqlite": + dsn := cfg.DBPath + if dsn == "" { + dsn = "data/gateway.db" + } + if err := os.MkdirAll(filepath.Dir(dsn), 0755); err != nil { + log.Printf("⚠️ Failed to create DB directory: %v", err) + } + dialector = sqlite.Open(dsn) + dbEngine = "sqlite" + default: + dialector = postgres.Open(cfg.PostgresDSN) + } + + var err error + db, err = gorm.Open(dialector, &gorm.Config{ + Logger: gormlogger.Default.LogMode(gormlogger.Warn), + }) + if err != nil { + log.Printf("⚠️ DB connection failed (continuing without DB): %v", err) + db = nil + } else { + if err := db.AutoMigrate(&models.RequestLog{}); err != nil { + log.Printf("⚠️ AutoMigrate failed: %v", err) + } else { + log.Printf("✅ Database connected and migrated (%s)", dbEngine) + } + } + } else { + log.Println("ℹ️ No POSTGRES_DSN/DATABASE_DSN set — running without database logging") + } + + // ------------------------------------------------------------------ + // Fiber v3 application + // ------------------------------------------------------------------ + app := fiber.New(fiber.Config{ + AppName: "LLM Gateway v1.0", + ServerHeader: "optoant-gateway", + }) + + // ------------------------------------------------------------------ + // Swagger UI at /swagger/* — Fiber v3 native, no v2 dependency + // Swagger UI is loaded from CDN; spec is served from /swagger/swagger.json + // ------------------------------------------------------------------ + app.Get("/swagger/swagger.json", func(c fiber.Ctx) error { + return c.SendFile("./docs/swagger.json") + }) + app.Get("/swagger", func(c fiber.Ctx) error { + return c.Redirect().To("/swagger/") + }) + app.Get("/swagger/*", func(c fiber.Ctx) error { + html := ` + + + + LLM Gateway — Swagger UI + + + +
+ + + + +` + return c.Type("html").SendString(html) + }) + + // ------------------------------------------------------------------ + // Routes + // ------------------------------------------------------------------ + + // Health check + app.Get("/health", handlers.HealthHandler(cfg, db)) + + // OpenAI-compatible: forward all /v1/* methods to OPENAI_BACKEND + app.All("/v1/*", handlers.OpenAIHandler(cfg, db)) + + // Anthropic-compatible: forward all /anthropic/* to backend or Bifrost + app.All("/anthropic", handlers.AnthropicHandler(cfg, db)) + app.All("/anthropic/*", handlers.AnthropicHandler(cfg, db)) + + // ------------------------------------------------------------------ + // Start server + // ------------------------------------------------------------------ + addr := fmt.Sprintf(":%s", cfg.Port) + log.Printf("🚀 LLM Gateway starting on %s", addr) + log.Printf(" Log level : %s", cfg.LogLevel) + log.Printf(" Upstream backend: %s", cfg.OpenAIBackend) + log.Printf(" OpenAI endpoint : /v1/* (direct passthrough)") + log.Printf(" Anthropic endpoint: /anthropic/* (Anthropic↔OpenAI transform)") + if db != nil { + log.Printf(" DB logging : enabled") + } + + if err := app.Listen(addr); err != nil { + log.Fatalf("server error: %v", err) + os.Exit(1) + } +} diff --git a/models/request_log.go b/models/request_log.go new file mode 100644 index 0000000..4fea242 --- /dev/null +++ b/models/request_log.go @@ -0,0 +1,32 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +const maxBodyLength = 2000 + +// RequestLog stores a minimal record of every proxied request. +// Sensitive headers (e.g. Authorization) are NEVER stored verbatim. +type RequestLog struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + Endpoint string `gorm:"size:512" json:"endpoint"` + Method string `gorm:"size:16" json:"method"` + ClientIP string `gorm:"size:64" json:"client_ip"` + RequestBody string `gorm:"type:text" json:"request_body"` + ResponseStatus int `json:"response_status"` + LatencyMs int64 `json:"latency_ms"` +} + +// TruncateBody returns at most maxBodyLength characters from body. +func TruncateBody(body string) string { + if len(body) <= maxBodyLength { + return body + } + return body[:maxBodyLength] + "…[truncated]" +} diff --git a/tests/anthropic_test.go b/tests/anthropic_test.go new file mode 100644 index 0000000..b0f630b --- /dev/null +++ b/tests/anthropic_test.go @@ -0,0 +1,650 @@ +package tests + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + + "optoant/config" + "optoant/handlers" + "optoant/internal/transform" +) + +// ────────────────────────────────────────────── +// Transform Unit Tests — AnthropicToBifrost +// ────────────────────────────────────────────── + +func TestAnthropicToBifrost_Basic(t *testing.T) { + antBody := `{"model":"claude-3-5-sonnet","max_tokens":1024,"messages":[{"role":"user","content":"Hello!"}]}` + out, err := transform.AnthropicToBifrost([]byte(antBody)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var bfReq struct { + Model string `json:"model"` + MaxTokens int `json:"max_tokens"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(out, &bfReq); err != nil { + t.Fatalf("invalid bifrost json: %v", err) + } + if bfReq.Model != "Anthropic/claude-3-5-sonnet" { + t.Errorf("expected Anthropic/claude-3-5-sonnet, got %s", bfReq.Model) + } + if bfReq.MaxTokens != 1024 { + t.Errorf("expected 1024, got %d", bfReq.MaxTokens) + } + if len(bfReq.Messages) != 1 || bfReq.Messages[0].Role != "user" { + t.Errorf("unexpected messages: %+v", bfReq.Messages) + } +} + +func TestAnthropicToBifrost_WithSystem(t *testing.T) { + antBody := `{"model":"claude-3","system":"You are helpful.","messages":[{"role":"user","content":"Hi"}]}` + out, err := transform.AnthropicToBifrost([]byte(antBody)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var bfReq struct { + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + json.Unmarshal(out, &bfReq) + if len(bfReq.Messages) != 2 { + t.Fatalf("expected 2 messages (system+user), got %d", len(bfReq.Messages)) + } + if bfReq.Messages[0].Role != "system" || bfReq.Messages[0].Content != "You are helpful." { + t.Errorf("system message missing or wrong: %+v", bfReq.Messages[0]) + } + if bfReq.Messages[1].Role != "user" || bfReq.Messages[1].Content != "Hi" { + t.Errorf("user message wrong: %+v", bfReq.Messages[1]) + } +} + +func TestAnthropicToBifrost_ModelPrefix(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"claude-3-5-sonnet", "Anthropic/claude-3-5-sonnet"}, + {"deepseek-v4-flash", "DeepSeek/deepseek-v4-flash"}, + {"gpt-4", "OpenAI/gpt-4"}, + {"gemini-pro", "Google/gemini-pro"}, + {"unknown-model", "DeepSeek/unknown-model"}, + {"Anthropic/claude-3", "Anthropic/claude-3"}, + {"DeepSeek/deepseek-v4", "DeepSeek/deepseek-v4"}, + } + for _, tc := range tests { + body := `{"model":"` + tc.input + `","messages":[{"role":"user","content":"Hi"}]}` + out, err := transform.AnthropicToBifrost([]byte(body)) + if err != nil { + t.Fatalf("error for model %s: %v", tc.input, err) + } + var bfReq struct { + Model string `json:"model"` + } + json.Unmarshal(out, &bfReq) + if bfReq.Model != tc.expected { + t.Errorf("model %s: expected %s, got %s", tc.input, tc.expected, bfReq.Model) + } + } +} + +func TestAnthropicToBifrost_StreamPassthrough(t *testing.T) { + body := `{"model":"claude-3","stream":true,"messages":[{"role":"user","content":"Hi"}]}` + out, err := transform.AnthropicToBifrost([]byte(body)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var bfReq struct { + Stream bool `json:"stream"` + } + json.Unmarshal(out, &bfReq) + if !bfReq.Stream { + t.Error("expected stream=true to be preserved") + } +} + +func TestAnthropicToBifrost_InvalidJSON(t *testing.T) { + _, err := transform.AnthropicToBifrost([]byte(`not json`)) + if err == nil { + t.Fatal("expected error for invalid json") + } +} + +// ────────────────────────────────────────────── +// Transform Unit Tests — BifrostToAnthropic +// ────────────────────────────────────────────── + +func TestBifrostToAnthropic_Basic(t *testing.T) { + bfBody := `{"id":"chatcmpl-abc","object":"chat.completion","created":1700000000,"model":"Anthropic/claude-3","choices":[{"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop","index":0}]}` + out, err := transform.BifrostToAnthropic([]byte(bfBody)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var antResp struct { + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Model string `json:"model"` + StopReason string `json:"stop_reason"` + } + json.Unmarshal(out, &antResp) + if antResp.Type != "message" { + t.Errorf("expected type=message, got %s", antResp.Type) + } + if antResp.Role != "assistant" { + t.Errorf("expected role=assistant, got %s", antResp.Role) + } + if len(antResp.Content) != 1 || antResp.Content[0].Text != "Hello!" { + t.Errorf("unexpected content: %+v", antResp.Content) + } + if antResp.StopReason != "stop" { + t.Errorf("expected stop_reason=stop, got %s", antResp.StopReason) + } + if antResp.ID != "chatcmpl-abc" { + t.Errorf("expected id to pass through, got %s", antResp.ID) + } +} + +func TestBifrostToAnthropic_EmptyFinishReason(t *testing.T) { + bfBody := `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Done"},"finish_reason":"","index":0}]}` + out, err := transform.BifrostToAnthropic([]byte(bfBody)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var antResp struct { + StopReason string `json:"stop_reason"` + } + json.Unmarshal(out, &antResp) + if antResp.StopReason != "end_turn" { + t.Errorf("expected end_turn for empty finish_reason, got %s", antResp.StopReason) + } +} + +func TestBifrostToAnthropic_MultipleChoices(t *testing.T) { + bfBody := `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[ + {"message":{"role":"assistant","content":"First"},"finish_reason":"stop","index":0}, + {"message":{"role":"assistant","content":"Second"},"finish_reason":"stop","index":1} + ]}` + out, err := transform.BifrostToAnthropic([]byte(bfBody)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var antResp struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + json.Unmarshal(out, &antResp) + if len(antResp.Content) != 2 { + t.Fatalf("expected 2 content blocks, got %d", len(antResp.Content)) + } + if antResp.Content[0].Text != "First" || antResp.Content[1].Text != "Second" { + t.Errorf("unexpected content order: %+v", antResp.Content) + } +} + +func TestBifrostToAnthropic_InvalidJSON(t *testing.T) { + _, err := transform.BifrostToAnthropic([]byte(`not json`)) + if err == nil { + t.Fatal("expected error for invalid json") + } +} + +// ────────────────────────────────────────────── +// AnthropicHandler Integration Tests +// ────────────────────────────────────────────── + +func newAnthropicTestApp(cfg *config.Config) *fiber.App { + app := fiber.New() + app.All("/anthropic", handlers.AnthropicHandler(cfg, nil)) + app.All("/anthropic/*", handlers.AnthropicHandler(cfg, nil)) + return app +} + +func TestAnthropicProxy_Success(t *testing.T) { + upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-abc","object":"chat.completion","created":1700000000,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Hello from Bifrost!"},"finish_reason":"stop","index":0}]}`) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + } + app := newAnthropicTestApp(cfg) + + payload := `{"model":"claude-3-5-sonnet","max_tokens":1024,"messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + + if result["type"] != "message" { + t.Errorf("expected type=message, got %v", result["type"]) + } + if result["role"] != "assistant" { + t.Errorf("expected role=assistant, got %v", result["role"]) + } + + content, ok := result["content"].([]interface{}) + if !ok || len(content) == 0 { + t.Fatalf("expected content array, got: %v", result["content"]) + } + firstBlock := content[0].(map[string]interface{}) + if firstBlock["type"] != "text" { + t.Errorf("expected text content type, got %v", firstBlock["type"]) + } + if firstBlock["text"] != "Hello from Bifrost!" { + t.Errorf("expected Hello from Bifrost!, got %v", firstBlock["text"]) + } +} + +func TestAnthropicProxy_InvalidFormatPassthrough(t *testing.T) { + upstream := mockUpstream(t, http.StatusOK, `{"raw":"response"}`) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + } + app := newAnthropicTestApp(cfg) + + payload := `this is not valid json` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 (passthrough), got %d", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "raw") { + t.Errorf("expected raw response body, got: %s", string(body)) + } +} + +func TestAnthropicProxy_UpstreamError(t *testing.T) { + cfg := &config.Config{ + OpenAIBackend: "http://127.0.0.1:19999", + RequestTimeoutSeconds: 2, + } + app := newAnthropicTestApp(cfg) + + payload := `{"model":"claude-3","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("expected 502, got %d", resp.StatusCode) + } +} + +func TestAnthropicProxy_ModelsList(t *testing.T) { + cfg := &config.Config{OpenAIBackend: "http://127.0.0.1:19999", RequestTimeoutSeconds: 1} + app := newAnthropicTestApp(cfg) + + req := httptest.NewRequest(http.MethodGet, "/anthropic/v1/models", nil) + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + data, ok := result["data"].([]interface{}) + if !ok || len(data) == 0 { + t.Fatalf("expected data array, got: %v", result["data"]) + } + + first := data[0].(map[string]interface{}) + if first["id"] != "deepseek-v4-flash" { + t.Errorf("expected deepseek-v4-flash as first model, got %v", first["id"]) + } +} + +func TestAnthropicProxy_EmptyBody(t *testing.T) { + cfg := &config.Config{OpenAIBackend: "http://127.0.0.1:19999", RequestTimeoutSeconds: 1} + app := newAnthropicTestApp(cfg) + + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", nil) + req.Header.Set("Authorization", "Bearer test-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + + if result["type"] != "message" { + t.Errorf("expected type=message, got %v", result["type"]) + } +} + +func TestAnthropicProxy_HEAD(t *testing.T) { + cfg := &config.Config{OpenAIBackend: "http://127.0.0.1:19999", RequestTimeoutSeconds: 1} + app := newAnthropicTestApp(cfg) + + req := httptest.NewRequest(http.MethodHead, "/anthropic/v1/messages", nil) + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 for HEAD, got %d", resp.StatusCode) + } +} + +func TestAnthropicProxy_xApiKeyConversion(t *testing.T) { + upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Auth ok"},"finish_reason":"stop","index":0}]}`) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + } + app := newAnthropicTestApp(cfg) + + payload := `{"model":"claude-3","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", "my-secret-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + json.NewDecoder(resp.Body).Decode(&result) + content := result["content"].([]interface{}) + first := content[0].(map[string]interface{}) + if first["text"] != "Auth ok" { + t.Errorf("expected response with text, got %v", first["text"]) + } +} + +// ────────────────────────────────────────────── +// Claude Code Simulation Tests +// ────────────────────────────────────────────── + +// TestClaudeCode_ExactRequestFormat simulates what Claude Code CLI sends: +// - POST /v1/messages (via /anthropic/v1/messages) +// - x-api-key header (NOT Authorization) +// - anthropic-version header +// - Exact Anthropic Messages API format with model: "claude-sonnet-4-20250514" +func TestClaudeCode_ExactRequestFormat(t *testing.T) { + upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-cc","object":"chat.completion","created":1700000001,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Hello from Bifrost! I am connected through the LLM Gateway."},"finish_reason":"stop","index":0}]}`) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + } + app := newAnthropicTestApp(cfg) + + // Exact format Claude Code sends: + // https://docs.anthropic.com/en/api/messages + payload := `{"model":"claude-sonnet-4-20250514","max_tokens":8192,"stream":false,"messages":[{"role":"user","content":"Hello, can you help me with a coding task?"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", "sk-ant-my-claude-code-key") + req.Header.Set("Anthropic-Version", "2023-06-01") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + + // Verify Anthropic Messages API response shape + if result["type"] != "message" { + t.Errorf("Claude Code expects type=message, got %v", result["type"]) + } + if result["role"] != "assistant" { + t.Errorf("Claude Code expects role=assistant, got %v", result["role"]) + } + + content, ok := result["content"].([]interface{}) + if !ok || len(content) == 0 { + t.Fatalf("Claude Code expects content array, got: %v", result["content"]) + } + firstBlock := content[0].(map[string]interface{}) + if firstBlock["type"] != "text" { + t.Errorf("expected text block, got %v", firstBlock["type"]) + } + if !strings.Contains(firstBlock["text"].(string), "Hello from Bifrost!") { + t.Errorf("expected Bifrost greeting, got: %v", firstBlock["text"]) + } + + // Verify model was auto-prefixed correctly + upstreamReqBody := upstreamResponseBody(t, req, app) + if strings.Contains(upstreamReqBody, `"model":"Anthropic/claude-sonnet-4-20250514"`) { + t.Logf("✓ Model auto-prefixed to Anthropic/claude-sonnet-4-20250514") + } +} + +// upstreamResponseBody captures what the mock upstream received. +// Only used in TestClaudeCode_ExactRequestFormat for verifying the transformed request. +func upstreamResponseBody(t *testing.T, originalReq *http.Request, app *fiber.App) string { + t.Helper() + resp, err := app.Test(originalReq, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return string(body) +} + +func TestClaudeCode_MaxTokensDefault(t *testing.T) { + // Claude Code doesn't always send max_tokens (SDK has defaults) + upstream := mockUpstream(t, http.StatusOK, `{"id":"chatcmpl-cc2","object":"chat.completion","created":1700000002,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"Response without max_tokens"},"finish_reason":"end_turn","index":0}]}`) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + } + app := newAnthropicTestApp(cfg) + + // No max_tokens - Claude Code doesn't always send it + payload := `{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", "sk-ant-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} + +// ────────────────────────────────────────────── +// Default Model Injection Tests +// ────────────────────────────────────────────── + +func TestAnthropicProxy_DefaultModelInjection(t *testing.T) { + var capturedBody string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop","index":0}]}`) + })) + t.Cleanup(upstream.Close) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + OpenAIModel: "deepseek/deepseek-v4-pro", + } + app := newAnthropicTestApp(cfg) + + // No model in payload — should be injected + payload := `{"max_tokens":256,"messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + // Verify the injected model was passed through as-is (already has "/") + var bfReq struct { + Model string `json:"model"` + } + json.Unmarshal([]byte(capturedBody), &bfReq) + expected := "deepseek/deepseek-v4-pro" + if bfReq.Model != expected { + t.Errorf("expected model=%q (injected as-is, no prefix needed), got %q", expected, bfReq.Model) + } +} + +func TestAnthropicProxy_DefaultModelInjection_NopWhenModelExists(t *testing.T) { + var capturedBody string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","created":1,"model":"DeepSeek/deepseek-v4","choices":[{"message":{"role":"assistant","content":"ok"},"finish_reason":"stop","index":0}]}`) + })) + t.Cleanup(upstream.Close) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + OpenAIModel: "deepseek/deepseek-v4-pro", + } + app := newAnthropicTestApp(cfg) + + // Model already set — should NOT be overridden + payload := `{"model":"claude-3","max_tokens":256,"messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var bfReq struct { + Model string `json:"model"` + } + json.Unmarshal([]byte(capturedBody), &bfReq) + expected := "Anthropic/claude-3" + if bfReq.Model != expected { + t.Errorf("expected model=%q (preserved + prefixed), got %q", expected, bfReq.Model) + } +} + +func TestAnthropicProxy_TransformErrorFallsbackToPassthrough(t *testing.T) { + bfBody := `this is not a valid JSON response either` + upstream := mockUpstream(t, http.StatusOK, bfBody) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + } + app := newAnthropicTestApp(cfg) + + payload := `{"model":"claude-3","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/v1/messages", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if !strings.Contains(string(body), "not a valid JSON") { + t.Errorf("expected raw fallback body, got: %s", string(body)) + } +} diff --git a/tests/openai_test.go b/tests/openai_test.go new file mode 100644 index 0000000..af08a9b --- /dev/null +++ b/tests/openai_test.go @@ -0,0 +1,178 @@ +package tests + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + + "optoant/config" + "optoant/handlers" +) + +// mockUpstream starts a test HTTP server that always responds with the given +// status code and body. +func mockUpstream(t *testing.T, status int, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) + t.Cleanup(srv.Close) + return srv +} + +// newTestApp creates a Fiber app wired to an OpenAI handler pointing at mockURL. +func newTestApp(cfg *config.Config) *fiber.App { + app := fiber.New() + app.All("/v1/*", handlers.OpenAIHandler(cfg, nil)) + return app +} + +// TestOpenAIProxy_Success verifies that a valid upstream response is forwarded correctly. +func TestOpenAIProxy_Success(t *testing.T) { + upstream := mockUpstream(t, http.StatusOK, `{"choices":[{"message":{"role":"assistant","content":"Hello!"}}]}`) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + } + app := newTestApp(cfg) + + payload := `{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + choices, ok := result["choices"].([]interface{}) + if !ok || len(choices) == 0 { + t.Errorf("expected choices in response, got: %v", result) + } +} + +// TestOpenAIProxy_DefaultModelInjection verifies that OPENAI_MODEL is injected +// when the request body has no model field. +func TestOpenAIProxy_DefaultModelInjection(t *testing.T) { + var capturedBody string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`) + })) + t.Cleanup(upstream.Close) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + OpenAIModel: "deepseek/deepseek-v4-pro", + } + app := newTestApp(cfg) + + // No model in payload — should be injected + payload := `{"messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + json.Unmarshal([]byte(capturedBody), &result) + if result["model"] != cfg.OpenAIModel { + t.Errorf("expected model=%q, got %q", cfg.OpenAIModel, result["model"]) + } +} + +// TestOpenAIProxy_DefaultModelInjection_ExistingModel verifies that an existing +// model in the request body is NOT overridden by OPENAI_MODEL. +func TestOpenAIProxy_DefaultModelInjection_ExistingModel(t *testing.T) { + var capturedBody string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedBody = string(body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"ok"}}]}`) + })) + t.Cleanup(upstream.Close) + + cfg := &config.Config{ + OpenAIBackend: upstream.URL, + RequestTimeoutSeconds: 5, + OpenAIModel: "deepseek/deepseek-v4-pro", + } + app := newTestApp(cfg) + + // Model already set — should NOT be overridden + payload := `{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var result map[string]interface{} + json.Unmarshal([]byte(capturedBody), &result) + if result["model"] != "gpt-4" { + t.Errorf("expected model=gpt-4 (preserved), got %q", result["model"]) + } +} + +// TestOpenAIProxy_UpstreamError verifies that a 502 is returned when upstream fails. +func TestOpenAIProxy_UpstreamError(t *testing.T) { + // Point to an address that should refuse connection + cfg := &config.Config{ + OpenAIBackend: "http://127.0.0.1:19999", // nothing listening + RequestTimeoutSeconds: 2, + } + app := newTestApp(cfg) + + payload := `{"model":"gpt-4","messages":[{"role":"user","content":"Hi"}]}` + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req, fiber.TestConfig{Timeout: -1}) + if err != nil { + t.Fatalf("app.Test error: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusBadGateway { + t.Errorf("expected 502, got %d", resp.StatusCode) + } +} diff --git a/wiki_schema.md b/wiki_schema.md new file mode 100644 index 0000000..0de2f5c --- /dev/null +++ b/wiki_schema.md @@ -0,0 +1,18 @@ +# OTONOM WIKI VE MIMARI HAFIZA KURALLARI + +Sen bu projenin Baş Mimarı ve hafıza yöneticisisin. Görevin, codebase'i okuyarak `/docs/wiki` klasöründe Obsidian formatında bir Bilgi Grafiği (Knowledge Graph) oluşturmak ve güncel tutmaktır. + +## 1. Temel Kurallar +- `/docs/wiki` klasörü senin hafızandır. Sadece `.md` formatında dosyalar üreteceksin. +- ASLA kodu değiştirme veya silme (Aksi belirtilmedikçe). Sadece analiz et ve Wiki'ye yaz. +- Yeni bir dosya/kavram oluşturduğunda MUTLAKA köşeli parantez ile Obsidian linki ver. (Örn: `[[Supabase_Client]]`, `[[Auth_Flow]]`) + +## 2. Node (Dosya) Formatı +Oluşturduğun her Wiki sayfasının en üstünde şunlar ZORUNLUDUR: +- **Özet:** Modülün ne yaptığını anlatan maksimum 3 cümlelik net bir açıklama. +- **Kütüphaneler:** Kullanılan temel teknolojiler (Örn: Supabase, Tailwind). +- **Bağlantılar:** İlgili UI bileşenlerine mutlaka link ver (Örn: `[[Navbar]]`, `[[Sidebar]]`). + +## 3. Operasyonlar +- **INGEST:** Tüm projeyi veya son değişiklikleri tara, mimariyi anla ve `/docs/wiki` içine yeni dosyalar yazarak birbirine bağla. Her Ingest sonrası `[[Index.md]]` dosyasını ana harita olarak güncelle. +- **QUERY:** Benden yeni bir mimari plan/özellik istendiğinde, kodu taramak yerine ÖNCE `/docs/wiki/Index.md`'ye git, ilgili Wiki dosyalarını oku ve ona göre plan çıkar. \ No newline at end of file diff --git a/yap.md b/yap.md new file mode 100644 index 0000000..ec38980 --- /dev/null +++ b/yap.md @@ -0,0 +1,12 @@ +tam olarak istedigim sey ornek +base_url (OpenAI) https://api.deepseek.com burda yapildigi gibi openai apisini +base_url (Anthropic) https://api.deepseek.com/anthropic burda oldugu gibi anthropic apisine cevirmek +ben bifrost llm gateway da yapacagim http://10.80.80.70:8080/v1 bunu http://localhost:8000/anthropic e cevirecek +web server olarak go get github.com/gofiber/fiber/v3 +database olarak postgres kullanacagim go get -u gorm.io/gorm go get -u gorm.io/driver/postgres +swagger ui fiber v3 ile native html uzerinden sunuluyor, harici swagger paketi gerekmiyor + +ihtiyacin olan her seyi kullanabilirsin + + +http://localhost:8000/anthropic/v1/messages \ No newline at end of file