Files
new-api/relaykit/relayconvert/internal/toolconv/decode.go
T
Calcium-Ion bbd97446c2 fix(relay): follow-up billing integrity and conversion completions (#7170)
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
2026-09-03 10:40:05 +08:00

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
}