anthropics / anthropics/anthropic-sdk-go
transformSchema: a zeroed subschema leaks out of properties/items/additionalProperties/$defs as the literal `true` (the anyOf guard covers only anyOf)
- Lingua principale
- Go
- Stelle
- 1.2k
- Fork
- 213
- Merge medio
- 1g 12h
- PR unite (30g)
- 11
Descrizione
### Summary
`transformSchema` bails on a node it cannot key on by assigning the zero `jsonschema.Schema` (`schemautil.go:85-91`). A zero schema marshals as the literal JSON `true` — a match-anything schema. The SDK already knows this: the `anyOf` recursion prunes zeroed variants, with a comment saying so verbatim:
```go
// Recurse into anyOf variants, dropping any that transformSchema zeroed out
// as invalid — a zero jsonschema.Schema marshals as the literal JSON `true`,
// which would otherwise leak into the variant list as a match-everything.
```
That guard is correct and it is applied to exactly one container. The same value leaks out of `properties`, `items`, `additionalProperties` and `$defs`, and at the root it takes the whole schema with it.
Measured on `v1.62.0`, Go 1.26.1. Every line below is the output of the program at the bottom, run as written. No API key needed — `BetaJSONSchemaOutputFormat` and `BetaToolInputSchema` produce identical output here, so this is checkable offline.
| input | output |
|---|---|
| `{"type":"object","additionalProperties":{"not":{}}}` | `{"type":"object","additionalProperties":true}` |
| `{"type":"object","additionalProperties":{}}` *(control)* | `{"type":"object","additionalProperties":true}` |
| `{"type":"object","additionalProperties":{"type":"string"}}` *(control)* | preserved ✅ |
| `{"type":"object","properties":{"a":{"minLength":3}}}` | `{"properties":{"a":true},…}` |
| `{"type":"array","items":{"minLength":3}}` | `{"items":true,"type":"array"}` |
| `$defs.T = {"minLength":3}`, live `$ref` to it | `{"$defs":{"T":true},…}` — the `$ref` still resolves, to match-anything |
| `anyOf:[{"minLength":3},{"type":"integer"}]` | variant pruned ✅ *(the guard)* |
| root `{"minLength":3}` | **`null`** |
### Why the `additionalProperties` row is the one worth fixing first
The dictionary clause (`schemautil.go:100-103`) is a deliberate, correct special case — it is why this SDK preserves a Go `map[string]T` where the TypeScript and Python `output_format` transformers rebuild the node as `{"type":"object","properties":{},"additionalProperties":false}`. But it *recurses into the value schema*, and that recursion hands the value to the same bail as everything else. So the map is preserved and its value type is deleted.
Reachability is not hypothetical: `z.record(z.string(), z.never())` on `zod@4.4.3` emits
```json
{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"not":{}}}
```
verbatim. That is a map for which **no** value is legal. It comes back as a map for which **every** value is legal — a complete inversion, not a loss of enforcement. Any `BetaToolInputSchema` caller forwarding a schema it did not author (an MCP host, a tool registry, a gateway) can hit this.
The reason it is hard to notice from the outside: the destroyed output is **byte-identical** to the output for a genuinely unconstrained map. `z.record(z.string(), z.unknown())` emits `additionalProperties: {}`, and `{}` and `true` are the same schema, so `true` is a faithful rendering there. Nothing downstream can distinguish a correct rendering from a destroyed one.
### The root case is a separate, larger failure
`transformSchemaMap` marshals the transformed schema and unmarshals it back into `map[string]any` (`schemautil.go:167-175`). When the root zeroes out, `json.Marshal` yields `true`, and `json.Unmarshal("true", &map[string]any{})` fails, so the function hits `return nil`. The caller gets `BetaJSONOutputFormatParam{Schema: nil}` and the request is built with no schema at all, with no error anywhere. A root carrying only `{"description":"..."}` does this.
### Suggested shape of a fix
The `anyOf` guard is the right idea; it is the *placement* that is narrow. Two options, and they are not equivalent:
1. **Detect at the bail rather than at each call site.** Have `transformSchema` report that it zeroed the node (return a bool, or take a pointer to a flag), and let each recursion site decide. That is one change instead of five, and it makes the root case reachable too.
2. **Return an error.** For `additionalProperties` and `items` there is no "prune" that preserves meaning — dropping `items` widens the array, dropping `additionalProperties` widens the map — so silently continuing is a choice about *which* wrong answer to give. Erroring is arguably right here, since the caller supplied a constraint the SDK cannot represent.
Worth noting the existing guard is not free either: pruning `{"minLength":3}` out of a union silently deletes a legal branch, which narrows the union. Better than leaking a match-everything, but still a silent semantic change — a caller who wanted to know would rather be told.
### Two warnings about the regression test
- **Accept/reject cannot see this bug.** Nothing raises today and nothing raises after a fix that only changes output shape. The test has to assert on the returned map. `additionalProperties` must not be `true` when the input's value schema had keys.
- **A test with a typed value schema passes either way.** `{"additionalProperties":{"type":"string"}}` is preserved before and after — it is the control, not the case. The failing case needs a value schema with no `type` and no `anyOf`/`allOf`/`enum`/`const`; `{"not":{}}` is the one a real generator produces.
### Repro
```go
package main
import (
"encoding/json"
"fmt"
"github.com/anthropics/anthropic-sdk-go"
)
func show(label string, in map[string]any) {
out, _ := json.Marshal(anthropic.BetaJSONSchemaOutputFormat(in).Schema)
fmt.Printf("%-24s %s\n", label, string(out))
}
func main() {
show("map/never", map[string]any{
"type": "object",
"additionalProperties": map[string]any{"not": map[string]any{}},
})
show("map/unknown (control)", map[string]any{
"type": "object", "additionalProperties": map[string]any{},
})
show("map/typed (control)", map[string]any{
"type": "object", "additionalProperties": map[string]any{"type": "string"},
})
show("properties", map[string]any{
"type": "object", "properties": map[string]any{"a": map[string]any{"minLength": 3}},
})
show("items", map[string]any{
"type": "array", "items": map[string]any{"minLength": 3},
})
show("$defs (ref stays live)", map[string]any{
"type": "object",
"properties": map[string]any{"a": map[string]any{"$ref": "#/$defs/T"}},
"$defs": map[string]any{"T": map[string]any{"minLength": 3}},
})
show("anyOf (guarded)", map[string]any{
"type": "object",
"properties": map[string]any{"a": map[string]any{
"anyOf": []any{map[string]any{"minLength": 3}, map[string]any{"type": "integer"}}}},
})
show("root", map[string]any{"minLength": 3})
}
```
```
map/never {"additionalProperties":true,"type":"object"}
map/unknown (control) {"additionalProperties":true,"type":"object"}
map/typed (control) {"additionalProperties":{"type":"string"},"type":"object"}
properties {"additionalProperties":false,"properties":{"a":true},"type":"object"}
items {"items":true,"type":"array"}
$defs (ref stays live) {"$defs":{"T":true},"additionalProperties":false,"properties":{"a":{"$ref":"#/$defs/T"}},"type":"object"}
anyOf (guarded) {"additionalProperties":false,"properties":{"a":{"anyOf":[{"type":"integer"}]}},"type":"object"}
root null
```
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.