riffer 0.28.0 → 0.29.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/.agents/architecture.md +18 -11
- data/.agents/code-style.md +1 -1
- data/.agents/rbs-inline.md +2 -2
- data/.agents/testing.md +9 -5
- data/.release-please-manifest.json +1 -1
- data/AGENTS.md +17 -10
- data/CHANGELOG.md +19 -0
- data/README.md +17 -18
- data/Steepfile +7 -1
- data/docs/03_AGENTS.md +34 -3
- data/docs/04_AGENT_LIFECYCLE.md +87 -86
- data/docs/05_AGENT_LOOP.md +2 -2
- data/docs/06_TOOLS.md +9 -4
- data/docs/07_TOOL_ADVANCED.md +17 -17
- data/docs/08_MESSAGES.md +25 -32
- data/docs/09_STREAM_EVENTS.md +1 -1
- data/docs/10_CONFIGURATION.md +7 -18
- data/docs/providers/01_PROVIDERS.md +6 -0
- data/docs/providers/06_MOCK_PROVIDER.md +2 -1
- data/docs/providers/07_CUSTOM_PROVIDERS.md +4 -4
- data/docs/providers/08_GEMINI.md +2 -2
- data/docs/providers/09_OPENROUTER.md +242 -0
- data/lib/riffer/agent/config.rb +173 -0
- data/lib/riffer/agent/context.rb +125 -0
- data/lib/riffer/agent/run.rb +308 -0
- data/lib/riffer/agent/session/repair.rb +112 -0
- data/lib/riffer/agent/session.rb +268 -0
- data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
- data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
- data/lib/riffer/agent.rb +234 -923
- data/lib/riffer/config.rb +14 -7
- data/lib/riffer/evals/evaluator.rb +13 -3
- data/lib/riffer/evals/judge.rb +2 -2
- data/lib/riffer/evals/run_result.rb +2 -1
- data/lib/riffer/evals/scenario_result.rb +2 -1
- data/lib/riffer/guardrails/runner.rb +3 -2
- data/lib/riffer/helpers/call_or_value.rb +16 -0
- data/lib/riffer/helpers.rb +0 -1
- data/lib/riffer/mcp/authenticated_tool.rb +4 -0
- data/lib/riffer/mcp/client.rb +1 -1
- data/lib/riffer/mcp/registration.rb +2 -3
- data/lib/riffer/mcp/registry.rb +3 -1
- data/lib/riffer/mcp/tool_factory.rb +5 -0
- data/lib/riffer/messages/assistant.rb +9 -3
- data/lib/riffer/messages/base.rb +22 -0
- data/lib/riffer/messages/converter.rb +6 -6
- data/lib/riffer/{file_part.rb → messages/file_part.rb} +5 -5
- data/lib/riffer/messages/tool.rb +1 -1
- data/lib/riffer/messages/user.rb +4 -4
- data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
- data/lib/riffer/{param.rb → params/param.rb} +6 -6
- data/lib/riffer/params.rb +27 -21
- data/lib/riffer/providers/amazon_bedrock.rb +19 -20
- data/lib/riffer/providers/anthropic.rb +27 -28
- data/lib/riffer/providers/base.rb +10 -9
- data/lib/riffer/providers/gemini.rb +15 -12
- data/lib/riffer/providers/mock.rb +41 -13
- data/lib/riffer/providers/open_ai.rb +24 -22
- data/lib/riffer/providers/open_router.rb +318 -0
- data/lib/riffer/providers/repository.rb +1 -0
- data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
- data/lib/riffer/providers.rb +1 -0
- data/lib/riffer/runner/fibers.rb +4 -3
- data/lib/riffer/runner/sequential.rb +1 -1
- data/lib/riffer/runner/threaded.rb +1 -1
- data/lib/riffer/runner.rb +1 -1
- data/lib/riffer/skills/activate_tool.rb +4 -3
- data/lib/riffer/skills/config.rb +1 -1
- data/lib/riffer/skills/context.rb +3 -3
- data/lib/riffer/skills/filesystem_backend.rb +7 -5
- data/lib/riffer/skills/markdown_adapter.rb +1 -1
- data/lib/riffer/skills/xml_adapter.rb +1 -1
- data/lib/riffer/stream_events/interrupt.rb +1 -1
- data/lib/riffer/stream_events/token_usage_done.rb +2 -2
- data/lib/riffer/stream_events/web_search_status.rb +1 -1
- data/lib/riffer/tool.rb +3 -3
- data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
- data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
- data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
- data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +9 -9
- data/lib/riffer/{toolable.rb → tools/toolable.rb} +12 -9
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +2 -1
- data/sig/generated/riffer/agent/config.rbs +119 -0
- data/sig/generated/riffer/agent/context.rbs +91 -0
- data/sig/generated/riffer/agent/run.rbs +144 -0
- data/sig/generated/riffer/agent/session/repair.rbs +51 -0
- data/sig/generated/riffer/agent/session.rbs +145 -0
- data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
- data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
- data/sig/generated/riffer/agent.rbs +143 -342
- data/sig/generated/riffer/config.rbs +17 -5
- data/sig/generated/riffer/evals/judge.rbs +2 -2
- data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
- data/sig/generated/riffer/helpers.rbs +0 -1
- data/sig/generated/riffer/messages/assistant.rbs +7 -3
- data/sig/generated/riffer/messages/base.rbs +18 -0
- data/sig/generated/riffer/messages/converter.rbs +4 -4
- data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +5 -5
- data/sig/generated/riffer/messages/user.rbs +4 -4
- data/sig/generated/riffer/params/boolean.rbs +10 -0
- data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
- data/sig/generated/riffer/params.rbs +15 -15
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
- data/sig/generated/riffer/providers/anthropic.rbs +4 -4
- data/sig/generated/riffer/providers/base.rbs +10 -10
- data/sig/generated/riffer/providers/gemini.rbs +4 -4
- data/sig/generated/riffer/providers/mock.rbs +25 -5
- data/sig/generated/riffer/providers/open_ai.rbs +4 -4
- data/sig/generated/riffer/providers/open_router.rbs +85 -0
- data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
- data/sig/generated/riffer/providers.rbs +1 -0
- data/sig/generated/riffer/runner/fibers.rbs +2 -2
- data/sig/generated/riffer/runner/sequential.rbs +2 -2
- data/sig/generated/riffer/runner/threaded.rbs +2 -2
- data/sig/generated/riffer/runner.rbs +2 -2
- data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
- data/sig/generated/riffer/skills/config.rbs +1 -1
- data/sig/generated/riffer/skills/context.rbs +2 -2
- data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
- data/sig/generated/riffer/tool.rbs +5 -5
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
- data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
- data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +12 -12
- data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +6 -6
- data/sig/stubs/agent_ivars.rbs +7 -0
- data/sig/stubs/async.rbs +24 -0
- data/sig/stubs/aws-sdk-core/seahorse_request_context.rbs +7 -0
- data/sig/stubs/aws-sdk-core/static_token_provider.rbs +5 -0
- data/sig/stubs/extend_self.rbs +11 -0
- data/sig/stubs/lib_ivars.rbs +101 -0
- data/sig/stubs/mcp_sdk.rbs +22 -0
- data/sig/stubs/provider_ivars.rbs +36 -0
- data/sig/stubs/provider_sdk_methods.rbs +50 -0
- data/sig/stubs/zeitwerk.rbs +12 -0
- metadata +54 -33
- data/lib/riffer/core.rb +0 -28
- data/lib/riffer/helpers/validations.rb +0 -18
- data/sig/generated/riffer/boolean.rbs +0 -10
- data/sig/generated/riffer/core.rbs +0 -19
- data/sig/generated/riffer/helpers/validations.rbs +0 -12
data/docs/08_MESSAGES.md
CHANGED
|
@@ -32,9 +32,9 @@ msg.to_h # => {role: :user, content: "Hello, how are you?"}
|
|
|
32
32
|
User messages can include file attachments:
|
|
33
33
|
|
|
34
34
|
```ruby
|
|
35
|
-
file = Riffer::FilePart.from_path("photo.jpg")
|
|
35
|
+
file = Riffer::Messages::FilePart.from_path("photo.jpg")
|
|
36
36
|
msg = Riffer::Messages::User.new("Describe this image", files: [file])
|
|
37
|
-
msg.files # => [#<Riffer::FilePart ...>]
|
|
37
|
+
msg.files # => [#<Riffer::Messages::FilePart ...>]
|
|
38
38
|
msg.to_h # => {role: :user, content: "Describe this image", files: [{...}]}
|
|
39
39
|
```
|
|
40
40
|
|
|
@@ -48,7 +48,7 @@ msg = Riffer::Messages::Assistant.new("I'm doing well, thank you!")
|
|
|
48
48
|
msg.role # => :assistant
|
|
49
49
|
msg.content # => "I'm doing well, thank you!"
|
|
50
50
|
msg.tool_calls # => []
|
|
51
|
-
msg.token_usage # => nil or Riffer::TokenUsage
|
|
51
|
+
msg.token_usage # => nil or Riffer::Providers::TokenUsage
|
|
52
52
|
|
|
53
53
|
# Response with tool calls
|
|
54
54
|
msg = Riffer::Messages::Assistant.new("", tool_calls: [
|
|
@@ -118,7 +118,7 @@ msg.error_type # => :execution_error
|
|
|
118
118
|
|
|
119
119
|
## File Parts
|
|
120
120
|
|
|
121
|
-
`Riffer::FilePart` represents a file attachment (image or document) that can be included with user messages.
|
|
121
|
+
`Riffer::Messages::FilePart` represents a file attachment (image or document) that can be included with user messages.
|
|
122
122
|
|
|
123
123
|
### Supported Media Types
|
|
124
124
|
|
|
@@ -130,18 +130,18 @@ msg.error_type # => :execution_error
|
|
|
130
130
|
|
|
131
131
|
```ruby
|
|
132
132
|
# From a file path (reads eagerly, detects media type from extension)
|
|
133
|
-
file = Riffer::FilePart.from_path("photo.jpg")
|
|
133
|
+
file = Riffer::Messages::FilePart.from_path("photo.jpg")
|
|
134
134
|
file.media_type # => "image/jpeg"
|
|
135
135
|
file.filename # => "photo.jpg"
|
|
136
136
|
file.image? # => true
|
|
137
137
|
|
|
138
138
|
# From a URL (stored directly, resolved lazily if provider needs bytes)
|
|
139
|
-
file = Riffer::FilePart.from_url("https://example.com/doc.pdf")
|
|
139
|
+
file = Riffer::Messages::FilePart.from_url("https://example.com/doc.pdf")
|
|
140
140
|
file.url? # => true
|
|
141
141
|
file.document? # => true
|
|
142
142
|
|
|
143
143
|
# From raw base64 data
|
|
144
|
-
file = Riffer::FilePart.new(media_type: "image/png", data: base64_string, filename: "chart.png")
|
|
144
|
+
file = Riffer::Messages::FilePart.new(media_type: "image/png", data: base64_string, filename: "chart.png")
|
|
145
145
|
```
|
|
146
146
|
|
|
147
147
|
### Hash Shorthand
|
|
@@ -183,39 +183,30 @@ This creates a `User` message internally.
|
|
|
183
183
|
|
|
184
184
|
### Message Arrays
|
|
185
185
|
|
|
186
|
-
For multi-turn conversations,
|
|
186
|
+
For multi-turn conversations restored from persisted state, construct a `Riffer::Agent::Session` with the message history and hand it to a new agent:
|
|
187
187
|
|
|
188
188
|
```ruby
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
]
|
|
189
|
+
session = Riffer::Agent::Session.new(messages: [
|
|
190
|
+
Riffer::Messages::User.new("What's the weather?"),
|
|
191
|
+
Riffer::Messages::Assistant.new("I'll check that for you."),
|
|
192
|
+
Riffer::Messages::User.new("Thanks, I meant in Tokyo specifically.")
|
|
193
|
+
])
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
agent = MyAgent.new(session: session)
|
|
196
|
+
response = agent.generate # session already carries the last user turn
|
|
196
197
|
```
|
|
197
198
|
|
|
198
|
-
Messages
|
|
199
|
-
|
|
200
|
-
```ruby
|
|
201
|
-
messages = [
|
|
202
|
-
Riffer::Messages::User.new("Hello"),
|
|
203
|
-
Riffer::Messages::Assistant.new("Hi there!"),
|
|
204
|
-
Riffer::Messages::User.new("How are you?")
|
|
205
|
-
]
|
|
206
|
-
|
|
207
|
-
response = agent.generate(messages)
|
|
208
|
-
```
|
|
199
|
+
`Riffer::Agent::Session.new(messages:)` accepts `Riffer::Messages::Base` objects. If your persistence layer hands back hashes, normalize them first via `Riffer::Messages::Converter#convert_to_message_object` or your own adapter (e.g. jane's `to_riffer`).
|
|
209
200
|
|
|
210
201
|
### Accessing Message History
|
|
211
202
|
|
|
212
|
-
After calling `generate` or `stream`, access the full conversation:
|
|
203
|
+
Conversation state lives on `agent.session` — a `Riffer::Agent::Session` instance. After calling `generate` or `stream`, access the full conversation:
|
|
213
204
|
|
|
214
205
|
```ruby
|
|
215
206
|
agent = MyAgent.new
|
|
216
207
|
agent.generate("Hello!")
|
|
217
208
|
|
|
218
|
-
agent.messages.each do |msg|
|
|
209
|
+
agent.session.messages.each do |msg|
|
|
219
210
|
puts "[#{msg.role}] #{msg.content}"
|
|
220
211
|
end
|
|
221
212
|
# [system] You are a helpful assistant.
|
|
@@ -223,6 +214,8 @@ end
|
|
|
223
214
|
# [assistant] Hi there! How can I help you today?
|
|
224
215
|
```
|
|
225
216
|
|
|
217
|
+
`Riffer::Agent::Session` includes `Enumerable`, so `find`, `select`, `count`, `reverse_each` etc. work directly on the session without going through `.messages`.
|
|
218
|
+
|
|
226
219
|
## Tool Call Structure
|
|
227
220
|
|
|
228
221
|
Tool calls in assistant messages have this structure:
|
|
@@ -264,19 +257,19 @@ Without this step, the same model can receive different input depending on the p
|
|
|
264
257
|
When a context message is injected before the user's turn, two consecutive user messages are merged into one:
|
|
265
258
|
|
|
266
259
|
```ruby
|
|
267
|
-
|
|
260
|
+
session = Riffer::Agent::Session.new(messages: [
|
|
268
261
|
Riffer::Messages::System.new("You are a code reviewer."),
|
|
269
262
|
Riffer::Messages::User.new("The repository uses RSpec for testing."),
|
|
270
263
|
Riffer::Messages::User.new("Review this pull request.")
|
|
271
|
-
]
|
|
264
|
+
])
|
|
272
265
|
|
|
273
|
-
|
|
266
|
+
MyAgent.new(session: session).generate
|
|
274
267
|
# The provider receives two messages:
|
|
275
268
|
# 1. System — "You are a code reviewer."
|
|
276
269
|
# 2. User — "The repository uses RSpec for testing.\n\nReview this pull request."
|
|
277
270
|
```
|
|
278
271
|
|
|
279
|
-
Merging happens at serialization time only. The
|
|
272
|
+
Merging happens at serialization time only. The session's `messages` array still contains the original separate messages for logging, evals, and debugging.
|
|
280
273
|
|
|
281
274
|
## IDs
|
|
282
275
|
|
|
@@ -330,4 +323,4 @@ Subclasses implement `role` and optionally extend `to_h` with additional fields.
|
|
|
330
323
|
|
|
331
324
|
## Editing history after the fact
|
|
332
325
|
|
|
333
|
-
The
|
|
326
|
+
The session's `messages` array is mutable, but the message value objects themselves are immutable. To edit recorded history — truncate an assistant message, rewrite a tool result, fill an orphan `tool_use` — use the mutators on `agent.session` (`update`, `remove`). Each one enforces the `tool_use` ↔ `tool_result` invariant. See [Mutating history](04_AGENT_LIFECYCLE.md#mutating-history) for the full list.
|
data/docs/09_STREAM_EVENTS.md
CHANGED
|
@@ -249,7 +249,7 @@ Emitted when token usage data is available at the end of a response:
|
|
|
249
249
|
```ruby
|
|
250
250
|
event = Riffer::StreamEvents::TokenUsageDone.new(token_usage: token_usage)
|
|
251
251
|
event.role # => :assistant
|
|
252
|
-
event.token_usage # => Riffer::TokenUsage
|
|
252
|
+
event.token_usage # => Riffer::Providers::TokenUsage
|
|
253
253
|
event.token_usage.input_tokens # => 100
|
|
254
254
|
event.token_usage.output_tokens # => 50
|
|
255
255
|
event.token_usage.total_tokens # => 150
|
data/docs/10_CONFIGURATION.md
CHANGED
|
@@ -61,14 +61,14 @@ Configure the default tool runtime for all agents:
|
|
|
61
61
|
|
|
62
62
|
```ruby
|
|
63
63
|
Riffer.configure do |config|
|
|
64
|
-
config.tool_runtime = Riffer::
|
|
64
|
+
config.tool_runtime = Riffer::Tools::Runtime::Threaded
|
|
65
65
|
end
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
| Value | Description |
|
|
69
69
|
| ------------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
70
|
-
| `Riffer::
|
|
71
|
-
| `Riffer::
|
|
70
|
+
| `Riffer::Tools::Runtime` subclass | Instantiated automatically (e.g., `Riffer::Tools::Runtime::Inline`, `Riffer::Tools::Runtime::Threaded`) |
|
|
71
|
+
| `Riffer::Tools::Runtime` instance | Custom runtime with specific options |
|
|
72
72
|
| `Proc` | Dynamic resolution |
|
|
73
73
|
|
|
74
74
|
Per-agent configuration overrides this global default. See [Advanced Tool Configuration — Tool Runtime](07_TOOL_ADVANCED.md#tool-runtime-experimental) for details.
|
|
@@ -119,18 +119,7 @@ end
|
|
|
119
119
|
|
|
120
120
|
When the strategy is not `:none`, every `Riffer::Messages::Base` instance — user prompts, system instructions, assistant responses, and tool results — gets an auto-generated `id` at construction time. IDs are included in `message.to_h` when present and omitted when `nil`. Provider API payloads are unaffected; the `id` stays on the Ruby side.
|
|
121
121
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
```ruby
|
|
125
|
-
Riffer.configure { |c| c.message_id_strategy = :uuidv7 }
|
|
126
|
-
|
|
127
|
-
agent.generate([
|
|
128
|
-
{role: :user, content: "Hi", id: "msg-001"},
|
|
129
|
-
{role: :assistant, content: "Hello!", id: "msg-002"}
|
|
130
|
-
])
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
Missing ids raise `Riffer::ArgumentError` with the offending index.
|
|
122
|
+
When constructing a `Riffer::Agent::Session` from persisted history with the strategy enabled, supply ids on every seeded message yourself — Riffer never fabricates identifiers for pre-existing history. Messages built via the `Riffer::Messages::*` constructors auto-generate ids per the strategy, so as long as those constructors are used at message-creation time, ids flow through.
|
|
134
123
|
|
|
135
124
|
See [Messages — IDs](08_MESSAGES.md#ids) for more details.
|
|
136
125
|
|
|
@@ -148,12 +137,12 @@ end
|
|
|
148
137
|
|
|
149
138
|
When enabled, two repairs run automatically:
|
|
150
139
|
|
|
151
|
-
1. **Seeded
|
|
140
|
+
1. **Seeded session.** Passing a pre-populated `Riffer::Agent::Session` to `Agent.new(session: ...)` silently drops orphaned `tool_use` exchanges (assistant `tool_call` with no matching `Tool` result) and parentless `Tool` messages before the next inference call. Pending tool calls on the **resume boundary** — the last assistant whose tail is purely `Tool` results (or none) — are preserved; `execute_pending_tool_calls` runs them on the next LLM call.
|
|
152
141
|
2. **Interrupts.** Any orphan `tool_use` left when the loop is interrupted (caller-issued `interrupt!` or the built-in `INTERRUPT_MAX_STEPS` ceiling) is filled with a placeholder `Riffer::Messages::Tool` carrying `error_type: :interrupted` and the content `"Tool call interrupted before completion."`. Filled `call_id`s are exposed on `Riffer::Agent::Response#healed_tool_call_ids` (and `Riffer::StreamEvents::Interrupt#healed_tool_call_ids` when streaming).
|
|
153
142
|
|
|
154
|
-
Defaults to `false` — pre-healing behavior. Seeded
|
|
143
|
+
Defaults to `false` — pre-healing behavior. Seeded sessions pass through untouched, and orphan `tool_use` left by an interrupt remain in history for `execute_pending_tool_calls` to re-run on the next call.
|
|
155
144
|
|
|
156
|
-
There is no per-call override and no customizable placeholder. Callers needing finer control can call
|
|
145
|
+
There is no per-call override and no customizable placeholder. Callers needing finer control can call `agent.session.update(tool_call_id:, ...)` after the interrupt returns to upgrade a placeholder in place. See [Agent Lifecycle — Healing pending tool results on interrupt](04_AGENT_LIFECYCLE.md#healing-pending-tool-results-on-interrupt-experimental).
|
|
157
146
|
|
|
158
147
|
## Agent-Level Configuration
|
|
159
148
|
|
|
@@ -11,6 +11,7 @@ Providers are adapters that connect Riffer to LLM services. They implement a com
|
|
|
11
11
|
| Amazon Bedrock | `amazon_bedrock` | `aws-sdk-bedrockruntime` |
|
|
12
12
|
| Anthropic | `anthropic` | `anthropic` |
|
|
13
13
|
| Gemini | `gemini` | None |
|
|
14
|
+
| OpenRouter | `openrouter` | `openai` |
|
|
14
15
|
| Mock | `mock` | None |
|
|
15
16
|
|
|
16
17
|
## Model String Format
|
|
@@ -24,6 +25,7 @@ class MyAgent < Riffer::Agent
|
|
|
24
25
|
model 'amazon_bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0' # Bedrock
|
|
25
26
|
model 'anthropic/claude-haiku-4-5-20251001' # Anthropic
|
|
26
27
|
model 'gemini/gemini-2.5-flash-lite' # Gemini
|
|
28
|
+
model 'openrouter/anthropic/claude-sonnet-4.6' # OpenRouter
|
|
27
29
|
model 'mock/any' # Mock provider
|
|
28
30
|
end
|
|
29
31
|
```
|
|
@@ -165,6 +167,9 @@ Riffer::Providers::Repository.find(:anthropic)
|
|
|
165
167
|
Riffer::Providers::Repository.find(:gemini)
|
|
166
168
|
# => Riffer::Providers::Gemini
|
|
167
169
|
|
|
170
|
+
Riffer::Providers::Repository.find(:openrouter)
|
|
171
|
+
# => Riffer::Providers::OpenRouter
|
|
172
|
+
|
|
168
173
|
Riffer::Providers::Repository.find(:mock)
|
|
169
174
|
# => Riffer::Providers::Mock
|
|
170
175
|
```
|
|
@@ -178,3 +183,4 @@ Riffer::Providers::Repository.find(:mock)
|
|
|
178
183
|
- [Mock](06_MOCK_PROVIDER.md) - Mock provider for testing
|
|
179
184
|
- [Custom Providers](07_CUSTOM_PROVIDERS.md) - Creating your own provider
|
|
180
185
|
- [Gemini](08_GEMINI.md) - Gemini models via Google GenAI API
|
|
186
|
+
- [OpenRouter](09_OPENROUTER.md) - Unified gateway across many vendors
|
|
@@ -121,7 +121,8 @@ class MyAgentTest < Minitest::Test
|
|
|
121
121
|
])
|
|
122
122
|
@provider.stub_response("Done.")
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
agent = TestableAgent.new(context: {user_id: 123})
|
|
125
|
+
agent.generate("Do something")
|
|
125
126
|
|
|
126
127
|
# Tool receives the context
|
|
127
128
|
end
|
|
@@ -60,7 +60,7 @@ class Riffer::Providers::MyProvider < Riffer::Providers::Base
|
|
|
60
60
|
usage = response.usage
|
|
61
61
|
return nil unless usage
|
|
62
62
|
|
|
63
|
-
Riffer::TokenUsage.new(
|
|
63
|
+
Riffer::Providers::TokenUsage.new(
|
|
64
64
|
input_tokens: usage.input_tokens,
|
|
65
65
|
output_tokens: usage.output_tokens
|
|
66
66
|
)
|
|
@@ -234,7 +234,7 @@ Riffer::StreamEvents::WebSearchDone.new(
|
|
|
234
234
|
|
|
235
235
|
# Token usage (emit at end of stream)
|
|
236
236
|
Riffer::StreamEvents::TokenUsageDone.new(
|
|
237
|
-
token_usage: Riffer::TokenUsage.new(
|
|
237
|
+
token_usage: Riffer::Providers::TokenUsage.new(
|
|
238
238
|
input_tokens: 100,
|
|
239
239
|
output_tokens: 50
|
|
240
240
|
)
|
|
@@ -309,7 +309,7 @@ class Riffer::Providers::MyProvider < Riffer::Providers::Base
|
|
|
309
309
|
yielder << Riffer::StreamEvents::TextDone.new(accumulated_text)
|
|
310
310
|
when :usage
|
|
311
311
|
yielder << Riffer::StreamEvents::TokenUsageDone.new(
|
|
312
|
-
token_usage: Riffer::TokenUsage.new(
|
|
312
|
+
token_usage: Riffer::Providers::TokenUsage.new(
|
|
313
313
|
input_tokens: event.usage.input_tokens,
|
|
314
314
|
output_tokens: event.usage.output_tokens
|
|
315
315
|
)
|
|
@@ -322,7 +322,7 @@ class Riffer::Providers::MyProvider < Riffer::Providers::Base
|
|
|
322
322
|
usage = response.usage
|
|
323
323
|
return nil unless usage
|
|
324
324
|
|
|
325
|
-
Riffer::TokenUsage.new(
|
|
325
|
+
Riffer::Providers::TokenUsage.new(
|
|
326
326
|
input_tokens: usage.input_tokens,
|
|
327
327
|
output_tokens: usage.output_tokens
|
|
328
328
|
)
|
data/docs/providers/08_GEMINI.md
CHANGED
|
@@ -90,7 +90,7 @@ end
|
|
|
90
90
|
params = Riffer::Params.new
|
|
91
91
|
params.required(:sentiment, String)
|
|
92
92
|
params.required(:score, Float)
|
|
93
|
-
structured_output = Riffer::StructuredOutput.new(params)
|
|
93
|
+
structured_output = Riffer::Agent::StructuredOutput.new(params)
|
|
94
94
|
|
|
95
95
|
response = provider.generate_text(
|
|
96
96
|
prompt: "Analyze: 'This is great!'",
|
|
@@ -125,7 +125,7 @@ response = provider.generate_text(
|
|
|
125
125
|
Gemini supports inline base64-encoded files (images and documents):
|
|
126
126
|
|
|
127
127
|
```ruby
|
|
128
|
-
file = Riffer::FilePart.new(data: base64_data, media_type: "image/png")
|
|
128
|
+
file = Riffer::Messages::FilePart.new(data: base64_data, media_type: "image/png")
|
|
129
129
|
response = provider.generate_text(
|
|
130
130
|
prompt: "Describe this image",
|
|
131
131
|
model: "gemini-2.5-flash-lite",
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# OpenRouter Provider
|
|
2
|
+
|
|
3
|
+
The OpenRouter provider connects Riffer to [OpenRouter](https://openrouter.ai) — a unified gateway that exposes hundreds of LLMs from many vendors (Anthropic, OpenAI, Meta, Mistral, DeepSeek, Google, Grok, Qwen, and more) behind a single OpenAI-compatible Chat Completions endpoint.
|
|
4
|
+
|
|
5
|
+
OpenRouter is useful when you want one credential, one model-string format, and access to models Riffer doesn't have a direct provider for. It also offers built-in routing, fallback, and prompt transforms.
|
|
6
|
+
|
|
7
|
+
> **Note:** OpenRouter exposes only the OpenAI **Chat Completions** API, not the Responses API. That's why this provider does not subclass `Riffer::Providers::OpenAI` (which uses Responses). It implements the five hook methods independently against Chat Completions while still sharing the `openai` Ruby gem.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add the OpenAI gem to your Gemfile — OpenRouter reuses it:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem 'openai'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Set your API key globally:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
Riffer.configure do |config|
|
|
23
|
+
config.openrouter.api_key = ENV['OPENROUTER_API_KEY']
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or per-agent:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
class MyAgent < Riffer::Agent
|
|
31
|
+
model 'openrouter/anthropic/claude-sonnet-4.6'
|
|
32
|
+
provider_options api_key: ENV['MY_OR_KEY']
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `api_key` resolves in order: keyword arg → `Riffer.config.openrouter.api_key` → `ENV['OPENROUTER_API_KEY']`.
|
|
37
|
+
|
|
38
|
+
## Supported Models
|
|
39
|
+
|
|
40
|
+
Use any OpenRouter model in the `openrouter/<openrouter-model-id>` format. The OpenRouter model ID is everything after the first slash:
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
model 'openrouter/anthropic/claude-sonnet-4.6'
|
|
44
|
+
model 'openrouter/openai/gpt-4o-mini'
|
|
45
|
+
model 'openrouter/meta-llama/llama-3.1-70b-instruct'
|
|
46
|
+
model 'openrouter/deepseek/deepseek-r1'
|
|
47
|
+
model 'openrouter/mistralai/mixtral-8x22b-instruct'
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
See OpenRouter's [model catalog](https://openrouter.ai/models) for the full list.
|
|
51
|
+
|
|
52
|
+
## Model Options
|
|
53
|
+
|
|
54
|
+
### temperature, max_tokens, top_p, etc.
|
|
55
|
+
|
|
56
|
+
Standard sampling options pass through to the underlying model:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
model_options temperature: 0.5, max_tokens: 2048
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### reasoning
|
|
63
|
+
|
|
64
|
+
For reasoning models (DeepSeek R1, OpenAI o-series via OpenRouter, etc.):
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
model_options reasoning: 'high' # 'low' | 'medium' | 'high'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Pass a hash for finer control:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
model_options reasoning: {effort: 'medium', max_tokens: 5000}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Streaming yields `Riffer::StreamEvents::ReasoningDelta` and `ReasoningDone` events when the model returns reasoning content.
|
|
77
|
+
|
|
78
|
+
### provider (routing preferences)
|
|
79
|
+
|
|
80
|
+
Pin which upstream provider OpenRouter should use, set allow/deny lists, or prefer a sort order:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
model_options provider: {
|
|
84
|
+
order: ['anthropic', 'openai'],
|
|
85
|
+
allow_fallbacks: false
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
See OpenRouter's [provider routing docs](https://openrouter.ai/docs/provider-routing) for the full schema.
|
|
90
|
+
|
|
91
|
+
### models (fallback chain)
|
|
92
|
+
|
|
93
|
+
If the primary model is unavailable, OpenRouter will try the next one in the list:
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
model_options models: ['openai/gpt-4o', 'anthropic/claude-sonnet-4.6']
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### transforms
|
|
100
|
+
|
|
101
|
+
Prompt transforms applied by OpenRouter (e.g. middle-out auto-truncation):
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
model_options transforms: ['middle-out']
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Example
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
Riffer.configure do |config|
|
|
111
|
+
config.openrouter.api_key = ENV['OPENROUTER_API_KEY']
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
class TranslateAgent < Riffer::Agent
|
|
115
|
+
model 'openrouter/anthropic/claude-sonnet-4.6'
|
|
116
|
+
instructions 'You translate English to French.'
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
puts TranslateAgent.new.generate('Hello, world!')
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Streaming
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
agent.stream('Explain Ruby blocks').each do |event|
|
|
126
|
+
case event
|
|
127
|
+
when Riffer::StreamEvents::TextDelta
|
|
128
|
+
print event.content
|
|
129
|
+
when Riffer::StreamEvents::ReasoningDelta
|
|
130
|
+
print "[thinking] #{event.content}"
|
|
131
|
+
when Riffer::StreamEvents::TokenUsageDone
|
|
132
|
+
puts "\n[tokens: #{event.token_usage.total_tokens}]"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The provider opts into `stream_options: {include_usage: true}` automatically so `TokenUsageDone` fires reliably.
|
|
138
|
+
|
|
139
|
+
## Tool Calling
|
|
140
|
+
|
|
141
|
+
Tools are converted to OpenAI Chat Completions function format. The provider handles tool name encoding/decoding (slashes in tool names are wire-encoded with `__`) just like the OpenAI and Anthropic providers.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class CalculatorTool < Riffer::Tool
|
|
145
|
+
description 'Performs basic math'
|
|
146
|
+
params do
|
|
147
|
+
required :operation, String, enum: ['add', 'subtract', 'multiply', 'divide']
|
|
148
|
+
required :a, Float
|
|
149
|
+
required :b, Float
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def call(context:, operation:, a:, b:)
|
|
153
|
+
result = case operation
|
|
154
|
+
when 'add' then a + b
|
|
155
|
+
when 'subtract' then a - b
|
|
156
|
+
when 'multiply' then a * b
|
|
157
|
+
when 'divide' then a / b
|
|
158
|
+
end
|
|
159
|
+
text(result.to_s)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
class MathAgent < Riffer::Agent
|
|
164
|
+
model 'openrouter/openai/gpt-4o-mini'
|
|
165
|
+
uses_tools [CalculatorTool]
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Reasoning Models
|
|
170
|
+
|
|
171
|
+
Reasoning models surface their thought process via OpenRouter's normalised `reasoning` field. Enable it with the `reasoning` option:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
class ThinkAgent < Riffer::Agent
|
|
175
|
+
model 'openrouter/deepseek/deepseek-r1'
|
|
176
|
+
model_options reasoning: 'medium'
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
ThinkAgent.new.stream('What is 2+2? Think step by step.').each do |event|
|
|
180
|
+
case event
|
|
181
|
+
when Riffer::StreamEvents::ReasoningDelta
|
|
182
|
+
print "[reasoning] #{event.content}"
|
|
183
|
+
when Riffer::StreamEvents::TextDelta
|
|
184
|
+
print event.content
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Routing & Fallbacks
|
|
190
|
+
|
|
191
|
+
Survive an upstream outage by chaining models:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class ResilientAgent < Riffer::Agent
|
|
195
|
+
model 'openrouter/openai/gpt-4o-mini'
|
|
196
|
+
model_options models: [
|
|
197
|
+
'openai/gpt-4o-mini',
|
|
198
|
+
'anthropic/claude-haiku-4.5',
|
|
199
|
+
'google/gemini-flash-1.5'
|
|
200
|
+
]
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Pin to a specific upstream when consistency matters:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
model_options provider: {order: ['anthropic'], allow_fallbacks: false}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Message Format
|
|
211
|
+
|
|
212
|
+
Riffer messages convert to Chat Completions roles:
|
|
213
|
+
|
|
214
|
+
| Riffer Message | Chat Completions Role |
|
|
215
|
+
| -------------- | --------------------- |
|
|
216
|
+
| `System` | `system` |
|
|
217
|
+
| `User` | `user` |
|
|
218
|
+
| `Assistant` | `assistant` |
|
|
219
|
+
| `Tool` | `tool` |
|
|
220
|
+
|
|
221
|
+
User messages with files become multi-part content (`image_url` for images, `file` for documents). Assistant tool calls go into a nested `tool_calls` array on the assistant message.
|
|
222
|
+
|
|
223
|
+
## Limitations (v1)
|
|
224
|
+
|
|
225
|
+
- **No unified web search.** OpenRouter doesn't expose a cross-vendor web-search tool — capability varies per upstream model.
|
|
226
|
+
- **Audio and image generation models** are not supported.
|
|
227
|
+
- **Responses API features** (e.g. OpenAI's `response.id` for continuation) are unavailable — OpenRouter implements only Chat Completions.
|
|
228
|
+
|
|
229
|
+
## Direct Provider Usage
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
provider = Riffer::Providers::OpenRouter.new(api_key: ENV['OPENROUTER_API_KEY'])
|
|
233
|
+
|
|
234
|
+
response = provider.generate_text(
|
|
235
|
+
prompt: 'Hello!',
|
|
236
|
+
model: 'anthropic/claude-sonnet-4.6',
|
|
237
|
+
temperature: 0.7
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
puts response.content
|
|
241
|
+
puts response.token_usage.total_tokens
|
|
242
|
+
```
|