Files
cloudpods/pkg/aiproxy/providers/openai/anthropic_compat.go
Zexi Li 167682c39b feat(aiproxy): add Anthropic Messages API and migrate functional tests to Go (#25107)
Add /v1/messages handler with Anthropic-to-OpenAI translation, upstream
failover, and probe endpoints. Replace shell-based functional test scripts
with pkg/aiproxy/ft and climc test commands; consolidate documentation.
2026-07-07 20:13:14 +08:00

509 lines
14 KiB
Go

// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package openai
import (
"encoding/json"
"fmt"
"strings"
"yunion.io/x/jsonutils"
)
// AnthropicToChatCompletions converts an Anthropic Messages request body to OpenAI chat/completions shape.
func AnthropicToChatCompletions(body *jsonutils.JSONDict, upstreamModel string) (*jsonutils.JSONDict, error) {
if body == nil {
return nil, fmt.Errorf("nil request body")
}
out := jsonutils.NewDict()
model := strings.TrimSpace(upstreamModel)
if model == "" {
if m, err := body.GetString("model"); err == nil {
model = strings.TrimSpace(m)
}
}
if model == "" {
return nil, fmt.Errorf("missing model")
}
out.Set("model", jsonutils.NewString(model))
maxTokens, err := body.Int("max_tokens")
if err != nil || maxTokens <= 0 {
return nil, fmt.Errorf("max_tokens is required")
}
out.Set("max_tokens", jsonutils.NewInt(maxTokens))
if stream, _ := body.Bool("stream"); stream {
out.Set("stream", jsonutils.JSONTrue)
streamOpts := jsonutils.NewDict()
streamOpts.Set("include_usage", jsonutils.JSONTrue)
out.Set("stream_options", streamOpts)
}
if v, ok := FloatParam(body, "temperature"); ok {
out.Set("temperature", jsonutils.NewFloat64(v))
}
if v, ok := FloatParam(body, "top_p"); ok {
out.Set("top_p", jsonutils.NewFloat64(v))
}
if stops, err := body.Get("stop_sequences"); err == nil {
out.Set("stop", stops)
}
msgs, err := anthropicMessagesToOpenAI(body)
if err != nil {
return nil, err
}
out.Set("messages", msgs)
if tools, toolChoice, err := anthropicToolsToOpenAI(body); err != nil {
return nil, err
} else if tools != nil && tools.Length() > 0 {
out.Set("tools", tools)
if toolChoice != nil {
out.Set("tool_choice", toolChoice)
}
}
return out, nil
}
func anthropicMessagesToOpenAI(body *jsonutils.JSONDict) (*jsonutils.JSONArray, error) {
var systemParts []string
if sysRaw, err := body.Get("system"); err == nil {
if sysText := anthropicSystemText(sysRaw); sysText != "" {
systemParts = append(systemParts, sysText)
}
}
rawMsgs, err := body.Get("messages")
if err != nil {
return nil, fmt.Errorf("missing messages")
}
var messages []json.RawMessage
if err := json.Unmarshal([]byte(rawMsgs.String()), &messages); err != nil {
return nil, fmt.Errorf("invalid messages: %w", err)
}
converted := jsonutils.NewArray()
for _, raw := range messages {
var msg struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
if err := json.Unmarshal(raw, &msg); err != nil {
return nil, fmt.Errorf("invalid message: %w", err)
}
role := strings.ToLower(strings.TrimSpace(msg.Role))
switch role {
case "system":
sysText, err := anthropicMessageContentText(msg.Content)
if err != nil {
return nil, err
}
if sysText != "" {
systemParts = append(systemParts, sysText)
}
case "user":
parts, tools, err := parseAnthropicUserContent(msg.Content)
if err != nil {
return nil, err
}
for _, tr := range tools {
toolMsg := jsonutils.NewDict()
toolMsg.Set("role", jsonutils.NewString("tool"))
toolMsg.Set("tool_call_id", jsonutils.NewString(tr.ID))
toolMsg.Set("content", jsonutils.NewString(tr.Content))
converted.Add(toolMsg)
}
if parts != nil {
userMsg := jsonutils.NewDict()
userMsg.Set("role", jsonutils.NewString("user"))
userMsg.Set("content", parts)
converted.Add(userMsg)
}
case "assistant":
assistant, err := parseAnthropicAssistantContent(msg.Content)
if err != nil {
return nil, err
}
if assistant == nil {
continue
}
converted.Add(assistant)
default:
return nil, fmt.Errorf("unsupported message role %q", role)
}
}
if converted.Size() == 0 {
return nil, fmt.Errorf("no convertible messages")
}
arr := jsonutils.NewArray()
if len(systemParts) > 0 {
sysMsg := jsonutils.NewDict()
sysMsg.Set("role", jsonutils.NewString("system"))
sysMsg.Set("content", jsonutils.NewString(strings.Join(systemParts, "\n\n")))
arr.Add(sysMsg)
}
for i := 0; i < converted.Size(); i++ {
obj, err := converted.GetAt(i)
if err != nil {
return nil, err
}
arr.Add(obj)
}
return arr, nil
}
func anthropicMessageContentText(raw json.RawMessage) (string, error) {
if len(raw) == 0 {
return "", nil
}
parsed, err := jsonutils.Parse(raw)
if err != nil {
return "", fmt.Errorf("invalid message content: %w", err)
}
return anthropicSystemText(parsed), nil
}
type anthropicToolResult struct {
ID string
Content string
}
func anthropicSystemText(raw jsonutils.JSONObject) string {
if raw == nil {
return ""
}
var s string
if err := json.Unmarshal([]byte(raw.String()), &s); err == nil {
return strings.TrimSpace(s)
}
var blocks []struct {
Type string `json:"type"`
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(raw.String()), &blocks); err == nil {
var b strings.Builder
for _, blk := range blocks {
if blk.Type == "text" && blk.Text != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(blk.Text)
}
}
return b.String()
}
return ""
}
func parseAnthropicUserContent(raw json.RawMessage) (jsonutils.JSONObject, []anthropicToolResult, error) {
if len(raw) == 0 {
return nil, nil, nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if strings.TrimSpace(s) == "" {
return nil, nil, nil
}
return jsonutils.NewString(s), nil, nil
}
var blocks []map[string]interface{}
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, nil, fmt.Errorf("invalid user content: %w", err)
}
var textParts []string
var tools []anthropicToolResult
for _, blk := range blocks {
typ, _ := blk["type"].(string)
switch typ {
case "text":
if t, _ := blk["text"].(string); t != "" {
textParts = append(textParts, t)
}
case "tool_result":
id, _ := blk["tool_use_id"].(string)
content := anthropicBlockContentText(blk["content"])
tools = append(tools, anthropicToolResult{ID: id, Content: content})
}
}
if len(textParts) == 0 {
return nil, tools, nil
}
if len(textParts) == 1 {
return jsonutils.NewString(textParts[0]), tools, nil
}
parts := jsonutils.NewArray()
for _, p := range textParts {
blk := jsonutils.NewDict()
blk.Set("type", jsonutils.NewString("text"))
blk.Set("text", jsonutils.NewString(p))
parts.Add(blk)
}
return parts, tools, nil
}
func anthropicBlockContentText(v interface{}) string {
switch c := v.(type) {
case string:
return c
case []interface{}:
var parts []string
for _, item := range c {
if m, ok := item.(map[string]interface{}); ok {
if t, _ := m["text"].(string); t != "" {
parts = append(parts, t)
}
}
}
return strings.Join(parts, "\n")
default:
return fmt.Sprint(v)
}
}
func parseAnthropicAssistantContent(raw json.RawMessage) (*jsonutils.JSONDict, error) {
if len(raw) == 0 {
return nil, nil
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
if strings.TrimSpace(s) == "" {
return nil, nil
}
msg := jsonutils.NewDict()
msg.Set("role", jsonutils.NewString("assistant"))
msg.Set("content", jsonutils.NewString(s))
return msg, nil
}
var blocks []AnthropicBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return nil, fmt.Errorf("invalid assistant content: %w", err)
}
assistant := AnthropicBlocksToAssistant(blocks)
msg := jsonutils.NewDict()
msg.Set("role", jsonutils.NewString("assistant"))
if assistant.Content != "" {
msg.Set("content", jsonutils.NewString(assistant.Content))
}
if len(assistant.ToolCalls) > 0 {
calls := jsonutils.NewArray()
for _, tc := range assistant.ToolCalls {
call := jsonutils.NewDict()
call.Set("id", jsonutils.NewString(tc.ID))
call.Set("type", jsonutils.NewString("function"))
fn := jsonutils.NewDict()
fn.Set("name", jsonutils.NewString(tc.Function.Name))
fn.Set("arguments", jsonutils.NewString(tc.Function.Arguments))
call.Set("function", fn)
calls.Add(call)
}
msg.Set("tool_calls", calls)
}
return msg, nil
}
func anthropicToolsToOpenAI(body *jsonutils.JSONDict) (*jsonutils.JSONArray, jsonutils.JSONObject, error) {
rawTools, err := body.Get("tools")
if err != nil {
return nil, nil, nil
}
var toolsIn []struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
if err := json.Unmarshal([]byte(rawTools.String()), &toolsIn); err != nil {
return nil, nil, fmt.Errorf("invalid tools: %w", err)
}
if len(toolsIn) == 0 {
return nil, nil, nil
}
out := jsonutils.NewArray()
for _, t := range toolsIn {
name := strings.TrimSpace(t.Name)
if name == "" {
continue
}
tool := jsonutils.NewDict()
tool.Set("type", jsonutils.NewString("function"))
fn := jsonutils.NewDict()
fn.Set("name", jsonutils.NewString(name))
if desc := strings.TrimSpace(t.Description); desc != "" {
fn.Set("description", jsonutils.NewString(desc))
}
if len(t.InputSchema) > 0 && string(t.InputSchema) != "null" {
if params, err := jsonutils.Parse(t.InputSchema); err == nil {
fn.Set("parameters", params)
}
}
tool.Set("function", fn)
out.Add(tool)
}
var toolChoice jsonutils.JSONObject
if tcRaw, err := body.Get("tool_choice"); err == nil {
toolChoice = anthropicToolChoiceToOpenAI(tcRaw)
}
return out, toolChoice, nil
}
func anthropicToolChoiceToOpenAI(raw jsonutils.JSONObject) jsonutils.JSONObject {
var obj map[string]interface{}
if err := json.Unmarshal([]byte(raw.String()), &obj); err != nil {
return nil
}
typ, _ := obj["type"].(string)
switch strings.ToLower(strings.TrimSpace(typ)) {
case "auto", "":
return jsonutils.NewString("auto")
case "none":
return jsonutils.NewString("none")
case "any":
return jsonutils.NewString("required")
case "tool":
name, _ := obj["name"].(string)
if strings.TrimSpace(name) == "" {
return jsonutils.NewString("required")
}
choice := jsonutils.NewDict()
choice.Set("type", jsonutils.NewString("function"))
fn := jsonutils.NewDict()
fn.Set("name", jsonutils.NewString(strings.TrimSpace(name)))
choice.Set("function", fn)
return choice
default:
return nil
}
}
// ChatCompletionToAnthropic converts an OpenAI chat.completion JSON body to Anthropic Messages response.
func ChatCompletionToAnthropic(body []byte) ([]byte, error) {
var resp struct {
ID string `json:"id"`
Model string `json:"model"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
ToolCalls []ToolCall `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("invalid OpenAI response: %w", err)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("empty OpenAI choices")
}
choice := resp.Choices[0]
blocks := make([]map[string]interface{}, 0, 1+len(choice.Message.ToolCalls))
if text := MessageTextContent(choice.Message.Content); text != "" {
blocks = append(blocks, map[string]interface{}{
"type": "text",
"text": text,
})
}
for _, tc := range choice.Message.ToolCalls {
input := map[string]interface{}{}
if args := strings.TrimSpace(tc.Function.Arguments); args != "" {
_ = json.Unmarshal([]byte(args), &input)
}
id := strings.TrimSpace(tc.ID)
if id == "" {
id = "toolu_" + strings.TrimSpace(tc.Function.Name)
}
blocks = append(blocks, map[string]interface{}{
"type": "tool_use",
"id": id,
"name": strings.TrimSpace(tc.Function.Name),
"input": input,
})
}
stopReason := openAIFinishReasonToAnthropic(choice.FinishReason)
out := map[string]interface{}{
"id": resp.ID,
"type": "message",
"role": "assistant",
"model": resp.Model,
"content": blocks,
"stop_reason": stopReason,
"usage": map[string]interface{}{
"input_tokens": resp.Usage.PromptTokens,
"output_tokens": resp.Usage.CompletionTokens,
},
}
return json.Marshal(out)
}
func openAIFinishReasonToAnthropic(reason string) string {
switch strings.TrimSpace(reason) {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls", "function_call":
return "tool_use"
default:
if reason == "" {
return "end_turn"
}
return reason
}
}
// OpenAIErrorToAnthropic converts an OpenAI-style error JSON body to Anthropic error format.
func OpenAIErrorToAnthropic(body []byte, statusCode int) []byte {
msg := "upstream request failed"
var wrap struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
if json.Unmarshal(body, &wrap) == nil && wrap.Error.Message != "" {
msg = wrap.Error.Message
}
errType := "api_error"
if statusCode == 400 {
errType = "invalid_request_error"
} else if statusCode == 401 {
errType = "authentication_error"
} else if statusCode == 429 {
errType = "rate_limit_error"
}
out, _ := json.Marshal(map[string]interface{}{
"type": "error",
"error": map[string]interface{}{
"type": errType,
"message": msg,
},
})
return out
}
// NewAnthropicErrorBody builds an Anthropic-style error response body.
func NewAnthropicErrorBody(errType, message string) []byte {
out, _ := json.Marshal(map[string]interface{}{
"type": "error",
"error": map[string]interface{}{
"type": errType,
"message": message,
},
})
return out
}