mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/yunionio/cloudpods.git
synced 2026-09-20 08:03:53 +08:00
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.
509 lines
14 KiB
Go
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
|
|
}
|