riffer 0.52.1 → 0.53.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +7 -0
  4. data/docs/AGENTS.md +15 -0
  5. data/docs/CATALOG.md +205 -0
  6. data/docs/CONFIGURATION.md +35 -23
  7. data/docs/MESSAGES.md +1 -1
  8. data/docs/SERIALIZATION.md +1 -0
  9. data/docs/TRACING.md +2 -1
  10. data/docs/providers/AMAZON_BEDROCK.md +2 -0
  11. data/docs/providers/ANTHROPIC.md +2 -0
  12. data/docs/providers/AZURE_OPENAI.md +2 -0
  13. data/docs/providers/CUSTOM_PROVIDERS.md +15 -1
  14. data/docs/providers/GEMINI.md +10 -0
  15. data/docs/providers/MOCK_PROVIDER.md +2 -0
  16. data/docs/providers/OPENAI.md +2 -0
  17. data/docs/providers/OPENROUTER.md +2 -0
  18. data/docs-site/manifest.yml +3 -0
  19. data/lib/riffer/agent/config.rb +17 -0
  20. data/lib/riffer/agent/run.rb +2 -0
  21. data/lib/riffer/agent/serializer.rb +2 -0
  22. data/lib/riffer/agent.rb +7 -0
  23. data/lib/riffer/{config/pricing → catalog}/rates.rb +21 -6
  24. data/lib/riffer/catalog.rb +225 -0
  25. data/lib/riffer/config/pricing.rb +17 -27
  26. data/lib/riffer/config.rb +29 -0
  27. data/lib/riffer/providers/amazon_bedrock.rb +14 -0
  28. data/lib/riffer/providers/anthropic.rb +8 -0
  29. data/lib/riffer/providers/base.rb +58 -6
  30. data/lib/riffer/providers/gemini.rb +6 -0
  31. data/lib/riffer/providers/mock.rb +7 -0
  32. data/lib/riffer/providers/open_ai.rb +6 -0
  33. data/lib/riffer/providers/open_router.rb +6 -0
  34. data/lib/riffer/version.rb +1 -1
  35. data/lib/riffer.rb +2 -0
  36. data/sig/generated/riffer/agent/config.rbs +10 -1
  37. data/sig/generated/riffer/agent.rbs +4 -0
  38. data/sig/generated/riffer/{config/pricing → catalog}/rates.rbs +10 -4
  39. data/sig/generated/riffer/catalog.rbs +77 -0
  40. data/sig/generated/riffer/config/pricing.rbs +12 -11
  41. data/sig/generated/riffer/config.rbs +17 -0
  42. data/sig/generated/riffer/providers/amazon_bedrock.rbs +6 -0
  43. data/sig/generated/riffer/providers/anthropic.rbs +4 -0
  44. data/sig/generated/riffer/providers/base.rbs +22 -2
  45. data/sig/generated/riffer/providers/gemini.rbs +4 -0
  46. data/sig/generated/riffer/providers/mock.rbs +5 -0
  47. data/sig/generated/riffer/providers/open_ai.rbs +4 -0
  48. data/sig/generated/riffer/providers/open_router.rbs +4 -0
  49. data/sig/generated/riffer.rbs +1 -0
  50. metadata +6 -3
@@ -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
@@ -78,6 +78,20 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
78
78
  end
79
79
  end
80
80
 
81
+ # Claude's fields, since application inference profile ARNs hide the model
82
+ # family; other families need a catalog entry.
83
+ #--
84
+ #: (Symbol) -> Hash[Symbol, untyped]
85
+ def default_reasoning_options(level)
86
+ fields =
87
+ if level == :off
88
+ { thinking: { type: "disabled" } }
89
+ else
90
+ { thinking: { type: "adaptive" }, output_config: { effort: level.to_s } }
91
+ end
92
+ { additional_model_request_fields: fields }
93
+ end
94
+
81
95
  #--
