first commit
This commit is contained in:
@@ -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