iop/apps/edge/internal/openai/provider_normalization.go

821 lines
29 KiB
Go

package openai
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"strings"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/go/config"
)
// providerRequestRequirements is the caller-neutral request shape used for
// provider operation selection. Agent or SDK identity is intentionally absent.
type providerRequestRequirements struct {
Effort string
HasTools bool
HasTokenBudget bool
Stream bool
StructuredOutput bool
}
type providerOperationPlan struct {
Operation config.ProtocolOperation
Effort string
EffortWire string
}
type openAIResponsesBridgeResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
Output []struct {
Type string `json:"type"`
ID string `json:"id"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments string `json:"arguments"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Summary []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"summary"`
} `json:"output"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
InputDetails struct {
CachedTokens int `json:"cached_tokens"`
} `json:"input_tokens_details"`
} `json:"usage"`
IncompleteDetails struct {
Reason string `json:"reason"`
} `json:"incomplete_details"`
}
// singleRequestChatBridgeResponse admits the portable Chat Completions response
// fields needed by private stages. Provider-selected bookkeeping such as
// service_tier, system_fingerprint, annotations, and logprobs is intentionally
// left outside the canonical stage envelope.
type singleRequestChatBridgeResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []singleRequestChatBridgeChoice `json:"choices"`
Usage json.RawMessage `json:"usage"`
}
type singleRequestChatBridgeChoice struct {
Index int `json:"index"`
FinishReason string `json:"finish_reason"`
Message singleRequestChatBridgeMessage `json:"message"`
}
type singleRequestChatBridgeMessage struct {
Role string `json:"role"`
Content *string `json:"content"`
ToolCalls json.RawMessage `json:"tool_calls"`
ReasoningContent *string `json:"reasoning_content"`
ExtraContent json.RawMessage `json:"extra_content"`
Refusal json.RawMessage `json:"refusal"`
}
func (v *singleRequestChatBridgeResponse) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "id", "object", "created", "model", "choices", "usage", "service_tier", "system_fingerprint", "timings"); err != nil {
return err
}
type alias singleRequestChatBridgeResponse
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeResponse(decoded)
return nil
}
func (v *singleRequestChatBridgeChoice) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "index", "finish_reason", "message", "logprobs"); err != nil {
return err
}
type alias singleRequestChatBridgeChoice
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeChoice(decoded)
return nil
}
func (v *singleRequestChatBridgeMessage) UnmarshalJSON(data []byte) error {
if err := validateSingleRequestObjectFields(data, "role", "content", "tool_calls", "reasoning_content", "extra_content", "refusal", "annotations"); err != nil {
return err
}
type alias singleRequestChatBridgeMessage
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*v = singleRequestChatBridgeMessage(decoded)
return nil
}
func singleRequestProviderRequirements(options map[string]any, tools []any, responseFormat *singleRequestProviderResponseFormat) providerRequestRequirements {
requirements := providerRequestRequirements{HasTools: len(tools) > 0, StructuredOutput: responseFormat != nil}
if effort, ok := options["reasoning_effort"].(string); ok {
requirements.Effort = strings.TrimSpace(effort)
}
return requirements
}
func singleRequestProviderCandidatePredicate(frozen *edgeservice.SingleRequestStageDispatchBinding, requirements providerRequestRequirements) edgeservice.ProviderPoolCandidatePredicate {
return func(candidate edgeservice.ProviderPoolCandidate) bool {
if frozen == nil || candidate.ProtocolProfile == nil || candidate.ExecutionPath != string(edgeservice.ProviderPoolPathTunnel) {
return false
}
if frozen.CandidatePredicate != nil && !frozen.CandidatePredicate(candidate) {
return false
}
_, err := selectProviderOperation(*candidate.ProtocolProfile, config.OperationChatCompletions, requirements)
return err == nil
}
}
func singleRequestProviderTunnelPreparer(requirements providerRequestRequirements, buildChat singleRequestProviderBodyBuilder) func(edgeservice.SubmitProviderTunnelRequest, edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
return func(tunnel edgeservice.SubmitProviderTunnelRequest, candidate edgeservice.ProviderPoolCandidate) (edgeservice.SubmitProviderTunnelRequest, error) {
if candidate.ProtocolProfile == nil || buildChat == nil {
return tunnel, errProviderStageMissingBinding
}
plan, err := selectProviderOperation(*candidate.ProtocolProfile, config.OperationChatCompletions, requirements)
if err != nil {
return tunnel, err
}
tunnel.Operation = string(plan.Operation)
switch plan.Operation {
case config.OperationChatCompletions:
tunnel.Path = "/v1/chat/completions"
case config.OperationResponses:
tunnel.Path = "/v1/responses"
default:
return tunnel, errProviderStageMissingBinding
}
tunnel.BuildBody = func(target string) ([]byte, error) {
body, err := buildChat(target)
if err != nil {
return nil, err
}
return normalizeSingleRequestProviderRequest(body, plan)
}
return tunnel, nil
}
}
func normalizeSingleRequestProviderRequest(chatBody []byte, plan providerOperationPlan) ([]byte, error) {
var body map[string]any
if err := json.Unmarshal(chatBody, &body); err != nil {
return nil, errProviderStageMalformed
}
if plan.Operation == config.OperationChatCompletions {
if plan.Effort != "" {
body["reasoning_effort"] = plan.Effort
}
return json.Marshal(body)
}
if plan.Operation != config.OperationResponses {
return nil, errProviderStageMalformed
}
return singleRequestChatToResponses(body, plan)
}
func singleRequestChatToResponses(chat map[string]any, plan providerOperationPlan) ([]byte, error) {
responses := map[string]any{"model": chat["model"], "stream": false}
for _, key := range []string{"temperature", "top_p", "service_tier"} {
if value, exists := chat[key]; exists {
responses[key] = value
}
}
for _, key := range []string{"max_completion_tokens", "max_tokens"} {
if value, exists := chat[key]; exists {
responses["max_output_tokens"] = value
break
}
}
messages, ok := chat["messages"].([]any)
if !ok {
return nil, errProviderStageMalformed
}
input := make([]any, 0, len(messages))
instructions := make([]string, 0, 1)
for _, raw := range messages {
message, ok := raw.(map[string]any)
if !ok {
return nil, errProviderStageMalformed
}
role, _ := message["role"].(string)
content, _ := message["content"].(string)
switch role {
case "system":
if content != "" {
instructions = append(instructions, content)
}
case "user":
input = append(input, map[string]any{"type": "message", "role": "user", "content": []any{map[string]any{"type": "input_text", "text": content}}})
case "assistant":
if content != "" {
input = append(input, map[string]any{"type": "message", "role": "assistant", "content": []any{map[string]any{"type": "output_text", "text": content}}})
}
for _, rawCall := range anySlice(message["tool_calls"]) {
call, ok := rawCall.(map[string]any)
function, functionOK := call["function"].(map[string]any)
if !ok || !functionOK {
return nil, errProviderStageMalformed
}
input = append(input, map[string]any{"type": "function_call", "call_id": call["id"], "name": function["name"], "arguments": function["arguments"]})
}
case "tool":
input = append(input, map[string]any{"type": "function_call_output", "call_id": message["tool_call_id"], "output": content})
default:
return nil, errProviderStageMalformed
}
}
if len(instructions) > 0 {
responses["instructions"] = strings.Join(instructions, "\n\n")
}
responses["input"] = input
if plan.Effort != "" {
responses["reasoning"] = map[string]any{"effort": plan.Effort}
}
if rawTools := anySlice(chat["tools"]); len(rawTools) > 0 {
tools := make([]any, 0, len(rawTools))
for _, rawTool := range rawTools {
tool, ok := rawTool.(map[string]any)
function, functionOK := tool["function"].(map[string]any)
if !ok || !functionOK {
return nil, errProviderStageMalformed
}
converted := map[string]any{"type": "function", "name": function["name"], "parameters": function["parameters"]}
for _, key := range []string{"description", "strict"} {
if value, exists := function[key]; exists {
converted[key] = value
}
}
tools = append(tools, converted)
}
responses["tools"] = tools
}
if choice, ok := chat["tool_choice"]; ok {
responses["tool_choice"] = singleRequestResponsesToolChoice(choice)
}
if parallel, ok := chat["parallel_tool_calls"]; ok {
responses["parallel_tool_calls"] = parallel
}
if format, ok := chat["response_format"].(map[string]any); ok {
if schema, schemaOK := format["json_schema"].(map[string]any); schemaOK {
converted := map[string]any{"type": "json_schema"}
for _, key := range []string{"name", "strict", "schema"} {
converted[key] = schema[key]
}
responses["text"] = map[string]any{"format": converted}
}
}
return json.Marshal(responses)
}
func anySlice(value any) []any {
if value == nil {
return nil
}
items, _ := value.([]any)
return items
}
func singleRequestResponsesToolChoice(choice any) any {
if object, ok := choice.(map[string]any); ok {
if function, functionOK := object["function"].(map[string]any); functionOK {
return map[string]any{"type": "function", "name": function["name"]}
}
}
return choice
}
func normalizeSingleRequestProviderResponse(body []byte, dispatch edgeservice.RunDispatch) ([]byte, error) {
switch dispatch.ProfileOperation {
case string(config.OperationResponses):
return normalizeSingleRequestResponsesResponse(body)
case string(config.OperationChatCompletions), "":
return normalizeSingleRequestChatResponse(body)
default:
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
}
func normalizeSingleRequestChatResponse(body []byte) ([]byte, error) {
if err := validateSingleRequestJSON(body); err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
var response singleRequestChatBridgeResponse
if err := json.Unmarshal(body, &response); err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
choices := make([]any, 0, len(response.Choices))
for _, choice := range response.Choices {
if refusal := bytes.TrimSpace(choice.Message.Refusal); len(refusal) > 0 && !bytes.Equal(refusal, []byte("null")) {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
message := map[string]any{
"role": choice.Message.Role,
"content": choice.Message.Content,
}
if len(choice.Message.ToolCalls) > 0 {
message["tool_calls"] = choice.Message.ToolCalls
}
if choice.Message.ReasoningContent != nil {
message["reasoning_content"] = choice.Message.ReasoningContent
}
if len(choice.Message.ExtraContent) > 0 {
message["extra_content"] = choice.Message.ExtraContent
}
choices = append(choices, map[string]any{
"index": choice.Index, "finish_reason": choice.FinishReason, "message": message,
})
}
canonical := map[string]any{
"id": response.ID, "object": response.Object, "created": response.Created,
"model": response.Model, "choices": choices,
}
if len(response.Usage) > 0 && !bytes.Equal(bytes.TrimSpace(response.Usage), []byte("null")) {
canonical["usage"] = response.Usage
}
encoded, err := json.Marshal(canonical)
if err != nil {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
return encoded, nil
}
func normalizeSingleRequestResponsesResponse(body []byte) ([]byte, error) {
var response openAIResponsesBridgeResponse
if err := json.Unmarshal(body, &response); err != nil || strings.TrimSpace(response.ID) == "" {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
content := ""
toolCalls := make([]any, 0)
for _, item := range response.Output {
switch item.Type {
case "message":
for _, part := range item.Content {
if part.Type == "output_text" {
content += part.Text
}
}
case "function_call":
if item.CallID == "" || item.Name == "" || !json.Valid([]byte(item.Arguments)) {
return nil, errors.Join(errProviderStageGeneric, errProviderStageMalformed)
}
toolCalls = append(toolCalls, map[string]any{"id": item.CallID, "type": "function", "function": map[string]any{"name": item.Name, "arguments": item.Arguments}})
}
}
finishReason := "stop"
if len(toolCalls) > 0 {
finishReason = "tool_calls"
} else if response.Status == "incomplete" && response.IncompleteDetails.Reason == "max_output_tokens" {
finishReason = "length"
}
var contentValue any = content
if len(toolCalls) > 0 && content == "" {
contentValue = nil
}
chat := map[string]any{
"id": response.ID, "object": "chat.completion", "created": int64(0), "model": response.Model,
"choices": []any{map[string]any{"index": 0, "finish_reason": finishReason, "message": map[string]any{"role": "assistant", "content": contentValue, "tool_calls": toolCalls}}},
}
return json.Marshal(chat)
}
func anthropicProviderRequirements(req anthropicMessageRequest) providerRequestRequirements {
requirements := providerRequestRequirements{
HasTools: len(req.Tools) > 0,
Stream: req.Stream,
HasTokenBudget: req.Thinking != nil && req.Thinking.Type == "enabled",
StructuredOutput: req.OutputConfig != nil && req.OutputConfig.Format != nil,
}
if req.OutputConfig != nil {
requirements.Effort = strings.TrimSpace(req.OutputConfig.Effort)
}
return requirements
}
func decodeAnthropicProviderRequirements(body []byte) (providerRequestRequirements, error) {
var request struct {
Stream bool `json:"stream"`
Tools []json.RawMessage `json:"tools"`
Thinking *anthropicThinkingConfig `json:"thinking"`
OutputConfig *anthropicOutputConfig `json:"output_config"`
}
if err := json.Unmarshal(body, &request); err != nil {
return providerRequestRequirements{}, fmt.Errorf("decode Messages request")
}
return anthropicProviderRequirements(anthropicMessageRequest{
Stream: request.Stream, Tools: make([]anthropicTool, len(request.Tools)),
Thinking: request.Thinking, OutputConfig: request.OutputConfig,
}), nil
}
func decodeResponsesProviderRequirements(body []byte) (providerRequestRequirements, error) {
var request struct {
Tools []json.RawMessage `json:"tools"`
Reasoning *struct {
Effort string `json:"effort"`
} `json:"reasoning"`
}
if err := json.Unmarshal(body, &request); err != nil {
return providerRequestRequirements{}, fmt.Errorf("decode Responses request")
}
requirements := providerRequestRequirements{HasTools: len(request.Tools) > 0}
if request.Reasoning != nil {
requirements.Effort = strings.TrimSpace(request.Reasoning.Effort)
}
return requirements, nil
}
func responsesCandidatePredicate(requirements providerRequestRequirements) edgeservice.ProviderPoolCandidatePredicate {
return func(candidate edgeservice.ProviderPoolCandidate) bool {
// A nil profile is the legacy tunnel contract: operation resolution is
// deferred to the existing dispatch path, which must remain compatible.
if candidate.ProtocolProfile == nil {
return true
}
if candidate.ExecutionPath != string(edgeservice.ProviderPoolPathTunnel) {
return requirements.Effort == "" && !requirements.HasTools && !requirements.HasTokenBudget
}
_, err := selectProviderOperation(*candidate.ProtocolProfile, config.OperationResponses, requirements)
return err == nil
}
}
func rewriteResponsesProviderControls(body []byte, target string, plan providerOperationPlan) ([]byte, error) {
patches := make([]topLevelJSONPatch, 0, 2)
if strings.TrimSpace(target) != "" {
modelJSON, err := json.Marshal(target)
if err != nil {
return nil, err
}
patches = append(patches, topLevelJSONPatch{name: "model", value: modelJSON})
}
if plan.Effort != "" {
var root map[string]json.RawMessage
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("decode Responses request")
}
var reasoning map[string]any
if raw := root["reasoning"]; len(raw) > 0 && string(raw) != "null" {
if err := json.Unmarshal(raw, &reasoning); err != nil {
return nil, fmt.Errorf("reasoning must be an object")
}
}
if reasoning == nil {
reasoning = make(map[string]any)
}
reasoning["effort"] = plan.Effort
reasoningJSON, err := json.Marshal(reasoning)
if err != nil {
return nil, err
}
patches = append(patches, topLevelJSONPatch{name: "reasoning", value: reasoningJSON})
}
if len(patches) == 0 {
return body, nil
}
patchPlan, err := planTopLevelJSONPatches(body, patches)
if err != nil {
return nil, err
}
return patchPlan.apply(), nil
}
// selectProviderOperation chooses an operation solely from normalized request
// requirements and the selected provider profile. The order prefers the
// closest wire surface, but only an operation that preserves every declared
// requirement is eligible.
func selectProviderOperation(profile config.ConcreteProtocolProfile, ingress config.ProtocolOperation, requirements providerRequestRequirements) (providerOperationPlan, error) {
operations := []config.ProtocolOperation{ingress}
switch ingress {
case config.OperationMessages:
switch profile.Driver {
case config.ProtocolDriverAnthropicMessages:
operations = []config.ProtocolOperation{config.OperationMessages}
case config.ProtocolDriverOpenAIChat:
operations = []config.ProtocolOperation{config.OperationChatCompletions, config.OperationResponses}
case config.ProtocolDriverOpenAIResponses:
operations = []config.ProtocolOperation{config.OperationResponses}
default:
operations = nil
}
case config.OperationChatCompletions:
switch profile.Driver {
case config.ProtocolDriverOpenAIChat:
operations = []config.ProtocolOperation{config.OperationChatCompletions, config.OperationResponses}
case config.ProtocolDriverOpenAIResponses:
operations = []config.ProtocolOperation{config.OperationResponses}
default:
operations = nil
}
}
for _, operation := range operations {
if _, ok := profile.Operations[string(operation)]; !ok {
continue
}
if required := operationRequiredCapability(operation); required != "" && !profile.HasCapability(required) {
continue
}
if requirements.Stream && !profile.HasCapability("streaming") {
continue
}
if requirements.HasTools && !profile.HasCapability("tool_calling") {
continue
}
plan := providerOperationPlan{Operation: operation}
mapping, hasMapping := profile.EffortMapping(operation)
supportsTokenBudget := hasMapping && mapping.TokenBudget
if operation == config.OperationChatCompletions && profileSupportsAnthropicThinking(profile) {
supportsTokenBudget = true
}
if requirements.HasTokenBudget && !supportsTokenBudget {
continue
}
if requirements.Effort != "" {
mapped, ok := profile.MapReasoningEffort(operation, requirements.Effort, requirements.HasTools)
if !ok {
continue
}
plan.Effort = mapped
plan.EffortWire = mapping.Wire
}
return plan, nil
}
return providerOperationPlan{}, fmt.Errorf("protocol profile %q cannot preserve the requested operation, tools, and reasoning controls", profile.ID)
}
func operationRequiredCapability(operation config.ProtocolOperation) string {
switch operation {
case config.OperationMessages:
return "messages"
case config.OperationChatCompletions:
return "chat"
case config.OperationResponses:
return "responses"
case config.OperationCountTokens:
return "count_tokens"
default:
return ""
}
}
func prepareAnthropicResponsesBridge(body []byte, target string, profile config.ConcreteProtocolProfile, plan providerOperationPlan) ([]byte, anthropicMessageRequest, error) {
req, err := decodeAnthropicMessageRequest(body, true)
if err != nil {
return nil, req, err
}
if plan.Operation != config.OperationResponses || (plan.Effort != "" && plan.EffortWire != config.ProtocolEffortWireOpenAIResponses) {
return nil, req, fmt.Errorf("selected operation has incompatible Responses effort normalization")
}
if req.TopK != nil {
return nil, req, fmt.Errorf("top_k is not supported by the Responses bridge")
}
if req.Thinking != nil && req.Thinking.Type == "enabled" {
return nil, req, fmt.Errorf("selected Responses profile does not support an explicit thinking token budget")
}
input := make([]map[string]any, 0, len(req.Messages))
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 := anthropicMessageToResponses(message.Role, blocks)
if err != nil {
return nil, req, fmt.Errorf("messages[%d]: %w", index, err)
}
input = append(input, converted...)
}
responses := map[string]any{
"model": target,
"input": input,
"max_output_tokens": *req.MaxTokens,
"stream": req.Stream,
}
if system, err := decodeAnthropicSystem(req.System); err != nil {
return nil, req, err
} else if len(system) > 0 {
parts := make([]string, 0, len(system))
for _, block := range system {
parts = append(parts, block.Text)
}
responses["instructions"] = strings.Join(parts, "\n")
}
if plan.Effort != "" {
responses["reasoning"] = map[string]any{"effort": plan.Effort}
}
if req.Temperature != nil {
responses["temperature"] = *req.Temperature
}
if req.TopP != nil {
responses["top_p"] = *req.TopP
}
if len(req.StopSequences) > 0 {
return nil, req, fmt.Errorf("stop_sequences is not supported by the Responses bridge")
}
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)
}
converted := map[string]any{"type": "function", "name": tool.Name, "parameters": schema}
if tool.Description != "" {
converted["description"] = tool.Description
}
tools = append(tools, converted)
}
responses["tools"] = tools
}
if req.ToolChoice != nil {
choice, parallel := anthropicToolChoiceToResponses(*req.ToolChoice)
responses["tool_choice"] = choice
if parallel != nil {
responses["parallel_tool_calls"] = *parallel
}
}
if req.OutputConfig != nil && req.OutputConfig.Format != nil {
var schema map[string]any
if err := json.Unmarshal(req.OutputConfig.Format.Schema, &schema); err != nil {
return nil, req, fmt.Errorf("decode output_config.format.schema: %w", err)
}
responses["text"] = map[string]any{"format": map[string]any{
"type": "json_schema", "name": "response", "strict": true, "schema": schema,
}}
}
encoded, err := json.Marshal(responses)
if err != nil {
return nil, req, fmt.Errorf("encode Responses bridge request: %w", err)
}
return encoded, req, nil
}
func anthropicMessageToResponses(role string, blocks []anthropicContentBlock) ([]map[string]any, error) {
if role == "assistant" {
out := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
switch block.Type {
case "text":
out = append(out, map[string]any{"type": "message", "role": "assistant", "content": []map[string]any{{"type": "output_text", "text": block.Text}}})
case "thinking":
if block.Signature != "" {
return nil, fmt.Errorf("signed thinking blocks cannot be represented by the Responses bridge")
}
case "tool_use":
callID, _, _ := decodeAnthropicBridgeToolID(block.ID)
out = append(out, map[string]any{"type": "function_call", "call_id": callID, "name": block.Name, "arguments": string(block.Input)})
default:
return nil, fmt.Errorf("content block %q is invalid for an assistant message", block.Type)
}
}
return out, nil
}
if role == "system" {
content := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
if block.Type != "text" {
return nil, fmt.Errorf("content block %q is invalid for a system message", block.Type)
}
content = append(content, map[string]any{"type": "input_text", "text": block.Text})
}
if len(content) == 0 {
return nil, fmt.Errorf("system message content is empty")
}
return []map[string]any{{"type": "message", "role": "system", "content": content}}, nil
}
out := make([]map[string]any, 0, len(blocks))
content := make([]map[string]any, 0, len(blocks))
flushContent := func() {
if len(content) > 0 {
out = append(out, map[string]any{"type": "message", "role": "user", "content": content})
content = nil
}
}
for _, block := range blocks {
switch block.Type {
case "text":
content = append(content, map[string]any{"type": "input_text", "text": block.Text})
case "image":
imageURL := block.Source.URL
if block.Source.Type == "base64" {
imageURL = "data:" + block.Source.MediaType + ";base64," + block.Source.Data
}
content = append(content, map[string]any{"type": "input_image", "image_url": imageURL})
case "tool_result":
flushContent()
result, err := anthropicToolResultText(block.Content)
if err != nil {
return nil, err
}
if block.IsError {
result = "Error: " + result
}
callID, _, _ := decodeAnthropicBridgeToolID(block.ToolUseID)
out = append(out, map[string]any{"type": "function_call_output", "call_id": callID, "output": result})
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 anthropicToolChoiceToResponses(choice anthropicToolChoice) (any, *bool) {
parallel := !choice.DisableParallelToolUse
switch choice.Type {
case "auto":
return "auto", &parallel
case "any":
return "required", &parallel
case "none":
return "none", &parallel
default:
return map[string]any{"type": "function", "name": choice.Name}, &parallel
}
}
func convertResponsesResponseToAnthropic(body []byte, requestModel string) (anthropicMessageResponse, error) {
var response openAIResponsesBridgeResponse
if err := json.Unmarshal(body, &response); err != nil {
return anthropicMessageResponse{}, fmt.Errorf("decode Responses response: %w", err)
}
if strings.TrimSpace(response.ID) == "" {
return anthropicMessageResponse{}, fmt.Errorf("Responses response has no id")
}
content := make([]map[string]any, 0, len(response.Output))
hasTools := false
for _, item := range response.Output {
switch item.Type {
case "message":
for _, part := range item.Content {
if part.Type == "output_text" && part.Text != "" {
content = append(content, map[string]any{"type": "text", "text": part.Text})
}
}
case "reasoning":
for _, part := range item.Summary {
if part.Text != "" {
content = append(content, map[string]any{"type": "thinking", "thinking": part.Text, "signature": ""})
}
}
case "function_call":
if item.CallID == "" || item.Name == "" || !json.Valid([]byte(item.Arguments)) {
return anthropicMessageResponse{}, fmt.Errorf("Responses function call has invalid call_id, name, or arguments")
}
var input any
if err := json.Unmarshal([]byte(item.Arguments), &input); err != nil {
return anthropicMessageResponse{}, fmt.Errorf("decode Responses function arguments: %w", err)
}
content = append(content, map[string]any{
"type": "tool_use", "id": encodeAnthropicBridgeToolID(item.CallID, openAIChatToolExtraContent{}),
"name": item.Name, "input": input,
})
hasTools = true
}
}
stopReason := "end_turn"
if hasTools {
stopReason = "tool_use"
} else if response.Status == "incomplete" && response.IncompleteDetails.Reason == "max_output_tokens" {
stopReason = "max_tokens"
}
model := requestModel
if model == "" {
model = response.Model
}
return anthropicMessageResponse{
ID: response.ID, Type: "message", Role: "assistant", Model: model, Content: content,
StopReason: &stopReason,
Usage: anthropicUsage{
InputTokens: response.Usage.InputTokens, OutputTokens: response.Usage.OutputTokens,
CacheReadInputTokens: response.Usage.InputDetails.CachedTokens,
},
}, nil
}