mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-13 07:40:56 +00:00
feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)
* feat(relaykit): preserve hosted tools across conversions - add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies - bridge citations, grounding metadata, and hosted-tool stream lifecycles - document the public conversion behavior and channel policy controls * refactor(relaykit): normalize reasoning and thinking intent - centralize provider-neutral reasoning intent, effort, and budget mappings - parse model suffixes at the host entry boundary while preserving provider-owned tails - keep adaptive Claude thinking and explicit zero-token compatibility consistent * fix(billing): preserve authoritative usage across relay hops - carry native BillingUsage sidecars through direct and streamed protocol bridges - merge partial and terminal usage monotonically with safe fallback settlement - retain cache metadata, penultimate usage, and per-call Gemini tool surcharges * feat(relay): bridge Responses with Claude and Gemini protocols - add direct request, response, and stream converters across supported relay formats - expose Claude count_tokens and Chat-to-Responses compatibility endpoints - carry conversion diagnostics through the host while retaining the curated public goldens * fix(relay): wire relaykit conversions into host channels - connect handlers, adaptors, and channel settings to the standalone conversion layer - keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned - ignore local audit artifacts and retain focused public regression coverage
This commit is contained in:
@@ -0,0 +1,960 @@
|
||||
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 {
|
||||
function := function
|
||||
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 == "" {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user