feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s

This commit is contained in:
Codex committed 2026-09-11 05:07:48 +08:00
commit bb7968b612
2381 files changed
+551494

No files matched your search

@@ -0,0 +1,997 @@
package toolconv
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
const maxClaudeWebSearchUses = 1000
func ExtractRequest(format types.RelayFormat, request any) (any, Set, error) {
switch format {
case types.RelayFormatOpenAI:
return extractOpenAIChatRequest(request)
case types.RelayFormatOpenAIResponses:
return extractOpenAIResponsesRequest(request)
case types.RelayFormatClaude:
return extractClaudeRequest(request)
case types.RelayFormatGemini:
return extractGeminiRequest(request)
default:
return request, Set{Source: format}, nil
}
}
func extractOpenAIChatRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
value, valueOK := request.(dto.GeneralOpenAIRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAI}
set.ParallelAllowed = source.ParallelTooCalls
if len(source.Functions) > 0 {
var functions []dto.FunctionRequest
if err := kitutil.Unmarshal(source.Functions, &functions); err != nil {
return nil, Set{}, fmt.Errorf("invalid legacy functions: %w", err)
}
for _, function := range functions {
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{Name: function.Name, Description: function.Description, Parameters: function.Parameters, Strict: function.Strict},
})
}
}
for index, tool := range source.Tools {
if tool.Type == "function" || tool.Type == "" {
if definition, ok := decodeOpenAIChatPseudoHostedTool(tool.Function.Name); ok {
set.Definitions = append(set.Definitions, definition)
continue
}
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{
Name: tool.Function.Name,
Description: tool.Function.Description,
Parameters: tool.Function.Parameters,
Strict: tool.Function.Strict,
},
})
continue
}
if len(tool.Custom) == 0 {
return nil, Set{}, fmt.Errorf("tools[%d] has unsupported type %q without a native payload", index, tool.Type)
}
definition, err := decodeOpenAIResponsesDefinition(tool.Custom)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
if source.WebSearchOptions != nil {
webSearch := &WebSearch{
SearchContextSize: source.WebSearchOptions.SearchContextSize,
}
location, err := decodeOpenAIChatLocation(source.WebSearchOptions.UserLocation)
if err != nil {
return nil, Set{}, err
}
webSearch.Location = location
set.Definitions = append(set.Definitions, Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "web_search_options",
WebSearch: webSearch,
})
}
if choice, err := decodeOpenAIChatChoice(source.ToolChoice); err != nil {
return nil, Set{}, err
} else if choice != nil {
set.Choice = choice
}
if len(source.FunctionCall) > 0 {
legacyChoice, err := decodeLegacyOpenAIFunctionChoice(source.FunctionCall)
if err != nil {
return nil, Set{}, err
}
if set.Choice != nil && legacyChoice != nil {
return nil, Set{}, fmt.Errorf("tool_choice and legacy function_call cannot both be converted")
}
set.Choice = legacyChoice
}
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.WebSearchOptions = nil
clone.Functions = nil
clone.FunctionCall = nil
clone.ParallelTooCalls = nil
return &clone, set, nil
}
func extractOpenAIResponsesRequest(request any) (any, Set, error) {
source, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
value, valueOK := request.(dto.OpenAIResponsesRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI Responses request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAIResponses}
set.ParallelAllowed = rawBoolPointer(source.ParallelToolCalls)
if len(source.Tools) > 0 {
var rawTools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &rawTools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
}
for index, rawTool := range rawTools {
definition, err := decodeOpenAIResponsesDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.ParallelToolCalls = nil
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
if err != nil {
return nil, Set{}, err
}
clone.Input = sanitizedInput
set.History = history
return &clone, set, nil
}
func extractClaudeRequest(request any) (any, Set, error) {
source, ok := request.(*dto.ClaudeRequest)
if !ok {
value, valueOK := request.(dto.ClaudeRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Claude Messages request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatClaude}
if source.ToolChoice != nil {
rawChoice, rawErr := rawJSON(source.ToolChoice)
if rawErr == nil {
var choiceMap map[string]any
if kitutil.Unmarshal(rawChoice, &choiceMap) == nil {
if disabled, ok := choiceMap["disable_parallel_tool_use"].(bool); ok {
allowed := !disabled
set.ParallelAllowed = &allowed
}
}
}
}
if source.Tools != nil {
rawTools, err := rawJSON(source.Tools)
if err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
var tools []json.RawMessage
if err := kitutil.Unmarshal(rawTools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
for index, rawTool := range tools {
definition, err := decodeClaudeDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeClaudeChoice(source.ToolChoice, set.Definitions)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.Messages, set.History, err = extractClaudeHostedHistory(source.Messages)
if err != nil {
return nil, Set{}, err
}
return &clone, set, nil
}
func extractGeminiRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeminiChatRequest)
if !ok {
value, valueOK := request.(dto.GeminiChatRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Gemini generateContent request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatGemini}
if source.ToolConfig != nil {
set.NativeToolConfig, _ = rawJSON(source.ToolConfig)
}
if len(source.Tools) > 0 {
var tools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Gemini tools: %w", err)
}
for index, rawTool := range tools {
definitions, err := decodeGeminiDefinitions(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
for definitionIndex := range definitions {
definitions[definitionIndex].Group = index
}
set.Definitions = append(set.Definitions, definitions...)
}
}
set.Choice = decodeGeminiChoice(source.ToolConfig)
clone := *source
clone.Tools = nil
clone.ToolConfig = nil
return &clone, set, nil
}
func decodeOpenAIResponsesDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
webSearch := &WebSearch{
SearchContextSize: strings.TrimSpace(kitutil.Interface2String(tool["search_context_size"])),
ExternalWebAccess: boolPointer(tool, "external_web_access"),
}
if value, exists := tool["return_token_budget"]; exists {
encoded, err := rawJSON(value)
if err != nil {
return Definition{}, err
}
webSearch.ReturnTokenBudget = encoded
}
if filters, ok := tool["filters"].(map[string]any); ok {
webSearch.AllowedDomains = stringSlice(filters["allowed_domains"])
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Raw: cloneRaw(raw),
}, nil
}
func decodeClaudeDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if strings.HasPrefix(toolType, "web_search") {
if !isVersionedClaudeWebSearchType(toolType) {
return Definition{}, fmt.Errorf("invalid Claude web-search tool version %q", toolType)
}
if !isKnownClaudeWebSearchType(toolType) {
return Definition{
Kind: KindNative,
Execution: ExecutionServer,
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
toolName := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
if toolName != "web_search" {
return Definition{}, fmt.Errorf("Claude web-search tool name must be %q", "web_search")
}
webSearch := &WebSearch{
AllowedDomains: stringSlice(tool["allowed_domains"]),
BlockedDomains: stringSlice(tool["blocked_domains"]),
AllowedCallers: stringSlice(tool["allowed_callers"]),
ResponseInclusion: strings.TrimSpace(kitutil.Interface2String(tool["response_inclusion"])),
}
if _, exists := tool["max_uses"]; exists {
var fields struct {
MaxUses *int `json:"max_uses"`
}
if err := kitutil.Unmarshal(raw, &fields); err != nil || fields.MaxUses == nil {
return Definition{}, fmt.Errorf("max_uses must be a JSON integer")
}
if *fields.MaxUses <= 0 || *fields.MaxUses > maxClaudeWebSearchUses {
return Definition{}, fmt.Errorf("max_uses must be between 1 and %d", maxClaudeWebSearchUses)
}
webSearch.MaxUses = fields.MaxUses
}
if len(webSearch.AllowedDomains) > 0 && len(webSearch.BlockedDomains) > 0 {
return Definition{}, fmt.Errorf("allowed_domains and blocked_domains are mutually exclusive")
}
if webSearch.ResponseInclusion != "" && !claudeWebSearchSupportsResponseInclusion(toolType) {
return Definition{}, fmt.Errorf("response_inclusion requires Claude web_search_20260318")
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
Name: toolName,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
if toolType == "" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["input_schema"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return nil, err
}
definitions := make([]Definition, 0)
if functions, ok := tool["functionDeclarations"].([]any); ok {
for _, value := range functions {
function, ok := value.(map[string]any)
if !ok {
continue
}
parameters := function["parameters"]
parametersJSONSchema, hasParametersJSONSchema := function["parametersJsonSchema"]
if parameters != nil && hasParametersJSONSchema && parametersJSONSchema != nil {
return nil, fmt.Errorf("function %q declares both parameters and parametersJsonSchema", strings.TrimSpace(kitutil.Interface2String(function["name"])))
}
if parameters == nil && hasParametersJSONSchema {
parameters = parametersJSONSchema
}
functionRaw, err := rawJSON(map[string]any{"functionDeclarations": []any{value}})
if err != nil {
return nil, err
}
definitions = append(definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Raw: functionRaw,
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Description: kitutil.Interface2String(function["description"]),
Parameters: parameters,
},
})
}
}
for key := range tool {
var kind Kind
var nativeType string
switch key {
case "functionDeclarations":
continue
case "googleSearch":
kind, nativeType = KindWebSearch, "googleSearch"
case "googleSearchRetrieval":
kind, nativeType = KindWebSearch, "googleSearchRetrieval"
case "enterpriseWebSearch":
kind, nativeType = KindWebSearch, "enterpriseWebSearch"
case "googleMaps":
kind, nativeType = KindNative, "googleMaps"
case "codeExecution":
kind, nativeType = KindCodeExecution, "codeExecution"
case "urlContext":
kind, nativeType = KindURLContext, "urlContext"
case "fileSearch":
kind, nativeType = KindFileSearch, "fileSearch"
case "computerUse":
kind, nativeType = KindComputerUse, "computerUse"
case "retrieval":
kind, nativeType = KindFileSearch, "retrieval"
default:
kind, nativeType = KindNative, key
}
keyRaw, err := rawJSON(map[string]any{key: tool[key]})
if err != nil {
return nil, err
}
definition := Definition{
Kind: kind,
Execution: ExecutionServer,
NativeType: nativeType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: keyRaw,
}
if kind == KindWebSearch {
definition.WebSearch = &WebSearch{}
}
definitions = append(definitions, definition)
}
return definitions, nil
}
func decodeLegacyOpenAIFunctionChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, err
}
return choiceFromString(value), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid legacy function_call: %w", err)
}
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("legacy function_call requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func rawBoolPointer(raw json.RawMessage) *bool {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "boolean" {
return nil
}
var value bool
if kitutil.Unmarshal(raw, &value) != nil {
return nil
}
return &value
}
// decodeOpenAIChatPseudoHostedTool recognizes the OpenAI Chat dialect that
// declares Gemini hosted tools as function definitions named googleSearch,
// codeExecution, or urlContext. The names are the historical public contract;
// recognition lives here so every target format goes through the same hosted
// ToolDefinition pipeline.
func decodeOpenAIChatPseudoHostedTool(name string) (Definition, bool) {
switch name {
case "googleSearch":
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "googleSearch",
Name: "googleSearch",
WebSearch: &WebSearch{},
}, true
case "codeExecution":
return Definition{
Kind: KindCodeExecution,
Execution: ExecutionServer,
NativeType: "codeExecution",
Name: "codeExecution",
}, true
case "urlContext":
return Definition{
Kind: KindURLContext,
Execution: ExecutionServer,
NativeType: "urlContext",
Name: "urlContext",
}, true
default:
return Definition{}, false
}
}
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
if len(raw) == 0 {
return nil, nil
}
var wrapper map[string]any
if err := kitutil.Unmarshal(raw, &wrapper); err != nil {
return nil, fmt.Errorf("invalid web_search_options.user_location: %w", err)
}
location, ok := wrapper["approximate"].(map[string]any)
if !ok {
return nil, nil
}
return locationFromMap(location), nil
}
func decodeOpenAIChatChoice(value any) (*Choice, error) {
if value == nil {
return nil, nil
}
if text, ok := value.(string); ok {
return choiceFromString(text), nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
if strings.TrimSpace(kitutil.Interface2String(choice["type"])) != "function" {
return &Choice{Mode: ChoiceOpaque, Raw: cloneRaw(raw)}, nil
}
function, _ := choice["function"].(map[string]any)
name := strings.TrimSpace(kitutil.Interface2String(function["name"]))
if name == "" {
return nil, fmt.Errorf("Chat function tool_choice requires function.name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var text string
if err := kitutil.Unmarshal(raw, &text); err != nil {
return nil, err
}
return choiceFromString(text), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid Responses tool_choice: %w", err)
}
toolType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if toolType == "function" {
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("Responses function tool_choice requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
return &Choice{Mode: ChoiceNamed, Kind: KindWebSearch, Name: "web_search", NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
return &Choice{Mode: ChoiceOpaque, Kind: kindFromNativeType(toolType), NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
func decodeClaudeChoice(value any, definitions []Definition) (*Choice, error) {
if value == nil {
return nil, nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
choiceType := strings.TrimSpace(kitutil.Interface2String(choice["type"]))
var decoded *Choice
switch choiceType {
case "auto":
decoded = &Choice{Mode: ChoiceAuto}
case "none":
decoded = &Choice{Mode: ChoiceNone}
case "any":
decoded = &Choice{Mode: ChoiceRequired}
case "tool":
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
kind := KindNative
matches := 0
for _, definition := range definitions {
definitionName := definition.Name
if definition.Kind == KindFunction && definition.Function != nil {
definitionName = definition.Function.Name
}
if definitionName != name {
continue
}
matches++
kind = definition.Kind
}
if matches > 1 {
return nil, fmt.Errorf("Claude tool_choice name %q is ambiguous across %d definitions", name, matches)
}
decoded = &Choice{Mode: ChoiceNamed, Kind: kind, Name: name}
default:
decoded = &Choice{Mode: ChoiceOpaque}
}
if disabled, ok := choice["disable_parallel_tool_use"].(bool); ok {
decoded.DisableParallelToolUse = &disabled
}
decoded.Raw = cloneRaw(raw)
return decoded, nil
}
func decodeGeminiChoice(config *dto.ToolConfig) *Choice {
if config == nil || config.FunctionCallingConfig == nil {
return nil
}
functionConfig := config.FunctionCallingConfig
switch strings.ToUpper(strings.TrimSpace(string(functionConfig.Mode))) {
case "NONE":
return &Choice{Mode: ChoiceNone}
case "ANY":
if len(functionConfig.AllowedFunctionNames) == 1 {
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: functionConfig.AllowedFunctionNames[0]}
}
return &Choice{
Mode: ChoiceRequired,
Kind: KindFunction,
AllowedNames: append([]string(nil), functionConfig.AllowedFunctionNames...),
}
case "", "AUTO":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(functionConfig)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func choiceFromString(value string) *Choice {
switch strings.ToLower(strings.TrimSpace(value)) {
case "none":
return &Choice{Mode: ChoiceNone}
case "required", "any":
return &Choice{Mode: ChoiceRequired}
case "auto":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(value)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func isOpenAIResponsesWebSearchType(toolType string) bool {
switch toolType {
case "web_search", "web_search_2025_08_26", "web_search_preview", "web_search_preview_2025_03_11":
return true
default:
return false
}
}
func claudeWebSearchSupportsResponseInclusion(toolType string) bool {
return toolType == "web_search_20260318"
}
func isKnownClaudeWebSearchType(toolType string) bool {
switch toolType {
case "web_search_20250305", "web_search_20260209", "web_search_20260318":
return true
default:
return false
}
}
func isVersionedClaudeWebSearchType(toolType string) bool {
const prefix = "web_search_"
version := strings.TrimPrefix(toolType, prefix)
if !strings.HasPrefix(toolType, prefix) || len(version) != 8 {
return false
}
_, err := strconv.ParseUint(version, 10, 32)
return err == nil
}
func locationFromMap(value map[string]any) *ApproximateLocation {
if len(value) == 0 {
return nil
}
location := &ApproximateLocation{
City: strings.TrimSpace(kitutil.Interface2String(value["city"])),
Region: strings.TrimSpace(kitutil.Interface2String(value["region"])),
Country: strings.TrimSpace(kitutil.Interface2String(value["country"])),
Timezone: strings.TrimSpace(kitutil.Interface2String(value["timezone"])),
}
if location.City == "" && location.Region == "" && location.Country == "" && location.Timezone == "" {
return nil
}
return location
}
func boolPointer(value map[string]any, key string) *bool {
raw, exists := value[key]
if !exists {
return nil
}
parsed, ok := raw.(bool)
if !ok {
return nil
}
return &parsed
}
func stringSlice(value any) []string {
items, ok := value.([]any)
if !ok {
if strings, stringsOK := value.([]string); stringsOK {
return append([]string(nil), strings...)
}
return nil
}
result := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
result = append(result, text)
}
}
return result
}
func rawJSON(value any) (json.RawMessage, error) {
switch raw := value.(type) {
case json.RawMessage:
return cloneRaw(raw), nil
case []byte:
return cloneRaw(raw), nil
default:
encoded, err := kitutil.Marshal(value)
return json.RawMessage(encoded), err
}
}
func cloneRaw(raw []byte) json.RawMessage {
return append(json.RawMessage(nil), raw...)
}
func kindFromNativeType(toolType string) Kind {
switch {
case toolType == "file_search":
return KindFileSearch
case strings.HasPrefix(toolType, "web_fetch"):
return KindWebFetch
case toolType == "code_interpreter", strings.HasPrefix(toolType, "code_execution"):
return KindCodeExecution
case strings.Contains(toolType, "computer"):
return KindComputerUse
case toolType == "url_context":
return KindURLContext
case toolType == "mcp", toolType == "mcp_toolset":
return KindMCP
case toolType == "image_generation":
return KindImage
default:
return KindNative
}
}
func executionFromNativeType(toolType string) Execution {
if strings.HasPrefix(toolType, "computer_") || strings.HasPrefix(toolType, "bash_") || strings.HasPrefix(toolType, "text_editor_") || strings.HasPrefix(toolType, "memory_") {
return ExecutionClient
}
return ExecutionServer
}
func extractOpenAIResponsesHostedHistory(input json.RawMessage) (json.RawMessage, []HostedHistoryItem, error) {
if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
return input, nil, nil
}
var rawItems []json.RawMessage
if err := kitutil.Unmarshal(input, &rawItems); err != nil {
return nil, nil, fmt.Errorf("invalid Responses input: %w", err)
}
filtered := make([]json.RawMessage, 0, len(rawItems))
var history []HostedHistoryItem
for index, rawItem := range rawItems {
var item map[string]any
if err := kitutil.Unmarshal(rawItem, &item); err != nil {
return nil, nil, fmt.Errorf("input[%d]: %w", index, err)
}
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
if !isResponsesHostedHistoryType(itemType) {
filtered = append(filtered, rawItem)
continue
}
status := strings.TrimSpace(kitutil.Interface2String(item["status"]))
action := rawMapValue(item, "action")
results := firstRawMapValue(item, "results", "sources", "output")
if itemType == "mcp_call" {
action = rawMapValue(item, "arguments")
output := rawMapValue(item, "output")
itemError := rawMapValue(item, "error")
results = output
if rawJSONPresent(itemError) {
results = itemError
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: hostedKindFromResponsesType(itemType),
NativeType: itemType,
Role: strings.TrimSpace(kitutil.Interface2String(item["role"])),
MessageIndex: index,
Sequence: index,
ID: strings.TrimSpace(kitutil.Interface2String(item["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(item["call_id"])),
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
ServerName: strings.TrimSpace(kitutil.Interface2String(item["server_label"])),
Status: status,
Action: action,
Results: results,
Caller: rawMapValue(item, "caller"),
Raw: cloneRaw(rawItem),
})
}
if len(history) == 0 {
return input, nil, nil
}
encoded, err := kitutil.Marshal(filtered)
if err != nil {
return nil, nil, err
}
return encoded, history, nil
}
func isResponsesHostedHistoryType(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "web_search_call", "file_search_call", "code_interpreter_call", "computer_call", "computer_call_output", "image_generation_call", "local_shell_call", "local_shell_call_output", "apply_patch_call", "apply_patch_call_output", "mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
return true
default:
return false
}
}
func extractClaudeHostedHistory(messages []dto.ClaudeMessage) ([]dto.ClaudeMessage, []HostedHistoryItem, error) {
clonedMessages := make([]dto.ClaudeMessage, 0, len(messages))
var history []HostedHistoryItem
for messageIndex := range messages {
message := messages[messageIndex]
if message.IsStringContent() {
clonedMessages = append(clonedMessages, message)
continue
}
rawContent, err := rawJSON(message.Content)
if err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
var blocks []json.RawMessage
if err := kitutil.Unmarshal(rawContent, &blocks); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
filtered := make([]any, 0, len(blocks))
historyStart := len(history)
for blockIndex, rawBlock := range blocks {
var block map[string]any
if err := kitutil.Unmarshal(rawBlock, &block); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content[%d]: %w", messageIndex, blockIndex, err)
}
blockType := strings.TrimSpace(kitutil.Interface2String(block["type"]))
if blockType != "server_tool_use" && blockType != "mcp_tool_use" && !isClaudeHostedToolBlock(blockType) {
filtered = append(filtered, block)
continue
}
name := strings.TrimSpace(kitutil.Interface2String(block["name"]))
kind := hostedKindFromClaudeCall(blockType, name)
if strings.HasSuffix(blockType, "_tool_result") {
kind = hostedKindFromClaudeResult(blockType)
}
results := rawMapValue(block, "content")
status := "in_progress"
if strings.HasSuffix(blockType, "_tool_result") {
status = "completed"
isError, _ := block["is_error"].(bool)
failed, _ := claudeHostedResultFailure(
blockType,
results,
&isError,
strings.TrimSpace(kitutil.Interface2String(block["error_code"])),
)
if failed {
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: kind,
NativeType: blockType,
Role: message.Role,
MessageIndex: messageIndex,
BlockIndex: blockIndex,
Sequence: len(history),
ID: strings.TrimSpace(kitutil.Interface2String(block["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(block["tool_use_id"])),
Name: name,
ServerName: strings.TrimSpace(kitutil.Interface2String(block["server_name"])),
Status: status,
Action: rawMapValue(block, "input"),
Results: results,
Caller: rawMapValue(block, "caller"),
Raw: cloneRaw(rawBlock),
})
}
if len(filtered) > 0 {
for index := historyStart; index < len(history); index++ {
history[index].MessageHasRegular = true
}
message.Content = filtered
clonedMessages = append(clonedMessages, message)
}
}
return clonedMessages, history, nil
}
func rawMapValue(value map[string]any, key string) json.RawMessage {
item, exists := value[key]
if !exists {
return nil
}
encoded, err := kitutil.Marshal(item)
if err != nil {
return nil
}
return encoded
}
func firstRawMapValue(value map[string]any, keys ...string) json.RawMessage {
for _, key := range keys {
if raw := rawMapValue(value, key); len(raw) > 0 {
return raw
}
}
return nil
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,171 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// claudeWebSearchInputFromResponses narrows the richer Responses action union
// to the only operation exposed by Claude's web-search server tool: one query.
func claudeWebSearchInputFromResponses(raw json.RawMessage) (map[string]any, error) {
canonical, err := dto.NormalizeResponsesWebSearchAction(raw)
if err != nil {
return nil, err
}
var action struct {
Type string `json:"type"`
Query string `json:"query"`
Queries []string `json:"queries"`
}
if err := kitutil.Unmarshal(canonical, &action); err != nil {
return nil, fmt.Errorf("decode normalized Responses web-search action: %w", err)
}
if action.Type != "search" {
return nil, fmt.Errorf("Responses web-search action %q has no Claude equivalent", action.Type)
}
queries := make([]string, 0, len(action.Queries)+1)
for _, query := range action.Queries {
query = strings.TrimSpace(query)
if query != "" {
queries = append(queries, query)
}
}
deprecatedQuery := strings.TrimSpace(action.Query)
if len(queries) == 0 && deprecatedQuery != "" {
queries = append(queries, deprecatedQuery)
} else if deprecatedQuery != "" && (len(queries) != 1 || queries[0] != deprecatedQuery) {
return nil, fmt.Errorf("Responses web-search action contains conflicting query and queries fields")
}
if len(queries) != 1 {
return nil, fmt.Errorf("Claude web search requires exactly one query, got %d", len(queries))
}
return map[string]any{"query": queries[0]}, nil
}
// responsesMCPArgumentsFromClaude converts Claude's JSON-object input into the
// JSON string required by a Responses mcp_call.arguments field.
func responsesMCPArgumentsFromClaude(raw json.RawMessage) (json.RawMessage, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || kitutil.GetJsonType(raw) != "object" {
return nil, fmt.Errorf("Claude MCP input must be a JSON object")
}
var object map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &object); err != nil {
return nil, fmt.Errorf("decode Claude MCP input: %w", err)
}
encoded, err := kitutil.Marshal(trimmed)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
return encoded, nil
}
// claudeMCPInputFromResponses decodes the outer Responses JSON string and
// validates that its contents satisfy Claude's JSON-object input contract.
func claudeMCPInputFromResponses(raw json.RawMessage) (any, error) {
var encoded string
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return nil, fmt.Errorf("Responses MCP arguments must be a JSON string")
}
if err := kitutil.Unmarshal(raw, &encoded); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments string: %w", err)
}
encoded = strings.TrimSpace(encoded)
if encoded == "" || kitutil.GetJsonType(json.RawMessage(encoded)) != "object" {
return nil, fmt.Errorf("Responses MCP arguments must contain a JSON object")
}
var input map[string]any
if err := kitutil.Unmarshal([]byte(encoded), &input); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments object: %w", err)
}
return input, nil
}
// responsesMCPStringFromClaudeContent maps the Claude result shapes that can
// be represented without changing their meaning. A single text block is the
// structured form of a plain MCP text result; other block arrays can contain
// media/resources that a Responses string cannot faithfully preserve.
func responsesMCPStringFromClaudeContent(raw json.RawMessage) (json.RawMessage, bool, error) {
switch kitutil.GetJsonType(raw) {
case "string":
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result string: %w", err)
}
return append(json.RawMessage(nil), raw...), false, nil
case "array":
var blocks []map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &blocks); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result blocks: %w", err)
}
if len(blocks) == 0 {
encoded, err := kitutil.Marshal("")
return encoded, true, err
}
if len(blocks) != 1 {
return nil, false, fmt.Errorf("Responses MCP output cannot preserve %d Claude content blocks", len(blocks))
}
var blockType string
if err := kitutil.Unmarshal(blocks[0]["type"], &blockType); err != nil || blockType != "text" {
return nil, false, fmt.Errorf("Responses MCP output can only preserve a Claude text result block")
}
var text string
if err := kitutil.Unmarshal(blocks[0]["text"], &text); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP text result: %w", err)
}
encoded, err := kitutil.Marshal(text)
if err != nil {
return nil, false, fmt.Errorf("encode Responses MCP output: %w", err)
}
return encoded, true, nil
default:
return nil, false, fmt.Errorf("Responses MCP output cannot preserve Claude result type %q", kitutil.GetJsonType(raw))
}
}
func claudeMCPContentFromResponsesString(raw json.RawMessage) (string, error) {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return "", fmt.Errorf("Responses MCP output/error must be a JSON string")
}
var content string
if err := kitutil.Unmarshal(raw, &content); err != nil {
return "", fmt.Errorf("decode Responses MCP output/error string: %w", err)
}
return content, nil
}
func claudeHostedResultFailure(blockType string, content json.RawMessage, explicitError *bool, explicitCode string) (bool, string) {
if explicitError != nil && *explicitError {
return true, strings.TrimSpace(explicitCode)
}
if strings.TrimSpace(explicitCode) != "" {
return true, strings.TrimSpace(explicitCode)
}
if !strings.HasSuffix(strings.TrimSpace(blockType), "_tool_result") || kitutil.GetJsonType(content) != "object" {
return false, ""
}
var resultError struct {
Type string `json:"type"`
ErrorCode string `json:"error_code"`
}
if kitutil.Unmarshal(content, &resultError) != nil {
return false, ""
}
if !strings.HasSuffix(strings.TrimSpace(resultError.Type), "_error") && strings.TrimSpace(resultError.ErrorCode) == "" {
return false, ""
}
return true, strings.TrimSpace(resultError.ErrorCode)
}
func responsesMCPErrorFromClaudeContent(raw json.RawMessage, errorCode string) (json.RawMessage, bool, error) {
if errorCode = strings.TrimSpace(errorCode); errorCode != "" {
encoded, err := kitutil.Marshal(errorCode)
return encoded, false, err
}
return responsesMCPStringFromClaudeContent(raw)
}
@@ -0,0 +1,118 @@
package toolconv
import (
"encoding/json"
"github.com/QuantumNous/new-api/relaykit/types"
)
type Kind string
const (
KindFunction Kind = "function"
KindWebSearch Kind = "web_search"
KindFileSearch Kind = "file_search"
KindWebFetch Kind = "web_fetch"
KindCodeExecution Kind = "code_execution"
KindComputerUse Kind = "computer_use"
KindURLContext Kind = "url_context"
KindMCP Kind = "mcp"
KindImage Kind = "image_generation"
KindNative Kind = "native"
)
type Execution string
const (
ExecutionClient Execution = "client"
ExecutionServer Execution = "server"
)
type Function struct {
Name string
Description string
Parameters any
Strict *bool
}
type ApproximateLocation struct {
City string
Region string
Country string
Timezone string
}
type WebSearch struct {
Location *ApproximateLocation
AllowedDomains []string
BlockedDomains []string
SearchContextSize string
MaxUses *int
AllowedCallers []string
ResponseInclusion string
ExternalWebAccess *bool
ReturnTokenBudget json.RawMessage
}
type Definition struct {
Kind Kind
Execution Execution
NativeType string
Name string
Function *Function
WebSearch *WebSearch
Raw json.RawMessage
Group int
}
type ChoiceMode string
const (
ChoiceAuto ChoiceMode = "auto"
ChoiceNone ChoiceMode = "none"
ChoiceRequired ChoiceMode = "required"
ChoiceNamed ChoiceMode = "named"
ChoiceOpaque ChoiceMode = "opaque"
)
type Choice struct {
Mode ChoiceMode
Kind Kind
Name string
AllowedNames []string
NativeType string
DisableParallelToolUse *bool
Raw json.RawMessage
}
type Set struct {
Source types.RelayFormat
Definitions []Definition
Choice *Choice
ParallelAllowed *bool
NativeToolConfig json.RawMessage
History []HostedHistoryItem
}
func (s Set) Empty() bool {
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
}
type HostedHistoryItem struct {
Kind Kind
NativeType string
Role string
MessageIndex int
BlockIndex int
MessageHasRegular bool
Sequence int
ID string
CallID string
Name string
ServerName string
Status string
Action json.RawMessage
Results json.RawMessage
Caller json.RawMessage
Raw json.RawMessage
}
@@ -0,0 +1,103 @@
package toolconv
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func geminiCodeExecutionRequest(t *testing.T) *dto.GeminiChatRequest {
t.Helper()
tools, err := kitutil.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
require.NoError(t, err)
return &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
},
Tools: tools,
}
}
func hasDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return true
}
}
return false
}
func TestDefaultPolicyAllowsGeminiCodeExecutionToOpenAI(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
out, diagnostics, err := AttachRequest(types.RelayFormatOpenAI, target, set, &convmeta.Options{})
require.NoError(t, err)
require.NotNil(t, out)
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
assert.Equal(t, types.ConversionLossPolicyAllow, (&convmeta.Options{}).EffectiveToolLossPolicy())
}
func TestResponsePhaseNeverRejectsEvenUnderStrictPolicy(t *testing.T) {
t.Parallel()
text := "hello"
resp := &dto.ClaudeResponse{
Type: "message",
Role: "assistant",
StopReason: "pause_turn",
Content: []dto.ClaudeMediaMessage{
{Type: "redacted_thinking", Data: "secret"},
{Type: "text", Text: &text},
},
}
diagnostics := InspectResponse(types.RelayFormatClaude, types.RelayFormatOpenAI, resp)
require.True(t, hasDiagnosticCode(diagnostics, "continuation_state_lost"))
require.Error(t, types.RejectConversionLoss(types.ConversionLossPolicyStrict, diagnostics))
_, hosted, err := ExtractHostedResponse(types.RelayFormatClaude, resp)
require.NoError(t, err)
out, _, err := AttachHostedResponse(
types.RelayFormatOpenAI,
&dto.OpenAITextResponse{},
hosted,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict},
)
require.NoError(t, err)
require.NotNil(t, out)
}
func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
_, diagnostics, err := AttachRequest(
types.RelayFormatOpenAI,
target,
set,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe},
)
require.Error(t, err)
var loss *types.ConversionLossError
require.ErrorAs(t, err, &loss)
require.NotEmpty(t, loss.Diagnostics)
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
}
@@ -0,0 +1,515 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
// InspectResponse reports protocol information that the current response
// converters cannot faithfully express. It keeps loss handling centralized so
// direct and multi-step routes behave consistently.
func InspectResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from == to {
return nil
}
var diagnostics []types.ConversionDiagnostic
switch value := response.(type) {
case *dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(value, to)
case dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(&value, to)
case *dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(value, to)
case dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(&value, to)
case *dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(value)
case dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(&value)
case *dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(value, to)
case dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(&value, to)
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
// InspectStreamResponse avoids treating a single Gemini streaming chunk as a
// complete grounding document. Gemini grounding chunk indexes and segment
// offsets are cumulative across the stream; the stateful converter validates
// and resolves them after accumulating prior chunks.
func InspectStreamResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from != types.RelayFormatGemini {
return InspectResponse(from, to, response)
}
var value *dto.GeminiChatResponse
switch response := response.(type) {
case *dto.GeminiChatResponse:
value = response
case dto.GeminiChatResponse:
value = &response
default:
return InspectResponse(from, to, response)
}
if value == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range value.Candidates {
metadata := value.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("candidates[%d].groundingMetadata.webSearchQueries", index),
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target stream converter cannot produce an OpenAI Responses web_search_call lifecycle",
))
}
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("candidates[%d].groundingMetadata", index),
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved across stream chunks, but provider-specific search metadata has no target-protocol equivalent",
))
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
func inspectClaudeResponse(response *dto.ClaudeResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
if to != types.RelayFormatOpenAIResponses && (response.StopReason == "pause_turn" || response.Delta != nil && response.Delta.StopReason != nil && *response.Delta.StopReason == "pause_turn") {
diagnostics = append(diagnostics, responseSemanticLoss(
"stop_reason",
"continuation_state_lost",
"Claude pause_turn requires protocol-native continuation state that the target response cannot preserve",
))
}
for index := range response.Content {
diagnostics = append(diagnostics, inspectClaudeContentBlock(&response.Content[index], fmt.Sprintf("content[%d]", index), to, false)...)
}
if response.ContentBlock != nil {
diagnostics = append(diagnostics, inspectClaudeContentBlock(response.ContentBlock, "content_block", to, true)...)
}
return diagnostics
}
func inspectClaudeContentBlock(block *dto.ClaudeMediaMessage, path string, to types.RelayFormat, stream bool) []types.ConversionDiagnostic {
if block == nil {
return nil
}
blockType := strings.TrimSpace(block.Type)
var diagnostics []types.ConversionDiagnostic
if isClaudeHostedToolBlock(blockType) {
kind := KindNative
if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
kind = hostedKindFromClaudeCall(blockType, block.Name)
} else {
kind = hostedKindFromClaudeResult(blockType)
}
if to != types.RelayFormatOpenAIResponses || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent Claude hosted-tool response block %q", to, blockType),
))
} else if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
if block.Id == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".id",
"hosted_tool_id_missing",
"Claude hosted-tool call has no id for pairing it with its result",
))
}
if kind == KindMCP && (block.Name == "" || block.ServerName == "") {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"mcp_identity_missing",
"Claude MCP tool use must include both name and server_name for Responses MCP mapping",
))
}
if rawJSONPresent(block.Caller) {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".caller",
"hosted_tool_caller_unrepresentable",
"OpenAI Responses web_search_call and mcp_call items cannot preserve Claude's hosted-tool caller provenance",
))
}
if !stream {
input, err := kitutil.Marshal(block.Input)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"hosted_tool_input_invalid",
err.Error(),
))
} else if kind == KindMCP {
if _, err := responsesMCPArgumentsFromClaude(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"mcp_arguments_unrepresentable",
err.Error(),
))
}
} else if kind == KindWebSearch {
if _, err := dto.NormalizeResponsesWebSearchAction(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"web_search_action_unrepresentable",
err.Error(),
))
}
}
}
} else if block.ToolUseId == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".tool_use_id",
"hosted_tool_id_missing",
"Claude hosted-tool result has no tool_use_id for pairing it with its call",
))
}
if kind == KindMCP && blockType == "mcp_tool_result" {
content, err := kitutil.Marshal(block.Content)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else {
failed, errorCode := claudeHostedResultFailure(blockType, content, block.IsError, block.ErrorCode)
var normalized bool
if failed {
_, normalized, err = responsesMCPErrorFromClaudeContent(content, errorCode)
} else {
_, normalized, err = responsesMCPStringFromClaudeContent(content)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
path+".content",
"mcp_text_result_normalized",
"Claude's single MCP text block is normalized to a Responses output string",
))
}
}
}
}
if blockType == "redacted_thinking" && block.Data != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".data",
"continuation_state_lost",
"Claude encrypted thinking state cannot be represented by the target response",
))
}
return diagnostics
}
func isClaudeHostedToolBlock(blockType string) bool {
if blockType == "server_tool_use" || blockType == "mcp_tool_use" || blockType == "mcp_tool_result" {
return true
}
return strings.HasSuffix(blockType, "_tool_result")
}
func inspectOpenAIResponsesResponse(response *dto.OpenAIResponsesResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Output {
output := &response.Output[index]
if !isResponsesHostedOutput(output.Type) {
continue
}
kind := hostedKindFromResponsesType(output.Type)
if to != types.RelayFormatClaude || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent OpenAI Responses hosted-tool output %q", to, output.Type),
))
continue
}
if output.ID == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].id", index),
"hosted_tool_id_missing",
"hosted-tool output has no id for pairing the call with its result",
))
}
if kind == KindMCP {
if output.Name == "" || output.ServerLabel == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_identity_missing",
"Responses MCP output must include both name and server_label for Claude MCP mapping",
))
}
if output.ApprovalRequestID != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].approval_request_id", index),
"mcp_approval_state_unrepresentable",
"Claude MCP response blocks cannot preserve a Responses approval_request_id",
))
}
if _, err := claudeMCPInputFromResponses(output.Arguments); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].arguments", index),
"mcp_arguments_unrepresentable",
err.Error(),
))
}
if rawJSONPresent(output.Output) && rawJSONPresent(output.ItemError) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_result_ambiguous",
"Responses MCP output contains both output and error",
))
}
for _, field := range []struct {
name string
raw json.RawMessage
}{{name: "output", raw: output.Output}, {name: "error", raw: output.ItemError}} {
if !rawJSONPresent(field.raw) {
continue
}
if _, err := claudeMCPContentFromResponsesString(field.raw); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].%s", index, field.name),
"mcp_result_unrepresentable",
err.Error(),
))
}
}
} else if kind == KindWebSearch {
if _, err := claudeWebSearchInputFromResponses(output.Action); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].action", index),
"web_search_action_unrepresentable",
err.Error(),
))
}
}
if output.Status != "" && output.Status != "in_progress" && output.Status != "completed" && output.Status != "failed" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_status_unrepresentable",
fmt.Sprintf("Claude cannot preserve hosted-tool status %q", output.Status),
))
}
if output.Status == "failed" && !rawJSONPresent(output.ItemError) && !rawJSONPresent(output.Output) && !rawJSONPresent(output.Results) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_error_missing",
"failed hosted-tool output has no error or output that Claude can preserve",
))
}
}
return diagnostics
}
func inspectOpenAIResponsesStreamResponse(response *dto.ResponsesStreamResponse) []types.ConversionDiagnostic {
if response == nil {
return nil
}
if response.Item != nil && isResponsesHostedOutput(response.Item.Type) {
return []types.ConversionDiagnostic{responseSemanticLoss(
"item",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool output %q has no semantic target-protocol stream mapping", response.Item.Type),
)}
}
eventType := strings.TrimSpace(response.Type)
if strings.Contains(eventType, ".web_search_call.") ||
strings.Contains(eventType, ".file_search_call.") ||
strings.Contains(eventType, ".code_interpreter_call.") ||
strings.Contains(eventType, ".computer_tool_call.") ||
strings.Contains(eventType, ".image_generation_call.") ||
strings.Contains(eventType, ".mcp_call.") {
return []types.ConversionDiagnostic{responseSemanticLoss(
"type",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool stream event %q has no semantic target-protocol mapping", eventType),
)}
}
return nil
}
func isResponsesHostedOutput(outputType string) bool {
switch strings.TrimSpace(outputType) {
case "", "message", "reasoning", "function_call", "custom_tool_call":
return false
default:
return true
}
}
func inspectGeminiResponse(response *dto.GeminiChatResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Candidates {
metadata := response.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
path := fmt.Sprintf("candidates[%d].groundingMetadata", index)
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".webSearchQueries",
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target converter cannot produce an OpenAI Responses web_search_call item",
))
}
diagnostics = append(diagnostics, inspectGeminiGroundingCitations(response.Candidates[index].Content, metadata, path)...)
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
path,
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved, but provider-specific search metadata has no target-protocol equivalent",
))
}
return diagnostics
}
type groundingSupportForInspection struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type groundingChunkForInspection struct {
Web *groundingSourceForInspection `json:"web,omitempty"`
RetrievedContext *groundingSourceForInspection `json:"retrievedContext,omitempty"`
}
type groundingSourceForInspection struct {
URI string `json:"uri,omitempty"`
}
func inspectGeminiGroundingCitations(content dto.GeminiChatContent, metadata *dto.GeminiGroundingMetadata, path string) []types.ConversionDiagnostic {
if len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []groundingChunkForInspection
if len(metadata.GroundingChunks) == 0 || kitutil.Unmarshal(metadata.GroundingChunks, &chunks) != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingChunks",
"grounding_source_invalid",
"Gemini grounding chunks are missing or cannot be decoded",
)}
}
var supports []groundingSupportForInspection
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingSupports",
"grounding_citation_invalid",
fmt.Sprintf("Gemini grounding supports cannot be decoded: %v", err),
)}
}
textPartCount := 0
soleTextPart := -1
for index := range content.Parts {
if content.Parts[index].Text == "" || content.Parts[index].Thought {
continue
}
textPartCount++
soleTextPart = index
}
var diagnostics []types.ConversionDiagnostic
for index, support := range supports {
segmentPath := fmt.Sprintf("%s.groundingSupports[%d].segment", path, index)
hasSource := false
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source != nil && source.URI != "" {
hasSource = true
break
}
}
if !hasSource {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("%s.groundingSupports[%d].groundingChunkIndices", path, index),
"grounding_source_invalid",
"Gemini grounding support does not reference a valid source URI",
))
continue
}
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_ambiguous",
"Gemini grounding omitted partIndex while multiple text parts are present, so citation placement is ambiguous",
))
continue
}
if partIndex < 0 || partIndex >= len(content.Parts) || content.Parts[partIndex].Text == "" || content.Parts[partIndex].Thought {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_invalid",
fmt.Sprintf("Gemini grounding references non-text part %d", partIndex),
))
continue
}
partText := content.Parts[partIndex].Text
start, end := support.Segment.StartIndex, support.Segment.EndIndex
if start < 0 || end <= start || end > len(partText) || !utf8.ValidString(partText[:start]) || !utf8.ValidString(partText[:end]) {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath,
"grounding_offset_invalid",
"Gemini grounding byte offsets do not identify valid UTF-8 boundaries in the referenced part",
))
continue
}
if support.Segment.Text != "" && partText[start:end] != support.Segment.Text {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".text",
"grounding_text_mismatch",
"Gemini grounding segment text does not match the referenced part range",
))
}
}
return diagnostics
}
func responseSemanticLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
}
func responsePresentationLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
}
@@ -0,0 +1,816 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type HostedResponseItem struct {
Kind Kind
NativeType string
ID string
CallID string
Name string
Status string
Position int
Action json.RawMessage
Results json.RawMessage
Sources json.RawMessage
Caller json.RawMessage
Arguments json.RawMessage
Output json.RawMessage
Error json.RawMessage
ServerName string
IsError *bool
ApprovalRequestID string
Tools json.RawMessage
ErrorCode string
Raw json.RawMessage
}
type HostedResponseSet struct {
Source types.RelayFormat
Items []HostedResponseItem
SourceLength int
RegularPositions []int
}
type positionedResponsesOutput struct {
position int
output dto.ResponsesOutput
}
type positionedClaudeBlocks struct {
position int
blocks []dto.ClaudeMediaMessage
}
func (s HostedResponseSet) Empty() bool {
return len(s.Items) == 0
}
// ExtractHostedResponse removes server-executed tool artifacts before a
// message converter sees them. The artifacts travel beside multi-step routes,
// just like request tool definitions, so a lossy Chat pivot cannot reclassify
// or discard them.
func ExtractHostedResponse(format types.RelayFormat, response any) (any, HostedResponseSet, error) {
switch format {
case types.RelayFormatClaude:
return extractClaudeHostedResponse(response)
case types.RelayFormatOpenAIResponses:
return extractOpenAIHostedResponse(response)
case types.RelayFormatGemini:
return extractGeminiHostedResponse(response)
default:
return response, HostedResponseSet{Source: format}, nil
}
}
func AttachHostedResponse(format types.RelayFormat, response any, set HostedResponseSet, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
if set.Empty() {
return response, nil, nil
}
var (
value any
diagnostics []types.ConversionDiagnostic
err error
)
switch format {
case types.RelayFormatOpenAIResponses:
value, diagnostics, err = attachOpenAIHostedResponse(response, set)
case types.RelayFormatClaude:
value, diagnostics, err = attachClaudeHostedResponse(response, set)
default:
value = response
for index, item := range set.Items {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_event_omitted",
fmt.Sprintf("%s cannot represent hosted-tool response %q", format, item.NativeType),
))
}
}
if err != nil {
return nil, diagnostics, err
}
for index := range diagnostics {
diagnostics[index].From = set.Source
diagnostics[index].To = format
}
return value, diagnostics, nil
}
func extractClaudeHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.ClaudeResponse
switch value := response.(type) {
case *dto.ClaudeResponse:
source = value
case dto.ClaudeResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Claude response, got %T", response)
}
clone := *source
clone.Content = make([]dto.ClaudeMediaMessage, 0, len(source.Content))
set := HostedResponseSet{Source: types.RelayFormatClaude, SourceLength: len(source.Content)}
for position := range source.Content {
block := source.Content[position]
blockType := strings.TrimSpace(block.Type)
switch {
case blockType == "server_tool_use" || blockType == "mcp_tool_use":
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
action, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, set, fmt.Errorf("content[%d].input: %w", position, err)
}
item := HostedResponseItem{
Kind: hostedKindFromClaudeCall(blockType, block.Name),
NativeType: blockType,
ID: block.Id,
CallID: block.Id,
Name: block.Name,
Status: "in_progress",
Position: position,
Action: action,
Caller: append(json.RawMessage(nil), block.Caller...),
ServerName: block.ServerName,
Raw: rawBlock,
}
set.Items = append(set.Items, item)
case isClaudeHostedToolBlock(blockType):
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
results, err := kitutil.Marshal(block.Content)
if err != nil {
return nil, set, fmt.Errorf("content[%d].content: %w", position, err)
}
failed, errorCode := claudeHostedResultFailure(blockType, results, block.IsError, block.ErrorCode)
status := "completed"
isError := block.IsError
if failed {
status = "failed"
if isError == nil {
value := true
isError = &value
}
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromClaudeResult(blockType),
NativeType: blockType,
ID: block.ToolUseId,
CallID: block.ToolUseId,
Status: status,
Position: position,
Results: results,
ErrorCode: errorCode,
IsError: isError,
Raw: rawBlock,
})
default:
clone.Content = append(clone.Content, block)
set.RegularPositions = append(set.RegularPositions, position)
}
}
return &clone, set, nil
}
func extractOpenAIHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.OpenAIResponsesResponse
switch value := response.(type) {
case *dto.OpenAIResponsesResponse:
source = value
case dto.OpenAIResponsesResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
clone := *source
clone.Output = make([]dto.ResponsesOutput, 0, len(source.Output))
set := HostedResponseSet{Source: types.RelayFormatOpenAIResponses, SourceLength: len(source.Output)}
for position := range source.Output {
output := source.Output[position]
if !isResponsesHostedOutput(output.Type) {
clone.Output = append(clone.Output, output)
set.RegularPositions = append(set.RegularPositions, position)
continue
}
rawOutput, err := kitutil.Marshal(output)
if err != nil {
return nil, set, fmt.Errorf("output[%d]: %w", position, err)
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromResponsesType(output.Type),
NativeType: output.Type,
ID: output.ID,
CallID: output.CallId,
Name: output.Name,
Status: output.Status,
Position: position,
Action: append(json.RawMessage(nil), output.Action...),
Results: append(json.RawMessage(nil), output.Results...),
Sources: append(json.RawMessage(nil), output.Sources...),
Caller: append(json.RawMessage(nil), output.Caller...),
Arguments: append(json.RawMessage(nil), output.Arguments...),
Output: append(json.RawMessage(nil), output.Output...),
Error: append(json.RawMessage(nil), output.ItemError...),
ServerName: output.ServerLabel,
ApprovalRequestID: output.ApprovalRequestID,
Tools: append(json.RawMessage(nil), output.MCPTools...),
Raw: rawOutput,
})
}
return &clone, set, nil
}
func extractGeminiHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.GeminiChatResponse
switch value := response.(type) {
case *dto.GeminiChatResponse:
source = value
case dto.GeminiChatResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Gemini response, got %T", response)
}
queries := geminichat.GroundingWebSearchQueries(source)
if len(queries) == 0 {
return source, HostedResponseSet{Source: types.RelayFormatGemini}, nil
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": queries,
})
if err != nil {
return nil, HostedResponseSet{}, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
// The Chat pivot emits the answer as the regular Responses output. Place
// the hosted call after that output, matching the stream bridge which only
// learns Gemini's queries once grounding metadata arrives near stream end.
set := HostedResponseSet{
Source: types.RelayFormatGemini,
SourceLength: 2,
RegularPositions: []int{0},
Items: []HostedResponseItem{{
Kind: KindWebSearch,
NativeType: "googleSearch",
ID: fmt.Sprintf("ws_%s", kitutil.GetUUID()),
Status: "completed",
Position: 1,
Action: action,
}},
}
return source, set, nil
}
func attachOpenAIHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.OpenAIResponsesResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedOutput := make([]positionedResponsesOutput, 0, len(set.Items))
convertedByID := make(map[string]int, len(set.Items)*2)
for index, item := range set.Items {
if set.Source == types.RelayFormatOpenAIResponses && len(item.Raw) > 0 {
var output dto.ResponsesOutput
if err := kitutil.Unmarshal(item.Raw, &output); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
continue
}
outputType := responsesTypeFromHostedKind(item.Kind)
if outputType == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("OpenAI Responses has no lossless response mapping for %q", item.NativeType),
))
continue
}
if isClaudeHostedResult(item.NativeType) {
outputIndex, exists := convertedByID[item.CallID]
if !exists {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].tool_use_id", index),
"hosted_tool_result_orphaned",
fmt.Sprintf("hosted-tool result references unknown call %q", item.CallID),
))
continue
}
output := &hostedOutput[outputIndex].output
output.Status = hostedCompletionStatus(item)
if item.Kind == KindMCP {
failed := hostedItemFailed(item)
var (
encoded json.RawMessage
normalized bool
err error
)
if failed {
encoded, normalized, err = responsesMCPErrorFromClaudeContent(item.Results, item.ErrorCode)
} else {
encoded, normalized, err = responsesMCPStringFromClaudeContent(item.Results)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_result_unrepresentable",
err.Error(),
))
continue
}
if failed {
output.Output = nil
output.ItemError = encoded
} else {
output.Output = encoded
output.ItemError = nil
}
if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_text_result_normalized",
"Claude's single MCP text block was normalized to a Responses output string",
))
}
} else if item.Kind == KindWebSearch && rawJSONPresent(item.Results) {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"web_search_result_omitted",
"Claude web-search result content is provider-private and has no field on an OpenAI Responses web_search_call; completion status and citations remain available",
))
}
continue
}
output := dto.ResponsesOutput{
Type: outputType,
ID: firstNonEmpty(item.ID, item.CallID),
Status: hostedCompletionStatus(item),
}
switch item.Kind {
case KindWebSearch:
action, err := dto.NormalizeResponsesWebSearchAction(item.Action)
if err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
output.Action = action
case KindMCP:
if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.ServerName) == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_identity_missing",
"Claude MCP output requires both name and server_name for Responses mapping",
))
continue
}
output.CallId = item.CallID
output.Name = item.Name
output.Caller = append(json.RawMessage(nil), item.Caller...)
output.ServerLabel = item.ServerName
output.ApprovalRequestID = item.ApprovalRequestID
output.MCPTools = append(json.RawMessage(nil), item.Tools...)
arguments := item.Arguments
if len(arguments) == 0 {
arguments = item.Action
}
encodedArguments, argumentErr := responsesMCPArgumentsFromClaude(arguments)
if argumentErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].input", index),
"mcp_arguments_unrepresentable",
argumentErr.Error(),
))
continue
}
output.Arguments = encodedArguments
}
outputIndex := len(hostedOutput)
for _, key := range []string{item.ID, item.CallID} {
if key != "" {
convertedByID[key] = outputIndex
}
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
if set.Source != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeResponsesOutput(target.Output, hostedOutput, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Output = merged
return target, diagnostics, nil
}
func attachClaudeHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.ClaudeResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected Claude response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedContent := make([]positionedClaudeBlocks, 0, len(set.Items))
for index, item := range set.Items {
if set.Source == types.RelayFormatClaude && len(item.Raw) > 0 {
var block dto.ClaudeMediaMessage
if err := kitutil.Unmarshal(item.Raw, &block); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: []dto.ClaudeMediaMessage{block}})
continue
}
if set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"web_search_response_unrepresentable",
"Responses web-search execution cannot reconstruct Claude's required encrypted web_search_tool_result continuation state",
))
continue
}
name := claudeNameFromHostedKind(item.Kind)
if item.Kind == KindMCP {
name = item.Name
}
if name == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("Claude has no lossless response mapping for %q", item.NativeType),
))
continue
}
var input any = map[string]any{}
if item.Kind == KindWebSearch {
webInput, inputErr := claudeWebSearchInputFromResponses(item.Action)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].action", index),
"web_search_action_unrepresentable",
inputErr.Error(),
))
continue
}
input = webInput
} else if item.Kind == KindMCP {
mcpInput, inputErr := claudeMCPInputFromResponses(item.Arguments)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].arguments", index),
"mcp_arguments_unrepresentable",
inputErr.Error(),
))
continue
}
input = mcpInput
} else if len(item.Action) > 0 {
if err := kitutil.Unmarshal(item.Action, &input); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
}
callType := "server_tool_use"
if item.Kind == KindMCP {
callType = "mcp_tool_use"
}
blocks := []dto.ClaudeMediaMessage{{
Type: callType,
Id: item.ID,
Name: name,
Input: input,
Caller: append(json.RawMessage(nil), item.Caller...),
ServerName: item.ServerName,
}}
result := item.Results
if item.Kind == KindMCP {
result = item.Output
if hostedItemFailed(item) && rawJSONPresent(item.Error) {
result = item.Error
}
} else if len(result) == 0 {
result = item.Sources
}
if len(result) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch) {
var content any
if item.Kind == KindMCP {
decoded, resultErr := claudeMCPContentFromResponsesString(result)
if resultErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].output", index),
"mcp_result_unrepresentable",
resultErr.Error(),
))
continue
}
content = decoded
} else if err := kitutil.Unmarshal(result, &content); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].results: %w", index, err)
}
isError := hostedItemFailed(item)
blocks = append(blocks, dto.ClaudeMediaMessage{
Type: claudeResultTypeFromHostedKind(item.Kind),
ToolUseId: item.ID,
Content: content,
IsError: &isError,
ErrorCode: item.ErrorCode,
})
} else if len(result) > 0 && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].results", index),
"web_search_result_omitted",
"Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
))
} else if item.Kind == KindMCP && (item.Status == "completed" || item.Status == "failed") {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_result_missing",
fmt.Sprintf("Responses MCP output has status %q but no output or error", item.Status),
))
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: blocks})
if set.Source != types.RelayFormatClaude {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeClaudeContent(target.Content, hostedContent, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Content = merged
return target, diagnostics, nil
}
func mergeResponsesOutput(regular []dto.ResponsesOutput, hosted []positionedResponsesOutput, set HostedResponseSet) ([]dto.ResponsesOutput, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ResponsesOutput, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.output)
}
regularByPosition := make(map[int]dto.ResponsesOutput, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if output, exists := regularByPosition[position]; exists {
merged = append(merged, output)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(hostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range before {
merged = append(merged, hosted[item].output)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].output)
}
return merged, nil
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range hosted {
merged = append(merged, item.output)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"output",
"hosted_tool_order_unrepresentable",
"hosted-tool items were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func mergeClaudeContent(regular []dto.ClaudeMediaMessage, hosted []positionedClaudeBlocks, set HostedResponseSet) ([]dto.ClaudeMediaMessage, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ClaudeMediaMessage, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.blocks...)
}
regularByPosition := make(map[int]dto.ClaudeMediaMessage, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if block, exists := regularByPosition[position]; exists {
merged = append(merged, block)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(claudeHostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range before {
merged = append(merged, hosted[item].blocks...)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].blocks...)
}
return merged, nil
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range hosted {
merged = append(merged, item.blocks...)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"content",
"hosted_tool_order_unrepresentable",
"hosted-tool blocks were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func hostedPositions(items []positionedResponsesOutput) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func claudeHostedPositions(items []positionedClaudeBlocks) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func hostedOutsideRegularRange(hosted []int, regular []int) (before []int, after []int, exact bool) {
if len(regular) == 0 {
indices := make([]int, len(hosted))
for index := range hosted {
indices[index] = index
}
return indices, nil, true
}
firstRegular, lastRegular := regular[0], regular[len(regular)-1]
for index, position := range hosted {
switch {
case position < firstRegular:
before = append(before, index)
case position > lastRegular:
after = append(after, index)
default:
return nil, nil, false
}
}
return before, after, true
}
func hostedKindFromClaudeCall(blockType string, name string) Kind {
if blockType == "mcp_tool_use" {
return KindMCP
}
switch strings.TrimSpace(name) {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
default:
return KindNative
}
}
func hostedKindFromClaudeResult(blockType string) Kind {
switch strings.TrimSuffix(blockType, "_tool_result") {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
case "mcp":
return KindMCP
default:
return KindNative
}
}
func hostedKindFromResponsesType(outputType string) Kind {
normalized := strings.TrimSpace(outputType)
normalized = strings.TrimSuffix(normalized, "_output")
normalized = strings.TrimSuffix(normalized, "_call")
switch normalized {
case "web_search":
return KindWebSearch
case "file_search":
return KindFileSearch
case "code_interpreter", "local_shell":
return KindCodeExecution
case "computer":
return KindComputerUse
case "image_generation":
return KindImage
case "mcp":
return KindMCP
default:
return KindNative
}
}
func responsesTypeFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search_call"
case KindMCP:
return "mcp_call"
default:
return ""
}
}
func claudeNameFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search"
case KindWebFetch:
return "web_fetch"
case KindCodeExecution:
return ""
case KindMCP:
return "mcp"
default:
return ""
}
}
func isClaudeHostedResult(nativeType string) bool {
return nativeType == "mcp_tool_result" || strings.HasSuffix(nativeType, "_tool_result")
}
func hostedCompletionStatus(item HostedResponseItem) string {
if hostedItemFailed(item) {
return "failed"
}
if item.Status != "" && item.Status != "in_progress" || len(item.Results) > 0 || len(item.Output) > 0 {
return "completed"
}
return "in_progress"
}
func hostedItemFailed(item HostedResponseItem) bool {
return item.Status == "failed" || item.ErrorCode != "" || rawJSONPresent(item.Error) || item.IsError != nil && *item.IsError
}
func hostedErrorValue(item HostedResponseItem) json.RawMessage {
if len(item.Error) > 0 {
return append(json.RawMessage(nil), item.Error...)
}
if item.ErrorCode == "" {
return nil
}
encoded, _ := kitutil.Marshal(item.ErrorCode)
return encoded
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func rawJSONPresent(value json.RawMessage) bool {
normalized := strings.TrimSpace(string(value))
return normalized != "" && normalized != "null"
}
func claudeResultTypeFromHostedKind(kind Kind) string {
name := claudeNameFromHostedKind(kind)
if name == "mcp" {
return "mcp_tool_result"
}
return name + "_tool_result"
}