riffer 0.37.1 → 0.38.1

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: bf9db0e6d260a9528768fd4c13e59af50504afde1a251fdbe056f2e0220e60e4
4
- data.tar.gz: 71064f20c5214d303080a57494b3a4d3b93f23b3f446c131bbb34c015ec7a4ae
3
+ metadata.gz: 81a97541ff82791dbf24d5bcad216678db7db1262d3f0e18e4dfe18d77169b20
4
+ data.tar.gz: 294d8493c2e6349e6005c190deb15c7b56c1d1cfc49bcdafa2d99ea39fb4b1e3
5
5
  SHA512:
6
- metadata.gz: c4971ac39454bc52acb06ee24542ac64764db8360f8f07252c709a4145301a590c8d336b6496a10a5a1920964cb12dcc815c05519ece50bf6b07d17dba7118e9
7
- data.tar.gz: 653fe046ac9feb510284ef89808b2ac17a83b274fe2c4b979c33d7c46ba8362bd1a3280259957b217f0fc1855c1acdfbc4979764314ed1b5290e1f84370ab6ae
6
+ metadata.gz: 7004eefa26eaa6bd7a02ff31ae04a4596c063f591b0375f7089e8f39037254945b32a5e57ef6595ddc3f0845173b07d5e020b01c553b573df2a9b535f21d15a2
7
+ data.tar.gz: c0444136536b7034241212e9bca19ad634c98080a1491af4cb32e51a2dcecf0566dcc0e5cd798e2fd433a0bebf1aff02fee46f231454500afaa1a8fb7e5675a3
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.37.1"
2
+ ".": "0.38.1"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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.38.1](https://github.com/janeapp/riffer/compare/riffer/v0.38.0...riffer/v0.38.1) (2026-07-29)
9
+
10
+
11
+ ### Performance Improvements
12
+
13
+ * avoid O(n^2) text accumulation in streaming providers ([#378](https://github.com/janeapp/riffer/issues/378)) ([d344fa7](https://github.com/janeapp/riffer/commit/d344fa7122492d14ee1c91c0be3a88501170e255))
14
+
15
+ ## [0.38.0](https://github.com/janeapp/riffer/compare/riffer/v0.37.1...riffer/v0.38.0) (2026-07-21)
16
+
17
+
18
+ ### Features
19
+
20
+ * record time to first chunk on streaming chat spans ([#369](https://github.com/janeapp/riffer/issues/369)) ([133b019](https://github.com/janeapp/riffer/commit/133b0197dd358ae1cd37adbeb7b5df7446b0cbde))
21
+
8
22
  ## [0.37.1](https://github.com/janeapp/riffer/compare/riffer/v0.37.0...riffer/v0.37.1) (2026-07-13)
9
23
 
10
24
 
data/docs/16_TRACING.md CHANGED
@@ -147,6 +147,7 @@ Usage on this span is the run total, aggregated across every step. See [Token us
147
147
  | `riffer.cost` | float | When the call's model was priced |
148
148
  | `gen_ai.response.finish_reasons` | string[] | When the provider reported a finish reason |
149
149
  | `riffer.finish_reason.raw` | string | When the raw value differs from the normalized one |
150
+ | `gen_ai.response.time_to_first_chunk` | float | Streaming only — seconds from request issuance to the first stream event |
150
151
  | `gen_ai.input.messages` | string | When `capture_messages` is on (JSON; see [capture](#message-content-capture)) |
151
152
  | `gen_ai.system_instructions` | string | When `capture_messages` is on and a system prompt exists |
152
153
  | `gen_ai.output.messages` | string | When `capture_messages` is on (JSON) |
@@ -121,7 +121,7 @@ module Riffer::Agent::Run
121
121
  #--
122
122
  #: (Riffer::Agent, Enumerator::Yielder, ?Hash[String, String]) -> Riffer::Messages::Assistant
123
123
  def accumulate_streamed_response(agent, stream_yielder, tags = {})
124
- accumulated_content = ""
124
+ accumulated_content = +""
125
125
  accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
126
126
  accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
127
127
  accumulated_finish_reason = nil #: Symbol?
@@ -131,9 +131,13 @@ module Riffer::Agent::Run
131
131
 
132
132
  case event
133
133
  when Riffer::StreamEvents::TextDelta
134
- accumulated_content += event.content
134
+ # Append in place rather than += (which reallocates and copies the whole
135
+ # buffer per delta, O(n^2) over a stream). accumulated_content stays an
136
+ # owned buffer; replace (not =) on TextDone keeps it that way so a later
137
+ # delta's << can never mutate the string held by a TextDone event.
138
+ accumulated_content << event.content
135
139
  when Riffer::StreamEvents::TextDone
136
- accumulated_content = event.content
140
+ accumulated_content.replace(event.content)
137
141
  when Riffer::StreamEvents::ToolCallDone
138
142
  accumulated_tool_calls << Riffer::Messages::Assistant::ToolCall.new(
139
143
  call_id: event.call_id,
@@ -267,7 +267,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
267
267
  state[:tool_call] = {
268
268
  id: typed_event.start.tool_use.tool_use_id,
269
269
  name: decode_tool_name(typed_event.start.tool_use.name, tools: @current_tools),
270
- arguments: ""
270
+ arguments: +""
271
271
  }
272
272
  end
273
273
 
@@ -276,8 +276,13 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
276
276
  def handle_content_block_delta_text_delta(event, state:, yielder:)
277
277
  typed_event = event #: Aws::BedrockRuntime::Types::ContentBlockDeltaEvent
278
278
  delta_text = typed_event.delta.text
279
- state[:text] ||= ""
280
- state[:text] += delta_text
279
+ # Mutating append: += would reallocate and copy the whole accumulated
280
+ # buffer on every delta (O(n^2) per content block). state[:text] is handed
281
+ # off to TextDone and then cleared on block stop, so nothing reads the
282
+ # pre-append string, making in-place mutation safe. Seed with an unfrozen
283
+ # String (+"") so << does not raise under frozen_string_literal.
284
+ state[:text] ||= +""
285
+ state[:text] << delta_text
281
286
  yielder << Riffer::StreamEvents::TextDelta.new(delta_text)
282
287
  end
283
288
 
@@ -287,7 +292,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
287
292
  typed_event = event #: Aws::BedrockRuntime::Types::ContentBlockDeltaEvent
288
293
  input_delta = typed_event.delta.tool_use.input
289
294
 
290
- state[:tool_call][:arguments] += input_delta
295
+ state[:tool_call][:arguments] << input_delta
291
296
 
292
297
  yielder << Riffer::StreamEvents::ToolCallDelta.new(
293
298
  item_id: state[:tool_call][:id],
@@ -233,7 +233,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
233
233
  content_block = event.content_block
234
234
  if content_block.type.to_s == "server_tool_use" && content_block.name.to_s == "web_search"
235
235
  state[:web_search_index] = event.index
236
- state[:web_search_json] = ""
236
+ state[:web_search_json] = +""
237
237
  end
238
238
  end
239
239
 
@@ -243,22 +243,27 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
243
243
  return unless state[:web_search_index] == event.index
244
244
 
245
245
  delta = event.delta
246
- state[:web_search_json] += delta.partial_json if delta.respond_to?(:partial_json)
246
+ state[:web_search_json] << delta.partial_json if delta.respond_to?(:partial_json)
247
247
  end
248
248
 
249
249
  #--
250
250
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
251
251
  def handle_text_event(event, state:, yielder:)
252
- state[:text] ||= ""
253
- state[:text] += event.text
252
+ # Mutating append instead of += to avoid reallocating and copying the whole
253
+ # accumulated buffer on every delta (O(n^2) per content block). The buffer
254
+ # is handed to TextDone and cleared on block stop, so no reader observes the
255
+ # pre-append string. Seed with an unfrozen String so << is legal under
256
+ # frozen_string_literal.
257
+ state[:text] ||= +""
258
+ state[:text] << event.text
254
259
  yielder << Riffer::StreamEvents::TextDelta.new(event.text)
255
260
  end
256
261
 
257
262
  #--
258
263
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
259
264
  def handle_thinking_event(event, state:, yielder:)
260
- state[:reasoning] ||= ""
261
- state[:reasoning] += event.thinking
265
+ state[:reasoning] ||= +""
266
+ state[:reasoning] << event.thinking
262
267
  yielder << Riffer::StreamEvents::ReasoningDelta.new(event.thinking)
263
268
  end
264
269
 
@@ -266,9 +271,9 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
266
271
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
267
272
  def handle_input_json_event(event, state:, yielder:)
268
273
  if state[:tool_call].nil?
269
- state[:tool_call] = {id: nil, name: nil, arguments: ""}
274
+ state[:tool_call] = {id: nil, name: nil, arguments: +""}
270
275
  end
271
- state[:tool_call][:arguments] += event.partial_json
276
+ state[:tool_call][:arguments] << event.partial_json
272
277
  yielder << Riffer::StreamEvents::ToolCallDelta.new(
273
278
  item_id: state[:tool_call][:id] || "pending",
274
279
  name: state[:tool_call][:name],
@@ -263,6 +263,8 @@ class Riffer::Providers::Base
263
263
  def record_stream_outcome(span, recorder)
264
264
  Riffer::Tracing.record_usage(span, recorder.token_usage)
265
265
  record_finish_reason(span, recorder.finish_reason, recorder.raw_finish_reason)
266
+ time_to_first_chunk = recorder.time_to_first_chunk
267
+ span.set_attribute("gen_ai.response.time_to_first_chunk", time_to_first_chunk) if time_to_first_chunk
266
268
  capture_output(span, content: recorder.content, tool_calls: recorder.tool_calls, finish_reason: recorder.finish_reason)
267
269
  end
268
270
 
@@ -5,9 +5,13 @@
5
5
  # forwarding every event downstream untouched.
6
6
  class Riffer::Tracing::StreamRecorder # :nodoc: all
7
7
  # @rbs @yielder: Enumerator::Yielder
8
+ # @rbs @clock: ^() -> Float
9
+ # @rbs @started_at: Float
8
10
 
9
11
  attr_reader :token_usage #: Riffer::Providers::TokenUsage?
10
12
 
13
+ attr_reader :time_to_first_chunk #: Float?
14
+
11
15
  attr_reader :finish_reason #: Symbol?
12
16
 
13
17
  attr_reader :raw_finish_reason #: String?
@@ -17,15 +21,18 @@ class Riffer::Tracing::StreamRecorder # :nodoc: all
17
21
  attr_reader :tool_calls #: Array[Riffer::Messages::Assistant::ToolCall]
18
22
 
19
23
  #--
20
- #: (Enumerator::Yielder) -> void
21
- def initialize(yielder)
24
+ #: (Enumerator::Yielder, ?clock: ^() -> Float) -> void
25
+ def initialize(yielder, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
22
26
  @yielder = yielder
27
+ @clock = clock
28
+ @started_at = clock.call
23
29
  @tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
24
30
  end
25
31
 
26
32
  #--
27
33
  #: (Riffer::StreamEvents::Base) -> self
28
34
  def <<(event)
35
+ @time_to_first_chunk ||= @clock.call - @started_at
29
36
  record(event)
30
37
  @yielder << event
31
38
  self
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.37.1" #: String
5
+ VERSION = "0.38.1" #: String
6
6
  end
@@ -5,8 +5,14 @@
5
5
  class Riffer::Tracing::StreamRecorder
6
6
  @yielder: Enumerator::Yielder
7
7
 
8
+ @clock: ^() -> Float
9
+
10
+ @started_at: Float
11
+
8
12
  attr_reader token_usage: Riffer::Providers::TokenUsage?
9
13
 
14
+ attr_reader time_to_first_chunk: Float?
15
+
10
16
  attr_reader finish_reason: Symbol?
11
17
 
12
18
  attr_reader raw_finish_reason: String?
@@ -16,8 +22,8 @@ class Riffer::Tracing::StreamRecorder
16
22
  attr_reader tool_calls: Array[Riffer::Messages::Assistant::ToolCall]
17
23
 
18
24
  # --
19
- # : (Enumerator::Yielder) -> void
20
- def initialize: (Enumerator::Yielder) -> void
25
+ # : (Enumerator::Yielder, ?clock: ^() -> Float) -> void
26
+ def initialize: (Enumerator::Yielder, ?clock: ^() -> Float) -> void
21
27
 
22
28
  # --
23
29
  # : (Riffer::StreamEvents::Base) -> self
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.37.1
4
+ version: 0.38.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -35,14 +35,14 @@ dependencies:
35
35
  requirements:
36
36
  - - "~>"
37
37
  - !ruby/object:Gem::Version
38
- version: 1.55.0
38
+ version: 1.59.0
39
39
  type: :development
40
40
  prerelease: false
41
41
  version_requirements: !ruby/object:Gem::Requirement
42
42
  requirements:
43
43
  - - "~>"
44
44
  - !ruby/object:Gem::Version
45
- version: 1.55.0
45
+ version: 1.59.0
46
46
  - !ruby/object:Gem::Dependency
47
47
  name: aws-sdk-bedrockruntime
48
48
  requirement: !ruby/object:Gem::Requirement
@@ -77,28 +77,28 @@ dependencies:
77
77
  requirements:
78
78
  - - "~>"
79
79
  - !ruby/object:Gem::Version
80
- version: '0.8'
80
+ version: '1.0'
81
81
  type: :development
82
82
  prerelease: false
83
83
  version_requirements: !ruby/object:Gem::Requirement
84
84
  requirements:
85
85
  - - "~>"
86
86
  - !ruby/object:Gem::Version
87
- version: '0.8'
87
+ version: '1.0'
88
88
  - !ruby/object:Gem::Dependency
89
89
  name: openai
90
90
  requirement: !ruby/object:Gem::Requirement
91
91
  requirements:
92
92
  - - "~>"
93
93
  - !ruby/object:Gem::Version
94
- version: 0.69.0
94
+ version: 0.72.0
95
95
  type: :development
96
96
  prerelease: false
97
97
  version_requirements: !ruby/object:Gem::Requirement
98
98
  requirements:
99
99
  - - "~>"
100
100
  - !ruby/object:Gem::Version
101
- version: 0.69.0
101
+ version: 0.72.0
102
102
  - !ruby/object:Gem::Dependency
103
103
  name: async
104
104
  requirement: !ruby/object:Gem::Requirement
@@ -108,7 +108,7 @@ dependencies:
108
108
  version: '2.25'
109
109
  - - "<"
110
110
  - !ruby/object:Gem::Version
111
- version: '2.43'
111
+ version: '2.44'
112
112
  type: :development
113
113
  prerelease: false
114
114
  version_requirements: !ruby/object:Gem::Requirement
@@ -118,7 +118,7 @@ dependencies:
118
118
  version: '2.25'
119
119
  - - "<"
120
120
  - !ruby/object:Gem::Version
121
- version: '2.43'
121
+ version: '2.44'
122
122
  - !ruby/object:Gem::Dependency
123
123
  name: io-event
124
124
  requirement: !ruby/object:Gem::Requirement