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
@@ -87,6 +87,8 @@ Enables extended thinking (for supported models):
87
87
  model_options reasoning: 'medium' # 'low', 'medium', or 'high'
88
88
  ```
89
89
 
90
+ The agent-level [`reasoning`](../AGENTS.md#reasoning) setting maps to this option the same way as on the [OpenAI provider](OPENAI.md#reasoning).
91
+
90
92
  Reasoning is captured and replayed on later turns exactly as with the [OpenAI provider](OPENAI.md#reasoning-replay), except that parts are tagged `format: "azure-openai-v1"`. Azure only accepts reasoning produced by the same resource, so parts from the OpenAI provider are never sent to Azure, and Azure's are never sent to OpenAI.
91
93
 
92
94
  ### structured_output
@@ -157,7 +157,7 @@ Register your provider under an identifier.
157
157
  Riffer::Providers::Repository.register(:my_provider) { Riffer::Providers::MyProvider }
158
158
  ```
159
159
 
160
- The block resolves the provider class lazily, so it need not be loaded at registration time. Registration is idempotent — re-registering the same identifier replaces the previous factory — and a custom registration takes precedence over a built-in sharing the identifier, which lets you route an existing prefix (e.g. `openai`) through your own backend. Both `find` and `key_for` (used for pricing and observability keys) honor the registration. Remove one with `Riffer::Providers::Repository.unregister(:my_provider)`.
160
+ The block resolves the provider class lazily, so it need not be loaded at registration time. Registration is idempotent — re-registering the same identifier replaces the previous factory — and a custom registration takes precedence over a built-in sharing the identifier, which lets you route an existing prefix (e.g. `openai`) through your own backend. Both `find` and `key_for` (used for [catalog](../CATALOG.md) keys) honor the registration. Remove one with `Riffer::Providers::Repository.unregister(:my_provider)`.
161
161
 
162
162
  Register during application boot — from a Rails initializer or equivalent — before you start handling requests. The registry is not synchronized for concurrent mutation, so treat registration as a one-time setup step rather than something you do from a live request path.
163
163
 
@@ -308,6 +308,20 @@ yielder << Riffer::StreamEvents::ReasoningDone.new(part)
308
308
 
309
309
  If your adapter surfaces reasoning text but cannot yet replay it, call `yield_reasoning_done(yielder, text)` instead. It wraps the text in a `:text` part with no `format`, which persists for display and is skipped on replay.
310
310
 
