riffer 0.44.0 → 0.46.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +24 -0
  4. data/docs/AGENTS.md +13 -0
  5. data/docs/AGENT_LIFECYCLE.md +39 -14
  6. data/docs/AGENT_LOOP.md +8 -9
  7. data/docs/CONFIGURATION.md +24 -17
  8. data/docs/GUARDRAILS.md +4 -4
  9. data/docs/MESSAGES.md +14 -11
  10. data/docs/STREAM_EVENTS.md +1 -1
  11. data/docs/TOOL_ADVANCED.md +2 -0
  12. data/docs/TRACING.md +4 -2
  13. data/docs/providers/CUSTOM_PROVIDERS.md +1 -1
  14. data/docs/providers/MOCK_PROVIDER.md +18 -11
  15. data/lib/riffer/agent/outcome.rb +52 -0
  16. data/lib/riffer/agent/response.rb +12 -33
  17. data/lib/riffer/agent/run.rb +54 -16
  18. data/lib/riffer/agent/session.rb +2 -0
  19. data/lib/riffer/messages/assistant.rb +24 -2
  20. data/lib/riffer/messages/base.rb +11 -19
  21. data/lib/riffer/providers/amazon_bedrock.rb +3 -0
  22. data/lib/riffer/providers/anthropic.rb +9 -0
  23. data/lib/riffer/providers/base.rb +1 -0
  24. data/lib/riffer/providers/finish_reason.rb +1 -1
  25. data/lib/riffer/providers/gemini.rb +10 -1
  26. data/lib/riffer/providers/open_ai.rb +30 -23
  27. data/lib/riffer/providers/open_router.rb +21 -7
  28. data/lib/riffer/runner/fibers.rb +12 -8
  29. data/lib/riffer/version.rb +1 -1
  30. data/sig/_private/async.rbs +4 -0
  31. data/sig/generated/riffer/agent/outcome.rbs +41 -0
  32. data/sig/generated/riffer/agent/response.rbs +11 -26
  33. data/sig/generated/riffer/agent/run.rbs +18 -7
  34. data/sig/generated/riffer/messages/assistant.rbs +14 -2
  35. data/sig/generated/riffer/providers/open_ai.rbs +8 -3
  36. data/sig/generated/riffer/providers/open_router.rbs +10 -2
  37. data/sig/generated/riffer/runner/fibers.rbs +2 -0
  38. metadata +3 -1
@@ -1,30 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Wraps an agent generation response. When a guardrail blocks execution,
5
- # +content+ is empty and +tripwire+ carries the block details.
4
+ # Wraps an agent generation response. +outcome+ says how the run ended; when a
5
+ # guardrail blocks execution, +content+ is empty and +tripwire+ carries the
6
+ # block details.
6
7
  #
7
8
  # response = agent.generate("Hello")
8
- # if response.blocked?
9
- # puts "Blocked: #{response.tripwire.reason}"
10
- # else
9
+ # if response.outcome.success?
11
10
  # puts response.content
11
+ # else
12
+ # puts "#{response.outcome.reason}: #{response.outcome.detail}"
12
13
  # end
13
14
  class Riffer::Agent::Response
14
- # @rbs @interrupted: bool
15
-
16
15
  # The response content.
17
16
  attr_reader :content #: String
18
17
 
18
+ # How the run ended.
19
+ attr_reader :outcome #: Riffer::Agent::Outcome
20
+
19
21
  # The tripwire if execution was blocked.
20
22
  attr_reader :tripwire #: Riffer::Guardrails::Tripwire?
21
23
 
22
24
  # The modifications made by guardrails during processing.
23
25
  attr_reader :modifications #: Array[Riffer::Guardrails::Modification]
24
26
 
25
- # The reason provided with the interrupt, if any.
26
- attr_reader :interrupt_reason #: (String | Symbol)?
27
-
28
27
  # The parsed structured output, if structured output was configured.
29
28
  attr_reader :structured_output #: Hash[Symbol, untyped]?
30
29
 
