riffer 0.15.1 → 0.16.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 948e36451ba6cbe794054e91152030b3a5f1c89e4d81dddac69fe6624dad1a14
4
- data.tar.gz: e3ec468daf389cac46ba6f5b027987f939ffc263611c27160a637b0525e84506
3
+ metadata.gz: 6fff2cbd2ae809651bb2a3b54cc79e591d9ba295fe8d096769e74ee75159e32b
4
+ data.tar.gz: 18ea73a692e864fbb2bd1c4c880ec293348cd4012a5218364b9207d51686b9bf
5
5
  SHA512:
6
- metadata.gz: 060c70baee5ae31adbf8b5442be9c163c6c69931df310260005d836f9bfcddf53c17f2e705f194f37197d94345bf31449b992702bf457471262d689cbe806731
7
- data.tar.gz: f10ebb1ee13561a3a197c1fb757fdc1a41dbacb04499c1160c26f116510b43fb4150b83ab17d77696713f36b896d0bbdc830f0cb0bc8266c28a5a469aab020c8
6
+ metadata.gz: 01eeb93e3253a1061d884e19470f880eebfdd354e3ae1a32673364533411e67f9e0ccfb48a1a90d7be88e12d0bf0d5b3027ea399e517b2d18783ec8599b0ee4b
7
+ data.tar.gz: fd8ca068330ab1858055dc3e5a0fc75a69db606189cfe7af5feac049e8b634919fb78e3501a8bb513bc542d1883babfe627d8b6a355927eb1df4707dc05943a6
data/.agents/providers.md CHANGED
@@ -116,9 +116,19 @@ def build_request_params(messages, model, options)
116
116
  end
117
117
  ```
118
118
 
119
+ ### Strict schema
120
+
121
+ All providers apply `strict_schema` (defined in `Providers::Base`) to schemas sent to LLMs — both structured output and tool parameters. This transformation:
122
+
123
+ - Moves all properties into `required`
124
+ - Makes originally-optional properties nullable (`[type, "null"]`)
125
+ - Recurses into nested objects and array items
126
+
127
+ This ensures all providers return proper `null` for optional fields instead of empty strings or garbage.
128
+
119
129
  ### Provider-specific formats
120
130
 
121
- **OpenAI** — uses `params[:text][:format]`:
131
+ **OpenAI** — uses `params[:text][:format]` with `strict: true`:
122
132
 
123
133
  ```ruby
124
134
  if structured_output
@@ -126,7 +136,7 @@ if structured_output
126
136
  format: {
127
137
  type: "json_schema",
128
138
  name: "response",
129
- schema: structured_output.json_schema,
139
+ schema: strict_schema(structured_output.json_schema),
130
140
  strict: true
131
141
  }
132
142
  }
@@ -140,7 +150,7 @@ if structured_output
140
150
  params[:output_config] = {
141
151
  format: {
142
152
  type: "json_schema",
143
- schema: structured_output.json_schema
153
+ schema: strict_schema(structured_output.json_schema)
144
154
  }
145
155
  }
146
156
  end
@@ -155,7 +165,7 @@ if structured_output
155
165
  type: "json_schema",
156
166
  structure: {
157
167
  json_schema: {
158
- schema: structured_output.json_schema.to_json,
168
+ schema: strict_schema(structured_output.json_schema).to_json,
159
169
  name: "response"
160
170
  }
161
171
  }
@@ -167,6 +177,7 @@ end
167
177
  ### Key details
168
178
 
169
179
  - `structured_output.json_schema` returns a Hash with `type`, `properties`, `required`, and `additionalProperties` keys
180
+ - All providers wrap schemas with `strict_schema` to ensure proper null handling
170
181
  - Bedrock requires the schema as a JSON string (`.to_json`), others use the Hash directly
171
182
  - The agent handles parsing and validation of the response — providers only need to pass the schema to the SDK
