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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/architecture.md +18 -11
  3. data/.agents/code-style.md +1 -1
  4. data/.agents/rbs-inline.md +2 -2
  5. data/.agents/testing.md +9 -5
  6. data/.release-please-manifest.json +1 -1
  7. data/AGENTS.md +17 -10
  8. data/CHANGELOG.md +19 -0
  9. data/README.md +17 -18
  10. data/Steepfile +7 -1
  11. data/docs/03_AGENTS.md +34 -3
  12. data/docs/04_AGENT_LIFECYCLE.md +87 -86
  13. data/docs/05_AGENT_LOOP.md +2 -2
  14. data/docs/06_TOOLS.md +9 -4
  15. data/docs/07_TOOL_ADVANCED.md +17 -17
  16. data/docs/08_MESSAGES.md +25 -32
  17. data/docs/09_STREAM_EVENTS.md +1 -1
  18. data/docs/10_CONFIGURATION.md +7 -18
  19. data/docs/providers/01_PROVIDERS.md +6 -0
  20. data/docs/providers/06_MOCK_PROVIDER.md +2 -1
  21. data/docs/providers/07_CUSTOM_PROVIDERS.md +4 -4
  22. data/docs/providers/08_GEMINI.md +2 -2
  23. data/docs/providers/09_OPENROUTER.md +242 -0
  24. data/lib/riffer/agent/config.rb +173 -0
  25. data/lib/riffer/agent/context.rb +125 -0
  26. data/lib/riffer/agent/run.rb +308 -0
  27. data/lib/riffer/agent/session/repair.rb +112 -0
  28. data/lib/riffer/agent/session.rb +268 -0
  29. data/lib/riffer/{structured_output → agent/structured_output}/result.rb +1 -1
  30. data/lib/riffer/{structured_output.rb → agent/structured_output.rb} +4 -4
  31. data/lib/riffer/agent.rb +234 -923
  32. data/lib/riffer/config.rb +14 -7
  33. data/lib/riffer/evals/evaluator.rb +13 -3
  34. data/lib/riffer/evals/judge.rb +2 -2
  35. data/lib/riffer/evals/run_result.rb +2 -1
  36. data/lib/riffer/evals/scenario_result.rb +2 -1
  37. data/lib/riffer/guardrails/runner.rb +3 -2
  38. data/lib/riffer/helpers/call_or_value.rb +16 -0
  39. data/lib/riffer/helpers.rb +0 -1
  40. data/lib/riffer/mcp/authenticated_tool.rb +4 -0
  41. data/lib/riffer/mcp/client.rb +1 -1
  42. data/lib/riffer/mcp/registration.rb +2 -3
  43. data/lib/riffer/mcp/registry.rb +3 -1
  44. data/lib/riffer/mcp/tool_factory.rb +5 -0
  45. data/lib/riffer/messages/assistant.rb +9 -3
  46. data/lib/riffer/messages/base.rb +22 -0
  47. data/lib/riffer/messages/converter.rb +6 -6
  48. data/lib/riffer/{file_part.rb → messages/file_part.rb} +5 -5
  49. data/lib/riffer/messages/tool.rb +1 -1
  50. data/lib/riffer/messages/user.rb +4 -4
  51. data/lib/riffer/{boolean.rb → params/boolean.rb} +3 -3
  52. data/lib/riffer/{param.rb → params/param.rb} +6 -6
  53. data/lib/riffer/params.rb +27 -21
  54. data/lib/riffer/providers/amazon_bedrock.rb +19 -20
  55. data/lib/riffer/providers/anthropic.rb +27 -28
  56. data/lib/riffer/providers/base.rb +10 -9
  57. data/lib/riffer/providers/gemini.rb +15 -12
  58. data/lib/riffer/providers/mock.rb +41 -13
  59. data/lib/riffer/providers/open_ai.rb +24 -22
  60. data/lib/riffer/providers/open_router.rb +318 -0
  61. data/lib/riffer/providers/repository.rb +1 -0
  62. data/lib/riffer/{token_usage.rb → providers/token_usage.rb} +4 -4
  63. data/lib/riffer/providers.rb +1 -0
  64. data/lib/riffer/runner/fibers.rb +4 -3
  65. data/lib/riffer/runner/sequential.rb +1 -1
  66. data/lib/riffer/runner/threaded.rb +1 -1
  67. data/lib/riffer/runner.rb +1 -1
  68. data/lib/riffer/skills/activate_tool.rb +4 -3
  69. data/lib/riffer/skills/config.rb +1 -1
  70. data/lib/riffer/skills/context.rb +3 -3
  71. data/lib/riffer/skills/filesystem_backend.rb +7 -5
  72. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  73. data/lib/riffer/skills/xml_adapter.rb +1 -1
  74. data/lib/riffer/stream_events/interrupt.rb +1 -1
  75. data/lib/riffer/stream_events/token_usage_done.rb +2 -2
  76. data/lib/riffer/stream_events/web_search_status.rb +1 -1
  77. data/lib/riffer/tool.rb +3 -3
  78. data/lib/riffer/{tool_runtime → tools/runtime}/fibers.rb +2 -2
  79. data/lib/riffer/{tool_runtime → tools/runtime}/inline.rb +1 -1
  80. data/lib/riffer/{tool_runtime → tools/runtime}/threaded.rb +2 -2
  81. data/lib/riffer/{tool_runtime.rb → tools/runtime.rb} +9 -9
  82. data/lib/riffer/{toolable.rb → tools/toolable.rb} +12 -9
  83. data/lib/riffer/version.rb +1 -1
  84. data/lib/riffer.rb +2 -1
  85. data/sig/generated/riffer/agent/config.rbs +119 -0
  86. data/sig/generated/riffer/agent/context.rbs +91 -0
  87. data/sig/generated/riffer/agent/run.rbs +144 -0
  88. data/sig/generated/riffer/agent/session/repair.rbs +51 -0
  89. data/sig/generated/riffer/agent/session.rbs +145 -0
  90. data/sig/generated/riffer/{structured_output → agent/structured_output}/result.rbs +2 -2
  91. data/sig/generated/riffer/{structured_output.rbs → agent/structured_output.rbs} +6 -6
  92. data/sig/generated/riffer/agent.rbs +143 -342
  93. data/sig/generated/riffer/config.rbs +17 -5
  94. data/sig/generated/riffer/evals/judge.rbs +2 -2
  95. data/sig/generated/riffer/helpers/call_or_value.rbs +9 -0
  96. data/sig/generated/riffer/helpers.rbs +0 -1
  97. data/sig/generated/riffer/messages/assistant.rbs +7 -3
  98. data/sig/generated/riffer/messages/base.rbs +18 -0
  99. data/sig/generated/riffer/messages/converter.rbs +4 -4
  100. data/sig/generated/riffer/{file_part.rbs → messages/file_part.rbs} +5 -5
  101. data/sig/generated/riffer/messages/user.rbs +4 -4
  102. data/sig/generated/riffer/params/boolean.rbs +10 -0
  103. data/sig/generated/riffer/{param.rbs → params/param.rbs} +3 -3
  104. data/sig/generated/riffer/params.rbs +15 -15
  105. data/sig/generated/riffer/providers/amazon_bedrock.rbs +22 -22
  106. data/sig/generated/riffer/providers/anthropic.rbs +4 -4
  107. data/sig/generated/riffer/providers/base.rbs +10 -10
  108. data/sig/generated/riffer/providers/gemini.rbs +4 -4
  109. data/sig/generated/riffer/providers/mock.rbs +25 -5
  110. data/sig/generated/riffer/providers/open_ai.rbs +4 -4
  111. data/sig/generated/riffer/providers/open_router.rbs +85 -0
  112. data/sig/generated/riffer/{token_usage.rbs → providers/token_usage.rbs} +5 -5
  113. data/sig/generated/riffer/providers.rbs +1 -0
  114. data/sig/generated/riffer/runner/fibers.rbs +2 -2
  115. data/sig/generated/riffer/runner/sequential.rbs +2 -2
  116. data/sig/generated/riffer/runner/threaded.rbs +2 -2
  117. data/sig/generated/riffer/runner.rbs +2 -2
  118. data/sig/generated/riffer/skills/activate_tool.rbs +4 -3
  119. data/sig/generated/riffer/skills/config.rbs +1 -1
  120. data/sig/generated/riffer/skills/context.rbs +2 -2
  121. data/sig/generated/riffer/stream_events/token_usage_done.rbs +3 -3
  122. data/sig/generated/riffer/tool.rbs +5 -5
  123. data/sig/generated/riffer/{tool_runtime → tools/runtime}/fibers.rbs +3 -3
  124. data/sig/generated/riffer/{tool_runtime → tools/runtime}/inline.rbs +2 -2
  125. data/sig/generated/riffer/{tool_runtime → tools/runtime}/threaded.rbs +3 -3
  126. data/sig/generated/riffer/{tool_runtime.rbs → tools/runtime.rbs} +12 -12
  127. data/sig/generated/riffer/{toolable.rbs → tools/toolable.rbs} +6 -6
  128. data/sig/stubs/agent_ivars.rbs +7 -0
  129. data/sig/stubs/async.rbs +24 -0
  130. data/sig/stubs/aws-sdk-core/seahorse_request_context.rbs +7 -0
  131. data/sig/stubs/aws-sdk-core/static_token_provider.rbs +5 -0
  132. data/sig/stubs/extend_self.rbs +11 -0
  133. data/sig/stubs/lib_ivars.rbs +101 -0
  134. data/sig/stubs/mcp_sdk.rbs +22 -0
  135. data/sig/stubs/provider_ivars.rbs +36 -0
  136. data/sig/stubs/provider_sdk_methods.rbs +50 -0
  137. data/sig/stubs/zeitwerk.rbs +12 -0
  138. metadata +54 -33
  139. data/lib/riffer/core.rb +0 -28
  140. data/lib/riffer/helpers/validations.rb +0 -18
  141. data/sig/generated/riffer/boolean.rbs +0 -10
  142. data/sig/generated/riffer/core.rbs +0 -19
  143. 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, pass an array of messages:
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
- messages = [
190
- {role: :user, content: "What's the weather?"},
191
- {role: :assistant, content: "I'll check that for you."},
192
- {role: :user, content: "Thanks, I meant in Tokyo specifically."}
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
- response = agent.generate(messages)
195
+ agent = MyAgent.new(session: session)
196
+ response = agent.generate # session already carries the last user turn
196
197
  ```
197
198
 
198
- Messages can be hashes or `Riffer::Messages::Base` objects:
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
- messages = [
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
- agent.generate(messages)
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 agent's `messages` array still contains the original separate messages for logging, evals, and debugging.
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 agent's `messages` array is mutable, but the message value objects themselves are immutable. To edit recorded history — truncate an assistant message, replace a tool result, fill an orphan `tool_use` — use the mutators on `Riffer::Agent`. Each mutator enforces the `tool_use` ↔ `tool_result` invariant. See [Mutating history](04_AGENT_LIFECYCLE.md#mutating-history) for the full list.
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.
@@ -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
@@ -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::ToolRuntime::Threaded
64
+ config.tool_runtime = Riffer::Tools::Runtime::Threaded
65
65
  end
66
66
  ```
67
67
 
68
68
  | Value | Description |
69
69
  | ------------------------------ | ------------------------------------------------------------------------------------------------- |
70
- | `Riffer::ToolRuntime` subclass | Instantiated automatically (e.g., `Riffer::ToolRuntime::Inline`, `Riffer::ToolRuntime::Threaded`) |
71
- | `Riffer::ToolRuntime` instance | Custom runtime with specific options |
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
- Seeded messages passed to `agent.generate([...])` must carry their own `:id` when the strategy is enabled — Riffer never fabricates identifiers for pre-existing history:
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 history.** `agent.generate(messages_array)` silently drops orphaned `tool_use` exchanges (assistant `tool_call` with no matching `Tool` result) and parentless `Tool` messages from the seed before the run begins. 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.
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 arrays 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.
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 the `replace_tool_result` mutator 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).
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
- @agent.generate("Do something", context: {user_id: 123})
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
  )
@@ -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
+ ```