fix(bifrost): 恢复兼容渠道的工具降级转发 (#653)

* fix(execution): 保留跨协议工具调用约束

* fix(cpa): 修正工具白名单兼容边界

* fix(bifrost): 拒绝丢失压缩上下文

* fix(execution): 保持计数请求工具约束

* fix(bifrost): 恢复兼容渠道的工具降级转发
This commit is contained in:
tbphp authored and GitHub committed 2026-09-14 22:21:59 +08:00
1 parent 0a19e80d26
commit 91df89efed
10 files changed
+2084 -51

No files matched your search

@@ -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
@@ -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)
}
@@ -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()
+17
View File
@@ -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,
@@ -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 = &params
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 = &params
return &cloned, &params
}
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
}
@@ -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"
@@ -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)
}
}
}
+6 -6
View File
@@ -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 {
+296 -3
View File
@@ -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
}
@@ -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)
}
})
}
}