Skip to content

Commit f2a3722

Browse files
authored
fix(anthropic): preserve canonical tool JSON (#186)
1 parent a49b89a commit f2a3722

2 files changed

Lines changed: 42 additions & 56 deletions

File tree

features/model/anthropic/client.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -577,20 +577,21 @@ func toolExampleInput(raw rawjson.Message) (map[string]any, error) {
577577
}
578578

579579
// decodeToolJSONObject converts a canonical raw tool schema or example to the
580-
// SDK document shape without rounding JSON numbers.
580+
// top-level map required by the Anthropic SDK. Raw field values preserve the
581+
// canonical JSON because the SDK's document encoder treats json.Number as a
582+
// string.
581583
func decodeToolJSONObject(data []byte) (map[string]any, error) {
582-
if !json.Valid(data) {
583-
return nil, errors.New("invalid JSON object")
584-
}
585-
var object map[string]any
586-
decoder := json.NewDecoder(bytes.NewReader(data))
587-
decoder.UseNumber()
588-
if err := decoder.Decode(&object); err != nil {
584+
var fields map[string]json.RawMessage
585+
if err := json.Unmarshal(data, &fields); err != nil {
589586
return nil, err
590587
}
591-
if object == nil {
588+
if fields == nil {
592589
return nil, errors.New("JSON value must be an object")
593590
}
591+
object := make(map[string]any, len(fields))
592+
for name, value := range fields {
593+
object[name] = value
594+
}
594595
return object, nil
595596
}
596597

features/model/anthropic/client_test.go

Lines changed: 32 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,15 @@ func TestCountTokensOmitsEmptyTools(t *testing.T) {
297297
}
298298

299299
func TestCountTokensEnablesToolExamplesBeta(t *testing.T) {
300-
var beta string
300+
var (
301+
beta string
302+
body []byte
303+
)
301304
httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
302305
beta = req.Header.Get("anthropic-beta")
306+
var err error
307+
body, err = io.ReadAll(req.Body)
308+
require.NoError(t, err)
303309
return &http.Response{
304310
StatusCode: http.StatusOK,
305311
Header: http.Header{"Content-Type": []string{"application/json"}},
@@ -323,14 +329,36 @@ func TestCountTokensEnablesToolExamplesBeta(t *testing.T) {
323329
Parts: []model.Part{model.TextPart{Text: "hello"}},
324330
}},
325331
Tools: []*model.ToolDefinition{{
326-
Name: "reports.complete",
327-
Description: "Complete a report",
328-
Input: model.ToolInputFromSpec(toolInputExampleSpec()),
332+
Name: "reports.concurrent",
333+
Description: "Check concurrent reports",
334+
Input: model.ToolInputFromSpec(tools.TypeSpec{
335+
Name: "ReportsConcurrentPayload",
336+
Schema: tools.RawJSON(`{"type":"object","properties":{"minimum":{"type":"integer","const":9007199254740993},"ratios":{"type":"array","items":{"type":"number"}}},"required":["minimum","ratios"],"example":{"minimum":9007199254740993,"ratios":[0.25,2]}}`),
337+
SchemaWithoutRootExample: tools.RawJSON(`{"type":"object","properties":{"minimum":{"type":"integer","const":9007199254740993},"ratios":{"type":"array","items":{"type":"number"}}},"required":["minimum","ratios"]}`),
338+
ExampleJSON: tools.RawJSON(`{"minimum":9007199254740993,"ratios":[0.25,2]}`),
339+
}),
329340
}},
330341
})
331342

332343
require.NoError(t, err)
333344
assert.Equal(t, "tool-examples-2025-10-29", beta)
345+
var request struct {
346+
Tools []struct {
347+
InputSchema json.RawMessage `json:"input_schema"`
348+
InputExamples []json.RawMessage `json:"input_examples"`
349+
} `json:"tools"`
350+
}
351+
require.NoError(t, json.Unmarshal(body, &request))
352+
require.Len(t, request.Tools, 1)
353+
require.Len(t, request.Tools[0].InputExamples, 1)
354+
require.JSONEq(t,
355+
`{"type":"object","properties":{"minimum":{"type":"integer","const":9007199254740993},"ratios":{"type":"array","items":{"type":"number"}}},"required":["minimum","ratios"]}`,
356+
string(request.Tools[0].InputSchema),
357+
)
358+
require.JSONEq(t,
359+
`{"minimum":9007199254740993,"ratios":[0.25,2]}`,
360+
string(request.Tools[0].InputExamples[0]),
361+
)
334362
}
335363

336364
// The beta must be opt-in per request: gateways that proxy the Messages API
@@ -552,49 +580,6 @@ func completionParamsFor(t *testing.T, cl *Client, req *model.Request) *sdk.Mess
552580
return params
553581
}
554582

555-
func TestEncodeTools_UsesSchemaWithoutRootExampleAndInputExamples(t *testing.T) {
556-
defs := []*model.ToolDefinition{{
557-
Name: "reports.complete",
558-
Description: "Complete a report",
559-
Input: model.ToolInputFromSpec(toolInputExampleSpec()),
560-
}}
561-
562-
tools, _, _, err := encodeTools(context.Background(), defs, false)
563-
if err != nil {
564-
t.Fatalf("encodeTools: %v", err)
565-
}
566-
if len(tools) != 1 || tools[0].OfTool == nil {
567-
t.Fatalf("expected one encoded tool, got %#v", tools)
568-
}
569-
if len(tools[0].OfTool.InputExamples) != 1 {
570-
t.Fatalf("expected one input example, got %#v", tools[0].OfTool.InputExamples)
571-
}
572-
if got := tools[0].OfTool.InputExamples[0]["summary"]; got != "Done" {
573-
t.Fatalf("unexpected input example summary %v", got)
574-
}
575-
if _, ok := tools[0].OfTool.InputSchema.ExtraFields["example"]; ok {
576-
t.Fatalf("plain schema should not include root example: %#v", tools[0].OfTool.InputSchema.ExtraFields)
577-
}
578-
}
579-
580-
func TestToolInputSchemaPreservesLargeIntegers(t *testing.T) {
581-
schema, err := toolInputSchema(
582-
context.Background(),
583-
rawjson.Message(`{"type":"integer","const":9007199254740993}`),
584-
)
585-
require.NoError(t, err)
586-
require.Equal(t, json.Number("9007199254740993"), schema.ExtraFields["const"])
587-
}
588-
589-
func toolInputExampleSpec() tools.TypeSpec {
590-
return tools.TypeSpec{
591-
Name: "ReportsCompletePayload",
592-
Schema: tools.RawJSON(`{"type":"object","example":{"summary":"Done"}}`),
593-
SchemaWithoutRootExample: tools.RawJSON(`{"type":"object"}`),
594-
ExampleJSON: tools.RawJSON(`{"summary":"Done"}`),
595-
}
596-
}
597-
598583
func TestComplete_RateLimited(t *testing.T) {
599584
stub := &stubMessagesClient{
600585
err: model.ErrRateLimited,

0 commit comments

Comments
 (0)