riffer 0.44.0 → 0.45.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: 7f7862ed670d852eee1e076386032449773a8782424fbfc9bc80e96aab61b1e3
4
- data.tar.gz: b520d7629441e6acd75a677bc3d094359f5a5b7f31c2986d6aadd51f2935b3e1
3
+ metadata.gz: b166b910624129bd5b64b9443caba3b5c781b691a7453b5640cd132c21cd72c2
4
+ data.tar.gz: d64178e66b49945647393430b57f84a501ec7c0bfbe866b20f6f1b6af44ef570
5
5
  SHA512:
6
- metadata.gz: '069d1d96193748ffdc48eb95a8fe22b57c6293a33e7ba4658a01fda4e5c4a7847145bb234605c0507059830c300dc2d85ec295ecf2d59de20265385644d6dc89'
7
- data.tar.gz: 86bbcfb5408f3b5ba0fbb327dff9c2a98c0ee410e3ce4ab72c89a3a66d8aaa8e4d095e2ee14bc32c814caa28503cf0d099095441a60eb4cb9e11b7639f522d65
6
+ metadata.gz: 36ce62e6539e22a01c5f8523278203b0307a2753c8e3af8e683133b741144417b360721862249d2c64fb118211f134e7b968d681e4c31e20f9ca051768e9aef6
7
+ data.tar.gz: 39ac8834695876cd923e3dffe7065045f7296e418056e18eae9da4811ba04c886b546342a4a4586d92ac529ec456a18e0b272b0598eb70c72371af2dbefebce3
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.44.0"
2
+ ".": "0.45.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ 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.45.0](https://github.com/janeapp/riffer/compare/riffer/v0.44.0...riffer/v0.45.0) (2026-09-03)
9
+
10
+
11
+ ### Features
12
+
13
+ * expand finish reason vocabulary and close provider gaps ([#419](https://github.com/janeapp/riffer/issues/419)) ([daf91a6](https://github.com/janeapp/riffer/commit/daf91a61956ce8da626a590d30307e11e90d474b))
14
+
8
15
  ## [0.44.0](https://github.com/janeapp/riffer/compare/riffer/v0.43.0...riffer/v0.44.0) (2026-08-28)
9
16
 
10
17
 
data/docs/MESSAGES.md CHANGED
@@ -83,16 +83,18 @@ The cache buckets are subsets of `input_tokens`, never additions to it — summi
83
83
 
84
84
  `finish_reason` carries the same meaning for every provider — each adapter maps its raw wire value (Anthropic's `end_turn`, OpenAI's response status, Gemini's `STOP`, …) into a normalized vocabulary:
85
85
 
86
- | Value | Meaning |
87
- | ----------------- | --------------------------------------------------------------- |
88
- | `:stop` | The model finished its turn naturally (or hit a stop sequence). |
89
- | `:length` | Output was truncated at the max-token limit. |
90
- | `:tool_calls` | The model stopped to call tools. |
91
- | `:content_filter` | A provider safety system blocked or cut the response. |
92
- | `:error` | The provider reported an error finish. |
93
- | `:other` | A provider-specific value with no normalized equivalent. |
94
-
95
- `finish_reason` is `nil` when the provider doesn't report one. Use it to detect truncation without parsing provider responses:
86
+ | Value | Meaning |
87
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
88
+ | `:stop` | The model finished its turn naturally (or hit a stop sequence). |
89
+ | `:length` | Output was truncated at the max-token limit. |
90
+ | `:tool_calls` | The model stopped to call tools. |
91
+ | `:content_filter` | A provider safety system blocked or cut the response. |
92
+ | `:context_window` | Input plus output hit the model's context window; trim or compact history rather than raising `max_tokens`. |
93
+ | `:malformed_output` | The model emitted output the provider could not parse, such as an invalid tool call; retry or nudge rather than backing off. |
94
+ | `:error` | The provider reported an error finish. |
95
+ | `:other` | A provider-specific value with no normalized equivalent. |
96
+
97
+ `finish_reason` is `nil` when the provider doesn't report one. The provider's raw wire value travels alongside on the `FinishReasonDone` stream event and the `riffer.finish_reason.raw` trace attribute — for OpenRouter that is the upstream model's `native_finish_reason`, and for a failed OpenAI response it is the error code. Use `finish_reason` to detect truncation without parsing provider responses:
96
98
 
97
99
  ```ruby
98
100
  response = agent.generate("Summarize this document")
data/docs/TRACING.md CHANGED
@@ -153,7 +153,7 @@ Usage on this span is the run total, aggregated across every step. See [Token us
153
153
  | `gen_ai.output.messages` | string | When `capture_messages` is on (JSON) |
154
154
  | `error.type` | string | On an unhandled exception |
155
155
 
156
- `gen_ai.response.finish_reasons` is an array of exactly one normalized value, from the fixed vocabulary `stop`, `length`, `tool_calls`, `content_filter`, `error`, `other`. When the provider's raw wire value carries more nuance than the normalized one, the raw string is preserved on `riffer.finish_reason.raw`.
156
+ `gen_ai.response.finish_reasons` is an array of exactly one normalized value, from the fixed vocabulary `stop`, `length`, `tool_calls`, `content_filter`, `context_window`, `malformed_output`, `error`, `other`. When the provider's raw wire value carries more nuance than the normalized one, the raw string is preserved on `riffer.finish_reason.raw`.
157
157
 
158
158
  ## `execute_tool {tool}` — the tool call span
159
159
 
@@ -252,7 +252,7 @@ Riffer::StreamEvents::FinishReasonDone.new(
252
252
 
253
253
  ## Finish Reasons
254
254
 
255
- `Riffer::Providers::FinishReason` is the same kind of normalized contract — map your provider's raw finish/stop value into the vocabulary defined in [Messages — Finish Reasons](../MESSAGES.md#finish-reasons) (`:stop`, `:length`, `:tool_calls`, `:content_filter`, `:error`, `:other`), keeping the raw wire value alongside:
255
+ `Riffer::Providers::FinishReason` is the same kind of normalized contract — map your provider's raw finish/stop value into the vocabulary defined in [Messages — Finish Reasons](../MESSAGES.md#finish-reasons) (`:stop`, `:length`, `:tool_calls`, `:content_filter`, `:context_window`, `:malformed_output`, `:error`, `:other`), keeping the raw wire value alongside:
256
256
 
257
257
  ```ruby
258
258
  def extract_finish_reason(response)
@@ -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.
@@ -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
 
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.44.0" #: String
5
+ VERSION = "0.45.0" #: String
6
6
  end
@@ -4,6 +4,11 @@
4
4
  class Riffer::Providers::OpenAI < Riffer::Providers::Base
5
5
  WEB_SEARCH_TOOL_TYPE: String
6
6
 
7
+ # The Responses API has no finish_reason field. The response +status+ is
8
+ # the primary signal; an +incomplete+ status is only meaningful together
9
+ # with <tt>incomplete_details.reason</tt>, so that branch nests one level.
10
+ FINISH_REASONS: Hash[String, Symbol | Hash[String, Symbol]]
11
+
7
12
  # The GenAI semconv well-known provider name.
8
13
  # --
9
14
  # : () -> String
@@ -50,14 +55,14 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
50
55
  # : (untyped) -> Riffer::Providers::FinishReason?
51
56
  def extract_finish_reason: (untyped) -> Riffer::Providers::FinishReason?
52
57
 
53
- # The Responses API reports no finish_reason field, so one is derived.
54
58
  # --
55
59
  # : (untyped) -> Riffer::Providers::FinishReason?
56
60
  def build_finish_reason: (untyped) -> Riffer::Providers::FinishReason?
57
61
 
62
+ # The nested field that names the cause behind an ambiguous status.
58
63
  # --
59
- # : (untyped) -> Riffer::Providers::FinishReason
60
- def incomplete_finish_reason: (untyped) -> Riffer::Providers::FinishReason
64
+ # : (untyped, String) -> String?
65
+ def finish_detail: (untyped, String) -> String?
61
66
 
62
67
  # --
63
68
  # : (untyped) -> String
@@ -57,9 +57,17 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
57
57
  # : (untyped) -> Riffer::Providers::FinishReason?
58
58
  def extract_finish_reason: (untyped) -> Riffer::Providers::FinishReason?
59
59
 
60
+ # +native+ is the upstream model's own finish reason, which OpenRouter
61
+ # reports alongside its normalized one; it wins as +raw+ when present.
60
62
  # --
61
- # : (untyped) -> Riffer::Providers::FinishReason?
62
- def build_finish_reason: (untyped) -> Riffer::Providers::FinishReason?
63
+ # : (untyped, ?native: untyped) -> Riffer::Providers::FinishReason?
64
+ def build_finish_reason: (untyped, ?native: untyped) -> Riffer::Providers::FinishReason?
65
+
66
+ # +native_finish_reason+ is outside the OpenAI schema, so it is only
67
+ # reachable through the SDK model's raw data hash.
68
+ # --
69
+ # : (untyped) -> untyped
70
+ def native_finish_reason: (untyped) -> untyped
63
71
 
64
72
  # --
65
73
  # : (untyped) -> String
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.44.0
4
+ version: 0.45.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall