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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -0
- data/docs/AGENTS.md +15 -0
- data/docs/CATALOG.md +205 -0
- data/docs/CONFIGURATION.md +35 -23
- data/docs/MESSAGES.md +1 -1
- data/docs/SERIALIZATION.md +1 -0
- data/docs/TRACING.md +2 -1
- data/docs/providers/AMAZON_BEDROCK.md +2 -0
- data/docs/providers/ANTHROPIC.md +2 -0
- data/docs/providers/AZURE_OPENAI.md +2 -0
- data/docs/providers/CUSTOM_PROVIDERS.md +15 -1
- data/docs/providers/GEMINI.md +10 -0
- data/docs/providers/MOCK_PROVIDER.md +2 -0
- data/docs/providers/OPENAI.md +2 -0
- data/docs/providers/OPENROUTER.md +2 -0
- data/docs-site/manifest.yml +3 -0
- data/lib/riffer/agent/config.rb +17 -0
- data/lib/riffer/agent/run.rb +2 -0
- data/lib/riffer/agent/serializer.rb +2 -0
- data/lib/riffer/agent.rb +7 -0
- data/lib/riffer/{config/pricing → catalog}/rates.rb +21 -6
- data/lib/riffer/catalog.rb +225 -0
- data/lib/riffer/config/pricing.rb +17 -27
- data/lib/riffer/config.rb +29 -0
- data/lib/riffer/providers/amazon_bedrock.rb +14 -0
- data/lib/riffer/providers/anthropic.rb +8 -0
- data/lib/riffer/providers/base.rb +58 -6
- data/lib/riffer/providers/gemini.rb +6 -0
- data/lib/riffer/providers/mock.rb +7 -0
- data/lib/riffer/providers/open_ai.rb +6 -0
- data/lib/riffer/providers/open_router.rb +6 -0
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +2 -0
- data/sig/generated/riffer/agent/config.rbs +10 -1
- data/sig/generated/riffer/agent.rbs +4 -0
- data/sig/generated/riffer/{config/pricing → catalog}/rates.rbs +10 -4
- data/sig/generated/riffer/catalog.rbs +77 -0
- data/sig/generated/riffer/config/pricing.rbs +12 -11
- data/sig/generated/riffer/config.rbs +17 -0
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +6 -0
- data/sig/generated/riffer/providers/anthropic.rbs +4 -0
- data/sig/generated/riffer/providers/base.rbs +22 -2
- data/sig/generated/riffer/providers/gemini.rbs +4 -0
- data/sig/generated/riffer/providers/mock.rbs +5 -0
- data/sig/generated/riffer/providers/open_ai.rbs +4 -0
- data/sig/generated/riffer/providers/open_router.rbs +4 -0
- data/sig/generated/riffer.rbs +1 -0
- metadata +6 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
|
-
class Riffer::
|
|
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:
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
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
|
-
#: (
|
|
59
|
-
def
|
|
60
|
-
return
|
|
47
|
+
#: () -> void
|
|
48
|
+
def self.warn_deprecated
|
|
49
|
+
return if @warned
|
|
61
50
|
|
|
62
|
-
|
|
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::
|
|
193
|
+
#: () -> Riffer::Catalog::Rates?
|
|
192
194
|
def pricing_rates
|
|
193
195
|
model = @current_model
|
|
194
196
|
return nil unless model
|
|
195
197
|
|
|
196
|
-
|
|
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(
|
|
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)
|
data/lib/riffer/version.rb
CHANGED
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)?
|