- Nueva interfaz Discoverer con Refresh() por backend - Kilo: full auto (isFree + metadata completa del upstream) - OpenCode: heurística -free para descubrir nuevos free - Kimchi: agrega todos los IDs no-curados del upstream - discoveryLoop cada 10 min en el Proxy - Backends stateful (mutex + catálogo dinámico) - CREDENCIALES.md y KIMCHI_ANALISIS.md documentan el setup
873 lines
24 KiB
Go
873 lines
24 KiB
Go
package proxy
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ---------- Anthropic request/response types ----------
|
|
|
|
// AnthropicRequest is the incoming Anthropic Messages API request.
|
|
type AnthropicRequest struct {
|
|
Model string `json:"model"`
|
|
Messages []AnthropicMessage `json:"messages"`
|
|
System any `json:"system,omitempty"` // string or []AnthropicTextBlock
|
|
MaxTokens int `json:"max_tokens"`
|
|
Temperature *float64 `json:"temperature,omitempty"`
|
|
TopP *float64 `json:"top_p,omitempty"`
|
|
TopK *int `json:"top_k,omitempty"`
|
|
StopSequences []string `json:"stop_sequences,omitempty"`
|
|
Stream bool `json:"stream,omitempty"`
|
|
Tools []AnthropicTool `json:"tools,omitempty"`
|
|
Metadata map[string]any `json:"metadata,omitempty"`
|
|
}
|
|
|
|
// AnthropicMessage is a message in Anthropic format.
|
|
// Content can be either a plain string or an array of content blocks.
|
|
type AnthropicMessage struct {
|
|
Role string `json:"role"` // "user" or "assistant"
|
|
Content AnthropicContentBlocks `json:"content"`
|
|
}
|
|
|
|
// AnthropicContentBlocks handles both string and array content formats.
|
|
type AnthropicContentBlocks []AnthropicContent
|
|
|
|
func (a *AnthropicContentBlocks) UnmarshalJSON(b []byte) error {
|
|
// Try string first: "content": "hello"
|
|
if len(b) > 0 && b[0] == '"' {
|
|
var s string
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return err
|
|
}
|
|
*a = []AnthropicContent{{Type: "text", Text: s}}
|
|
return nil
|
|
}
|
|
// Otherwise it's an array: "content": [{"type":"text","text":"hello"}]
|
|
var blocks []AnthropicContent
|
|
if err := json.Unmarshal(b, &blocks); err != nil {
|
|
return err
|
|
}
|
|
*a = blocks
|
|
return nil
|
|
}
|
|
|
|
// AnthropicContent is a content block (text, tool_use, tool_result, image).
|
|
type AnthropicContent struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
|
Content any `json:"content,omitempty"` // for tool_result
|
|
IsError *bool `json:"is_error,omitempty"`
|
|
Source *AnthropicImage `json:"source,omitempty"`
|
|
}
|
|
|
|
// AnthropicImage is an image source in Anthropic format.
|
|
type AnthropicImage struct {
|
|
Type string `json:"type"`
|
|
MediaType string `json:"media_type"`
|
|
Data string `json:"data"`
|
|
}
|
|
|
|
// AnthropicTool defines a tool in Anthropic format.
|
|
type AnthropicTool struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
InputSchema map[string]any `json:"input_schema"`
|
|
}
|
|
|
|
// AnthropicResponse is the non-streaming Anthropic response.
|
|
type AnthropicResponse struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
Model string `json:"model"`
|
|
Content []AnthropicContent `json:"content"`
|
|
StopReason string `json:"stop_reason"`
|
|
StopSeq string `json:"stop_sequence,omitempty"`
|
|
Usage AnthropicUsage `json:"usage"`
|
|
}
|
|
|
|
// AnthropicUsage holds token usage in Anthropic format.
|
|
type AnthropicUsage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
}
|
|
|
|
// AnthropicError is an error response in Anthropic format.
|
|
type AnthropicError struct {
|
|
Type string `json:"type"`
|
|
Error AnthropicErrorBody `json:"error"`
|
|
}
|
|
|
|
// AnthropicErrorBody is the inner error details.
|
|
type AnthropicErrorBody struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// ---------- Anthropic SSE event types ----------
|
|
|
|
type anthropicSSE struct {
|
|
Type string `json:"type"`
|
|
Index *int `json:"index,omitempty"`
|
|
Delta *struct {
|
|
Type string `json:"type,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
PartialJSON string `json:"partial_json,omitempty"`
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
StopSequence string `json:"stop_sequence,omitempty"`
|
|
} `json:"delta,omitempty"`
|
|
Message *anthropicSSEMessage `json:"message,omitempty"`
|
|
Usage *AnthropicUsage `json:"usage,omitempty"`
|
|
ContentBlock *struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
} `json:"content_block,omitempty"`
|
|
}
|
|
|
|
type anthropicSSEMessage struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
Model string `json:"model"`
|
|
Content []AnthropicContent `json:"content"`
|
|
StopReason string `json:"stop_reason"`
|
|
StopSeq string `json:"stop_sequence,omitempty"`
|
|
Usage AnthropicUsage `json:"usage"`
|
|
}
|
|
|
|
// ---------- Anthropic → OpenAI conversion ----------
|
|
|
|
// AnthropicToOpenAI converts an Anthropic Messages request to OpenAI Chat Completions format.
|
|
func AnthropicToOpenAI(ar *AnthropicRequest) *OpenAIChatRequest {
|
|
var messages []OpenAIMessage
|
|
|
|
// System prompt: Anthropic system → OpenAI system message
|
|
systemText := extractSystemText(ar.System)
|
|
if systemText != "" {
|
|
messages = append(messages, OpenAIMessage{
|
|
Role: "system",
|
|
Content: systemText,
|
|
})
|
|
}
|
|
|
|
// Convert each message
|
|
for _, am := range ar.Messages {
|
|
msg := anthropicMessageToOpenAI(am)
|
|
messages = append(messages, msg)
|
|
}
|
|
|
|
// Convert tools
|
|
var tools []OpenAITool
|
|
for _, at := range ar.Tools {
|
|
tools = append(tools, OpenAITool{
|
|
Type: "function",
|
|
Function: OpenAIFunction{
|
|
Name: at.Name,
|
|
Description: at.Description,
|
|
Parameters: at.InputSchema,
|
|
},
|
|
})
|
|
}
|
|
|
|
temperature := 0.7
|
|
if ar.Temperature != nil {
|
|
temperature = *ar.Temperature
|
|
}
|
|
|
|
oaReq := &OpenAIChatRequest{
|
|
Model: ar.Model,
|
|
Messages: messages,
|
|
MaxTokens: ar.MaxTokens,
|
|
Temperature: temperature,
|
|
Stream: ar.Stream,
|
|
Tools: tools,
|
|
}
|
|
|
|
return oaReq
|
|
}
|
|
|
|
func extractSystemText(system any) string {
|
|
switch s := system.(type) {
|
|
case string:
|
|
return s
|
|
case []any:
|
|
var parts []string
|
|
for _, block := range s {
|
|
if b, ok := block.(map[string]any); ok {
|
|
if b["type"] == "text" {
|
|
if text, ok := b["text"].(string); ok {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func anthropicMessageToOpenAI(am AnthropicMessage) OpenAIMessage {
|
|
msg := OpenAIMessage{
|
|
Role: am.Role,
|
|
}
|
|
|
|
var textParts []string
|
|
var imageParts []map[string]any
|
|
var toolCalls []OpenAIToolCall
|
|
|
|
for _, block := range am.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
textParts = append(textParts, block.Text)
|
|
case "tool_use":
|
|
toolCalls = append(toolCalls, OpenAIToolCall{
|
|
ID: block.ID,
|
|
Type: "function",
|
|
Function: OpenAIToolFunc{
|
|
Name: block.Name,
|
|
Arguments: string(block.Input),
|
|
},
|
|
})
|
|
case "tool_result":
|
|
msg.Role = "tool"
|
|
msg.ToolCallID = block.ToolUseID
|
|
if block.Content != nil {
|
|
msg.Content = flattenContent(block.Content)
|
|
}
|
|
if block.IsError != nil && *block.IsError {
|
|
msg.Content = fmt.Sprintf("Error: %v", msg.Content)
|
|
}
|
|
return msg
|
|
case "image":
|
|
if block.Source != nil {
|
|
imageParts = append(imageParts, map[string]any{
|
|
"type": "image_url",
|
|
"image_url": map[string]any{
|
|
"url": fmt.Sprintf("data:%s;base64,%s", block.Source.MediaType, block.Source.Data),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(imageParts) > 0 {
|
|
var content []map[string]any
|
|
for _, t := range textParts {
|
|
content = append(content, map[string]any{"type": "text", "text": t})
|
|
}
|
|
content = append(content, imageParts...)
|
|
msg.Content = content
|
|
} else if len(toolCalls) > 0 {
|
|
msg.ToolCalls = toolCalls
|
|
msg.Content = strings.Join(textParts, "\n")
|
|
} else {
|
|
msg.Content = strings.Join(textParts, "\n")
|
|
}
|
|
|
|
return msg
|
|
}
|
|
|
|
// flattenContent converts tool_result content (string or array of content blocks) to a plain string.
|
|
func flattenContent(v any) string {
|
|
if s, ok := v.(string); ok {
|
|
return s
|
|
}
|
|
if arr, ok := v.([]any); ok {
|
|
var parts []string
|
|
for _, item := range arr {
|
|
if m, ok := item.(map[string]any); ok {
|
|
if t, ok := m["text"].(string); ok {
|
|
parts = append(parts, t)
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
return fmt.Sprintf("%v", v)
|
|
}
|
|
|
|
// requestHasImages checks whether any message in the Anthropic request contains an image block.
|
|
func requestHasImages(ar *AnthropicRequest) bool {
|
|
for _, m := range ar.Messages {
|
|
for _, block := range m.Content {
|
|
if block.Type == "image" {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---------- OpenAI → Anthropic response conversion ----------
|
|
|
|
// OpenAIToAnthropicResponse converts a non-streaming OpenAI response to Anthropic format.
|
|
func OpenAIToAnthropicResponse(oaResp *OpenAIChatResponse, model string) *AnthropicResponse {
|
|
ar := &AnthropicResponse{
|
|
ID: oaResp.ID,
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Model: model,
|
|
Content: make([]AnthropicContent, 0),
|
|
}
|
|
|
|
if len(oaResp.Choices) > 0 {
|
|
choice := oaResp.Choices[0]
|
|
msg := choice.Message
|
|
|
|
// Extract text content
|
|
if text, ok := msg.Content.(string); ok && text != "" {
|
|
ar.Content = append(ar.Content, AnthropicContent{
|
|
Type: "text",
|
|
Text: text,
|
|
})
|
|
}
|
|
|
|
// Extract tool calls
|
|
for _, tc := range msg.ToolCalls {
|
|
ar.Content = append(ar.Content, AnthropicContent{
|
|
Type: "tool_use",
|
|
ID: tc.ID,
|
|
Name: tc.Function.Name,
|
|
Input: json.RawMessage(tc.Function.Arguments),
|
|
})
|
|
}
|
|
|
|
// Map finish reason
|
|
ar.StopReason = mapFinishReason(choice.FinishReason)
|
|
}
|
|
|
|
// Map usage
|
|
if oaResp.Usage != nil {
|
|
ar.Usage = AnthropicUsage{
|
|
InputTokens: oaResp.Usage.PromptTokens,
|
|
OutputTokens: oaResp.Usage.CompletionTokens,
|
|
}
|
|
}
|
|
|
|
return ar
|
|
}
|
|
|
|
func mapFinishReason(oaReason string) string {
|
|
switch oaReason {
|
|
case "stop":
|
|
return "end_turn"
|
|
case "length":
|
|
return "max_tokens"
|
|
case "tool_calls":
|
|
return "tool_use"
|
|
case "content_filter":
|
|
return "end_turn"
|
|
default:
|
|
return "end_turn"
|
|
}
|
|
}
|
|
|
|
// ---------- OpenAI SSE → Anthropic SSE streaming ----------
|
|
|
|
// ---------- Anthropic SSE state machine ----------
|
|
|
|
type anthropicSSEState struct {
|
|
w http.ResponseWriter
|
|
flusher http.Flusher
|
|
model string
|
|
msgID string
|
|
|
|
messageStarted bool
|
|
finished bool
|
|
inputTokens int
|
|
outputTokens int
|
|
|
|
textBlockIdx int // -1 = not started
|
|
nextBlockIdx int // next sequential block index
|
|
|
|
// toolCallIdx maps OpenAI tool call index → anthropic block state
|
|
toolByOpenAIIdx map[int]*anthropicToolBlock
|
|
// openBlocks tracks blocks in open order for sequential close
|
|
openBlocks []int // values are anthropic block indices
|
|
}
|
|
|
|
type anthropicToolBlock struct {
|
|
anthropicIdx int
|
|
id string
|
|
name string
|
|
}
|
|
|
|
func newAnthropicSSEState(w http.ResponseWriter, flusher http.Flusher, model, requestID string) *anthropicSSEState {
|
|
return &anthropicSSEState{
|
|
w: w,
|
|
flusher: flusher,
|
|
model: model,
|
|
msgID: fmt.Sprintf("msg_%s", requestID),
|
|
textBlockIdx: -1,
|
|
toolByOpenAIIdx: make(map[int]*anthropicToolBlock),
|
|
}
|
|
}
|
|
|
|
func (s *anthropicSSEState) startMessage() {
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "message_start",
|
|
Message: &anthropicSSEMessage{
|
|
ID: s.msgID,
|
|
Type: "message",
|
|
Role: "assistant",
|
|
Model: s.model,
|
|
Content: []AnthropicContent{},
|
|
},
|
|
}, s.flusher)
|
|
s.messageStarted = true
|
|
}
|
|
|
|
func (s *anthropicSSEState) ensureTextBlock() {
|
|
if s.textBlockIdx >= 0 {
|
|
return
|
|
}
|
|
s.textBlockIdx = s.nextBlockIdx
|
|
s.nextBlockIdx++
|
|
s.openBlocks = append(s.openBlocks, s.textBlockIdx)
|
|
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "content_block_start",
|
|
Index: &s.textBlockIdx,
|
|
ContentBlock: &struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
}{Type: "text"},
|
|
}, s.flusher)
|
|
}
|
|
|
|
func (s *anthropicSSEState) handleText(text string) {
|
|
s.ensureTextBlock()
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "content_block_delta",
|
|
Index: &s.textBlockIdx,
|
|
Delta: &struct {
|
|
Type string `json:"type,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
PartialJSON string `json:"partial_json,omitempty"`
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
StopSequence string `json:"stop_sequence,omitempty"`
|
|
}{Type: "text_delta", Text: text},
|
|
}, s.flusher)
|
|
}
|
|
|
|
func (s *anthropicSSEState) handleToolCall(openaiIdx int, tc map[string]any) {
|
|
id, _ := tc["id"].(string)
|
|
fn, _ := tc["function"].(map[string]any)
|
|
name, _ := fn["name"].(string)
|
|
args, _ := fn["arguments"].(string)
|
|
|
|
block, exists := s.toolByOpenAIIdx[openaiIdx]
|
|
if !exists {
|
|
// Close text block if open — text must precede all tool blocks
|
|
if s.textBlockIdx >= 0 {
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "content_block_stop",
|
|
Index: &s.textBlockIdx,
|
|
}, s.flusher)
|
|
// textBlockIdx stays set (don't reopen)
|
|
}
|
|
|
|
block = &anthropicToolBlock{
|
|
anthropicIdx: s.nextBlockIdx,
|
|
id: id,
|
|
name: name,
|
|
}
|
|
s.nextBlockIdx++
|
|
s.toolByOpenAIIdx[openaiIdx] = block
|
|
s.openBlocks = append(s.openBlocks, block.anthropicIdx)
|
|
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "content_block_start",
|
|
Index: &block.anthropicIdx,
|
|
ContentBlock: &struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
}{Type: "tool_use", Name: block.name, ID: block.id},
|
|
}, s.flusher)
|
|
} else {
|
|
// Update id/name if present (first chunk may have both, later only args)
|
|
if id != "" {
|
|
block.id = id
|
|
}
|
|
if name != "" {
|
|
block.name = name
|
|
}
|
|
}
|
|
|
|
if args != "" {
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "content_block_delta",
|
|
Index: &block.anthropicIdx,
|
|
Delta: &struct {
|
|
Type string `json:"type,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
PartialJSON string `json:"partial_json,omitempty"`
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
StopSequence string `json:"stop_sequence,omitempty"`
|
|
}{Type: "input_json_delta", PartialJSON: args},
|
|
}, s.flusher)
|
|
}
|
|
}
|
|
|
|
func (s *anthropicSSEState) finish(stopReason string) {
|
|
// Close all open blocks in order
|
|
for _, blockIdx := range s.openBlocks {
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "content_block_stop",
|
|
Index: &blockIdx,
|
|
}, s.flusher)
|
|
}
|
|
s.openBlocks = nil
|
|
|
|
writeAnthropicSSE(s.w, anthropicSSE{
|
|
Type: "message_delta",
|
|
Delta: &struct {
|
|
Type string `json:"type,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
PartialJSON string `json:"partial_json,omitempty"`
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
StopSequence string `json:"stop_sequence,omitempty"`
|
|
}{StopReason: stopReason},
|
|
Usage: &AnthropicUsage{
|
|
InputTokens: s.inputTokens,
|
|
OutputTokens: s.outputTokens,
|
|
},
|
|
}, s.flusher)
|
|
writeAnthropicSSE(s.w, anthropicSSE{Type: "message_stop"}, s.flusher)
|
|
s.finished = true
|
|
}
|
|
|
|
// StreamOpenAIAsAnthropic reads OpenAI SSE chunks and converts them to Anthropic SSE events.
|
|
func StreamOpenAIAsAnthropic(w http.ResponseWriter, resp *http.Response, model string, requestID string) error {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("anthropic-version", "2023-06-01")
|
|
w.Header().Set("x-request-id", requestID)
|
|
w.WriteHeader(200)
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
return fmt.Errorf("streaming not supported")
|
|
}
|
|
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
|
|
|
state := newAnthropicSSEState(w, flusher, model, requestID)
|
|
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
if data == "[DONE]" {
|
|
if !state.finished {
|
|
state.finish("end_turn")
|
|
}
|
|
continue
|
|
}
|
|
|
|
var chunk map[string]any
|
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
|
continue
|
|
}
|
|
|
|
choices, _ := chunk["choices"].([]any)
|
|
if len(choices) == 0 {
|
|
if u, ok := chunk["usage"].(map[string]any); ok {
|
|
if it, ok := u["prompt_tokens"].(float64); ok {
|
|
state.inputTokens = int(it)
|
|
}
|
|
if ot, ok := u["completion_tokens"].(float64); ok {
|
|
state.outputTokens = int(ot)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
choice := choices[0].(map[string]any)
|
|
delta, _ := choice["delta"].(map[string]any)
|
|
|
|
if !state.messageStarted {
|
|
state.startMessage()
|
|
}
|
|
|
|
if content, ok := delta["content"].(string); ok && content != "" {
|
|
state.handleText(content)
|
|
}
|
|
|
|
if tcs, ok := delta["tool_calls"].([]any); ok {
|
|
for _, tcAny := range tcs {
|
|
tc := tcAny.(map[string]any)
|
|
idxFloat, ok := tc["index"].(float64)
|
|
if !ok {
|
|
continue
|
|
}
|
|
state.handleToolCall(int(idxFloat), tc)
|
|
}
|
|
}
|
|
|
|
if fr, ok := choice["finish_reason"].(string); ok && fr != "" && !state.finished {
|
|
state.finish(mapFinishReason(fr))
|
|
}
|
|
}
|
|
|
|
if _, err := fmt.Fprint(w, ""); err != nil {
|
|
return nil
|
|
}
|
|
flusher.Flush()
|
|
return nil
|
|
}
|
|
|
|
func writeAnthropicSSE(w io.Writer, event anthropicSSE, flusher http.Flusher) {
|
|
data, _ := json.Marshal(event)
|
|
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event.Type, data)
|
|
if flusher != nil {
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
// ---------- Forwarding for Anthropic ----------
|
|
|
|
// ForwardAnthropicMessages converts an Anthropic request, forwards to Zen, and returns Anthropic response.
|
|
func (p *Proxy) ForwardAnthropicMessages(w http.ResponseWriter, r *http.Request, body []byte) error {
|
|
var ar AnthropicRequest
|
|
if err := json.Unmarshal(body, &ar); err != nil {
|
|
return fmt.Errorf("invalid Anthropic request: %w", err)
|
|
}
|
|
|
|
// Route to the backend that owns this model (default = pass-through).
|
|
backend, resolved, known := p.resolveModel(ar.Model)
|
|
if !known {
|
|
resolved = ar.Model
|
|
}
|
|
// Keep original client-requested model name for response rewriting.
|
|
// Claude Code validates that the response model matches the request model.
|
|
clientModel := ar.Model
|
|
|
|
// Auto-route to a vision model only inside the opencode backend (which owns
|
|
// a vision-capable free model). Kilo models are forwarded as-is.
|
|
hasImages := requestHasImages(&ar)
|
|
if hasImages && backend.Name() == "opencode" {
|
|
var mi *ModelInfo
|
|
if oc, ok := backend.(*OpenCodeBackend); ok {
|
|
mi = oc.ModelByID(resolved)
|
|
}
|
|
if mi == nil || !mi.SupportsVision {
|
|
fmt.Printf("[anthropic] model=%s lacks vision, auto-routing to mimo-v2.5-free\n", resolved)
|
|
resolved = "mimo-v2.5-free"
|
|
}
|
|
}
|
|
|
|
fmt.Printf("[anthropic] model=%s → %s via %s, max_tokens=%d, stream=%v, messages=%d, tools=%d\n",
|
|
clientModel, resolved, backend.Name(), ar.MaxTokens, ar.Stream, len(ar.Messages), len(ar.Tools))
|
|
|
|
// Convert Anthropic → OpenAI
|
|
oaReq := AnthropicToOpenAI(&ar)
|
|
oaReq.Model = resolved
|
|
|
|
oaBody, err := json.Marshal(oaReq)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal OpenAI request: %w", err)
|
|
}
|
|
|
|
fmt.Printf("[anthropic] → openai request (%d bytes)\n", len(oaBody))
|
|
|
|
// Generate request/session IDs
|
|
requestID := RequestID()
|
|
sessionID := p.SessionID("anthropic")
|
|
|
|
// Build upstream request to the chosen backend
|
|
upstreamReq, err := http.NewRequestWithContext(r.Context(), "POST", backend.ChatURL(), bytes.NewReader(oaBody))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create upstream request: %w", err)
|
|
}
|
|
|
|
for k, v := range backend.Headers(requestID, sessionID) {
|
|
upstreamReq.Header.Set(k, v)
|
|
}
|
|
|
|
// Execute
|
|
client := &http.Client{Timeout: 10 * time.Minute}
|
|
resp, err := client.Do(upstreamReq)
|
|
if err != nil {
|
|
return fmt.Errorf("upstream request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
fmt.Printf("[anthropic] upstream status=%d, content-type=%s\n", resp.StatusCode, resp.Header.Get("Content-Type"))
|
|
|
|
if resp.StatusCode != 200 {
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
|
fmt.Printf("[anthropic] upstream error body (%d bytes): %s\n", len(bodyBytes), string(bodyBytes))
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
p.markRateLimited(resolved)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(resp.StatusCode)
|
|
json.NewEncoder(w).Encode(AnthropicError{
|
|
Type: "error",
|
|
Error: AnthropicErrorBody{
|
|
Type: "api_error",
|
|
Message: string(bodyBytes),
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// Handle streaming vs non-streaming
|
|
if ar.Stream {
|
|
return StreamOpenAIAsAnthropic(w, resp, clientModel, requestID)
|
|
}
|
|
|
|
// Non-streaming: collect full response and convert
|
|
return p.anthropicNonStreamResponse(w, resp, clientModel)
|
|
}
|
|
|
|
func (p *Proxy) anthropicNonStreamResponse(w http.ResponseWriter, resp *http.Response, model string) error {
|
|
bodyBytes, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read upstream: %w", err)
|
|
}
|
|
|
|
contentType := resp.Header.Get("Content-Type")
|
|
if strings.Contains(contentType, "text/event-stream") {
|
|
// Accumulate SSE chunks into a single OpenAI response, then convert
|
|
var accumulated OpenAIChatResponse
|
|
accumulated.ID = "msg_" + RequestID()
|
|
accumulated.Object = "chat.completion"
|
|
accumulated.Created = time.Now().Unix()
|
|
accumulated.Model = model
|
|
accumulated.Choices = []OpenAIChoice{{
|
|
Index: 0,
|
|
Message: OpenAIMessage{
|
|
Role: "assistant",
|
|
},
|
|
}}
|
|
|
|
var contentBuf strings.Builder
|
|
toolCallsByIndex := make(map[int]*OpenAIToolCall)
|
|
var toolIndices []int
|
|
scanner := bufio.NewScanner(bytes.NewReader(bodyBytes))
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
if !strings.HasPrefix(line, "data: ") {
|
|
continue
|
|
}
|
|
data := strings.TrimPrefix(line, "data: ")
|
|
if data == "[DONE]" {
|
|
continue
|
|
}
|
|
var chunk map[string]any
|
|
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
|
|
continue
|
|
}
|
|
if choices, ok := chunk["choices"].([]any); ok && len(choices) > 0 {
|
|
choice := choices[0].(map[string]any)
|
|
if delta, ok := choice["delta"].(map[string]any); ok {
|
|
if content, ok := delta["content"].(string); ok {
|
|
contentBuf.WriteString(content)
|
|
}
|
|
if tcs, ok := delta["tool_calls"].([]any); ok && len(tcs) > 0 {
|
|
for _, tcAny := range tcs {
|
|
tc := tcAny.(map[string]any)
|
|
idxFloat, ok := tc["index"].(float64)
|
|
if !ok {
|
|
continue
|
|
}
|
|
idx := int(idxFloat)
|
|
fn, _ := tc["function"].(map[string]any)
|
|
|
|
existing, exists := toolCallsByIndex[idx]
|
|
if !exists {
|
|
id, _ := tc["id"].(string)
|
|
name, _ := fn["name"].(string)
|
|
existing = &OpenAIToolCall{
|
|
ID: id,
|
|
Type: "function",
|
|
Function: OpenAIToolFunc{
|
|
Name: name,
|
|
Arguments: "",
|
|
},
|
|
}
|
|
toolCallsByIndex[idx] = existing
|
|
toolIndices = append(toolIndices, idx)
|
|
}
|
|
|
|
if name, ok := fn["name"].(string); ok && name != "" {
|
|
existing.Function.Name = name
|
|
}
|
|
if args, ok := fn["arguments"].(string); ok {
|
|
existing.Function.Arguments += args
|
|
}
|
|
if id, ok := tc["id"].(string); ok && id != "" {
|
|
existing.ID = id
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if fr, ok := choice["finish_reason"].(string); ok && fr != "" {
|
|
accumulated.Choices[0].FinishReason = fr
|
|
}
|
|
}
|
|
if u, ok := chunk["usage"].(map[string]any); ok {
|
|
accumulated.Usage = &OpenAIUsage{}
|
|
if it, ok := u["prompt_tokens"].(float64); ok {
|
|
accumulated.Usage.PromptTokens = int(it)
|
|
}
|
|
if ot, ok := u["completion_tokens"].(float64); ok {
|
|
accumulated.Usage.CompletionTokens = int(ot)
|
|
}
|
|
accumulated.Usage.TotalTokens = accumulated.Usage.PromptTokens + accumulated.Usage.CompletionTokens
|
|
}
|
|
}
|
|
|
|
accumulated.Choices[0].Message.Content = contentBuf.String()
|
|
sort.Ints(toolIndices)
|
|
for _, idx := range toolIndices {
|
|
accumulated.Choices[0].Message.ToolCalls = append(accumulated.Choices[0].Message.ToolCalls, *toolCallsByIndex[idx])
|
|
}
|
|
if accumulated.Choices[0].FinishReason == "" {
|
|
accumulated.Choices[0].FinishReason = "stop"
|
|
}
|
|
|
|
anthroResp := OpenAIToAnthropicResponse(&accumulated, model)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
return json.NewEncoder(w).Encode(anthroResp)
|
|
}
|
|
|
|
// Direct JSON response from upstream
|
|
var oaResp OpenAIChatResponse
|
|
if err := json.Unmarshal(bodyBytes, &oaResp); err != nil {
|
|
// Pass through if not valid OpenAI JSON
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(200)
|
|
w.Write(bodyBytes)
|
|
return nil
|
|
}
|
|
|
|
anthroResp := OpenAIToAnthropicResponse(&oaResp, model)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("anthropic-version", "2023-06-01")
|
|
w.Header().Set("x-request-id", "req_"+RequestID())
|
|
w.WriteHeader(200)
|
|
return json.NewEncoder(w).Encode(anthroResp)
|
|
}
|