표준 Responses 요청이 선택된 provider profile을 통해 손실 없이 실행되고 Gemini의 휴대 가능한 reasoning 등급만 전달되도록 한다.
1173 lines
41 KiB
Go
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", ¶llel
|
|
case "any":
|
|
return "required", ¶llel
|
|
case "none":
|
|
return "none", ¶llel
|
|
default:
|
|
return map[string]any{"type": "function", "name": choice.Name}, ¶llel
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|