riffer 0.52.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/rbs-inline.md +2 -2
  3. data/.release-please-manifest.json +1 -1
  4. data/CHANGELOG.md +22 -0
  5. data/Steepfile +19 -0
  6. data/docs/AGENTS.md +81 -4
  7. data/docs/CATALOG.md +205 -0
  8. data/docs/CONFIGURATION.md +35 -23
  9. data/docs/MESSAGES.md +1 -1
  10. data/docs/OVERVIEW.md +1 -1
  11. data/docs/SERIALIZATION.md +1 -0
  12. data/docs/TOOLS.md +15 -12
  13. data/docs/TRACING.md +2 -1
  14. data/docs/providers/AMAZON_BEDROCK.md +2 -0
  15. data/docs/providers/ANTHROPIC.md +2 -0
  16. data/docs/providers/AZURE_OPENAI.md +2 -0
  17. data/docs/providers/CUSTOM_PROVIDERS.md +15 -1
  18. data/docs/providers/GEMINI.md +11 -0
  19. data/docs/providers/MOCK_PROVIDER.md +2 -0
  20. data/docs/providers/OPENAI.md +2 -0
  21. data/docs/providers/OPENROUTER.md +2 -0
  22. data/docs-site/manifest.yml +3 -0
  23. data/lib/riffer/agent/config.rb +17 -0
  24. data/lib/riffer/agent/run.rb +2 -0
  25. data/lib/riffer/agent/serializer.rb +2 -0
  26. data/lib/riffer/agent/structured_output.rb +23 -3
  27. data/lib/riffer/agent.rb +7 -0
  28. data/lib/riffer/{config/pricing → catalog}/rates.rb +21 -6
  29. data/lib/riffer/catalog.rb +225 -0
  30. data/lib/riffer/config/pricing.rb +17 -27
  31. data/lib/riffer/config.rb +29 -0
  32. data/lib/riffer/params/any_of.rb +22 -0
  33. data/lib/riffer/params/format.rb +73 -0
  34. data/lib/riffer/params/param.rb +120 -17
  35. data/lib/riffer/params.rb +135 -24
  36. data/lib/riffer/providers/amazon_bedrock.rb +14 -0
  37. data/lib/riffer/providers/anthropic.rb +8 -0
  38. data/lib/riffer/providers/base.rb +67 -15
  39. data/lib/riffer/providers/gemini.rb +7 -0
  40. data/lib/riffer/providers/mock.rb +7 -0
  41. data/lib/riffer/providers/open_ai.rb +6 -0
  42. data/lib/riffer/providers/open_router.rb +6 -0
  43. data/lib/riffer/version.rb +1 -1
  44. data/lib/riffer.rb +2 -0
  45. data/rbs_collection.lock.yaml +8 -0
  46. data/rbs_collection.yaml +2 -0
  47. data/sig/generated/riffer/agent/config.rbs +10 -1
  48. data/sig/generated/riffer/agent/structured_output.rbs +6 -0
  49. data/sig/generated/riffer/agent.rbs +4 -0
  50. data/sig/generated/riffer/{config/pricing → catalog}/rates.rbs +10 -4
  51. data/sig/generated/riffer/catalog.rbs +77 -0
  52. data/sig/generated/riffer/config/pricing.rbs +12 -11
  53. data/sig/generated/riffer/config.rbs +17 -0
  54. data/sig/generated/riffer/params/any_of.rbs +13 -0
  55. data/sig/generated/riffer/params/format.rbs +39 -0
  56. data/sig/generated/riffer/params/param.rbs +22 -4
  57. data/sig/generated/riffer/params.rbs +34 -8
  58. data/sig/generated/riffer/providers/amazon_bedrock.rbs +6 -0
  59. data/sig/generated/riffer/providers/anthropic.rbs +4 -0
  60. data/sig/generated/riffer/providers/base.rbs +22 -2
  61. data/sig/generated/riffer/providers/gemini.rbs +4 -0
  62. data/sig/generated/riffer/providers/mock.rbs +5 -0
  63. data/sig/generated/riffer/providers/open_ai.rbs +4 -0
  64. data/sig/generated/riffer/providers/open_router.rbs +4 -0
  65. data/sig/generated/riffer.rbs +1 -0
  66. data/sig/manifest.yaml +2 -1
  67. data/sig/manual/riffer/params/format.rbs +5 -0
  68. metadata +11 -3
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "date"
5
+ require "ipaddr"
6
+
7
+ module Riffer::Params::Format
8
+ extend self
9
+
10
+ DATE = /\A(\d{4})-(\d{2})-(\d{2})\z/ #: Regexp
11
+
12
+ DATE_TIME = /\A(\d{4})-(\d{2})-(\d{2})[Tt]([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)\z/ #: Regexp
13
+
14
+ # The offset is optional: Anthropic's grammar emits times without one.
15
+ TIME = /\A([01]\d|2[0-3]):[0-5]\d:([0-5]\d|60)(\.\d+)?([Zz]|[+-]([01]\d|2[0-3]):[0-5]\d)?\z/ #: Regexp
16
+
17
+ DURATION = /\AP(?!\z)((\d+Y)?(\d+M)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+S)?)?|\d+W)\z/ #: Regexp
18
+
19
+ UUID = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/ #: Regexp
20
+
21
+ # The WHATWG HTML pattern: no single pattern matches RFC 5322 exactly, so
22
+ # this one only rules out values that are clearly not an address.
23
+ EMAIL = %r{\A[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\z} #: Regexp
24
+
25
+ HOSTNAME = /\A(?=.{1,253}\.?\z)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?\z/ #: Regexp
26
+
27
+ # The formats OpenAI, Anthropic and Bedrock all accept. OpenAI rejects uri.
28
+ CHECKS = {
29
+ "date-time" => ->(value) { calendar_date?(DATE_TIME, value) },
30
+ "time" => ->(value) { TIME.match?(value) },
31
+ "date" => ->(value) { calendar_date?(DATE, value) },
32
+ "duration" => ->(value) { DURATION.match?(value) },
33
+ "email" => ->(value) { EMAIL.match?(value) },
34
+ "hostname" => ->(value) { HOSTNAME.match?(value) },
35
+ "ipv4" => ->(value) { ip_address?(value, &:ipv4?) },
36
+ "ipv6" => ->(value) { ip_address?(value, &:ipv6?) },
37
+ "uuid" => ->(value) { UUID.match?(value) },
38
+ }.freeze #: Hash[String, ^(String) -> bool]
39
+
40
+ private_constant :DATE, :DATE_TIME, :TIME, :DURATION, :UUID, :EMAIL, :HOSTNAME, :CHECKS
41
+
42
+ NAMES = CHECKS.keys.freeze #: Array[String]
43
+
44
+ #--
45
+ #: (String, String) -> bool
46
+ def valid?(format, value)
47
+ CHECKS.fetch(format).call(value)
48
+ end
49
+
50
+ private
51
+
52
+ #--
53
+ #: (Regexp, String) -> bool
54
+ def calendar_date?(pattern, value)
55
+ match = pattern.match(value)
56
+ return false unless match
57
+
58
+ # RFC 3339 dates are proleptic Gregorian; Date defaults to the Julian calendar before 1582.
59
+ Date.valid_date?(match[1].to_i, match[2].to_i, match[3].to_i, Date::GREGORIAN)
60
+ end
61
+
62
+ #--
63
+ #: (String) { (IPAddr) -> bool } -> bool
64
+ def ip_address?(value)
65
+ # IPAddr also accepts a prefix (10.0.0.0/24), a zone ID (fe80::1%eth0) and
66
+ # brackets ([::1]), none of which are part of an address.
67
+ return false if value.match?(%r{[/%\[\]]})
68
+
69
+ yield IPAddr.new(value)
70
+ rescue IPAddr::Error
71
+ false
72
+ end
73
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
+ require "json"
5
+
4
6
  class Riffer::Params::Param
5
7
  TYPE_MAPPINGS = {
6
8
  String => "string",
@@ -34,43 +36,99 @@ class Riffer::Params::Param
34
36
 
35
37
  attr_reader :enum #: Array[untyped]? # @dynamic enum
36
38
 
39
+ attr_reader :format #: String? # @dynamic format
40
+
37
41
  attr_reader :default #: untyped # @dynamic default
38
42
 
39
43
  attr_reader :item_type #: Module? # @dynamic item_type
40
44
 
45
+ attr_reader :dimensions #: Integer # @dynamic dimensions
46
+
41
47
  attr_reader :nested_params #: Riffer::Params? # @dynamic nested_params
42
48
 
49
+ attr_reader :variants #: Array[Riffer::Params]? # @dynamic variants
50
+
43
51
  #--
44
52
  #: (Symbol, Hash[Symbol, untyped], required: bool) -> Riffer::Params::Param
45
53
  def self.from_json_schema(name, schema, required:)
46
- ruby_type = json_type_to_ruby(schema[:type])
47
- item_type, nested = resolve_nesting(ruby_type, schema)
54
+ variants = resolve_variants(schema[:anyOf]) if schema.key?(:anyOf)
55
+ ruby_type = variants ? Riffer::Params::AnyOf : json_type_to_ruby(schema[:type])
56
+ nested = resolve_nested_params(ruby_type, schema)
57
+ item_type, dimensions, items = resolve_typed_items(schema) if ruby_type == Array && !nested
48
58
 
49
59
  new(
50
60
  name: name,
51
61
  type: ruby_type,
52
62
  required: required,
53
63
  description: schema[:description],
54
- enum: schema[:enum],
64
+ enum: schema[:enum] || items&.dig(:enum),
65
+ format: schema[:format] || items&.dig(:format),
55
66
  default: schema[:default],
56
67
  item_type: item_type,
68
+ dimensions: dimensions || 1,
57
69
  nested_params: nested,
70
+ variants: variants,
58
71
  )
59
72
  end
60
73
 
61
74
  #--
62
- #: (Module, Hash[Symbol, untyped]) -> [Module?, Riffer::Params?]
63
- def self.resolve_nesting(ruby_type, schema)
64
- return [nil, Riffer::Params.from_json_schema(schema)] if ruby_type == Hash && schema[:properties]
65
- return [nil, nil] unless ruby_type == Array
75
+ #: (Array[Hash[Symbol, untyped]]) -> Array[Riffer::Params]
76
+ def self.resolve_variants(branches)
77
+ # Strict schemas add a null branch to optional fields; nullability comes from +required+.
78
+ variants = branches.reject { |branch| branch[:type] == "null" }.map do |branch|
79
+ raise Riffer::ArgumentError, "Unsupported anyOf branch: #{branch.to_json}" unless branch[:type] == "object"
66
80
 
81
+ Riffer::Params.from_json_schema(branch)
82
+ end
83
+ raise Riffer::ArgumentError, "anyOf requires at least two object branches" if variants.size < 2
84
+
85
+ variants
86
+ end
87
+ private_class_method :resolve_variants
88
+
89
+ #--
90
+ #: (Module, Hash[Symbol, untyped]) -> Riffer::Params?
91
+ def self.resolve_nested_params(ruby_type, schema)
92
+ if ruby_type == Hash && schema[:properties]
93
+ Riffer::Params.from_json_schema(schema)
94
+ elsif ruby_type == Array && schema[:items].is_a?(Hash) && schema[:items][:properties]
95
+ Riffer::Params.from_json_schema(schema[:items])
96
+ end
97
+ end
98
+ private_class_method :resolve_nested_params
99
+
100
+ #--
101
+ #: (Hash[Symbol, untyped]) -> [Module, Integer, Hash[Symbol, untyped]]?
102
+ def self.resolve_typed_items(schema)
67
103
  items = schema[:items]
68
- return [nil, nil] unless items.is_a?(Hash)
69
- return [nil, Riffer::Params.from_json_schema(items)] if items[:properties]
104
+ return unless items.is_a?(Hash)
70
105
 
71
- [json_type_to_ruby(items[:type]), nil]
106
+ dimensions = 1
107
+ # Riffer only writes nested arrays with enum/format and a single primitive type on the innermost
108
+ # items, and no nullable inner arrays; any other shape would read back with a different meaning.
109
+ while json_type_to_ruby(items[:type]) == Array
110
+ inner = items[:items]
111
+ unless items[:type] == "array" && inner.is_a?(Hash) && !items.keys.intersect?(%i[enum format])
112
+ raise Riffer::ArgumentError, "Unsupported nested array: #{items.to_json}"
113
+ end
114
+
115
+ items = inner
116
+ dimensions += 1
117
+ end
118
+
119
+ item_type = json_type_to_ruby(items[:type])
120
+ if dimensions > 1
121
+ if schema.keys.intersect?(%i[enum format])
122
+ raise Riffer::ArgumentError, "Unsupported nested array: #{schema.to_json}"
123
+ end
124
+ unless items[:type].is_a?(String) && PRIMITIVE_TYPES.include?(item_type)
125
+ raise Riffer::ArgumentError, "Unsupported nested array items: #{items.to_json}"
126
+ end
127
+ end
128
+
129
+ [item_type, dimensions, items]
72
130
  end
73
- private_class_method :resolve_nesting
131
+ private_class_method :resolve_typed_items
74
132
 
75
133
  #--
76
134
  #: (untyped) -> Module
@@ -81,16 +139,27 @@ class Riffer::Params::Param
81
139
  private_class_method :json_type_to_ruby
82
140
 
83
141
  #--
84
- #: (name: Symbol, type: Module, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?item_type: Module?, ?nested_params: Riffer::Params?) -> void
85
- def initialize(name:, type:, required:, description: nil, enum: nil, default: nil, item_type: nil, nested_params: nil)
142
+ #: (name: Symbol, type: Module, required: bool, ?description: String?, ?enum: Array[untyped]?, ?format: String?, ?default: untyped, ?item_type: Module?, ?dimensions: Integer, ?nested_params: Riffer::Params?, ?variants: Array[Riffer::Params]?) -> void
143
+ def initialize(name:, type:, required:, description: nil, enum: nil, format: nil, default: nil, item_type: nil,
144
+ dimensions: 1, nested_params: nil, variants: nil)
145
+ if type == Riffer::Params::AnyOf && variants.nil?
146
+ raise Riffer::ArgumentError, "#{name}: an AnyOf param requires variants"
147
+ end
148
+
86
149
  @name = name.to_sym
87
150
  @type = type
88
151
  @required = required
89
152
  @description = description
90
153
  @enum = enum
154
+ # Frozen and deduplicated, so copies never share a mutable string.
155
+ @format = format.is_a?(String) ? -format : format
91
156
  @default = default
92
157
  @item_type = item_type
158
+ @dimensions = dimensions
93
159
  @nested_params = nested_params
160
+ @variants = variants
161
+ # Checked here rather than in Params so from_json_schema enforces it too.
162
+ validate_format! if format
94
163
  end
95
164
 
96
165
  #--
@@ -102,6 +171,8 @@ class Riffer::Params::Param
102
171
  [true, false].include?(value)
103
172
  elsif type == Float
104
173
  value.is_a?(Numeric)
174
+ elsif type == Riffer::Params::AnyOf
175
+ value.is_a?(Hash)
105
176
  else
106
177
  value.is_a?(type)
107
178
  end
@@ -110,6 +181,8 @@ class Riffer::Params::Param
110
181
  #--
111
182
  #: () -> String
112
183
  def type_name
184
+ return "object" if type == Riffer::Params::AnyOf
185
+
113
186
  TYPE_MAPPINGS[type] || type.to_s.downcase
114
187
  end
115
188
 
@@ -120,9 +193,21 @@ class Riffer::Params::Param
120
193
  # Nullable so strict providers can distinguish absent from present.
121
194
  nullable = strict && !required
122
195
 
196
+ if type == Riffer::Params::AnyOf && variants
197
+ schema = { anyOf: variants.map { |variant| variant.to_json_schema(strict: strict) } } #: Hash[Symbol, untyped]
198
+ schema[:anyOf] << { type: "null" } if nullable
199
+ schema[:description] = description if description
200
+ schema[:default] = default unless strict || default.nil?
201
+ return schema
202
+ end
203
+
204
+ # A typed array's enum and format constrain its items, not the array.
205
+ value_enum = enum unless item_type
206
+ value_format = format unless item_type
207
+
123
208
  # Providers like Anthropic reject a nullable type union combined with enum.
124
- if nullable && enum
125
- schema = { anyOf: [{ type: type_name, enum: enum }, { type: "null" }] } #: Hash[Symbol, untyped]
209
+ if nullable && value_enum
210
+ schema = { anyOf: [{ type: type_name, enum: value_enum }, { type: "null" }] } #: Hash[Symbol, untyped]
126
211
  schema[:description] = description if description
127
212
  return schema
128
213
  end
@@ -132,7 +217,8 @@ class Riffer::Params::Param
132
217
 
133
218
  schema = { type: type } #: Hash[Symbol, untyped]
134
219
  schema[:description] = description if description
135
- schema[:enum] = enum if enum
220
+ schema[:enum] = value_enum if value_enum
221
+ schema[:format] = value_format if value_format
136
222
  # Strict providers reject the +default+ keyword; emit it only in
137
223
  # non-strict mode, where it makes the schema a lossless round-trip
138
224
  # source for +from_json_schema+.
@@ -141,7 +227,9 @@ class Riffer::Params::Param
141
227
  if self.type == Array && nested_params
142
228
  schema[:items] = nested_params.to_json_schema(strict: strict)
143
229
  elsif self.type == Array && item_type
144
- schema[:items] = { type: TYPE_MAPPINGS[item_type] }
230
+ items = { type: TYPE_MAPPINGS[item_type], enum: enum, format: format }.compact #: Hash[Symbol, untyped]
231
+ (dimensions - 1).times { items = { type: "array", items: items } }
232
+ schema[:items] = items
145
233
  elsif self.type == Hash && nested_params
146
234
  # The nested schema carries its own type: "object", which would clobber a nullable union.
147
235
  schema.merge!(nested_params.to_json_schema(strict: strict).except(:type))
@@ -160,10 +248,25 @@ class Riffer::Params::Param
160
248
  def initialize_copy(source)
161
249
  super
162
250
  @nested_params = source.nested_params&.dup
251
+ @variants = source.variants&.map(&:dup)
163
252
  @enum = Riffer::Helpers::DeepDup.call(source.enum)
164
253
  @default = Riffer::Helpers::DeepDup.call(source.default)
165
254
  end
166
255
 
256
+ #--
257
+ #: () -> void
258
+ def validate_format!
259
+ unless Riffer::Params::Format::NAMES.include?(format)
260
+ raise Riffer::ArgumentError,
261
+ "#{name}: format: must be a supported format (#{Riffer::Params::Format::NAMES.join(', ')}), " \
262
+ "got #{format.inspect}"
263
+ end
264
+ unless type == String || (type == Array && item_type == String)
265
+ raise Riffer::ArgumentError, "#{name}: format: requires a String or an Array whose innermost type is String"
266
+ end
267
+ raise Riffer::ArgumentError, "#{name}: format: cannot be used with enum:" if enum
268
+ end
269
+
167
270
  #--
168
271
  #: () -> void
169
272
  def validate_strict_shape!
data/lib/riffer/params.rb CHANGED
@@ -29,33 +29,45 @@ class Riffer::Params
29
29
  end
30
30
 
31
31
  #--
32
- #: (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?of: Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
33
- def required(name, type, description: nil, enum: nil, of: nil, &)
34
- nested = build_nested(type, of, &)
32
+ #: (Symbol, singleton(Riffer::Params::AnyOf), ?description: String?) { (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> void
33
+ #: (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?format: String?, ?of: (Module | Array[untyped])?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
34
+ def required(name, type, description: nil, enum: nil, format: nil, of: nil, &)
35
+ item_type, dimensions = unwrap_of(of)
36
+ nested = build_nested(type, item_type, enum, &) unless type == Riffer::Params::AnyOf
37
+ variants = build_variants(of, enum, &) if type == Riffer::Params::AnyOf
35
38
  @parameters << Riffer::Params::Param.new(
36
39
  name: name,
37
40
  type: type,
38
41
  required: true,
39
42
  description: description,
40
43
  enum: enum,
41
- item_type: of,
44
+ format: format,
45
+ item_type: item_type,
46
+ dimensions: dimensions,
42
47
  nested_params: nested,
48
+ variants: variants,
43
49
  )
44
50
  end
45
51
 
46
52
  #--
47
- #: (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?of: Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
48
- def optional(name, type, description: nil, enum: nil, default: nil, of: nil, &)
49
- nested = build_nested(type, of, &)
53
+ #: (Symbol, singleton(Riffer::Params::AnyOf), ?description: String?, ?default: untyped) { (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> void
54
+ #: (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?format: String?, ?default: untyped, ?of: (Module | Array[untyped])?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
55
+ def optional(name, type, description: nil, enum: nil, format: nil, default: nil, of: nil, &)
56
+ item_type, dimensions = unwrap_of(of)
57
+ nested = build_nested(type, item_type, enum, &) unless type == Riffer::Params::AnyOf
58
+ variants = build_variants(of, enum, &) if type == Riffer::Params::AnyOf
50
59
  @parameters << Riffer::Params::Param.new(
51
60
  name: name,
52
61
  type: type,
53
62
  required: false,
54
63
  description: description,
55
64
  enum: enum,
65
+ format: format,
56
66
  default: default,
57
- item_type: of,
67
+ item_type: item_type,
68
+ dimensions: dimensions,
58
69
  nested_params: nested,
70
+ variants: variants,
59
71
  )
60
72
  end
61
73
 
@@ -83,11 +95,17 @@ class Riffer::Params
83
95
  next
84
96
  end
85
97
 
86
- if param.enum && !param.enum.include?(value)
98
+ # A typed array's enum and format constrain its items, which validate_typed_array checks.
99
+ if param.enum && !param.item_type && !param.enum.include?(value)
87
100
  errors << "#{param.name} must be one of: #{param.enum.join(', ')}"
88
101
  next
89
102
  end
90
103
 
104
+ if param.format && !param.item_type && !Riffer::Params::Format.valid?(param.format, value)
105
+ errors << "#{param.name} must be a valid #{param.format}"
106
+ next
107
+ end
108
+
91
109
  value = validate_nested(param, value, errors)
92
110
 
93
111
  validated[param.name] = coerce_value(param.type, value)
@@ -131,9 +149,20 @@ class Riffer::Params
131
149
  end
132
150
 
133
151
  #--
134
- #: (Module, Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
135
- def build_nested(type, of, &block)
152
+ #: ((Module | Array[untyped])?, ?Integer) -> [Module?, Integer]
153
+ def unwrap_of(of, dimensions = 1)
154
+ return [of, dimensions] unless of.is_a?(Array)
155
+ raise Riffer::ArgumentError, "of: must nest exactly one type, got #{of.inspect}" unless of.size == 1 && of.first
156
+
157
+ unwrap_of(of.first, dimensions + 1)
158
+ end
159
+
160
+ #--
161
+ #: (Module, Module?, Array[untyped]?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
162
+ def build_nested(type, of, enum, &block)
136
163
  raise Riffer::ArgumentError, "cannot use both of: and a block" if of && block
164
+ raise Riffer::ArgumentError, "enum: cannot be used with Hash" if enum && type == Hash
165
+ raise Riffer::ArgumentError, "enum: with Array requires of:" if enum && type == Array && !of
137
166
 
138
167
  if of
139
168
  raise Riffer::ArgumentError, "of: can only be used with Array type, got #{type}" unless type == Array
@@ -155,10 +184,26 @@ class Riffer::Params
155
184
  nested
156
185
  end
157
186
 
187
+ #--
188
+ #: ((Module | Array[untyped])?, Array[untyped]?) ?{ (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> Array[Riffer::Params]
189
+ def build_variants(of, enum, &block)
190
+ raise Riffer::ArgumentError, "of: cannot be used with AnyOf" if of
191
+ raise Riffer::ArgumentError, "enum: cannot be used with AnyOf" if enum
192
+ raise Riffer::ArgumentError, "AnyOf requires a block defining its variants" unless block
193
+
194
+ any_of = Riffer::Params::AnyOf.new
195
+ any_of.instance_eval(&block)
196
+ raise Riffer::ArgumentError, "AnyOf requires at least two variants" if any_of.variants.size < 2
197
+
198
+ any_of.variants
199
+ end
200
+
158
201
  #--
159
202
  #: (Riffer::Params::Param, untyped, Array[String]) -> untyped
160
203
  def validate_nested(param, value, errors)
161
- if param.type == Hash && param.nested_params
204
+ if param.type == Riffer::Params::AnyOf
205
+ validate_any_of(param, value, errors)
206
+ elsif param.type == Hash && param.nested_params
162
207
  validate_nested_hash(param, value, errors)
163
208
  elsif param.type == Array && param.nested_params
164
209
  validate_nested_array_of_objects(param, value, errors)
@@ -183,6 +228,49 @@ class Riffer::Params
183
228
  value
184
229
  end
185
230
 
231
+ #--
232
+ #: (Riffer::Params::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
233
+ def validate_any_of(param, value, errors)
234
+ variants = param.variants
235
+ return value unless variants
236
+
237
+ variant_errors = [] #: Array[String]
238
+ variants.each.with_index(1) do |variant, number|
239
+ validated = variant.validate(value)
240
+ # Each variant's schema sets additionalProperties: false on every object
241
+ # it declares, so a key outside this variant rules it out.
242
+ unknown = undeclared_keys(value, validated)
243
+ return validated if unknown.empty?
244
+
245
+ variant_errors << "variant #{number} (#{unknown.map { |path| "#{path} is not allowed" }.join(', ')})"
246
+ rescue Riffer::ValidationError => e
247
+ # Enclosing params split errors on "; " to prefix their path, so one
248
+ # variant's errors must not contain it.
249
+ variant_errors << "variant #{number} (#{e.message.gsub('; ', ', ')})"
250
+ end
251
+
252
+ errors << "#{param.name} must match one of the variants: #{variant_errors.join(', ')}"
253
+ value
254
+ end
255
+
256
+ # Validation drops keys a Params doesn't declare, at any depth, so they are
257
+ # the keys in the input that are missing from the output.
258
+ #--
259
+ #: (untyped, untyped, ?String?) -> Array[String]
260
+ def undeclared_keys(value, validated, path = nil)
261
+ case value
262
+ when Hash
263
+ value.flat_map do |key, child|
264
+ key_path = [path, key].compact.join(".")
265
+ validated.key?(key) ? undeclared_keys(child, validated[key], key_path) : [key_path]
266
+ end
267
+ when Array
268
+ value.each_with_index.flat_map { |item, i| undeclared_keys(item, validated[i], "#{path}[#{i}]") }
269
+ else
270
+ []
271
+ end
272
+ end
273
+
186
274
  #--
187
275
  #: (Riffer::Params::Param, Array[untyped], Array[String]) -> Array[untyped]
188
276
  def validate_nested_array_of_objects(param, value, errors)
@@ -204,22 +292,33 @@ class Riffer::Params
204
292
  end
205
293
 
206
294
  #--
207
- #: (Riffer::Params::Param, Array[untyped], Array[String]) -> Array[untyped]
208
- def validate_typed_array(param, value, errors)
295
+ #: (Riffer::Params::Param, Array[untyped], Array[String], ?String, ?Integer) -> Array[untyped]
296
+ def validate_typed_array(param, value, errors, path = param.name.to_s, dimensions = param.dimensions)
209
297
  item_type = param.item_type
210
298
  return value unless item_type
211
299
 
212
- type_name = Riffer::Params::Param::TYPE_MAPPINGS[item_type]
213
- valid_item = if [Riffer::Params::Boolean, TrueClass, FalseClass].include?(item_type)
214
- ->(item) { [true, false].include?(item) }
215
- elsif item_type == Float
216
- ->(item) { item.is_a?(Numeric) }
217
- else
218
- ->(item) { item.is_a?(item_type) }
219
- end
220
300
  value.map.with_index do |item, i|
221
- unless valid_item.call(item)
222
- errors << "#{param.name}[#{i}] must be a #{type_name}"
301
+ item_path = "#{path}[#{i}]"
302
+
303
+ if dimensions > 1
304
+ next validate_typed_array(param, item, errors, item_path, dimensions - 1) if item.is_a?(Array)
305
+
306
+ errors << "#{item_path} must be an array"
307
+ next item
308
+ end
309
+
310
+ unless valid_item?(item_type, item)
311
+ errors << "#{item_path} must be a #{Riffer::Params::Param::TYPE_MAPPINGS[item_type]}"
312
+ next item
313
+ end
314
+
315
+ if param.enum && !param.enum.include?(item)
316
+ errors << "#{item_path} must be one of: #{param.enum.join(', ')}"
317
+ next item
318
+ end
319
+
320
+ if param.format && !Riffer::Params::Format.valid?(param.format, item)
321
+ errors << "#{item_path} must be a valid #{param.format}"
223
322
  next item
224
323
  end
225
324
 
@@ -227,6 +326,18 @@ class Riffer::Params
227
326
  end
228
327
  end
229
328
 
329
+ #--
330
+ #: (Module, untyped) -> bool
331
+ def valid_item?(type, item)
332
+ if [Riffer::Params::Boolean, TrueClass, FalseClass].include?(type)
333
+ [true, false].include?(item)
334
+ elsif type == Float
335
+ item.is_a?(Numeric)
336
+ else
337
+ item.is_a?(type)
338
+ end
339
+ end
340
+
230
341
  #--
231
342
  #: (Module, untyped) -> untyped
232
343
  def coerce_value(type, value)
@@ -78,6 +78,20 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
78
78
  end
79
79
  end
80
80
 
81
+ # Claude's fields, since application inference profile ARNs hide the model
82
+ # family; other families need a catalog entry.
83
+ #--
84
+ #: (Symbol) -> Hash[Symbol, untyped]
85
+ def default_reasoning_options(level)
86
+ fields =
87
+ if level == :off
88
+ { thinking: { type: "disabled" } }
89
+ else
90
+ { thinking: { type: "adaptive" }, output_config: { effort: level.to_s } }
91
+ end
92
+ { additional_model_request_fields: fields }
93
+ end
94
+
81
95
  #--
82
96
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
83
97
  def build_request_params(messages, model, options)
@@ -56,6 +56,14 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
56
56
  ::Anthropic::Client.new(**{ api_key: Riffer.config.anthropic.api_key }.compact)
57
57
  end
58
58
 
59
+ #--
60
+ #: (Symbol) -> Hash[Symbol, untyped]
61
+ def default_reasoning_options(level)
62
+ return { thinking: { type: "disabled" } } if level == :off
63
+
64
+ { thinking: { type: "adaptive" }, output_config: { effort: level.to_s } }
65
+ end
66
+
59
67
  #--
60
68
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
61
69
  def build_request_params(messages, model, options)