riffer 0.53.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 70784c56c20102b8da3e1e147bc843db2287e2be910f1e10d26b8ee9a2f8e0f9
4
- data.tar.gz: 6df766acde899e39867a06de11fca0d013671e4bda34d3759b4ae310a43d04de
3
+ metadata.gz: da8c08b227ba3ddddaa025377b70f1dfb9acaa3c86b52ff9866446e8e908443c
4
+ data.tar.gz: 598cddab932d5f4e3c775847eafaba44d7d0fe21741539927fa1cad15cb1767e
5
5
  SHA512:
6
- metadata.gz: 39af9afa9f695fe6dcc661915e1173ddf812db2ee425b83f77ee42d2080cea623b0f03f82c80ae606e7b4caf2f42bb3799a63ed0955ab7baa13b2c30861a2781
7
- data.tar.gz: 1f79ef71cf1a838092444368f6986cd772803aeffe0de9acadc5cf8c23834e0e56c04e5049001eaeab3360d13a0eb3767a8118f429cacdbad7372879706ce332
6
+ metadata.gz: d01ca1aa3fa2f183c93a7c783eb5d23040f8e76d403114abe4f53f2cb4a299b69046a0397c405380013467d0c035e9cb1b7e1fcc72b45e945a7a6c786159b274
7
+ data.tar.gz: 3f568d27f9b5cbd12d23be97acbe049d8e9dd70f817bc6375d273f2cae62d60cbc7ebf40d7588d516739107734a12f3b450863a13f327ed1e2d1d46b7f60ca1d
@@ -68,13 +68,13 @@ end
68
68
 
69
69
  ### Where stubs and stdlib deps live
70
70
 
