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.
- checksums.yaml +4 -4
- data/.claude/rules/rbs-inline.md +2 -2
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +22 -0
- data/Steepfile +19 -0
- data/docs/AGENTS.md +81 -4
- data/docs/CATALOG.md +205 -0
- data/docs/CONFIGURATION.md +35 -23
- data/docs/MESSAGES.md +1 -1
- data/docs/OVERVIEW.md +1 -1
- data/docs/SERIALIZATION.md +1 -0
- data/docs/TOOLS.md +15 -12
- 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 +11 -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/structured_output.rb +23 -3
- 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/params/any_of.rb +22 -0
- data/lib/riffer/params/format.rb +73 -0
- data/lib/riffer/params/param.rb +120 -17
- data/lib/riffer/params.rb +135 -24
- data/lib/riffer/providers/amazon_bedrock.rb +14 -0
- data/lib/riffer/providers/anthropic.rb +8 -0
- data/lib/riffer/providers/base.rb +67 -15
- data/lib/riffer/providers/gemini.rb +7 -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/rbs_collection.lock.yaml +8 -0
- data/rbs_collection.yaml +2 -0
- data/sig/generated/riffer/agent/config.rbs +10 -1
- data/sig/generated/riffer/agent/structured_output.rbs +6 -0
- 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/params/any_of.rbs +13 -0
- data/sig/generated/riffer/params/format.rbs +39 -0
- data/sig/generated/riffer/params/param.rbs +22 -4
- data/sig/generated/riffer/params.rbs +34 -8
- 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
- data/sig/manifest.yaml +2 -1
- data/sig/manual/riffer/params/format.rbs +5 -0
- 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
|
|
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:
|
data/docs/providers/GEMINI.md
CHANGED
|
@@ -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:
|
data/docs/providers/OPENAI.md
CHANGED
|
@@ -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:
|
data/docs-site/manifest.yml
CHANGED
|
@@ -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
|
data/lib/riffer/agent/config.rb
CHANGED
|
@@ -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
|
data/lib/riffer/agent/run.rb
CHANGED
|
@@ -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
|
-
|
|
34
|
-
|
|
35
|
-
"
|
|
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::
|
|
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
|
|
@@ -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
|