Files
xinghuoapi/backend/internal/pkg/apicompat/anthropic_to_responses.go

341 lines
9.2 KiB
Go

package apicompat
import (
"encoding/json"
"fmt"
"strings"
)
// AnthropicToResponses converts an Anthropic Messages request directly into
// a Responses API request. This preserves fields that would be lost in a
// Chat Completions intermediary round-trip (e.g. thinking, cache_control,
// structured system prompts).
func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
input, err := convertAnthropicToResponsesInput(req.System, req.Messages)
if err != nil {
return nil, err
}
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, err
}
out := &ResponsesRequest{
Model: req.Model,
Input: inputJSON,
Temperature: req.Temperature,
TopP: req.TopP,
Stream: req.Stream,
Include: []string{"reasoning.encrypted_content"},
}
storeFalse := false
out.Store = &storeFalse
if req.MaxTokens > 0 {
v := req.MaxTokens
if v < minMaxOutputTokens {
v = minMaxOutputTokens
}
out.MaxOutputTokens = &v
}
if len(req.Tools) > 0 {
out.Tools = convertAnthropicToolsToResponses(req.Tools)
}
// Convert thinking → reasoning.
// generate_summary="auto" causes the upstream to emit reasoning_summary_text
// streaming events; the include array only needs reasoning.encrypted_content
// (already set above) for content continuity.
if req.Thinking != nil {
switch req.Thinking.Type {
case "enabled":
out.Reasoning = &ResponsesReasoning{Effort: "high", Summary: "auto"}
case "adaptive":
out.Reasoning = &ResponsesReasoning{Effort: "medium", Summary: "auto"}
}
// "disabled" or unknown → omit reasoning
}
// Convert tool_choice
if len(req.ToolChoice) > 0 {
tc, err := convertAnthropicToolChoiceToResponses(req.ToolChoice)
if err != nil {
return nil, fmt.Errorf("convert tool_choice: %w", err)
}
out.ToolChoice = tc
}
return out, nil
}
// convertAnthropicToolChoiceToResponses maps Anthropic tool_choice to Responses format.
//
// {"type":"auto"} → "auto"
// {"type":"any"} → "required"
// {"type":"none"} → "none"
// {"type":"tool","name":"X"} → {"type":"function","function":{"name":"X"}}
func convertAnthropicToolChoiceToResponses(raw json.RawMessage) (json.RawMessage, error) {
var tc struct {
Type string `json:"type"`
Name string `json:"name"`
}
if err := json.Unmarshal(raw, &tc); err != nil {
return nil, err
}
switch tc.Type {
case "auto":
return json.Marshal("auto")
case "any":
return json.Marshal("required")
case "none":
return json.Marshal("none")
case "tool":
return json.Marshal(map[string]any{
"type": "function",
"function": map[string]string{"name": tc.Name},
})
default:
// Pass through unknown types as-is
return raw, nil
}
}
// convertAnthropicToResponsesInput builds the Responses API input items array
// from the Anthropic system field and message list.
func convertAnthropicToResponsesInput(system json.RawMessage, msgs []AnthropicMessage) ([]ResponsesInputItem, error) {
var out []ResponsesInputItem
// System prompt → system role input item.
if len(system) > 0 {
sysText, err := parseAnthropicSystemPrompt(system)
if err != nil {
return nil, err
}
if sysText != "" {
content, _ := json.Marshal(sysText)
out = append(out, ResponsesInputItem{
Role: "system",
Content: content,
})
}
}
for _, m := range msgs {
items, err := anthropicMsgToResponsesItems(m)
if err != nil {
return nil, err
}
out = append(out, items...)
}
return out, nil
}
// parseAnthropicSystemPrompt handles the Anthropic system field which can be
// a plain string or an array of text blocks.
func parseAnthropicSystemPrompt(raw json.RawMessage) (string, error) {
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return "", err
}
var parts []string
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
parts = append(parts, b.Text)
}
}
return strings.Join(parts, "\n\n"), nil
}
// anthropicMsgToResponsesItems converts a single Anthropic message into one
// or more Responses API input items.
func anthropicMsgToResponsesItems(m AnthropicMessage) ([]ResponsesInputItem, error) {
switch m.Role {
case "user":
return anthropicUserToResponses(m.Content)
case "assistant":
return anthropicAssistantToResponses(m.Content)
default:
return anthropicUserToResponses(m.Content)
}
}
// anthropicUserToResponses handles an Anthropic user message. Content can be a
// plain string or an array of blocks. tool_result blocks are extracted into
// function_call_output items.
func anthropicUserToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) {
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
content, _ := json.Marshal(s)
return []ResponsesInputItem{{Role: "user", Content: content}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
var out []ResponsesInputItem
// Extract tool_result blocks → function_call_output items.
for _, b := range blocks {
if b.Type != "tool_result" {
continue
}
text := extractAnthropicToolResultText(b)
if text == "" {
// OpenAI Responses API requires "output" field; use placeholder for empty results.
text = "(empty)"
}
out = append(out, ResponsesInputItem{
Type: "function_call_output",
CallID: toResponsesCallID(b.ToolUseID),
Output: text,
})
}
// Remaining text blocks → user message.
text := extractAnthropicTextFromBlocks(blocks)
if text != "" {
content, _ := json.Marshal(text)
out = append(out, ResponsesInputItem{Role: "user", Content: content})
}
return out, nil
}
// anthropicAssistantToResponses handles an Anthropic assistant message.
// Text content → assistant message with output_text parts.
// tool_use blocks → function_call items.
// thinking blocks → ignored (OpenAI doesn't accept them as input).
func anthropicAssistantToResponses(raw json.RawMessage) ([]ResponsesInputItem, error) {
// Try plain string.
var s string
if err := json.Unmarshal(raw, &s); err == nil {
parts := []ResponsesContentPart{{Type: "output_text", Text: s}}
partsJSON, err := json.Marshal(parts)
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "assistant", Content: partsJSON}}, nil
}
var blocks []AnthropicContentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, err
}
var items []ResponsesInputItem
// Text content → assistant message with output_text content parts.
text := extractAnthropicTextFromBlocks(blocks)
if text != "" {
parts := []ResponsesContentPart{{Type: "output_text", Text: text}}
partsJSON, err := json.Marshal(parts)
if err != nil {
return nil, err
}
items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON})
}
// tool_use → function_call items.
for _, b := range blocks {
if b.Type != "tool_use" {
continue
}
args := "{}"
if len(b.Input) > 0 {
args = string(b.Input)
}
fcID := toResponsesCallID(b.ID)
items = append(items, ResponsesInputItem{
Type: "function_call",
CallID: fcID,
Name: b.Name,
Arguments: args,
ID: fcID,
})
}
return items, nil
}
// toResponsesCallID converts an Anthropic tool ID (toolu_xxx / call_xxx) to a
// Responses API function_call ID that starts with "fc_".
func toResponsesCallID(id string) string {
if strings.HasPrefix(id, "fc_") {
return id
}
return "fc_" + id
}
// fromResponsesCallID reverses toResponsesCallID, stripping the "fc_" prefix
// that was added during request conversion.
func fromResponsesCallID(id string) string {
if after, ok := strings.CutPrefix(id, "fc_"); ok {
// Only strip if the remainder doesn't look like it was already "fc_" prefixed.
// E.g. "fc_toolu_xxx" → "toolu_xxx", "fc_call_xxx" → "call_xxx"
if strings.HasPrefix(after, "toolu_") || strings.HasPrefix(after, "call_") {
return after
}
}
return id
}
// extractAnthropicToolResultText gets the text content from a tool_result block.
func extractAnthropicToolResultText(b AnthropicContentBlock) string {
if len(b.Content) == 0 {
return ""
}
var s string
if err := json.Unmarshal(b.Content, &s); err == nil {
return s
}
var inner []AnthropicContentBlock
if err := json.Unmarshal(b.Content, &inner); err == nil {
var parts []string
for _, ib := range inner {
if ib.Type == "text" && ib.Text != "" {
parts = append(parts, ib.Text)
}
}
return strings.Join(parts, "\n\n")
}
return ""
}
// extractAnthropicTextFromBlocks joins all text blocks, ignoring thinking/
// tool_use/tool_result blocks.
func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string {
var parts []string
for _, b := range blocks {
if b.Type == "text" && b.Text != "" {
parts = append(parts, b.Text)
}
}
return strings.Join(parts, "\n\n")
}
// convertAnthropicToolsToResponses maps Anthropic tool definitions to
// Responses API function tools (input_schema → parameters).
func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool {
out := make([]ResponsesTool, len(tools))
for i, t := range tools {
out[i] = ResponsesTool{
Type: "function",
Name: t.Name,
Description: t.Description,
Parameters: t.InputSchema,
}
}
return out
}