382 lines
12 KiB
Go
382 lines
12 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
type openAIChatBridgeResponse struct {
|
|
ID string `json:"id"`
|
|
Model string `json:"model"`
|
|
Choices []struct {
|
|
Message struct {
|
|
Role string `json:"role"`
|
|
Content any `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
Reasoning string `json:"reasoning"`
|
|
ToolCalls []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
Arguments string `json:"arguments"`
|
|
} `json:"function"`
|
|
} `json:"tool_calls"`
|
|
} `json:"message"`
|
|
FinishReason *string `json:"finish_reason"`
|
|
} `json:"choices"`
|
|
Usage openAIChatBridgeUsage `json:"usage"`
|
|
}
|
|
|
|
type openAIChatBridgeUsage struct {
|
|
PromptTokens int `json:"prompt_tokens"`
|
|
CompletionTokens int `json:"completion_tokens"`
|
|
PromptDetails struct {
|
|
CachedTokens int `json:"cached_tokens"`
|
|
} `json:"prompt_tokens_details"`
|
|
}
|
|
|
|
type openAIChatBridgeError struct {
|
|
Error struct {
|
|
Type string `json:"type"`
|
|
Message string `json:"message"`
|
|
Code any `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
|
|
func prepareAnthropicChatBridge(body []byte, target string, profile config.ConcreteProtocolProfile) ([]byte, anthropicMessageRequest, error) {
|
|
req, err := decodeAnthropicMessageRequest(body, true)
|
|
if err != nil {
|
|
return nil, req, err
|
|
}
|
|
if req.TopK != nil {
|
|
return nil, req, fmt.Errorf("top_k is not supported by the Chat bridge")
|
|
}
|
|
if req.Thinking != nil && !profileSupportsAnthropicThinking(profile) {
|
|
return nil, req, fmt.Errorf("selected Chat profile does not support thinking")
|
|
}
|
|
|
|
messages := make([]map[string]any, 0, len(req.Messages)+1)
|
|
systemBlocks, err := decodeAnthropicSystem(req.System)
|
|
if err != nil {
|
|
return nil, req, err
|
|
}
|
|
if len(systemBlocks) > 0 {
|
|
parts := make([]string, 0, len(systemBlocks))
|
|
for _, block := range systemBlocks {
|
|
parts = append(parts, block.Text)
|
|
}
|
|
messages = append(messages, map[string]any{"role": "system", "content": strings.Join(parts, "\n")})
|
|
}
|
|
|
|
for index, message := range req.Messages {
|
|
blocks, err := decodeAnthropicContent(message.Content)
|
|
if err != nil {
|
|
return nil, req, fmt.Errorf("messages[%d].content: %w", index, err)
|
|
}
|
|
converted, err := anthropicMessageToChat(message.Role, blocks, profile)
|
|
if err != nil {
|
|
return nil, req, fmt.Errorf("messages[%d]: %w", index, err)
|
|
}
|
|
messages = append(messages, converted...)
|
|
}
|
|
|
|
chat := map[string]any{
|
|
"model": target,
|
|
"messages": messages,
|
|
"max_tokens": *req.MaxTokens,
|
|
"stream": req.Stream,
|
|
}
|
|
if req.Stream {
|
|
chat["stream_options"] = map[string]any{"include_usage": true}
|
|
}
|
|
if req.Temperature != nil {
|
|
chat["temperature"] = *req.Temperature
|
|
}
|
|
if req.TopP != nil {
|
|
chat["top_p"] = *req.TopP
|
|
}
|
|
if len(req.StopSequences) > 0 {
|
|
chat["stop"] = req.StopSequences
|
|
}
|
|
if len(req.Metadata) > 0 && !bytes.Equal(bytes.TrimSpace(req.Metadata), []byte("null")) {
|
|
var metadata map[string]any
|
|
if err := json.Unmarshal(req.Metadata, &metadata); err != nil {
|
|
return nil, req, fmt.Errorf("metadata must be an object")
|
|
}
|
|
chat["metadata"] = metadata
|
|
}
|
|
if len(req.Tools) > 0 {
|
|
tools := make([]map[string]any, 0, len(req.Tools))
|
|
for _, tool := range req.Tools {
|
|
var schema map[string]any
|
|
if err := json.Unmarshal(tool.InputSchema, &schema); err != nil {
|
|
return nil, req, fmt.Errorf("tool %q input_schema is invalid", tool.Name)
|
|
}
|
|
function := map[string]any{"name": tool.Name, "parameters": schema}
|
|
if tool.Description != "" {
|
|
function["description"] = tool.Description
|
|
}
|
|
tools = append(tools, map[string]any{"type": "function", "function": function})
|
|
}
|
|
chat["tools"] = tools
|
|
}
|
|
if req.ToolChoice != nil {
|
|
choice, parallel := anthropicToolChoiceToChat(*req.ToolChoice)
|
|
chat["tool_choice"] = choice
|
|
if parallel != nil {
|
|
chat["parallel_tool_calls"] = *parallel
|
|
}
|
|
}
|
|
if req.Thinking != nil {
|
|
chat["think"] = true
|
|
chat["include_reasoning"] = true
|
|
chat["thinking_token_budget"] = req.Thinking.BudgetTokens
|
|
}
|
|
encoded, err := json.Marshal(chat)
|
|
if err != nil {
|
|
return nil, req, fmt.Errorf("encode Chat bridge request: %w", err)
|
|
}
|
|
return encoded, req, nil
|
|
}
|
|
|
|
func anthropicMessageToChat(role string, blocks []anthropicContentBlock, profile config.ConcreteProtocolProfile) ([]map[string]any, error) {
|
|
if role == "assistant" {
|
|
return anthropicAssistantToChat(blocks, profile)
|
|
}
|
|
var out []map[string]any
|
|
var content []map[string]any
|
|
flushContent := func() {
|
|
if len(content) == 0 {
|
|
return
|
|
}
|
|
out = append(out, map[string]any{"role": "user", "content": content})
|
|
content = nil
|
|
}
|
|
for _, block := range blocks {
|
|
switch block.Type {
|
|
case "text":
|
|
content = append(content, map[string]any{"type": "text", "text": block.Text})
|
|
case "image":
|
|
url := block.Source.URL
|
|
if block.Source.Type == "base64" {
|
|
url = "data:" + block.Source.MediaType + ";base64," + block.Source.Data
|
|
}
|
|
content = append(content, map[string]any{"type": "image_url", "image_url": map[string]any{"url": url}})
|
|
case "tool_result":
|
|
flushContent()
|
|
text, err := anthropicToolResultText(block.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if block.IsError {
|
|
text = "Error: " + text
|
|
}
|
|
out = append(out, map[string]any{"role": "tool", "tool_call_id": block.ToolUseID, "content": text})
|
|
default:
|
|
return nil, fmt.Errorf("content block %q is invalid for a user message", block.Type)
|
|
}
|
|
}
|
|
flushContent()
|
|
if len(out) == 0 {
|
|
return nil, fmt.Errorf("user message content is empty")
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func anthropicAssistantToChat(blocks []anthropicContentBlock, profile config.ConcreteProtocolProfile) ([]map[string]any, error) {
|
|
message := map[string]any{"role": "assistant"}
|
|
var content []map[string]any
|
|
var reasoning []string
|
|
var toolCalls []map[string]any
|
|
for _, block := range blocks {
|
|
switch block.Type {
|
|
case "text":
|
|
content = append(content, map[string]any{"type": "text", "text": block.Text})
|
|
case "thinking":
|
|
if !profileSupportsAnthropicThinking(profile) {
|
|
return nil, fmt.Errorf("selected Chat profile does not support thinking blocks")
|
|
}
|
|
if block.Signature != "" {
|
|
return nil, fmt.Errorf("signed thinking blocks cannot be represented by the Chat bridge")
|
|
}
|
|
reasoning = append(reasoning, block.Thinking)
|
|
case "tool_use":
|
|
toolCalls = append(toolCalls, map[string]any{
|
|
"id": block.ID, "type": "function",
|
|
"function": map[string]any{"name": block.Name, "arguments": string(block.Input)},
|
|
})
|
|
default:
|
|
return nil, fmt.Errorf("content block %q is invalid for an assistant message", block.Type)
|
|
}
|
|
}
|
|
if len(content) > 0 {
|
|
message["content"] = content
|
|
} else {
|
|
message["content"] = nil
|
|
}
|
|
if len(reasoning) > 0 {
|
|
message["reasoning_content"] = strings.Join(reasoning, "")
|
|
}
|
|
if len(toolCalls) > 0 {
|
|
message["tool_calls"] = toolCalls
|
|
}
|
|
return []map[string]any{message}, nil
|
|
}
|
|
|
|
func anthropicToolResultText(raw json.RawMessage) (string, error) {
|
|
if len(bytes.TrimSpace(raw)) == 0 || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) {
|
|
return "", nil
|
|
}
|
|
var text string
|
|
if err := json.Unmarshal(raw, &text); err == nil {
|
|
return text, nil
|
|
}
|
|
blocks, err := decodeAnthropicContent(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts := make([]string, 0, len(blocks))
|
|
for _, block := range blocks {
|
|
if block.Type != "text" {
|
|
return "", fmt.Errorf("Chat bridge tool_result supports only text content")
|
|
}
|
|
parts = append(parts, block.Text)
|
|
}
|
|
return strings.Join(parts, ""), nil
|
|
}
|
|
|
|
func anthropicToolChoiceToChat(choice anthropicToolChoice) (any, *bool) {
|
|
parallel := !choice.DisableParallelToolUse
|
|
switch choice.Type {
|
|
case "auto":
|
|
return "auto", ¶llel
|
|
case "any":
|
|
return "required", ¶llel
|
|
case "none":
|
|
return "none", ¶llel
|
|
default:
|
|
return map[string]any{"type": "function", "function": map[string]any{"name": choice.Name}}, ¶llel
|
|
}
|
|
}
|
|
|
|
func profileSupportsAnthropicThinking(profile config.ConcreteProtocolProfile) bool {
|
|
for _, key := range []string{"thinking", "reasoning"} {
|
|
value, ok := profile.Extensions[key]
|
|
if enabled, valid := value.(bool); ok && valid && enabled {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func convertChatResponseToAnthropic(body []byte, requestModel string) (anthropicMessageResponse, error) {
|
|
var response openAIChatBridgeResponse
|
|
if err := json.Unmarshal(body, &response); err != nil {
|
|
return anthropicMessageResponse{}, fmt.Errorf("decode Chat response: %w", err)
|
|
}
|
|
if len(response.Choices) != 1 {
|
|
return anthropicMessageResponse{}, fmt.Errorf("Chat response must contain exactly one choice")
|
|
}
|
|
choice := response.Choices[0]
|
|
content := make([]map[string]any, 0, 2+len(choice.Message.ToolCalls))
|
|
reasoning := choice.Message.ReasoningContent
|
|
if reasoning == "" {
|
|
reasoning = choice.Message.Reasoning
|
|
}
|
|
if reasoning != "" {
|
|
content = append(content, map[string]any{"type": "thinking", "thinking": reasoning, "signature": ""})
|
|
}
|
|
text, err := openAIChatContentText(choice.Message.Content)
|
|
if err != nil {
|
|
return anthropicMessageResponse{}, err
|
|
}
|
|
if text != "" {
|
|
content = append(content, map[string]any{"type": "text", "text": text})
|
|
}
|
|
for _, call := range choice.Message.ToolCalls {
|
|
if call.ID == "" || call.Function.Name == "" || !json.Valid([]byte(call.Function.Arguments)) {
|
|
return anthropicMessageResponse{}, fmt.Errorf("Chat tool call has invalid id, name, or arguments")
|
|
}
|
|
var input any
|
|
if err := json.Unmarshal([]byte(call.Function.Arguments), &input); err != nil {
|
|
return anthropicMessageResponse{}, fmt.Errorf("decode Chat tool arguments: %w", err)
|
|
}
|
|
content = append(content, map[string]any{"type": "tool_use", "id": call.ID, "name": call.Function.Name, "input": input})
|
|
}
|
|
stopReason, err := anthropicStopReason(choice.FinishReason)
|
|
if err != nil {
|
|
return anthropicMessageResponse{}, err
|
|
}
|
|
id := response.ID
|
|
if id == "" {
|
|
id = "msg_iop"
|
|
}
|
|
return anthropicMessageResponse{
|
|
ID: id, Type: "message", Role: "assistant", Model: requestModel,
|
|
Content: content, StopReason: stopReason, StopSequence: nil,
|
|
Usage: anthropicUsage{
|
|
InputTokens: response.Usage.PromptTokens, OutputTokens: response.Usage.CompletionTokens,
|
|
CacheReadInputTokens: response.Usage.PromptDetails.CachedTokens,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func openAIChatContentText(content any) (string, error) {
|
|
switch value := content.(type) {
|
|
case nil:
|
|
return "", nil
|
|
case string:
|
|
return value, nil
|
|
case []any:
|
|
var parts []string
|
|
for _, item := range value {
|
|
block, ok := item.(map[string]any)
|
|
if !ok || block["type"] != "text" {
|
|
return "", fmt.Errorf("unsupported Chat response content block")
|
|
}
|
|
text, ok := block["text"].(string)
|
|
if !ok {
|
|
return "", fmt.Errorf("Chat response text block is invalid")
|
|
}
|
|
parts = append(parts, text)
|
|
}
|
|
return strings.Join(parts, ""), nil
|
|
default:
|
|
return "", fmt.Errorf("unsupported Chat response content")
|
|
}
|
|
}
|
|
|
|
func anthropicStopReason(finishReason *string) (*string, error) {
|
|
if finishReason == nil || *finishReason == "" {
|
|
return nil, nil
|
|
}
|
|
var reason string
|
|
switch *finishReason {
|
|
case "stop":
|
|
reason = "end_turn"
|
|
case "length":
|
|
reason = "max_tokens"
|
|
case "tool_calls":
|
|
reason = "tool_use"
|
|
default:
|
|
return nil, fmt.Errorf("unsupported Chat finish_reason %q", *finishReason)
|
|
}
|
|
return &reason, nil
|
|
}
|
|
|
|
func convertChatErrorToAnthropic(body []byte) anthropicErrorResponse {
|
|
var provider openAIChatBridgeError
|
|
if json.Unmarshal(body, &provider) == nil && strings.TrimSpace(provider.Error.Message) != "" {
|
|
errorType := strings.TrimSpace(provider.Error.Type)
|
|
if errorType == "" {
|
|
errorType = "api_error"
|
|
}
|
|
return anthropicErrorResponse{Type: "error", Error: errorBody{Type: errorType, Message: provider.Error.Message}}
|
|
}
|
|
return anthropicErrorResponse{Type: "error", Error: errorBody{Type: "api_error", Message: "upstream provider error"}}
|
|
}
|