@@ -45,10 +44,9 @@ class Riffer::Agent::Response
45
44
  #--
46
45
  #: (
47
46
  # String,
47
+ # outcome: Riffer::Agent::Outcome,
48
48
  # ?tripwire: Riffer::Guardrails::Tripwire?,
49
49
  # ?modifications: Array[Riffer::Guardrails::Modification],
50
- # ?interrupted: bool,
51
- # ?interrupt_reason: (String | Symbol)?,
52
50
  # ?structured_output: Hash[Symbol, untyped]?,
53
51
  # ?messages: Array[Riffer::Messages::Base],
54
52
  # ?healed_tool_call_ids: Array[String],
@@ -57,10 +55,9 @@ class Riffer::Agent::Response
57
55
  # ) -> void
58
56
  def initialize(
59
57
  content,
58
+ outcome:,
60
59
  tripwire: nil,
61
60
  modifications: [],
62
- interrupted: false,
63
- interrupt_reason: nil,
64
61
  structured_output: nil,
65
62
  messages: [],
66
63
  healed_tool_call_ids: [],
@@ -68,10 +65,9 @@ class Riffer::Agent::Response
68
65
  steps: 0
69
66
  )
70
67
  @content = content
68
+ @outcome = outcome
71
69
  @tripwire = tripwire
72
70
  @modifications = modifications
73
- @interrupted = interrupted
74
- @interrupt_reason = interrupt_reason
75
71
  @structured_output = structured_output
76
72
  @messages = messages
77
73
  @healed_tool_call_ids = healed_tool_call_ids
@@ -79,14 +75,6 @@ class Riffer::Agent::Response
79
75
  @steps = steps
80
76
  end
81
77
 
82
- # Returns true if the response was blocked by a guardrail.
83
- #
84
- #--
85
- #: () -> bool
86
- def blocked?
87
- !tripwire.nil?
88
- end
89
-
90
78
  # Returns true if any guardrail modified data during processing.
91
79
  #
92
80
  #--
@@ -94,13 +82,4 @@ class Riffer::Agent::Response
94
82
  def modified?
95
83
  modifications.any?
96
84
  end
97
-
98
- # Returns true if the agent loop was interrupted by a callback
99
- # via <tt>throw :riffer_interrupt</tt>.
100
- #
101
- #--
102
- #: () -> bool
103
- def interrupted?
104
- @interrupted
105
- end
106
85
  end
@@ -155,6 +155,7 @@ module Riffer::Agent::Run
155
155
  accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
156
156
  accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
157
157
  accumulated_finish_reason = nil #: Symbol?
158
+ accumulated_finish_reason_raw = nil #: String?
158
159
 
159
160
  call_llm_stream(agent, tags).each do |event|
160
161
  stream_yielder << event
@@ -178,6 +179,7 @@ module Riffer::Agent::Run
178
179
  accumulated_token_usage = event.token_usage
179
180
  when Riffer::StreamEvents::FinishReasonDone
180
181
  accumulated_finish_reason = event.finish_reason
182
+ accumulated_finish_reason_raw = event.raw_finish_reason
181
183
  end
182
184
  end
183
185
 
@@ -186,6 +188,7 @@ module Riffer::Agent::Run
186
188
  tool_calls: accumulated_tool_calls,
187
189
  token_usage: accumulated_token_usage,
188
190
  finish_reason: accumulated_finish_reason,
191
+ finish_reason_raw: accumulated_finish_reason_raw,
189
192
  )
190
193
  end
191
194
 