311
+ ## Reasoning Level
312
+
313
+ When an agent sets [`reasoning`](../AGENTS.md#reasoning), riffer resolves the level to `model_options` params before calling `build_request_params`, so your provider receives ordinary options and never sees the level itself. It uses the model's [catalog](../CATALOG.md#reasoning) entry under your registration key first, then your provider's default. Override the private `default_reasoning_options` to supply one:
314
+
315
+ ```ruby
316
+ private
317
+
318
+ def default_reasoning_options(level)
319
+ { reasoning_effort: level.to_s }
320
+ end
321
+ ```
322
+
323
+ The base class returns `nil`, which makes riffer raise `Riffer::ArgumentError` for any level the catalog doesn't map.
324
+
311
325
  ## Tags
312
326
 
313
327
  Every agent and judge call passes a `:tags` option: a flat `String => String` hash of the caller's [per-call tags](../AGENTS.md#per-call-tags) plus the [default tags](../AGENTS.md#default-tags). `kind` (`"agent"` or `"judge"`) and `agent` (the agent or evaluator identifier) tell you who the call is on behalf of:
@@ -80,6 +80,16 @@ Nucleus sampling:
80
80
  model_options topP: 0.9
81
81
  ```
82
82
 
83
+ ### thinkingConfig
84
+
85
+ Controls thinking, passed through as Gemini expects:
86
+
87
+ ```ruby
88
+ model_options thinkingConfig: {thinkingLevel: "low"}
89
+ ```
90
+
91
+ The agent-level [`reasoning`](../AGENTS.md#reasoning) setting writes `thinkingConfig: {thinkingLevel: "<level>"}` by default, including `"off"` for `:off`, which Gemini rejects. Gemini 2.5 models take `thinkingBudget` instead; map them in a [catalog](../CATALOG.md#reasoning) entry.
92
+
83
93
  ## Usage
84
94
 
85
95
  ### Basic Generation
@@ -162,3 +172,4 @@ A `FilePart.from_url` source works too — riffer downloads and base64-encodes i
162
172
 
163
173
  - **No web search** - Gemini's standard API does not include a web search tool
164
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
@@ -64,6 +64,8 @@ agent.session.messages.last.reasoning_text # => "The user wants the answer."
64
64
 
65
65
  When streaming, each part is emitted as a `ReasoningDelta` (only when it carries `text`) followed by a `ReasoningDone` carrying the part, ahead of the text events.
66
66
 
67
+ An agent's [`reasoning`](../AGENTS.md#reasoning) level needs no catalog entry on the mock provider: it maps every level to no params, so `calls` show only your `model_options` unless a [catalog](../CATALOG.md) entry under `mock/...` maps the level.
68
+
67
69
  ## Queueing Multiple Responses
68
70
 
69
71
  Responses are consumed in order:
@@ -82,6 +82,8 @@ model_options reasoning: 'medium' # 'low', 'medium', or 'high'
82
82
 
83
83
  When reasoning is enabled, you'll receive `ReasoningDelta` and `ReasoningDone` events during streaming.
84
84
 
85
+ The agent-level [`reasoning`](../AGENTS.md#reasoning) setting writes this same option by default: `reasoning :low` sends `reasoning: 'low'`, and `:off` sends `'none'`. A [catalog](../CATALOG.md#reasoning) entry can map a level differently.
86
+
85
87
  #### Reasoning Replay
86
88
 
87
89
  Each `reasoning` item in a Responses API output becomes [reasoning parts](../MESSAGES.md#reasoning) on the assistant message, on both `generate_text` and `stream_text`, all tagged `format: "openai-v1"` and carrying the item's `id`:
@@ -80,6 +80,8 @@ model_options reasoning: {effort: 'medium', max_tokens: 5000}
80
80
 
81
81
  Streaming yields `Riffer::StreamEvents::ReasoningDelta` and `ReasoningDone` events when the model returns reasoning content.
82
82
 
83
+ The agent-level [`reasoning`](../AGENTS.md#reasoning) setting writes this same option by default: `reasoning :low` sends `reasoning: 'low'`, and `:off` sends `'none'`. A [catalog](../CATALOG.md#reasoning) entry can map a level differently.
84
+
83
85
  ### provider (routing preferences)
84
86
 
85
87
  Pin which upstream provider OpenRouter should use, set allow/deny lists, or prefer a sort order:
@@ -45,6 +45,9 @@ groups:
45
45
  - source: CONFIGURATION.md
46
46
  slug: configuration
47
47
  description: Framework configuration
48
+ - source: CATALOG.md
49
+ slug: catalog
50
+ description: Reasoning mappings and pricing per model
48
51
  - source: EVALS.md
49
52
  slug: evals
50
53
  description: Evaluating agent quality
@@ -5,6 +5,8 @@ class Riffer::Agent::Config
5
5
  DEFAULT_MAX_STEPS = 16 #: Integer
6
6
  STRUCTURED_OUTPUT_STRATEGIES = %i[native prompted].freeze #: Array[Symbol]
7
7
 
8
+ REASONING_LEVELS = %i[off low medium high xhigh max].freeze #: Array[Symbol]
9
+
8
10
  # @rbs @tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?
9
11
 
10
12
  attr_reader :identifier #: String? # @dynamic identifier
@@ -14,6 +16,7 @@ class Riffer::Agent::Config
14
16
  attr_reader :structured_output #: Riffer::Params? # @dynamic structured_output
15
17
  attr_reader :structured_output_strategy #: Symbol # @dynamic structured_output_strategy
16
18
  attr_accessor :max_steps #: Numeric? # @dynamic max_steps, max_steps=
19
+ attr_reader :reasoning #: Symbol? # @dynamic reasoning
17
20
  attr_accessor :tools_config #: (Array[singleton(Riffer::Tool)] | Proc)? # @dynamic tools_config, tools_config=
18
21
  attr_reader :mcp_configs #: Array[Hash[Symbol, untyped]] # @dynamic mcp_configs
19
22
  attr_accessor :skills_config #: Riffer::Skills::Config? # @dynamic skills_config, skills_config=
@@ -28,6 +31,7 @@ class Riffer::Agent::Config
28
31
  # ?structured_output: Riffer::Params?,
29
32
  # ?structured_output_strategy: Symbol,
30
33
  # ?max_steps: Numeric?,
34
+ # ?reasoning: (Symbol | String)?,
31
35
  # ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?,
32
36
  # ?mcp_configs: Array[Hash[Symbol, untyped]],
33
37
  # ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?,
@@ -42,6 +46,7 @@ class Riffer::Agent::Config
42
46
  structured_output: nil,
43
47
  structured_output_strategy: :native,
44
48
  max_steps: DEFAULT_MAX_STEPS,
49
+ reasoning: nil,
45
50
  tools_config: nil,
46
51
  mcp_configs: [],
47
52
  tool_runtime: nil,
@@ -59,6 +64,7 @@ class Riffer::Agent::Config
59
64
  self.instructions = instructions
60
65
  self.structured_output = structured_output
61
66
  self.structured_output_strategy = structured_output_strategy
67
+ self.reasoning = reasoning
62
68
  self.tool_runtime = tool_runtime if tool_runtime
63
69
  end
64
70
 
@@ -89,6 +95,17 @@ class Riffer::Agent::Config
89
95
  @structured_output_strategy = value
90
96
  end
91
97
 
98
+ #--
99
+ #: ((Symbol | String)?) -> Symbol?
100
+ def reasoning=(value)
101
+ level = value&.to_s&.to_sym
102
+ unless level.nil? || REASONING_LEVELS.include?(level)
103
+ raise Riffer::ArgumentError, "reasoning must be one of #{REASONING_LEVELS.join(', ')}, got #{value.inspect}"
104
+ end
105
+
106
+ @reasoning = level
107
+ end
108
+
92
109
  #--
93
110
  #: () -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
94
111
  def tool_runtime
@@ -354,6 +354,8 @@ module Riffer::Agent::Run
354
354
  def merged_model_options(agent, tags = {})
355
355
  opts = agent.config.model_options.dup
356
356
  opts[:structured_output] = agent.structured_output if agent.structured_output
357
+ reasoning = agent.config.reasoning
358
+ opts[:riffer_reasoning_level] = reasoning if reasoning
357
359
  # Providers extract :tags for native request metadata; it never reaches an
358
360
  # SDK call verbatim.
359
361
  opts[:tags] = tags unless tags.empty?
@@ -28,6 +28,7 @@ module Riffer::Agent::Serializer
28
28
  max_steps: encode_max_steps(config.max_steps),
29
29
  structured_output: config.structured_output&.to_json_schema(strict: false),
30
30
  structured_output_strategy: config.structured_output_strategy,
31
+ reasoning: config.reasoning,
31
32
  tools: agent.tools.map { |tool_class| tool_descriptor(tool_class) },
32
33
  }
33
34
  end
@@ -79,6 +80,7 @@ module Riffer::Agent::Serializer
79
80
  structured_output: decode_structured_output(hash[:structured_output]),
80
81
  structured_output_strategy: (hash[:structured_output_strategy] || :native).to_sym,
81
82
  max_steps: decode_max_steps(hash),
83
+ reasoning: hash[:reasoning],
82
84
  tools_config: tools,
83
85
  } #: Hash[Symbol, untyped]
84
86
  # Config#tool_runtime= rejects nil.
@@ -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
data/lib/riffer/agent.rb CHANGED
@@ -73,6 +73,13 @@ class Riffer::Agent
73
73
  config.max_steps = value.first
74
74
  end
75
75
 
76
+ #--
77
+ #: (?(Symbol | String)?) -> Symbol?
78
+ def self.reasoning(value = nil)
79
+ config.reasoning = value if value
80
+ config.reasoning
81
+ end
82
+
76
83
  #--
77
84
  #: (?(Array[singleton(Riffer::Tool)] | Proc)?) -> (Array[singleton(Riffer::Tool)] | Proc)?
78
85
  def self.uses_tools(value = nil)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- class Riffer::Config::Pricing::Rates
4
+ class Riffer::Catalog::Rates
5
5
  attr_reader :input #: Float # @dynamic input
6
6
 
7
7
  attr_reader :output #: Float # @dynamic output
@@ -11,12 +11,12 @@ class Riffer::Config::Pricing::Rates
11
11
  attr_reader :cache_write #: Float? # @dynamic cache_write
12
12
 
13
13
  #--
14
- #: (input: Float, output: Float, ?cache_read: Float?, ?cache_write: Float?) -> void
14
+ #: (input: untyped, output: untyped, ?cache_read: untyped, ?cache_write: untyped) -> void
15
15
  def initialize(input:, output:, cache_read: nil, cache_write: nil)
16
- @input = input
17
- @output = output
18
- @cache_read = cache_read
19
- @cache_write = cache_write
16
+ @input = coerce_rate(input, "input")
17
+ @output = coerce_rate(output, "output")
18
+ @cache_read = cache_read.nil? ? nil : coerce_rate(cache_read, "cache_read")
19
+ @cache_write = cache_write.nil? ? nil : coerce_rate(cache_write, "cache_write")
20
20
  end
21
21
 
22
22
  #--
@@ -33,4 +33,19 @@ class Riffer::Config::Pricing::Rates
33
33
  (output_tokens * output)
34
34
  per_million / 1_000_000.0
35
35
  end
36
+
37
+ private
38
+
39
+ #--
40
+ #: (untyped, String) -> Float
41
+ def coerce_rate(value, attribute)
42
+ number = value
43
+ float = value.is_a?(Numeric) ? number.to_f : nil #: Float?
44
+ unless float&.finite? && float >= 0
45
+ raise Riffer::ArgumentError,
46
+ "#{attribute} rate must be a non-negative number, got #{value.inspect}"
47
+ end
48
+
49
+ float
50
+ end
36
51
  end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+
6
+ class Riffer::Catalog
7
+ SUPPORTED_VERSIONS = [1].freeze #: Array[Integer]
8
+
9
+ FILE_KEYS = %w[version models].freeze #: Array[String]
10
+
11
+ ENTRY_KEYS = %w[aliases pricing reasoning].freeze #: Array[String]
12
+
13
+ PRICING_KEYS = %w[input output cache_read cache_write].freeze #: Array[String]
14
+
15
+ # @rbs @models: Hash[String, Hash[Symbol, untyped]]
16
+ # @rbs @aliases: Hash[String, String]
17
+
18
+ #--
19
+ #: (Array[String]) -> Riffer::Catalog
20
+ def self.load(paths)
21
+ models = {} #: Hash[String, Hash[Symbol, untyped]]
22
+ aliases = {} #: Hash[String, String]
23
+
24
+ paths.each do |path|
25
+ parse_file(path).each do |key, entry|
26
+ layered = models[key] || { aliases: [] }
27
+ layered = layered.merge(entry.slice(:pricing, :reasoning))
28
+ layered[:aliases] = layered[:aliases] | entry[:aliases]
29
+ models[key] = layered
30
+
31
+ # A later file wins a claimed alias, so a user file can repoint one.
32
+ entry[:aliases].each do |alias_key|
33
+ previous = aliases[alias_key]
34
+ previous_entry = models[previous] if previous && previous != key
35
+ previous_entry[:aliases] -= [alias_key] if previous_entry
36
+ aliases[alias_key] = key
37
+ end
38
+ end
39
+ end
40
+
41
+ aliases.each do |alias_key, key|
42
+ next unless models.key?(alias_key)
43
+
44
+ raise Riffer::ArgumentError, "catalog alias #{alias_key.inspect} on #{key.inspect} is also a model key"
45
+ end
46
+
47
+ new(models: models, aliases: aliases)
48
+ end
49
+
50
+ #--
51
+ #: (models: Hash[String, Hash[Symbol, untyped]], aliases: Hash[String, String]) -> void
52
+ def initialize(models:, aliases:)
53
+ @models = deep_freeze(models)
54
+ @aliases = aliases.dup.freeze
55
+ freeze
56
+ end
57
+
58
+ #--
59
+ #: (String, Symbol) -> Hash[Symbol, untyped]?
60
+ def reasoning_options(key, level)
61
+ entry(key)&.dig(:reasoning, level)
62
+ end
63
+
64
+ #--
65
+ #: (String) -> Riffer::Catalog::Rates?
66
+ def rates_for(key)
67
+ entry(key)&.dig(:pricing)
68
+ end
69
+
70
+ #--
71
+ #: () -> bool
72
+ def empty?
73
+ @models.empty?
74
+ end
75
+
76
+ #--
77
+ #: (String) -> Hash[String, Hash[Symbol, untyped]]
78
+ def self.parse_file(path)
79
+ data = begin
80
+ JSON.parse(File.read(path))
81
+ rescue JSON::ParserError, SystemCallError => e
82
+ raise Riffer::ArgumentError, "#{path}: cannot load catalog file (#{e.message})"
83
+ end
84
+
85
+ fail_load!(path, "must be a JSON object") unless data.is_a?(Hash)
86
+ unknown = data.keys - FILE_KEYS
87
+ fail_load!(path, "unknown keys #{unknown.inspect}; allowed: #{FILE_KEYS.inspect}") unless unknown.empty?
88
+ unless SUPPORTED_VERSIONS.include?(data["version"])
89
+ fail_load!(path, "unsupported version #{data['version'].inspect}; supported: #{SUPPORTED_VERSIONS.inspect}")
90
+ end
91
+
92
+ no_models = {} #: Hash[String, untyped]
93
+ models = data.fetch("models", no_models)
94
+ fail_load!(path, "models must be an object") unless models.is_a?(Hash)
95
+
96
+ claimed = {} #: Hash[String, String]
97
+ models.to_h do |key, raw|
98
+ entry = parse_entry(path, key, raw)
99
+ entry[:aliases].each do |alias_key|
100
+ if claimed.key?(alias_key)
101
+ fail_load!(path,
102
+ "alias #{alias_key.inspect} is listed by both #{claimed[alias_key].inspect} and #{key.inspect}",)
103
+ end
104
+
105
+ claimed[alias_key] = key
106
+ end
107
+ [key, entry]
108
+ end
109
+ end
110
+ private_class_method :parse_file
111
+
112
+ #--
113
+ #: (String, String, untyped) -> Hash[Symbol, untyped]
114
+ def self.parse_entry(path, key, raw)
115
+ validate_model_key!(path, key, "model key")
116
+ fail_load!(path, "#{key}: entry must be an object") unless raw.is_a?(Hash)
117
+ unknown = raw.keys - ENTRY_KEYS
118
+ fail_load!(path, "#{key}: unknown keys #{unknown.inspect}; allowed: #{ENTRY_KEYS.inspect}") unless unknown.empty?
119
+
120
+ entry = { aliases: parse_aliases(path, key, raw.fetch("aliases", [])) } #: Hash[Symbol, untyped]
121
+ entry[:pricing] = parse_pricing(path, key, raw["pricing"]) if raw.key?("pricing")
122
+ entry[:reasoning] = parse_reasoning(path, key, raw["reasoning"]) if raw.key?("reasoning")
123
+ entry
124
+ end
125
+ private_class_method :parse_entry
126
+
127
+ #--
128
+ #: (String, String, untyped) -> Array[String]
129
+ def self.parse_aliases(path, key, raw)
130
+ fail_load!(path, "#{key}: aliases must be an array of strings") unless raw.is_a?(Array)
131
+
132
+ raw.each { |alias_key| validate_model_key!(path, alias_key, "#{key}: alias") }
133
+ raw.uniq
134
+ end
135
+ private_class_method :parse_aliases
136
+
137
+ #--
138
+ #: (String, String, untyped) -> Riffer::Catalog::Rates
139
+ def self.parse_pricing(path, key, raw)
140
+ fail_load!(path, "#{key}: pricing must be an object") unless raw.is_a?(Hash)
141
+ unknown = raw.keys - PRICING_KEYS
142
+ unless unknown.empty?
143
+ fail_load!(path,
144
+ "#{key}: unknown pricing keys #{unknown.inspect}; allowed: #{PRICING_KEYS.inspect}",)
145
+ end
146
+ missing = %w[input output] - raw.keys
147
+ fail_load!(path, "#{key}: pricing is missing #{missing.inspect}") unless missing.empty?
148
+
149
+ begin
150
+ Riffer::Catalog::Rates.new(
151
+ input: raw["input"],
152
+ output: raw["output"],
153
+ cache_read: raw["cache_read"],
154
+ cache_write: raw["cache_write"],
155
+ )
156
+ rescue Riffer::ArgumentError => e
157
+ fail_load!(path, "#{key}: pricing #{e.message}")
158
+ end
159
+ end
160
+ private_class_method :parse_pricing
161
+
162
+ #--
163
+ #: (String, String, untyped) -> Hash[Symbol, Hash[Symbol, untyped]]
164
+ def self.parse_reasoning(path, key, raw)
165
+ fail_load!(path, "#{key}: reasoning must be an object") unless raw.is_a?(Hash)
166
+
167
+ levels = Riffer::Agent::Config::REASONING_LEVELS
168
+ raw.to_h do |level, fragment|
169
+ unless levels.include?(level.to_sym)
170
+ fail_load!(path, "#{key}: unknown reasoning level #{level.inspect}; allowed: #{levels.map(&:to_s).inspect}")
171
+ end
172
+ fail_load!(path, "#{key}: reasoning #{level} must be an object") unless fragment.is_a?(Hash)
173
+
174
+ # Symbol keys, so a fragment deep-merges with the caller's model_options.
175
+ [level.to_sym, symbolize_keys(fragment)]
176
+ end
177
+ end
178
+ private_class_method :parse_reasoning
179
+
180
+ #--
181
+ #: (String, untyped, String) -> void
182
+ def self.validate_model_key!(path, value, attribute)
183
+ Riffer::Helpers::Validate.model_id(value, attribute: attribute)
184
+ rescue Riffer::ArgumentError => e
185
+ fail_load!(path, e.message)
186
+ end
187
+ private_class_method :validate_model_key!
188
+
189
+ #--
190
+ #: (untyped) -> untyped
191
+ def self.symbolize_keys(value)
192
+ case value
193
+ when Hash then value.to_h { |key, entry| [key.to_sym, symbolize_keys(entry)] }
194
+ when Array then value.map { |entry| symbolize_keys(entry) }
195
+ else value
196
+ end
197
+ end
198
+ private_class_method :symbolize_keys
199
+
200
+ #--
201
+ #: (String, String) -> bot
202
+ def self.fail_load!(path, message)
203
+ raise Riffer::ArgumentError, "#{path}: #{message}"
204
+ end
205
+ private_class_method :fail_load!
206
+
207
+ private
208
+
209
+ #--
210
+ #: (String) -> Hash[Symbol, untyped]?
211
+ def entry(key)
212
+ target = @aliases.fetch(key, key)
213
+ @models[target]
214
+ end
215
+
216
+ #--
217
+ #: (untyped) -> untyped
218
+ def deep_freeze(value)
219
+ case value
220
+ when Hash then value.each_value { |entry| deep_freeze(entry) }
221
+ when Array then value.each { |entry| deep_freeze(entry) }
222
+ end
223
+ value.freeze
224
+ end
225
+ end
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
+ # @deprecated Set pricing in a catalog file instead (see docs/CATALOG.md).
5
+ # A catalog entry's pricing wins over rates registered here.
4
6
  class Riffer::Config::Pricing
5
- # @rbs @rates: Hash[String, Riffer::Config::Pricing::Rates]
7
+ DEPRECATION_MESSAGE = "riffer: config.pricing is deprecated; move rates into a catalog file " \
8
+ "listed in config.catalog_files (see docs/CATALOG.md)" #: String
9
+
10
+ # @rbs @rates: Hash[String, Riffer::Catalog::Rates]
11
+ # @rbs self.@warned: bool
6
12
 
7
13
  #--
8
14
  #: () -> void
@@ -13,22 +19,19 @@ class Riffer::Config::Pricing
13
19
  #--
14
20
  #: ((String | Array[String]), input: Numeric, output: Numeric, ?cache_read: Numeric?, ?cache_write: Numeric?) -> void
15
21
  def set(models, input:, output:, cache_read: nil, cache_write: nil)
22
+ self.class.warn_deprecated
23
+
16
24
  ids = models.is_a?(Array) ? models : [models]
17
25
  raise Riffer::ArgumentError, "at least one model id is required" if ids.empty?
18
26
 
19
27
  ids.each { |id| Riffer::Helpers::Validate.model_id(id, attribute: "pricing model id") }
20
28
 
21
- rates = Rates.new(
22
- input: coerce_rate(input, "input"),
23
- output: coerce_rate(output, "output"),
24
- cache_read: coerce_optional_rate(cache_read, "cache_read"),
25
- cache_write: coerce_optional_rate(cache_write, "cache_write"),
26
- )
29
+ rates = Riffer::Catalog::Rates.new(input: input, output: output, cache_read: cache_read, cache_write: cache_write)
27
30
  ids.each { |id| @rates[id] = rates }
28
31
  end
29
32
 
30
33
  #--
31
- #: (String) -> Riffer::Config::Pricing::Rates?
34
+ #: (String) -> Riffer::Catalog::Rates?
32
35
  def rates_for(model)
33
36
  @rates[model]
34
37
  end
@@ -39,26 +42,13 @@ class Riffer::Config::Pricing
39
42
  @rates.empty?
40
43
  end
41
44
 
42
- private
43
-
44
- #--
45
- #: (untyped, String) -> Float
46
- def coerce_rate(value, attribute)
47
- number = value
48
- float = value.is_a?(Numeric) ? number.to_f : nil #: Float?
49
- unless float&.finite? && float >= 0
50
- raise Riffer::ArgumentError,
51
- "#{attribute} rate must be a non-negative number, got #{value.inspect}"
52
- end
53
-
54
- float
55
- end
56
-
45
+ # Once per process, so a boot that sets many models logs one line.
57
46
  #--
58
- #: (untyped, String) -> Float?
59
- def coerce_optional_rate(value, attribute)
60
- return nil if value.nil?
47
+ #: () -> void
48
+ def self.warn_deprecated
49
+ return if @warned
61
50
 
62
- coerce_rate(value, attribute)
51
+ @warned = true
52
+ Kernel.warn(DEPRECATION_MESSAGE)
63
53
  end
64
54
  end
data/lib/riffer/config.rb CHANGED
@@ -4,6 +4,8 @@
4
4
  class Riffer::Config
5
5
  VALID_MESSAGE_ID_STRATEGIES = %i[none uuid uuidv7].freeze
6
6
 
7
+ # @rbs @catalog: Riffer::Catalog?
8
+
7
9
  attr_reader :amazon_bedrock #: Riffer::Config::AmazonBedrock # @dynamic amazon_bedrock
8
10
 
9
11
  attr_reader :anthropic #: Riffer::Config::Anthropic # @dynamic anthropic
@@ -41,8 +43,33 @@ class Riffer::Config
41
43
 
42
44
  attr_reader :files #: Riffer::Config::Files # @dynamic files
43
45
 
46
+ # @deprecated Use +catalog_files+.
44
47
  attr_reader :pricing #: Riffer::Config::Pricing # @dynamic pricing
45
48
 
49
+ attr_reader :catalog_files #: Array[String] # @dynamic catalog_files
50
+
51
+ #--
52
+ #: (Array[untyped]) -> void
53
+ def catalog_files=(paths)
54
+ valid = paths.is_a?(Array) && paths.all? { |path| path.is_a?(String) || path.respond_to?(:to_path) }
55
+ raise Riffer::ArgumentError, "catalog_files must be an Array of file paths" unless valid
56
+
57
+ @catalog_files = paths.map(&:to_s).freeze
58
+ @catalog = nil
59
+ end
60
+
61
+ #--
62
+ #: () -> Riffer::Catalog
63
+ def catalog
64
+ @catalog ||= Riffer::Catalog.load(@catalog_files)
65
+ end
66
+
67
+ #--
68
+ #: () -> Riffer::Catalog
69
+ def reload_catalog
70
+ @catalog = Riffer::Catalog.load(@catalog_files)
71
+ end
72
+
46
73
  # When not +:none+, seeded messages passed to +Riffer::Agent#generate+ must
47
74
  # carry their own +id+.
48
75
  attr_reader :message_id_strategy #: Symbol # @dynamic message_id_strategy
@@ -73,6 +100,8 @@ class Riffer::Config
73
100
  @tracing = Tracing.new
74
101
  @files = Files.new
75
102
  @pricing = Pricing.new
103
+ no_files = [] #: Array[String]
104
+ @catalog_files = no_files.freeze
76
105
  @message_id_strategy = :none
77
106
  end
78
107
  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