github.com/anthropics/anthropic-sdk-go@v1.70.1/schemautil.go (about) 1 package anthropic 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "reflect" 7 "slices" 8 "sort" 9 "strings" 10 11 "github.com/invopop/jsonschema" 12 orderedmap "github.com/pb33f/ordered-map/v2" 13 ) 14 15 // supportedSchemaKeySet is a set version of supportedSchemaKeys for O(1) lookups. 16 var supportedSchemaKeySet = func() map[string]bool { 17 m := make(map[string]bool, len(supportedSchemaKeys)) 18 for _, k := range supportedSchemaKeys { 19 m[k] = true 20 } 21 return m 22 }() 23 24 // transformSchema transforms a [jsonschema.Schema] in-place to ensure it 25 // conforms to the Anthropic API's expectations. 26 // 27 // The transformation process: 28 // - Preserves $ref references 29 // - Transforms $defs, anyOf, and allOf recursively 30 // - Handles oneOf by converting it to anyOf 31 // - Ensures objects have additionalProperties: false 32 // - Filters string formats to only supported ones 33 // - Limits array minItems to 0 or 1 34 // - Appends unsupported properties to the description 35 func transformSchema(s *jsonschema.Schema) { 36 if s == nil { 37 return 38 } 39 40 // $ref is not supported alongside other properties 41 if s.Ref != "" { 42 *s = jsonschema.Schema{Ref: s.Ref} 43 return 44 } 45 46 // Collect and clear fields not in the supported set 47 extras := extractUnsupportedFields(s) 48 49 // Transform $defs recursively 50 for _, def := range s.Definitions { 51 transformSchema(def) 52 } 53 54 // Convert oneOf to anyOf before recursing so variants are transformed once. 55 if len(s.OneOf) > 0 && len(s.AnyOf) == 0 { 56 s.AnyOf = s.OneOf 57 } 58 s.OneOf = nil 59 60 // Recurse into anyOf variants, dropping any that transformSchema zeroed out 61 // as invalid — a zero jsonschema.Schema marshals as the literal JSON `true`, 62 // which would otherwise leak into the variant list as a match-everything. 63 if len(s.AnyOf) > 0 { 64 kept := s.AnyOf[:0] 65 for _, variant := range s.AnyOf { 66 transformSchema(variant) 67 if variant != nil && !reflect.ValueOf(*variant).IsZero() { 68 kept = append(kept, variant) 69 } 70 } 71 s.AnyOf = kept 72 } 73 74 // Recurse into allOf variants. Unlike anyOf, a zeroed-out (`true`) variant 75 // adds no constraint to a conjunction, so there's no need to prune. 76 for _, variant := range s.AllOf { 77 transformSchema(variant) 78 } 79 80 // Bail if the schema carries no shape information — schema is invalid or a 81 // boolean schema. enum/const/allOf can all stand in for an explicit type. 82 // Boolean schemas (JSON true/false) carry meaning in an unexported field 83 // that zeroing would clear, flipping false→true. Detect them by checking 84 // whether any exported field is non-zero: if not, the schema is either 85 // boolean (preserve) or truly empty (zeroing is a no-op anyway). 86 if s.Type == "" && len(s.AnyOf) == 0 && len(s.AllOf) == 0 && len(s.Enum) == 0 && s.Const == nil { 87 if !hasExportedContent(s) { 88 return 89 } 90 *s = jsonschema.Schema{} 91 return 92 } 93 94 switch s.Type { 95 case "object": 96 if s.Properties != nil { 97 for pair := s.Properties.Oldest(); pair != nil; pair = pair.Next() { 98 transformSchema(pair.Value) 99 } 100 s.AdditionalProperties = jsonschema.FalseSchema 101 } else if s.AdditionalProperties != nil { 102 // Dictionary-style schema (e.g. Go map types): no fixed properties, 103 // additionalProperties describes the value type. Preserve and recurse. 104 transformSchema(s.AdditionalProperties) 105 } else { 106 s.Properties = orderedmap.New[string, *jsonschema.Schema]() 107 s.AdditionalProperties = jsonschema.FalseSchema 108 } 109 110 case "string": 111 if s.Format != "" && !slices.Contains(supportedStringFormats, s.Format) { 112 extras["format"] = s.Format 113 s.Format = "" 114 } 115 116 case "array": 117 if s.Items != nil { 118 transformSchema(s.Items) 119 } 120 if s.MinItems != nil && *s.MinItems != 0 && *s.MinItems != 1 { 121 extras["minItems"] = *s.MinItems 122 s.MinItems = nil 123 } 124 } 125 126 // Append unsupported properties to description 127 if len(extras) > 0 { 128 keys := make([]string, 0, len(extras)) 129 for k := range extras { 130 keys = append(keys, k) 131 } 132 sort.Strings(keys) 133 134 parts := make([]string, 0, len(keys)) 135 for _, k := range keys { 136 parts = append(parts, fmt.Sprintf("%s: %s", k, formatExtraValue(extras[k]))) 137 } 138 139 extraStr := "{" + strings.Join(parts, ", ") + "}" 140 if s.Description != "" { 141 s.Description += "\n\n" + extraStr 142 } else { 143 s.Description = extraStr 144 } 145 } 146 } 147 148 // transformSchemaMap transforms a JSON schema map to conform to Anthropic's API 149 // requirements. It delegates to [transformSchema] via a JSON round-trip. 150 func transformSchemaMap(jsonSchema map[string]any) map[string]any { 151 if jsonSchema == nil { 152 return nil 153 } 154 155 b, err := json.Marshal(jsonSchema) 156 if err != nil { 157 return nil 158 } 159 160 var s jsonschema.Schema 161 if err := json.Unmarshal(b, &s); err != nil { 162 return nil 163 } 164 165 transformSchema(&s) 166 167 result, err := json.Marshal(&s) 168 if err != nil { 169 return nil 170 } 171 172 var out map[string]any 173 if err := json.Unmarshal(result, &out); err != nil { 174 return nil 175 } 176 return out 177 } 178 179 // formatExtraValue renders a value extracted from the schema for inclusion in 180 // the description. Composite values (*Schema, []*Schema, maps) are 181 // JSON-marshaled so they render readably instead of as Go pointer dumps. 182 // Scalars use %v so strings stay unquoted. Pointers are dereferenced first: 183 // many keywords (maxLength, maxItems, ...) are pointer-typed on the schema 184 // struct, and %v on a pointer prints its address. 185 func formatExtraValue(v any) string { 186 rv := reflect.ValueOf(v) 187 for rv.Kind() == reflect.Pointer { 188 if rv.IsNil() { 189 return "null" 190 } 191 rv = rv.Elem() 192 } 193 // A JSON null in Extras arrives as an untyped nil, which has no Kind. 194 if !rv.IsValid() { 195 return "null" 196 } 197 switch rv.Kind() { 198 case reflect.Slice, reflect.Array, reflect.Map, reflect.Struct: 199 if b, err := json.Marshal(v); err == nil { 200 return string(b) 201 } 202 } 203 return fmt.Sprintf("%v", rv.Interface()) 204 } 205 206 // hasExportedContent reports whether any exported field on the schema is non-zero. 207 // Boolean schemas (JSON true/false) store their value in an unexported field, so 208 // they return false here despite being meaningful. 209 func hasExportedContent(s *jsonschema.Schema) bool { 210 v := reflect.ValueOf(s).Elem() 211 t := v.Type() 212 for i := range t.NumField() { 213 if t.Field(i).IsExported() && !v.Field(i).IsZero() { 214 return true 215 } 216 } 217 return false 218 } 219 220 // extractUnsupportedFields uses reflection to find non-zero exported fields on 221 // the schema whose JSON key is not in the supported set. It collects their 222 // values into a map and zeros the fields, so they won't appear in the marshaled 223 // output. This avoids enumerating every field explicitly and automatically 224 // handles new fields added to the jsonschema library. 225 func extractUnsupportedFields(s *jsonschema.Schema) map[string]any { 226 extras := make(map[string]any) 227 v := reflect.ValueOf(s).Elem() 228 t := v.Type() 229 230 for i := range t.NumField() { 231 field := t.Field(i) 232 if !field.IsExported() { 233 continue 234 } 235 236 jsonTag := field.Tag.Get("json") 237 name, _, _ := strings.Cut(jsonTag, ",") 238 if name == "" || name == "-" { 239 continue 240 } 241 242 if supportedSchemaKeySet[name] { 243 continue 244 } 245 246 fv := v.Field(i) 247 if fv.IsZero() { 248 continue 249 } 250 251 extras[name] = fv.Interface() 252 fv.SetZero() 253 } 254 255 // Handle the Extras map (tagged json:"-", inlined by Schema.MarshalJSON) 256 for k, val := range s.Extras { 257 if !supportedSchemaKeySet[k] { 258 extras[k] = val 259 } 260 } 261 s.Extras = nil 262 263 return extras 264 } 265 266 // BetaJSONSchemaOutputFormat creates a BetaJSONOutputFormatParam from a JSON schema map. 267 // It transforms the schema to ensure compatibility with Anthropic's JSON schema requirements. 268 // 269 // Example: 270 // 271 // schema := map[string]any{ 272 // "type": "object", 273 // "properties": map[string]any{ 274 // "name": map[string]any{"type": "string"}, 275 // "age": map[string]any{"type": "integer", "minimum": 0}, 276 // }, 277 // "required": []string{"name"}, 278 // } 279 // outputFormat := BetaJSONSchemaOutputFormat(schema) 280 // 281 // msg, _ := client.Beta.Messages.New(ctx, anthropic.BetaMessageNewParams{ 282 // Model: anthropic.Model("claude-sonnet-4-5"), 283 // Messages: anthropic.F([]anthropic.BetaMessageParam{...}), 284 // MaxTokens: 1024, 285 // OutputFormat: outputFormat, 286 // }) 287 func BetaJSONSchemaOutputFormat(jsonSchema map[string]any) BetaJSONOutputFormatParam { 288 return BetaJSONOutputFormatParam{Schema: transformSchemaMap(jsonSchema)} 289 } 290 291 // BetaToolInputSchema creates a BetaToolInputSchemaParam from a JSON schema map. 292 // It transforms the schema to ensure compatibility with Anthropic's tool calling requirements. 293 func BetaToolInputSchema(jsonSchema map[string]any) BetaToolInputSchemaParam { 294 return BetaToolInputSchemaParam{ExtraFields: transformSchemaMap(jsonSchema)} 295 } 296 297 var supportedStringFormats = []string{ 298 "date-time", 299 "time", 300 "date", 301 "duration", 302 "email", 303 "hostname", 304 "uri", 305 "ipv4", 306 "ipv6", 307 "uuid", 308 } 309 var supportedSchemaKeys = []string{ 310 // Top-level schema keys 311 "$ref", 312 "$defs", 313 "type", 314 "anyOf", 315 "oneOf", 316 "allOf", 317 "description", 318 "title", 319 320 // Value constraints 321 "enum", 322 "const", 323 324 // Object-specific keys 325 "properties", 326 "additionalProperties", 327 "required", 328 329 // Array-specific keys 330 "items", 331 "minItems", 332 333 // String-specific keys 334 "format", 335 "pattern", 336 }