82
96
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
83
97
  def build_request_params(messages, model, options)
@@ -56,6 +56,14 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
56
56
  ::Anthropic::Client.new(**{ api_key: Riffer.config.anthropic.api_key }.compact)
57
57
  end
58
58
 
59
+ #--
60
+ #: (Symbol) -> Hash[Symbol, untyped]
61
+ def default_reasoning_options(level)
62
+ return { thinking: { type: "disabled" } } if level == :off
63
+
64
+ { thinking: { type: "adaptive" }, output_config: { effort: level.to_s } }
65
+ end
66
+
59
67
  #--
60
68
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
61
69
  def build_request_params(messages, model, options)
@@ -39,6 +39,7 @@ class Riffer::Providers::Base
39
39
  validate_normalized_messages!(messages)
40
40
  Riffer::Files::Resolver.new(provider: self).resolve!(messages)
41
41
  messages = merge_consecutive_messages(with_structured_output_prompt(messages, options[:structured_output]))
42
+ options = apply_reasoning(model, options)
42
43
  params = build_request_params(messages, model, request_options(options))
43
44
 
44
45
  in_chat_span(model, messages, options) do |span|
@@ -77,6 +78,7 @@ class Riffer::Providers::Base
77
78
  validate_normalized_messages!(messages)
78
79
  Riffer::Files::Resolver.new(provider: self).resolve!(messages)
79
80
  messages = merge_consecutive_messages(with_structured_output_prompt(messages, options[:structured_output]))
81
+ options = apply_reasoning(model, options)
80
82
  params = build_request_params(messages, model, request_options(options))
81
83
 
82
84
  # The enumerator body runs in its own fiber, where the fiber-local OTEL
@@ -188,18 +190,64 @@ class Riffer::Providers::Base
188
190
  end
189
191
 
190
192
  #--
191
- #: () -> Riffer::Config::Pricing::Rates?
193
+ #: () -> Riffer::Catalog::Rates?
192
194
  def pricing_rates
193
195
  model = @current_model
194
196
  return nil unless model
195
197
 
196
- pricing = Riffer.config.pricing
197
- return nil if pricing.empty?
198
-
199
- key = Riffer::Providers::Repository.key_for(self.class)
198
+ key = catalog_key(model)
200
199
  return nil unless key
201
200
 
202
- pricing.rates_for("#{key}/#{model}")
201
+ Riffer.config.catalog.rates_for(key) || Riffer.config.pricing.rates_for(key)
202
+ end
203
+
204
+ #--
205
+ #: (String) -> String?
206
+ def catalog_key(model)
207
+ provider_key = Riffer::Providers::Repository.key_for(self.class)
208
+ "#{provider_key}/#{model}" if provider_key
209
+ end
210
+
211
+ # A catalog fragment beats the provider default per level; the caller's
212
+ # options are merged over either, so +model_options+ always wins.
213
+ #--
214
+ #: (String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
215
+ def apply_reasoning(model, options)
216
+ value = options[:riffer_reasoning_level]
217
+ return options if value.nil?
218
+
219
+ level = value.to_s.to_sym
220
+ levels = Riffer::Agent::Config::REASONING_LEVELS
221
+ unless levels.include?(level)
222
+ raise Riffer::ArgumentError, "riffer_reasoning_level must be one of #{levels.join(', ')}, got #{value.inspect}"
223
+ end
224
+
225
+ key = model && catalog_key(model)
226
+ fragment = (key && Riffer.config.catalog.reasoning_options(key, level)) || default_reasoning_options(level)
227
+ unless fragment
228
+ raise Riffer::ArgumentError,
229
+ "No reasoning mapping for #{key || model.inspect} at level #{level.inspect}. " \
230
+ "Add one in a catalog file (see docs/CATALOG.md)."
231
+ end
232
+
233
+ # A copy, since catalog fragments are frozen and providers may edit nested params.
234
+ deep_merge(Riffer::Helpers::DeepDup.call(fragment), options.merge(riffer_reasoning_level: level))
235
+ end
236
+
237
+ # Maps a level onto the provider's own reasoning params when the catalog has
238
+ # no entry; +nil+ means riffer raises before the request.
239
+ #--
240
+ #: (Symbol) -> Hash[Symbol, untyped]?
241
+ def default_reasoning_options(_level)
242
+ nil
243
+ end
244
+
245
+ #--
246
+ #: (Hash[Symbol, untyped], Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
247
+ def deep_merge(base, override)
248
+ base.merge(override) do |_key, base_value, override_value|
249
+ base_value.is_a?(Hash) && override_value.is_a?(Hash) ? deep_merge(base_value, override_value) : override_value
250
+ end
203
251
  end