172
183
 
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.15.1"
2
+ ".": "0.16.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.16.0](https://github.com/janeapp/riffer/compare/riffer/v0.15.1...riffer/v0.16.0) (2026-02-27)
9
+
10
+
11
+ ### Features
12
+
13
+ * add Riffer::Boolean sentinel type for boolean params ([#147](https://github.com/janeapp/riffer/issues/147)) ([5337cf3](https://github.com/janeapp/riffer/commit/5337cf3820d54cf9e03a630ca32b8a7f59347221))
14
+ * nested params DSL and strict schema for all providers ([#144](https://github.com/janeapp/riffer/issues/144)) ([2984855](https://github.com/janeapp/riffer/commit/2984855c64a1d0b421497aa81c5e0a393d443e46))
15
+
8
16
  ## [0.15.1](https://github.com/janeapp/riffer/compare/riffer/v0.15.0...riffer/v0.15.1) (2026-02-25)
9
17
 
10
18
 
data/README.md CHANGED
@@ -93,6 +93,28 @@ Run the interactive console:
93
93
  bin/console
94
94
  ```
95
95
 
96
+ ### Recording VCR Cassettes
97
+
98
+ Integration tests use [VCR](https://github.com/vcr/vcr) to record and replay HTTP interactions. When adding new tests that hit provider APIs, you need to record cassettes with real API keys.
99
+
100
+ Create a `.env` file in the project root (it is gitignored):
101
+
102
+ ```bash
103
+ OPENAI_API_KEY=sk-...
104
+ ANTHROPIC_API_KEY=sk-ant-...
105
+ AWS_BEDROCK_API_TOKEN=...
106
+ ```
107
+
108
+ The test helper loads this file automatically via `dotenv`. Then run the specific tests that need new cassettes:
109
+
110
+ ```bash
111
+ bundle exec ruby -Itest test/riffer/providers/open_ai_test.rb
112
+ bundle exec ruby -Itest test/riffer/providers/anthropic_test.rb
113
+ bundle exec ruby -Itest test/riffer/providers/amazon_bedrock_test.rb
114
+ ```
115
+
116
+ VCR records the HTTP interactions to `test/fixtures/vcr_cassettes/` on the first run. Subsequent runs replay from the cassettes without hitting the API. API keys are automatically filtered from recorded cassettes.
117
+
96
118
  ## Contributing
97
119
 
98
120
  1. Fork the repository and create your branch: `git checkout -b feature/foo`
data/docs/01_OVERVIEW.md CHANGED
@@ -39,7 +39,7 @@ See [Tools](04_TOOLS.md) for details.
39
39
 
40
40
  ### Structured Output
41
41
 
42
- Agents can return structured JSON responses that conform to a schema. The response is automatically parsed and validated:
42
+ Agents can return structured JSON responses that conform to a schema. The response is automatically parsed and validated. Schemas support nested objects (`Hash`), typed arrays (`Array, of:`), and arrays of objects (`Array` with block):
43
43
 
44
44
  ```ruby
45
45
  class SentimentAgent < Riffer::Agent
@@ -47,11 +47,15 @@ class SentimentAgent < Riffer::Agent
47
47
  structured_output do
48
48
  required :sentiment, String
49
49
  required :score, Float
50
+ required :entities, Array, description: "Named entities" do
51
+ required :name, String
52
+ required :type, String, enum: ["person", "place", "org"]
53
+ end
50
54
  end
51
55
  end
52
56
 
53
57
  response = SentimentAgent.generate('Analyze: "I love this!"')
54
- response.structured_output # => {sentiment: "positive", score: 0.95}
58
+ response.structured_output # => {sentiment: "positive", score: 0.95, entities: [...]}
55
59
  ```
56
60
 
57
61
  See the [structured output section in Agents](03_AGENTS.md#structured_output) for details.
data/docs/03_AGENTS.md CHANGED
@@ -149,6 +149,74 @@ end
149
149
 
150
150
  The LLM response is automatically parsed and validated against the schema. Access the result via `response.structured_output`.
151
151
 
152
+ #### Nested Objects
153
+
154
+ Use `Hash` with a block to define nested object schemas:
155
+
156
+ ```ruby
157
+ structured_output do
158
+ required :name, String, description: "Person name"
159
+ required :address, Hash, description: "Mailing address" do
160
+ required :street, String, description: "Street address"
161
+ required :city, String, description: "City"
162
+ optional :postal_code, String, description: "Postal or zip code"
163
+ end
164
+ end
165
+ ```
166
+
167
+ Validation errors use dot-path notation: `address.city is required`.
168
+
169
+ #### Typed Arrays
170
+
171
+ Use `Array` with the `of:` keyword for arrays of primitive types:
172
+
173
+ ```ruby
174
+ structured_output do
175
+ required :tags, Array, of: String, description: "Tags"
176
+ required :scores, Array, of: Float, description: "Scores"
177
+ end
178
+ ```
179
+
180
+ Only primitive types are allowed with `of:`: `String`, `Integer`, `Float`, `TrueClass`, `FalseClass`.
181
+
182
+ #### Arrays of Objects
183
+
184
+ Use `Array` with a block to define arrays of objects:
185
+
186
+ ```ruby
187
+ structured_output do
188
+ required :items, Array, description: "Line items" do
189
+ required :name, String, description: "Product name"
190
+ required :price, Float, description: "Price"
191
+ optional :quantity, Integer, description: "Quantity"
192
+ end
193
+ end
194
+ ```
195
+
196
+ Validation errors include the array index: `items[1].price is required`.
197
+
198
+ #### Deep Nesting
199
+
200
+ Blocks can be nested arbitrarily deep:
201
+
202
+ ```ruby
203
+ structured_output do
204
+ required :orders, Array, description: "Orders" do
205
+ required :id, String, description: "Order ID"
206
+ required :shipping, Hash, description: "Shipping info" do
207
+ required :address, Hash, description: "Address" do
208
+ required :street, String
209
+ required :city, String
210
+ end
211
+ end
212
+ end
213
+ end
214
+ ```
215
+
216
+ #### Limitations
217
+
218
+ Using both `of:` and a block raises `Riffer::ArgumentError`. Using `of:` with a non-primitive type (e.g. `of: Hash`) also raises `Riffer::ArgumentError`.
219
+
152
220
  Structured output is not compatible with streaming — calling `stream` on an agent with structured output configured raises `Riffer::ArgumentError`.
153
221
 
154
222
  ### guardrail
data/docs/04_TOOLS.md CHANGED
@@ -104,10 +104,41 @@ Options:
104
104
  | `String` | `string` |
105
105
  | `Integer` | `integer` |
106
106
  | `Float` | `number` |
107
+ | `Riffer::Boolean` | `boolean` |
107
108
  | `TrueClass` / `FalseClass` | `boolean` |
108
109
  | `Array` | `array` |
109
110
  | `Hash` | `object` |
110
111
 
112
+ `Riffer::Boolean` is the preferred way to declare boolean parameters. `TrueClass` and `FalseClass` continue to work for backwards compatibility.
113
+
114
+ ### Nested Parameters
115
+
116
+ Tool params support the same nested DSL as structured output — nested objects (`Hash` with block), typed arrays (`Array, of:`), and arrays of objects (`Array` with block). See the [structured output section in Agents](03_AGENTS.md#nested-objects) for full syntax.
117
+
118
+ ```ruby
119
+ class CreateOrderTool < Riffer::Tool
120
+ description "Creates an order"
121
+
122
+ params do
123
+ required :items, Array, description: "Line items" do
124
+ required :product_id, Integer
125
+ required :quantity, Integer
126
+ optional :notes, String
127
+ end
128
+ required :shipping, Hash, description: "Shipping address" do
129
+ required :street, String
130
+ required :city, String
131
+ optional :zip, String
132
+ end
133
+ end
134
+
135
+ def call(context:, items:, shipping:)
136
+ # items is an Array of Hashes with symbolized keys
137
+ # shipping is a Hash with symbolized keys
138
+ end
139
+ end
140
+ ```
141
+
111
142
  ## The call Method
112
143
 
113
144
  Every tool must implement the `call` method and return a `Riffer::Tools::Response`:
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Riffer::Boolean is a sentinel type for declaring boolean parameters.
5
+ #
6
+ # Ruby has no +Boolean+ class (+true+ is +TrueClass+, +false+ is +FalseClass+).
7
+ # Use this module wherever you need a single type that means "boolean":
8
+ #
9
+ # required :verbose, Riffer::Boolean
10
+ #
11
+ module Riffer::Boolean
12
+ end
data/lib/riffer/param.rb CHANGED
@@ -10,11 +10,15 @@ class Riffer::Param
10
10
  String => "string",
11
11
  Integer => "integer",
12
12
  Float => "number",
13
+ Riffer::Boolean => "boolean",
13
14
  TrueClass => "boolean",
14
15
  FalseClass => "boolean",
15
16
  Array => "array",
16
17
  Hash => "object"
17
- }.freeze #: Hash[Class, String]
18
+ }.freeze #: Hash[Module, String]
19
+
20
+ # Primitive types allowed for the +of:+ keyword on Array params
21
+ PRIMITIVE_TYPES = (TYPE_MAPPINGS.keys - [Array, Hash]).freeze #: Array[Class]
18
22
 
19
23
  attr_reader :name #: Symbol
20
24
  attr_reader :type #: Class
@@ -22,15 +26,19 @@ class Riffer::Param
22
26
  attr_reader :description #: String?
23
27
  attr_reader :enum #: Array[untyped]?
24
28
  attr_reader :default #: untyped
29
+ attr_reader :item_type #: Class?
30
+ attr_reader :nested_params #: Riffer::Params?
25
31
 
26
- #: (name: Symbol, type: Class, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped) -> void
27
- def initialize(name:, type:, required:, description: nil, enum: nil, default: nil)
32
+ #: (name: Symbol, type: Class, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?item_type: Class?, ?nested_params: Riffer::Params?) -> void
33
+ def initialize(name:, type:, required:, description: nil, enum: nil, default: nil, item_type: nil, nested_params: nil)
28
34
  @name = name.to_sym
29
35
  @type = type
30
36
  @required = required
31
37
  @description = description
32
38
  @enum = enum
33
39
  @default = default
40
+ @item_type = item_type
41
+ @nested_params = nested_params
34
42
  end
35
43
 
36
44
  # Validates that a value matches the expected type.
@@ -39,7 +47,7 @@ class Riffer::Param
39
47
  def valid_type?(value)
40
48
  return true if value.nil? && !required
41
49
 
42
- if type == TrueClass || type == FalseClass
50
+ if type == Riffer::Boolean || type == TrueClass || type == FalseClass
43
51
  value == true || value == false
44
52
  else
45
53
  value.is_a?(type)
@@ -55,11 +63,27 @@ class Riffer::Param
55
63
 
56
64
  # Converts this parameter to JSON Schema format.
57
65
  #
58
- #: () -> Hash[Symbol, untyped]
59
- def to_json_schema
60
- schema = {type: type_name}
66
+ # When +strict+ is true, optional parameters are made nullable
67
+ # (+["type", "null"]+) so that strict mode providers can distinguish
68
+ # "absent" from "present" without rejecting the schema.
69
+ #
70
+ #: (?strict: bool) -> Hash[Symbol, untyped]
71
+ def to_json_schema(strict: false)
72
+ type = type_name
73
+ type = [type, "null"] if strict && !required
74
+
75
+ schema = {type: type}
61
76
  schema[:description] = description if description
62
77
  schema[:enum] = enum if enum
78
+
79
+ if self.type == Array && nested_params
80
+ schema[:items] = nested_params.to_json_schema(strict: strict)
81
+ elsif self.type == Array && item_type
82
+ schema[:items] = {type: TYPE_MAPPINGS[item_type]}
83
+ elsif self.type == Hash && nested_params
84
+ schema.merge!(nested_params.to_json_schema(strict: strict))
85
+ end
86
+
63
87
  schema
64
88
  end
65
89
  end
data/lib/riffer/params.rb CHANGED
@@ -21,28 +21,34 @@ class Riffer::Params
21
21
 
22
22
  # Defines a required parameter.
23
23
  #
24
- #: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?) -> void
25
- def required(name, type, description: nil, enum: nil)
24
+ #: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?of: Class?) ?{ () -> void } -> void
25
+ def required(name, type, description: nil, enum: nil, of: nil, &block)
26
+ nested = build_nested(type, of, &block)
26
27
  @parameters << Riffer::Param.new(
27
28
  name: name,
28
29
  type: type,
29
30
  required: true,
30
31
  description: description,
31
- enum: enum
32
+ enum: enum,
33
+ item_type: of,
34
+ nested_params: nested
32
35
  )
33
36
  end
34
37
 
35
38
  # Defines an optional parameter.
36
39
  #
37
- #: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?default: untyped) -> void
38
- def optional(name, type, description: nil, enum: nil, default: nil)
40
+ #: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?of: Class?) ?{ () -> void } -> void
41
+ def optional(name, type, description: nil, enum: nil, default: nil, of: nil, &block)
42
+ nested = build_nested(type, of, &block)
39
43
  @parameters << Riffer::Param.new(
40
44
  name: name,
41
45
  type: type,
42
46
  required: false,
43
47
  description: description,
44
48
  enum: enum,
45
- default: default
49
+ default: default,
50
+ item_type: of,
51
+ nested_params: nested
46
52
  )
47
53
  end
48
54
 
@@ -78,6 +84,8 @@ class Riffer::Params
78
84
  next
79
85
  end
80
86
 
87
+ value = validate_nested(param, value, errors)
88
+
81
89
  validated[param.name] = value
82
90
  end
83
91
 
@@ -88,14 +96,18 @@ class Riffer::Params
88
96
 
89
97
  # Converts all parameters to JSON Schema format.
90
98
  #
91
- #: () -> Hash[Symbol, untyped]
92
- def to_json_schema
99
+ # When +strict+ is true, every property appears in +required+ and
100
+ # optional properties are made nullable instead. This satisfies
101
+ # providers that enforce strict structured output schemas.
102
+ #
103
+ #: (?strict: bool) -> Hash[Symbol, untyped]
104
+ def to_json_schema(strict: false)
93
105
  properties = {}
94
106
  required_params = []
95
107
 
96
108
  @parameters.each do |param|
97
- properties[param.name.to_s] = param.to_json_schema
98
- required_params << param.name.to_s if param.required
109
+ properties[param.name.to_s] = param.to_json_schema(strict: strict)
110
+ required_params << param.name.to_s if strict || param.required
99
111
  end
100
112
 
101
113
  {
@@ -105,4 +117,99 @@ class Riffer::Params
105
117
  additionalProperties: false
106
118
  }
107
119
  end
120
+
121
+ private
122
+
123
+ #: (Class, Class?) ?{ () -> void } -> Riffer::Params?
124
+ def build_nested(type, of, &block)
125
+ if of && block
126
+ raise Riffer::ArgumentError, "cannot use both of: and a block"
127
+ end
128
+
129
+ if of
130
+ unless type == Array
131
+ raise Riffer::ArgumentError, "of: can only be used with Array type, got #{type}"
132
+ end
133
+ unless Riffer::Param::PRIMITIVE_TYPES.include?(of)
134
+ raise Riffer::ArgumentError,
135
+ "of: must be a primitive type (#{Riffer::Param::PRIMITIVE_TYPES.map(&:name).join(", ")}), got #{of}"
136
+ end
137
+ return nil
138
+ end
139
+
140
+ if block
141
+ unless type == Hash || type == Array
142
+ raise Riffer::ArgumentError, "block can only be used with Hash or Array type, got #{type}"
143
+ end
144
+ nested = Riffer::Params.new
145
+ nested.instance_eval(&block)
146
+ nested
147
+ end
148
+ end
149
+
150
+ #: (Riffer::Param, untyped, Array[String]) -> untyped
151
+ def validate_nested(param, value, errors)
152
+ if param.type == Hash && param.nested_params
153
+ validate_nested_hash(param, value, errors)
154
+ elsif param.type == Array && param.nested_params
155
+ validate_nested_array_of_objects(param, value, errors)
156
+ elsif param.type == Array && param.item_type
157
+ validate_typed_array(param, value, errors)
158
+ value
159
+ else
160
+ value
161
+ end
162
+ end
163
+
164
+ #: (Riffer::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
165
+ def validate_nested_hash(param, value, errors)
166
+ sym_value = deep_symbolize_keys(value)
167
+ param.nested_params.validate(sym_value)
168
+ rescue Riffer::ValidationError => e
169
+ e.message.split("; ").each do |msg|
170
+ errors << "#{param.name}.#{msg}"
171
+ end
172
+ sym_value
173
+ end
174
+
175
+ #: (Riffer::Param, Array[untyped], Array[String]) -> Array[untyped]
176
+ def validate_nested_array_of_objects(param, value, errors)
177
+ value.map.with_index do |item, i|
178
+ unless item.is_a?(Hash)
179
+ errors << "#{param.name}[#{i}] must be an object"
180
+ next item
181
+ end
182
+ sym_item = deep_symbolize_keys(item)
183
+ param.nested_params.validate(sym_item)
184
+ rescue Riffer::ValidationError => e
185
+ e.message.split("; ").each do |msg|
186
+ errors << "#{param.name}[#{i}].#{msg}"
187
+ end
188
+ sym_item
189
+ end
190
+ end
191
+
192
+ #: (Riffer::Param, Array[untyped], Array[String]) -> void
193
+ def validate_typed_array(param, value, errors)
194
+ type_name = Riffer::Param::TYPE_MAPPINGS[param.item_type]
195
+ value.each_with_index do |item, i|
196
+ valid = if param.item_type == Riffer::Boolean || param.item_type == TrueClass || param.item_type == FalseClass
197
+ item == true || item == false
198
+ else
199
+ item.is_a?(param.item_type)
200
+ end
201
+ errors << "#{param.name}[#{i}] must be a #{type_name}" unless valid
202
+ end
203
+ end
204
+
205
+ #: (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
206
+ def deep_symbolize_keys(hash)
207
+ hash.each_with_object({}) do |(key, value), result|
208
+ result[key.to_sym] = case value
209
+ when Hash then deep_symbolize_keys(value)
210
+ when Array then value.map { |v| v.is_a?(Hash) ? deep_symbolize_keys(v) : v }
211
+ else value
212
+ end
213
+ end
214
+ end
108
215
  end
@@ -50,12 +50,15 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
50
50
  end
51
51
 
52
52
  if structured_output
53
+ # Use strict schema to make optional fields nullable. Without this,
54
+ # Bedrock may return string literals like ": null," instead of actual
55
+ # null values for optional fields that the model has no value for.
53
56
  params[:output_config] = {
54
57
  text_format: {
55
58
  type: "json_schema",
56
59
  structure: {
57
60
  json_schema: {
58
- schema: structured_output.json_schema.to_json,
61
+ schema: structured_output.json_schema(strict: true).to_json,
59
62
  name: "response"
60
63
  }
61
64
  }
@@ -302,7 +305,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
302
305
  name: tool.name,
303
306
  description: tool.description,
304
307
  input_schema: {
305
- json: tool.parameters_schema
308
+ json: tool.parameters_schema(strict: true)
306
309
  }
307
310
  }
308
311
  }
@@ -50,10 +50,13 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
50
50
  end
51
51
 
52
52
  if structured_output
53
+ # Use strict schema to make optional fields nullable. Without this,
54
+ # Anthropic may return empty strings or whitespace instead of null
55
+ # for optional fields that the model has no value for.
53
56
  params[:output_config] = {
54
57
  format: {
55
58
  type: "json_schema",
56
- schema: structured_output.json_schema
59
+ schema: structured_output.json_schema(strict: true)
57
60
  }
58
61
  }
59
62
  end
@@ -335,7 +338,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
335
338
  {
336
339
  name: tool.name,
337
340
  description: tool.description,
338
- input_schema: tool.parameters_schema
341
+ input_schema: tool.parameters_schema(strict: true)
339
342
  }
340
343
  end
341
344
  end
@@ -46,11 +46,13 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
46
46
  end
47
47
 
48
48
  if structured_output
49
+ # OpenAI requires strict mode schemas.
50
+ # https://platform.openai.com/docs/guides/structured-outputs#all-fields-must-be-required
49
51
  params[:text] = {
50
52
  format: {
51
53
  type: "json_schema",
52
54
  name: "response",
53
- schema: structured_output.json_schema,
55
+ schema: structured_output.json_schema(strict: true),
54
56
  strict: true
55
57
  }
56
58
  }
@@ -292,7 +294,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
292
294
  type: "function",
293
295
  name: tool.name,
294
296
  description: tool.description,
295
- parameters: tool.parameters_schema,
297
+ parameters: tool.parameters_schema(strict: true),
296
298
  strict: true
297
299
  }
298
300
  end
@@ -14,12 +14,17 @@ require "json"
14
14
  #
15
15
  class Riffer::StructuredOutput
16
16
  attr_reader :params #: Riffer::Params
17
- attr_reader :json_schema #: Hash[Symbol, untyped]
18
17
 
19
18
  #: (Riffer::Params) -> void
20
19
  def initialize(params)
21
20
  @params = params
22
- @json_schema = @params.to_json_schema
21
+ end
22
+
23
+ # Returns the JSON Schema for this structured output.
24
+ #
25
+ #: (?strict: bool) -> Hash[Symbol, untyped]
26
+ def json_schema(strict: false)
27
+ @params.to_json_schema(strict: strict)
23
28
  end
24
29
 
25
30
  # Parses a JSON string and validates it against the schema.
data/lib/riffer/tool.rb CHANGED
@@ -74,9 +74,9 @@ class Riffer::Tool
74
74
 
75
75
  # Returns the JSON Schema for the tool's parameters.
76
76
  #
77
- #: () -> Hash[Symbol, untyped]
78
- def self.parameters_schema
79
- @params_builder&.to_json_schema || empty_schema
77
+ #: (?strict: bool) -> Hash[Symbol, untyped]
78
+ def self.parameters_schema(strict: false)
79
+ @params_builder&.to_json_schema(strict: strict) || empty_schema
80
80
  end
81
81
 
82
82
  def self.empty_schema # :nodoc:
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.15.1" #: String
5
+ VERSION = "0.16.0" #: String
6
6
  end
@@ -0,0 +1,10 @@
1
+ # Generated from lib/riffer/boolean.rb with RBS::Inline
2
+
3
+ # Riffer::Boolean is a sentinel type for declaring boolean parameters.
4
+ #
5
+ # Ruby has no +Boolean+ class (+true+ is +TrueClass+, +false+ is +FalseClass+).
6
+ # Use this module wherever you need a single type that means "boolean":
7
+ #
8
+ # required :verbose, Riffer::Boolean
9
+ module Riffer::Boolean
10
+ end
@@ -5,7 +5,10 @@
5
5
  # Handles type validation and JSON Schema generation for individual parameters.
6
6
  class Riffer::Param
7
7
  # Maps Ruby types to JSON Schema type strings
8
- TYPE_MAPPINGS: Hash[Class, String]
8
+ TYPE_MAPPINGS: Hash[Module, String]
9
+
10
+ # Primitive types allowed for the +of:+ keyword on Array params
11
+ PRIMITIVE_TYPES: Array[Class]
9
12
 
10
13
  attr_reader name: Symbol
11
14
 
@@ -19,8 +22,12 @@ class Riffer::Param
19
22
 
20
23
  attr_reader default: untyped
21
24
 
22
- # : (name: Symbol, type: Class, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped) -> void
23
- def initialize: (name: Symbol, type: Class, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped) -> void
25
+ attr_reader item_type: Class?
26
+
27
+ attr_reader nested_params: Riffer::Params?
28
+
29
+ # : (name: Symbol, type: Class, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?item_type: Class?, ?nested_params: Riffer::Params?) -> void
30
+ def initialize: (name: Symbol, type: Class, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?item_type: Class?, ?nested_params: Riffer::Params?) -> void
24
31
 
25
32
  # Validates that a value matches the expected type.
26
33
  #
@@ -34,6 +41,10 @@ class Riffer::Param
34
41
 
35
42
  # Converts this parameter to JSON Schema format.
36
43
  #
37
- # : () -> Hash[Symbol, untyped]
38
- def to_json_schema: () -> Hash[Symbol, untyped]
44
+ # When +strict+ is true, optional parameters are made nullable
45
+ # (+["type", "null"]+) so that strict mode providers can distinguish
46
+ # "absent" from "present" without rejecting the schema.
47
+ #
48
+ # : (?strict: bool) -> Hash[Symbol, untyped]
49
+ def to_json_schema: (?strict: bool) -> Hash[Symbol, untyped]
39
50
  end
@@ -17,13 +17,13 @@ class Riffer::Params
17
17
 
18
18
  # Defines a required parameter.
19
19
  #
20
- # : (Symbol, Class, ?description: String?, ?enum: Array[untyped]?) -> void
21
- def required: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?) -> void
20
+ # : (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?of: Class?) ?{ () -> void } -> void
21
+ def required: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?of: Class?) ?{ () -> void } -> void
22
22
 
23
23
  # Defines an optional parameter.
24
24
  #
25
- # : (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?default: untyped) -> void
26
- def optional: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?default: untyped) -> void
25
+ # : (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?of: Class?) ?{ () -> void } -> void
26
+ def optional: (Symbol, Class, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?of: Class?) ?{ () -> void } -> void
27
27
 
28
28
  # Validates arguments against parameter definitions.
29
29
  #
@@ -34,6 +34,30 @@ class Riffer::Params
34
34
 
35
35
  # Converts all parameters to JSON Schema format.
36
36
  #
37
- # : () -> Hash[Symbol, untyped]
38
- def to_json_schema: () -> Hash[Symbol, untyped]
37
+ # When +strict+ is true, every property appears in +required+ and
38
+ # optional properties are made nullable instead. This satisfies
39
+ # providers that enforce strict structured output schemas.
40
+ #
41
+ # : (?strict: bool) -> Hash[Symbol, untyped]
42
+ def to_json_schema: (?strict: bool) -> Hash[Symbol, untyped]
43
+
44
+ private
45
+
46
+ # : (Class, Class?) ?{ () -> void } -> Riffer::Params?
47
+ def build_nested: (Class, Class?) ?{ () -> void } -> Riffer::Params?
48
+
49
+ # : (Riffer::Param, untyped, Array[String]) -> untyped
50
+ def validate_nested: (Riffer::Param, untyped, Array[String]) -> untyped
51
+
52
+ # : (Riffer::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
53
+ def validate_nested_hash: (Riffer::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
54
+
55
+ # : (Riffer::Param, Array[untyped], Array[String]) -> Array[untyped]
56
+ def validate_nested_array_of_objects: (Riffer::Param, Array[untyped], Array[String]) -> Array[untyped]
57
+
58
+ # : (Riffer::Param, Array[untyped], Array[String]) -> void
59
+ def validate_typed_array: (Riffer::Param, Array[untyped], Array[String]) -> void
60
+
61
+ # : (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
62
+ def deep_symbolize_keys: (Hash[untyped, untyped]) -> Hash[Symbol, untyped]
39
63
  end
@@ -11,11 +11,14 @@
11
11
  class Riffer::StructuredOutput
12
12
  attr_reader params: Riffer::Params
13
13
 
14
- attr_reader json_schema: Hash[Symbol, untyped]
15
-
16
14
  # : (Riffer::Params) -> void
17
15
  def initialize: (Riffer::Params) -> void
18
16
 
17
+ # Returns the JSON Schema for this structured output.
18
+ #
19
+ # : (?strict: bool) -> Hash[Symbol, untyped]
20
+ def json_schema: (?strict: bool) -> Hash[Symbol, untyped]
21
+
19
22
  # Parses a JSON string and validates it against the schema.
20
23
  #
21
24
  # Returns a Result with the validated object on success, or an error message on failure.
@@ -54,8 +54,8 @@ class Riffer::Tool
54
54
 
55
55
  # Returns the JSON Schema for the tool's parameters.
56
56
  #
57
- # : () -> Hash[Symbol, untyped]
58
- def self.parameters_schema: () -> Hash[Symbol, untyped]
57
+ # : (?strict: bool) -> Hash[Symbol, untyped]
58
+ def self.parameters_schema: (?strict: bool) -> Hash[Symbol, untyped]
59
59
 
60
60
  def self.empty_schema: () -> untyped
61
61
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riffer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.1
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -218,6 +218,7 @@ files:
218
218
  - lib/riffer.rb
219
219
  - lib/riffer/agent.rb
220
220
  - lib/riffer/agent/response.rb
221
+ - lib/riffer/boolean.rb
221
222
  - lib/riffer/config.rb
222
223
  - lib/riffer/core.rb
223
224
  - lib/riffer/evals.rb
@@ -278,6 +279,7 @@ files:
278
279
  - sig/generated/riffer.rbs
279
280
  - sig/generated/riffer/agent.rbs
280
281
  - sig/generated/riffer/agent/response.rbs
282
+ - sig/generated/riffer/boolean.rbs
281
283
  - sig/generated/riffer/config.rbs
282
284
  - sig/generated/riffer/core.rbs
283
285
  - sig/generated/riffer/evals.rbs