335 lines
9.5 KiB
Go
335 lines
9.5 KiB
Go
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)
|
|
}
|
|
|
|
bfReq := BifrostRequest{
|
|
Model: model,
|
|
Messages: bfMessages,
|
|
MaxTokens: antReq.MaxTokens,
|
|
Stream: antReq.Stream,
|
|
}
|
|
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 {
|
|
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")
|
|
}
|