@@ -207,6 +210,7 @@ module Riffer::Agent::Run
207
210
  build_response(
208
211
  agent,
209
212
  "",
213
+ outcome: Riffer::Agent::Outcome.new(reason: :guardrail_blocked, detail: tripwire.reason),
210
214
  tripwire: tripwire,
211
215
  modifications: all_modifications,
212
216
  token_usage: token_usage,
@@ -215,18 +219,41 @@ module Riffer::Agent::Run
215
219
  end
216
220
 
217
221
  #--
218
- #: (Riffer::Agent, Array[Riffer::Guardrails::Modification], **untyped) -> Riffer::Agent::Response
219
- def final_response(agent, all_modifications, **extra)
220
- response = agent.session.final_assistant_message
222
+ #: (Riffer::Agent, Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, **untyped) -> Riffer::Agent::Response
223
+ def final_response(agent, all_modifications, interrupted: false, interrupt_reason: nil, **extra)
224
+ message = agent.session.final_assistant_message
225
+ result = agent.structured_output && structured_output_result(agent, message)
221
226
  build_response(
222
227
  agent,
223
- response&.content || "",
228
+ message&.content || "",
229
+ outcome: final_outcome(message, result, interrupted: interrupted, interrupt_reason: interrupt_reason),
224
230
  modifications: all_modifications,
225
- structured_output: validate_structured_output(agent, response),
231
+ structured_output: result&.object,
226
232
  **extra,
227
233
  )
228
234
  end
229
235
 
236
+ # Checked in the order things happened. The loop being stopped (max_steps or
237
+ # an interrupt) beats the provider's finish reason, which beats riffer's own
238
+ # validation of the content. A truncated response that also fails the schema
239
+ # therefore reports :length, not :invalid_structured_output.
240
+ #--
241
+ #: (Riffer::Messages::Assistant?, Riffer::Agent::StructuredOutput::Result?, interrupted: bool, interrupt_reason: (String | Symbol)?) -> Riffer::Agent::Outcome
242
+ def final_outcome(message, result, interrupted:, interrupt_reason:)
243
+ finish_reason = message&.finish_reason
244
+ if interrupted && interrupt_reason == Riffer::Agent::INTERRUPT_MAX_STEPS
245
+ Riffer::Agent::Outcome.new(reason: :max_steps)
246
+ elsif interrupted
247
+ Riffer::Agent::Outcome.new(reason: :interrupted, detail: interrupt_reason&.to_s)
248
+ elsif finish_reason && !Riffer::Agent::Outcome::NORMAL_FINISH_REASONS.include?(finish_reason)
249
+ Riffer::Agent::Outcome.new(reason: finish_reason, detail: message&.finish_reason_raw)
250
+ elsif result&.failure?
251
+ Riffer::Agent::Outcome.new(reason: :invalid_structured_output, detail: result.error)
252
+ else
253
+ Riffer::Agent::Outcome.new(reason: :completed)
254
+ end
255
+ end
256
+
230
257
  #--
231
258
  #: (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
232
259
  def call_llm(agent, tags = {})
@@ -327,11 +354,11 @@ module Riffer::Agent::Run
327
354
  end
328
355
 
329
356
  #--
330
- #: (Riffer::Agent, Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
331
- def validate_structured_output(agent, response)
332
- return unless response&.structured_output? && agent.structured_output
357
+ #: (Riffer::Agent, Riffer::Messages::Assistant?) -> Riffer::Agent::StructuredOutput::Result?
358
+ def structured_output_result(agent, message)
359
+ return unless message
333
360
 
334
- agent.structured_output.parse_and_validate(response.content).object
361
+ agent.structured_output&.parse_and_validate(message.content)
335
362
  end
336
363
 
337
364
  #--
@@ -358,10 +385,9 @@ module Riffer::Agent::Run
358
385
  #: (
359
386
  # Riffer::Agent,
360
387
  # String,
388
+ # outcome: Riffer::Agent::Outcome,
361
389
  # ?tripwire: Riffer::Guardrails::Tripwire?,
362
390
  # ?modifications: Array[Riffer::Guardrails::Modification],
363
- # ?interrupted: bool,
364
- # ?interrupt_reason: (String | Symbol)?,
365
391
  # ?structured_output: Hash[Symbol, untyped]?,
366
392
  # ?healed_tool_call_ids: Array[String],
367
393
  # ?token_usage: Riffer::Providers::TokenUsage?,
@@ -370,10 +396,9 @@ module Riffer::Agent::Run
370
396
  def build_response(
371
397
  agent,
372
398
  content,
399
+ outcome:,
373
400
  tripwire: nil,
374
401
  modifications: [],
375
- interrupted: false,
376
- interrupt_reason: nil,
377
402
  structured_output: nil,
378
403
  healed_tool_call_ids: [],
379
404
  token_usage: nil,
@@ -382,10 +407,9 @@ module Riffer::Agent::Run
382
407
  messages = agent.session.messages
383
408
  Riffer::Agent::Response.new(
384
409
  content,
410
+ outcome: outcome,
385
411
  tripwire: tripwire,
386
412
  modifications: modifications,
387
- interrupted: interrupted,
388
- interrupt_reason: interrupt_reason,
389
413
  structured_output: structured_output,
390
414
  messages: messages.frozen? ? messages : messages.dup.freeze,
391
415
  healed_tool_call_ids: healed_tool_call_ids,
@@ -460,7 +484,12 @@ module Riffer::Agent::Run
460
484
  span.set_attribute("riffer.steps", response.steps)
461
485
  Riffer::Tracing.record_usage(span, response.token_usage)
462
486
 
463
- span.set_attribute("riffer.interrupt.reason", response.interrupt_reason.to_s) if response.interrupt_reason
487
+ outcome = response.outcome
488
+ span.set_attribute("riffer.outcome.reason", outcome.reason.to_s)
489
+ detail = outcome.detail
490
+ span.set_attribute("riffer.outcome.detail", detail) if detail
491
+ interrupt_reason = interrupt_reason_attribute(outcome)
492
+ span.set_attribute("riffer.interrupt.reason", interrupt_reason) if interrupt_reason
464
493
 
465
494
  tripwire = response.tripwire
466
495
  return unless tripwire
@@ -470,4 +499,13 @@ module Riffer::Agent::Run
470
499
  span.set_attribute("riffer.tripwire.reason", tripwire.reason)
471
500
  span.set_attribute("riffer.tripwire.phase", tripwire.phase.to_s)
472
501
  end
502
+
503
+ #--
504
+ #: (Riffer::Agent::Outcome) -> String?
505
+ def interrupt_reason_attribute(outcome)
506
+ case outcome.reason
507
+ when :max_steps then Riffer::Agent::INTERRUPT_MAX_STEPS.to_s
508
+ when :interrupted then outcome.detail
509
+ end
510
+ end
473
511
  end
@@ -203,6 +203,8 @@ class Riffer::Agent::Session
203
203
  tool_calls: attrs.fetch(:tool_calls, old.tool_calls),
204
204
  token_usage: attrs.fetch(:token_usage, old.token_usage),
205
205
  structured_output: attrs.fetch(:structured_output, old.structured_output),
206
+ finish_reason: attrs.fetch(:finish_reason, old.finish_reason),
207
+ finish_reason_raw: attrs.fetch(:finish_reason_raw, old.finish_reason_raw),
206
208
  )
207
209
  when Riffer::Messages::Tool
208
210
  Riffer::Messages::Tool.new(
@@ -19,11 +19,31 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
19
19
  # <tt>Riffer::Providers::FinishReason::VALUES</tt>).
20
20
  attr_reader :finish_reason #: Symbol?
21
21
 
22
+ # The provider's raw finish-reason value behind +finish_reason+, when one
23
+ # exists on the wire.
24
+ attr_reader :finish_reason_raw #: String?
25
+
22
26
  # Raises Riffer::ArgumentError when +finish_reason+ is outside the
23
27
  # normalized vocabulary.
24
28
  #--
25
- #: (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?, ?finish_reason: Symbol?) -> void
26
- def initialize(content, id: nil, tool_calls: [], token_usage: nil, structured_output: nil, finish_reason: nil)
29
+ #: (
30
+ # String,
31
+ # ?id: String?,
32
+ # ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall],
33
+ # ?token_usage: Riffer::Providers::TokenUsage?,
34
+ # ?structured_output: Hash[Symbol, untyped]?,
35
+ # ?finish_reason: Symbol?,
36
+ # ?finish_reason_raw: String?
37
+ # ) -> void
38
+ def initialize(
39
+ content,
40
+ id: nil,
41
+ tool_calls: [],
42
+ token_usage: nil,
43
+ structured_output: nil,
44
+ finish_reason: nil,
45
+ finish_reason_raw: nil
46
+ )
27
47
  if finish_reason && !Riffer::Providers::FinishReason::VALUES.include?(finish_reason)
28
48
  values = Riffer::Providers::FinishReason::VALUES.inspect
29
49
  raise Riffer::ArgumentError, "finish_reason must be one of #{values}, got #{finish_reason.inspect}"
@@ -34,6 +54,7 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
34
54
  @token_usage = token_usage
35
55
  @structured_output = structured_output
36
56
  @finish_reason = finish_reason
57
+ @finish_reason_raw = finish_reason_raw
37
58
  end
38
59
 
39
60
  #--
@@ -71,6 +92,7 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
71
92
  hash[:token_usage] = token_usage.to_h if token_usage
72
93
  hash[:structured_output] = structured_output if structured_output?
73
94
  hash[:finish_reason] = finish_reason if finish_reason
95
+ hash[:finish_reason_raw] = finish_reason_raw if finish_reason_raw
74
96
  hash
75
97
  end
76
98
  end
@@ -14,38 +14,30 @@ class Riffer::Messages::Base
14
14
 
15
15
  raise Riffer::ArgumentError, "Message must be a Hash or Message object, got #{msg.class}" unless msg.is_a?(Hash)
16
16
 
17
- role = msg[:role]
18
- content = msg[:content]
17
+ raise Riffer::ArgumentError, "Message hash must include a 'role' key" if msg[:role].nil? || msg[:role].empty?
19
18
 
20
- raise Riffer::ArgumentError, "Message hash must include a 'role' key" if role.nil? || role.empty?
21
-
22
- id = msg[:id]
23
-
24
- case role.to_sym
19
+ case msg[:role].to_sym
25
20
  when :user
26
21
  files = (msg[:files] || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
27
- Riffer::Messages::User.new(content, id: id, files: files)
22
+ Riffer::Messages::User.new(msg[:content], id: msg[:id], files: files)
28
23
  when :assistant
29
24
  tool_calls = (msg[:tool_calls] || []).map do |tc|
30
25
  tc.is_a?(Riffer::Messages::Assistant::ToolCall) ? tc : Riffer::Messages::Assistant::ToolCall.new(**tc)
31
26
  end
32
- structured_output = msg[:structured_output]
33
- finish_reason = msg[:finish_reason]&.to_sym
34
27
  Riffer::Messages::Assistant.new(
35
- content,
36
- id: id,
28
+ msg[:content],
29
+ id: msg[:id],
37
30
  tool_calls: tool_calls,
38
- structured_output: structured_output,
39
- finish_reason: finish_reason,
31
+ structured_output: msg[:structured_output],
32
+ finish_reason: msg[:finish_reason]&.to_sym,
33
+ finish_reason_raw: msg[:finish_reason_raw],
40
34
  )
41
35
  when :system
42
- Riffer::Messages::System.new(content, id: id)
36
+ Riffer::Messages::System.new(msg[:content], id: msg[:id])
43
37
  when :tool
44
- tool_call_id = msg[:tool_call_id]
45
- name = msg[:name]
46
- Riffer::Messages::Tool.new(content, id: id, tool_call_id: tool_call_id, name: name)
38
+ Riffer::Messages::Tool.new(msg[:content], id: msg[:id], tool_call_id: msg[:tool_call_id], name: msg[:name])
47
39
  else
48
- raise Riffer::ArgumentError, "Unknown message role: #{role}"
40
+ raise Riffer::ArgumentError, "Unknown message role: #{msg[:role]}"
49
41
  end
50
42
  end
51
43
 
@@ -17,6 +17,9 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
17
17
  "tool_use" => :tool_calls,
18
18
  "guardrail_intervened" => :content_filter,
19
19
  "content_filtered" => :content_filter,
20
+ "malformed_model_output" => :malformed_output,
21
+ "malformed_tool_use" => :malformed_output,
22
+ "model_context_window_exceeded" => :context_window,
20
23
  }.freeze #: Hash[String, Symbol]
21
24
 
22
25
  # Returns the skill adapter for the Bedrock model — XML for Anthropic models
@@ -12,6 +12,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
12
12
  "max_tokens" => :length,
13
13
  "tool_use" => :tool_calls,
14
14
  "refusal" => :content_filter,
15
+ "model_context_window_exceeded" => :context_window,
16
+ # A paused server-tool turn resumes only by re-sending the response; the
17
+ # agent loop does not do that, so it has no normalized equivalent.
18
+ "pause_turn" => :other,
15
19
  }.freeze #: Hash[String, Symbol]
16
20
 
17
21
  # Returns the XML skill adapter for Anthropic/Claude.
@@ -92,7 +96,12 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
92
96
  # Use strict schema to make optional fields nullable. Without this,
93
97
  # Anthropic may return empty strings or whitespace instead of null
94
98
  # for optional fields that the model has no value for.
99
+ #
100
+ # Merged over any caller-supplied output_config (e.g. effort) so those
101
+ # keys survive; the structured-output format wins because the run loop
102
+ # validates the response against it.
95
103
  params[:output_config] = {
104
+ **(params[:output_config] || {}),
96
105
  format: {
97
106
  type: "json_schema",
98
107
  schema: structured_output.json_schema(strict: true),
@@ -71,6 +71,7 @@ class Riffer::Providers::Base
71
71
  token_usage: token_usage,
72
72
  structured_output: structured_output,
73
73
  finish_reason: finish_reason&.reason,
74
+ finish_reason_raw: finish_reason&.raw,
74
75
  )
75
76
  end
76
77
  end
@@ -5,7 +5,7 @@
5
5
  # wire value. +reason+ carries the same meaning for every provider.
6
6
  class Riffer::Providers::FinishReason
7
7
  # The normalized vocabulary every provider maps into.
8
- VALUES = %i[stop length tool_calls content_filter error other].freeze #: Array[Symbol]
8
+ VALUES = %i[stop length tool_calls content_filter context_window malformed_output error other].freeze #: Array[Symbol]
9
9
 
10
10
  # The normalized reason.
11
11
  attr_reader :reason #: Symbol
@@ -17,7 +17,16 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
17
17
  "PROHIBITED_CONTENT" => :content_filter,
18
18
  "SPII" => :content_filter,
19
19
  "IMAGE_SAFETY" => :content_filter,
20
- "MALFORMED_FUNCTION_CALL" => :error,
20
+ "IMAGE_PROHIBITED_CONTENT" => :content_filter,
21
+ "IMAGE_RECITATION" => :content_filter,
22
+ "LANGUAGE" => :content_filter,
23
+ "MALFORMED_FUNCTION_CALL" => :malformed_output,
24
+ "UNEXPECTED_TOOL_CALL" => :malformed_output,
25
+ "NO_IMAGE" => :error,
26
+ "TOO_MANY_TOOL_CALLS" => :other,
27
+ "OTHER" => :other,
28
+ "IMAGE_OTHER" => :other,
29
+ "FINISH_REASON_UNSPECIFIED" => :other,
21
30
  }.freeze #: Hash[String, Symbol]
22
31
 
23
32
  # The GenAI semconv well-known provider name.
@@ -5,6 +5,21 @@
5
5
  class Riffer::Providers::OpenAI < Riffer::Providers::Base
6
6
  WEB_SEARCH_TOOL_TYPE = "web_search_preview" #: String
7
7
 
8
+ # The Responses API has no finish_reason field. The response +status+ is
9
+ # the primary signal; an +incomplete+ status is only meaningful together
10
+ # with <tt>incomplete_details.reason</tt>, so that branch nests one level.
11
+ FINISH_REASONS = {
12
+ "completed" => :stop,
13
+ "incomplete" => {
14
+ "max_output_tokens" => :length,
15
+ "content_filter" => :content_filter,
16
+ },
17
+ "failed" => :error,
18
+ "cancelled" => :other,
19
+ "in_progress" => :other,
20
+ "queued" => :other,
21
+ }.freeze #: Hash[String, Symbol | Hash[String, Symbol]]
22
+
8
23
  # The GenAI semconv well-known provider name.
9
24
  #--
10
25
  #: () -> String
@@ -133,40 +148,32 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
133
148
  build_finish_reason(response)
134
149
  end
135
150
 
136
- # The Responses API reports no finish_reason field, so one is derived.
137
151
  #--
138
152
  #: (untyped) -> Riffer::Providers::FinishReason?
139
153
  def build_finish_reason(response)
140
154
  typed_response = response #: OpenAI::Models::Responses::Response
141
- status = typed_response.status
155
+ status = typed_response.status&.to_s
142
156
  return nil unless status
143
157
 
144
- case status.to_sym
145
- when :completed
146
- reason = extract_tool_calls(typed_response).empty? ? :stop : :tool_calls
147
- Riffer::Providers::FinishReason.new(reason: reason, raw: "completed")
148
- when :incomplete
149
- incomplete_finish_reason(typed_response)
150
- when :failed
151
- Riffer::Providers::FinishReason.new(reason: :error, raw: "failed")
152
- else
153
- Riffer::Providers::FinishReason.new(reason: :other, raw: status.to_s)
154
- end
158
+ detail = finish_detail(typed_response, status)
159
+ mapping = FINISH_REASONS.fetch(status, :other)
160
+ reason = mapping.is_a?(Hash) ? mapping.fetch(detail.to_s, :other) : mapping
161
+ # A completed response signals tool use only through its output items.
162
+ reason = :tool_calls if reason == :stop && !extract_tool_calls(typed_response).empty?
163
+
164
+ Riffer::Providers::FinishReason.new(reason: reason, raw: detail || status)
155
165
  end
156
166
 
167
+ # The nested field that names the cause behind an ambiguous status.
157
168
  #--
158
- #: (untyped) -> Riffer::Providers::FinishReason
159
- def incomplete_finish_reason(response)
169
+ #: (untyped, String) -> String?
170
+ def finish_detail(response, status)
160
171
  typed_response = response #: OpenAI::Models::Responses::Response
161
- raw = typed_response.incomplete_details&.reason&.to_s
162
-
163
- reason = case raw
164
- when "max_output_tokens" then :length
165
- when "content_filter" then :content_filter
166
- else :other
167
- end
168
172
 
169
- Riffer::Providers::FinishReason.new(reason: reason, raw: raw || "incomplete")
173
+ case status
174
+ when "incomplete" then typed_response.incomplete_details&.reason&.to_s
175
+ when "failed" then typed_response.error&.code&.to_s
176
+ end
170
177
  end
171
178
 
172
179
  #--
@@ -133,18 +133,30 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
133
133
  #: (untyped) -> Riffer::Providers::FinishReason?
134
134
  def extract_finish_reason(response)
135
135
  typed_response = response #: OpenAI::Models::Chat::ChatCompletion
136
- build_finish_reason(typed_response.choices.first&.finish_reason)
136
+ choice = typed_response.choices.first
137
+ build_finish_reason(choice&.finish_reason, native: native_finish_reason(choice))
137
138
  end
138
139
 
140
+ # +native+ is the upstream model's own finish reason, which OpenRouter
141
+ # reports alongside its normalized one; it wins as +raw+ when present.
139
142
  #--
140
- #: (untyped) -> Riffer::Providers::FinishReason?
141
- def build_finish_reason(finish_reason)
143
+ #: (untyped, ?native: untyped) -> Riffer::Providers::FinishReason?
144
+ def build_finish_reason(finish_reason, native: nil)
142
145
  return nil unless finish_reason
143
146
 
144
- raw = finish_reason.to_s
145
- return nil if raw.empty?
147
+ normalized = finish_reason.to_s
148
+ return nil if normalized.empty?
149
+
150
+ raw = native.to_s.empty? ? normalized : native.to_s
151
+ Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(normalized, :other), raw: raw)
152
+ end
146
153
 
147
- Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(raw, :other), raw: raw)
154
+ # +native_finish_reason+ is outside the OpenAI schema, so it is only
155
+ # reachable through the SDK model's raw data hash.
156
+ #--
157
+ #: (untyped) -> untyped
158
+ def native_finish_reason(choice)
159
+ choice && choice.to_h[:native_finish_reason]
148
160
  end
