feat: backend core - auth, user, role, permission, device, webhook, monitoring, cache, repository, service, middleware, API handlers
This commit is contained in:
464
internal/pkg/apicompat/responses_to_anthropic_request.go
Normal file
464
internal/pkg/apicompat/responses_to_anthropic_request.go
Normal file
@@ -0,0 +1,464 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ResponsesToAnthropicRequest converts a Responses API request into an
|
||||
// Anthropic Messages request. This is the reverse of AnthropicToResponses and
|
||||
// enables Anthropic platform groups to accept OpenAI Responses API requests
|
||||
// by converting them to the native /v1/messages format before forwarding upstream.
|
||||
func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) {
|
||||
system, messages, err := convertResponsesInputToAnthropic(req.Input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &AnthropicRequest{
|
||||
Model: req.Model,
|
||||
Messages: messages,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
|
||||
if len(system) > 0 {
|
||||
out.System = system
|
||||
}
|
||||
|
||||
// max_output_tokens → max_tokens
|
||||
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
|
||||
out.MaxTokens = *req.MaxOutputTokens
|
||||
}
|
||||
if out.MaxTokens == 0 {
|
||||
// Anthropic requires max_tokens; default to a sensible value.
|
||||
out.MaxTokens = 8192
|
||||
}
|
||||
|
||||
// Convert tools
|
||||
if len(req.Tools) > 0 {
|
||||
out.Tools = convertResponsesToAnthropicTools(req.Tools)
|
||||
}
|
||||
|
||||
// Convert tool_choice (reverse of convertAnthropicToolChoiceToResponses)
|
||||
if len(req.ToolChoice) > 0 {
|
||||
tc, err := convertResponsesToAnthropicToolChoice(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert tool_choice: %w", err)
|
||||
}
|
||||
out.ToolChoice = tc
|
||||
}
|
||||
|
||||
// reasoning.effort → output_config.effort + thinking
|
||||
if req.Reasoning != nil && req.Reasoning.Effort != "" {
|
||||
effort := mapResponsesEffortToAnthropic(req.Reasoning.Effort)
|
||||
out.OutputConfig = &AnthropicOutputConfig{Effort: effort}
|
||||
// Enable thinking for non-low efforts
|
||||
if effort != "low" {
|
||||
out.Thinking = &AnthropicThinking{
|
||||
Type: "enabled",
|
||||
BudgetTokens: defaultThinkingBudget(effort),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defaultThinkingBudget returns a sensible thinking budget based on effort level.
|
||||
func defaultThinkingBudget(effort string) int {
|
||||
switch effort {
|
||||
case "low":
|
||||
return 1024
|
||||
case "medium":
|
||||
return 4096
|
||||
case "high":
|
||||
return 10240
|
||||
case "max":
|
||||
return 32768
|
||||
default:
|
||||
return 10240
|
||||
}
|
||||
}
|
||||
|
||||
// mapResponsesEffortToAnthropic converts OpenAI Responses reasoning effort to
|
||||
// Anthropic effort levels. Reverse of mapAnthropicEffortToResponses.
|
||||
//
|
||||
// low → low
|
||||
// medium → medium
|
||||
// high → high
|
||||
// xhigh → max
|
||||
func mapResponsesEffortToAnthropic(effort string) string {
|
||||
if effort == "xhigh" {
|
||||
return "max"
|
||||
}
|
||||
return effort // low→low, medium→medium, high→high, unknown→passthrough
|
||||
}
|
||||
|
||||
// convertResponsesInputToAnthropic extracts system prompt and messages from
|
||||
// a Responses API input array. Returns the system as raw JSON (for Anthropic's
|
||||
// polymorphic system field) and a list of Anthropic messages.
|
||||
func convertResponsesInputToAnthropic(inputRaw json.RawMessage) (json.RawMessage, []AnthropicMessage, error) {
|
||||
// Try as plain string input.
|
||||
var inputStr string
|
||||
if err := json.Unmarshal(inputRaw, &inputStr); err == nil {
|
||||
content, _ := json.Marshal(inputStr)
|
||||
return nil, []AnthropicMessage{{Role: "user", Content: content}}, nil
|
||||
}
|
||||
|
||||
var items []ResponsesInputItem
|
||||
if err := json.Unmarshal(inputRaw, &items); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse responses input: %w", err)
|
||||
}
|
||||
|
||||
var system json.RawMessage
|
||||
var messages []AnthropicMessage
|
||||
|
||||
for _, item := range items {
|
||||
switch {
|
||||
case item.Role == "system":
|
||||
// System prompt → Anthropic system field
|
||||
text := extractTextFromContent(item.Content)
|
||||
if text != "" {
|
||||
system, _ = json.Marshal(text)
|
||||
}
|
||||
|
||||
case item.Type == "function_call":
|
||||
// function_call → assistant message with tool_use block
|
||||
input := json.RawMessage("{}")
|
||||
if item.Arguments != "" {
|
||||
input = json.RawMessage(item.Arguments)
|
||||
}
|
||||
block := AnthropicContentBlock{
|
||||
Type: "tool_use",
|
||||
ID: fromResponsesCallIDToAnthropic(item.CallID),
|
||||
Name: item.Name,
|
||||
Input: input,
|
||||
}
|
||||
blockJSON, _ := json.Marshal([]AnthropicContentBlock{block})
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "assistant",
|
||||
Content: blockJSON,
|
||||
})
|
||||
|
||||
case item.Type == "function_call_output":
|
||||
// function_call_output → user message with tool_result block
|
||||
outputContent := item.Output
|
||||
if outputContent == "" {
|
||||
outputContent = "(empty)"
|
||||
}
|
||||
contentJSON, _ := json.Marshal(outputContent)
|
||||
block := AnthropicContentBlock{
|
||||
Type: "tool_result",
|
||||
ToolUseID: fromResponsesCallIDToAnthropic(item.CallID),
|
||||
Content: contentJSON,
|
||||
}
|
||||
blockJSON, _ := json.Marshal([]AnthropicContentBlock{block})
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: blockJSON,
|
||||
})
|
||||
|
||||
case item.Role == "user":
|
||||
content, err := convertResponsesUserToAnthropicContent(item.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: content,
|
||||
})
|
||||
|
||||
case item.Role == "assistant":
|
||||
content, err := convertResponsesAssistantToAnthropicContent(item.Content)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
})
|
||||
|
||||
default:
|
||||
// Unknown role/type — attempt as user message
|
||||
if item.Content != nil {
|
||||
messages = append(messages, AnthropicMessage{
|
||||
Role: "user",
|
||||
Content: item.Content,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge consecutive same-role messages (Anthropic requires alternating roles)
|
||||
messages = mergeConsecutiveMessages(messages)
|
||||
|
||||
return system, messages, nil
|
||||
}
|
||||
|
||||
// extractTextFromContent extracts text from a content field that may be a
|
||||
// plain string or an array of content parts.
|
||||
func extractTextFromContent(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s
|
||||
}
|
||||
var parts []ResponsesContentPart
|
||||
if err := json.Unmarshal(raw, &parts); err == nil {
|
||||
var texts []string
|
||||
for _, p := range parts {
|
||||
if (p.Type == "input_text" || p.Type == "output_text" || p.Type == "text") && p.Text != "" {
|
||||
texts = append(texts, p.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(texts, "\n\n")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// convertResponsesUserToAnthropicContent converts a Responses user message
|
||||
// content field into Anthropic content blocks JSON.
|
||||
func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
return json.Marshal("") // empty string content
|
||||
}
|
||||
|
||||
// Try plain string.
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return json.Marshal(s)
|
||||
}
|
||||
|
||||
// Array of content parts → Anthropic content blocks.
|
||||
var parts []ResponsesContentPart
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
// Pass through as-is if we can't parse
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var blocks []AnthropicContentBlock
|
||||
for _, p := range parts {
|
||||
switch p.Type {
|
||||
case "input_text", "text":
|
||||
if p.Text != "" {
|
||||
blocks = append(blocks, AnthropicContentBlock{
|
||||
Type: "text",
|
||||
Text: p.Text,
|
||||
})
|
||||
}
|
||||
case "input_image":
|
||||
src := dataURIToAnthropicImageSource(p.ImageURL)
|
||||
if src != nil {
|
||||
blocks = append(blocks, AnthropicContentBlock{
|
||||
Type: "image",
|
||||
Source: src,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
return json.Marshal("")
|
||||
}
|
||||
return json.Marshal(blocks)
|
||||
}
|
||||
|
||||
// convertResponsesAssistantToAnthropicContent converts a Responses assistant
|
||||
// message content field into Anthropic content blocks JSON.
|
||||
func convertResponsesAssistantToAnthropicContent(raw json.RawMessage) (json.RawMessage, error) {
|
||||
if len(raw) == 0 {
|
||||
return json.Marshal([]AnthropicContentBlock{{Type: "text", Text: ""}})
|
||||
}
|
||||
|
||||
// Try plain string.
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return json.Marshal([]AnthropicContentBlock{{Type: "text", Text: s}})
|
||||
}
|
||||
|
||||
// Array of content parts → Anthropic content blocks.
|
||||
var parts []ResponsesContentPart
|
||||
if err := json.Unmarshal(raw, &parts); err != nil {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var blocks []AnthropicContentBlock
|
||||
for _, p := range parts {
|
||||
switch p.Type {
|
||||
case "output_text", "text":
|
||||
if p.Text != "" {
|
||||
blocks = append(blocks, AnthropicContentBlock{
|
||||
Type: "text",
|
||||
Text: p.Text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(blocks) == 0 {
|
||||
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: ""})
|
||||
}
|
||||
return json.Marshal(blocks)
|
||||
}
|
||||
|
||||
// fromResponsesCallIDToAnthropic converts an OpenAI function call ID back to
|
||||
// Anthropic format. Reverses toResponsesCallID.
|
||||
func fromResponsesCallIDToAnthropic(id string) string {
|
||||
// If it has our "fc_" prefix wrapping a known Anthropic prefix, strip it
|
||||
if after, ok := strings.CutPrefix(id, "fc_"); ok {
|
||||
if strings.HasPrefix(after, "toolu_") || strings.HasPrefix(after, "call_") {
|
||||
return after
|
||||
}
|
||||
}
|
||||
// Generate a synthetic Anthropic tool ID
|
||||
if !strings.HasPrefix(id, "toolu_") && !strings.HasPrefix(id, "call_") {
|
||||
return "toolu_" + id
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// dataURIToAnthropicImageSource parses a data URI into an AnthropicImageSource.
|
||||
func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource {
|
||||
if !strings.HasPrefix(dataURI, "data:") {
|
||||
return nil
|
||||
}
|
||||
// Format: data:<media_type>;base64,<data>
|
||||
rest := strings.TrimPrefix(dataURI, "data:")
|
||||
semicolonIdx := strings.Index(rest, ";")
|
||||
if semicolonIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
mediaType := rest[:semicolonIdx]
|
||||
rest = rest[semicolonIdx+1:]
|
||||
if !strings.HasPrefix(rest, "base64,") {
|
||||
return nil
|
||||
}
|
||||
data := strings.TrimPrefix(rest, "base64,")
|
||||
return &AnthropicImageSource{
|
||||
Type: "base64",
|
||||
MediaType: mediaType,
|
||||
Data: data,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeConsecutiveMessages merges consecutive messages with the same role
|
||||
// because Anthropic requires alternating user/assistant turns.
|
||||
func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage {
|
||||
if len(messages) <= 1 {
|
||||
return messages
|
||||
}
|
||||
|
||||
var merged []AnthropicMessage
|
||||
for _, msg := range messages {
|
||||
if len(merged) == 0 || merged[len(merged)-1].Role != msg.Role {
|
||||
merged = append(merged, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Same role — merge content arrays
|
||||
last := &merged[len(merged)-1]
|
||||
lastBlocks := parseContentBlocks(last.Content)
|
||||
newBlocks := parseContentBlocks(msg.Content)
|
||||
combined := append(lastBlocks, newBlocks...)
|
||||
last.Content, _ = json.Marshal(combined)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// parseContentBlocks attempts to parse content as []AnthropicContentBlock.
|
||||
// If it's a string, wraps it in a text block.
|
||||
func parseContentBlocks(raw json.RawMessage) []AnthropicContentBlock {
|
||||
var blocks []AnthropicContentBlock
|
||||
if err := json.Unmarshal(raw, &blocks); err == nil {
|
||||
return blocks
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return []AnthropicContentBlock{{Type: "text", Text: s}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertResponsesToAnthropicTools maps Responses API tools to Anthropic format.
|
||||
// Reverse of convertAnthropicToolsToResponses.
|
||||
func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool {
|
||||
var out []AnthropicTool
|
||||
for _, t := range tools {
|
||||
switch t.Type {
|
||||
case "web_search":
|
||||
out = append(out, AnthropicTool{
|
||||
Type: "web_search_20250305",
|
||||
Name: "web_search",
|
||||
})
|
||||
case "function":
|
||||
out = append(out, AnthropicTool{
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: normalizeAnthropicInputSchema(t.Parameters),
|
||||
})
|
||||
default:
|
||||
// Pass through unknown tool types
|
||||
out = append(out, AnthropicTool{
|
||||
Type: t.Type,
|
||||
Name: t.Name,
|
||||
Description: t.Description,
|
||||
InputSchema: t.Parameters,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeAnthropicInputSchema ensures the input_schema has a "type" field.
|
||||
func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
|
||||
if len(schema) == 0 || string(schema) == "null" {
|
||||
return json.RawMessage(`{"type":"object","properties":{}}`)
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
// convertResponsesToAnthropicToolChoice maps Responses tool_choice to Anthropic format.
|
||||
// Reverse of convertAnthropicToolChoiceToResponses.
|
||||
//
|
||||
// "auto" → {"type":"auto"}
|
||||
// "required" → {"type":"any"}
|
||||
// "none" → {"type":"none"}
|
||||
// {"type":"function","function":{"name":"X"}} → {"type":"tool","name":"X"}
|
||||
func convertResponsesToAnthropicToolChoice(raw json.RawMessage) (json.RawMessage, error) {
|
||||
// Try as string first
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
switch s {
|
||||
case "auto":
|
||||
return json.Marshal(map[string]string{"type": "auto"})
|
||||
case "required":
|
||||
return json.Marshal(map[string]string{"type": "any"})
|
||||
case "none":
|
||||
return json.Marshal(map[string]string{"type": "none"})
|
||||
default:
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try as object with type=function
|
||||
var tc struct {
|
||||
Type string `json:"type"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"function"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &tc); err == nil && tc.Type == "function" && tc.Function.Name != "" {
|
||||
return json.Marshal(map[string]string{
|
||||
"type": "tool",
|
||||
"name": tc.Function.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// Pass through unknown
|
||||
return raw, nil
|
||||
}
|
||||
Reference in New Issue
Block a user