204
252
 
205
253
  #--
@@ -274,6 +322,9 @@ class Riffer::Providers::Base
274
322
  attributes[attribute] = value if value
275
323
  end
276
324
 
325
+ reasoning_level = options[:riffer_reasoning_level]
326
+ attributes["riffer.request.reasoning_level"] = reasoning_level.to_s if reasoning_level
327
+
277
328
  attributes.merge(tag_attributes(options[:tags] || {}))
278
329
  end
279
330
 
@@ -388,6 +439,7 @@ class Riffer::Providers::Base
388
439
  #--
389
440
  #: (Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
390
441
  def request_options(options)
442
+ options = options.except(:riffer_reasoning_level)
391
443
  options[:structured_output]&.prompted? ? options.except(:structured_output) : options
392
444
  end
393
445
 
@@ -54,6 +54,12 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
54
54
  Riffer::Providers::Gemini::Client.new(**{ api_key: Riffer.config.gemini.api_key }.compact)
55
55
  end
56
56
 
57
+ #--
58
+ #: (Symbol) -> Hash[Symbol, untyped]
59
+ def default_reasoning_options(level)
60
+ { thinkingConfig: { thinkingLevel: level.to_s } }
61
+ end
62
+
57
63
  #--
58
64
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
59
65
  def build_request_params(messages, model, options)
@@ -75,6 +75,13 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
75
75
  }
76
76
  end
77
77
 
78
+ # Never raises, so test suites can set +reasoning+ without catalog entries.
79
+ #--
80
+ #: (Symbol) -> Hash[Symbol, untyped]
81
+ def default_reasoning_options(_level)
82
+ {}
83
+ end
84
+
78
85
  #--
79
86
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
80
87
  def build_request_params(messages, model, options)
@@ -56,6 +56,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
56
56
  }.compact)
57
57
  end
58
58
 
59
+ #--
60
+ #: (Symbol) -> Hash[Symbol, untyped]
61
+ def default_reasoning_options(level)
62
+ { reasoning: level == :off ? "none" : level.to_s }
63
+ end
64
+
59
65
  #--
60
66
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
61
67
  def build_request_params(messages, model, options)
@@ -72,6 +72,12 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
72
72
  ::OpenAI::Client.new(api_key: api_key, base_url: BASE_URL)
73
73
  end
74
74
 
75
+ #--
76
+ #: (Symbol) -> Hash[Symbol, untyped]
77
+ def default_reasoning_options(level)
78
+ { reasoning: level == :off ? "none" : level.to_s }
79
+ end
80
+
75
81
  #--
76
82
  #: (Array[Riffer::Messages::Base], String?, Hash[Symbol, untyped]) -> Hash[Symbol, untyped]
77
83
  def build_request_params(messages, model, options)
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.52.1" #: String
5
+ VERSION = "0.53.0" #: String
6
6
  end
data/lib/riffer.rb CHANGED
@@ -62,10 +62,12 @@ module Riffer
62
62
  @config ||= Config.new
63
63
  end
64
64
 
65
+ # Builds the catalog after the block, so a bad catalog file fails boot.
65
66
  #--
66
67
  #: () ?{ (Riffer::Config) -> void } -> void