71
- Dependency signatures come from [`rbs collection`](https://github.com/ruby/gem_rbs_collection): `rbs_collection.yaml` pins the upstream repo to a commit SHA, the gem set is derived from `Gemfile.lock`, and `rbs_collection.lock.yaml` is committed (the installed `.gem_rbs_collection/` is gitignored). Steep auto-detects the config, so the `Steepfile` declares no `library` lines. `yaml` is the one hand-listed entry under `gems:` — it's stdlib, so it never appears in `Gemfile.lock`.
71
+ Dependency signatures come from [`rbs collection`](https://github.com/ruby/gem_rbs_collection): `rbs_collection.yaml` pins the upstream repo to a commit SHA, the gem set is derived from `Gemfile.lock`, and `rbs_collection.lock.yaml` is committed (the installed `.gem_rbs_collection/` is gitignored). Steep auto-detects the config, so the `Steepfile` declares no `library` lines. Stdlib libraries that no gem in `Gemfile.lock` pulls in are hand-listed under `gems:` (`yaml`, `cgi`, `date`, `ipaddr`).
72
72
 
73
73
  `bin/typecheck` runs `rbs collection update` before `steep check`, keeping the lock in sync with `Gemfile.lock`; CI fails on lock drift. So after a Dependabot gem bump, run `bin/typecheck` and commit the refreshed lock. To pull newer upstream stubs, bump `revision` in `rbs_collection.yaml` and run `bin/typecheck` — pinning is what makes `update` deterministic.
74
74
 
75
75
  - `sig/_private/` — signatures that must **not** ship. RBS **skips** `_`-prefixed directories in library mode, so consumers never load them; riffer's own `steep check` does (via the `Steepfile`). Two kinds, by predictable path: external-gem signatures are named by gem at the top level — full stubs for the gems upstream has nothing for (`mcp.rbs`, `zeitwerk.rbs`, `opentelemetry.rbs`, `rspec.rbs`, `aws-sdk-core/*`) plus arity patches for the provider SDKs (`openai.rbs`, `anthropic.rbs`); riffer's own hidden stubs mirror `lib/` under `riffer/` (e.g. `riffer/providers/anthropic.rbs` narrows the private `client` method to the SDK-typed client). A stub must be **deleted** once the collection covers that gem, or RBS raises `DuplicatedMethodDefinitionError`.
76
76
  - `sig/manual/` — hand-written riffer-only signatures that are **safe to ship**, for the few things rbs-inline can't generate _at all_ (mirroring `lib/`). In practice that's `extend self` modules (`riffer/agent/run.rbs`, `riffer/helpers/call_or_value.rbs`) and modeling an include applied dynamically (`riffer/tools/toolable.rbs`). SDK-free ivars are **not** hand-written here — declare them inline with `# @rbs` (see "Annotation Conventions"). SDK-typed signatures can't ship, so they go in `_private/riffer/providers/` (the narrowed `client`).
77
- - `sig/manifest.yaml` — declares the **stdlib** RBS the shipped sigs reference (`uri`, `net-http`) so `rbs -r riffer` resolves them.
77
+ - `sig/manifest.yaml` — declares the **stdlib** RBS the shipped sigs reference (`uri`, `net-http`, `digest`, `ipaddr`) so `rbs -r riffer` resolves them.
78
78
 
79
79
  ## Workflow
80
80
 
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.53.0"
2
+ ".": "0.54.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ 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.54.0](https://github.com/janeapp/riffer/compare/riffer/v0.53.0...riffer/v0.54.0) (2026-10-20)
9
+
10
+
11
+ ### Features
12
+
13
+ * **params:** add `format:` for string formats ([#497](https://github.com/janeapp/riffer/issues/497)) ([3c2dec8](https://github.com/janeapp/riffer/commit/3c2dec8e70b28de2d7b9d655e8d17ce85dde6812))
14
+ * **params:** add `Riffer::Params::AnyOf` ([#491](https://github.com/janeapp/riffer/issues/491)) ([65493ca](https://github.com/janeapp/riffer/commit/65493caa8073cf86983ec27dc6fb84ede068b6b9))
15
+ * **params:** add arrays of arrays with nested `of:` ([#498](https://github.com/janeapp/riffer/issues/498)) ([2b3449c](https://github.com/janeapp/riffer/commit/2b3449c61474f5cc79643903c08668a8fd9db8b6))
16
+ * **structured_output:** append prompted schema to user messages ([#492](https://github.com/janeapp/riffer/issues/492)) ([332db21](https://github.com/janeapp/riffer/commit/332db216d2e7716904d5ff4a9c1d9d42e128ac83))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **params:** apply enum to each item of an Array of: param ([#493](https://github.com/janeapp/riffer/issues/493)) ([09da9b0](https://github.com/janeapp/riffer/commit/09da9b0ccb0e63b2ed3202a28546371b17118e8c))
22
+
8
23
  ## [0.53.0](https://github.com/janeapp/riffer/compare/riffer/v0.52.1...riffer/v0.53.0) (2026-10-13)
9
24
 
10
25
 
data/Steepfile CHANGED
@@ -2,6 +2,25 @@
2
2
 
3
3
  D = Steep::Diagnostic
4
4
 
5
+ # RBS 4.2.0 re-validates every declaration of a module each time its type
6
+ # params are read, which is quadratic in how often a module is reopened. The
7
+ # openai and anthropic gems reopen their namespace ~1,600 times each, so
8
+ # loading signatures takes over a minute per Steep worker. Fixed upstream in
9
+ # ruby/rbs a5458a83; delete this once the bundle has an RBS release newer
10
+ # than 4.2.
11
+ if RBS::Environment::ModuleEntry.instance_method(:validate_type_params).owner == RBS::Environment::ModuleEntry
12
+ validate_once = Module.new do
13
+ def validate_type_params
14
+ return if @validated_decl_count == context_decls.size
15
+
16
+ super
17
+ @validated_decl_count = context_decls.size
18
+ end
19
+ end
20
+ RBS::Environment::ModuleEntry.prepend(validate_once)
21
+ RBS::Environment::ClassEntry.prepend(validate_once)
22
+ end
23
+
5
24
  target :lib do
6
25
  signature "sig/generated"
7
26
  signature "sig/manual"
data/docs/AGENTS.md CHANGED
@@ -212,12 +212,12 @@ class SentimentAgent < Riffer::Agent
212
212
  end
213
213
  ```
214
214
 
215
- With `strategy: :prompted`, the provider never receives its native structured-output field. Instead, an instruction carrying the JSON Schema is appended to the system instructions on every request of the run (it is not stored in `session.messages`), and the reply is parsed and validated exactly as above: `response.content` is the reply's JSON text, `response.structured_output` holds the validated object, and a reply that fails parsing or validation ends with `:invalid_structured_output`. Because nothing forces the model to comply, expect more `:invalid_structured_output` outcomes than with the native strategy.
215
+ With `strategy: :prompted`, the provider never receives its native structured-output field. Instead, an instruction is appended to the end of every user message on every request of the run, after consecutive user messages are merged (it is not stored in `session.messages`, and the system instructions are left untouched). The instruction carries the JSON Schema, an example of the expected shape with placeholder values (`"<string>"`, enum values as `"a | b"`, an `AnyOf` field as its first variant), and a nudge to reply with the JSON right away unless the request needs a tool. The reply is parsed and validated exactly as above: `response.content` is the reply's JSON text, `response.structured_output` holds the validated object, and a reply that fails parsing or validation ends with `:invalid_structured_output`. Because nothing forces the model to comply, expect more `:invalid_structured_output` outcomes than with the native strategy.
216
216
 
217
217
  | Strategy | Behavior |
218
218
  | ------------------- | --------------------------------------------------------------- |
219
219
  | `:native` (default) | Schema sent through the provider's structured-output field |
220
- | `:prompted` | Schema sent in the system prompt; experimental, may change |
220
+ | `:prompted` | Schema appended to each user message; experimental, may change |
221
221
 
222
222
  Riffer does not pick a strategy per model — the choice is always yours. The strategy is copied to subclasses and survives [serialization](SERIALIZATION.md). An unknown value raises `Riffer::ArgumentError`. `strategy:` can also be passed with a `Riffer::Params` instance: `structured_output params, strategy: :prompted`.
223
223
 
@@ -249,7 +249,46 @@ structured_output do
249
249
  end
250
250
  ```
251
251
 
252
- Only primitive types are allowed with `of:`: `String`, `Integer`, `Float`, `TrueClass`, `FalseClass`.
252
+ `of:` takes a primitive type: `String`, `Integer`, `Float`, `Riffer::Params::Boolean`, `TrueClass`, `FalseClass`.
253
+
254
+ Add `enum:` to limit each item to a list of values:
255
+
256
+ ```ruby
257
+ structured_output do
258
+ required :tags, Array, of: String, enum: %w[urgent billing technical]
259
+ end
260
+ ```
261
+
262
+ An item outside the list fails validation with its index: `tags[1] must be one of: urgent, billing, technical`.
263
+
264
+ For an array of arrays, put the type in brackets. Each pair of brackets adds a level, and `enum:` and `format:` apply to the innermost items:
265
+
266
+ ```ruby
267
+ structured_output do
268
+ required :rows, Array, of: [Integer], description: "Seat numbers, one array per row"
269
+ required :board, Array, of: [String], enum: %w[x o]
270
+ end
271
+ ```
272
+
273
+ A row that isn't an array fails validation with its index: `rows[1] must be an array`.
274
+
275
+ #### Formats
276
+
277
+ Use `format:` on a `String`, or on an `Array` whose innermost type is `String`, to require a standard string format:
278
+
279
+ ```ruby
280
+ structured_output do
281
+ required :born_on, String, format: "date"
282
+ optional :email, String, format: "email"
283
+ required :visit_ids, Array, of: String, format: "uuid"
284
+ end
285
+ ```
286
+
287
+ Supported formats: `date-time`, `time`, `date`, `duration`, `email`, `hostname`, `ipv4`, `ipv6`, `uuid`. Dates and times use RFC 3339 syntax, and a `time` may leave out its UTC offset. `email` and `hostname` are checked loosely, rejecting only values that are clearly not an address or a host name.
288
+
289
+ A value that doesn't match fails validation, with its index on an array: `born_on must be a valid date`, `visit_ids[1] must be a valid uuid`. Values stay strings.
290
+
291
+ A model that must match a format writes a matching value even when it has none, so it may invent an email. Make such a field `optional` so the model can return `null`.
253
292
 
254
293
  #### Arrays of Objects
255
294
 
@@ -285,9 +324,32 @@ structured_output do
285
324
  end
286
325
  ```
287
326
 
327
+ #### Either/Or Shapes
328
+
329
+ Use `Riffer::Params::AnyOf` when a field must be one of several object shapes. Each `variant` block defines one shape, the same way a `Hash` block does:
330
+
331
+ ```ruby
332
+ structured_output do
333
+ required :result, Riffer::Params::AnyOf, description: "The address, or why none was found" do
334
+ variant do
335
+ required :street, String, description: "Street address"
336
+ required :city, String, description: "City"
337
+ end
338
+ variant do
339
+ required :error_code, String, enum: %w[no_address multiple_addresses]
340
+ required :error_message, String
341
+ end
342
+ end
343
+ end
344
+ ```
345
+
346
+ The schema sends the field as `anyOf`, with one object per variant. An answer must match one variant completely: every required field of that variant, and no field from outside it. Variants are tried in order and the first match is returned. When none match, the error lists each variant's problems: `result must match one of the variants: variant 1 (street is required, city is required), variant 2 (error_code is required, error_message is required)`.
347
+
348
+ An optional `AnyOf` field can also be `null`.
349
+
288
350
  #### Limitations
289
351
 
290
- A `Hash` param requires a block, and an `Array` param requires a block or `of:`. Using both `of:` and a block raises `Riffer::ArgumentError`. Using `of:` with a non-primitive type (e.g. `of: Hash`) also raises `Riffer::ArgumentError`.
352
+ A `Hash` param requires a block, and an `Array` param requires a block or `of:`. Using both `of:` and a block raises `Riffer::ArgumentError`. Using `of:` with a non-primitive type (e.g. `of: Hash` or `of: [Hash]`), or with more than one type (`of: [Integer, String]`), also raises `Riffer::ArgumentError`. An `AnyOf` param requires a block with at least two `variant`s, and can't be used with `of:` or `enum:`. Using `enum:` on a `Hash`, or on an `Array` without `of:`, also raises `Riffer::ArgumentError`. So does `format:` with an unsupported format, on a type other than `String` or an `Array` whose innermost type is `String`, or together with `enum:`.
291
353
 
292
354
  Structured output is not compatible with streaming — calling `stream` on an agent with structured output configured raises `Riffer::ArgumentError`.
293
355
 
data/docs/OVERVIEW.md CHANGED
@@ -39,7 +39,7 @@ See [Tools](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. Schemas support nested objects (`Hash`), typed arrays (`Array, of:`), and arrays of objects (`Array` with block):
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:`), arrays of arrays (`Array, of: [Integer]`), arrays of objects (`Array` with block), and either/or shapes (`Riffer::Params::AnyOf`):
43
43
 
44
44
  ```ruby
45
45
  class SentimentAgent < Riffer::Agent
data/docs/TOOLS.md CHANGED
@@ -82,7 +82,8 @@ end
82
82
  Options:
83
83
 
84
84
  - `description` - Human-readable description for the LLM
85
- - `enum` - Array of allowed values
85
+ - `enum` - Array of allowed values (for the innermost items of an `Array` with `of:`)
86
+ - `format` - String format such as `"date"` or `"email"` (for the innermost `String` items of an `Array`; see [Formats](AGENTS.md#formats))
86
87
 
87
88
  ### optional
88
89
 
@@ -99,19 +100,21 @@ Options:
99
100
 
100
101
  - `description` - Human-readable description
101
102
  - `default` - Default value when not provided
102
- - `enum` - Array of allowed values
103
+ - `enum` - Array of allowed values (for the innermost items of an `Array` with `of:`)
104
+ - `format` - String format such as `"date"` or `"email"` (for the innermost `String` items of an `Array`; see [Formats](AGENTS.md#formats))
103
105
 
104
106
  ### Supported Types
105
107
 
106
- | Ruby Type | JSON Schema Type |
107
- | -------------------------- | ---------------- |
108
- | `String` | `string` |
109
- | `Integer` | `integer` |
110
- | `Float` | `number` |
111
- | `Riffer::Params::Boolean` | `boolean` |
112
- | `TrueClass` / `FalseClass` | `boolean` |
113
- | `Array` | `array` |
114
- | `Hash` | `object` |
108
+ | Ruby Type | JSON Schema Type |
109
+ | -------------------------- | ------------------ |
110
+ | `String` | `string` |
111
+ | `Integer` | `integer` |
112
+ | `Float` | `number` |
113
+ | `Riffer::Params::Boolean` | `boolean` |
114
+ | `TrueClass` / `FalseClass` | `boolean` |
115
+ | `Array` | `array` |
116
+ | `Hash` | `object` |
117
+ | `Riffer::Params::AnyOf` | `anyOf` of objects |
115
118
 
116
119
  `Riffer::Params::Boolean` is the preferred way to declare boolean parameters. `TrueClass` and `FalseClass` continue to work for backwards compatibility.
117
120
 
@@ -119,7 +122,7 @@ A `Float` param accepts a whole number too, since JSON Schema's `number` covers
119
122
 
120
123
  ### Nested Parameters
121
124
 
122
- 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](AGENTS.md#nested-objects) for full syntax.
125
+ Tool params support the same nested DSL as structured output — nested objects (`Hash` with block), typed arrays (`Array, of:`), arrays of arrays (`Array, of: [Integer]`), arrays of objects (`Array` with block), and either/or shapes (`Riffer::Params::AnyOf` with `variant` blocks). See the [structured output section in Agents](AGENTS.md#nested-objects) for full syntax.
123
126
 
124
127
  ```ruby
125
128
  class CreateOrderTool < Riffer::Tool
@@ -172,3 +172,4 @@ A `FilePart.from_url` source works too — riffer downloads and base64-encodes i
172
172
 
173
173
  - **No web search** - Gemini's standard API does not include a web search tool
174
174
  - **Tool call IDs** - Gemini does not return unique call IDs for tool invocations; IDs are generated client-side
175
+ - **String enums only** - Gemini rejects non-string `enum` values, including on `Array, of:` items
@@ -30,9 +30,14 @@ class Riffer::Agent::StructuredOutput
30
30
  #--
31
31
  #: () -> String
32
32
  def prompt_instructions
33
- "When you give your final answer, respond with only a JSON object that conforms to " \
34
- "the following JSON Schema, with no other text before or after it:\n\n" \
35
- "#{JSON.generate(json_schema)}"
33
+ schema = json_schema
34
+ [
35
+ "When you give your final answer, respond with only a JSON object that conforms to " \
36
+ "the following JSON Schema, with no other text before or after it:",
37
+ JSON.generate(schema),
38
+ "Example of the shape (placeholder values):\n#{JSON.generate(placeholder(schema))}",
39
+ "Only call tools when the request needs them. If you can answer directly, reply with the JSON object right away.",
40
+ ].join("\n\n")
36
41
  end
37
42
 
38
43
  #--
@@ -46,4 +51,19 @@ class Riffer::Agent::StructuredOutput
46
51
  rescue Riffer::ValidationError => e
47
52
  Result.new(error: "Validation error: #{e.message}")
48
53
  end
54
+
55
+ private
56
+
57
+ #--
58
+ #: (Hash[Symbol, untyped]) -> untyped
59
+ def placeholder(schema)
60
+ return schema[:enum].join(" | ") if schema[:enum]
61
+ return placeholder(schema[:anyOf].first) if schema[:anyOf]
62
+
63
+ case schema[:type]
64
+ when "object" then (schema[:properties] || {}).transform_values { |property| placeholder(property) }
65
+ when "array" then schema[:items] ? [placeholder(schema[:items])] : []
66
+ else "<#{schema[:type]}>"
67
+ end
68
+ end
49
69
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ class Riffer::Params::AnyOf
5
+ attr_reader :variants #: Array[Riffer::Params] # @dynamic variants
6
+
7
+ #--
8
+ #: () -> void
9
+ def initialize
10
+ @variants = []
11
+ end
12
+
13
+ #--
14
+ #: () ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
15
+ def variant(&block)
16
+ raise Riffer::ArgumentError, "variant requires a block defining its properties" unless block
17
+
18
+ params = Riffer::Params.new
19
+ params.instance_eval(&block)
20
+ @variants << params
21
+ end
22
+ end
@@ -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)
@@ -38,7 +38,7 @@ class Riffer::Providers::Base
38
38
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
39
39
  validate_normalized_messages!(messages)
40
40
  Riffer::Files::Resolver.new(provider: self).resolve!(messages)
41
- messages = merge_consecutive_messages(with_structured_output_prompt(messages, options[:structured_output]))
41
+ messages = with_structured_output_prompt(merge_consecutive_messages(messages), options[:structured_output])
42
42
  options = apply_reasoning(model, options)
43
43
  params = build_request_params(messages, model, request_options(options))
44
44
 
@@ -77,7 +77,7 @@ class Riffer::Providers::Base
77
77
  messages = normalize_messages(prompt: prompt, system: system, messages: messages, files: files)
78
78
  validate_normalized_messages!(messages)
79
79
  Riffer::Files::Resolver.new(provider: self).resolve!(messages)
80
- messages = merge_consecutive_messages(with_structured_output_prompt(messages, options[:structured_output]))
80
+ messages = with_structured_output_prompt(merge_consecutive_messages(messages), options[:structured_output])
81
81
  options = apply_reasoning(model, options)
82
82
  params = build_request_params(messages, model, request_options(options))
83
83
 
@@ -427,13 +427,13 @@ class Riffer::Providers::Base
427
427
  def with_structured_output_prompt(messages, structured_output)
428
428
  return messages unless structured_output&.prompted?
429
429
 
430
- # After the leading system messages, so merge_consecutive_messages folds it into the system prompt.
431
- leading_system = messages.take_while { |m| m.is_a?(Riffer::Messages::System) }
432
- [
433
- *leading_system,
434
- Riffer::Messages::System.new(structured_output.prompt_instructions),
435
- *messages.drop(leading_system.length),
436
- ]
430
+ # Models follow the schema more reliably at the end of each user turn than in the system prompt.
431
+ instructions = structured_output.prompt_instructions
432
+ messages.map do |message|
433
+ next message unless message.is_a?(Riffer::Messages::User)
434
+
435
+ Riffer::Messages::User.new("#{message.content}\n\n#{instructions}", id: message.id, files: message.files)
436
+ end
437
437
  end
438
438
 
439
439
  #--
@@ -340,6 +340,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
340
340
  end
341
341
 
342
342
  schema[:items] = strip_additional_properties(schema[:items]) if schema[:items].is_a?(Hash)
343
+ schema[:anyOf] = schema[:anyOf].map { |branch| strip_additional_properties(branch) } if schema[:anyOf]
343
344
 
344
345
  schema
345
346
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.53.0" #: String
5
+ VERSION = "0.54.0" #: String
6
6
  end
@@ -77,6 +77,10 @@ gems:
77
77
  revision: 3913b7dffb65bdffdfe5cb9095108f932e85433b
78
78
  remote: https://github.com/ruby/gem_rbs_collection.git
79
79
  repo_dir: gems
80
+ - name: date
81
+ version: '0'
82
+ source:
83
+ type: stdlib
80
84
  - name: dbm
81
85
  version: '0'
82
86
  source:
@@ -125,6 +129,10 @@ gems:
125
129
  version: '0'
126
130
  source:
127
131
  type: stdlib
132
+ - name: ipaddr
133
+ version: '0'
134
+ source:
135
+ type: stdlib
128
136
  - name: json
129
137
  version: '0'
130
138
  source:
data/rbs_collection.yaml CHANGED
@@ -10,3 +10,5 @@ path: .gem_rbs_collection
10
10
  gems:
11
11
  - name: yaml
12
12
  - name: cgi
13
+ - name: date
14
+ - name: ipaddr
@@ -25,4 +25,10 @@ class Riffer::Agent::StructuredOutput
25
25
  # --
26
26
  # : (String) -> Riffer::Agent::StructuredOutput::Result
27
27
  def parse_and_validate: (String) -> Riffer::Agent::StructuredOutput::Result
28
+
29
+ private
30
+
31
+ # --
32
+ # : (Hash[Symbol, untyped]) -> untyped
33
+ def placeholder: (Hash[Symbol, untyped]) -> untyped
28
34
  end
@@ -0,0 +1,13 @@
1
+ # Generated from lib/riffer/params/any_of.rb with RBS::Inline
2
+
3
+ class Riffer::Params::AnyOf
4
+ attr_reader variants: Array[Riffer::Params]
5
+
6
+ # --
7
+ # : () -> void
8
+ def initialize: () -> void
9
+
10
+ # --
11
+ # : () ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
12
+ def variant: () ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
13
+ end
@@ -0,0 +1,39 @@
1
+ # Generated from lib/riffer/params/format.rb with RBS::Inline
2
+
3
+ module Riffer::Params::Format
4
+ DATE: Regexp
5
+
6
+ DATE_TIME: Regexp
7
+
8
+ # The offset is optional: Anthropic's grammar emits times without one.
9
+ TIME: Regexp
10
+
11
+ DURATION: Regexp
12
+
13
+ UUID: Regexp
14
+
15
+ # The WHATWG HTML pattern: no single pattern matches RFC 5322 exactly, so
16
+ # this one only rules out values that are clearly not an address.
17
+ EMAIL: Regexp
18
+
19
+ HOSTNAME: Regexp
20
+
21
+ # The formats OpenAI, Anthropic and Bedrock all accept. OpenAI rejects uri.
22
+ CHECKS: Hash[String, ^(String) -> bool]
23
+
24
+ NAMES: Array[String]
25
+
26
+ # --
27
+ # : (String, String) -> bool
28
+ def valid?: (String, String) -> bool
29
+
30
+ private
31
+
32
+ # --
33
+ # : (Regexp, String) -> bool
34
+ def calendar_date?: (Regexp, String) -> bool
35
+
36
+ # --
37
+ # : (String) { (IPAddr) -> bool } -> bool
38
+ def ip_address?: (String) { (IPAddr) -> bool } -> bool
39
+ end
@@ -17,27 +17,41 @@ class Riffer::Params::Param
17
17
 
18
18
  attr_reader enum: Array[untyped]?
19
19
 
20
+ attr_reader format: String?
21
+
20
22
  attr_reader default: untyped
21
23
 
22
24
  attr_reader item_type: Module?
23
25
 
26
+ attr_reader dimensions: Integer
27
+
24
28
  attr_reader nested_params: Riffer::Params?
25
29
 
30
+ attr_reader variants: Array[Riffer::Params]?
31
+
26
32
  # --
27
33
  # : (Symbol, Hash[Symbol, untyped], required: bool) -> Riffer::Params::Param
28
34
  def self.from_json_schema: (Symbol, Hash[Symbol, untyped], required: bool) -> Riffer::Params::Param
29
35
 
30
36
  # --
31
- # : (Module, Hash[Symbol, untyped]) -> [Module?, Riffer::Params?]
32
- def self.resolve_nesting: (Module, Hash[Symbol, untyped]) -> [ Module?, Riffer::Params? ]
37
+ # : (Array[Hash[Symbol, untyped]]) -> Array[Riffer::Params]
38
+ def self.resolve_variants: (Array[Hash[Symbol, untyped]]) -> Array[Riffer::Params]
39
+
40
+ # --
41
+ # : (Module, Hash[Symbol, untyped]) -> Riffer::Params?
42
+ def self.resolve_nested_params: (Module, Hash[Symbol, untyped]) -> Riffer::Params?
43
+
44
+ # --
45
+ # : (Hash[Symbol, untyped]) -> [Module, Integer, Hash[Symbol, untyped]]?
46
+ def self.resolve_typed_items: (Hash[Symbol, untyped]) -> [ Module, Integer, Hash[Symbol, untyped] ]?
33
47
 
34
48
  # --
35
49
  # : (untyped) -> Module
36
50
  def self.json_type_to_ruby: (untyped) -> Module
37
51
 
38
52
  # --
39
- # : (name: Symbol, type: Module, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?item_type: Module?, ?nested_params: Riffer::Params?) -> void
40
- def initialize: (name: Symbol, type: Module, required: bool, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?item_type: Module?, ?nested_params: Riffer::Params?) -> void
53
+ # : (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
54
+ def initialize: (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
41
55
 
42
56
  # --
43
57
  # : (untyped) -> bool
@@ -60,6 +74,10 @@ class Riffer::Params::Param
60
74
  # : (Riffer::Params::Param) -> void
61
75
  def initialize_copy: (Riffer::Params::Param) -> void
62
76
 
77
+ # --
78
+ # : () -> void
79
+ def validate_format!: () -> void
80
+
63
81
  # --
64
82
  # : () -> void
65
83
  def validate_strict_shape!: () -> void
@@ -14,12 +14,16 @@ class Riffer::Params
14
14
  def self.from_json_schema: (Hash[Symbol, untyped]) -> Riffer::Params
15
15
 
16
16
  # --
17
- # : (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?of: Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
18
- def required: (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?of: Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
17
+ # : (Symbol, singleton(Riffer::Params::AnyOf), ?description: String?) { (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> void
18
+ # : (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?format: String?, ?of: (Module | Array[untyped])?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
19
+ def required: (Symbol, singleton(Riffer::Params::AnyOf), ?description: String?) { (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> void
20
+ | (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?format: String?, ?of: (Module | Array[untyped])?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
19
21
 
20
22
  # --
21
- # : (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?of: Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
22
- def optional: (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?default: untyped, ?of: Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
23
+ # : (Symbol, singleton(Riffer::Params::AnyOf), ?description: String?, ?default: untyped) { (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> void
24
+ # : (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?format: String?, ?default: untyped, ?of: (Module | Array[untyped])?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
25
+ def optional: (Symbol, singleton(Riffer::Params::AnyOf), ?description: String?, ?default: untyped) { (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> void
26
+ | (Symbol, Module, ?description: String?, ?enum: Array[untyped]?, ?format: String?, ?default: untyped, ?of: (Module | Array[untyped])?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> void
23
27
 
24
28
  # --
25
29
  # : (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
@@ -38,8 +42,16 @@ class Riffer::Params
38
42
  def initialize_copy: (Riffer::Params) -> void
39
43
 
40
44
  # --
41
- # : (Module, Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
42
- def build_nested: (Module, Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
45
+ # : ((Module | Array[untyped])?, ?Integer) -> [Module?, Integer]
46
+ def unwrap_of: ((Module | Array[untyped])?, ?Integer) -> [ Module?, Integer ]
47
+
48
+ # --
49
+ # : (Module, Module?, Array[untyped]?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
50
+ def build_nested: (Module, Module?, Array[untyped]?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
51
+
52
+ # --
53
+ # : ((Module | Array[untyped])?, Array[untyped]?) ?{ (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> Array[Riffer::Params]
54
+ def build_variants: ((Module | Array[untyped])?, Array[untyped]?) ?{ (Riffer::Params::AnyOf) [self: Riffer::Params::AnyOf] -> void } -> Array[Riffer::Params]
43
55
 
44
56
  # --
45
57
  # : (Riffer::Params::Param, untyped, Array[String]) -> untyped
@@ -49,13 +61,27 @@ class Riffer::Params
49
61
  # : (Riffer::Params::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
50
62
  def validate_nested_hash: (Riffer::Params::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
51
63
 
64
+ # --
65
+ # : (Riffer::Params::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
66
+ def validate_any_of: (Riffer::Params::Param, Hash[Symbol, untyped], Array[String]) -> Hash[Symbol, untyped]
67
+
68
+ # Validation drops keys a Params doesn't declare, at any depth, so they are
69
+ # the keys in the input that are missing from the output.
70
+ # --
71
+ # : (untyped, untyped, ?String?) -> Array[String]
72
+ def undeclared_keys: (untyped, untyped, ?String?) -> Array[String]
73
+
52
74
  # --
53
75
  # : (Riffer::Params::Param, Array[untyped], Array[String]) -> Array[untyped]
54
76
  def validate_nested_array_of_objects: (Riffer::Params::Param, Array[untyped], Array[String]) -> Array[untyped]
55
77
 
56
78
  # --
57
- # : (Riffer::Params::Param, Array[untyped], Array[String]) -> Array[untyped]
58
- def validate_typed_array: (Riffer::Params::Param, Array[untyped], Array[String]) -> Array[untyped]
79
+ # : (Riffer::Params::Param, Array[untyped], Array[String], ?String, ?Integer) -> Array[untyped]
80
+ def validate_typed_array: (Riffer::Params::Param, Array[untyped], Array[String], ?String, ?Integer) -> Array[untyped]
81
+
82
+ # --
83
+ # : (Module, untyped) -> bool
84
+ def valid_item?: (Module, untyped) -> bool
59
85
 
60
86
  # --
61
87
  # : (Module, untyped) -> untyped
data/sig/manifest.yaml CHANGED
@@ -1,4 +1,5 @@
1
1
  dependencies:
2
2
  - name: uri
3
3
  - name: net-http
4
- - name: digest
4
+ - name: digest
5
+ - name: ipaddr
@@ -0,0 +1,5 @@
1
+ # `Riffer::Params::Format` uses `extend self`; rbs-inline doesn't emit that,
2
+ # so re-extend here to expose its instance methods as singleton methods.
3
+ module Riffer::Params::Format
4
+ extend ::Riffer::Params::Format
5
+ end
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.53.0
4
+ version: 0.54.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -177,7 +177,9 @@ files:
177
177
  - lib/riffer/messages/user.rb
178
178
  - lib/riffer/messages/user/file_part.rb
179
179
  - lib/riffer/params.rb
180
+ - lib/riffer/params/any_of.rb
180
181
  - lib/riffer/params/boolean.rb
182
+ - lib/riffer/params/format.rb
181
183
  - lib/riffer/params/param.rb
182
184
  - lib/riffer/providers.rb
183
185
  - lib/riffer/providers/amazon_bedrock.rb
@@ -327,7 +329,9 @@ files:
327
329
  - sig/generated/riffer/messages/user.rbs
328
330
  - sig/generated/riffer/messages/user/file_part.rbs
329
331
  - sig/generated/riffer/params.rbs
332
+ - sig/generated/riffer/params/any_of.rbs
330
333
  - sig/generated/riffer/params/boolean.rbs
334
+ - sig/generated/riffer/params/format.rbs
331
335
  - sig/generated/riffer/params/param.rbs
332
336
  - sig/generated/riffer/providers.rbs
333
337
  - sig/generated/riffer/providers/amazon_bedrock.rbs
@@ -407,6 +411,7 @@ files:
407
411
  - sig/manual/riffer/mcp/authenticated_tool.rbs
408
412
  - sig/manual/riffer/mcp/registry.rbs
409
413
  - sig/manual/riffer/mcp/tool_factory.rbs
414
+ - sig/manual/riffer/params/format.rbs
410
415
  - sig/manual/riffer/providers.rbs
411
416
  - sig/manual/riffer/providers/repository.rbs
412
417
  - sig/manual/riffer/testing.rbs