149
161
 
150
162
  #--
@@ -187,6 +199,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
187
199
  reasoning: +"",
188
200
  tool_calls: {},
189
201
  finish_reason: nil,
202
+ native_finish_reason: nil,
190
203
  } #: Hash[Symbol, untyped]
191
204
 
192
205
  # Use stream_raw (not stream) — the latter yields a higher-level
@@ -211,7 +224,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
211
224
 
212
225
  yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
213
226
  yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
214
- yield_finish_reason(yielder, build_finish_reason(state[:finish_reason]))
227
+ yield_finish_reason(yielder, build_finish_reason(state[:finish_reason], native: state[:native_finish_reason]))
215
228
  end
216
229
 
217
230
  #--
@@ -228,6 +241,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
228
241
  end
229
242
 
230
243
  state[:finish_reason] = choice.finish_reason if choice&.finish_reason
244
+ state[:native_finish_reason] = native_finish_reason(choice) || state[:native_finish_reason]
231
245
 
232
246
  emit_tool_call_done_events(state: state, yielder: yielder) if choice && finish_reason_is_tool_calls?(choice)
233
247
 
@@ -5,6 +5,8 @@
5
5
  # +max_concurrency+ caps simultaneous fibers via an <tt>Async::Semaphore</tt>.
6
6
  # If multiple fibers raise, only the first exception is re-raised after all