67
68
  def configure(&)
68
69
  yield config if block_given?
70
+ config.reload_catalog
69
71
  end
70
72
 
71
73
  #--
@@ -5,6 +5,8 @@ class Riffer::Agent::Config
5
5
 
6
6
  STRUCTURED_OUTPUT_STRATEGIES: Array[Symbol]
7
7
 
8
+ REASONING_LEVELS: Array[Symbol]
9
+
8
10
  @tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?
9
11
 
10
12
  attr_reader identifier: String?
@@ -21,6 +23,8 @@ class Riffer::Agent::Config
21
23
 
22
24
  attr_accessor max_steps: Numeric?
23
25
 
26
+ attr_reader reasoning: Symbol?
27
+
24
28
  attr_accessor tools_config: (Array[singleton(Riffer::Tool)] | Proc)?
25
29
 
26
30
  attr_reader mcp_configs: Array[Hash[Symbol, untyped]]
@@ -38,13 +42,14 @@ class Riffer::Agent::Config
38
42
  # ?structured_output: Riffer::Params?,
39
43
  # ?structured_output_strategy: Symbol,
40
44
  # ?max_steps: Numeric?,
45
+ # ?reasoning: (Symbol | String)?,
41
46
  # ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?,
42
47
  # ?mcp_configs: Array[Hash[Symbol, untyped]],
43
48
  # ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?,
44
49
  # ?skills_config: Riffer::Skills::Config?,
45
50
  # ?guardrails: Hash[Symbol, Array[Hash[Symbol, untyped]]]
46
51
  # ) -> void
47
- def initialize: (?identifier: String?, ?model: (String | Proc)?, ?instructions: (String | Proc)?, ?model_options: Hash[Symbol, untyped], ?structured_output: Riffer::Params?, ?structured_output_strategy: Symbol, ?max_steps: Numeric?, ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?, ?mcp_configs: Array[Hash[Symbol, untyped]], ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?, ?skills_config: Riffer::Skills::Config?, ?guardrails: Hash[Symbol, Array[Hash[Symbol, untyped]]]) -> void
52
+ def initialize: (?identifier: String?, ?model: (String | Proc)?, ?instructions: (String | Proc)?, ?model_options: Hash[Symbol, untyped], ?structured_output: Riffer::Params?, ?structured_output_strategy: Symbol, ?max_steps: Numeric?, ?reasoning: (Symbol | String)?, ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?, ?mcp_configs: Array[Hash[Symbol, untyped]], ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?, ?skills_config: Riffer::Skills::Config?, ?guardrails: Hash[Symbol, Array[Hash[Symbol, untyped]]]) -> void
48
53
 
49
54
  # --
50
55
  # : (untyped) -> String?
@@ -58,6 +63,10 @@ class Riffer::Agent::Config
58
63
  # : (Symbol) -> Symbol
59
64
  def structured_output_strategy=: (Symbol) -> Symbol
60
65
 
66
+ # --
67
+ # : ((Symbol | String)?) -> Symbol?
68
+ def reasoning=: ((Symbol | String)?) -> Symbol?
69
+
61
70
  # --
62
71
  # : () -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
63
72
  def tool_runtime: () -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
@@ -39,6 +39,10 @@ class Riffer::Agent
39
39
  # : (*Numeric?) -> Numeric?
40
40
  def self.max_steps: (*Numeric?) -> Numeric?
41
41
 
42
+ # --
43
+ # : (?(Symbol | String)?) -> Symbol?
44
+ def self.reasoning: (?(Symbol | String)?) -> Symbol?
45
+
42
46
  # --
43
47
  # : (?(Array[singleton(Riffer::Tool)] | Proc)?) -> (Array[singleton(Riffer::Tool)] | Proc)?
44
48
  def self.uses_tools: (?(Array[singleton(Riffer::Tool)] | Proc)?) -> (Array[singleton(Riffer::Tool)] | Proc)?