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
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:
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"
|
||||
}
|
||||
Reference in new issue
Block a user