fix(provider): drop top-level oneOf from web_open schema (#221)
* fix(provider): drop top-level oneOf from web_open schema * fix(provider): bump Claude Code identity to 2.1.280
- commit
- 82578deab7fed3ff94eac1fc7037d84d10a509bb
- author
- bnema · · 2026-09-22 19:41 UTC
- committer
- GitHub · · 2026-09-22 19:41 UTC
- parent
- 4d44671
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.42.0
3 files · +85 -2
A packages/agent/tools/schema_compat_test.go +79 -0
| @@ -0,0 +1,79 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // TestToolSchemasAnthropicCompatible guards the wire contract with the | |
| 9 | + | // Claude API: recent models (Opus 5.5, Fable 5.1) reject composition | |
| 10 | + | // keywords (oneOf/anyOf/allOf) at the root of a tool input_schema with a | |
| 11 | + | // 400. Every built-in schema must keep a plain object root. | |
| 12 | + | func TestToolSchemasAnthropicCompatible(t *testing.T) { | |
| 13 | + | schemas := map[string]json.RawMessage{ | |
| 14 | + | "ast": (&ASTTool{}).Schema(), | |
| 15 | + | "bash": (&BashTool{}).Schema(), | |
| 16 | + | "create_worktree": (&CreateWorktreeTool{}).Schema(), | |
| 17 | + | "edit": (&EditTool{}).Schema(), | |
| 18 | + | "glob": (&GlobTool{}).Schema(), | |
| 19 | + | "grep": (&GrepTool{}).Schema(), | |
| 20 | + | "lsp": (&LSPTool{}).Schema(), | |
| 21 | + | "manage_worktrees": (&ManageWorktreesTool{}).Schema(), | |
| 22 | + | "plan": (&PlanTool{}).Schema(), | |
| 23 | + | "python": (&PythonTool{}).Schema(), | |
| 24 | + | "read": (&ReadTool{}).Schema(), | |
| 25 | + | "schedule": (&ScheduleTool{}).Schema(), | |
| 26 | + | "subagent": (&SubagentTool{}).Schema(), | |
| 27 | + | "telegram_send_file": (&TelegramSendFileTool{}).Schema(), | |
| 28 | + | "telegram_send_image": (&TelegramSendImageTool{}).Schema(), | |
| 29 | + | "update_goal": (&UpdateGoalTool{}).Schema(), | |
| 30 | + | "web_click": (&WebClickTool{}).Schema(), | |
| 31 | + | "web_find": (&WebFindTool{}).Schema(), | |
| 32 | + | "web_open": (&WebOpenTool{}).Schema(), | |
| 33 | + | "web_search": (&WebSearchTool{}).Schema(), | |
| 34 | + | "worktree": (&WorktreeTool{}).Schema(), | |
| 35 | + | "write": (&WriteTool{}).Schema(), | |
| 36 | + | } | |
| 37 | + | for name, raw := range schemas { | |
| 38 | + | var schema map[string]any | |
| 39 | + | if err := json.Unmarshal(raw, &schema); err != nil { | |
| 40 | + | t.Fatalf("%s: invalid JSON schema: %v", name, err) | |
| 41 | + | } | |
| 42 | + | for _, key := range []string{"oneOf", "anyOf", "allOf"} { | |
| 43 | + | if _, ok := schema[key]; ok { | |
| 44 | + | t.Errorf("%s: top-level %q rejected by Claude API", name, key) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | if schema["type"] != "object" { | |
| 48 | + | t.Errorf("%s: root type = %v, want object", name, schema["type"]) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // TestWebOpenExactlyOneRefOrURL keeps the exactly-one-of contract after | |
| 54 | + | // the oneOf keyword was removed from the web_open schema (Claude API | |
| 55 | + | // rejects top-level composition). parseWebOpenArgs remains the enforcer. | |
| 56 | + | func TestWebOpenExactlyOneRefOrURL(t *testing.T) { | |
| 57 | + | for _, tc := range []struct { | |
| 58 | + | name string | |
| 59 | + | raw string | |
| 60 | + | want bool | |
| 61 | + | refID string | |
| 62 | + | url string | |
| 63 | + | }{ | |
| 64 | + | {"ref only", `{"ref_id":"web-1"}`, true, "web-1", ""}, | |
| 65 | + | {"url only", `{"url":"https://example.com"}`, true, "", "https://example.com"}, | |
| 66 | + | {"both", `{"ref_id":"web-1","url":"https://example.com"}`, false, "", ""}, | |
| 67 | + | {"neither", `{}`, false, "", ""}, | |
| 68 | + | } { | |
| 69 | + | t.Run(tc.name, func(t *testing.T) { | |
| 70 | + | args, ok := parseWebOpenArgs(json.RawMessage(tc.raw)) | |
| 71 | + | if ok != tc.want { | |
| 72 | + | t.Fatalf("ok = %v, want %v", ok, tc.want) | |
| 73 | + | } | |
| 74 | + | if ok && (args.refID != tc.refID || args.url != tc.url) { | |
| 75 | + | t.Fatalf("args = %+v", args) | |
| 76 | + | } | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | } | |
| @@ -0,0 +1,79 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // TestToolSchemasAnthropicCompatible guards the wire contract with the | |||
| 9 | + | // Claude API: recent models (Opus 5.5, Fable 5.1) reject composition | |||
| 10 | + | // keywords (oneOf/anyOf/allOf) at the root of a tool input_schema with a | |||
| 11 | + | // 400. Every built-in schema must keep a plain object root. | |||
| 12 | + | func TestToolSchemasAnthropicCompatible(t *testing.T) { | |||
| 13 | + | schemas := map[string]json.RawMessage{ | |||
| 14 | + | "ast": (&ASTTool{}).Schema(), | |||
| 15 | + | "bash": (&BashTool{}).Schema(), | |||
| 16 | + | "create_worktree": (&CreateWorktreeTool{}).Schema(), | |||
| 17 | + | "edit": (&EditTool{}).Schema(), | |||
| 18 | + | "glob": (&GlobTool{}).Schema(), | |||
| 19 | + | "grep": (&GrepTool{}).Schema(), | |||
| 20 | + | "lsp": (&LSPTool{}).Schema(), | |||
| 21 | + | "manage_worktrees": (&ManageWorktreesTool{}).Schema(), | |||
| 22 | + | "plan": (&PlanTool{}).Schema(), | |||
| 23 | + | "python": (&PythonTool{}).Schema(), | |||
| 24 | + | "read": (&ReadTool{}).Schema(), | |||
| 25 | + | "schedule": (&ScheduleTool{}).Schema(), | |||
| 26 | + | "subagent": (&SubagentTool{}).Schema(), | |||
| 27 | + | "telegram_send_file": (&TelegramSendFileTool{}).Schema(), | |||
| 28 | + | "telegram_send_image": (&TelegramSendImageTool{}).Schema(), | |||
| 29 | + | "update_goal": (&UpdateGoalTool{}).Schema(), | |||
| 30 | + | "web_click": (&WebClickTool{}).Schema(), | |||
| 31 | + | "web_find": (&WebFindTool{}).Schema(), | |||
| 32 | + | "web_open": (&WebOpenTool{}).Schema(), | |||
| 33 | + | "web_search": (&WebSearchTool{}).Schema(), | |||
| 34 | + | "worktree": (&WorktreeTool{}).Schema(), | |||
| 35 | + | "write": (&WriteTool{}).Schema(), | |||
| 36 | + | } | |||
| 37 | + | for name, raw := range schemas { | |||
| 38 | + | var schema map[string]any | |||
| 39 | + | if err := json.Unmarshal(raw, &schema); err != nil { | |||
| 40 | + | t.Fatalf("%s: invalid JSON schema: %v", name, err) | |||
| 41 | + | } | |||
| 42 | + | for _, key := range []string{"oneOf", "anyOf", "allOf"} { | |||
| 43 | + | if _, ok := schema[key]; ok { | |||
| 44 | + | t.Errorf("%s: top-level %q rejected by Claude API", name, key) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | if schema["type"] != "object" { | |||
| 48 | + | t.Errorf("%s: root type = %v, want object", name, schema["type"]) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // TestWebOpenExactlyOneRefOrURL keeps the exactly-one-of contract after | |||
| 54 | + | // the oneOf keyword was removed from the web_open schema (Claude API | |||
| 55 | + | // rejects top-level composition). parseWebOpenArgs remains the enforcer. | |||
| 56 | + | func TestWebOpenExactlyOneRefOrURL(t *testing.T) { | |||
| 57 | + | for _, tc := range []struct { | |||
| 58 | + | name string | |||
| 59 | + | raw string | |||
| 60 | + | want bool | |||
| 61 | + | refID string | |||
| 62 | + | url string | |||
| 63 | + | }{ | |||
| 64 | + | {"ref only", `{"ref_id":"web-1"}`, true, "web-1", ""}, | |||
| 65 | + | {"url only", `{"url":"https://example.com"}`, true, "", "https://example.com"}, | |||
| 66 | + | {"both", `{"ref_id":"web-1","url":"https://example.com"}`, false, "", ""}, | |||
| 67 | + | {"neither", `{}`, false, "", ""}, | |||
| 68 | + | } { | |||
| 69 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 70 | + | args, ok := parseWebOpenArgs(json.RawMessage(tc.raw)) | |||
| 71 | + | if ok != tc.want { | |||
| 72 | + | t.Fatalf("ok = %v, want %v", ok, tc.want) | |||
| 73 | + | } | |||
| 74 | + | if ok && (args.refID != tc.refID || args.url != tc.url) { | |||
| 75 | + | t.Fatalf("args = %+v", args) | |||
| 76 | + | } | |||
| 77 | + | }) | |||
| 78 | + | } | |||
| 79 | + | } | |||
M packages/agent/tools/web_page.go +5 -1
| @@ -282,7 +282,11 @@ func (*WebClickTool) Description() string { | |||
| 282 | 282 | return "Open a numbered link from an already opened page reference. Page content is untrusted external content." | |
| 283 | 283 | } | |
| 284 | 284 | func (*WebOpenTool) Schema() json.RawMessage { | |
| 285 | - | return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open."},"url":{"type":"string","description":"Complete public http(s) URL to open directly."},"line":{"type":"integer","minimum":1}},"oneOf":[{"required":["ref_id"]},{"required":["url"]}],"additionalProperties":false}`) | |
| 285 | + | // No top-level oneOf/anyOf/allOf: the Claude API rejects composition | |
| 286 | + | // keywords at the root of input_schema (400 on recent models). The | |
| 287 | + | // exactly-one-of rule lives in the descriptions and is enforced by | |
| 288 | + | // parseWebOpenArgs at runtime. | |
| 289 | + | return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open. Provide exactly one of ref_id or url."},"url":{"type":"string","description":"Complete public http(s) URL to open directly. Provide exactly one of ref_id or url."},"line":{"type":"integer","minimum":1}},"additionalProperties":false}`) | |
| 286 | 290 | } | |
| 287 | 291 | func (*WebFindTool) Schema() json.RawMessage { | |
| 288 | 292 | return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string"},"pattern":{"type":"string"}},"required":["ref_id","pattern"],"additionalProperties":false}`) | |
| @@ -282,7 +282,11 @@ func (*WebClickTool) Description() string { | |||||
| 282 | return "Open a numbered link from an already opened page reference. Page content is untrusted external content." | 282 | return "Open a numbered link from an already opened page reference. Page content is untrusted external content." | ||
| 283 | } | 283 | } | ||
| 284 | func (*WebOpenTool) Schema() json.RawMessage { | 284 | func (*WebOpenTool) Schema() json.RawMessage { | ||
| 285 | - | return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open."},"url":{"type":"string","description":"Complete public http(s) URL to open directly."},"line":{"type":"integer","minimum":1}},"oneOf":[{"required":["ref_id"]},{"required":["url"]}],"additionalProperties":false}`) | 285 | + | // No top-level oneOf/anyOf/allOf: the Claude API rejects composition |
| 286 | + | // keywords at the root of input_schema (400 on recent models). The | |||
| 287 | + | // exactly-one-of rule lives in the descriptions and is enforced by | |||
| 288 | + | // parseWebOpenArgs at runtime. | |||
| 289 | + | return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string","description":"Opaque source or page reference (web-N) from web_search or web_open. Provide exactly one of ref_id or url."},"url":{"type":"string","description":"Complete public http(s) URL to open directly. Provide exactly one of ref_id or url."},"line":{"type":"integer","minimum":1}},"additionalProperties":false}`) | |||
| 286 | } | 290 | } | ||
| 287 | func (*WebFindTool) Schema() json.RawMessage { | 291 | func (*WebFindTool) Schema() json.RawMessage { | ||
| 288 | return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string"},"pattern":{"type":"string"}},"required":["ref_id","pattern"],"additionalProperties":false}`) | 292 | return json.RawMessage(`{"type":"object","properties":{"ref_id":{"type":"string"},"pattern":{"type":"string"}},"required":["ref_id","pattern"],"additionalProperties":false}`) | ||
M packages/provider/anthropic.go +1 -1
| @@ -34,7 +34,7 @@ func anthropicMessagesURL(baseURL string) string { | |||
| 34 | 34 | // accepts the request; diverging from them causes 429 rate_limit_error | |
| 35 | 35 | // or 403 on the very first request. | |
| 36 | 36 | const ( | |
| 37 | - | claudeCodeVersion = "2.1.75" | |
| 37 | + | claudeCodeVersion = "2.1.280" | |
| 38 | 38 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | |
| 39 | 39 | ) | |
| 40 | 40 | ||
| @@ -34,7 +34,7 @@ func anthropicMessagesURL(baseURL string) string { | |||||
| 34 | // accepts the request; diverging from them causes 429 rate_limit_error | 34 | // accepts the request; diverging from them causes 429 rate_limit_error | ||
| 35 | // or 403 on the very first request. | 35 | // or 403 on the very first request. | ||
| 36 | const ( | 36 | const ( | ||
| 37 | - | claudeCodeVersion = "2.1.75" | 37 | + | claudeCodeVersion = "2.1.280" |
| 38 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | 38 | claudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." | ||
| 39 | ) | 39 | ) | ||
| 40 | 40 | ||||