diff --git a/internal/execution/bifrost/conversion_fidelity.go b/internal/execution/bifrost/conversion_fidelity.go index 27dc74040..9e48390a1 100644 --- a/internal/execution/bifrost/conversion_fidelity.go +++ b/internal/execution/bifrost/conversion_fidelity.go @@ -16,6 +16,13 @@ func finishConvertedPreparation(spec execution.AttemptSpec, providerKind channel (spec.Operation != execution.OperationChatCompletion && spec.Operation != execution.OperationResponsesCreate) { return prepared, nil } + var toolConstraintsValid bool + var needsToolHistoryCheck bool + prepared, needsToolHistoryCheck, toolConstraintsValid = prepareConvertedToolConstraints(spec.ClientProtocol, providerKind, spec.UpstreamModel, prepared) + if !toolConstraintsValid { + failure := notSentConversionFailure(execution.ErrorCodeCriticalSemanticLoss, "conversion cannot preserve requested tools or tool choice") + return preparedAttempt{}, &failure + } vertexChat := providerKind == channel.ProviderGoogleVertex && vertexUsesChatFallback(spec.UpstreamModel) if providerKind == channel.ProviderAnthropic || providerKind == channel.ProviderGemini || providerKind == channel.ProviderAWSBedrock || (providerKind == channel.ProviderGoogleVertex && !vertexChat) { @@ -24,16 +31,23 @@ func finishConvertedPreparation(spec execution.AttemptSpec, providerKind channel chatFallback := providerKind == channel.ProviderOpenAICompatible || providerKind == channel.ProviderGroq || providerKind == channel.ProviderDeepSeek || vertexChat if chatFallback { - if chatFallbackDropsTools(prepared.responsesRequest) { + dropsTools, newlyAllowed := chatFallbackToolCompatibility(prepared.responsesRequest) + needsToolHistoryCheck = needsToolHistoryCheck || newlyAllowed + // Compatible 保持 SDK 的尽力转换行为:不支持的工具及选择可被过滤, + // 不因此拒绝整个请求;普通函数白名单仍由前面的准备阶段适配。 + if dropsTools && providerKind != channel.ProviderOpenAICompatible { failure := notSentConversionFailure(execution.ErrorCodeCriticalSemanticLoss, "Chat conversion cannot preserve requested tools or tool choice") return preparedAttempt{}, &failure } - normalizeChatFallbackToolMode(prepared.responsesRequest) if providerKind == channel.ProviderDeepSeek && deepSeekConversionDisablesThinking(prepared.responsesRequest) { failure := notSentConversionFailure(execution.ErrorCodeCriticalSemanticLoss, "DeepSeek conversion cannot preserve explicit thinking with this tool choice or history") return preparedAttempt{}, &failure } } + if needsToolHistoryCheck && !convertedTargetPreservesToolHistory(providerKind, spec.UpstreamModel, prepared.responsesRequest) { + failure := notSentConversionFailure(execution.ErrorCodeCriticalSemanticLoss, "conversion cannot preserve requested tool history") + return preparedAttempt{}, &failure + } wantSystems := dialect.CountMidConversationSystemMessages(spec.ClientProtocol, spec.Body) if wantSystems == 0 { return prepared, nil @@ -95,44 +109,6 @@ func preserveResponsesGlobalInstructions(request *schemas.BifrostResponsesReques request.Params.Instructions = nil } -func chatFallbackDropsTools(request *schemas.BifrostResponsesRequest) bool { - if request == nil || request.Params == nil { - return false - } - // 仅检查 SDK 的参数映射;不重复转换消息,也不自行维护工具能力表。 - converted := (&schemas.BifrostResponsesRequest{Params: request.Params}).ToChatRequest() - if len(converted.Params.Tools) != len(request.Params.Tools) { - return true - } - choice := request.Params.ToolChoice - if choice == nil || converted.Params.ToolChoice != nil { - return false - } - // 没有声明工具时,省略 auto/none 不会改变行为;强制调用约束仍不能丢失。 - mode := "" - if choice.ResponsesToolChoiceStr != nil { - mode = *choice.ResponsesToolChoiceStr - } else if value := choice.ResponsesToolChoiceStruct; value != nil && len(value.Tools) == 0 && value.Name == nil && value.ServerLabel == nil { - mode = string(value.Type) - } - return mode != "auto" && mode != "none" -} - -func normalizeChatFallbackToolMode(request *schemas.BifrostResponsesRequest) { - if request == nil || request.Params == nil || request.Params.ToolChoice == nil { - return - } - choice := request.Params.ToolChoice.ResponsesToolChoiceStruct - if choice == nil || len(choice.Tools) != 0 || choice.Name != nil || choice.ServerLabel != nil { - return - } - switch choice.Type { - case schemas.ResponsesToolChoiceTypeAuto, schemas.ResponsesToolChoiceTypeNone, schemas.ResponsesToolChoiceTypeRequired: - // SDK 的结构化 mode:auto 会误映射为 any;采用同义字符串保留原始约束。 - request.Params.ToolChoice = &schemas.ResponsesToolChoice{ResponsesToolChoiceStr: schemas.Ptr(string(choice.Type))} - } -} - func deepSeekConversionDisablesThinking(request *schemas.BifrostResponsesRequest) bool { if request == nil || request.Params == nil || request.Params.Reasoning == nil { return false diff --git a/internal/execution/bifrost/conversion_fidelity_test.go b/internal/execution/bifrost/conversion_fidelity_test.go index 09b1fad18..bd4e051c1 100644 --- a/internal/execution/bifrost/conversion_fidelity_test.go +++ b/internal/execution/bifrost/conversion_fidelity_test.go @@ -172,7 +172,7 @@ func TestChatFallbackDoesNotDropRequestedTools(t *testing.T) { {"no tools required", `[]`, `"required"`, true}, {"web search", `[{"type":"web_search_preview"}]`, `"auto"`, true}, {"function", `[{"type":"function","name":"lookup","parameters":{"type":"object"}}]`, `"required"`, false}, - {"allowed tools", `[{"type":"function","name":"lookup","parameters":{"type":"object"}}]`, `{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}`, true}, + {"allowed tools", `[{"type":"function","name":"lookup","parameters":{"type":"object"}}]`, `{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}`, false}, } { for _, stream := range []bool{false, true} { t.Run(fmt.Sprintf("%s/%s/stream=%t", channelID, test.name, stream), func(t *testing.T) { @@ -199,7 +199,7 @@ func TestChatFallbackDoesNotDropRequestedTools(t *testing.T) { } else { evidence = runtime.Execute(t.Context(), spec).Error } - if test.wantError { + if test.wantError && channelID != channel.OpenAICompatible { if calls.Load() != 0 || evidence == nil || evidence.Kind != execution.ErrorKindConversionUnsupported || evidence.Code != execution.ErrorCodeCriticalSemanticLoss { t.Fatalf("tool requirement was lost: calls=%d error=%+v", calls.Load(), evidence) } diff --git a/internal/execution/bifrost/count_tokens_test.go b/internal/execution/bifrost/count_tokens_test.go index 353053d3f..a56f298f8 100644 --- a/internal/execution/bifrost/count_tokens_test.go +++ b/internal/execution/bifrost/count_tokens_test.go @@ -8,6 +8,8 @@ import ( "net/http/httptest" "testing" + "github.com/tidwall/gjson" + "gpt-load/internal/channel" "gpt-load/internal/execution" "gpt-load/internal/protocol" @@ -183,6 +185,46 @@ func TestConvertedCountTokensUsesBifrostProviderEndpointAndClientShape(t *testin } } +func TestConvertedCountTokensUsesGenerationToolConstraints(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + clientProtocol protocol.Protocol + operation execution.Operation + path string + body string + }{ + { + name: "OpenAI Responses", clientProtocol: protocol.OpenAIResponses, + operation: execution.OperationResponsesInputTokens, path: "/v1/responses/input_tokens", + body: `{"model":"client-model","input":"hello","store":false,"tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"},{"type":"function","name":"remove_record"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"}]}}`, + }, + { + name: "Gemini", clientProtocol: protocol.Gemini, + operation: execution.OperationCountTokens, path: "/v1beta/models/client-model:countTokens", + body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup"},{"name":"summarize"},{"name":"remove_record"}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY","allowedFunctionNames":["lookup","summarize"]}}}`, + }, + } { + t.Run(test.name, func(t *testing.T) { + runtime := newProtocolTestRuntime(t, testRuntimeOptions{}) + spec := countTokensSpec(channel.Anthropic, test.clientProtocol, test.path, test.body, execution.RouteConverted) + spec.Operation = test.operation + spec = freezeTestAttempt(spec) + prepared, failure := runtime.prepare(spec, false) + if failure != nil { + t.Fatalf("prepare token-count request: %+v", failure.Error) + } + wire := convertedToolTargetWire(t, channel.ProviderAnthropic, spec.UpstreamModel, prepared.countTokensRequest) + if gjson.GetBytes(wire, "tools.#").Int() != 2 || + gjson.GetBytes(wire, "tools.0.name").String() != "lookup" || + gjson.GetBytes(wire, "tools.1.name").String() != "summarize" || + gjson.GetBytes(wire, "tool_choice.type").String() != "any" { + t.Fatalf("token-count constraints differ from generation: %s", wire) + } + }) + } +} + func TestCountTokensUnsupportedIsRequestRejected(t *testing.T) { t.Parallel() diff --git a/internal/execution/bifrost/executor.go b/internal/execution/bifrost/executor.go index 78532c14a..cab6043a4 100644 --- a/internal/execution/bifrost/executor.go +++ b/internal/execution/bifrost/executor.go @@ -774,6 +774,23 @@ func (r *Runtime) prepare(spec execution.AttemptSpec, stream bool) (preparedAtte failure := notSentUnaryFailure(execution.ErrorKindInvalidRequest, conversionErr.Error()) return preparedAttempt{}, &failure } + if spec.RouteMode == execution.RouteConverted { + toolPrepared, needsToolHistoryCheck, toolConstraintsValid := prepareConvertedToolConstraints( + spec.ClientProtocol, + providerKind, + spec.UpstreamModel, + preparedAttempt{responsesRequest: request}, + ) + if !toolConstraintsValid { + failure := notSentConversionFailure(execution.ErrorCodeCriticalSemanticLoss, "conversion cannot preserve requested tools or tool choice") + return preparedAttempt{}, &failure + } + if needsToolHistoryCheck && !convertedTargetPreservesToolHistory(providerKind, spec.UpstreamModel, toolPrepared.responsesRequest) { + failure := notSentConversionFailure(execution.ErrorCodeCriticalSemanticLoss, "conversion cannot preserve requested tool history") + return preparedAttempt{}, &failure + } + request = toolPrepared.responsesRequest + } typedURL, upstreamProtocol, targetErr := countTokensTypedTarget( providerKind, customTargetBaseURL, diff --git a/internal/execution/bifrost/tool_compatibility.go b/internal/execution/bifrost/tool_compatibility.go new file mode 100644 index 000000000..325af707f --- /dev/null +++ b/internal/execution/bifrost/tool_compatibility.go @@ -0,0 +1,495 @@ +package bifrost + +import ( + "strings" + + "github.com/maximhq/bifrost/core/schemas" + + "gpt-load/internal/channel" + "gpt-load/internal/protocol" +) + +type functionAllowlist struct { + mode string + names map[string]struct{} +} + +// 只适配锁定 SDK 的目标转换器无法保留的工具选择;目标能原生表达白名单时保留完整工具列表。 +func prepareConvertedToolConstraints( + clientProtocol protocol.Protocol, + providerKind channel.ProviderKind, + model string, + prepared preparedAttempt, +) (preparedAttempt, bool, bool) { + needsToolHistoryCheck := false + if prepared.responsesRequest != nil && prepared.responsesRequest.Params != nil { + request := prepared.responsesRequest + fallback := responsesTargetNeedsAllowlistFallback(providerKind, model) + if fallback || clientProtocol == protocol.Gemini && responsesAllowlistNeedsCanonicalType(request) { + var allowlist functionAllowlist + var present, valid bool + request, allowlist, present, valid = responsesFunctionAllowlist(request) + if present && !valid { + return preparedAttempt{}, false, false + } + if present && fallback { + request = applyResponsesFunctionAllowlist(request, allowlist) + needsToolHistoryCheck = true + } else if present { + request = canonicalizeResponsesFunctionAllowlist(request) + } + } + request = normalizeConvertedResponseToolMode(request) + prepared.responsesRequest = request + } + + if prepared.request != nil && prepared.request.Params != nil && chatTargetNeedsAllowlistFallback(providerKind, model) { + request, allowlist, present, valid := chatFunctionAllowlist(prepared.request) + if present && !valid { + return preparedAttempt{}, false, false + } + if present { + prepared.request = applyChatFunctionAllowlist(request, allowlist) + } + } + return prepared, needsToolHistoryCheck, true +} + +func responsesAllowlistNeedsCanonicalType(request *schemas.BifrostResponsesRequest) bool { + if request == nil || request.Params == nil || request.Params.ToolChoice == nil || + request.Params.ToolChoice.ResponsesToolChoiceStruct == nil { + return false + } + choice := request.Params.ToolChoice.ResponsesToolChoiceStruct + return choice.Type != schemas.ResponsesToolChoiceTypeAllowedTools && len(choice.Tools) > 0 +} + +func responsesFunctionAllowlist( + request *schemas.BifrostResponsesRequest, +) (*schemas.BifrostResponsesRequest, functionAllowlist, bool, bool) { + choice := request.Params.ToolChoice + if choice == nil || choice.ResponsesToolChoiceStruct == nil { + return request, functionAllowlist{}, false, true + } + value := choice.ResponsesToolChoiceStruct + present := value.Type == schemas.ResponsesToolChoiceTypeAllowedTools || len(value.Tools) != 0 + if !present { + return request, functionAllowlist{}, false, true + } + if value.Mode == nil || (*value.Mode != "auto" && *value.Mode != "required") || len(value.Tools) == 0 { + return request, functionAllowlist{}, true, false + } + + names := make(map[string]struct{}, len(value.Tools)) + for _, allowed := range value.Tools { + if allowed.Type != string(schemas.ResponsesToolTypeFunction) || allowed.Name == nil || strings.TrimSpace(*allowed.Name) == "" { + return request, functionAllowlist{}, true, false + } + if _, duplicate := names[*allowed.Name]; duplicate { + return request, functionAllowlist{}, true, false + } + names[*allowed.Name] = struct{}{} + } + if !responsesFunctionsResolveUniquely(request.Params.Tools, names) { + return request, functionAllowlist{}, true, false + } + return request, functionAllowlist{mode: *value.Mode, names: names}, true, true +} + +func chatFunctionAllowlist( + request *schemas.BifrostChatRequest, +) (*schemas.BifrostChatRequest, functionAllowlist, bool, bool) { + choice := request.Params.ToolChoice + if choice == nil || choice.ChatToolChoiceStruct == nil || + choice.ChatToolChoiceStruct.Type != schemas.ChatToolChoiceTypeAllowedTools { + return request, functionAllowlist{}, false, true + } + allowed := choice.ChatToolChoiceStruct.AllowedTools + if allowed == nil || (allowed.Mode != "auto" && allowed.Mode != "required") || len(allowed.Tools) == 0 { + return request, functionAllowlist{}, true, false + } + names := make(map[string]struct{}, len(allowed.Tools)) + for _, tool := range allowed.Tools { + name := tool.Function.Name + if tool.Type != string(schemas.ChatToolTypeFunction) || strings.TrimSpace(name) == "" { + return request, functionAllowlist{}, true, false + } + if _, duplicate := names[name]; duplicate { + return request, functionAllowlist{}, true, false + } + names[name] = struct{}{} + } + if !chatFunctionsResolveUniquely(request.Params.Tools, names) { + return request, functionAllowlist{}, true, false + } + return request, functionAllowlist{mode: allowed.Mode, names: names}, true, true +} + +func responsesFunctionsResolveUniquely(tools []schemas.ResponsesTool, names map[string]struct{}) bool { + counts := make(map[string]int, len(names)) + for _, tool := range tools { + if tool.Type != schemas.ResponsesToolTypeFunction || tool.Name == nil { + continue + } + if _, selected := names[*tool.Name]; selected { + counts[*tool.Name]++ + } + } + for name := range names { + if counts[name] != 1 { + return false + } + } + return true +} + +func chatFunctionsResolveUniquely(tools []schemas.ChatTool, names map[string]struct{}) bool { + counts := make(map[string]int, len(names)) + for _, tool := range tools { + if tool.Type != schemas.ChatToolTypeFunction || tool.Function == nil { + continue + } + if _, selected := names[tool.Function.Name]; selected { + counts[tool.Function.Name]++ + } + } + for name := range names { + if counts[name] != 1 { + return false + } + } + return true +} + +func applyResponsesFunctionAllowlist( + request *schemas.BifrostResponsesRequest, + allowlist functionAllowlist, +) *schemas.BifrostResponsesRequest { + cloned, params := cloneResponsesRequestParams(request) + params.Tools = make([]schemas.ResponsesTool, 0, len(allowlist.names)) + for _, tool := range request.Params.Tools { + if tool.Type == schemas.ResponsesToolTypeFunction && tool.Name != nil { + if _, selected := allowlist.names[*tool.Name]; selected { + params.Tools = append(params.Tools, tool) + } + } + } + params.ToolChoice = &schemas.ResponsesToolChoice{ResponsesToolChoiceStr: schemas.Ptr(allowlist.mode)} + return cloned +} + +func canonicalizeResponsesFunctionAllowlist(request *schemas.BifrostResponsesRequest) *schemas.BifrostResponsesRequest { + choice := request.Params.ToolChoice.ResponsesToolChoiceStruct + if choice.Type == schemas.ResponsesToolChoiceTypeAllowedTools { + return request + } + cloned, params := cloneResponsesRequestParams(request) + choiceCopy := *choice + choiceCopy.Type = schemas.ResponsesToolChoiceTypeAllowedTools + params.ToolChoice = &schemas.ResponsesToolChoice{ResponsesToolChoiceStruct: &choiceCopy} + return cloned +} + +func applyChatFunctionAllowlist( + request *schemas.BifrostChatRequest, + allowlist functionAllowlist, +) *schemas.BifrostChatRequest { + cloned := *request + params := *request.Params + cloned.Params = ¶ms + params.Tools = make([]schemas.ChatTool, 0, len(allowlist.names)) + for _, tool := range request.Params.Tools { + if tool.Type == schemas.ChatToolTypeFunction && tool.Function != nil { + if _, selected := allowlist.names[tool.Function.Name]; selected { + params.Tools = append(params.Tools, tool) + } + } + } + params.ToolChoice = &schemas.ChatToolChoice{ChatToolChoiceStr: schemas.Ptr(allowlist.mode)} + return &cloned +} + +func normalizeConvertedResponseToolMode(request *schemas.BifrostResponsesRequest) *schemas.BifrostResponsesRequest { + choice := request.Params.ToolChoice + if choice == nil { + return request + } + mode := "" + if choice.ResponsesToolChoiceStr != nil { + mode = *choice.ResponsesToolChoiceStr + } else if value := choice.ResponsesToolChoiceStruct; value != nil && + len(value.Tools) == 0 && value.Name == nil && value.ServerLabel == nil { + mode = string(value.Type) + if mode == "" && value.Mode != nil { + mode = *value.Mode + } + } + if mode == "any" { + mode = "required" + } + if mode != "auto" && mode != "none" && mode != "required" { + return request + } + if choice.ResponsesToolChoiceStr != nil && *choice.ResponsesToolChoiceStr == mode { + return request + } + cloned, params := cloneResponsesRequestParams(request) + params.ToolChoice = &schemas.ResponsesToolChoice{ResponsesToolChoiceStr: schemas.Ptr(mode)} + return cloned +} + +func cloneResponsesRequestParams( + request *schemas.BifrostResponsesRequest, +) (*schemas.BifrostResponsesRequest, *schemas.ResponsesParameters) { + cloned := *request + params := *request.Params + cloned.Params = ¶ms + return &cloned, ¶ms +} + +func responsesTargetNeedsAllowlistFallback(providerKind channel.ProviderKind, model string) bool { + switch providerKind { + case channel.ProviderOpenAICompatible, channel.ProviderAnthropic, channel.ProviderDeepSeek, + channel.ProviderGroq, channel.ProviderAWSBedrock: + return true + case channel.ProviderGoogleVertex: + return !vertexUsesGeminiResponses(model) + default: + return false + } +} + +func chatTargetNeedsAllowlistFallback(providerKind channel.ProviderKind, model string) bool { + switch providerKind { + case channel.ProviderAnthropic, channel.ProviderGemini, channel.ProviderAWSBedrock: + return true + case channel.ProviderGoogleVertex: + return schemas.IsAnthropicModelFamily(nil, model) || vertexUsesGeminiResponses(model) + default: + return false + } +} + +func vertexUsesGeminiResponses(model string) bool { + return schemas.IsGeminiModelFamily(nil, model) || schemas.IsGemmaModelFamily(nil, model) || schemas.IsAllDigitsASCII(model) +} + +func targetUsesChatFallback(providerKind channel.ProviderKind, model string) bool { + return providerKind == channel.ProviderOpenAICompatible || providerKind == channel.ProviderGroq || + providerKind == channel.ProviderDeepSeek || providerKind == channel.ProviderGoogleVertex && vertexUsesChatFallback(model) +} + +func chatFallbackToolCompatibility(request *schemas.BifrostResponsesRequest) (bool, bool) { + if request == nil || request.Params == nil { + return false, false + } + converted := (&schemas.BifrostResponsesRequest{Params: request.Params}).ToChatRequest() + choice := request.Params.ToolChoice + mode, selectedFunction := responsesToolChoiceMode(choice) + toolsChanged := !chatFallbackPreservesAllTools(request.Params.Tools, converted.Params.Tools) + + switch mode { + case "none": + if len(converted.Params.Tools) == 0 { + return false, toolsChanged + } + return chatToolChoiceMode(converted.Params.ToolChoice) != "none", toolsChanged + case "function": + if selectedFunction == "" || !responsesFunctionSelectedUniquely(request.Params.Tools, selectedFunction) || + !chatFunctionSelectedUniquely(converted.Params.Tools, selectedFunction) || + chatSelectedFunction(converted.Params.ToolChoice) != selectedFunction { + return true, false + } + return false, toolsChanged + } + + if toolsChanged { + return true, false + } + if choice == nil { + return converted.Params.ToolChoice != nil, false + } + convertedMode := chatToolChoiceMode(converted.Params.ToolChoice) + if len(request.Params.Tools) == 0 && (mode == "auto" || mode == "none") && converted.Params.ToolChoice == nil { + return false, false + } + return convertedMode != mode, false +} + +func chatFallbackPreservesAllTools(source []schemas.ResponsesTool, target []schemas.ChatTool) bool { + if len(source) != len(target) { + return false + } + for index, tool := range source { + if tool.Type != schemas.ResponsesToolTypeFunction || tool.Name == nil || target[index].Function == nil || + target[index].Type != schemas.ChatToolTypeFunction || target[index].Function.Name != *tool.Name { + return false + } + } + return true +} + +func responsesToolChoiceMode(choice *schemas.ResponsesToolChoice) (string, string) { + if choice == nil { + return "", "" + } + if choice.ResponsesToolChoiceStr != nil { + return *choice.ResponsesToolChoiceStr, "" + } + value := choice.ResponsesToolChoiceStruct + if value == nil { + return "", "" + } + if value.Type == schemas.ResponsesToolChoiceTypeFunction && value.Name != nil { + return "function", *value.Name + } + return string(value.Type), "" +} + +func chatToolChoiceMode(choice *schemas.ChatToolChoice) string { + if choice == nil { + return "" + } + if choice.ChatToolChoiceStr != nil { + return *choice.ChatToolChoiceStr + } + if choice.ChatToolChoiceStruct != nil { + return string(choice.ChatToolChoiceStruct.Type) + } + return "" +} + +func chatSelectedFunction(choice *schemas.ChatToolChoice) string { + if choice == nil || choice.ChatToolChoiceStruct == nil || + choice.ChatToolChoiceStruct.Type != schemas.ChatToolChoiceTypeFunction || choice.ChatToolChoiceStruct.Function == nil { + return "" + } + return choice.ChatToolChoiceStruct.Function.Name +} + +func responsesFunctionSelectedUniquely(tools []schemas.ResponsesTool, name string) bool { + count := 0 + for _, tool := range tools { + if tool.Type == schemas.ResponsesToolTypeFunction && tool.Name != nil && *tool.Name == name { + count++ + } + } + return count == 1 +} + +func chatFunctionSelectedUniquely(tools []schemas.ChatTool, name string) bool { + count := 0 + for _, tool := range tools { + if tool.Type == schemas.ChatToolTypeFunction && tool.Function != nil && tool.Function.Name == name { + count++ + } + } + return count == 1 +} + +func chatFallbackPreservesToolHistory(request *schemas.BifrostResponsesRequest) bool { + if request == nil { + return true + } + for _, message := range request.Input { + if message.Type == nil { + continue + } + switch *message.Type { + case schemas.ResponsesMessageTypeMessage, + schemas.ResponsesMessageTypeReasoning, + schemas.ResponsesMessageTypeFunctionCall, + schemas.ResponsesMessageTypeFunctionCallOutput, + schemas.ResponsesMessageTypeItemReference, + schemas.ResponsesMessageTypeRefusal: + continue + case schemas.ResponsesMessageTypeFileSearchCall, + schemas.ResponsesMessageTypeComputerCall, + schemas.ResponsesMessageTypeComputerCallOutput, + schemas.ResponsesMessageTypeWebSearchCall, + schemas.ResponsesMessageTypeWebFetchCall, + schemas.ResponsesMessageTypeToolSearchCall, + schemas.ResponsesMessageTypeToolSearchOutput, + schemas.ResponsesMessageTypeCodeInterpreterCall, + schemas.ResponsesMessageTypeLocalShellCall, + schemas.ResponsesMessageTypeLocalShellCallOutput, + schemas.ResponsesMessageTypeMCPCall, + schemas.ResponsesMessageTypeCustomToolCall, + schemas.ResponsesMessageTypeCustomToolCallOutput, + schemas.ResponsesMessageTypeImageGenerationCall, + schemas.ResponsesMessageTypeMCPListTools, + schemas.ResponsesMessageTypeMCPApprovalRequest, + schemas.ResponsesMessageTypeMCPApprovalResponses, + schemas.ResponsesMessageTypeAdditionalTools, + schemas.ResponsesMessageTypeAdvisorCall: + return false + default: + return false + } + } + return true +} + +func convertedTargetPreservesToolHistory( + providerKind channel.ProviderKind, + model string, + request *schemas.BifrostResponsesRequest, +) bool { + if targetUsesChatFallback(providerKind, model) { + return chatFallbackPreservesToolHistory(request) + } + if providerKind == channel.ProviderAnthropic || + providerKind == channel.ProviderGoogleVertex && schemas.IsAnthropicModelFamily(nil, model) { + return responsesHistorySupportedBy(request, channel.ProviderAnthropic) + } + if providerKind == channel.ProviderAWSBedrock { + return responsesHistorySupportedBy(request, channel.ProviderAWSBedrock) + } + return true +} + +func responsesHistorySupportedBy( + request *schemas.BifrostResponsesRequest, + providerKind channel.ProviderKind, +) bool { + if request == nil { + return true + } + for _, message := range request.Input { + if message.Type == nil { + continue + } + switch *message.Type { + case schemas.ResponsesMessageTypeMessage, + schemas.ResponsesMessageTypeReasoning, + schemas.ResponsesMessageTypeFunctionCall, + schemas.ResponsesMessageTypeFunctionCallOutput, + schemas.ResponsesMessageTypeItemReference, + schemas.ResponsesMessageTypeRefusal: + continue + } + supported := false + switch providerKind { + case channel.ProviderAnthropic: + switch *message.Type { + case schemas.ResponsesMessageTypeComputerCall, + schemas.ResponsesMessageTypeComputerCallOutput, + schemas.ResponsesMessageTypeWebSearchCall, + schemas.ResponsesMessageTypeWebFetchCall, + schemas.ResponsesMessageTypeToolSearchCall, + schemas.ResponsesMessageTypeCodeInterpreterCall, + schemas.ResponsesMessageTypeMCPCall, + schemas.ResponsesMessageTypeMCPApprovalRequest, + schemas.ResponsesMessageTypeAdvisorCall: + supported = true + } + case channel.ProviderAWSBedrock: + supported = *message.Type == schemas.ResponsesMessageTypeWebSearchCall || + *message.Type == schemas.ResponsesMessageTypeCodeInterpreterCall + } + if !supported { + return false + } + } + return true +} diff --git a/internal/execution/bifrost/tool_compatibility_runtime_test.go b/internal/execution/bifrost/tool_compatibility_runtime_test.go new file mode 100644 index 000000000..c281598aa --- /dev/null +++ b/internal/execution/bifrost/tool_compatibility_runtime_test.go @@ -0,0 +1,197 @@ +package bifrost + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/tidwall/gjson" + + "gpt-load/internal/channel" + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestCompatibleBestEffortToolConversion(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + client protocol.Protocol + body string + wantTools int64 + }{ + {"Codex mixed tools", protocol.OpenAIResponses, `{"model":"client-model","input":"hello","store":false,"tool_choice":"auto","tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}},{"type":"namespace","name":"helpers","tools":[{"type":"function","name":"search","parameters":{"type":"object"}}]},{"type":"web_search"},{"type":"custom","name":"apply_patch","format":{"type":"text"}}]}`, 1}, + {"Responses selected search", protocol.OpenAIResponses, `{"model":"client-model","input":"hello","store":false,"tools":[{"type":"web_search"}],"tool_choice":{"type":"function","name":"web_search"}}`, 0}, + {"Claude selected search", protocol.Anthropic, `{"model":"client-model","max_tokens":64,"messages":[{"role":"user","content":"hello"}],"tools":[{"type":"web_search_20250305","name":"web_search"}],"tool_choice":{"type":"tool","name":"web_search"}}`, 0}, + } { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", test.name, stream), func(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + return + } + if r.URL.Path != "/chat/completions" || gjson.GetBytes(body, "tools.#").Int() != test.wantTools { + t.Errorf("unexpected SDK fallback wire: path=%s body=%s", r.URL.Path, body) + } + if test.wantTools == 1 { + if gjson.GetBytes(body, "tools.0.function.name").String() != "lookup" || gjson.GetBytes(body, "tool_choice").String() != "auto" { + t.Errorf("function or mode lost: %s", body) + } + } else if gjson.GetBytes(body, "tool_choice").Exists() { + t.Errorf("SDK should omit unsupported choice: %s", body) + } + if stream { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, openAIChatFinalStream) + } else { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, openAIChatFinalResponse) + } + })) + defer server.Close() + runtime := newRuntimeForTest(t, testRuntimeOptions{allowPrivateNetwork: true}) + runtime.baseURLs[channel.OpenAICompatible] = server.URL + op, path := execution.OperationResponsesCreate, "/v1/responses" + if test.client == protocol.Anthropic { + op, path = execution.OperationChatCompletion, "/v1/messages" + } + spec := convertedSpec(channel.OpenAICompatible, test.client, op, path, []byte(test.body)) + var failure *execution.ErrorEvidence + if stream { + failure = runtime.ExecuteStream(t.Context(), spec, func(execution.StreamEvent) error { return nil }).Error + } else { + failure = runtime.Execute(t.Context(), spec).Error + } + if failure != nil || calls.Load() != 1 { + t.Fatalf("best effort request blocked: calls=%d error=%+v", calls.Load(), failure) + } + }) + } + } +} + +func TestOpenAICompatibleAllowedToolsTwoTurnFunctionFlow(t *testing.T) { + t.Parallel() + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) { + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + call := calls.Add(1) + body, _ := io.ReadAll(request.Body) + if request.URL.Path != "/tenant/v1/chat/completions" || + gjson.GetBytes(body, "tools.#").Int() != 1 || + gjson.GetBytes(body, "tools.0.function.name").String() != "lookup" || + gjson.GetBytes(body, "tool_choice").String() != "required" || + gjson.GetBytes(body, "parallel_tool_calls").Bool() { + t.Errorf("converted request lost tool constraints: %s", body) + } + if call == 2 { + assertChatFunctionHistory(t, body) + } + if stream { + writer.Header().Set("Content-Type", "text/event-stream") + if call == 1 { + _, _ = io.WriteString(writer, openAIChatToolStream) + } else { + _, _ = io.WriteString(writer, openAIChatFinalStream) + } + return + } + writer.Header().Set("Content-Type", "application/json") + if call == 1 { + _, _ = io.WriteString(writer, openAIChatToolResponse) + } else { + _, _ = io.WriteString(writer, openAIChatFinalResponse) + } + })) + defer server.Close() + + runtime := newProtocolTestRuntime(t, testRuntimeOptions{allowPrivateNetwork: true}) + target, _ := json.Marshal(map[string]string{"base_url": server.URL + "/tenant/v1"}) + first := executeConvertedToolTurn(t, runtime, target, stream, + `[{"role":"user","content":"start"}]`) + if !strings.Contains(first, "call_1") || !strings.Contains(first, "lookup") || !strings.Contains(first, `\"query\"`) { + t.Fatalf("tool call was not returned as Responses output: %s", first) + } + + second := executeConvertedToolTurn(t, runtime, target, stream, + `[{"role":"user","content":"start"},{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"query\":\"x\"}"},{"type":"function_call_output","call_id":"call_1","output":""},{"role":"user","content":"continue"}]`) + if !strings.Contains(second, "done") { + t.Fatalf("final response was not returned: %s", second) + } + if calls.Load() != 2 { + t.Fatalf("upstream calls = %d, want 2", calls.Load()) + } + }) + } +} + +func executeConvertedToolTurn( + t *testing.T, + runtime *testRuntime, + target json.RawMessage, + stream bool, + input string, +) string { + t.Helper() + spec := openAIResponsesSpec(execution.OperationResponsesCreate, http.MethodPost, "/v1/responses") + spec.ChannelID = string(channel.OpenAICompatible) + spec.TargetConfig = target + spec.ClientModel = "client-model" + spec.UpstreamModel = "upstream-model" + spec.Body = []byte(fmt.Sprintf(`{"model":"client-model","stream":%t,"input":%s,"store":false,"parallel_tool_calls":false,"tools":%s,"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`, stream, input, convertedAllowedTools)) + spec = freezeTestAttempt(spec) + if !stream { + result := runtime.Execute(context.Background(), spec) + if err := result.Validate(); err != nil || result.Error != nil { + t.Fatalf("execute converted tool turn: result=%+v err=%v body=%s", result, err, result.Body) + } + return string(result.Body) + } + var data bytes.Buffer + result := runtime.ExecuteStream(context.Background(), spec, func(event execution.StreamEvent) error { + if event.Kind == execution.StreamEventData { + data.Write(event.Data) + } + return nil + }) + if err := result.Validate(); err != nil || result.Error != nil { + t.Fatalf("stream converted tool turn: result=%+v err=%v data=%s", result, err, data.String()) + } + return data.String() +} + +func assertChatFunctionHistory(t *testing.T, body []byte) { + t.Helper() + assistant := gjson.GetBytes(body, `messages.#(role=="assistant")`) + tool := gjson.GetBytes(body, `messages.#(role=="tool")`) + if assistant.Get("tool_calls.0.id").String() != "call_1" || + assistant.Get("tool_calls.0.function.name").String() != "lookup" || + assistant.Get("tool_calls.0.function.arguments").String() != `{"query":"x"}` || + tool.Get("tool_call_id").String() != "call_1" || tool.Get("content").String() != "" { + t.Errorf("function call history was not preserved: %s", body) + } +} + +const openAIChatToolResponse = `{"id":"chat_tool_1","object":"chat.completion","created":1,"model":"served","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"lookup","arguments":"{\"query\":\"x\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}` + +const openAIChatFinalResponse = `{"id":"chat_final_1","object":"chat.completion","created":2,"model":"served","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":8,"completion_tokens":1,"total_tokens":9}}` + +const openAIChatToolStream = "data: {\"id\":\"chat_tool_1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"served\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"query\\\":\\\"x\\\"}\"}}]},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"chat_tool_1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"served\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n" + + "data: [DONE]\n\n" + +const openAIChatFinalStream = "data: {\"id\":\"chat_final_1\",\"object\":\"chat.completion.chunk\",\"created\":2,\"model\":\"served\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":null}]}\n\n" + + "data: {\"id\":\"chat_final_1\",\"object\":\"chat.completion.chunk\",\"created\":2,\"model\":\"served\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":8,\"completion_tokens\":1,\"total_tokens\":9}}\n\n" + + "data: [DONE]\n\n" diff --git a/internal/execution/bifrost/tool_compatibility_test.go b/internal/execution/bifrost/tool_compatibility_test.go new file mode 100644 index 000000000..c4e91ac19 --- /dev/null +++ b/internal/execution/bifrost/tool_compatibility_test.go @@ -0,0 +1,648 @@ +package bifrost + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/maximhq/bifrost/core/providers/anthropic" + "github.com/maximhq/bifrost/core/providers/bedrock" + "github.com/maximhq/bifrost/core/providers/gemini" + "github.com/maximhq/bifrost/core/providers/openai" + "github.com/maximhq/bifrost/core/schemas" + "github.com/tidwall/gjson" + + "gpt-load/internal/channel" + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +const convertedAllowedTools = `[{"type":"function","name":"lookup","description":"lookup description","parameters":{"type":"object","properties":{"query":{"type":"string"}},"required":["query"],"additionalProperties":false},"strict":true},{"type":"function","name":"summarize","parameters":{"type":"object"},"strict":false}]` + +func TestConvertedResponsesAllowedToolsPreserveTargetConstraint(t *testing.T) { + t.Parallel() + for _, mode := range []string{"auto", "required"} { + for _, target := range []struct { + name string + providerKind channel.ProviderKind + provider schemas.ModelProvider + model string + wantTools int + }{ + {"Chat fallback", channel.ProviderOpenAICompatible, schemas.OpenAI, "compatible-model", 1}, + {"OpenAI Responses", channel.ProviderOpenAI, schemas.OpenAI, "gpt-5.2", 2}, + {"Anthropic", channel.ProviderAnthropic, schemas.Anthropic, "claude-sonnet-4-6", 1}, + {"Gemini", channel.ProviderGemini, schemas.Gemini, "gemini-2.5-pro", 2}, + {"Bedrock", channel.ProviderAWSBedrock, schemas.Bedrock, "anthropic.claude-sonnet-4-6", 1}, + } { + t.Run(target.name+"/"+mode, func(t *testing.T) { + spec, request := convertedResponsesAllowedToolsRequest(t, target.provider, target.model, mode, + `[{"role":"user","content":"hello"}]`) + prepared, failure := finishConvertedPreparation(spec, target.providerKind, preparedAttempt{responsesRequest: request}) + if failure != nil { + t.Fatalf("valid function allowlist rejected: %+v", failure.Error) + } + wire := convertedToolTargetWire(t, target.providerKind, target.model, prepared.responsesRequest) + if got := int(gjson.GetBytes(wire, convertedToolArrayPath(target.providerKind)+".#").Int()); got != target.wantTools { + t.Fatalf("target tools = %d, want %d: %s", got, target.wantTools, wire) + } + assertAllowedToolTargetMode(t, wire, target.providerKind, mode) + if target.wantTools == 1 && !targetHasOnlyLookupTool(target.providerKind, wire) { + t.Fatalf("target allowlist was widened: %s", wire) + } + if target.providerKind == channel.ProviderOpenAICompatible && + (gjson.GetBytes(wire, "tools.0.function.description").String() != "lookup description" || + gjson.GetBytes(wire, "tools.0.function.parameters.properties.query.type").String() != "string" || + !gjson.GetBytes(wire, "tools.0.function.strict").Bool() || + !gjson.GetBytes(wire, "parallel_tool_calls").Exists() || gjson.GetBytes(wire, "parallel_tool_calls").Bool()) { + t.Fatalf("Chat function definition or parallel mode changed: %s", wire) + } + }) + } + } +} + +func TestConvertedOpenAIChatAllowedToolsPreserveTargetConstraint(t *testing.T) { + t.Parallel() + for _, mode := range []string{"auto", "required"} { + for _, target := range []struct { + name string + providerKind channel.ProviderKind + provider schemas.ModelProvider + model string + }{ + {"Anthropic", channel.ProviderAnthropic, schemas.Anthropic, "claude-sonnet-4-6"}, + {"Gemini", channel.ProviderGemini, schemas.Gemini, "gemini-2.5-pro"}, + {"Bedrock", channel.ProviderAWSBedrock, schemas.Bedrock, "anthropic.claude-sonnet-4-6"}, + } { + t.Run(target.name+"/"+mode, func(t *testing.T) { + request := convertedChatAllowedToolsRequest(t, target.provider, target.model, mode) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAICompletions, + RouteMode: execution.RouteConverted, + Operation: execution.OperationChatCompletion, + UpstreamModel: target.model, + } + prepared, failure := finishConvertedPreparation(spec, target.providerKind, preparedAttempt{request: request}) + if failure != nil { + t.Fatalf("valid Chat function allowlist rejected: %+v", failure.Error) + } + wire := convertedChatToolTargetWire(t, target.providerKind, prepared.request) + if got := int(gjson.GetBytes(wire, convertedToolArrayPath(target.providerKind)+".#").Int()); got != 1 { + t.Fatalf("target tools = %d, want 1: %s", got, wire) + } + assertAllowedToolTargetMode(t, wire, target.providerKind, mode) + if !targetHasOnlyLookupTool(target.providerKind, wire) { + t.Fatalf("target allowlist was widened: %s", wire) + } + }) + } + } +} + +func TestConvertedAllowedToolsRejectInvalidConstraints(t *testing.T) { + t.Parallel() + for _, target := range []channel.ProviderKind{ + channel.ProviderOpenAICompatible, + channel.ProviderAnthropic, + channel.ProviderAWSBedrock, + } { + for _, choice := range []string{ + `{"type":"allowed_tools","mode":"auto","tools":[]}`, + `{"type":"allowed_tools","mode":"required"}`, + `{"type":"allowed_tools","mode":"none","tools":[{"type":"function","name":"lookup"}]}`, + `{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"missing"}]}`, + `{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"lookup"}]}`, + `{"type":"allowed_tools","mode":"required","tools":[{"type":"mcp","server_label":"docs"}]}`, + } { + t.Run(fmt.Sprintf("%s/%s", target, choice), func(t *testing.T) { + provider, model := convertedToolProvider(target) + body := []byte(fmt.Sprintf(`{"model":"client-model","input":"hello","store":false,"tools":%s,"tool_choice":%s}`, convertedAllowedTools, choice)) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAIResponses, + RouteMode: execution.RouteConverted, + Operation: execution.OperationResponsesCreate, + ClientModel: "client-model", + UpstreamModel: model, + Body: body, + } + request, err := buildConvertedResponsesRequest(spec, provider) + if err != nil { + t.Fatal(err) + } + _, failure := finishConvertedPreparation(spec, target, preparedAttempt{responsesRequest: request}) + if failure == nil || failure.DispatchState != execution.DispatchNotSent || failure.Error == nil || + failure.Error.Kind != execution.ErrorKindConversionUnsupported || failure.Error.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("invalid allowlist was not rejected before dispatch: %+v", failure) + } + }) + } + } +} + +func TestConvertedOpenAIChatAllowedToolsRejectInvalidConstraints(t *testing.T) { + t.Parallel() + for _, choice := range []string{ + `{"type":"allowed_tools"}`, + `{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[]}}`, + `{"type":"allowed_tools","allowed_tools":{"mode":"none","tools":[{"type":"function","function":{"name":"lookup"}}]}}`, + `{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[{"type":"function","function":{"name":"missing"}}]}}`, + } { + t.Run(choice, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"client-model","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup"}}],"tool_choice":%s}`, choice)) + var wire openai.OpenAIChatRequest + if err := json.Unmarshal(body, &wire); err != nil { + t.Fatal(err) + } + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + defer ctx.Cancel() + request := wire.ToBifrostChatRequest(ctx) + request.Provider, request.Model = schemas.Anthropic, "claude-sonnet-4-6" + spec := execution.AttemptSpec{ClientProtocol: protocol.OpenAICompletions, RouteMode: execution.RouteConverted, Operation: execution.OperationChatCompletion, UpstreamModel: request.Model} + _, failure := finishConvertedPreparation(spec, channel.ProviderAnthropic, preparedAttempt{request: request}) + if failure == nil || failure.DispatchState != execution.DispatchNotSent || failure.Error == nil || + failure.Error.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("invalid Chat allowlist was not rejected: %+v", failure) + } + }) + } +} + +func TestChatFallbackRejectsOnlyCallableUnsupportedTools(t *testing.T) { + t.Parallel() + const tools = `[{"type":"function","name":"lookup","parameters":{"type":"object"}},{"type":"web_search_preview"}]` + for _, test := range []struct { + name string + choice string + wantError bool + wantMode string + }{ + {"disabled", `"none"`, false, "none"}, + {"selected function", `{"type":"function","name":"lookup"}`, false, "function"}, + {"automatic", `"auto"`, true, ""}, + {"required from all tools", `"required"`, true, ""}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"client-model","input":"hello","store":false,"tools":%s,"tool_choice":%s}`, tools, test.choice)) + spec := execution.AttemptSpec{ClientProtocol: protocol.OpenAIResponses, RouteMode: execution.RouteConverted, Operation: execution.OperationResponsesCreate, ClientModel: "client-model", UpstreamModel: "compatible-model", Body: body} + request, err := buildConvertedResponsesRequest(spec, schemas.OpenAI) + if err != nil { + t.Fatal(err) + } + prepared, failure := finishConvertedPreparation(spec, channel.ProviderGroq, preparedAttempt{responsesRequest: request}) + if test.wantError { + if failure == nil || failure.DispatchState != execution.DispatchNotSent { + t.Fatalf("callable unsupported tool was not rejected: %+v", failure) + } + return + } + if failure != nil { + t.Fatalf("inactive tool caused rejection: %+v", failure.Error) + } + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + defer ctx.Cancel() + wire, err := json.Marshal(openai.ToOpenAIChatRequest(ctx, prepared.responsesRequest.ToChatRequest())) + if err != nil { + t.Fatal(err) + } + if gjson.GetBytes(wire, "tools.#").Int() != 1 || gjson.GetBytes(wire, "tools.0.function.name").String() != "lookup" { + t.Fatalf("convertible function was not preserved: %s", wire) + } + if test.wantMode == "function" { + if gjson.GetBytes(wire, "tool_choice.type").String() != "function" || gjson.GetBytes(wire, "tool_choice.function.name").String() != "lookup" { + t.Fatalf("selected function changed: %s", wire) + } + } else if gjson.GetBytes(wire, "tool_choice").String() != test.wantMode { + t.Fatalf("tool mode changed: %s", wire) + } + }) + } +} + +func TestConvertedToolModesUseTargetEquivalentValues(t *testing.T) { + t.Parallel() + t.Run("Anthropic any becomes Chat required", func(t *testing.T) { + mode := "any" + request := &schemas.BifrostResponsesRequest{Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{{ + Type: schemas.ResponsesToolTypeFunction, + Name: schemas.Ptr("lookup"), + ResponsesToolFunction: &schemas.ResponsesToolFunction{}, + }}, + ToolChoice: &schemas.ResponsesToolChoice{ResponsesToolChoiceStr: &mode}, + }} + spec := execution.AttemptSpec{ClientProtocol: protocol.Anthropic, RouteMode: execution.RouteConverted, Operation: execution.OperationChatCompletion} + prepared, failure := finishConvertedPreparation(spec, channel.ProviderOpenAICompatible, preparedAttempt{responsesRequest: request}) + if failure != nil { + t.Fatalf("required tool mode rejected: %+v", failure.Error) + } + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + defer ctx.Cancel() + wire, err := json.Marshal(openai.ToOpenAIChatRequest(ctx, prepared.responsesRequest.ToChatRequest())) + if err != nil { + t.Fatal(err) + } + if got := gjson.GetBytes(wire, "tool_choice").String(); got != "required" { + t.Fatalf("Chat tool_choice = %q, want required: %s", got, wire) + } + }) + + for _, test := range []struct { + geminiMode string + wantType string + wantMode string + }{ + {"AUTO", "allowed_tools", "auto"}, + {"ANY", "function", ""}, + } { + t.Run("Gemini "+test.geminiMode+" allowlist becomes valid OpenAI Responses choice", func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup","parameters":{"type":"object"}},{"name":"summarize","parameters":{"type":"object"}}]}],"toolConfig":{"functionCallingConfig":{"mode":%q,"allowedFunctionNames":["lookup"]}}}`, test.geminiMode)) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.Gemini, + RouteMode: execution.RouteConverted, + Operation: execution.OperationChatCompletion, + ClientModel: "client-model", + UpstreamModel: "gpt-5.2", + Body: body, + } + request, err := buildConvertedResponsesRequest(spec, schemas.OpenAI) + if err != nil { + t.Fatal(err) + } + prepared, failure := finishConvertedPreparation(spec, channel.ProviderOpenAI, preparedAttempt{responsesRequest: request}) + if failure != nil { + t.Fatalf("valid Gemini allowlist rejected: %+v", failure.Error) + } + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + defer ctx.Cancel() + wire, err := json.Marshal(openai.ToOpenAIResponsesRequest(ctx, prepared.responsesRequest)) + if err != nil { + t.Fatal(err) + } + choice := gjson.GetBytes(wire, "tool_choice") + if choice.Get("type").String() != test.wantType || + (test.wantMode != "" && choice.Get("mode").String() != test.wantMode) || + (test.wantType == "function" && choice.Get("name").String() != "lookup") || + gjson.GetBytes(wire, "tools.#").Int() != 2 { + t.Fatalf("OpenAI Responses allowlist is invalid: %s", wire) + } + }) + } +} + +func TestConvertedAllowedToolsKeepsDeepSeekThinkingProtection(t *testing.T) { + t.Parallel() + for _, test := range []struct { + mode string + wantError bool + }{ + {"auto", false}, + {"required", true}, + } { + t.Run(test.mode, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"client-model","input":"hello","store":false,"reasoning":{"effort":"high"},"tools":%s,"tool_choice":{"type":"allowed_tools","mode":%q,"tools":[{"type":"function","name":"lookup"}]}}`, convertedAllowedTools, test.mode)) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAIResponses, + RouteMode: execution.RouteConverted, + Operation: execution.OperationResponsesCreate, + ClientModel: "client-model", + UpstreamModel: "deepseek-chat", + Body: body, + } + request, err := buildConvertedResponsesRequest(spec, schemas.DeepSeek) + if err != nil { + t.Fatal(err) + } + prepared, failure := finishConvertedPreparation(spec, channel.ProviderDeepSeek, preparedAttempt{responsesRequest: request}) + if test.wantError { + if failure == nil || failure.DispatchState != execution.DispatchNotSent || failure.Error == nil || + failure.Error.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("explicit thinking conflict was not rejected: %+v", failure) + } + return + } + if failure != nil { + t.Fatalf("automatic tools changed thinking compatibility: %+v", failure.Error) + } + if prepared.responsesRequest.Params.Reasoning == nil || prepared.responsesRequest.Params.Reasoning.Effort == nil || + *prepared.responsesRequest.Params.Reasoning.Effort != "high" || + prepared.responsesRequest.Params.ToolChoice == nil || prepared.responsesRequest.Params.ToolChoice.ResponsesToolChoiceStr == nil || + *prepared.responsesRequest.Params.ToolChoice.ResponsesToolChoiceStr != "auto" { + t.Fatalf("thinking or automatic tool mode changed: %#v", prepared.responsesRequest.Params) + } + }) + } +} + +func TestConvertedAllowedToolsKeepsParallelToolCallTriState(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + field string + want *bool + }{ + {"unset", "", nil}, + {"disabled", `,"parallel_tool_calls":false`, schemas.Ptr(false)}, + {"enabled", `,"parallel_tool_calls":true`, schemas.Ptr(true)}, + } { + t.Run(test.name, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"client-model","input":"hello","store":false%s,"tools":%s,"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`, test.field, convertedAllowedTools)) + spec := execution.AttemptSpec{ClientProtocol: protocol.OpenAIResponses, RouteMode: execution.RouteConverted, Operation: execution.OperationResponsesCreate, ClientModel: "client-model", UpstreamModel: "claude-sonnet-4-6", Body: body} + request, err := buildConvertedResponsesRequest(spec, schemas.Anthropic) + if err != nil { + t.Fatal(err) + } + prepared, failure := finishConvertedPreparation(spec, channel.ProviderAnthropic, preparedAttempt{responsesRequest: request}) + if failure != nil { + t.Fatalf("valid allowlist rejected: %+v", failure.Error) + } + got := prepared.responsesRequest.Params.ParallelToolCalls + if (got == nil) != (test.want == nil) || got != nil && *got != *test.want { + t.Fatalf("parallel_tool_calls = %v, want %v", got, test.want) + } + }) + } +} + +func TestConvertedToolCompatibilityPreservesHistoryAndIsolation(t *testing.T) { + t.Parallel() + t.Run("legal empty function result", func(t *testing.T) { + input := `[{"role":"user","content":"start"},{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"query\":\"x\"}"},{"type":"function_call_output","call_id":"call_1","output":""},{"role":"user","content":"continue"}]` + spec, request := convertedResponsesAllowedToolsRequest(t, schemas.OpenAI, "compatible-model", "required", input) + prepared, failure := finishConvertedPreparation(spec, channel.ProviderOpenAICompatible, preparedAttempt{responsesRequest: request}) + if failure != nil { + t.Fatalf("legal function history rejected: %+v", failure.Error) + } + chat := prepared.responsesRequest.ToChatRequest() + if len(chat.Input) != 4 || chat.Input[1].ChatAssistantMessage == nil || len(chat.Input[1].ToolCalls) != 1 || + chat.Input[2].ChatToolMessage == nil || chat.Input[2].Content == nil || chat.Input[2].Content.ContentStr == nil || + *chat.Input[2].Content.ContentStr != "" || *chat.Input[2].ChatToolMessage.ToolCallID != "call_1" { + t.Fatalf("function history was not preserved: %#v", chat.Input) + } + }) + + t.Run("unsupported custom history", func(t *testing.T) { + input := `[{"role":"user","content":"start"},{"type":"custom_tool_call","call_id":"call_1","name":"custom","input":"synthetic"},{"role":"user","content":"continue"}]` + spec, request := convertedResponsesAllowedToolsRequest(t, schemas.OpenAI, "compatible-model", "required", input) + _, failure := finishConvertedPreparation(spec, channel.ProviderOpenAICompatible, preparedAttempt{responsesRequest: request}) + if failure == nil || failure.DispatchState != execution.DispatchNotSent || failure.Error == nil || + failure.Error.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("unsupported tool history was not rejected: %+v", failure) + } + }) + + for _, target := range []struct { + name string + providerKind channel.ProviderKind + provider schemas.ModelProvider + model string + }{ + {"Anthropic", channel.ProviderAnthropic, schemas.Anthropic, "claude-sonnet-4-6"}, + {"Bedrock", channel.ProviderAWSBedrock, schemas.Bedrock, "anthropic.claude-sonnet-4-6"}, + } { + t.Run(target.name+" rejects downgraded custom history", func(t *testing.T) { + input := `[{"role":"user","content":"start"},{"type":"custom_tool_call","call_id":"call_1","name":"custom","input":"synthetic"},{"role":"user","content":"continue"}]` + spec, request := convertedResponsesAllowedToolsRequest(t, target.provider, target.model, "required", input) + _, failure := finishConvertedPreparation(spec, target.providerKind, preparedAttempt{responsesRequest: request}) + if failure == nil || failure.DispatchState != execution.DispatchNotSent || failure.Error == nil || + failure.Error.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("downgraded custom history was not rejected: %+v", failure) + } + }) + } + + for _, target := range []struct { + name string + providerKind channel.ProviderKind + provider schemas.ModelProvider + model string + }{ + {"Chat fallback", channel.ProviderOpenAICompatible, schemas.OpenAI, "compatible-model"}, + {"Anthropic", channel.ProviderAnthropic, schemas.Anthropic, "claude-sonnet-4-6"}, + {"Bedrock", channel.ProviderAWSBedrock, schemas.Bedrock, "anthropic.claude-sonnet-4-6"}, + } { + t.Run(target.name+" rejects compaction history", func(t *testing.T) { + input := `[{"role":"user","content":"start"},{"type":"compaction","encrypted_content":"opaque-context"},{"role":"user","content":"continue"}]` + spec, request := convertedResponsesAllowedToolsRequest(t, target.provider, target.model, "required", input) + _, failure := finishConvertedPreparation(spec, target.providerKind, preparedAttempt{responsesRequest: request}) + if failure == nil || failure.DispatchState != execution.DispatchNotSent || failure.Error == nil || + failure.Error.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("compaction history was not rejected: %+v", failure) + } + }) + } + + for _, choice := range []string{`"none"`, `{"type":"function","name":"lookup"}`} { + t.Run("newly allowed branch rejects custom history "+choice, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"model":"client-model","input":[{"role":"user","content":"start"},{"type":"custom_tool_call","call_id":"call_1","name":"custom","input":"synthetic"}],"store":false,"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}},{"type":"custom","name":"custom","format":{"type":"text"}}],"tool_choice":%s}`, choice)) + spec := execution.AttemptSpec{ClientProtocol: protocol.OpenAIResponses, RouteMode: execution.RouteConverted, Operation: execution.OperationResponsesCreate, ClientModel: "client-model", UpstreamModel: "compatible-model", Body: body} + request, err := buildConvertedResponsesRequest(spec, schemas.OpenAI) + if err != nil { + t.Fatal(err) + } + _, failure := finishConvertedPreparation(spec, channel.ProviderOpenAICompatible, preparedAttempt{responsesRequest: request}) + if failure == nil || failure.DispatchState != execution.DispatchNotSent || failure.Error == nil || + failure.Error.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("unsupported custom history was not rejected: %+v", failure) + } + }) + } + + t.Run("attempt-local request copy", func(t *testing.T) { + spec, request := convertedResponsesAllowedToolsRequest(t, schemas.Anthropic, "claude-sonnet-4-6", "required", + `[{"role":"user","content":"hello"}]`) + originalChoice, err := json.Marshal(request.Params.ToolChoice) + if err != nil { + t.Fatal(err) + } + prepared, failure := finishConvertedPreparation(spec, channel.ProviderAnthropic, preparedAttempt{responsesRequest: request}) + if failure != nil { + t.Fatalf("valid allowlist rejected: %+v", failure.Error) + } + if prepared.responsesRequest == request || prepared.responsesRequest.Params == request.Params || + len(request.Params.Tools) != 2 || len(prepared.responsesRequest.Params.Tools) != 1 { + t.Fatalf("tool adaptation was not isolated: original=%p/%p prepared=%p/%p", request, request.Params, prepared.responsesRequest, prepared.responsesRequest.Params) + } + currentChoice, err := json.Marshal(request.Params.ToolChoice) + if err != nil { + t.Fatal(err) + } + if string(currentChoice) != string(originalChoice) { + t.Fatalf("original choice mutated: got %s want %s", currentChoice, originalChoice) + } + }) +} + +func convertedResponsesAllowedToolsRequest( + t *testing.T, + provider schemas.ModelProvider, + model string, + mode string, + input string, +) (execution.AttemptSpec, *schemas.BifrostResponsesRequest) { + t.Helper() + body := []byte(fmt.Sprintf(`{"model":"client-model","input":%s,"store":false,"parallel_tool_calls":false,"tools":%s,"tool_choice":{"type":"allowed_tools","mode":%q,"tools":[{"type":"function","name":"lookup"}]}}`, input, convertedAllowedTools, mode)) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAIResponses, + RouteMode: execution.RouteConverted, + Operation: execution.OperationResponsesCreate, + ClientModel: "client-model", + UpstreamModel: model, + Body: body, + } + request, err := buildConvertedResponsesRequest(spec, provider) + if err != nil { + t.Fatal(err) + } + request.Model = model + return spec, request +} + +func convertedChatAllowedToolsRequest(t *testing.T, provider schemas.ModelProvider, model string, mode string) *schemas.BifrostChatRequest { + t.Helper() + body := []byte(fmt.Sprintf(`{"model":"client-model","messages":[{"role":"user","content":"hello"}],"parallel_tool_calls":false,"tools":[{"type":"function","function":{"name":"lookup","description":"lookup description","parameters":{"type":"object"},"strict":true}},{"type":"function","function":{"name":"summarize","parameters":{"type":"object"},"strict":false}}],"tool_choice":{"type":"allowed_tools","allowed_tools":{"mode":%q,"tools":[{"type":"function","function":{"name":"lookup"}}]}}}`, mode)) + var wire openai.OpenAIChatRequest + if err := json.Unmarshal(body, &wire); err != nil { + t.Fatal(err) + } + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + defer ctx.Cancel() + request := wire.ToBifrostChatRequest(ctx) + request.Provider = provider + request.Model = model + return request +} + +func convertedToolProvider(kind channel.ProviderKind) (schemas.ModelProvider, string) { + switch kind { + case channel.ProviderAnthropic: + return schemas.Anthropic, "claude-sonnet-4-6" + case channel.ProviderGemini: + return schemas.Gemini, "gemini-2.5-pro" + case channel.ProviderAWSBedrock: + return schemas.Bedrock, "anthropic.claude-sonnet-4-6" + default: + return schemas.OpenAI, "compatible-model" + } +} + +func convertedToolTargetWire(t *testing.T, kind channel.ProviderKind, model string, request *schemas.BifrostResponsesRequest) []byte { + t.Helper() + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + defer ctx.Cancel() + request.Model = model + var value any + var err error + switch kind { + case channel.ProviderOpenAICompatible: + value = openai.ToOpenAIChatRequest(ctx, request.ToChatRequest()) + case channel.ProviderOpenAI: + value = openai.ToOpenAIResponsesRequest(ctx, request) + case channel.ProviderAnthropic: + value, err = anthropic.ToAnthropicResponsesRequest(ctx, request) + case channel.ProviderGemini: + value, err = gemini.ToGeminiResponsesRequest(ctx, request) + case channel.ProviderAWSBedrock: + value, err = bedrock.ToBedrockResponsesRequest(ctx, request) + default: + t.Fatalf("unsupported test provider %q", kind) + } + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return body +} + +func convertedChatToolTargetWire(t *testing.T, kind channel.ProviderKind, request *schemas.BifrostChatRequest) []byte { + t.Helper() + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + defer ctx.Cancel() + var value any + var err error + switch kind { + case channel.ProviderAnthropic: + value, err = anthropic.ToAnthropicChatRequest(ctx, request) + case channel.ProviderGemini: + value, err = gemini.ToGeminiChatCompletionRequest(ctx, request) + case channel.ProviderAWSBedrock: + value, err = bedrock.ToBedrockChatCompletionRequest(ctx, request) + default: + t.Fatalf("unsupported test provider %q", kind) + } + if err != nil { + t.Fatal(err) + } + body, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return body +} + +func convertedToolArrayPath(kind channel.ProviderKind) string { + if kind == channel.ProviderAWSBedrock { + return "toolConfig.tools" + } + if kind == channel.ProviderGemini { + return "tools.0.functionDeclarations" + } + return "tools" +} + +func targetHasOnlyLookupTool(kind channel.ProviderKind, wire []byte) bool { + path := convertedToolArrayPath(kind) + namePath := path + ".0.name" + if kind == channel.ProviderOpenAICompatible { + namePath = path + ".0.function.name" + } else if kind == channel.ProviderAWSBedrock { + namePath = path + ".0.toolSpec.name" + } + return gjson.GetBytes(wire, namePath).String() == "lookup" +} + +func assertAllowedToolTargetMode(t *testing.T, wire []byte, kind channel.ProviderKind, mode string) { + t.Helper() + switch kind { + case channel.ProviderOpenAICompatible: + if got := gjson.GetBytes(wire, "tool_choice").String(); got != mode { + t.Fatalf("Chat tool_choice = %q, want %q: %s", got, mode, wire) + } + case channel.ProviderAnthropic: + want := mode + if want == "required" { + want = "any" + } + if got := gjson.GetBytes(wire, "tool_choice.type").String(); got != want { + t.Fatalf("Anthropic tool_choice = %q, want %q: %s", got, want, wire) + } + case channel.ProviderGemini: + want := "AUTO" + if mode == "required" { + want = "ANY" + } + allowed := gjson.GetBytes(wire, "toolConfig.functionCallingConfig.allowedFunctionNames") + if got := gjson.GetBytes(wire, "toolConfig.functionCallingConfig.mode").String(); got != want || + (allowed.Exists() && gjson.GetBytes(wire, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String() != "lookup") { + t.Fatalf("Gemini allowlist or mode mismatch: %s", wire) + } + case channel.ProviderAWSBedrock: + path := "toolConfig.toolChoice.auto" + if mode == "required" { + path = "toolConfig.toolChoice.any" + } + if mode == "required" && !gjson.GetBytes(wire, path).Exists() { + t.Fatalf("Bedrock tool choice %q is missing: %s", mode, wire) + } + case channel.ProviderOpenAI: + if gjson.GetBytes(wire, "tool_choice.type").String() != "allowed_tools" || + gjson.GetBytes(wire, "tool_choice.mode").String() != mode || + gjson.GetBytes(wire, "tool_choice.tools.0.name").String() != "lookup" { + t.Fatalf("OpenAI Responses allowlist mismatch: %s", wire) + } + } +} diff --git a/internal/execution/cpa/adapter.go b/internal/execution/cpa/adapter.go index 5b44d7be2..96e27d7e7 100644 --- a/internal/execution/cpa/adapter.go +++ b/internal/execution/cpa/adapter.go @@ -112,9 +112,9 @@ func (a *Adapter) Execute(ctx context.Context, spec execution.AttemptSpec) (resu if err != nil { return unaryNotSent(execution.ErrorKindInvalidRequest, "unsupported subscription request", "", err) } - spec, instructionFailure := prepareConvertedInstructions(spec, provider.ProviderKind()) - if instructionFailure != nil { - return execution.AttemptResult{DispatchState: execution.DispatchNotSent, Error: instructionFailure} + spec, fidelityFailure := prepareConvertedFidelity(spec, provider.ProviderKind()) + if fidelityFailure != nil { + return execution.AttemptResult{DispatchState: execution.DispatchNotSent, Error: fidelityFailure} } proxySettings, err := proxySettingsForAttempt(spec.Proxy) if err != nil { @@ -285,9 +285,9 @@ func (a *Adapter) ExecuteStream( if countTokensOperation(spec.Operation) { return streamNotSent(execution.ErrorKindInvalidRequest, "count tokens does not support streaming", "") } - spec, instructionFailure := prepareConvertedInstructions(spec, provider.ProviderKind()) - if instructionFailure != nil { - return execution.StreamResult{DispatchState: execution.DispatchNotSent, Error: instructionFailure} + spec, fidelityFailure := prepareConvertedFidelity(spec, provider.ProviderKind()) + if fidelityFailure != nil { + return execution.StreamResult{DispatchState: execution.DispatchNotSent, Error: fidelityFailure} } proxySettings, err := proxySettingsForAttempt(spec.Proxy) if err != nil { diff --git a/internal/execution/cpa/conversion_fidelity.go b/internal/execution/cpa/conversion_fidelity.go index dc9010cc1..90be196cf 100644 --- a/internal/execution/cpa/conversion_fidelity.go +++ b/internal/execution/cpa/conversion_fidelity.go @@ -1,7 +1,10 @@ package cpa import ( + "bytes" + "encoding/json" "strconv" + "strings" "github.com/tidwall/gjson" "github.com/tidwall/sjson" @@ -12,12 +15,17 @@ import ( "gpt-load/internal/protocol" ) -func prepareConvertedInstructions(spec execution.AttemptSpec, providerKind channel.ProviderKind) (execution.AttemptSpec, *execution.ErrorEvidence) { +func prepareConvertedFidelity(spec execution.AttemptSpec, providerKind channel.ProviderKind) (execution.AttemptSpec, *execution.ErrorEvidence) { if spec.RouteMode != execution.RouteConverted || (spec.Operation != execution.OperationChatCompletion && spec.Operation != execution.OperationResponsesCreate && - spec.Operation != execution.OperationCountTokens) { + !countTokensOperation(spec.Operation)) { return spec, nil } + var toolFailure *execution.ErrorEvidence + spec, toolFailure = prepareConvertedToolConstraints(spec, providerKind) + if toolFailure != nil { + return spec, toolFailure + } if (providerKind == channel.ProviderCodex || providerKind == channel.ProviderGrok) && spec.ClientProtocol == protocol.Anthropic { // 两个渠道共用 Codex 转换器;先映射角色,避免 CPA 将 system 降为 user 提醒并移动位置。 for index, message := range gjson.GetBytes(spec.Body, "messages").Array() { @@ -33,7 +41,7 @@ func prepareConvertedInstructions(spec execution.AttemptSpec, providerKind chann } return spec, nil } - if spec.Operation == execution.OperationCountTokens || + if countTokensOperation(spec.Operation) || dialect.CountMidConversationSystemMessages(spec.ClientProtocol, spec.Body) == 0 { return spec, nil } @@ -48,3 +56,288 @@ func prepareConvertedInstructions(spec execution.AttemptSpec, providerKind chann return spec, notSentEvidence(execution.ErrorKindConversionUnsupported, "conversion cannot preserve mid-conversation system instructions", execution.ErrorCodeCriticalSemanticLoss) } + +func prepareConvertedToolConstraints( + spec execution.AttemptSpec, + providerKind channel.ProviderKind, +) (execution.AttemptSpec, *execution.ErrorEvidence) { + if spec.RouteMode != execution.RouteConverted || + (spec.Operation != execution.OperationChatCompletion && spec.Operation != execution.OperationResponsesCreate && + !countTokensOperation(spec.Operation)) { + return spec, nil + } + switch providerKind { + case channel.ProviderClaude, channel.ProviderAntigravity, channel.ProviderCodex, channel.ProviderGrok: + default: + return spec, nil + } + + var body []byte + var present, valid bool + switch spec.ClientProtocol { + case protocol.OpenAIResponses: + // Codex/Grok 原生接收 Responses;保留结构化白名单和完整工具定义,避免破坏缓存形态。 + if providerKind == channel.ProviderCodex || providerKind == channel.ProviderGrok { + return spec, nil + } + body, present, valid = prepareResponsesFunctionAllowlist(spec.Body) + case protocol.OpenAICompletions: + body, present, valid = prepareChatFunctionAllowlist(spec.Body) + case protocol.Gemini: + body, present, valid = prepareGeminiFunctionAllowlist(spec.Body, providerKind) + default: + return spec, nil + } + if !present { + return spec, nil + } + if !valid || spec.ClientProtocol == protocol.OpenAIResponses && !subscriptionResponsesToolHistorySupported(spec.Body, providerKind) { + return spec, notSentEvidence( + execution.ErrorKindConversionUnsupported, + "subscription conversion cannot preserve requested tools or tool choice", + execution.ErrorCodeCriticalSemanticLoss, + ) + } + if providerKind == channel.ProviderAntigravity && strings.Contains(spec.UpstreamModel, "claude") && + gjson.GetBytes(body, "tool_choice").String() == "required" { + return spec, notSentEvidence( + execution.ErrorKindConversionUnsupported, + "subscription conversion cannot preserve requested tools or tool choice", + execution.ErrorCodeCriticalSemanticLoss, + ) + } + spec.Body = body + return spec, nil +} + +func prepareResponsesFunctionAllowlist(body []byte) ([]byte, bool, bool) { + choice := gjson.GetBytes(body, "tool_choice") + if !choice.IsObject() || choice.Get("type").String() != "allowed_tools" { + return body, false, true + } + mode := choice.Get("mode").String() + allowed := choice.Get("tools") + if (mode != "auto" && mode != "required") || !allowed.IsArray() || len(allowed.Array()) == 0 { + return body, true, false + } + names, valid := responseAllowedFunctionNames(allowed) + if !valid { + return body, true, false + } + filtered, valid := filterRawFunctionTools(gjson.GetBytes(body, "tools"), names, "name") + if !valid { + return body, true, false + } + prepared, err := setRawJSONArray(body, "tools", filtered) + if err != nil { + return body, true, false + } + prepared, err = sjson.SetBytes(prepared, "tool_choice", mode) + return prepared, true, err == nil +} + +func prepareChatFunctionAllowlist(body []byte) ([]byte, bool, bool) { + choice := gjson.GetBytes(body, "tool_choice") + if !choice.IsObject() || choice.Get("type").String() != "allowed_tools" { + return body, false, true + } + allowed := choice.Get("allowed_tools") + mode := allowed.Get("mode").String() + tools := allowed.Get("tools") + if !allowed.IsObject() || (mode != "auto" && mode != "required") || !tools.IsArray() || len(tools.Array()) == 0 { + return body, true, false + } + names := make(map[string]struct{}, len(tools.Array())) + for _, tool := range tools.Array() { + name := tool.Get("function.name").String() + if !tool.IsObject() || tool.Get("type").String() != "function" || strings.TrimSpace(name) == "" { + return body, true, false + } + if _, duplicate := names[name]; duplicate { + return body, true, false + } + names[name] = struct{}{} + } + filtered, valid := filterRawFunctionTools(gjson.GetBytes(body, "tools"), names, "function.name") + if !valid { + return body, true, false + } + prepared, err := setRawJSONArray(body, "tools", filtered) + if err != nil { + return body, true, false + } + prepared, err = sjson.SetBytes(prepared, "tool_choice", mode) + return prepared, true, err == nil +} + +func prepareGeminiFunctionAllowlist(body []byte, providerKind channel.ProviderKind) ([]byte, bool, bool) { + config := gjson.GetBytes(body, "toolConfig.functionCallingConfig") + if providerKind == channel.ProviderClaude && gjson.GetBytes(body, "tool_config").Exists() { + config = gjson.GetBytes(body, "tool_config.function_calling_config") + } + allowed := config.Get("allowedFunctionNames") + if providerKind == channel.ProviderClaude && !allowed.Exists() { + allowed = config.Get("allowed_function_names") + } + if !allowed.Exists() || !allowed.IsArray() || len(allowed.Array()) == 0 { + return body, false, true + } + mode := config.Get("mode").String() + if mode != "AUTO" && mode != "ANY" { + return body, true, false + } + names := make(map[string]struct{}, len(allowed.Array())) + for _, value := range allowed.Array() { + name := value.String() + if value.Type != gjson.String || strings.TrimSpace(name) == "" { + return body, true, false + } + if _, duplicate := names[name]; duplicate { + return body, true, false + } + names[name] = struct{}{} + } + + tools := gjson.GetBytes(body, "tools") + if !tools.IsArray() { + return body, true, false + } + counts := make(map[string]int, len(names)) + filteredGroups := make([]json.RawMessage, 0, len(tools.Array())) + for _, group := range tools.Array() { + if !group.IsObject() { + return body, true, false + } + declarations := group.Get("functionDeclarations") + if !declarations.Exists() { + filteredGroups = append(filteredGroups, json.RawMessage(group.Raw)) + continue + } + if !declarations.IsArray() { + return body, true, false + } + selected := make([]json.RawMessage, 0, len(declarations.Array())) + for _, declaration := range declarations.Array() { + name := declaration.Get("name").String() + if _, keep := names[name]; keep { + counts[name]++ + selected = append(selected, json.RawMessage(declaration.Raw)) + } + } + if len(selected) == 0 { + updated, err := sjson.DeleteBytes([]byte(group.Raw), "functionDeclarations") + if err != nil { + return body, true, false + } + if objectHasFields(updated) { + filteredGroups = append(filteredGroups, json.RawMessage(updated)) + } + continue + } + updated, err := setRawJSONArray([]byte(group.Raw), "functionDeclarations", selected) + if err != nil { + return body, true, false + } + filteredGroups = append(filteredGroups, json.RawMessage(updated)) + } + for name := range names { + if counts[name] != 1 { + return body, true, false + } + } + prepared, err := setRawJSONArray(body, "tools", filteredGroups) + return prepared, true, err == nil +} + +func responseAllowedFunctionNames(allowed gjson.Result) (map[string]struct{}, bool) { + names := make(map[string]struct{}, len(allowed.Array())) + for _, tool := range allowed.Array() { + name := tool.Get("name").String() + if !tool.IsObject() || tool.Get("type").String() != "function" || strings.TrimSpace(name) == "" || tool.Get("server_label").Exists() { + return nil, false + } + if _, duplicate := names[name]; duplicate { + return nil, false + } + names[name] = struct{}{} + } + return names, true +} + +func filterRawFunctionTools(tools gjson.Result, names map[string]struct{}, namePath string) ([]json.RawMessage, bool) { + if !tools.IsArray() { + return nil, false + } + counts := make(map[string]int, len(names)) + filtered := make([]json.RawMessage, 0, len(names)) + for _, tool := range tools.Array() { + if !tool.IsObject() || tool.Get("type").String() != "function" { + continue + } + name := tool.Get(namePath).String() + if _, keep := names[name]; keep { + counts[name]++ + filtered = append(filtered, json.RawMessage(tool.Raw)) + } + } + for name := range names { + if counts[name] != 1 { + return nil, false + } + } + return filtered, true +} + +func setRawJSONArray(body []byte, path string, values []json.RawMessage) ([]byte, error) { + var array bytes.Buffer + array.WriteByte('[') + for index, value := range values { + if index > 0 { + array.WriteByte(',') + } + array.Write(value) + } + array.WriteByte(']') + return sjson.SetRawBytes(body, path, array.Bytes()) +} + +func objectHasFields(body []byte) bool { + hasFields := false + gjson.ParseBytes(body).ForEach(func(_, _ gjson.Result) bool { + hasFields = true + return false + }) + return hasFields +} + +func subscriptionResponsesToolHistorySupported(body []byte, providerKind channel.ProviderKind) bool { + input := gjson.GetBytes(body, "input") + if !input.IsArray() { + return true + } + for _, item := range input.Array() { + kind := item.Get("type").String() + switch kind { + case "", "message", "reasoning", "function_call", "function_call_output", "refusal": + continue + case "custom_tool_call", "custom_tool_call_output": + if providerKind == channel.ProviderClaude || providerKind == channel.ProviderAntigravity { + continue + } + return false + case "web_search_call": + if providerKind == channel.ProviderClaude { + continue + } + return false + case "file_search_call", "computer_call", "computer_call_output", "web_fetch_call", + "tool_search_call", "tool_search_output", "code_interpreter_call", "local_shell_call", "local_shell_call_output", + "mcp_call", "image_generation_call", "mcp_list_tools", + "mcp_approval_request", "mcp_approval_responses", "additional_tools", "advisor_call": + return false + default: + return false + } + } + return true +} diff --git a/internal/execution/cpa/tool_compatibility_test.go b/internal/execution/cpa/tool_compatibility_test.go new file mode 100644 index 000000000..d74c530d8 --- /dev/null +++ b/internal/execution/cpa/tool_compatibility_test.go @@ -0,0 +1,365 @@ +package cpa + +import ( + "bytes" + "fmt" + "net/http" + "testing" + + "github.com/tidwall/gjson" + + "gpt-load/internal/channel" + "gpt-load/internal/execution" + "gpt-load/internal/protocol" +) + +func TestSubscriptionConvertedFunctionAllowlistsPreserveConstraints(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + providerKind channel.ProviderKind + protocol protocol.Protocol + upstreamModel string + body string + toolsPath string + }{ + { + name: "Responses to Claude", providerKind: channel.ProviderClaude, protocol: protocol.OpenAIResponses, + body: `{"model":"client-model","input":"hello","store":false,"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}},{"type":"function","name":"summarize","parameters":{"type":"object"}}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`, + toolsPath: "tools", + }, + { + name: "Responses to Antigravity", providerKind: channel.ProviderAntigravity, protocol: protocol.OpenAIResponses, + upstreamModel: "gemini-2.5-pro", + body: `{"model":"client-model","input":"hello","store":false,"tools":[{"type":"function","name":"lookup","parameters":{"type":"object"}},{"type":"function","name":"summarize","parameters":{"type":"object"}}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`, + toolsPath: "tools", + }, + { + name: "Chat to Claude", providerKind: channel.ProviderClaude, protocol: protocol.OpenAICompletions, + body: `{"model":"client-model","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}},{"type":"function","function":{"name":"summarize","parameters":{"type":"object"}}}],"tool_choice":{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[{"type":"function","function":{"name":"lookup"}}]}}}`, + toolsPath: "tools", + }, + { + name: "Chat to Antigravity", providerKind: channel.ProviderAntigravity, protocol: protocol.OpenAICompletions, + upstreamModel: "gemini-2.5-pro", + body: `{"model":"client-model","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}},{"type":"function","function":{"name":"summarize","parameters":{"type":"object"}}}],"tool_choice":{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[{"type":"function","function":{"name":"lookup"}}]}}}`, + toolsPath: "tools", + }, + { + name: "Chat to Codex", providerKind: channel.ProviderCodex, protocol: protocol.OpenAICompletions, + body: `{"model":"client-model","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}},{"type":"function","function":{"name":"summarize","parameters":{"type":"object"}}}],"tool_choice":{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[{"type":"function","function":{"name":"lookup"}}]}}}`, + toolsPath: "tools", + }, + { + name: "Chat to Grok", providerKind: channel.ProviderGrok, protocol: protocol.OpenAICompletions, + body: `{"model":"client-model","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup","parameters":{"type":"object"}}},{"type":"function","function":{"name":"summarize","parameters":{"type":"object"}}}],"tool_choice":{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[{"type":"function","function":{"name":"lookup"}}]}}}`, + toolsPath: "tools", + }, + { + name: "Gemini to Claude", providerKind: channel.ProviderClaude, protocol: protocol.Gemini, + body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup","parameters":{"type":"object"}},{"name":"summarize","parameters":{"type":"object"}}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY","allowedFunctionNames":["lookup"]}}}`, + toolsPath: "tools.0.functionDeclarations", + }, + { + name: "Gemini to Codex", providerKind: channel.ProviderCodex, protocol: protocol.Gemini, + body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup","parameters":{"type":"object"}},{"name":"summarize","parameters":{"type":"object"}}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY","allowedFunctionNames":["lookup"]}}}`, + toolsPath: "tools.0.functionDeclarations", + }, + { + name: "Gemini to Grok", providerKind: channel.ProviderGrok, protocol: protocol.Gemini, + body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup","parameters":{"type":"object"}},{"name":"summarize","parameters":{"type":"object"}}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY","allowedFunctionNames":["lookup"]}}}`, + toolsPath: "tools.0.functionDeclarations", + }, + } { + t.Run(test.name, func(t *testing.T) { + spec := execution.AttemptSpec{ + ClientProtocol: test.protocol, + RouteMode: execution.RouteConverted, + Operation: execution.OperationChatCompletion, + UpstreamModel: test.upstreamModel, + Body: []byte(test.body), + } + if test.protocol == protocol.OpenAIResponses { + spec.Operation = execution.OperationResponsesCreate + } + original := append([]byte(nil), spec.Body...) + prepared, evidence := prepareConvertedFidelity(spec, test.providerKind) + if evidence != nil { + t.Fatalf("valid function allowlist rejected: %+v", evidence) + } + if got := int(gjson.GetBytes(prepared.Body, test.toolsPath+".#").Int()); got != 1 { + t.Fatalf("prepared tools = %d, want 1: %s", got, prepared.Body) + } + if got := subscriptionPreparedToolName(test.protocol, prepared.Body); got != "lookup" { + t.Fatalf("prepared tool = %q, want lookup: %s", got, prepared.Body) + } + if got := subscriptionPreparedToolMode(test.protocol, prepared.Body); got != "required" { + t.Fatalf("prepared tool mode = %q, want required: %s", got, prepared.Body) + } + if !bytes.Equal(spec.Body, original) { + t.Fatalf("original body mutated: %s", spec.Body) + } + }) + } +} + +func TestSubscriptionConvertedFunctionAllowlistsRejectInvalidConstraints(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + protocol protocol.Protocol + body string + }{ + {"Responses empty", protocol.OpenAIResponses, `{"input":"hello","tools":[{"type":"function","name":"lookup"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[]}}`}, + {"Responses missing", protocol.OpenAIResponses, `{"input":"hello","tools":[{"type":"function","name":"lookup"}],"tool_choice":{"type":"allowed_tools","mode":"required"}}`}, + {"Responses invalid mode", protocol.OpenAIResponses, `{"input":"hello","tools":[{"type":"function","name":"lookup"}],"tool_choice":{"type":"allowed_tools","mode":"none","tools":[{"type":"function","name":"lookup"}]}}`}, + {"Responses duplicate", protocol.OpenAIResponses, `{"input":"hello","tools":[{"type":"function","name":"lookup"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"lookup"}]}}`}, + {"Chat empty", protocol.OpenAICompletions, `{"messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup"}}],"tool_choice":{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[]}}}`}, + {"Chat missing", protocol.OpenAICompletions, `{"messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup"}}],"tool_choice":{"type":"allowed_tools"}}`}, + {"Gemini missing function", protocol.Gemini, `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup"}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY","allowedFunctionNames":["missing"]}}}`}, + } { + t.Run(test.name, func(t *testing.T) { + spec := execution.AttemptSpec{ + ClientProtocol: test.protocol, + RouteMode: execution.RouteConverted, + Operation: execution.OperationChatCompletion, + Body: []byte(test.body), + } + if test.protocol == protocol.OpenAIResponses { + spec.Operation = execution.OperationResponsesCreate + } + _, evidence := prepareConvertedFidelity(spec, channel.ProviderClaude) + if evidence == nil || evidence.Kind != execution.ErrorKindConversionUnsupported || + evidence.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("invalid allowlist was not rejected: %+v", evidence) + } + }) + } +} + +func subscriptionPreparedToolName(clientProtocol protocol.Protocol, body []byte) string { + switch clientProtocol { + case protocol.OpenAICompletions: + return gjson.GetBytes(body, "tools.0.function.name").String() + case protocol.Gemini: + return gjson.GetBytes(body, "tools.0.functionDeclarations.0.name").String() + default: + return gjson.GetBytes(body, "tools.0.name").String() + } +} + +func subscriptionPreparedToolMode(clientProtocol protocol.Protocol, body []byte) string { + if clientProtocol == protocol.Gemini { + mode := gjson.GetBytes(body, "toolConfig.functionCallingConfig.mode").String() + if mode == "ANY" { + return "required" + } + return "auto" + } + return gjson.GetBytes(body, "tool_choice").String() +} + +func TestSubscriptionConvertedToolCompatibilityKeepsInstructionPreparation(t *testing.T) { + t.Parallel() + body := []byte(`{"model":"client-model","input":[{"role":"user","content":"start"},{"role":"assistant","content":"reply"},{"role":"system","content":"instruction"},{"role":"user","content":"continue"}],"tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"}],"tool_choice":{"type":"allowed_tools","mode":"auto","tools":[{"type":"function","name":"lookup"}]}}`) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAIResponses, + RouteMode: execution.RouteConverted, + Operation: execution.OperationResponsesCreate, + Body: body, + } + _, evidence := prepareConvertedFidelity(spec, channel.ProviderClaude) + if evidence == nil || evidence.Kind != execution.ErrorKindConversionUnsupported || + evidence.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("tool adaptation skipped instruction protection: %+v", evidence) + } +} + +func TestSubscriptionConvertedToolCompatibilityDoesNotApplyToNativeRoutes(t *testing.T) { + t.Parallel() + body := []byte(`{"input":"hello","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAIResponses, + RouteMode: execution.RouteNative, + Operation: execution.OperationResponsesCreate, + Body: body, + } + prepared, evidence := prepareConvertedFidelity(spec, channel.ProviderCodex) + if evidence != nil || !bytes.Equal(prepared.Body, body) { + t.Fatalf("native request changed: evidence=%+v body=%s", evidence, prepared.Body) + } +} + +func TestSubscriptionConvertedResponsesKeepsNativeCodexAllowlist(t *testing.T) { + t.Parallel() + body := []byte(`{"input":"hello","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`) + spec := execution.AttemptSpec{ + ClientProtocol: protocol.OpenAIResponses, + RouteMode: execution.RouteConverted, + Operation: execution.OperationResponsesCreate, + Body: body, + } + for _, providerKind := range []channel.ProviderKind{channel.ProviderCodex, channel.ProviderGrok} { + prepared, evidence := prepareConvertedFidelity(spec, providerKind) + if evidence != nil || !bytes.Equal(prepared.Body, body) { + t.Fatalf("%s native Responses allowlist changed: evidence=%+v body=%s", providerKind, evidence, prepared.Body) + } + } +} + +func TestSubscriptionConvertedFunctionAllowlistModes(t *testing.T) { + t.Parallel() + for _, mode := range []string{"auto", "required"} { + t.Run(mode, func(t *testing.T) { + body := []byte(fmt.Sprintf(`{"input":"hello","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"}],"tool_choice":{"type":"allowed_tools","mode":%q,"tools":[{"type":"function","name":"lookup"}]}}`, mode)) + spec := execution.AttemptSpec{ClientProtocol: protocol.OpenAIResponses, RouteMode: execution.RouteConverted, Operation: execution.OperationResponsesCreate, Body: body} + prepared, evidence := prepareConvertedFidelity(spec, channel.ProviderClaude) + if evidence != nil || gjson.GetBytes(prepared.Body, "tool_choice").String() != mode { + t.Fatalf("mode %q was not preserved: evidence=%+v body=%s", mode, evidence, prepared.Body) + } + }) + } +} + +func TestAdapterRejectsAntigravityClaudeRequiredFunctionAllowlistsBeforeDispatch(t *testing.T) { + for _, test := range []struct { + name string + protocol protocol.Protocol + operation execution.Operation + path string + body string + }{ + { + name: "Responses", protocol: protocol.OpenAIResponses, + operation: execution.OperationResponsesCreate, path: "/v1/responses", + body: `{"model":"client-model","input":"hello","tools":[{"type":"function","name":"lookup"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"}]}}`, + }, + { + name: "Chat", protocol: protocol.OpenAICompletions, + operation: execution.OperationChatCompletion, path: "/v1/chat/completions", + body: `{"model":"client-model","messages":[{"role":"user","content":"hello"}],"tools":[{"type":"function","function":{"name":"lookup"}}],"tool_choice":{"type":"allowed_tools","allowed_tools":{"mode":"required","tools":[{"type":"function","function":{"name":"lookup"}}]}}}`, + }, + } { + for _, stream := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/stream=%t", test.name, stream), func(t *testing.T) { + registry := channel.NewRegistry() + target, err := registry.Resolve(channel.Antigravity, nil) + if err != nil { + t.Fatal(err) + } + preparer := &fakeCredentialPreparer{} + adapter := NewAdapter(nil, registry) + adapter.credentials = preparer + spec := execution.NewAttemptSpec(execution.AttemptSpec{ + RequestID: "allowlist-request", AttemptID: "allowlist-attempt", Sequence: 1, + ChannelID: string(channel.Antigravity), TargetConfig: target.TargetConfig, + RouteMode: execution.RouteConverted, ClientProtocol: test.protocol, Operation: test.operation, + ClientModel: "client-model", UpstreamModel: "claude-sonnet-4-6", + Method: http.MethodPost, Path: test.path, Body: []byte(test.body), + Credential: execution.NewCredentialSnapshot(1, 1, 1, []byte(`{}`)), + }) + var dispatchState execution.DispatchState + var evidence *execution.ErrorEvidence + if stream { + result := adapter.ExecuteStream(t.Context(), spec, func(execution.StreamEvent) error { return nil }) + dispatchState, evidence = result.DispatchState, result.Error + } else { + result := adapter.Execute(t.Context(), spec) + dispatchState, evidence = result.DispatchState, result.Error + } + if preparer.calls != 0 || dispatchState != execution.DispatchNotSent || evidence == nil || + evidence.Kind != execution.ErrorKindConversionUnsupported || evidence.Code != execution.ErrorCodeCriticalSemanticLoss { + t.Fatalf("required allowlist reached dispatch: credential_calls=%d state=%s evidence=%+v", preparer.calls, dispatchState, evidence) + } + }) + } + } +} + +func TestSubscriptionGeminiAllowlistUsesClaudeSDKAliasPriority(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + body string + modePath string + }{ + { + name: "snake case", + body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup"},{"name":"summarize"},{"name":"remove_record"}]}],"tool_config":{"function_calling_config":{"mode":"ANY","allowed_function_names":["lookup","summarize"]}}}`, + modePath: "tool_config.function_calling_config.mode", + }, + { + name: "snake allowed names in camel config", + body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup"},{"name":"summarize"},{"name":"remove_record"}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY","allowed_function_names":["lookup","summarize"]}}}`, + modePath: "toolConfig.functionCallingConfig.mode", + }, + { + name: "snake top level takes priority", + body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup"},{"name":"summarize"},{"name":"remove_record"}]}],"tool_config":{"function_calling_config":{"mode":"ANY","allowed_function_names":["lookup","summarize"]}},"toolConfig":{"functionCallingConfig":{"mode":"AUTO","allowedFunctionNames":["remove_record"]}}}`, + modePath: "tool_config.function_calling_config.mode", + }, + } { + t.Run(test.name, func(t *testing.T) { + spec := execution.AttemptSpec{ + ClientProtocol: protocol.Gemini, + RouteMode: execution.RouteConverted, + Operation: execution.OperationChatCompletion, + Body: []byte(test.body), + } + prepared, evidence := prepareConvertedFidelity(spec, channel.ProviderClaude) + if evidence != nil { + t.Fatalf("valid Gemini allowlist rejected: %+v", evidence) + } + declarations := gjson.GetBytes(prepared.Body, "tools.0.functionDeclarations").Array() + if len(declarations) != 2 || declarations[0].Get("name").String() != "lookup" || + declarations[1].Get("name").String() != "summarize" { + t.Fatalf("prepared declarations = %s, want lookup and summarize", prepared.Body) + } + if mode := gjson.GetBytes(prepared.Body, test.modePath).String(); mode != "ANY" { + t.Fatalf("prepared mode = %q, want ANY: %s", mode, prepared.Body) + } + }) + } +} + +func TestSubscriptionTokenCountUsesGenerationToolConstraints(t *testing.T) { + t.Parallel() + const geminiBody = `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"tools":[{"functionDeclarations":[{"name":"lookup"},{"name":"summarize"},{"name":"remove_record"}]}],"toolConfig":{"functionCallingConfig":{"mode":"ANY","allowedFunctionNames":["lookup","summarize"]}}}` + const responsesBody = `{"input":"hello","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"},{"type":"function","name":"remove_record"}],"tool_choice":{"type":"allowed_tools","mode":"required","tools":[{"type":"function","name":"lookup"},{"type":"function","name":"summarize"}]}}` + for _, test := range []struct { + name string + providerKind channel.ProviderKind + clientProtocol protocol.Protocol + generationOperation execution.Operation + countOperation execution.Operation + upstreamModel string + body string + toolsPath string + }{ + {"Gemini to Claude", channel.ProviderClaude, protocol.Gemini, execution.OperationChatCompletion, execution.OperationCountTokens, "claude-sonnet-4-6", geminiBody, "tools.0.functionDeclarations"}, + {"Gemini to Codex", channel.ProviderCodex, protocol.Gemini, execution.OperationChatCompletion, execution.OperationCountTokens, "gpt-5.2", geminiBody, "tools.0.functionDeclarations"}, + {"Gemini to Grok", channel.ProviderGrok, protocol.Gemini, execution.OperationChatCompletion, execution.OperationCountTokens, "grok-4.3", geminiBody, "tools.0.functionDeclarations"}, + {"Responses to Claude", channel.ProviderClaude, protocol.OpenAIResponses, execution.OperationResponsesCreate, execution.OperationResponsesInputTokens, "claude-sonnet-4-6", responsesBody, "tools"}, + {"Responses to Antigravity", channel.ProviderAntigravity, protocol.OpenAIResponses, execution.OperationResponsesCreate, execution.OperationResponsesInputTokens, "gemini-2.5-pro", responsesBody, "tools"}, + } { + t.Run(test.name, func(t *testing.T) { + generation := execution.AttemptSpec{ + ClientProtocol: test.clientProtocol, RouteMode: execution.RouteConverted, + Operation: test.generationOperation, UpstreamModel: test.upstreamModel, Body: []byte(test.body), + } + want, evidence := prepareConvertedFidelity(generation, test.providerKind) + if evidence != nil { + t.Fatalf("generation constraints rejected: %+v", evidence) + } + count := generation + count.Operation = test.countOperation + got, evidence := prepareConvertedFidelity(count, test.providerKind) + if evidence != nil { + t.Fatalf("token-count constraints rejected: %+v", evidence) + } + if !bytes.Equal(got.Body, want.Body) || gjson.GetBytes(got.Body, test.toolsPath+".#").Int() != 2 { + t.Fatalf("token-count constraints differ from generation: got=%s want=%s", got.Body, want.Body) + } + }) + } +}