mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-08 10:46:58 +00:00
Deferred follow-ups from the relaykit-tools review cycle, verified by live end-to-end billing tests: - billing: normalize Gemini modality keys consistently between stream merge and settlement (case/whitespace variants no longer drop independent audio/image pricing) and sum duplicate modality entries on both paths - billing: sync legacy flat Claude cache-creation fields from the CacheCreation sub-object (including zeroing) and fall back to flat fields only when the snapshot never carried a sub-object, closing a stale 1h-cache overcharge path in cascaded deployments - relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state from gin.Context onto RelayInfo and reset it with SendResponseCount in InitChannelMeta, so channel retries start clean while per-request state (stream error collection, conversion diagnostics, channel chain, billing accumulators) survives - relay: Claude channel now serves Gemini-format clients (request via registry conversion, response and stream composed through the Chat pivot), removing the last unimplemented conversion direction - relaykit: recognize legacy pseudo tool names (googleSearch, codeExecution, urlContext) in the toolconv decode stage and drop the string-matching bypass in the Chat-to-Gemini converter; native Gemini tool output is restored and non-Gemini targets follow standard loss diagnostics - relaykit: attach upstream Gemini usage (with billing_usage sidecar) to intermediate stream chunks so converted Claude streams report upstream truth from message_start, and preserve the sidecar through Claude stream usage merges; billing settlement unchanged - billing: clamp negative Total-Prompt completion derivation, OR the Estimated flag across cross-dialect snapshot replacement, and fill canonical OpenAI prompt details via field-wise merge
999 lines
31 KiB
Go
999 lines
31 KiB
Go
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 == "" {
|
|
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
|
|
}
|