first commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user