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