7
7
  # finish.
8
+ # Joins the current reactor task when one is already running, and otherwise
9
+ # starts its own.
8
10
  class Riffer::Runner::Fibers < Riffer::Runner
9
11
  # @rbs @max_concurrency: Integer?
10
12
 
@@ -25,15 +27,15 @@ class Riffer::Runner::Fibers < Riffer::Runner
25
27
  results = Array.new(items.size)
26
28
  errors = Array.new(items.size)
27
29
 
28
- Async do
29
- barrier = Async::Barrier.new
30
- max = @max_concurrency
31
- parent = if max
32
- Async::Semaphore.new(max, parent: barrier)
33
- else
34
- barrier
35
- end
30
+ barrier = Async::Barrier.new
31
+ max = @max_concurrency
32
+ parent = if max
33
+ Async::Semaphore.new(max, parent: barrier)
34
+ else
35
+ barrier
36
+ end
36
37
 
38
+ Sync do
37
39
  items.each_with_index do |item, index|
38
40
  parent.async do
39
41
  results[index] = yield(item)
@@ -43,6 +45,8 @@ class Riffer::Runner::Fibers < Riffer::Runner
43
45
  end
44
46
 
45
47
  barrier.wait
48
+ ensure
49
+ barrier.stop
46
50
  end
47
51
 
48
52
  first_error = errors.compact.first
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.44.0" #: String
5
+ VERSION = "0.46.0" #: String
6
6
  end
@@ -10,6 +10,8 @@ module Async
10
10
  def async: () { () -> void } -> untyped
11
11
 
12
12
  def wait: () -> void
13
+
14
+ def stop: () -> void
13
15
  end
14
16
 
15
17
  class Semaphore
@@ -21,4 +23,6 @@ end
21
23
 
22
24
  module Kernel
23
25
  def Async: () { () -> void } -> untyped
26
+
27
+ def Sync: () { () -> void } -> untyped
24
28
  end