fix(bifrost): 恢复兼容渠道的工具降级转发 (#653)
* fix(execution): 保留跨协议工具调用约束 * fix(cpa): 修正工具白名单兼容边界 * fix(bifrost): 拒绝丢失压缩上下文 * fix(execution): 保持计数请求工具约束 * fix(bifrost): 恢复兼容渠道的工具降级转发
This commit is contained in:
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()
|
||||
|
||||
|
||||
@@ -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 = ¶ms
|
||||
params.Tools = make([]schemas.ChatTool, 0, len(allowlist.names))
|
||||
for _, tool := range request.Params.Tools {
|
||||
if tool.Type == schemas.ChatToolTypeFunction && tool.Function != nil {
|
||||
if _, selected := allowlist.names[tool.Function.Name]; selected {
|
||||
params.Tools = append(params.Tools, tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
params.ToolChoice = &schemas.ChatToolChoice{ChatToolChoiceStr: schemas.Ptr(allowlist.mode)}
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func normalizeConvertedResponseToolMode(request *schemas.BifrostResponsesRequest) *schemas.BifrostResponsesRequest {
|
||||
choice := request.Params.ToolChoice
|
||||
if choice == nil {
|
||||
return request
|
||||
}
|
||||
mode := ""
|
||||
if choice.ResponsesToolChoiceStr != nil {
|
||||
mode = *choice.ResponsesToolChoiceStr
|
||||
} else if value := choice.ResponsesToolChoiceStruct; value != nil &&
|
||||
len(value.Tools) == 0 && value.Name == nil && value.ServerLabel == nil {
|
||||
mode = string(value.Type)
|
||||
if mode == "" && value.Mode != nil {
|
||||
mode = *value.Mode
|
||||
}
|
||||
}
|
||||
if mode == "any" {
|
||||
mode = "required"
|
||||
}
|
||||
if mode != "auto" && mode != "none" && mode != "required" {
|
||||
return request
|
||||
}
|
||||
if choice.ResponsesToolChoiceStr != nil && *choice.ResponsesToolChoiceStr == mode {
|
||||
return request
|
||||
}
|
||||
cloned, params := cloneResponsesRequestParams(request)
|
||||
params.ToolChoice = &schemas.ResponsesToolChoice{ResponsesToolChoiceStr: schemas.Ptr(mode)}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneResponsesRequestParams(
|
||||
request *schemas.BifrostResponsesRequest,
|
||||
) (*schemas.BifrostResponsesRequest, *schemas.ResponsesParameters) {
|
||||
cloned := *request
|
||||
params := *request.Params
|
||||
cloned.Params = ¶ms
|
||||
return &cloned, ¶ms
|
||||
}
|
||||
|
||||
func responsesTargetNeedsAllowlistFallback(providerKind channel.ProviderKind, model string) bool {
|
||||
switch providerKind {
|
||||
case channel.ProviderOpenAICompatible, channel.ProviderAnthropic, channel.ProviderDeepSeek,
|
||||
channel.ProviderGroq, channel.ProviderAWSBedrock:
|
||||
return true
|
||||
case channel.ProviderGoogleVertex:
|
||||
return !vertexUsesGeminiResponses(model)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func chatTargetNeedsAllowlistFallback(providerKind channel.ProviderKind, model string) bool {
|
||||
switch providerKind {
|
||||
case channel.ProviderAnthropic, channel.ProviderGemini, channel.ProviderAWSBedrock:
|
||||
return true
|
||||
case channel.ProviderGoogleVertex:
|
||||
return schemas.IsAnthropicModelFamily(nil, model) || vertexUsesGeminiResponses(model)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func vertexUsesGeminiResponses(model string) bool {
|
||||
return schemas.IsGeminiModelFamily(nil, model) || schemas.IsGemmaModelFamily(nil, model) || schemas.IsAllDigitsASCII(model)
|
||||
}
|
||||
|
||||
func targetUsesChatFallback(providerKind channel.ProviderKind, model string) bool {
|
||||
return providerKind == channel.ProviderOpenAICompatible || providerKind == channel.ProviderGroq ||
|
||||
providerKind == channel.ProviderDeepSeek || providerKind == channel.ProviderGoogleVertex && vertexUsesChatFallback(model)
|
||||
}
|
||||
|
||||
func chatFallbackToolCompatibility(request *schemas.BifrostResponsesRequest) (bool, bool) {
|
||||
if request == nil || request.Params == nil {
|
||||
return false, false
|
||||
}
|
||||
converted := (&schemas.BifrostResponsesRequest{Params: request.Params}).ToChatRequest()
|
||||
choice := request.Params.ToolChoice
|
||||
mode, selectedFunction := responsesToolChoiceMode(choice)
|
||||
toolsChanged := !chatFallbackPreservesAllTools(request.Params.Tools, converted.Params.Tools)
|
||||
|
||||
switch mode {
|
||||
case "none":
|
||||
if len(converted.Params.Tools) == 0 {
|
||||
return false, toolsChanged
|
||||
}
|
||||
return chatToolChoiceMode(converted.Params.ToolChoice) != "none", toolsChanged
|
||||
case "function":
|
||||
if selectedFunction == "" || !responsesFunctionSelectedUniquely(request.Params.Tools, selectedFunction) ||
|
||||
!chatFunctionSelectedUniquely(converted.Params.Tools, selectedFunction) ||
|
||||
chatSelectedFunction(converted.Params.ToolChoice) != selectedFunction {
|
||||
return true, false
|
||||
}
|
||||
return false, toolsChanged
|
||||
}
|
||||
|
||||
if toolsChanged {
|
||||
return true, false
|
||||
}
|
||||
if choice == nil {
|
||||
return converted.Params.ToolChoice != nil, false
|
||||
}
|
||||
convertedMode := chatToolChoiceMode(converted.Params.ToolChoice)
|
||||
if len(request.Params.Tools) == 0 && (mode == "auto" || mode == "none") && converted.Params.ToolChoice == nil {
|
||||
return false, false
|
||||
}
|
||||
return convertedMode != mode, false
|
||||
}
|
||||
|
||||
func chatFallbackPreservesAllTools(source []schemas.ResponsesTool, target []schemas.ChatTool) bool {
|
||||
if len(source) != len(target) {
|
||||
return false
|
||||
}
|
||||
for index, tool := range source {
|
||||
if tool.Type != schemas.ResponsesToolTypeFunction || tool.Name == nil || target[index].Function == nil ||
|
||||
target[index].Type != schemas.ChatToolTypeFunction || target[index].Function.Name != *tool.Name {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func responsesToolChoiceMode(choice *schemas.ResponsesToolChoice) (string, string) {
|
||||
if choice == nil {
|
||||
return "", ""
|
||||
}
|
||||
if choice.ResponsesToolChoiceStr != nil {
|
||||
return *choice.ResponsesToolChoiceStr, ""
|
||||
}
|
||||
value := choice.ResponsesToolChoiceStruct
|
||||
if value == nil {
|
||||
return "", ""
|
||||
}
|
||||
if value.Type == schemas.ResponsesToolChoiceTypeFunction && value.Name != nil {
|
||||
return "function", *value.Name
|
||||
}
|
||||
return string(value.Type), ""
|
||||
}
|
||||
|
||||
func chatToolChoiceMode(choice *schemas.ChatToolChoice) string {
|
||||
if choice == nil {
|
||||
return ""
|
||||
}
|
||||
if choice.ChatToolChoiceStr != nil {
|
||||
return *choice.ChatToolChoiceStr
|
||||
}
|
||||
if choice.ChatToolChoiceStruct != nil {
|
||||
return string(choice.ChatToolChoiceStruct.Type)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func chatSelectedFunction(choice *schemas.ChatToolChoice) string {
|
||||
if choice == nil || choice.ChatToolChoiceStruct == nil ||
|
||||
choice.ChatToolChoiceStruct.Type != schemas.ChatToolChoiceTypeFunction || choice.ChatToolChoiceStruct.Function == nil {
|
||||
return ""
|
||||
}
|
||||
return choice.ChatToolChoiceStruct.Function.Name
|
||||
}
|
||||
|
||||
func responsesFunctionSelectedUniquely(tools []schemas.ResponsesTool, name string) bool {
|
||||
count := 0
|
||||
for _, tool := range tools {
|
||||
if tool.Type == schemas.ResponsesToolTypeFunction && tool.Name != nil && *tool.Name == name {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count == 1
|
||||
}
|
||||
|
||||
func chatFunctionSelectedUniquely(tools []schemas.ChatTool, name string) bool {
|
||||
count := 0
|
||||
for _, tool := range tools {
|
||||
if tool.Type == schemas.ChatToolTypeFunction && tool.Function != nil && tool.Function.Name == name {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count == 1
|
||||
}
|
||||
|
||||
func chatFallbackPreservesToolHistory(request *schemas.BifrostResponsesRequest) bool {
|
||||
if request == nil {
|
||||
return true
|
||||
}
|
||||
for _, message := range request.Input {
|
||||
if message.Type == nil {
|
||||
continue
|
||||
}
|
||||
switch *message.Type {
|
||||
case schemas.ResponsesMessageTypeMessage,
|
||||
schemas.ResponsesMessageTypeReasoning,
|
||||
schemas.ResponsesMessageTypeFunctionCall,
|
||||
schemas.ResponsesMessageTypeFunctionCallOutput,
|
||||
schemas.ResponsesMessageTypeItemReference,
|
||||
schemas.ResponsesMessageTypeRefusal:
|
||||
continue
|
||||
case schemas.ResponsesMessageTypeFileSearchCall,
|
||||
schemas.ResponsesMessageTypeComputerCall,
|
||||
schemas.ResponsesMessageTypeComputerCallOutput,
|
||||
schemas.ResponsesMessageTypeWebSearchCall,
|
||||
schemas.ResponsesMessageTypeWebFetchCall,
|
||||
schemas.ResponsesMessageTypeToolSearchCall,
|
||||
schemas.ResponsesMessageTypeToolSearchOutput,
|
||||
schemas.ResponsesMessageTypeCodeInterpreterCall,
|
||||
schemas.ResponsesMessageTypeLocalShellCall,
|
||||
schemas.ResponsesMessageTypeLocalShellCallOutput,
|
||||
schemas.ResponsesMessageTypeMCPCall,
|
||||
schemas.ResponsesMessageTypeCustomToolCall,
|
||||
schemas.ResponsesMessageTypeCustomToolCallOutput,
|
||||
schemas.ResponsesMessageTypeImageGenerationCall,
|
||||
schemas.ResponsesMessageTypeMCPListTools,
|
||||
schemas.ResponsesMessageTypeMCPApprovalRequest,
|
||||
schemas.ResponsesMessageTypeMCPApprovalResponses,
|
||||
schemas.ResponsesMessageTypeAdditionalTools,
|
||||
schemas.ResponsesMessageTypeAdvisorCall:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func convertedTargetPreservesToolHistory(
|
||||
providerKind channel.ProviderKind,
|
||||
model string,
|
||||
request *schemas.BifrostResponsesRequest,
|
||||
) bool {
|
||||
if targetUsesChatFallback(providerKind, model) {
|
||||
return chatFallbackPreservesToolHistory(request)
|
||||
}
|
||||
if providerKind == channel.ProviderAnthropic ||
|
||||
providerKind == channel.ProviderGoogleVertex && schemas.IsAnthropicModelFamily(nil, model) {
|
||||
return responsesHistorySupportedBy(request, channel.ProviderAnthropic)
|
||||
}
|
||||
if providerKind == channel.ProviderAWSBedrock {
|
||||
return responsesHistorySupportedBy(request, channel.ProviderAWSBedrock)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func responsesHistorySupportedBy(
|
||||
request *schemas.BifrostResponsesRequest,
|
||||
providerKind channel.ProviderKind,
|
||||
) bool {
|
||||
if request == nil {
|
||||
return true
|
||||
}
|
||||
for _, message := range request.Input {
|
||||
if message.Type == nil {
|
||||
continue
|
||||
}
|
||||
switch *message.Type {
|
||||
case schemas.ResponsesMessageTypeMessage,
|
||||
schemas.ResponsesMessageTypeReasoning,
|
||||
schemas.ResponsesMessageTypeFunctionCall,
|
||||
schemas.ResponsesMessageTypeFunctionCallOutput,
|
||||
schemas.ResponsesMessageTypeItemReference,
|
||||
schemas.ResponsesMessageTypeRefusal:
|
||||
continue
|
||||
}
|
||||
supported := false
|
||||
switch providerKind {
|
||||
case channel.ProviderAnthropic:
|
||||
switch *message.Type {
|
||||
case schemas.ResponsesMessageTypeComputerCall,
|
||||
schemas.ResponsesMessageTypeComputerCallOutput,
|
||||
schemas.ResponsesMessageTypeWebSearchCall,
|
||||
schemas.ResponsesMessageTypeWebFetchCall,
|
||||
schemas.ResponsesMessageTypeToolSearchCall,
|
||||
schemas.ResponsesMessageTypeCodeInterpreterCall,
|
||||
schemas.ResponsesMessageTypeMCPCall,
|
||||
schemas.ResponsesMessageTypeMCPApprovalRequest,
|
||||
schemas.ResponsesMessageTypeAdvisorCall:
|
||||
supported = true
|
||||
}
|
||||
case channel.ProviderAWSBedrock:
|
||||
supported = *message.Type == schemas.ResponsesMessageTypeWebSearchCall ||
|
||||
*message.Type == schemas.ResponsesMessageTypeCodeInterpreterCall
|
||||
}
|
||||
if !supported {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user