first commit

This commit is contained in:
Beyhan Ogur
2026-05-11 15:08:50 +03:00
commit a408821410
47 changed files with 4670 additions and 0 deletions
+58
View File
@@ -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
+8
View File
@@ -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.
+5
View File
@@ -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
+14
View File
@@ -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
+228
View File
@@ -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/<you>/<repo>`
- `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.
+191
View File
@@ -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
Executable
+16
View File
@@ -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}"
Executable
+73
View File
@@ -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 "$@"
Executable
+175
View File
@@ -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
+65
View File
@@ -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,
}
}
BIN
View File
Binary file not shown.
+34
View File
@@ -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"]
+38
View File
@@ -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:
+189
View File
@@ -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)
}
+165
View File
@@ -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"
}
}
}
+204
View File
@@ -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"
}
}
}
+114
View File
@@ -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"
+49
View File
@@ -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: <api-key>
# veya
Authorization: Bearer <api-key>
```
## 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
+46
View File
@@ -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 <BACKEND>/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 |
+61
View File
@@ -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) | — |
+95
View File
@@ -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="<upstream-api-key>"
claude
```
### 2. Claude Code Settings JSON
`~/.claude/settings.json`:
```json
{
"anthropicBaseUrl": "http://localhost:8000/anthropic",
"apiKey": "<upstream-api-key>"
}
```
## 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`
+30
View File
@@ -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
+60
View File
@@ -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
```
+29
View File
@@ -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) |
+86
View File
@@ -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)
```
+36
View File
@@ -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`
+36
View File
@@ -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 <api-key>
```
## 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)
+38
View File
@@ -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 <OPENAI_BACKEND>/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
+41
View File
@@ -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
+33
View File
@@ -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
+40
View File
@@ -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)
+71
View File
@@ -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)
+55
View File
@@ -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
)
+140
View File
@@ -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=
+352
View File
@@ -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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Anthropic API — optoant</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 720px; margin: 60px auto; padding: 0 20px; color: #222; }
code { background: #f0f0f0; padding: 2px 6px; border-radius: 4px; }
pre { background: #1e1e1e; color: #d4d4d4; padding: 16px; border-radius: 8px; overflow-x: auto; }
.ok { color: #22c55e; }
.url { color: #6366f1; }
</style>
</head>
<body>
<h1>Anthropic API — <span class="ok">aktif</span></h1>
<p>Bu endpoint Anthropic Messages API formatını kabul eder, OpenAI formatına çevirir ve upstream'e iletir.</p>
<h3>Endpoint</h3>
<code>POST http://%s/anthropic/v1/messages</code>
<h3>Upstream</h3>
<code class="url">%s/v1/chat/completions</code>
<h3>Örnek curl</h3>
<pre>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!"}]
}'</pre>
<p><small>Swagger UI: <a href="/swagger/">/swagger/</a> | Health: <a href="/health">/health</a></small></p>
</body>
</html>`, 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
}
+49
View File
@@ -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)
}
}
+156
View File
@@ -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
}
+73
View File
@@ -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()
}
+103
View File
@@ -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]"
}
+345
View File
@@ -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")
}
+11
View File
@@ -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 │
└───────────────────┴──────────────────────────────────────────────────┘
+168
View File
@@ -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 := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>LLM Gateway — Swagger UI</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
<script>
window.onload = () => {
SwaggerUIBundle({
url: "/swagger/swagger.json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: "StandaloneLayout",
deepLinking: true,
})
}
</script>
</body>
</html>`
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)
}
}
+32
View File
@@ -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]"
}
+650
View File
@@ -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))
}
}
+178
View File
@@ -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)
}
}
+18
View File
@@ -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.
+12
View File
@@ -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