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  }