341 lines
9.2 KiB
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
|
|
}
|