first commit
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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
Binary file not shown.
@@ -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"]
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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) | — |
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -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) |
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 │
|
||||
└───────────────────┴──────────────────────────────────────────────────┘
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]"
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user