mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-03-30 02:27:11 +00:00
- apply default mapped model only when scheduling fallback is actually used - preserve reasoning in OpenAI-compatible output via reasoning_content and avoid invalid input function_call ids
417 lines
12 KiB
Go
417 lines
12 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)
|
|
}
|
|
|
|
// Determine reasoning effort: only output_config.effort controls the
|
|
// level; thinking.type is ignored. Default is xhigh when unset.
|
|
// Anthropic levels map to OpenAI: low→low, medium→high, high→xhigh.
|
|
effort := "high" // default → maps to xhigh
|
|
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
|
|
effort = req.OutputConfig.Effort
|
|
}
|
|
out.Reasoning = &ResponsesReasoning{
|
|
Effort: mapAnthropicEffortToResponses(effort),
|
|
Summary: "auto",
|
|
}
|
|
|
|
// 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. Image blocks are converted to input_image parts.
|
|
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
|
|
var toolResultImageParts []ResponsesContentPart
|
|
|
|
// Extract tool_result blocks → function_call_output items.
|
|
// Images inside tool_results are extracted separately because the
|
|
// Responses API function_call_output.output only accepts strings.
|
|
for _, b := range blocks {
|
|
if b.Type != "tool_result" {
|
|
continue
|
|
}
|
|
outputText, imageParts := convertToolResultOutput(b)
|
|
out = append(out, ResponsesInputItem{
|
|
Type: "function_call_output",
|
|
CallID: toResponsesCallID(b.ToolUseID),
|
|
Output: outputText,
|
|
})
|
|
toolResultImageParts = append(toolResultImageParts, imageParts...)
|
|
}
|
|
|
|
// Remaining text + image blocks → user message with content parts.
|
|
// Also include images extracted from tool_results so the model can see them.
|
|
var parts []ResponsesContentPart
|
|
for _, b := range blocks {
|
|
switch b.Type {
|
|
case "text":
|
|
if b.Text != "" {
|
|
parts = append(parts, ResponsesContentPart{Type: "input_text", Text: b.Text})
|
|
}
|
|
case "image":
|
|
if uri := anthropicImageToDataURI(b.Source); uri != "" {
|
|
parts = append(parts, ResponsesContentPart{Type: "input_image", ImageURL: uri})
|
|
}
|
|
}
|
|
}
|
|
parts = append(parts, toolResultImageParts...)
|
|
|
|
if len(parts) > 0 {
|
|
content, err := json.Marshal(parts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// anthropicImageToDataURI converts an AnthropicImageSource to a data URI string.
|
|
// Returns "" if the source is nil or has no data.
|
|
func anthropicImageToDataURI(src *AnthropicImageSource) string {
|
|
if src == nil || src.Data == "" {
|
|
return ""
|
|
}
|
|
mediaType := src.MediaType
|
|
if mediaType == "" {
|
|
mediaType = "image/png"
|
|
}
|
|
return "data:" + mediaType + ";base64," + src.Data
|
|
}
|
|
|
|
// convertToolResultOutput extracts text and image content from a tool_result
|
|
// block. Returns the text as a string for the function_call_output Output
|
|
// field, plus any image parts that must be sent in a separate user message
|
|
// (the Responses API output field only accepts strings).
|
|
func convertToolResultOutput(b AnthropicContentBlock) (string, []ResponsesContentPart) {
|
|
if len(b.Content) == 0 {
|
|
return "(empty)", nil
|
|
}
|
|
|
|
// Try plain string content.
|
|
var s string
|
|
if err := json.Unmarshal(b.Content, &s); err == nil {
|
|
if s == "" {
|
|
s = "(empty)"
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// Array of content blocks — may contain text and/or images.
|
|
var inner []AnthropicContentBlock
|
|
if err := json.Unmarshal(b.Content, &inner); err != nil {
|
|
return "(empty)", nil
|
|
}
|
|
|
|
// Separate text (for function_call_output) from images (for user message).
|
|
var textParts []string
|
|
var imageParts []ResponsesContentPart
|
|
for _, ib := range inner {
|
|
switch ib.Type {
|
|
case "text":
|
|
if ib.Text != "" {
|
|
textParts = append(textParts, ib.Text)
|
|
}
|
|
case "image":
|
|
if uri := anthropicImageToDataURI(ib.Source); uri != "" {
|
|
imageParts = append(imageParts, ResponsesContentPart{Type: "input_image", ImageURL: uri})
|
|
}
|
|
}
|
|
}
|
|
|
|
text := strings.Join(textParts, "\n\n")
|
|
if text == "" {
|
|
text = "(empty)"
|
|
}
|
|
return text, imageParts
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to
|
|
// OpenAI Responses API effort levels.
|
|
//
|
|
// low → low
|
|
// medium → high
|
|
// high → xhigh
|
|
func mapAnthropicEffortToResponses(effort string) string {
|
|
switch effort {
|
|
case "medium":
|
|
return "high"
|
|
case "high":
|
|
return "xhigh"
|
|
default:
|
|
return effort // "low" and any unknown values pass through unchanged
|
|
}
|
|
}
|
|
|
|
// convertAnthropicToolsToResponses maps Anthropic tool definitions to
|
|
// Responses API tools. Server-side tools like web_search are mapped to their
|
|
// OpenAI equivalents; regular tools become function tools.
|
|
func convertAnthropicToolsToResponses(tools []AnthropicTool) []ResponsesTool {
|
|
var out []ResponsesTool
|
|
for _, t := range tools {
|
|
// Anthropic server tools like "web_search_20250305" → OpenAI {"type":"web_search"}
|
|
if strings.HasPrefix(t.Type, "web_search") {
|
|
out = append(out, ResponsesTool{Type: "web_search"})
|
|
continue
|
|
}
|
|
out = append(out, ResponsesTool{
|
|
Type: "function",
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: t.InputSchema,
|
|
})
|
|
}
|
|
return out
|
|
}
|