feat: publish sanitized custom.97 source
This commit is contained in:
1022 files changed
+69903
-19207
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
@@ -142,11 +143,11 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
|
||||
}
|
||||
for index, rawTool := range rawTools {
|
||||
definition, err := decodeOpenAIResponsesDefinition(rawTool)
|
||||
definitions, err := decodeOpenAIResponsesDefinitions(rawTool)
|
||||
if err != nil {
|
||||
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
|
||||
}
|
||||
set.Definitions = append(set.Definitions, definition)
|
||||
set.Definitions = append(set.Definitions, definitions...)
|
||||
}
|
||||
}
|
||||
choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
|
||||
@@ -155,11 +156,41 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
}
|
||||
set.Choice = choice
|
||||
|
||||
input, additionalDefinitions, additionalDiagnostics, err := extractOpenAIResponsesAdditionalTools(source.Input)
|
||||
if err != nil {
|
||||
return nil, Set{}, err
|
||||
}
|
||||
set.Definitions = append(set.Definitions, additionalDefinitions...)
|
||||
set.Diagnostics = append(set.Diagnostics, additionalDiagnostics...)
|
||||
// Flattened namespace names can collide with other tools.
|
||||
seenNames := make(map[string]struct{}, len(set.Definitions))
|
||||
seenNamespaces := make(map[string]struct{})
|
||||
definitions := set.Definitions[:0]
|
||||
for _, definition := range set.Definitions {
|
||||
if _, seen := seenNamespaces[definition.Namespace]; !seen && definition.Namespace != "" && definition.Namespace != convmeta.DefaultResponsesToolNamespace {
|
||||
seenNamespaces[definition.Namespace] = struct{}{}
|
||||
set.Diagnostics = append(set.Diagnostics, presentationLoss("tools", "tool_namespace_flattened",
|
||||
fmt.Sprintf("tools in namespace %q are sent as functions named %q", definition.Namespace, convmeta.NamespacedToolName(definition.Namespace, "<name>"))))
|
||||
}
|
||||
name := definition.Name
|
||||
_, duplicate := seenNames[name]
|
||||
if definition.Namespace != "" && name != "" && duplicate {
|
||||
set.Diagnostics = append(set.Diagnostics, semanticLoss("tools", "namespaced_tool_dropped",
|
||||
fmt.Sprintf("tool %q from namespace %q shares its flattened name with another tool and was omitted", name, definition.Namespace)))
|
||||
continue
|
||||
}
|
||||
if name != "" {
|
||||
seenNames[name] = struct{}{}
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
set.Definitions = definitions
|
||||
|
||||
clone := *source
|
||||
clone.Tools = nil
|
||||
clone.ToolChoice = nil
|
||||
clone.ParallelToolCalls = nil
|
||||
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
|
||||
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(input)
|
||||
if err != nil {
|
||||
return nil, Set{}, err
|
||||
}
|
||||
@@ -168,6 +199,103 @@ func extractOpenAIResponsesRequest(request any) (any, Set, error) {
|
||||
return &clone, set, nil
|
||||
}
|
||||
|
||||
// extractOpenAIResponsesAdditionalTools removes the additional_tools input
|
||||
// items Codex uses to declare tools and returns their definitions. No target
|
||||
// protocol has an input item that declares tools, so they join the top-level
|
||||
// tool list.
|
||||
func extractOpenAIResponsesAdditionalTools(input json.RawMessage) (json.RawMessage, []Definition, []types.ConversionDiagnostic, error) {
|
||||
if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
|
||||
return input, nil, nil, nil
|
||||
}
|
||||
var rawItems []json.RawMessage
|
||||
if err := kitutil.Unmarshal(input, &rawItems); err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("invalid Responses input: %w", err)
|
||||
}
|
||||
var (
|
||||
filtered []json.RawMessage
|
||||
definitions []Definition
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
)
|
||||
for index, rawItem := range rawItems {
|
||||
var item struct {
|
||||
Type string `json:"type"`
|
||||
Tools []json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(rawItem, &item); err != nil || strings.TrimSpace(item.Type) != "additional_tools" {
|
||||
filtered = append(filtered, rawItem)
|
||||
continue
|
||||
}
|
||||
for toolIndex, rawTool := range item.Tools {
|
||||
decoded, err := decodeOpenAIResponsesDefinitions(rawTool)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("input[%d].tools[%d]: %w", index, toolIndex, err)
|
||||
}
|
||||
definitions = append(definitions, decoded...)
|
||||
}
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("input[%d]", index), "additional_tools_flattened",
|
||||
"additional_tools items are sent as top-level tools; their position in the input is not preserved"))
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
return input, nil, nil, nil
|
||||
}
|
||||
encoded, err := kitutil.Marshal(filtered)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
return encoded, definitions, diagnostics, nil
|
||||
}
|
||||
|
||||
// decodeOpenAIResponsesDefinitions decodes one Responses tool. A namespace
|
||||
// tool expands into its member tools, each renamed with
|
||||
// convmeta.NamespacedToolName and described with the namespace description.
|
||||
func decodeOpenAIResponsesDefinitions(raw json.RawMessage) ([]Definition, error) {
|
||||
var namespace struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Tools []map[string]any `json:"tools"`
|
||||
}
|
||||
if err := kitutil.Unmarshal(raw, &namespace); err != nil || strings.TrimSpace(namespace.Type) != "namespace" {
|
||||
definition, err := decodeOpenAIResponsesDefinition(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Definition{definition}, nil
|
||||
}
|
||||
namespaceName := strings.TrimSpace(namespace.Name)
|
||||
if namespaceName == "" {
|
||||
return nil, fmt.Errorf("namespace tool requires name")
|
||||
}
|
||||
definitions := make([]Definition, 0, len(namespace.Tools))
|
||||
for index, tool := range namespace.Tools {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
|
||||
if name != "" {
|
||||
tool["name"] = convmeta.NamespacedToolName(namespaceName, name)
|
||||
}
|
||||
description := strings.TrimSpace(kitutil.Interface2String(tool["description"]))
|
||||
if namespaceDescription := strings.TrimSpace(namespace.Description); namespaceDescription != "" {
|
||||
description = strings.TrimSpace(namespaceDescription + "\n\n" + description)
|
||||
}
|
||||
if description != "" {
|
||||
tool["description"] = description
|
||||
}
|
||||
encoded, err := kitutil.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition, err := decodeOpenAIResponsesDefinition(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("namespace %q tools[%d]: %w", namespaceName, index, err)
|
||||
}
|
||||
definition.Namespace = namespaceName
|
||||
if definition.Name == "" {
|
||||
definition.Name = kitutil.Interface2String(tool["name"])
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
return definitions, nil
|
||||
}
|
||||
|
||||
func extractClaudeRequest(request any) (any, Set, error) {
|
||||
source, ok := request.(*dto.ClaudeRequest)
|
||||
if !ok {
|
||||
@@ -428,9 +556,10 @@ func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
|
||||
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,
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
|
||||
Description: kitutil.Interface2String(function["description"]),
|
||||
Parameters: parameters,
|
||||
OpenAPISchema: function["parameters"] != nil,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -610,6 +739,7 @@ func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("Responses function tool_choice requires name")
|
||||
}
|
||||
name = convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(value["namespace"])), name)
|
||||
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
|
||||
}
|
||||
if isOpenAIResponsesWebSearchType(toolType) {
|
||||
|
||||
@@ -24,16 +24,17 @@ func AttachRequest(format types.RelayFormat, request any, set Set, options *conv
|
||||
)
|
||||
switch format {
|
||||
case types.RelayFormatOpenAI:
|
||||
value, diagnostics, err = attachOpenAIChatRequest(request, set)
|
||||
value, diagnostics, err = attachOpenAIChatRequest(request, set, options)
|
||||
case types.RelayFormatOpenAIResponses:
|
||||
value, diagnostics, err = attachOpenAIResponsesRequest(request, set)
|
||||
value, diagnostics, err = attachOpenAIResponsesRequest(request, set, options)
|
||||
case types.RelayFormatClaude:
|
||||
value, diagnostics, err = attachClaudeRequest(request, set, options)
|
||||
case types.RelayFormatGemini:
|
||||
value, diagnostics, err = attachGeminiRequest(request, set)
|
||||
value, diagnostics, err = attachGeminiRequest(request, set, options)
|
||||
default:
|
||||
value = request
|
||||
}
|
||||
diagnostics = append(set.Diagnostics, diagnostics...)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
@@ -47,24 +48,30 @@ func AttachRequest(format types.RelayFormat, request any, set Set, options *conv
|
||||
return value, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
func attachOpenAIChatRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.GeneralOpenAIRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
|
||||
}
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
// chatWebSearchEncoded reports that the host encoder handled the hosted
|
||||
// web search; it may have written a tools[] entry or top-level fields
|
||||
// instead of web_search_options.
|
||||
chatWebSearchEncoded := false
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
target.Tools = append(target.Tools, dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: definition.Function.Name,
|
||||
Description: definition.Function.Description,
|
||||
Parameters: definition.Function.Parameters,
|
||||
Parameters: parameters,
|
||||
Strict: definition.Function.Strict,
|
||||
},
|
||||
})
|
||||
@@ -72,23 +79,52 @@ func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagn
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAI)...)
|
||||
}
|
||||
if target.WebSearchOptions != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d]: multiple hosted web-search definitions cannot be represented by Chat Completions", index)
|
||||
if target.WebSearchOptions != nil || chatWebSearchEncoded {
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"duplicate_hosted_web_search",
|
||||
"Chat Completions carries one hosted web search; this definition was dropped",
|
||||
))
|
||||
continue
|
||||
}
|
||||
options := &dto.WebSearchOptions{}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAI)
|
||||
call.Chat = target
|
||||
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
diagnostics = append(diagnostics, encoderDiagnostics...)
|
||||
chatWebSearchEncoded = true
|
||||
if tool != nil {
|
||||
chatTool, ok := tool.(dto.ToolCallRequest)
|
||||
if !ok {
|
||||
return nil, diagnostics, fmt.Errorf("%s: Chat web-search encoder returned %T, want dto.ToolCallRequest", call.Path(), tool)
|
||||
}
|
||||
target.Tools = append(target.Tools, chatTool)
|
||||
}
|
||||
continue
|
||||
}
|
||||
webSearchOptions := &dto.WebSearchOptions{}
|
||||
if definition.WebSearch != nil {
|
||||
options.SearchContextSize = definition.WebSearch.SearchContextSize
|
||||
webSearchOptions.SearchContextSize = definition.WebSearch.SearchContextSize
|
||||
if definition.WebSearch.Location != nil {
|
||||
location := map[string]any{
|
||||
"type": "approximate",
|
||||
"approximate": locationMap(definition.WebSearch.Location),
|
||||
}
|
||||
options.UserLocation, _ = kitutil.Marshal(location)
|
||||
webSearchOptions.UserLocation, _ = kitutil.Marshal(location)
|
||||
}
|
||||
diagnostics = append(diagnostics, openAIChatWebSearchDiagnostics(index, definition.WebSearch)...)
|
||||
}
|
||||
target.WebSearchOptions = options
|
||||
target.WebSearchOptions = webSearchOptions
|
||||
default:
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
target.Tools = append(target.Tools, tool.chatTool())
|
||||
}
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"unsupported_hosted_tool",
|
||||
@@ -97,17 +133,281 @@ func attachOpenAIChatRequest(request any, set Set) (any, []types.ConversionDiagn
|
||||
}
|
||||
}
|
||||
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAI)
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
|
||||
if chatWebSearchEncoded && forcedWebSearch(set.Choice) {
|
||||
// The encoder received Forced and expressed it in its own encoding.
|
||||
set.Choice = nil
|
||||
}
|
||||
if len(target.Tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatOpenAI)
|
||||
choice, choiceDiagnostics := encodeOpenAIChatChoice(normalizedChoice)
|
||||
target.ToolChoice = choice
|
||||
target.ParallelTooCalls = set.ParallelAllowed
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatOpenAI, set.History)...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachOpenAIResponsesRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
// newWebSearchCall describes one hosted web-search definition for the host
|
||||
// encoder; the caller sets the typed target request.
|
||||
func newWebSearchCall(set Set, index int, definition Definition, target types.RelayFormat) convmeta.WebSearchCall {
|
||||
call := convmeta.WebSearchCall{
|
||||
Source: set.Source,
|
||||
Target: target,
|
||||
NativeType: definition.NativeType,
|
||||
Index: index,
|
||||
Forced: forcedWebSearch(set.Choice),
|
||||
}
|
||||
if definition.WebSearch != nil {
|
||||
call.Spec = *definition.WebSearch
|
||||
}
|
||||
return call
|
||||
}
|
||||
|
||||
func forcedWebSearch(choice *Choice) bool {
|
||||
return choice != nil && choice.Mode == ChoiceNamed && choice.Kind == KindWebSearch
|
||||
}
|
||||
|
||||
// encodeWebSearch runs the host web-search encoder for one definition.
|
||||
// handled is false when no encoder is set or it returned nil; the caller then
|
||||
// keeps its default encoding and default diagnostics. When handled, the
|
||||
// encoder's diagnostics replace the default ones and tool (possibly nil) is
|
||||
// what the caller appends.
|
||||
func encodeWebSearch(call convmeta.WebSearchCall, options *convmeta.Options) (tool any, diagnostics []types.ConversionDiagnostic, handled bool, err error) {
|
||||
if options == nil || options.WebSearch == nil {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
encoding, err := options.WebSearch(call)
|
||||
if err != nil {
|
||||
return nil, nil, false, fmt.Errorf("%s: %w", call.Path(), err)
|
||||
}
|
||||
if encoding == nil {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
if encoding.Omitted {
|
||||
return nil, append(encoding.Diagnostics, call.SemanticLoss(
|
||||
"hosted_web_search_omitted",
|
||||
"the upstream has no hosted web search for this protocol; the web search tool was omitted",
|
||||
)), true, nil
|
||||
}
|
||||
return encoding.Tool, encoding.Diagnostics, true, nil
|
||||
}
|
||||
|
||||
// appendEncodedWebSearch runs the host encoder for a Responses, Claude, or
|
||||
// Gemini target. When the encoder handled the definition, its diagnostics and
|
||||
// tool (if any) are appended and the caller skips its default encoding.
|
||||
func appendEncodedWebSearch(tools *[]any, diagnostics *[]types.ConversionDiagnostic, call convmeta.WebSearchCall, options *convmeta.Options) (bool, error) {
|
||||
tool, encoderDiagnostics, handled, err := encodeWebSearch(call, options)
|
||||
if err != nil || !handled {
|
||||
return false, err
|
||||
}
|
||||
*diagnostics = append(*diagnostics, encoderDiagnostics...)
|
||||
if tool != nil {
|
||||
*tools = append(*tools, tool)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// toolChoiceWithoutToolsDiagnostics reports the tool_choice and
|
||||
// parallel_tool_calls controls omitted because no tool reached the target;
|
||||
// upstreams reject either control in a request without tools.
|
||||
func toolChoiceWithoutToolsDiagnostics(set Set) []types.ConversionDiagnostic {
|
||||
if set.Choice == nil && set.ParallelAllowed == nil {
|
||||
return nil
|
||||
}
|
||||
return []types.ConversionDiagnostic{presentationLoss("tool_choice", "tool_choice_without_tools",
|
||||
"the target request has no function tools, so tool_choice and parallel_tool_calls were omitted")}
|
||||
}
|
||||
|
||||
// jsonSchemaParameters returns function parameters as JSON Schema for a
|
||||
// non-Gemini target, rewriting Gemini's OpenAPI schema subset.
|
||||
func jsonSchemaParameters(index int, function *Function) (any, []types.ConversionDiagnostic) {
|
||||
if !function.OpenAPISchema {
|
||||
return function.Parameters, nil
|
||||
}
|
||||
parameters, dropped := sharedgemini.OpenAPISchemaToJSONSchema(function.Parameters)
|
||||
if len(dropped) == 0 {
|
||||
return parameters, nil
|
||||
}
|
||||
return parameters, []types.ConversionDiagnostic{presentationLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"gemini_schema_keyword_dropped",
|
||||
fmt.Sprintf("JSON Schema has no equivalent for Gemini schema keywords %s; they were dropped", strings.Join(dropped, ", ")),
|
||||
)}
|
||||
}
|
||||
|
||||
// responsesCustomTool is a Responses custom (freeform) tool sent upstream as
|
||||
// a function whose only argument is the raw input string.
|
||||
type responsesCustomTool struct {
|
||||
name string
|
||||
description string
|
||||
}
|
||||
|
||||
// inputSchema is the parameters schema of the function that carries a custom
|
||||
// tool: a single required string argument holding the raw input.
|
||||
func (tool *responsesCustomTool) inputSchema() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
convmeta.CustomToolInputArgument: map[string]any{
|
||||
"type": "string",
|
||||
"description": "Raw input for the tool.",
|
||||
},
|
||||
},
|
||||
"required": []string{convmeta.CustomToolInputArgument},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
}
|
||||
|
||||
func (tool *responsesCustomTool) chatTool() dto.ToolCallRequest {
|
||||
return dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: tool.name,
|
||||
Description: tool.description,
|
||||
Parameters: tool.inputSchema(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const responsesCustomToolType = "custom"
|
||||
|
||||
// ResponsesCustomToolNames returns the Responses custom tools that
|
||||
// AttachRequest sends upstream as functions. The response side restores
|
||||
// function calls with these names as custom_tool_call items.
|
||||
func ResponsesCustomToolNames(set Set) map[string]struct{} {
|
||||
customTools, _ := responsesCustomTools(set)
|
||||
var names map[string]struct{}
|
||||
for _, tool := range customTools {
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
if names == nil {
|
||||
names = make(map[string]struct{}, len(customTools))
|
||||
}
|
||||
names[tool.name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// ResponsesToolNamespaces maps the upstream name of every tool flattened
|
||||
// from a non-default Responses tool namespace to that namespace. The response
|
||||
// side restores the namespace on calls to these tools.
|
||||
func ResponsesToolNamespaces(set Set) map[string]string {
|
||||
if set.Source != types.RelayFormatOpenAIResponses {
|
||||
return nil
|
||||
}
|
||||
var namespaces map[string]string
|
||||
for _, definition := range set.Definitions {
|
||||
if definition.Namespace == "" || definition.Namespace == convmeta.DefaultResponsesToolNamespace || definition.Name == "" {
|
||||
continue
|
||||
}
|
||||
if namespaces == nil {
|
||||
namespaces = make(map[string]string)
|
||||
}
|
||||
namespaces[definition.Name] = definition.Namespace
|
||||
}
|
||||
return namespaces
|
||||
}
|
||||
|
||||
// responsesCustomTools selects, by definition index, the Responses custom
|
||||
// tools that the upstream receives as functions. A nil entry marks a custom
|
||||
// tool that was dropped with a diagnostic; indexes without an entry fall back
|
||||
// to the generic unsupported-tool loss. Request encoding and
|
||||
// ResponsesCustomToolNames share this selection so the restored names always
|
||||
// match the functions that were sent.
|
||||
func responsesCustomTools(set Set) (map[int]*responsesCustomTool, []types.ConversionDiagnostic) {
|
||||
if set.Source != types.RelayFormatOpenAIResponses {
|
||||
return nil, nil
|
||||
}
|
||||
functionNames := make(map[string]struct{})
|
||||
for _, definition := range set.Definitions {
|
||||
if definition.Kind == KindFunction && definition.Function != nil {
|
||||
functionNames[definition.Function.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
var (
|
||||
tools map[int]*responsesCustomTool
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
)
|
||||
for index, definition := range set.Definitions {
|
||||
if definition.Kind != KindNative || definition.NativeType != responsesCustomToolType {
|
||||
continue
|
||||
}
|
||||
tool := decodeResponsesCustomTool(definition.Raw)
|
||||
if tool == nil {
|
||||
continue
|
||||
}
|
||||
if tools == nil {
|
||||
tools = make(map[int]*responsesCustomTool)
|
||||
}
|
||||
path := fmt.Sprintf("tools[%d]", index)
|
||||
if _, exists := functionNames[tool.name]; exists {
|
||||
tools[index] = nil
|
||||
diagnostics = append(diagnostics, semanticLoss(path, "custom_tool_name_conflict",
|
||||
fmt.Sprintf("custom tool %q shares its name with another tool, so the target protocol cannot tell their calls apart", tool.name)))
|
||||
continue
|
||||
}
|
||||
functionNames[tool.name] = struct{}{}
|
||||
tools[index] = tool
|
||||
diagnostics = append(diagnostics, presentationLoss(path, "custom_tool_as_function",
|
||||
fmt.Sprintf("the target protocol has no freeform tools; custom tool %q is sent as a function with one string argument and its input format becomes a description hint", tool.name)))
|
||||
}
|
||||
return tools, diagnostics
|
||||
}
|
||||
|
||||
func decodeResponsesCustomTool(raw json.RawMessage) *responsesCustomTool {
|
||||
var value map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &value); err != nil {
|
||||
return nil
|
||||
}
|
||||
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if description := strings.TrimSpace(kitutil.Interface2String(value["description"])); description != "" {
|
||||
parts = append(parts, description)
|
||||
}
|
||||
parts = append(parts, `This tool takes freeform text. Put the complete raw text in the "input" argument.`)
|
||||
if format, ok := value["format"].(map[string]any); ok && strings.TrimSpace(kitutil.Interface2String(format["type"])) == "grammar" {
|
||||
if definition := strings.TrimSpace(kitutil.Interface2String(format["definition"])); definition != "" {
|
||||
switch syntax := strings.TrimSpace(kitutil.Interface2String(format["syntax"])); syntax {
|
||||
case "lark":
|
||||
parts = append(parts, "The input must match this Lark grammar:\n"+definition)
|
||||
case "regex":
|
||||
parts = append(parts, "The input must match this regular expression:\n"+definition)
|
||||
default:
|
||||
parts = append(parts, fmt.Sprintf("The input must match this %s grammar:\n%s", syntax, definition))
|
||||
}
|
||||
}
|
||||
}
|
||||
return &responsesCustomTool{name: name, description: strings.Join(parts, "\n\n")}
|
||||
}
|
||||
|
||||
// responsesCustomToolChoice rewrites a Responses {"type":"custom"} tool choice
|
||||
// into a named function choice when that custom tool was sent as a function.
|
||||
// Other choices are returned unchanged.
|
||||
func responsesCustomToolChoice(choice *Choice, customTools map[int]*responsesCustomTool) *Choice {
|
||||
if choice == nil || choice.Mode != ChoiceOpaque || choice.NativeType != responsesCustomToolType {
|
||||
return choice
|
||||
}
|
||||
var value map[string]any
|
||||
if err := kitutil.Unmarshal(choice.Raw, &value); err != nil {
|
||||
return choice
|
||||
}
|
||||
name := convmeta.NamespacedToolName(strings.TrimSpace(kitutil.Interface2String(value["namespace"])), strings.TrimSpace(kitutil.Interface2String(value["name"])))
|
||||
for _, tool := range customTools {
|
||||
if tool != nil && tool.name == name {
|
||||
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}
|
||||
}
|
||||
}
|
||||
return choice
|
||||
}
|
||||
|
||||
func attachOpenAIResponsesRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected OpenAI Responses request, got %T", request)
|
||||
@@ -120,11 +420,13 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
tool := map[string]any{
|
||||
"type": "function",
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
"parameters": definition.Function.Parameters,
|
||||
"parameters": parameters,
|
||||
}
|
||||
if definition.Function.Strict != nil {
|
||||
tool["strict"] = *definition.Function.Strict
|
||||
@@ -135,6 +437,15 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatOpenAIResponses)...)
|
||||
}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatOpenAIResponses)
|
||||
call.Responses = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
webSearch := definition.WebSearch
|
||||
toolType := "web_search"
|
||||
if set.Source == types.RelayFormatOpenAIResponses && definition.NativeType != "" {
|
||||
@@ -182,13 +493,19 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
encoded, err := kitutil.Marshal(tools)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
target.Tools = encoded
|
||||
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
if len(tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
encoded, err := kitutil.Marshal(tools)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
target.Tools = encoded
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatOpenAIResponses)
|
||||
choice, choiceDiagnostics, err := encodeOpenAIResponsesChoice(normalizedChoice, set.Source)
|
||||
if err != nil {
|
||||
@@ -200,11 +517,6 @@ func attachOpenAIResponsesRequest(request any, set Set) (any, []types.Conversion
|
||||
}
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
historyDiagnostics, err := appendHostedHistoryToOpenAIResponses(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
@@ -214,14 +526,16 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
return nil, nil, fmt.Errorf("expected Claude Messages request, got %T", request)
|
||||
}
|
||||
tools := make([]any, 0, len(set.Definitions))
|
||||
var diagnostics []types.ConversionDiagnostic
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
inputSchema, err := functionParametersMap(definition.Function.Parameters)
|
||||
parameters, schemaDiagnostics := jsonSchemaParameters(index, definition.Function)
|
||||
diagnostics = append(diagnostics, schemaDiagnostics...)
|
||||
inputSchema, err := functionParametersMap(parameters)
|
||||
if err != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d].input_schema: %w", index, err)
|
||||
}
|
||||
@@ -235,6 +549,15 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
if set.Source == types.RelayFormatGemini {
|
||||
diagnostics = append(diagnostics, geminiNativeWebSearchDiagnostics(index, definition, types.RelayFormatClaude)...)
|
||||
}
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatClaude)
|
||||
call.Claude = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
toolType := "web_search_20250305"
|
||||
if set.Source == types.RelayFormatClaude && isKnownClaudeWebSearchType(definition.NativeType) {
|
||||
toolType = definition.NativeType
|
||||
@@ -285,6 +608,16 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
tools = append(tools, tool)
|
||||
continue
|
||||
}
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
tools = append(tools, &dto.Tool{
|
||||
Name: tool.name,
|
||||
Description: tool.description,
|
||||
InputSchema: tool.inputSchema(),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"unsupported_hosted_tool",
|
||||
@@ -292,23 +625,33 @@ func attachClaudeRequest(request any, set Set, options *convmeta.Options) (any,
|
||||
))
|
||||
}
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
target.Tools = tools
|
||||
}
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(set.Choice, types.RelayFormatClaude)
|
||||
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
|
||||
target.ToolChoice = choice
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
historyDiagnostics, err := appendHostedHistoryToClaude(target, set)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
diagnostics = append(diagnostics, historyDiagnostics...)
|
||||
if len(tools) == 0 {
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
target.Tools = tools
|
||||
normalizedChoice, allowedChoiceDiagnostics := narrowAllowedFunctionChoice(responsesCustomToolChoice(set.Choice, customTools), types.RelayFormatClaude)
|
||||
diagnostics = append(diagnostics, allowedChoiceDiagnostics...)
|
||||
forced := normalizedChoice != nil && (normalizedChoice.Mode == ChoiceRequired || normalizedChoice.Mode == ChoiceNamed)
|
||||
manualThinking := target.Thinking != nil && target.Thinking.Type == "enabled"
|
||||
if forced && manualThinking && set.Source != types.RelayFormatClaude {
|
||||
downgraded := *normalizedChoice
|
||||
downgraded.Mode = ChoiceAuto
|
||||
normalizedChoice = &downgraded
|
||||
diagnostics = append(diagnostics, semanticLoss("tool_choice", "forced_tool_choice_downgraded",
|
||||
"Claude rejects forced tool use while manual extended thinking is enabled; tool_choice was sent as auto, so the model may answer without calling a tool"))
|
||||
}
|
||||
choice, choiceDiagnostics := encodeClaudeChoice(normalizedChoice, set.ParallelAllowed, set.Source)
|
||||
target.ToolChoice = choice
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
return target, diagnostics, nil
|
||||
}
|
||||
|
||||
func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnostic, error) {
|
||||
func attachGeminiRequest(request any, set Set, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
|
||||
target, ok := request.(*dto.GeminiChatRequest)
|
||||
if !ok || target == nil {
|
||||
return nil, nil, fmt.Errorf("expected Gemini generateContent request, got %T", request)
|
||||
@@ -334,33 +677,34 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
return target, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History), nil
|
||||
}
|
||||
var (
|
||||
functions []map[string]any
|
||||
tools []map[string]any
|
||||
diagnostics []types.ConversionDiagnostic
|
||||
functions []map[string]any
|
||||
tools []any
|
||||
)
|
||||
jsonSchema := options != nil && options.Gemini.SupportsJSONSchemaModel(set.Model)
|
||||
customTools, diagnostics := responsesCustomTools(set)
|
||||
for index, definition := range set.Definitions {
|
||||
switch definition.Kind {
|
||||
case KindFunction:
|
||||
if definition.Function == nil {
|
||||
continue
|
||||
}
|
||||
parameters := definition.Function.Parameters
|
||||
parametersField, parameters := "parameters", definition.Function.Parameters
|
||||
if parameters != nil {
|
||||
cloned, err := kitutil.Any2Type[any](parameters)
|
||||
if err != nil {
|
||||
return nil, diagnostics, fmt.Errorf("tools[%d].parameters: %w", index, err)
|
||||
}
|
||||
if params, ok := cloned.(map[string]any); ok {
|
||||
if properties, exists := params["properties"].(map[string]any); exists && len(properties) == 0 {
|
||||
cloned = nil
|
||||
}
|
||||
var misfits []string
|
||||
parametersField, parameters, misfits = sharedgemini.FunctionParameters(cloned, jsonSchema)
|
||||
if len(misfits) > 0 {
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].parameters", index), "gemini_schema_keyword_dropped",
|
||||
fmt.Sprintf("Gemini function parameters cannot carry %s", strings.Join(misfits, ", "))))
|
||||
}
|
||||
parameters = sharedgemini.CleanFunctionParameters(cloned)
|
||||
}
|
||||
function := map[string]any{
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
"parameters": parameters,
|
||||
"name": definition.Function.Name,
|
||||
"description": definition.Function.Description,
|
||||
parametersField: parameters,
|
||||
}
|
||||
deleteEmptyStrings(function)
|
||||
functions = append(functions, function)
|
||||
@@ -368,6 +712,15 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
diagnostics = append(diagnostics, presentationLoss(fmt.Sprintf("tools[%d].strict", index), "unsupported_function_strict", "Gemini does not expose OpenAI function strictness"))
|
||||
}
|
||||
case KindWebSearch:
|
||||
call := newWebSearchCall(set, index, definition, types.RelayFormatGemini)
|
||||
call.Gemini = target
|
||||
handled, err := appendEncodedWebSearch(&tools, &diagnostics, call, options)
|
||||
if err != nil {
|
||||
return nil, diagnostics, err
|
||||
}
|
||||
if handled {
|
||||
continue
|
||||
}
|
||||
if set.Source == types.RelayFormatGemini && len(definition.Raw) > 0 {
|
||||
var tool map[string]any
|
||||
if err := kitutil.Unmarshal(definition.Raw, &tool); err != nil {
|
||||
@@ -401,19 +754,18 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
tools = append(tools, tool)
|
||||
continue
|
||||
}
|
||||
// The established Responses-to-Gemini compatibility path removes
|
||||
// free-form/unknown tools together with custom call history in
|
||||
// PrepareOpenAIResponsesRequest. Keep that explicit downgrade as a
|
||||
// diagnostic; other opaque tools may be server-executed and remain a
|
||||
// semantic loss under the default Safe policy.
|
||||
if set.Source == types.RelayFormatOpenAIResponses && (definition.NativeType == "custom" || definition.NativeType == "unknown") {
|
||||
diagnostics = append(diagnostics, presentationLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
"custom_tool_omitted",
|
||||
"Gemini cannot represent this OpenAI free-form or unknown tool; its preprocessed call history and definition were omitted",
|
||||
))
|
||||
if tool, handled := customTools[index]; handled {
|
||||
if tool != nil {
|
||||
functions = append(functions, map[string]any{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": sharedgemini.CleanFunctionParameters(tool.inputSchema()),
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Other opaque tools may be server-executed and remain a semantic
|
||||
// loss under the default Safe policy.
|
||||
if set.Source == types.RelayFormatOpenAIResponses && definition.Kind == KindNative {
|
||||
diagnostics = append(diagnostics, semanticLoss(
|
||||
fmt.Sprintf("tools[%d]", index),
|
||||
@@ -439,7 +791,11 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
}
|
||||
target.Tools = encoded
|
||||
}
|
||||
config, choiceDiagnostics := encodeGeminiChoice(set.Choice)
|
||||
if len(functions) == 0 {
|
||||
diagnostics = append(diagnostics, unsupportedHostedHistoryDiagnostics(types.RelayFormatGemini, set.History)...)
|
||||
return target, append(diagnostics, toolChoiceWithoutToolsDiagnostics(set)...), nil
|
||||
}
|
||||
config, choiceDiagnostics := encodeGeminiChoice(responsesCustomToolChoice(set.Choice, customTools))
|
||||
target.ToolConfig = config
|
||||
diagnostics = append(diagnostics, choiceDiagnostics...)
|
||||
if set.ParallelAllowed != nil && !*set.ParallelAllowed {
|
||||
|
||||
@@ -3,6 +3,7 @@ package toolconv
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
@@ -33,26 +34,18 @@ type Function struct {
|
||||
Description string
|
||||
Parameters any
|
||||
Strict *bool
|
||||
// OpenAPISchema marks Parameters written in Gemini's OpenAPI schema
|
||||
// subset (uppercase type names, nullable, propertyOrdering) rather than
|
||||
// JSON Schema.
|
||||
OpenAPISchema bool
|
||||
}
|
||||
|
||||
type ApproximateLocation struct {
|
||||
City string
|
||||
Region string
|
||||
Country string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type WebSearch struct {
|
||||
Location *ApproximateLocation
|
||||
AllowedDomains []string
|
||||
BlockedDomains []string
|
||||
SearchContextSize string
|
||||
MaxUses *int
|
||||
AllowedCallers []string
|
||||
ResponseInclusion string
|
||||
ExternalWebAccess *bool
|
||||
ReturnTokenBudget json.RawMessage
|
||||
}
|
||||
// ApproximateLocation and WebSearch are the convmeta types, so a host
|
||||
// web-search encoder receives the decoded specification unchanged.
|
||||
type (
|
||||
ApproximateLocation = convmeta.ApproximateLocation
|
||||
WebSearch = convmeta.WebSearch
|
||||
)
|
||||
|
||||
type Definition struct {
|
||||
Kind Kind
|
||||
@@ -63,6 +56,9 @@ type Definition struct {
|
||||
WebSearch *WebSearch
|
||||
Raw json.RawMessage
|
||||
Group int
|
||||
// Namespace is the Responses tool namespace the definition was declared
|
||||
// in. Name and Function.Name already hold the flattened upstream name.
|
||||
Namespace string
|
||||
}
|
||||
|
||||
type ChoiceMode string
|
||||
@@ -92,10 +88,16 @@ type Set struct {
|
||||
ParallelAllowed *bool
|
||||
NativeToolConfig json.RawMessage
|
||||
History []HostedHistoryItem
|
||||
// Diagnostics reports losses found while extracting the definitions; they
|
||||
// are returned with the attach diagnostics.
|
||||
Diagnostics []types.ConversionDiagnostic
|
||||
// Model is the upstream model the definitions are attached for; target
|
||||
// capability checks read it. The caller of AttachRequest sets it.
|
||||
Model string
|
||||
}
|
||||
|
||||
func (s Set) Empty() bool {
|
||||
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
|
||||
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0 && len(s.Diagnostics) == 0
|
||||
}
|
||||
|
||||
type HostedHistoryItem struct {
|
||||
|
||||
@@ -101,3 +101,181 @@ func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
|
||||
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
|
||||
}
|
||||
|
||||
func codexResponsesToolsRequest(t *testing.T, toolChoice any, extraTools ...map[string]any) *dto.OpenAIResponsesRequest {
|
||||
t.Helper()
|
||||
tools := []map[string]any{
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "exec",
|
||||
"description": "Run code.",
|
||||
"format": map[string]any{"type": "grammar", "syntax": "lark", "definition": "start: /.+/"},
|
||||
},
|
||||
{"type": "function", "name": "wait", "parameters": map[string]any{"type": "object"}},
|
||||
}
|
||||
tools = append(tools, extraTools...)
|
||||
rawTools, err := kitutil.Marshal(tools)
|
||||
require.NoError(t, err)
|
||||
request := &dto.OpenAIResponsesRequest{Model: "gpt-test", Tools: rawTools}
|
||||
if toolChoice != nil {
|
||||
request.ToolChoice, err = kitutil.Marshal(toolChoice)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
// responsesCustomToolTargets are the upstream protocols that receive a
|
||||
// Responses custom tool as a function taking one string argument.
|
||||
var responsesCustomToolTargets = []struct {
|
||||
format types.RelayFormat
|
||||
request func() any
|
||||
}{
|
||||
{types.RelayFormatOpenAI, func() any { return &dto.GeneralOpenAIRequest{Model: "gpt-test"} }},
|
||||
{types.RelayFormatClaude, func() any { return &dto.ClaudeRequest{Model: "claude-test"} }},
|
||||
{types.RelayFormatGemini, func() any { return &dto.GeminiChatRequest{} }},
|
||||
}
|
||||
|
||||
// attachedFunctions returns the function declarations and the tool choice
|
||||
// that AttachRequest wrote into a Chat, Claude, or Gemini request, decoded
|
||||
// from their wire JSON.
|
||||
func attachedFunctions(t *testing.T, out any) ([]map[string]any, any) {
|
||||
t.Helper()
|
||||
var (
|
||||
functions []map[string]any
|
||||
choice any
|
||||
err error
|
||||
)
|
||||
switch target := out.(type) {
|
||||
case *dto.GeneralOpenAIRequest:
|
||||
for _, tool := range target.Tools {
|
||||
function, err := kitutil.Any2Type[map[string]any](tool.Function)
|
||||
require.NoError(t, err)
|
||||
functions = append(functions, function)
|
||||
}
|
||||
choice, err = kitutil.Any2Type[any](target.ToolChoice)
|
||||
case *dto.ClaudeRequest:
|
||||
functions, err = kitutil.Any2Type[[]map[string]any](target.Tools)
|
||||
require.NoError(t, err)
|
||||
choice, err = kitutil.Any2Type[any](target.ToolChoice)
|
||||
case *dto.GeminiChatRequest:
|
||||
var groups []struct {
|
||||
FunctionDeclarations []map[string]any `json:"functionDeclarations"`
|
||||
}
|
||||
require.NoError(t, kitutil.Unmarshal(target.Tools, &groups))
|
||||
require.Len(t, groups, 1)
|
||||
functions = groups[0].FunctionDeclarations
|
||||
choice, err = kitutil.Any2Type[any](target.ToolConfig)
|
||||
default:
|
||||
require.FailNow(t, "unexpected request type", "%T", out)
|
||||
}
|
||||
require.NoError(t, err)
|
||||
return functions, choice
|
||||
}
|
||||
|
||||
func TestResponsesCustomToolReachesEachTargetAsStringInputFunction(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const execDescription = "Run code.\n\nThis tool takes freeform text. Put the complete raw text in the \"input\" argument.\n\nThe input must match this Lark grammar:\nstart: /.+/"
|
||||
inputSchema := map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "string", "description": "Raw input for the tool."},
|
||||
},
|
||||
"required": []any{"input"},
|
||||
"additionalProperties": false,
|
||||
}
|
||||
expected := map[types.RelayFormat]struct {
|
||||
functions []map[string]any
|
||||
choice any
|
||||
}{
|
||||
types.RelayFormatOpenAI: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "parameters": inputSchema},
|
||||
{"name": "wait", "parameters": map[string]any{"type": "object"}},
|
||||
},
|
||||
choice: map[string]any{"type": "function", "function": map[string]any{"name": "exec"}},
|
||||
},
|
||||
types.RelayFormatClaude: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "input_schema": inputSchema},
|
||||
{"name": "wait", "input_schema": map[string]any{"type": "object", "properties": map[string]any{}}},
|
||||
},
|
||||
choice: map[string]any{"type": "tool", "name": "exec"},
|
||||
},
|
||||
// Gemini receives the cleaned OpenAPI schema without additionalProperties.
|
||||
types.RelayFormatGemini: {
|
||||
functions: []map[string]any{
|
||||
{"name": "exec", "description": execDescription, "parameters": map[string]any{
|
||||
"type": "OBJECT",
|
||||
"properties": map[string]any{
|
||||
"input": map[string]any{"type": "STRING", "description": "Raw input for the tool."},
|
||||
},
|
||||
"required": []any{"input"},
|
||||
}},
|
||||
{"name": "wait", "parameters": map[string]any{"type": "OBJECT"}},
|
||||
},
|
||||
choice: map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY", "allowedFunctionNames": []any{"exec"}}},
|
||||
},
|
||||
}
|
||||
|
||||
_, set, err := ExtractRequest(types.RelayFormatOpenAIResponses, codexResponsesToolsRequest(t, map[string]any{"type": "custom", "name": "exec"}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]struct{}{"exec": {}}, ResponsesCustomToolNames(set))
|
||||
|
||||
for _, target := range responsesCustomToolTargets {
|
||||
t.Run(string(target.format), func(t *testing.T) {
|
||||
out, diagnostics, err := AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe})
|
||||
require.NoError(t, err)
|
||||
functions, choice := attachedFunctions(t, out)
|
||||
assert.Equal(t, expected[target.format].functions, functions)
|
||||
assert.Equal(t, expected[target.format].choice, choice)
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "custom_tool_as_function"))
|
||||
assert.False(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
|
||||
assert.False(t, hasDiagnosticCode(diagnostics, "unsupported_tool_choice"))
|
||||
|
||||
_, _, err = AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict})
|
||||
var loss *types.ConversionLossError
|
||||
require.ErrorAs(t, err, &loss)
|
||||
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "custom_tool_as_function"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesCustomToolNameConflictIsDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The custom exec loses to the function exec, so a choice naming it has no
|
||||
// sent tool to point at and must stay unconverted.
|
||||
request := codexResponsesToolsRequest(t, map[string]any{"type": "custom", "name": "exec"},
|
||||
map[string]any{"type": "function", "name": "exec", "parameters": map[string]any{"type": "object"}},
|
||||
map[string]any{"type": "custom", "name": "apply_patch"},
|
||||
map[string]any{"type": "custom", "name": "apply_patch", "description": "duplicate"},
|
||||
)
|
||||
_, set, err := ExtractRequest(types.RelayFormatOpenAIResponses, request)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]struct{}{"apply_patch": {}}, ResponsesCustomToolNames(set))
|
||||
|
||||
for _, target := range responsesCustomToolTargets {
|
||||
t.Run(string(target.format), func(t *testing.T) {
|
||||
out, diagnostics, err := AttachRequest(target.format, target.request(), set, &convmeta.Options{})
|
||||
require.NoError(t, err)
|
||||
functions, choice := attachedFunctions(t, out)
|
||||
require.Len(t, functions, 3)
|
||||
names := make([]string, 0, len(functions))
|
||||
for _, function := range functions {
|
||||
names = append(names, function["name"].(string))
|
||||
}
|
||||
assert.Equal(t, []string{"wait", "exec", "apply_patch"}, names)
|
||||
// Only the custom definitions carry a description, so the kept exec
|
||||
// is the function one.
|
||||
assert.NotContains(t, functions[1], "description")
|
||||
assert.NotContains(t, functions[2]["description"], "duplicate")
|
||||
assert.Nil(t, choice)
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "custom_tool_name_conflict"))
|
||||
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_tool_choice"))
|
||||
|
||||
_, _, err = AttachRequest(target.format, target.request(), set, &convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user