688 lines
24 KiB
Go
688 lines
24 KiB
Go
package openai
|
|
|
|
import (
|
|
"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"`
|
|
}
|
|
|
|
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) {
|
|
if dispatch.ProfileOperation != string(config.OperationResponses) {
|
|
return body, nil
|
|
}
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|