iop/apps/edge/internal/openai/provider_normalization.go
toki b02654f781 feat(api): Responses 브리지와 Gemini effort를 완성한다
표준 Responses 요청이 선택된 provider profile을 통해 손실 없이 실행되고 Gemini의 휴대 가능한 reasoning 등급만 전달되도록 한다.
2026-08-14 08:07:10 +09:00

1173 lines
41 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 {
Stream bool `json:"stream"`
Tools []json.RawMessage `json:"tools"`
Reasoning *struct {
Effort string `json:"effort"`
} `json:"reasoning"`
MaxOutputTokens *int `json:"max_output_tokens"`
Text *responsesTextFormat `json:"text"`
}
if err := json.Unmarshal(body, &request); err != nil {
return providerRequestRequirements{}, fmt.Errorf("decode Responses request")
}
requirements := providerRequestRequirements{HasTools: len(request.Tools) > 0, Stream: request.Stream}
if request.MaxOutputTokens != nil {
requirements.HasTokenBudget = true
}
if request.Text != nil && request.Text.Format != nil {
requirements.StructuredOutput = true
}
if request.Reasoning != nil {
requirements.Effort = strings.TrimSpace(request.Reasoning.Effort)
}
return requirements, nil
}
// responsesTextFormat mirrors the Responses request text.format selector that
// carries structured-output constraints. It is intentionally minimal: only the
// presence of a format block is needed for admission decisions.
type responsesTextFormat struct {
Format json.RawMessage `json:"format,omitempty"`
}
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 != "" && 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
}
// prepareResponsesMessagesBridge and prepareResponsesChatBridge deliberately
// accept only the portable subset below. A Responses tunnel is otherwise
// lossless, but a bridge must never silently discard a caller control.
func prepareResponsesMessagesBridge(body []byte, target string, profile config.ConcreteProtocolProfile, plan providerOperationPlan) ([]byte, error) {
if plan.Operation != config.OperationMessages || (plan.Effort != "" && plan.EffortWire != config.ProtocolEffortWireAnthropicMessage) {
return nil, fmt.Errorf("selected Messages profile cannot preserve the requested Responses controls")
}
root, err := decodeResponsesBridgeRoot(body)
if err != nil {
return nil, err
}
messages, system, err := responsesInputToBridgeMessages(root["input"], true)
if err != nil {
return nil, err
}
if instructions, _ := root["instructions"].(string); strings.TrimSpace(instructions) != "" {
system = append([]string{instructions}, system...)
}
maxTokens, ok := root["max_output_tokens"]
if !ok {
return nil, fmt.Errorf("max_output_tokens is required for the Messages bridge")
}
maxTokensNumber, ok := maxTokens.(float64)
if !ok || maxTokensNumber <= 0 || maxTokensNumber != float64(int(maxTokensNumber)) {
return nil, fmt.Errorf("max_output_tokens must be a positive integer for the Messages bridge")
}
request := map[string]any{"model": target, "messages": messages, "max_tokens": maxTokens}
if len(system) > 0 {
request["system"] = strings.Join(system, "\n\n")
}
copyResponsesBridgeOption(root, request, "stream", "temperature", "top_p")
if plan.Effort != "" {
request["output_config"] = map[string]any{"effort": plan.Effort}
}
if err := copyResponsesToolsToMessages(root, request); err != nil {
return nil, err
}
if format, ok := responsesStructuredFormat(root); ok {
output, _ := request["output_config"].(map[string]any)
if output == nil {
output = map[string]any{}
}
output["format"] = format
request["output_config"] = output
}
return json.Marshal(request)
}
func prepareResponsesChatBridge(body []byte, target string, profile config.ConcreteProtocolProfile, plan providerOperationPlan) ([]byte, error) {
if plan.Operation != config.OperationChatCompletions || (plan.Effort != "" && plan.EffortWire != config.ProtocolEffortWireOpenAIChat && plan.EffortWire != config.ProtocolEffortWireGeminiChat) {
return nil, fmt.Errorf("selected Chat profile cannot preserve the requested Responses controls")
}
root, err := decodeResponsesBridgeRoot(body)
if err != nil {
return nil, err
}
messages, system, err := responsesInputToBridgeMessages(root["input"], false)
if err != nil {
return nil, err
}
if instructions, _ := root["instructions"].(string); strings.TrimSpace(instructions) != "" {
system = append([]string{instructions}, system...)
}
if len(system) > 0 {
messages = append([]any{map[string]any{"role": "system", "content": strings.Join(system, "\n\n")}}, messages...)
}
request := map[string]any{"model": target, "messages": messages}
copyResponsesBridgeOption(root, request, "stream", "temperature", "top_p", "parallel_tool_calls")
if max, ok := root["max_output_tokens"]; ok {
field := "max_tokens"
if mapping, ok := profile.EffortMapping(config.OperationChatCompletions); ok && mapping.Wire == config.ProtocolEffortWireOpenAIChat {
field = "max_completion_tokens"
}
request[field] = max
}
if plan.Effort != "" {
request["reasoning_effort"] = plan.Effort
}
if err := copyResponsesToolsToChat(root, request); err != nil {
return nil, err
}
if format, ok := responsesStructuredFormat(root); ok {
request["response_format"] = map[string]any{"type": "json_schema", "json_schema": format}
}
return json.Marshal(request)
}
func decodeResponsesBridgeRoot(body []byte) (map[string]any, error) {
var root map[string]any
if err := json.Unmarshal(body, &root); err != nil {
return nil, fmt.Errorf("decode Responses bridge request")
}
allowed := map[string]bool{"model": true, "input": true, "instructions": true, "stream": true, "max_output_tokens": true, "temperature": true, "top_p": true, "tools": true, "tool_choice": true, "parallel_tool_calls": true, "reasoning": true, "text": true}
for key := range root {
if !allowed[key] {
return nil, fmt.Errorf("Responses field %q is not representable by the selected provider wire", key)
}
}
if _, ok := root["input"]; !ok {
return nil, fmt.Errorf("input is required")
}
return root, nil
}
func validateResponsesBridgeControls(body []byte) error {
_, err := decodeResponsesBridgeRoot(body)
return err
}
func copyResponsesBridgeOption(source, target map[string]any, keys ...string) {
for _, key := range keys {
if value, ok := source[key]; ok {
target[key] = value
}
}
}
func responsesInputToBridgeMessages(input any, messagesWire bool) ([]any, []string, error) {
if text, ok := input.(string); ok {
return []any{map[string]any{"role": "user", "content": text}}, nil, nil
}
items, ok := input.([]any)
if !ok {
return nil, nil, fmt.Errorf("input must be a string or an item array")
}
var messages []any
var system []string
for _, raw := range items {
item, ok := raw.(map[string]any)
if !ok {
return nil, nil, fmt.Errorf("input item is invalid")
}
typ, _ := item["type"].(string)
switch typ {
case "message":
role, _ := item["role"].(string)
text, err := responsesMessageText(item["content"])
if err != nil {
return nil, nil, err
}
if role == "system" {
system = append(system, text)
continue
}
if role != "user" && role != "assistant" {
return nil, nil, fmt.Errorf("message role %q is not representable", role)
}
messages = append(messages, map[string]any{"role": role, "content": text})
case "function_call":
id, _ := item["call_id"].(string)
name, _ := item["name"].(string)
args, _ := item["arguments"].(string)
if id == "" || name == "" || !json.Valid([]byte(args)) {
return nil, nil, fmt.Errorf("function_call is invalid")
}
if messagesWire {
messages = append(messages, map[string]any{"role": "assistant", "content": []any{map[string]any{"type": "tool_use", "id": id, "name": name, "input": json.RawMessage(args)}}})
} else {
messages = append(messages, map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{map[string]any{"id": id, "type": "function", "function": map[string]any{"name": name, "arguments": args}}}})
}
case "function_call_output":
id, _ := item["call_id"].(string)
output, ok := item["output"].(string)
if id == "" || !ok {
return nil, nil, fmt.Errorf("function_call_output is invalid")
}
if messagesWire {
messages = append(messages, map[string]any{"role": "user", "content": []any{map[string]any{"type": "tool_result", "tool_use_id": id, "content": output}}})
} else {
original, signature, encoded, err := decodeGeminiThoughtSignatureToolID(id)
if err != nil {
return nil, nil, err
}
tool := map[string]any{"role": "tool", "tool_call_id": original, "content": output}
if encoded {
tool["extra_content"] = openAIChatThoughtSignature(signature)
}
messages = append(messages, tool)
}
default:
return nil, nil, fmt.Errorf("input item type %q is not representable", typ)
}
}
return messages, system, nil
}
func responsesMessageText(raw any) (string, error) {
if text, ok := raw.(string); ok {
return text, nil
}
parts, ok := raw.([]any)
if !ok {
return "", fmt.Errorf("message content is invalid")
}
var out []string
for _, rawPart := range parts {
part, ok := rawPart.(map[string]any)
if !ok {
return "", fmt.Errorf("message content part is invalid")
}
typ, _ := part["type"].(string)
text, _ := part["text"].(string)
if (typ != "input_text" && typ != "output_text" && typ != "text") || text == "" {
return "", fmt.Errorf("message content part %q is not representable", typ)
}
out = append(out, text)
}
return strings.Join(out, "\n"), nil
}
func copyResponsesToolsToChat(root, request map[string]any) error {
tools, exists := root["tools"]
if !exists {
return copyResponsesToolChoice(root, request, false)
}
items, ok := tools.([]any)
if !ok {
return fmt.Errorf("tools must be an array")
}
out := make([]any, 0, len(items))
for _, raw := range items {
tool, ok := raw.(map[string]any)
if !ok || tool["type"] != "function" {
return fmt.Errorf("tool is not representable by the Chat bridge")
}
name, _ := tool["name"].(string)
params, ok := tool["parameters"]
if name == "" || !ok {
return fmt.Errorf("tool is invalid")
}
fn := map[string]any{"name": name, "parameters": params}
if description, ok := tool["description"]; ok {
fn["description"] = description
}
out = append(out, map[string]any{"type": "function", "function": fn})
}
request["tools"] = out
return copyResponsesToolChoice(root, request, false)
}
func copyResponsesToolsToMessages(root, request map[string]any) error {
tools, exists := root["tools"]
if !exists {
return copyResponsesToolChoice(root, request, true)
}
items, ok := tools.([]any)
if !ok {
return fmt.Errorf("tools must be an array")
}
out := make([]any, 0, len(items))
for _, raw := range items {
tool, ok := raw.(map[string]any)
if !ok || tool["type"] != "function" {
return fmt.Errorf("tool is not representable by the Messages bridge")
}
name, _ := tool["name"].(string)
schema, ok := tool["parameters"]
if name == "" || !ok {
return fmt.Errorf("tool is invalid")
}
converted := map[string]any{"name": name, "input_schema": schema}
if description, ok := tool["description"]; ok {
converted["description"] = description
}
out = append(out, converted)
}
request["tools"] = out
return copyResponsesToolChoice(root, request, true)
}
func copyResponsesToolChoice(root, request map[string]any, messagesWire bool) error {
choice, exists := root["tool_choice"]
if !exists {
return nil
}
if text, ok := choice.(string); ok {
if text != "auto" && text != "required" && text != "none" {
return fmt.Errorf("tool_choice %q is not representable", text)
}
if messagesWire {
if text == "none" {
return fmt.Errorf("tool_choice none is not representable by Messages")
}
request["tool_choice"] = map[string]any{"type": text}
} else {
request["tool_choice"] = text
}
return nil
}
selected, ok := choice.(map[string]any)
if !ok || selected["type"] != "function" {
return fmt.Errorf("tool_choice is invalid")
}
name, _ := selected["name"].(string)
if name == "" {
return fmt.Errorf("tool_choice function name is required")
}
if messagesWire {
request["tool_choice"] = map[string]any{"type": "tool", "name": name}
} else {
request["tool_choice"] = map[string]any{"type": "function", "function": map[string]any{"name": name}}
}
return nil
}
func responsesStructuredFormat(root map[string]any) (map[string]any, bool) {
text, ok := root["text"].(map[string]any)
if !ok {
return nil, false
}
format, ok := text["format"].(map[string]any)
return format, ok
}
// 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.OperationResponses:
switch profile.Driver {
case config.ProtocolDriverOpenAIResponses:
operations = []config.ProtocolOperation{config.OperationResponses}
case config.ProtocolDriverAnthropicMessages:
operations = []config.ProtocolOperation{config.OperationMessages}
case config.ProtocolDriverOpenAIChat:
// A Chat driver may still declare its native Responses operation
// (the OpenAI profile does). Prefer that lossless wire; profiles such
// as Gemini that do not declare it use the Chat bridge.
if _, ok := profile.Operations[string(config.OperationResponses)]; ok {
operations = []config.ProtocolOperation{config.OperationResponses}
} else {
operations = []config.ProtocolOperation{config.OperationChatCompletions}
}
default:
operations = nil
}
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 && operation != ingress && !supportsTokenBudget {
continue
}
// Native ingress wire already carries this control verbatim. Capability
// mapping is required only when a bridge must translate it.
// Token output limits have direct fields on bridge wires; only an
// explicit reasoning effort needs the profile's token-budget mapping.
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
}