Skip to content

Commit 5f06bd8

Browse files
committed
test harness for quick checks
1 parent 734f02d commit 5f06bd8

19 files changed

Lines changed: 4406 additions & 507 deletions

Makefile

Lines changed: 266 additions & 118 deletions
Large diffs are not rendered by default.

core/internal/llmtests/provider_feature_support_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1256,7 +1256,8 @@ func TestRawBodyToolVersionRemapping(t *testing.T) {
12561256
t.Run(tt.name, func(t *testing.T) {
12571257
t.Parallel()
12581258

1259-
result, err := anthropic.RemapRawToolVersionsForProvider([]byte(tt.inputJSON), tt.provider)
1259+
model := providerUtils.GetJSONField([]byte(tt.inputJSON), "model").String()
1260+
result, err := anthropic.RemapRawToolVersionsForProvider([]byte(tt.inputJSON), tt.provider, model)
12601261

12611262
if tt.expectErr {
12621263
require.Error(t, err)

core/providers/anthropic/chat.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func convertFunctionToolToAnthropic(tool schemas.ChatTool) AnthropicTool {
100100
//
101101
// bash_*, memory_*, code_execution_*, and tool_search_* carry no variant
102102
// config — their Type + Name alone are enough, handled in the default branch.
103-
func convertServerToolToAnthropic(tool schemas.ChatTool) (AnthropicTool, bool) {
103+
func convertServerToolToAnthropic(tool schemas.ChatTool, model string) (AnthropicTool, bool) {
104104
typeStr := string(tool.Type)
105105
if typeStr == "" {
106106
return AnthropicTool{}, false
@@ -125,13 +125,25 @@ func convertServerToolToAnthropic(tool schemas.ChatTool) (AnthropicTool, bool) {
125125

126126
// Remaining server tools (web_search, web_fetch, computer, text_editor, etc.)
127127
// identify themselves via Name.
128-
if tool.Name == "" {
128+
toolName := tool.Name
129+
// Normalize computer-use family (computer / text_editor / bash) to the
130+
// canonical {type, name} pair for the model's generation. Keeps callers
131+
// from having to memorize Anthropic's per-generation tool naming matrix.
132+
if baseTool := computerUseBaseTool(typeStr); baseTool != "" {
133+
if wantType, wantName := NormalizedToolSpec(ComputerUseGeneration(model), baseTool); wantType != "" {
134+
typeStr = wantType
135+
if toolName == "" || toolName != wantName {
136+
toolName = wantName
137+
}
138+
}
139+
}
140+
if toolName == "" {
129141
return AnthropicTool{}, false
130142
}
131143

132144
atype := AnthropicToolType(typeStr)
133145
anthropicTool := AnthropicTool{
134-
Name: tool.Name,
146+
Name: toolName,
135147
Type: &atype,
136148
CacheControl: tool.CacheControl,
137149
DeferLoading: tool.DeferLoading,
@@ -425,7 +437,7 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif
425437
continue
426438
}
427439
// Non-function tool: attempt server-tool reconstruction.
428-
if converted, ok := convertServerToolToAnthropic(tool); ok {
440+
if converted, ok := convertServerToolToAnthropic(tool, bifrostReq.Model); ok {
429441
tools = append(tools, converted)
430442
}
431443
}
File renamed without changes.
File renamed without changes.

core/providers/anthropic/request_builder.go renamed to core/providers/anthropic/requestbuilder.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,10 @@ func BuildAnthropicResponsesRequestBody(ctx *schemas.BifrostContext, request *sc
177177
}
178178

179179
if cfg.RemapToolVersions {
180-
jsonBody, err = RemapRawToolVersionsForProvider(jsonBody, cfg.Provider)
180+
// request.Model is the alias-resolved model id; pass it so
181+
// computer-use / text-editor / bash tools get normalized to the
182+
// canonical {type, name} pair Anthropic expects for the model's generation.
183+
jsonBody, err = RemapRawToolVersionsForProvider(jsonBody, cfg.Provider, request.Model)
181184
if err != nil {
182185
return nil, newErr(err.Error(), nil, jsonBody)
183186
}
File renamed without changes.

core/providers/anthropic/utils.go

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,86 @@ func SupportsAdaptiveThinking(model string) bool {
654654
return strings.Contains(model, "opus") || strings.Contains(model, "sonnet")
655655
}
656656

657+
// Computer-use tool generations.
658+
// - "20251124" — Opus 4.7, Opus 4.6, Sonnet 4.6, Opus 4.5
659+
// - "20250124" — everything else (Sonnet 4.5, Haiku 4.5, Opus 4.1, Sonnet 4, Opus 4, Sonnet 3.7)
660+
//
661+
// The bash tool is generation-invariant (always bash_20250124).
662+
const (
663+
ComputerUseGen20251124 = "20251124"
664+
ComputerUseGen20250124 = "20250124"
665+
)
666+
667+
// ComputerUseGeneration returns the tool-version generation a Claude model
668+
// uses for computer-use / text-editor tools. This drives:
669+
// - Which beta header to inject (computer-use-2025-11-24 vs 2025-01-24).
670+
// - Which computer_*/text_editor_* type the upstream API will accept.
671+
// - Which `name` literal Anthropic's Pydantic validator demands for text_editor.
672+
func ComputerUseGeneration(model string) string {
673+
m := strings.ToLower(model)
674+
// Opus 4.7+ falls into the new generation.
675+
if IsOpus47(m) {
676+
return ComputerUseGen20251124
677+
}
678+
// Opus 4.6 / Sonnet 4.6 / Opus 4.5 also use the new generation.
679+
if strings.Contains(m, "opus") {
680+
if strings.Contains(m, "4-5") || strings.Contains(m, "4.5") ||
681+
strings.Contains(m, "4-6") || strings.Contains(m, "4.6") {
682+
return ComputerUseGen20251124
683+
}
684+
}
685+
if strings.Contains(m, "sonnet") {
686+
if strings.Contains(m, "4-6") || strings.Contains(m, "4.6") {
687+
return ComputerUseGen20251124
688+
}
689+
}
690+
return ComputerUseGen20250124
691+
}
692+
693+
// NormalizedToolSpec returns the canonical {type, name} pair Anthropic's API
694+
// expects for a server tool, given the model's computer-use generation.
695+
// baseTool is the family name with no version suffix: "computer", "text_editor", or "bash".
696+
// Returns ("", "") if baseTool is unknown.
697+
func NormalizedToolSpec(generation, baseTool string) (toolType, toolName string) {
698+
switch baseTool {
699+
case "computer":
700+
if generation == ComputerUseGen20251124 {
701+
return string(AnthropicToolTypeComputer20251124), "computer"
702+
}
703+
return string(AnthropicToolTypeComputer20250124), "computer"
704+
case "bash":
705+
// bash_20250124 is generation-invariant per Anthropic's docs.
706+
return string(AnthropicToolTypeBash20250124), "bash"
707+
case "text_editor":
708+
if generation == ComputerUseGen20251124 {
709+
return string(AnthropicToolTypeTextEditor20250728), "str_replace_based_edit_tool"
710+
}
711+
return string(AnthropicToolTypeTextEditor20250124), "str_replace_editor"
712+
}
713+
return "", ""
714+
}
715+
716+
// computerUseBaseTool extracts the family name from a versioned tool type.
717+
// Returns "" for tool types that are not part of the computer-use family.
718+
//
719+
// Examples:
720+
//
721+
// computer_20251124 -> "computer"
722+
// text_editor_20250728 -> "text_editor"
723+
// bash_20250124 -> "bash"
724+
// web_search_20250305 -> ""
725+
func computerUseBaseTool(toolType string) string {
726+
switch {
727+
case strings.HasPrefix(toolType, "computer_"):
728+
return "computer"
729+
case strings.HasPrefix(toolType, "text_editor_"):
730+
return "text_editor"
731+
case strings.HasPrefix(toolType, "bash_"):
732+
return "bash"
733+
}
734+
return ""
735+
}
736+
657737
// MapBifrostEffortToAnthropic maps a Bifrost effort level to an Anthropic effort level.
658738
// Anthropic supports "low", "medium", "high", "max"; Bifrost also has "minimal" which maps to "low".
659739
func MapBifrostEffortToAnthropic(effort string) string {
@@ -1080,9 +1160,16 @@ func StripAutoInjectableTools(jsonBody []byte) ([]byte, error) {
10801160
}
10811161

10821162
// RemapRawToolVersionsForProvider inspects tools in a raw JSON body and remaps
1083-
// unsupported tool versions to supported ones for the target provider.
1084-
// Returns an error if a tool type is fundamentally unsupported (no remap possible).
1085-
func RemapRawToolVersionsForProvider(jsonBody []byte, provider schemas.ModelProvider) ([]byte, error) {
1163+
// unsupported tool versions to supported ones for the target provider, and
1164+
// normalizes computer-use / text-editor / bash tool {type, name} pairs to match
1165+
// the model's required generation. Returns an error if a tool type is
1166+
// fundamentally unsupported (no remap possible).
1167+
//
1168+
// model is the request's "model" field; it drives ComputerUseGeneration so that
1169+
// (e.g.) a request pairing claude-sonnet-4-6 with text_editor_20250124 gets
1170+
// rewritten to text_editor_20250728 + str_replace_based_edit_tool before
1171+
// hitting Anthropic's strict Pydantic validator.
1172+
func RemapRawToolVersionsForProvider(jsonBody []byte, provider schemas.ModelProvider, model string) ([]byte, error) {
10861173
toolsResult := providerUtils.GetJSONField(jsonBody, "tools")
10871174
if !toolsResult.Exists() || !toolsResult.IsArray() {
10881175
return jsonBody, nil
@@ -1103,12 +1190,46 @@ func RemapRawToolVersionsForProvider(jsonBody []byte, provider schemas.ModelProv
11031190
}
11041191
}
11051192

1106-
// Apply version remaps
1193+
// Normalize computer-use / text-editor / bash tools to the canonical
1194+
// (type, name) pair for the model's generation. Runs before
1195+
// providerToolVersionRemaps so downgrades still work for non-Anthropic
1196+
// providers that share the schema.
1197+
generation := ComputerUseGeneration(model)
1198+
for i, tool := range tools {
1199+
toolType := tool.Get("type").String()
1200+
baseTool := computerUseBaseTool(toolType)
1201+
if baseTool == "" {
1202+
continue
1203+
}
1204+
wantType, wantName := NormalizedToolSpec(generation, baseTool)
1205+
if wantType == "" {
1206+
continue
1207+
}
1208+
if toolType != wantType {
1209+
path := fmt.Sprintf("tools.%d.type", i)
1210+
jsonBody, err = providerUtils.SetJSONField(jsonBody, path, wantType)
1211+
if err != nil {
1212+
return nil, fmt.Errorf("failed to normalize tool type: %w", err)
1213+
}
1214+
}
1215+
// Only set name if the tool has one (custom tools use input_schema; computer-use family always has a name).
1216+
if existingName := tool.Get("name").String(); existingName != "" && existingName != wantName {
1217+
path := fmt.Sprintf("tools.%d.name", i)
1218+
jsonBody, err = providerUtils.SetJSONField(jsonBody, path, wantName)
1219+
if err != nil {
1220+
return nil, fmt.Errorf("failed to normalize tool name: %w", err)
1221+
}
1222+
}
1223+
}
1224+
1225+
// Apply provider-specific version remaps (e.g. web_search downgrades for non-Anthropic providers)
11071226
remaps, ok := providerToolVersionRemaps[provider]
11081227
if !ok {
11091228
return jsonBody, nil
11101229
}
11111230

1231+
// Re-fetch tools array since paths may have changed via SetJSONField above
1232+
tools = providerUtils.GetJSONField(jsonBody, "tools").Array()
11121233
for i, tool := range tools {
11131234
toolType := tool.Get("type").String()
11141235
for _, remap := range remaps {

0 commit comments

Comments
 (0)