Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 67 additions & 11 deletions core/providers/anthropic/computertoolset_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -272,29 +272,59 @@ func TestComputerToolset_RawBodyUpgradedToToolset(t *testing.T) {
}

// A toolset carries no display geometry, and the dated computer tools validate
// display_*_px as >= 1 and require a name. Converting one into the other is
// therefore impossible, so the tool is dropped the way any unsupported tool is —
// the alternative, a zero-sized display, is a hard 400 from the API and turns a
// cross-provider fallback into a failed request rather than a degraded one.
func TestComputerToolset_DroppedWhenTargetNeedsGeometry(t *testing.T) {
// Every generation that is not the toolset: Opus 5 and Opus 4.8 on the Claude
// API, and Opus 5.5 itself on the surfaces that still serve the dated tool.
// display_*_px as >= 1, so one cannot be rewritten as the other. Where the target
// takes a toolset the caller's entry is forwarded as sent; where it takes neither
// form the tool is dropped, because a zero-sized display is a hard 400 and would
// turn a re-routed request into a failure rather than a degraded one.
func TestComputerToolset_ForwardedWhenTargetAcceptsIt(t *testing.T) {
// Every model the computer-use docs list for the toolset, on the two surfaces
// that serve it. All of these also accept the dated tool, so the generation
// says "dated" — forwarding still has to win, or the caller loses the toolset
// they explicitly asked for.
cases := []struct {
provider schemas.ModelProvider
model string
}{
{schemas.Anthropic, "claude-opus-5"},
{schemas.Anthropic, "claude-sonnet-5"},
{schemas.Anthropic, "claude-opus-4-8"},
{schemas.Anthropic, "claude-fable-5-1"},
{schemas.Vertex, "claude-opus-5"},
}
for _, tc := range cases {
t.Run(string(tc.provider)+"/"+tc.model, func(t *testing.T) {
caps := schemas.ResolveModelCaps(tc.provider, tc.model)
tool := convertBifrostToolToAnthropic(caps, &schemas.ResponsesTool{
Type: schemas.ResponsesToolTypeComputerUsePreview,
ResponsesToolComputerUsePreview: &schemas.ResponsesToolComputerUsePreview{Environment: "browser"},
}, tc.provider, false)

require.NotNil(t, tool, "the model takes a toolset, so it must not be dropped")
require.NotNil(t, tool.Type)
assert.Equal(t, AnthropicToolTypeComputerToolset20260801, *tool.Type)
assert.Empty(t, tool.Name, "a toolset entry rejects name")
assert.Nil(t, tool.AnthropicToolComputerUse, "a toolset entry rejects the display_* geometry")
})
}
}

func TestComputerToolset_DroppedWhenTargetTakesNeitherForm(t *testing.T) {
// AWS and Foundry serve only the dated tool, and Sonnet 4.6 and older reject
// the toolset on every surface — so there is no shape left to send.
cases := []struct {
provider schemas.ModelProvider
model string
}{
{schemas.Bedrock, "claude-opus-5-5"},
{schemas.BedrockMantle, "claude-opus-5-5"},
{schemas.Azure, "claude-opus-5-5"},
{schemas.Anthropic, "claude-sonnet-4-6"},
{schemas.Vertex, "claude-sonnet-4-6"},
}
for _, tc := range cases {
t.Run(string(tc.provider)+"/"+tc.model, func(t *testing.T) {
caps := schemas.ResolveModelCaps(tc.provider, tc.model)
require.NotEqual(t, ComputerUseGenToolset20260801, ComputerUseGeneration(caps),
"case is only meaningful where the target wants a dated tool")
require.False(t, AcceptsComputerToolset(caps), "case is only meaningful where the toolset is rejected")

tool := convertBifrostToolToAnthropic(caps, &schemas.ResponsesTool{
Type: schemas.ResponsesToolTypeComputerUsePreview,
Expand All @@ -306,6 +336,32 @@ func TestComputerToolset_DroppedWhenTargetNeedsGeometry(t *testing.T) {
}
}

// supports_computer_toolset moves a model onto or off the toolset without a
// release, in both directions.
func TestComputerToolset_AcceptanceDatasheetOutranksFallback(t *testing.T) {
t.Run("row grants the toolset to a model the fallback withholds it from", func(t *testing.T) {
yes := true
setOverride(t, "claude-sonnet-4-6", schemas.ModelCapabilities{SupportsComputerToolset: &yes})
assert.True(t, AcceptsComputerToolset(schemas.ResolveModelCaps(schemas.Anthropic, "claude-sonnet-4-6")))
})

t.Run("row withholds it from a model the fallback grants", func(t *testing.T) {
no := false
setOverride(t, "claude-opus-5", schemas.ModelCapabilities{SupportsComputerToolset: &no})
assert.False(t, AcceptsComputerToolset(schemas.ResolveModelCaps(schemas.Anthropic, "claude-opus-5")))
})

t.Run("a row cannot grant it on a surface that does not serve it", func(t *testing.T) {
yes := true
providerUtils.SetCapabilityResolver(func(p schemas.ModelProvider, m string) *schemas.ModelCapabilities {
return &schemas.ModelCapabilities{SupportsComputerToolset: &yes}
})
t.Cleanup(func() { providerUtils.SetCapabilityResolver(nil) })
assert.False(t, AcceptsComputerToolset(schemas.ResolveModelCaps(schemas.Bedrock, "claude-opus-5-5")),
"AWS serves only the dated tool regardless of the row")
})
}

// The guard keys off the geometry, not off where the tool came from, so a genuine
// computer_use_preview still converts untouched.
func TestComputerToolset_RealGeometryStillConverts(t *testing.T) {
Expand All @@ -330,12 +386,12 @@ func TestComputerToolset_RealGeometryStillConverts(t *testing.T) {
// guard: a toolset entry there would otherwise become a dated tool with neither a
// name nor a display.
func TestComputerToolset_RawBodyDropsUndowngradableToolset(t *testing.T) {
body := []byte(`{"model":"claude-opus-5","max_tokens":64,` +
body := []byte(`{"model":"claude-sonnet-4-6","max_tokens":64,` +
`"tools":[{"type":"computer_toolset_20260801"},` +
`{"type":"text_editor_20250728","name":"str_replace_based_edit_tool"},` +
`{"type":"bash_20250124","name":"bash"}]}`)

out, err := RemapRawToolVersionsForProvider(body, schemas.Vertex, "claude-opus-5")
out, err := RemapRawToolVersionsForProvider(body, schemas.Vertex, "claude-sonnet-4-6")
require.NoError(t, err)

types := []string{}
Expand Down
17 changes: 12 additions & 5 deletions core/providers/anthropic/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -8738,13 +8738,20 @@ func convertBifrostToolToAnthropic(caps schemas.ModelCaps, tool *schemas.Respons
case ComputerUseGen20251124:
computerToolType = AnthropicToolTypeComputer20251124
}
// The dated shapes validate display_*_px as >= 1, so a tool with no
// geometry cannot be expressed as one — a toolset carries none by
// design, and an OpenAI-shaped computer_use_preview may omit it. Drop
// it like any other tool the target cannot take rather than sending a
// zero-sized display the API rejects outright.
// No geometry means this cannot become a dated tool: those validate
// display_*_px as >= 1. A toolset carries none by design, so when the
// target takes a toolset, send that — converting it into a dated tool
// the caller never asked for, or dropping a tool the model supports,
// both lose capability. Only when neither form is reachable is the tool
// dropped, the way any unsupported tool is.
if tool.ResponsesToolComputerUsePreview.DisplayWidth <= 0 ||
tool.ResponsesToolComputerUsePreview.DisplayHeight <= 0 {
if AcceptsComputerToolset(caps) {
return &AnthropicTool{
Type: schemas.Ptr(AnthropicToolTypeComputerToolset20260801),
CacheControl: tool.CacheControl,
}
}
return nil
}
return &AnthropicTool{
Expand Down
37 changes: 37 additions & 0 deletions core/providers/anthropic/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -1437,6 +1437,37 @@ func toolsetOnlyComputerUseProvider(provider schemas.ModelProvider) bool {
return provider == schemas.Anthropic || provider == schemas.Vertex
}

// AcceptsComputerToolset reports whether the (provider, model) pair takes a
// computer_toolset_20260801 entry on the wire. Wider than ComputerUseGeneration,
// which answers what to *convert* to: every model here except Opus 5.5 also
// accepts the dated computer_* tools, so a caller that already sent the toolset
// must have it forwarded rather than converted into a form it never asked for.
//
// Override-aware: the datasheet's supports_computer_toolset decides when set, so
// a model gaining the toolset is a catalog change. The provider gate stays in
// code because the toolset is GA on the Claude API and Google Cloud only.
func AcceptsComputerToolset(caps schemas.ModelCaps) bool {
if !toolsetOnlyComputerUseProvider(caps.Provider()) {
return false
}
return caps.SupportsComputerToolset(DefaultSupportsComputerToolset(caps.Model()))
}

// DefaultSupportsComputerToolset is the name-based fallback for
// ModelCaps.SupportsComputerToolset, listing the models the computer-use docs
// publish for the toolset: the Fable/Mythos family, Opus 5.5, Opus 5, Sonnet 5
// and Opus 4.8. Opus 4.7 and older take the dated tool only.
//
// Source: https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool
func DefaultSupportsComputerToolset(model string) bool {
m := strings.ToLower(model)
if IsFableFamily(m) || schemas.IsOpus55Plus(m) || IsOpus5Plus(m) || IsSonnet5Plus(m) {
return true
}
return strings.Contains(m, "opus") &&
(strings.Contains(m, "4-8") || strings.Contains(m, "4.8"))
}

// TextEditorGeneration returns the text_editor tool-version generation for a model.
// Differs from ComputerUseGeneration because Anthropic's per-tool support matrix
// is not always uniform - e.g., sonnet-4-5 supports old-gen computer_20250124 but
Expand Down Expand Up @@ -2396,6 +2427,12 @@ func RemapRawToolVersionsForProvider(jsonBody []byte, provider schemas.ModelProv
if wantType == "" {
continue
}
// The caller already sent a form this pair accepts, so leave it alone.
// Normalizing it into the generation's dated tool would strip the toolset
// the model supports and lose its member-call shape.
if toolType == string(AnthropicToolTypeComputerToolset20260801) && AcceptsComputerToolset(caps) {
continue
}
// A dated computer tool validates display_*_px as >= 1 and requires a name;
// a toolset entry has neither, by design. Rewriting its type would emit an
// entry the API rejects outright, so drop it the way any unsupported tool is
Expand Down
1 change: 1 addition & 0 deletions core/schemas/modelcapabilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type ModelCapabilities struct {
SupportsCodeExecution *bool `json:"supports_code_execution,omitempty"`
SupportsBashTool *bool `json:"supports_bash_tool,omitempty"`
SupportsTextEditorTool *bool `json:"supports_text_editor_tool,omitempty"`
SupportsComputerToolset *bool `json:"supports_computer_toolset,omitempty"` // accepts the computer_toolset_20260801 client toolset; absent ⇒ name detection
SupportsMemoryTool *bool `json:"supports_memory_tool,omitempty"`
SupportsToolSearch *bool `json:"supports_tool_search,omitempty"`
ToolNameMaxLength *int `json:"tool_name_max_length,omitempty"` // longest tool name the wire accepts; absent falls back to the per-provider default in core/providers/utils (64 for OpenAI-compatible wires and Bedrock, 128 for Anthropic and Gemini)
Expand Down
11 changes: 11 additions & 0 deletions core/schemas/modelcaps.go
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,17 @@ func (c ModelCaps) SupportsFilesAPI(fallback bool) bool {
return fallback
}

// SupportsComputerToolset reports whether the model accepts the
// computer_toolset_20260801 client toolset. Distinct from the dated computer_*
// tools: most models that take the toolset still accept the dated form too, and
// Opus 5.5 on the Claude API and Google Cloud takes only the toolset.
func (c ModelCaps) SupportsComputerToolset(fallback bool) bool {
if c.record != nil && c.record.SupportsComputerToolset != nil {
return *c.record.SupportsComputerToolset
}
return fallback
}

// SupportsTextEditorTool reports whether the model accepts the text_editor client tool.
func (c ModelCaps) SupportsTextEditorTool(fallback bool) bool {
if c.record != nil && c.record.SupportsTextEditorTool != nil {
Expand Down
16 changes: 8 additions & 8 deletions tests/e2e/api/collections/provider-harness.json
Original file line number Diff line number Diff line change
Expand Up @@ -166479,7 +166479,7 @@
}
},
{
"name": "Anthropic /v1/responses - dimensionless computer tool dropped for a dated-generation model (anthropic/claude-opus-5) - opus-5-5",
"name": "Anthropic /v1/responses - dimensionless computer tool dropped for a model that takes neither form (anthropic/claude-sonnet-4-6) - opus-5-5",
Comment thread
TejasGhatte marked this conversation as resolved.
Resolved
"event": [
{
"listen": "test",
Expand All @@ -166492,11 +166492,11 @@
"var body = pm.response.json();",
"var raw = (body.extra_fields || {}).raw_request;",
"if (typeof raw === 'string') { raw = JSON.parse(raw); }",
"pm.test('no geometry means no dated tool - dropped, not a zero-sized display', function () {",
"pm.test('no toolset support and no geometry leaves no shape to send - dropped', function () {",
" pm.expect(raw, 'raw request capture missing: ' + pm.response.text()).to.exist;",
" var tools = raw.tools || [];",
" for (var i = 0; i < tools.length; i++) {",
" pm.expect(String(tools[i].type || ''), 'a computer tool with no display must be dropped, it 400s as 0x0').to.not.include('computer');",
" pm.expect(String(tools[i].type || ''), 'this model takes neither the toolset nor a dated tool without geometry').to.not.include('computer');",
" }",
"});"
]
Expand All @@ -166521,7 +166521,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"model\": \"anthropic/claude-opus-5\",\n \"max_output_tokens\": 16,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": \"Reply with the single word: ok\"\n }\n ],\n \"tools\": [\n {\n \"type\": \"computer_use_preview\",\n \"environment\": \"browser\"\n }\n ]\n}"
"raw": "{\n \"model\": \"anthropic/claude-sonnet-4-6\",\n \"max_output_tokens\": 16,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": \"Reply with the single word: ok\"\n }\n ],\n \"tools\": [\n {\n \"type\": \"computer_use_preview\",\n \"environment\": \"browser\"\n }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}/v1/responses",
Expand Down Expand Up @@ -166776,7 +166776,7 @@
}
},
{
"name": "Vertex /v1/responses - dimensionless computer tool dropped for a dated-generation model (vertex/claude-opus-5) - opus-5-5",
"name": "Vertex /v1/responses - dimensionless computer tool dropped for a model that takes neither form (vertex/claude-sonnet-4-6) - opus-5-5",
"event": [
{
"listen": "test",
Expand All @@ -166789,11 +166789,11 @@
"var body = pm.response.json();",
"var raw = (body.extra_fields || {}).raw_request;",
"if (typeof raw === 'string') { raw = JSON.parse(raw); }",
"pm.test('no geometry means no dated tool - dropped, not a zero-sized display', function () {",
"pm.test('no toolset support and no geometry leaves no shape to send - dropped', function () {",
" pm.expect(raw, 'raw request capture missing: ' + pm.response.text()).to.exist;",
" var tools = raw.tools || [];",
" for (var i = 0; i < tools.length; i++) {",
" pm.expect(String(tools[i].type || ''), 'a computer tool with no display must be dropped, it 400s as 0x0').to.not.include('computer');",
" pm.expect(String(tools[i].type || ''), 'this model takes neither the toolset nor a dated tool without geometry').to.not.include('computer');",
" }",
"});"
]
Expand All @@ -166818,7 +166818,7 @@
],
"body": {
"mode": "raw",
"raw": "{\n \"model\": \"vertex/claude-opus-5\",\n \"max_output_tokens\": 16,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": \"Reply with the single word: ok\"\n }\n ],\n \"tools\": [\n {\n \"type\": \"computer_use_preview\",\n \"environment\": \"browser\"\n }\n ]\n}"
"raw": "{\n \"model\": \"vertex/claude-sonnet-4-6\",\n \"max_output_tokens\": 16,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": \"Reply with the single word: ok\"\n }\n ],\n \"tools\": [\n {\n \"type\": \"computer_use_preview\",\n \"environment\": \"browser\"\n }\n ]\n}"
},
"url": {
"raw": "{{baseUrl}}/v1/responses",
Expand Down
Loading