riffer 0.49.0 → 0.50.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1bd95697d75079325d4a057c0f2bb17bc8fee9a3864c611967e78d8e1637ef8
4
- data.tar.gz: 84ddb8c10cef0b5711b00793b3a19de2713fcf8d7985166dfaa248fa7bbe58f0
3
+ metadata.gz: 1700c8028d7de9b0e109fc259f904c8a8ff1e92964e23c90cd00574cd43a0bc2
4
+ data.tar.gz: 1245b96f6a1490aa5cedd3d5e60ff062a8d70cc7330bd7ec01d0f027e05d65fa
5
5
  SHA512:
6
- metadata.gz: 375f9c10e95e1dbd60da4acd0209bdc5707a30dff00e742815fb874cb00755b187602967433d5677444e790b9d94153c1408e9bc8f91a80e5879aa696de0a550
7
- data.tar.gz: a05871a1bcd23100fa5d0018438810476560c19f87e82ebaaa6b7322e81b03577f12923845716792deb479c86f59a49127e65b2f862f4a84b95e11d75a6cb942
6
+ metadata.gz: dc40857b3085259180f014442cd8d2ec4c6f6f85d2c577cacbff6332176eb39e948de9e20b22732730e52ea4151016fcf23c3387de181f8b8ff27537df8cc21f
7
+ data.tar.gz: '0152801a535973a01216aac4ff1a5b07554b2e8bde282a9dd3fc2c09408b0ddb7890953e7d60c21102d0889896190adf88d6822ef0ca0ff4a6564b688f7b4dd2'
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.49.0"
2
+ ".": "0.50.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.50.0](https://github.com/janeapp/riffer/compare/riffer/v0.49.0...riffer/v0.50.0) (2026-09-28)
9
+
10
+
11
+ ### Features
12
+
13
+ * **anthropic:** capture and replay thinking blocks ([#463](https://github.com/janeapp/riffer/issues/463)) ([4a25851](https://github.com/janeapp/riffer/commit/4a25851141cb9b859778e38e58d701cbfe72939f))
14
+ * **open_ai:** capture and replay reasoning items ([#464](https://github.com/janeapp/riffer/issues/464)) ([03d54a9](https://github.com/janeapp/riffer/commit/03d54a90547797a8c56321527fd73ae1d3b9becc))
15
+
16
+
17
+ ### Bug Fixes
18
+
19
+ * **providers:** keep every text block in assistant content ([#468](https://github.com/janeapp/riffer/issues/468)) ([db14e7a](https://github.com/janeapp/riffer/commit/db14e7ac35bd764c1efa7eb2c7bea95c8050a984))
20
+
8
21
  ## [0.49.0](https://github.com/janeapp/riffer/compare/riffer/v0.48.0...riffer/v0.49.0) (2026-09-27)
9
22
 
10
23
 
@@ -53,7 +53,7 @@ event.content # => "Hello, how can I help you?"
53
53
  event.to_h # => {role: :assistant, content: "Hello, how can I help you?"}
54
54
  ```
55
55
 
56
- Contains the complete final text.
56
+ Fires once per model response, after its last `TextDelta`, and contains the response's full text: every text block the provider returned, concatenated in order, so it always equals the joined `TextDelta` contents. A response that carries no text (for example, only tool calls) emits no `TextDone`.
57
57
 
58
58
  ### ToolCallDelta
59
59
 
@@ -303,8 +303,8 @@ end
303
303
 
304
304
  When an agent uses tools during streaming, the flow is:
305
305
 
306
- 1. Text events stream in (`TextDelta`, `TextDone`)
307
- 2. If tool calls are present: `ToolCallDelta` events, then `ToolCallDone`
306
+ 1. `TextDelta`, `ToolCallDelta` and `ToolCallDone` events stream in, in whatever order the model produces them. Text can come before, between or after tool calls.
307
+ 2. If the response contained any text, one `TextDone` follows with the full text. A response that only calls tools has no `TextDone`.
308
308
  3. Agent executes tools internally
309
309
  4. Agent sends results back to LLM
310
310
  5. More text events stream in
@@ -187,6 +187,16 @@ agent.stream("Solve this complex math problem").each do |event|
187
187
  end
188
188
  ```
189
189
 
190
+ The reasoning is kept on the assistant message as [reasoning parts](../MESSAGES.md#reasoning), whether you call `generate` or `stream`. Read it with `response.reasoning`, or as plain text with `reasoning_text` on the message. Each `thinking` block becomes a `:text` part carrying its `signature`, and each `redacted_thinking` block becomes an `:encrypted` part whose `data` is the block's opaque payload. `reasoning_text` leaves the redacted parts out, since they have no readable text. Every part is tagged `format: "anthropic-messages-v1"` (`Riffer::Providers::Anthropic::REASONING_FORMAT`).
191
+
192
+ ### Reasoning Replay
193
+
194
+ Riffer sends the reasoning back to Anthropic on every later turn, as `thinking` and `redacted_thinking` blocks ahead of the message's text and `tool_use` blocks, in their original order and unchanged. For tool calls, sending it back is required: when thinking is enabled, Claude needs its earlier thinking blocks back to carry on after a tool result. You don't have to do anything; it happens as long as the assistant messages stay in the history.
195
+
196
+ If you persist sessions, keep the `reasoning` key when you store messages (see [Messages — Reasoning](../MESSAGES.md#reasoning)). If you drop it, later turns lose the model's earlier reasoning, and a tool-calling turn with thinking enabled may be rejected.
197
+
198
+ Only reasoning that this provider produced is sent back to Anthropic. A conversation that switches providers midway still works: reasoning from other providers (including OpenRouter's `anthropic-claude-v1` parts) is kept on the messages but left out of Anthropic requests.
199
+
190
200
  ## Web Search
191
201
 
192
202
  Web search allows Claude to search the web for up-to-date information. When enabled, the provider injects the `web_search_20250305` server tool into the request.
@@ -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
+ 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
+
90
92
  ### structured_output
91
93
 
92
94
  Structured JSON output works identically to the OpenAI provider.
@@ -82,6 +82,28 @@ 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
+ #### Reasoning Replay
86
+
87
+ 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`:
88
+
89
+ | Reasoning item field | `ReasoningPart` |
90
+ | -------------------- | --------------------------------------- |
91
+ | each `summary` entry | a `:summary` part with its `text` |
92
+ | each `content` entry | a `:text` part with its `text` |
93
+ | `encrypted_content` | one `:encrypted` part with it as `data` |
94
+
95
+ The `:encrypted` part is always present, even when the response carried no `encrypted_content`, so the item's `id` is kept. When streaming, the parts are yielded as `ReasoningDone` events once the item completes; summary text still arrives as `ReasoningDelta` events before that.
96
+
97
+ On the next request, parts sharing an `id` go back as one `reasoning` item, ahead of the assistant's text and function calls, in their original order and unchanged. This lets reasoning models carry their reasoning across turns and tool-call loops. You don't have to do anything as long as the assistant messages stay in the history; if you persist sessions, keep the `reasoning` key when you store them.
98
+
99
+ With `store` left on (the API default), OpenAI can resolve a replayed item from its `id` alone. With `model_options store: false`, the item can only be replayed from its encrypted payload, so request it explicitly:
100
+
101
+ ```ruby
102
+ model_options reasoning: 'medium', store: false, include: ['reasoning.encrypted_content']
103
+ ```
104
+
105
+ Only parts tagged `openai-v1` are sent. Reasoning from other providers stays on the messages but is left out, including OpenRouter's `openai-responses-v1` parts, which are encrypted under OpenRouter's organization rather than yours.
106
+
85
107
  ### web_search
86
108
 
87
109
  Enable server-side web search using OpenAI's `web_search_preview` tool. Pass `true` to use defaults or a hash to merge with the tool definition:
@@ -298,11 +298,11 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
298
298
  handle_content_block_delta_tool_use(event, state: current_state, yielder: yielder) if event.delta&.tool_use
299
299
  handle_reasoning_delta(event, state: current_state, yielder: yielder) if event.delta&.reasoning_content
300
300
  when Aws::BedrockRuntime::Types::ContentBlockStopEvent
301
- handle_content_block_stop_text_delta(event, state: current_state, yielder: yielder) if current_state[:text]
302
301
  handle_content_block_stop_tool_use(event, state: current_state, yielder: yielder) if current_state[:tool_call]
303
302
  handle_reasoning_stop(event, state: current_state, yielder: yielder) if current_state[:reasoning]
304
303
  when Aws::BedrockRuntime::Types::MessageStopEvent
305
304
  stream_completed = true
305
+ yielder << Riffer::StreamEvents::TextDone.new(current_state[:text]) if current_state[:text]
306
306
  yield_finish_reason(yielder, build_finish_reason(event.stop_reason))
307
307
  when Aws::BedrockRuntime::Types::ConverseStreamMetadataEvent
308
308
  handle_metadata_usage(event, state: current_state, yielder: yielder) if event.usage
@@ -362,7 +362,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
362
362
  typed_event = event #: Aws::BedrockRuntime::Types::ContentBlockDeltaEvent
363
363
  delta_text = typed_event.delta.text
364
364
  # << avoids += copying the whole buffer per delta (O(n^2)); safe because
365
- # nothing reads the pre-append string before block stop clears it.
365
+ # nothing reads the buffer before message stop hands it to TextDone.
366
366
  state[:text] ||= +""
367
367
  state[:text] << delta_text
368
368
  yielder << Riffer::StreamEvents::TextDelta.new(delta_text)
@@ -406,13 +406,6 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
406
406
  state[:reasoning] = nil
407
407
  end
408
408
 
409
- #--
410
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
411
- def handle_content_block_stop_text_delta(_event, state:, yielder:)
412
- yielder << Riffer::StreamEvents::TextDone.new(state[:text])
413
- state[:text] = nil
414
- end
415
-
416
409
  #--
417
410
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
418
411
  def handle_content_block_stop_tool_use(_event, state:, yielder:)
@@ -16,6 +16,11 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
16
16
  "pause_turn" => :other,
17
17
  }.freeze #: Hash[String, Symbol]
18
18
 
19
+ # Only parts carrying this tag are replayed, so reasoning captured by another
20
+ # adapter, whose signatures the Messages API may not accept, never reaches the
21
+ # request.
22
+ REASONING_FORMAT = "anthropic-messages-v1" #: String
23
+
19
24
  #--
20
25
  #: (?String?) -> singleton(Riffer::Skills::Adapter)
21
26
  def self.skills_adapter(_model = nil)
@@ -153,16 +158,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
153
158
  #: (untyped) -> String
154
159
  def extract_content(response)
155
160
  message = response #: Anthropic::Models::Message
156
- content_blocks = message.content
157
- return "" if content_blocks.nil? || content_blocks.empty?
158
-
159
- text_content = ""
160
-
161
- content_blocks.each do |block|
162
- text_content = block.text if block.is_a?(::Anthropic::Models::TextBlock)
163
- end
164
-
165
- text_content
161
+ (message.content || []).filter_map { |block| block.text if block.is_a?(::Anthropic::Models::TextBlock) }.join
166
162
  end
167
163
 
168
164
  #--
@@ -187,12 +183,34 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
187
183
  tool_calls
188
184
  end
189
185
 
186
+ #--
187
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
188
+ def extract_reasoning(response)
189
+ message = response #: Anthropic::Models::Message
190
+ (message.content || []).filter_map { |block| build_reasoning_part(block) }
191
+ end
192
+
193
+ #--
194
+ #: (untyped) -> Riffer::Messages::Assistant::ReasoningPart?
195
+ def build_reasoning_part(block)
196
+ case block
197
+ when ::Anthropic::Models::ThinkingBlock
198
+ Riffer::Messages::Assistant::ReasoningPart.new(
199
+ type: :text,
200
+ text: block.thinking,
201
+ signature: block.signature,
202
+ format: REASONING_FORMAT,
203
+ )
204
+ when ::Anthropic::Models::RedactedThinkingBlock
205
+ Riffer::Messages::Assistant::ReasoningPart.new(type: :encrypted, data: block.data, format: REASONING_FORMAT)
206
+ end
207
+ end
208
+
190
209
  #--
191
210
  #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
192
211
  def execute_stream(params, yielder)
193
212
  current_state = {
194
213
  text: nil,
195
- reasoning: nil,
196
214
  tool_call: nil,
197
215
  web_search_index: nil,
198
216
  web_search_json: nil,
@@ -218,19 +236,15 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
218
236
  when ::Anthropic::Helpers::Streaming::TextEvent
219
237
  handle_text_event(event, state: current_state, yielder: yielder)
220
238
  when ::Anthropic::Helpers::Streaming::ThinkingEvent
221
- handle_thinking_event(event, state: current_state, yielder: yielder)
239
+ yielder << Riffer::StreamEvents::ReasoningDelta.new(event.thinking)
222
240
  when ::Anthropic::Helpers::Streaming::InputJsonEvent
223
241
  handle_input_json_event(event, state: current_state, yielder: yielder)
224
242
  when ::Anthropic::Helpers::Streaming::ContentBlockStopEvent
225
243
  case event.content_block
226
- when ::Anthropic::Models::TextBlock
227
- handle_content_block_stop_text(event, state: current_state, yielder: yielder) if current_state[:text]
228
244
  when ::Anthropic::Models::ToolUseBlock
229
245
  handle_content_block_stop_tool_use(event, state: current_state, yielder: yielder)
230
- when ::Anthropic::Models::ThinkingBlock
231
- if current_state[:reasoning]
232
- handle_content_block_stop_thinking(event, state: current_state, yielder: yielder)
233
- end
246
+ when ::Anthropic::Models::ThinkingBlock, ::Anthropic::Models::RedactedThinkingBlock
247
+ handle_content_block_stop_reasoning(event, yielder: yielder)
234
248
  when ::Anthropic::Models::ServerToolUseBlock
235
249
  handle_content_block_stop_server_tool_use(event, state: current_state, yielder: yielder)
236
250
  when ::Anthropic::Models::WebSearchToolResultBlock
@@ -238,7 +252,9 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
238
252
  end
239
253
  when ::Anthropic::Helpers::Streaming::MessageStopEvent
240
254
  stream_completed = true
241
- handle_message_stop(event, accumulated_message: stream.accumulated_message, yielder: yielder)
255
+ handle_message_stop(
256
+ event, state: current_state, accumulated_message: stream.accumulated_message, yielder: yielder,
257
+ )
242
258
  end
243
259
  end
244
260
  ensure
@@ -275,21 +291,13 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
275
291
  #--
276
292
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
277
293
  def handle_text_event(event, state:, yielder:)
278
- # Mutating append avoids O(n^2) copying per content block; the buffer is
279
- # handed to TextDone and cleared on block stop, so no reader sees it mid-append.
294
+ # Mutating append avoids O(n^2) copying across the response; the buffer
295
+ # reaches TextDone only at message stop, so no reader sees it mid-append.
280
296
  state[:text] ||= +""
281
297
  state[:text] << event.text
282
298
  yielder << Riffer::StreamEvents::TextDelta.new(event.text)
283
299
  end
284
300
 
285
- #--
286
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
287
- def handle_thinking_event(event, state:, yielder:)
288
- state[:reasoning] ||= +""
289
- state[:reasoning] << event.thinking
290
- yielder << Riffer::StreamEvents::ReasoningDelta.new(event.thinking)
291
- end
292
-
293
301
  #--
294
302
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
295
303
  def handle_input_json_event(event, state:, yielder:)
@@ -320,18 +328,13 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
320
328
  state[:tool_call] = nil
321
329
  end
322
330
 
331
+ # The SDK's accumulated block already carries the full thinking text and the
332
+ # signature from its trailing signature_delta.
323
333
  #--
324
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
325
- def handle_content_block_stop_thinking(_event, state:, yielder:)
326
- yield_reasoning_done(yielder, state[:reasoning])
327
- state[:reasoning] = nil
328
- end
329
-
330
- #--
331
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
332
- def handle_content_block_stop_text(_event, state:, yielder:)
333
- yielder << Riffer::StreamEvents::TextDone.new(state[:text])
334
- state[:text] = nil
334
+ #: (untyped, yielder: Riffer::Providers::_EventSink) -> void
335
+ def handle_content_block_stop_reasoning(event, yielder:)
336
+ part = build_reasoning_part(event.content_block) #: Riffer::Messages::Assistant::ReasoningPart
337
+ yielder << Riffer::StreamEvents::ReasoningDone.new(part)
335
338
  end
336
339
 
337
340
  #--
@@ -361,8 +364,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
361
364
  end
362
365
 
363
366
  #--
364
- #: (untyped, accumulated_message: untyped, yielder: Riffer::Providers::_EventSink) -> void
365
- def handle_message_stop(_event, accumulated_message:, yielder:)
367
+ #: (untyped, state: Hash[Symbol, untyped], accumulated_message: untyped, yielder: Riffer::Providers::_EventSink) -> void
368
+ def handle_message_stop(_event, state:, accumulated_message:, yielder:)
369
+ yielder << Riffer::StreamEvents::TextDone.new(state[:text]) if state[:text]
370
+
366
371
  message = accumulated_message #: Anthropic::Models::Message?
367
372
  yield_finish_reason(yielder, build_finish_reason(message&.stop_reason))
368
373
 
@@ -413,7 +418,9 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
413
418
  #--
414
419
  #: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
415
420
  def convert_assistant_to_anthropic_format(message)
416
- content = [] #: Array[Hash[Symbol, untyped]]
421
+ content = message.reasoning.filter_map do |part|
422
+ convert_reasoning_part_to_anthropic_format(part) if part.format == REASONING_FORMAT
423
+ end
417
424
  content << { type: "text", text: message.content } if message.content && !message.content.empty?
418
425
 
419
426
  message.tool_calls.each do |tc|
@@ -428,6 +435,14 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
428
435
  { role: "assistant", content: content }
429
436
  end
430
437
 
438
+ #--
439
+ #: (Riffer::Messages::Assistant::ReasoningPart) -> Hash[Symbol, untyped]
440
+ def convert_reasoning_part_to_anthropic_format(part)
441
+ return { type: "redacted_thinking", data: part.data } if part.type == :encrypted
442
+
443
+ { type: "thinking", thinking: part.text, signature: part.signature }
444
+ end
445
+
431
446
  #--
432
447
  #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
433
448
  def convert_file_part_to_anthropic_format(file)
@@ -26,4 +26,11 @@ class Riffer::Providers::AzureOpenAI < Riffer::Providers::OpenAI
26
26
  # OpenAI credential elsewhere. A nil raises in the SDK instead.
27
27
  ::OpenAI::Client.new(api_key: api_key, base_url: base_url)
28
28
  end
29
+
30
+ # Azure resolves and decrypts reasoning items only within the resource that produced them.
31
+ #--
32
+ #: () -> String
33
+ def reasoning_format
34
+ "azure-openai-v1"
35
+ end
29
36
  end
@@ -173,16 +173,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
173
173
  #: (untyped) -> String
174
174
  def extract_content(response)
175
175
  typed_response = response #: OpenAI::Models::Responses::Response
176
- text_content = ""
177
176
 
178
- typed_response.output.each do |item|
179
- next unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
180
-
181
- text_block = item.content.find { |c| c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
182
- text_content = text_block.text if text_block.is_a?(::OpenAI::Models::Responses::ResponseOutputText)
183
- end
177
+ typed_response.output.flat_map do |item|
178
+ next [] unless item.is_a?(::OpenAI::Models::Responses::ResponseOutputMessage)
184
179
 
185
- text_content
180
+ item.content.filter_map { |c| c.text if c.is_a?(::OpenAI::Models::Responses::ResponseOutputText) }
181
+ end.join
186
182
  end
187
183
 
188
184
  #--
@@ -204,10 +200,47 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
204
200
  tool_calls
205
201
  end
206
202
 
203
+ #--
204
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
205
+ def extract_reasoning(response)
206
+ typed_response = response #: OpenAI::Models::Responses::Response
207
+ typed_response.output.
208
+ grep(::OpenAI::Models::Responses::ResponseReasoningItem).
209
+ flat_map { |item| build_reasoning_parts(item) }
210
+ end
211
+
212
+ # The trailing +:encrypted+ part is always present, even without a payload, so the item's id
213
+ # survives for replay: with +store+ on, OpenAI resolves the item from the id alone.
214
+ #--
215
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
216
+ def build_reasoning_parts(item)
217
+ typed_item = item #: OpenAI::Models::Responses::ResponseReasoningItem
218
+ id = typed_item.id
219
+ summaries = typed_item.summary.map { |summary| build_reasoning_part(type: :summary, id: id, text: summary.text) }
220
+ texts = (typed_item.content || []).map { |content| build_reasoning_part(type: :text, id: id, text: content.text) }
221
+
222
+ [*summaries, *texts, build_reasoning_part(type: :encrypted, id: id, data: typed_item.encrypted_content)]
223
+ end
224
+
225
+ #--
226
+ #: (type: Symbol, id: String, ?text: String?, ?data: String?) -> Riffer::Messages::Assistant::ReasoningPart
227
+ def build_reasoning_part(type:, id:, text: nil, data: nil)
228
+ Riffer::Messages::Assistant::ReasoningPart.new(type: type, text: text, data: data, id: id, format: reasoning_format)
229
+ end
230
+
231
+ # Distinct from OpenRouter's +openai-responses-v1+: reasoning relayed through OpenRouter is
232
+ # encrypted and stored under OpenRouter's organization, so neither side can replay the other's.
233
+ #--
234
+ #: () -> String
235
+ def reasoning_format
236
+ "openai-v1"
237
+ end
238
+
207
239
  #--
208
240
  #: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
209
241
  def execute_stream(params, yielder)
210
242
  current_state = {
243
+ text: nil,
211
244
  tool_info: {},
212
245
  } #: Hash[Symbol, untyped]
213
246
 
@@ -223,12 +256,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
223
256
  end
224
257
  when :"response.output_text.delta"
225
258
  handle_output_text_delta(event, state: current_state, yielder: yielder)
226
- when :"response.output_text.done"
227
- handle_output_text_done(event, state: current_state, yielder: yielder)
228
259
  when :"response.reasoning_summary_text.delta"
229
260
  handle_reasoning_summary_text_delta(event, state: current_state, yielder: yielder)
230
- when :"response.reasoning_summary_text.done"
231
- handle_reasoning_summary_text_done(event, state: current_state, yielder: yielder)
232
261
  when :"response.function_call_arguments.delta"
233
262
  handle_function_call_arguments_delta(event, state: current_state, yielder: yielder)
234
263
  when :"response.function_call_arguments.done"
@@ -240,7 +269,10 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
240
269
  when :"response.web_search_call.completed"
241
270
  handle_web_search_status(event, status: "completed", yielder: yielder)
242
271
  when :"response.output_item.done"
243
- handle_output_item_done_web_search(event, yielder: yielder) if event.item&.type == :web_search_call
272
+ case event.item&.type
273
+ when :web_search_call then handle_output_item_done_web_search(event, yielder: yielder)
274
+ when :reasoning then handle_output_item_done_reasoning(event, yielder: yielder)
275
+ end
244
276
  when :"response.completed", :"response.incomplete", :"response.failed"
245
277
  stream_completed = true
246
278
  handle_response_finished(event, state: current_state, yielder: yielder)
@@ -270,27 +302,19 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
270
302
  #--
271
303
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
272
304
  def handle_output_text_delta(event, state:, yielder:)
305
+ # << avoids += copying the whole buffer per delta (O(n^2)); safe because
306
+ # nothing reads the buffer before the response finishes and TextDone takes it.
307
+ state[:text] ||= +""
308
+ state[:text] << event.delta
273
309
  yielder << Riffer::StreamEvents::TextDelta.new(event.delta)
274
310
  end
275
311
 
276
- #--
277
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
278
- def handle_output_text_done(event, state:, yielder:)
279
- yielder << Riffer::StreamEvents::TextDone.new(event.text)
280
- end
281
-
282
312
  #--
283
313
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
284
314
  def handle_reasoning_summary_text_delta(event, state:, yielder:)
285
315
  yielder << Riffer::StreamEvents::ReasoningDelta.new(event.delta)
286
316
  end
287
317
 
288
- #--
289
- #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
290
- def handle_reasoning_summary_text_done(event, state:, yielder:)
291
- yield_reasoning_done(yielder, event.text)
292
- end
293
-
294
318
  #--
295
319
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
296
320
  def handle_function_call_arguments_delta(event, state:, yielder:)
@@ -317,6 +341,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
317
341
  #--
318
342
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
319
343
  def handle_response_finished(event, state:, yielder:)
344
+ yielder << Riffer::StreamEvents::TextDone.new(state[:text]) if state[:text]
345
+
320
346
  response = event.response
321
347
  return unless response
322
348
 
@@ -348,6 +374,12 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
348
374
  end
349
375
  end
350
376
 
377
+ #--
378
+ #: (untyped, yielder: Riffer::Providers::_EventSink) -> void
379
+ def handle_output_item_done_reasoning(event, yielder:)
380
+ build_reasoning_parts(event.item).each { |part| yielder << Riffer::StreamEvents::ReasoningDone.new(part) }
381
+ end
382
+
351
383
  #--
352
384
  #: (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
353
385
  def convert_messages_to_openai_format(messages)
@@ -378,12 +410,13 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
378
410
  end
379
411
 
380
412
  #--
381
- #: (Riffer::Messages::Assistant) -> (Hash[Symbol, untyped] | Array[Hash[Symbol, untyped]])
413
+ #: (Riffer::Messages::Assistant) -> Array[Hash[Symbol, untyped]]
382
414
  def convert_assistant_to_openai_format(message)
415
+ items = convert_reasoning_to_openai_format(message.reasoning)
416
+
383
417
  if message.tool_calls.empty?
384
- { role: "assistant", content: message.content }
418
+ items << { role: "assistant", content: message.content }
385
419
  else
386
- items = [] #: Array[Hash[Symbol, untyped]]
387
420
  if message.content && !message.content.empty?
388
421
  items << { type: "message", role: "assistant",
389
422
  content: message.content, }
@@ -396,8 +429,33 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
396
429
  arguments: tc.arguments,
397
430
  }
398
431
  end
399
- items
400
432
  end
433
+
434
+ items
435
+ end
436
+
437
+ #--
438
+ #: (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Array[Hash[Symbol, untyped]]
439
+ def convert_reasoning_to_openai_format(reasoning)
440
+ reasoning.
441
+ select { |part| part.format == reasoning_format }.
442
+ chunk_while { |a, b| a.id == b.id }.
443
+ map { |parts| convert_reasoning_item_to_openai_format(parts) }
444
+ end
445
+
446
+ #--
447
+ #: (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Hash[Symbol, untyped]
448
+ def convert_reasoning_item_to_openai_format(parts)
449
+ summary = parts.filter_map { |part| { type: "summary_text", text: part.text } if part.type == :summary }
450
+ content = parts.filter_map { |part| { type: "reasoning_text", text: part.text } if part.type == :text }
451
+
452
+ {
453
+ type: "reasoning",
454
+ id: parts.first&.id,
455
+ summary: summary,
456
+ content: content.empty? ? nil : content,
457
+ encrypted_content: parts.find { |part| part.type == :encrypted }&.data,
458
+ }.compact
401
459
  end
402
460
 
403
461
  #--
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.49.0" #: String
5
+ VERSION = "0.50.0" #: String
6
6
  end
@@ -120,10 +120,6 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
120
120
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
121
121
  def handle_reasoning_stop: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
122
122
 
123
- # --
124
- # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
125
- def handle_content_block_stop_text_delta: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
126
-
127
123
  # --
128
124
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
129
125
  def handle_content_block_stop_tool_use: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
@@ -5,6 +5,11 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
5
5
 
6
6
  FINISH_REASONS: Hash[String, Symbol]
7
7
 
8
+ # Only parts carrying this tag are replayed, so reasoning captured by another
9
+ # adapter, whose signatures the Messages API may not accept, never reaches the
10
+ # request.
11
+ REASONING_FORMAT: String
12
+
8
13
  # --
9
14
  # : (?String?) -> singleton(Riffer::Skills::Adapter)
10
15
  def self.skills_adapter: (?String?) -> singleton(Riffer::Skills::Adapter)
@@ -59,6 +64,14 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
59
64
  # : (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
60
65
  def extract_tool_calls: (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
61
66
 
67
+ # --
68
+ # : (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
69
+ def extract_reasoning: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
70
+
71
+ # --
72
+ # : (untyped) -> Riffer::Messages::Assistant::ReasoningPart?
73
+ def build_reasoning_part: (untyped) -> Riffer::Messages::Assistant::ReasoningPart?
74
+
62
75
  # --
63
76
  # : (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
64
77
  def execute_stream: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
@@ -75,10 +88,6 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
75
88
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
76
89
  def handle_text_event: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
77
90
 
78
- # --
79
- # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
80
- def handle_thinking_event: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
81
-
82
91
  # --
83
92
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
84
93
  def handle_input_json_event: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
@@ -87,13 +96,11 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
87
96
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
88
97
  def handle_content_block_stop_tool_use: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
89
98
 
99
+ # The SDK's accumulated block already carries the full thinking text and the
100
+ # signature from its trailing signature_delta.
90
101
  # --
91
- # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
92
- def handle_content_block_stop_thinking: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
93
-
94
- # --
95
- # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
96
- def handle_content_block_stop_text: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
102
+ # : (untyped, yielder: Riffer::Providers::_EventSink) -> void
103
+ def handle_content_block_stop_reasoning: (untyped, yielder: Riffer::Providers::_EventSink) -> void
97
104
 
98
105
  # --
99
106
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
@@ -104,8 +111,8 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
104
111
  def handle_content_block_stop_web_search_result: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
105
112
 
106
113
  # --
107
- # : (untyped, accumulated_message: untyped, yielder: Riffer::Providers::_EventSink) -> void
108
- def handle_message_stop: (untyped, accumulated_message: untyped, yielder: Riffer::Providers::_EventSink) -> void
114
+ # : (untyped, state: Hash[Symbol, untyped], accumulated_message: untyped, yielder: Riffer::Providers::_EventSink) -> void
115
+ def handle_message_stop: (untyped, state: Hash[Symbol, untyped], accumulated_message: untyped, yielder: Riffer::Providers::_EventSink) -> void
109
116
 
110
117
  # --
111
118
  # : (Array[Riffer::Messages::Base]) -> Hash[Symbol, untyped]
@@ -115,6 +122,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
115
122
  # : (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
116
123
  def convert_assistant_to_anthropic_format: (Riffer::Messages::Assistant) -> Hash[Symbol, untyped]
117
124
 
125
+ # --
126
+ # : (Riffer::Messages::Assistant::ReasoningPart) -> Hash[Symbol, untyped]
127
+ def convert_reasoning_part_to_anthropic_format: (Riffer::Messages::Assistant::ReasoningPart) -> Hash[Symbol, untyped]
128
+
118
129
  # --
119
130
  # : (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
120
131
  def convert_file_part_to_anthropic_format: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
@@ -14,4 +14,9 @@ class Riffer::Providers::AzureOpenAI < Riffer::Providers::OpenAI
14
14
  # --
15
15
  # : () -> untyped
16
16
  def build_client: () -> untyped
17
+
18
+ # Azure resolves and decrypts reasoning items only within the resource that produced them.
19
+ # --
20
+ # : () -> String
21
+ def reasoning_format: () -> String
17
22
  end
@@ -65,6 +65,26 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
65
65
  # : (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
66
66
  def extract_tool_calls: (untyped) -> Array[Riffer::Messages::Assistant::ToolCall]
67
67
 
68
+ # --
69
+ # : (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
70
+ def extract_reasoning: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
71
+
72
+ # The trailing +:encrypted+ part is always present, even without a payload, so the item's id
73
+ # survives for replay: with +store+ on, OpenAI resolves the item from the id alone.
74
+ # --
75
+ # : (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
76
+ def build_reasoning_parts: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
77
+
78
+ # --
79
+ # : (type: Symbol, id: String, ?text: String?, ?data: String?) -> Riffer::Messages::Assistant::ReasoningPart
80
+ def build_reasoning_part: (type: Symbol, id: String, ?text: String?, ?data: String?) -> Riffer::Messages::Assistant::ReasoningPart
81
+
82
+ # Distinct from OpenRouter's +openai-responses-v1+: reasoning relayed through OpenRouter is
83
+ # encrypted and stored under OpenRouter's organization, so neither side can replay the other's.
84
+ # --
85
+ # : () -> String
86
+ def reasoning_format: () -> String
87
+
68
88
  # --
69
89
  # : (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
70
90
  def execute_stream: (Hash[Symbol, untyped], Riffer::Providers::_EventSink) -> void
@@ -77,18 +97,10 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
77
97
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
78
98
  def handle_output_text_delta: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
79
99
 
80
- # --
81
- # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
82
- def handle_output_text_done: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
83
-
84
100
  # --
85
101
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
86
102
  def handle_reasoning_summary_text_delta: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
87
103
 
88
- # --
89
- # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
90
- def handle_reasoning_summary_text_done: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
91
-
92
104
  # --
93
105
  # : (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
94
106
  def handle_function_call_arguments_delta: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
@@ -109,13 +121,25 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
109
121
  # : (untyped, yielder: Riffer::Providers::_EventSink) -> void
110
122
  def handle_output_item_done_web_search: (untyped, yielder: Riffer::Providers::_EventSink) -> void
111
123
 
124
+ # --
125
+ # : (untyped, yielder: Riffer::Providers::_EventSink) -> void
126
+ def handle_output_item_done_reasoning: (untyped, yielder: Riffer::Providers::_EventSink) -> void
127
+
112
128
  # --
113
129
  # : (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
114
130
  def convert_messages_to_openai_format: (Array[Riffer::Messages::Base]) -> Array[Hash[Symbol, untyped]]
115
131
 
116
132
  # --
117
- # : (Riffer::Messages::Assistant) -> (Hash[Symbol, untyped] | Array[Hash[Symbol, untyped]])
118
- def convert_assistant_to_openai_format: (Riffer::Messages::Assistant) -> (Hash[Symbol, untyped] | Array[Hash[Symbol, untyped]])
133
+ # : (Riffer::Messages::Assistant) -> Array[Hash[Symbol, untyped]]
134
+ def convert_assistant_to_openai_format: (Riffer::Messages::Assistant) -> Array[Hash[Symbol, untyped]]
135
+
136
+ # --
137
+ # : (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Array[Hash[Symbol, untyped]]
138
+ def convert_reasoning_to_openai_format: (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Array[Hash[Symbol, untyped]]
139
+
140
+ # --
141
+ # : (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Hash[Symbol, untyped]
142
+ def convert_reasoning_item_to_openai_format: (Array[Riffer::Messages::Assistant::ReasoningPart]) -> Hash[Symbol, untyped]
119
143
 
120
144
  # --
121
145
  # : (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riffer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.49.0
4
+ version: 0.50.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall