lex-llm 0.7.3 → 0.8.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 (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,25 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Llm
6
- # Assembles streaming responses from LLMs into complete messages.
8
+ # Assembles Canonical streaming chunks into a complete Canonical::Response.
9
+ #
10
+ # The provider's build_chunk yields Canonical::Chunk objects (text_delta /
11
+ # thinking_delta / tool_call_delta / usage). This accumulator owns:
12
+ # - the stateful think-tag split (cross-chunk boundary buffering — the
13
+ # only streaming-specific code; the segment logic is shared with
14
+ # Responses::ThinkingExtractor, 10 U1),
15
+ # - the untagged-preamble heuristic,
16
+ # - tool-call fragment correlation: the provider's authoritative wire
17
+ # INDEX first, recency only as the fallback for providers that emit
18
+ # no index (the wire index-first/recency-fallback law).
19
+ # Fragments are assembled into a complete JSON string before the ONE
20
+ # strict arguments parser runs (10 U2).
7
21
  class StreamAccumulator
8
22
  include Legion::Logging::Helper
9
23
 
10
- attr_reader :content, :model_id, :tool_calls, :stop_reason
24
+ attr_reader :model_id, :stop_reason
11
25
 
12
- def initialize
26
+ def initialize(request_id: nil, conversation_id: nil, exchange_id: nil)
27
+ @request_id = request_id
28
+ @conversation_id = conversation_id
29
+ @exchange_id = exchange_id
13
30
  @content = +''
14
31
  @thinking_text = +''
15
32
  @thinking_signature = nil
16
33
  @tool_calls = {}
17
34
  @stop_reason = nil
18
- @input_tokens = nil
19
- @output_tokens = nil
20
- @cached_tokens = nil
21
- @cache_creation_tokens = nil
22
- @thinking_tokens = nil
35
+ @usage = {}
23
36
  @inside_think_tag = false
24
37
  @pending_think_tag = +''
25
38
  @active_think_close_tag = nil
@@ -29,231 +42,192 @@ module Legion
29
42
  @index_to_id = {}
30
43
  end
31
44
 
45
+ # Consume one Canonical::Chunk; returns the Array of Canonical::Chunk
46
+ # objects to emit to the caller (empty when nothing is emitted).
32
47
  def add(chunk)
33
48
  log.debug { chunk.inspect } if Legion::Extensions::Llm.config.log_stream_debug
34
- @model_id ||= chunk.model_id
35
-
36
- @last_content_delta = +''
37
- @last_thinking_delta = +''
38
- handle_chunk_content(chunk)
39
- append_thinking_from_chunk(chunk)
40
- count_tokens chunk
41
- @stop_reason = chunk.stop_reason if chunk.respond_to?(:stop_reason) && chunk.stop_reason
42
- log.debug { inspect } if Legion::Extensions::Llm.config.log_stream_debug
49
+ @model_id ||= chunk.metadata[:model] if chunk.metadata.is_a?(::Hash)
50
+ @stop_reason = chunk.stop_reason if chunk.stop_reason
51
+ track_usage(chunk.usage) if chunk.usage
52
+
53
+ case chunk.type
54
+ when :text_delta then add_text_delta(chunk)
55
+ when :thinking_delta then add_thinking_delta(chunk)
56
+ when :tool_call_delta then add_tool_call_delta(chunk)
57
+ when :usage then [chunk]
58
+ else []
59
+ end
43
60
  end
44
61
 
45
- def filtered_chunk(chunk)
46
- has_content = !@last_content_delta.empty?
47
- has_thinking = !@last_thinking_delta.empty?
48
- has_tokens = chunk.input_tokens&.positive? || chunk.output_tokens&.positive?
49
- return nil unless has_content || has_thinking || chunk.tool_call? || has_tokens
50
-
51
- Chunk.new(
52
- role: :assistant,
53
- content: has_content ? @last_content_delta : nil,
54
- thinking: has_thinking ? Thinking.build(text: @last_thinking_delta) : chunk.thinking,
55
- model_id: chunk.model_id,
56
- tool_calls: chunk.tool_calls,
57
- input_tokens: chunk.input_tokens,
58
- output_tokens: chunk.output_tokens,
59
- raw: chunk.raw
60
- )
62
+ # Flush any text still held by the untagged-preamble heuristic so
63
+ # short responses still stream at least one delta (the superset flush —
64
+ # 10 #19: folds the buffer into content AND emits the held text).
65
+ def flush_pending_chunk
66
+ return [] if @untagged_preamble_buffer.empty?
67
+
68
+ content, thinking = Responses::ThinkingExtractor.extract_untagged_preamble(@untagged_preamble_buffer)
69
+ emitted = []
70
+ if thinking
71
+ @content << content
72
+ @thinking_text << thinking
73
+ emitted << thinking_delta_for(thinking)
74
+ emitted << text_delta_for(content) unless content.empty?
75
+ else
76
+ @content << @untagged_preamble_buffer
77
+ emitted << text_delta_for(@untagged_preamble_buffer)
78
+ end
79
+ @untagged_preamble_buffer = +''
80
+ @untagged_preamble_pending = false
81
+ emitted
61
82
  end
62
83
 
63
- # Flush any text still held in the untagged-preamble buffer as a final
64
- # streamed chunk. Without this, short responses that match the
65
- # untagged-reasoning heuristic (e.g. starting with "I", "The", "Let me")
66
- # and never hit a double newline are buffered for the entire stream and
67
- # the caller's block never receives a single delta.
68
- def flush_pending_chunk
69
- return nil if @untagged_preamble_buffer.empty?
70
-
71
- @last_content_delta = +''
72
- @last_thinking_delta = +''
73
- flush_pending_untagged_preamble_into_deltas
74
- return nil if @last_content_delta.empty? && @last_thinking_delta.empty?
75
-
76
- Chunk.new(
77
- role: :assistant,
78
- content: @last_content_delta.empty? ? nil : @last_content_delta,
79
- thinking: @last_thinking_delta.empty? ? nil : Thinking.build(text: @last_thinking_delta),
80
- model_id: model_id
84
+ # The accumulated Canonical::Response (05 O5). model falls back to the
85
+ # Selection-derived model when the provider wire reported none.
86
+ def to_response(model: nil)
87
+ Canonical::Response.build(
88
+ text: @content.empty? ? nil : @content,
89
+ thinking: accumulated_thinking,
90
+ tool_calls: accumulated_tool_calls,
91
+ usage: accumulated_usage,
92
+ stop_reason: @stop_reason,
93
+ model: @model_id || model
81
94
  )
82
95
  end
83
96
 
84
- def to_message(response)
85
- flush_pending_untagged_preamble
97
+ private
86
98
 
87
- if content.length < 50
88
- log.debug '[llm][stream_accumulator] action=short_content_debug ' \
89
- "content=#{content.inspect} thinking_chars=#{@thinking_text.length} " \
90
- "tool_calls=#{tool_calls.size} " \
91
- "inside_think_tag=#{@inside_think_tag} " \
92
- "pending_think_tag=#{@pending_think_tag.inspect} " \
93
- "untagged_preamble_pending=#{@untagged_preamble_pending} " \
94
- "thinking_start=#{@thinking_text[0, 200].inspect} " \
95
- "thinking_end=#{@thinking_text[-200..].inspect}"
96
- if content.length < 5 && @thinking_text.length > 100 && tool_calls.empty?
97
- log.debug '[llm][stream_accumulator] action=POSSIBLE_CONTENT_EATEN ' \
98
- "full_thinking=#{@thinking_text.inspect}"
99
- end
99
+ def add_text_delta(chunk)
100
+ emitted = []
101
+ content_chunk, thinking_chunk = extract_think_tags(chunk.delta.to_s)
102
+ content_chunk, untagged_thinking = extract_untagged_preamble(content_chunk)
103
+ thinking_total = [untagged_thinking, thinking_chunk].compact.join
104
+ thinking_total = nil if thinking_total.empty?
105
+ if untagged_thinking
106
+ log.debug '[llm][stream_accumulator] action=untagged_thinking_from_chunk ' \
107
+ "content_kept=#{content_chunk[0, 50].inspect} " \
108
+ "untagged_thinking=#{untagged_thinking[0, 100].inspect} " \
109
+ "inside_think_tag=#{@inside_think_tag}"
100
110
  end
101
111
 
102
- Message.new(
103
- role: :assistant,
104
- content: content.empty? ? nil : content,
105
- thinking: Thinking.build(
106
- text: @thinking_text.empty? ? nil : @thinking_text,
107
- signature: @thinking_signature
108
- ),
109
- tokens: Tokens.build(
110
- input: @input_tokens,
111
- output: @output_tokens,
112
- cached: @cached_tokens,
113
- cache_creation: @cache_creation_tokens,
114
- thinking: @thinking_tokens
115
- ),
116
- model_id: model_id,
117
- tool_calls: tool_calls_from_stream,
118
- stop_reason: @stop_reason,
119
- raw: response
112
+ @content << content_chunk
113
+ @thinking_text << thinking_total if thinking_total
114
+ emitted << thinking_delta_for(thinking_total) if thinking_total
115
+ emitted << text_delta_for(content_chunk) unless content_chunk.empty?
116
+ emitted
117
+ end
118
+
119
+ def add_thinking_delta(chunk)
120
+ thinking_text = chunk.delta.to_s
121
+ @thinking_text << thinking_text
122
+ @thinking_signature ||= chunk.signature
123
+ thinking_text.empty? ? [] : [chunk]
124
+ end
125
+
126
+ def add_tool_call_delta(chunk)
127
+ accumulate_tool_call_fragment(chunk.tool_call) if chunk.tool_call
128
+ [chunk]
129
+ end
130
+
131
+ def thinking_delta_for(text)
132
+ Canonical::Chunk.thinking_delta(
133
+ delta: text, request_id: @request_id, conversation_id: @conversation_id, exchange_id: @exchange_id,
134
+ signature: @thinking_signature
120
135
  )
121
136
  end
122
137
 
123
- private
138
+ def text_delta_for(text)
139
+ Canonical::Chunk.text_delta(delta: text, request_id: @request_id, conversation_id: @conversation_id, exchange_id: @exchange_id)
140
+ end
124
141
 
125
- def tool_calls_from_stream
126
- tool_calls.transform_values do |tc|
127
- arguments = parse_accumulated_arguments(tc.arguments)
142
+ def accumulated_thinking
143
+ return nil if @thinking_text.empty?
128
144
 
129
- ToolCall.new(
130
- id: tc.id,
131
- name: tc.name,
132
- arguments: arguments,
133
- thought_signature: tc.thought_signature
145
+ Canonical::Thinking.build(content: @thinking_text, signature: @thinking_signature)
146
+ end
147
+
148
+ def accumulated_tool_calls
149
+ @tool_calls.values.map do |fragment|
150
+ # The thought signature (provider dialect, e.g. Gemini) has no
151
+ # canonical ToolCall member — it travels in metadata, never dropped.
152
+ Canonical::ToolCall.build(
153
+ name: fragment[:name].to_s,
154
+ id: fragment[:id],
155
+ arguments: Responses::ToolArguments.parse!(fragment[:arguments]),
156
+ metadata: fragment[:signature] ? { signature: fragment[:signature] } : {}
134
157
  )
135
158
  end
136
159
  end
137
160
 
138
- def parse_accumulated_arguments(arguments)
139
- return arguments unless arguments.is_a?(String)
140
- return {} if arguments.empty?
161
+ def accumulated_usage
162
+ return nil if @usage.empty?
141
163
 
142
- Legion::JSON.parse(arguments, symbolize_names: false)
143
- rescue Legion::JSON::ParseError => e
144
- handle_exception(e, level: :warn, handled: true, operation: 'llm.stream.parse_tool_arguments')
145
- {}
164
+ Canonical::Usage.build(**@usage)
146
165
  end
147
166
 
148
- def accumulate_tool_calls(new_tool_calls)
149
- log.debug { "Accumulating tool calls: #{new_tool_calls}" } if Legion::Extensions::Llm.config.log_stream_debug
150
- new_tool_calls.each_value do |tool_call|
151
- if tool_call.id
152
- start_tool_call(tool_call)
153
- elsif tool_call.name && @latest_tool_call_id.nil?
154
- start_tool_call_without_id(tool_call)
155
- else
156
- append_tool_call_fragment(tool_call)
157
- end
167
+ def track_usage(usage)
168
+ @usage[:input_tokens] = usage.input_tokens if usage.input_tokens
169
+ @usage[:output_tokens] = usage.output_tokens if usage.output_tokens
170
+ @usage[:cache_read_tokens] = usage.cache_read_tokens if usage.cache_read_tokens
171
+ @usage[:cache_write_tokens] = usage.cache_write_tokens if usage.cache_write_tokens
172
+ @usage[:thinking_tokens] = usage.thinking_tokens if usage.thinking_tokens
173
+ end
174
+
175
+ # Tool-call fragment correlation. The wire law (dead-stop postmortem,
176
+ # documented edge trade-off — keep this comment in sync with the
177
+ # behavior):
178
+ # - a continuation fragment (id=nil, name=nil) lands on the call at
179
+ # ITS provider index; recency (@latest_tool_call_id) is retained
180
+ # only as the fallback for providers that emit no index;
181
+ # - a continuation with an UNKNOWN index and no recency target is
182
+ # DROPPED (the incomplete JSON then fails the strict arguments
183
+ # parser at to_response — a dropped empty-string fragment is the
184
+ # known invisible case);
185
+ # - when the provider omits the tool-call id, a UUID is FABRICATED:
186
+ # the client sees an id the provider never issued, so downstream
187
+ # correlation must not assume provider-issued ids.
188
+ def accumulate_tool_call_fragment(fragment)
189
+ if fragment[:id]
190
+ start_tool_call(fragment)
191
+ elsif fragment[:name] && @latest_tool_call_id.nil?
192
+ start_tool_call_without_id(fragment)
193
+ else
194
+ append_tool_call_fragment(fragment)
158
195
  end
159
196
  end
160
197
 
161
- def start_tool_call(tool_call)
162
- resolved_id = tool_call.id.empty? ? SecureRandom.uuid : tool_call.id
163
- @tool_calls[resolved_id] = ToolCall.new(
198
+ def start_tool_call(fragment)
199
+ raw_id = fragment[:id].to_s
200
+ resolved_id = raw_id.empty? ? SecureRandom.uuid : raw_id
201
+ @tool_calls[resolved_id] = {
164
202
  id: resolved_id,
165
- name: tool_call.name,
166
- arguments: mutable_tool_arguments(tool_call.arguments),
167
- thought_signature: tool_call.thought_signature
168
- )
203
+ name: fragment[:name],
204
+ arguments: +fragment[:arguments].to_s,
205
+ signature: fragment[:signature]
206
+ }
169
207
  @latest_tool_call_id = resolved_id
170
- @index_to_id[tool_call.index] = resolved_id unless tool_call.index.nil?
171
- end
172
-
173
- def mutable_tool_arguments(arguments)
174
- if arguments.nil? || (arguments.respond_to?(:empty?) && arguments.empty?)
175
- +''
176
- elsif arguments.is_a?(String)
177
- +arguments
178
- else
179
- arguments
180
- end
208
+ @index_to_id[fragment[:index]] = resolved_id if fragment[:index]
181
209
  end
182
210
 
183
- def start_tool_call_without_id(tool_call)
211
+ def start_tool_call_without_id(fragment)
184
212
  generated_id = SecureRandom.uuid
185
- @tool_calls[generated_id] = ToolCall.new(
213
+ @tool_calls[generated_id] = {
186
214
  id: generated_id,
187
- name: tool_call.name,
188
- arguments: mutable_tool_arguments(tool_call.arguments),
189
- thought_signature: tool_call.thought_signature
190
- )
215
+ name: fragment[:name],
216
+ arguments: +fragment[:arguments].to_s,
217
+ signature: fragment[:signature]
218
+ }
191
219
  @latest_tool_call_id = generated_id
192
- @index_to_id[tool_call.index] = generated_id unless tool_call.index.nil?
220
+ @index_to_id[fragment[:index]] = generated_id if fragment[:index]
193
221
  end
194
222
 
195
- # Correlate a continuation fragment (id=nil, name=nil) to its call by the
196
- # provider's authoritative wire index. Interleaved parallel calls
197
- # (opener0, opener1, frag0, frag1, ...) only reassemble correctly when
198
- # each fragment lands on the call at ITS index; recency (@latest_tool_call_id)
199
- # sends every fragment to the last-opened call, losing one call's arguments
200
- # and contaminating the other. Recency is retained only as the fallback for
201
- # providers that emit no index.
202
- def append_tool_call_fragment(tool_call)
203
- target_id = @index_to_id[tool_call.index] if tool_call.index
223
+ def append_tool_call_fragment(fragment)
224
+ target_id = @index_to_id[fragment[:index]] if fragment[:index]
204
225
  target_id ||= @latest_tool_call_id
205
226
  existing = @tool_calls[target_id]
206
227
  return unless existing
207
228
 
208
- existing.arguments << tool_call.arguments.to_s
209
- return unless tool_call.thought_signature && existing.thought_signature.nil?
210
-
211
- existing.thought_signature = tool_call.thought_signature
212
- end
213
-
214
- def count_tokens(chunk)
215
- @input_tokens = chunk.input_tokens if chunk.input_tokens
216
- @output_tokens = chunk.output_tokens if chunk.output_tokens
217
- @cached_tokens = chunk.cached_tokens if chunk.cached_tokens
218
- @cache_creation_tokens = chunk.cache_creation_tokens if chunk.cache_creation_tokens
219
- @thinking_tokens = chunk.thinking_tokens if chunk.thinking_tokens
220
- end
221
-
222
- def handle_chunk_content(chunk)
223
- return accumulate_tool_calls(chunk.tool_calls) if chunk.tool_call?
224
-
225
- content_text = chunk.content || ''
226
- if content_text.is_a?(String)
227
- append_text_with_thinking(content_text)
228
- else
229
- @content << content_text.to_s
230
- end
231
- end
232
-
233
- def append_text_with_thinking(text)
234
- content_chunk, thinking_chunk = extract_think_tags(text)
235
- if @content.empty? && !content_chunk.empty?
236
- log.debug '[llm][stream_accumulator] action=first_content_after_extract ' \
237
- "content_chunk_start=#{content_chunk[0, 50].inspect} " \
238
- "thinking_chunk_present=#{!thinking_chunk.nil?} " \
239
- "thinking_text_so_far=#{@thinking_text.length} " \
240
- "raw_text_start=#{text[0, 50].inspect}"
241
- end
242
- content_chunk, untagged_thinking = extract_untagged_preamble(content_chunk)
243
- @content << content_chunk
244
- @last_content_delta << content_chunk
245
- if untagged_thinking
246
- log.debug '[llm][stream_accumulator] action=untagged_thinking_from_chunk ' \
247
- "content_kept=#{content_chunk[0, 50].inspect} " \
248
- "untagged_thinking=#{untagged_thinking[0, 100].inspect} " \
249
- "inside_think_tag=#{@inside_think_tag}"
250
- @thinking_text << untagged_thinking
251
- @last_thinking_delta << untagged_thinking
252
- end
253
- return unless thinking_chunk
254
-
255
- @thinking_text << thinking_chunk
256
- @last_thinking_delta << thinking_chunk
229
+ existing[:arguments] << fragment[:arguments].to_s
230
+ existing[:signature] ||= fragment[:signature]
257
231
  end
258
232
 
259
233
  def extract_untagged_preamble(content_chunk)
@@ -286,52 +260,8 @@ module Legion
286
260
  [content, thinking]
287
261
  end
288
262
 
289
- def flush_pending_untagged_preamble
290
- return if @untagged_preamble_buffer.empty?
291
-
292
- content, thinking = Responses::ThinkingExtractor.extract_untagged_preamble(@untagged_preamble_buffer)
293
- if thinking
294
- log.debug '[llm][stream_accumulator] action=untagged_preamble_classified_as_thinking ' \
295
- "buffer=#{@untagged_preamble_buffer[0, 100].inspect} " \
296
- "content_kept=#{content[0, 50].inspect} thinking_extracted=#{thinking[0, 100].inspect}"
297
- @content << content
298
- @thinking_text << thinking
299
- else
300
- @content << @untagged_preamble_buffer
301
- end
302
- @untagged_preamble_buffer = +''
303
- @untagged_preamble_pending = false
304
- end
305
-
306
- # Same as flush_pending_untagged_preamble, but also records the flushed
307
- # text in the per-chunk delta accumulators so flush_pending_chunk can
308
- # surface it to the streaming block.
309
- def flush_pending_untagged_preamble_into_deltas
310
- content, thinking = Responses::ThinkingExtractor.extract_untagged_preamble(@untagged_preamble_buffer)
311
- if thinking
312
- @content << content
313
- @last_content_delta << content
314
- @thinking_text << thinking
315
- @last_thinking_delta << thinking
316
- else
317
- @content << @untagged_preamble_buffer
318
- @last_content_delta << @untagged_preamble_buffer
319
- end
320
- @untagged_preamble_buffer = +''
321
- @untagged_preamble_pending = false
322
- end
323
-
324
- def append_thinking_from_chunk(chunk)
325
- thinking = chunk.thinking
326
- return unless thinking
327
-
328
- if thinking.text
329
- @thinking_text << thinking.text.to_s
330
- @last_thinking_delta << thinking.text.to_s
331
- end
332
- @thinking_signature ||= thinking.signature # rubocop:disable Naming/MemoizedInstanceVariableName
333
- end
334
-
263
+ # The stateful think-tag split — cross-chunk buffering only; the
264
+ # tag-matching/segment logic is the shared ThinkingExtractor core (U1).
335
265
  def extract_think_tags(text)
336
266
  remaining = @pending_think_tag + text
337
267
  @pending_think_tag = +''
@@ -350,7 +280,7 @@ module Legion
350
280
  remaining = if @inside_think_tag
351
281
  consume_think_content(remaining, @active_think_close_tag, thinking)
352
282
  else
353
- consume_non_think_content(remaining, output)
283
+ consume_non_think_content(remaining, output, thinking)
354
284
  end
355
285
  end
356
286
 
@@ -365,12 +295,11 @@ module Legion
365
295
  @active_think_close_tag = nil
366
296
  remaining.slice((end_index + end_tag.length)..) || +''
367
297
  else
368
- consumed = remaining.slice(0, remaining.length - longest_suffix_prefix(remaining, [end_tag]))
369
- if @content.length.positive? && consumed.length > 20
298
+ if @content.length.positive? && remaining.length > 20
370
299
  log.debug '[llm][stream_accumulator] action=think_consuming_without_close ' \
371
- "end_tag=#{end_tag.inspect} consumed_chars=#{consumed.length} " \
372
- "consumed_start=#{consumed[0, 80].inspect} " \
373
- "total_thinking=#{thinking.length + consumed.length}"
300
+ "end_tag=#{end_tag.inspect} consumed_chars=#{remaining.length} " \
301
+ "consumed_start=#{remaining[0, 80].inspect} " \
302
+ "total_thinking=#{thinking.length + remaining.length}"
374
303
  end
375
304
  suffix_len = longest_suffix_prefix(remaining, [end_tag])
376
305
  thinking << remaining.slice(0, remaining.length - suffix_len)
@@ -379,11 +308,13 @@ module Legion
379
308
  end
380
309
  end
381
310
 
382
- def consume_non_think_content(remaining, output)
383
- unmatched_close = next_stream_tag_match(remaining, :close)
384
- start_match = next_stream_tag_match(remaining, :open)
311
+ def consume_non_think_content(remaining, output, thinking)
312
+ unmatched_close = Responses::ThinkingExtractor.next_tag_match(remaining, :close)
313
+ start_match = Responses::ThinkingExtractor.next_tag_match(remaining, :open)
385
314
  if unmatched_close && (start_match.nil? || unmatched_close[:index] < start_match[:index])
386
- consume_unmatched_think_close(remaining, unmatched_close)
315
+ rest, eaten = consume_unmatched_think_close(remaining, unmatched_close)
316
+ thinking << eaten
317
+ rest
387
318
  elsif start_match
388
319
  if @content.length > 10 || output.length > 10
389
320
  log.debug '[llm][stream_accumulator] action=think_tag_opened_mid_content ' \
@@ -396,7 +327,7 @@ module Legion
396
327
  @active_think_close_tag = start_match[:close_tag]
397
328
  remaining.slice((start_match[:index] + start_match[:tag].length)..) || +''
398
329
  else
399
- suffix_len = longest_suffix_prefix(remaining, stream_tag_tokens)
330
+ suffix_len = longest_suffix_prefix(remaining, Responses::ThinkingExtractor.tag_tokens)
400
331
  output << remaining.slice(0, remaining.length - suffix_len)
401
332
  @pending_think_tag = remaining.slice(-suffix_len, suffix_len)
402
333
  +''
@@ -404,31 +335,16 @@ module Legion
404
335
  end
405
336
 
406
337
  def consume_unmatched_think_close(remaining, close_match)
407
- thinking = remaining.slice(0, close_match[:index])
408
- if thinking.length > 5
338
+ eaten = remaining.slice(0, close_match[:index])
339
+ if eaten.length > 5
409
340
  log.debug '[llm][stream_accumulator] action=unmatched_close_eating_content ' \
410
341
  "close_tag=#{close_match[:tag].inspect} " \
411
- "eaten_chars=#{thinking.length} " \
412
- "eaten_start=#{thinking[0, 80].inspect} " \
342
+ "eaten_chars=#{eaten.length} " \
343
+ "eaten_start=#{eaten[0, 80].inspect} " \
413
344
  "inside_think_tag=#{@inside_think_tag} " \
414
345
  "content_so_far=#{@content.length}"
415
346
  end
416
- @thinking_text << thinking
417
- @last_thinking_delta << thinking
418
- remaining.slice((close_match[:index] + close_match[:tag].length)..).to_s.sub(/\A[[:space:]]+/, '')
419
- end
420
-
421
- def next_stream_tag_match(text, type)
422
- matches = Responses::ThinkingExtractor::THINK_TAG_PAIRS.filter_map do |open_tag, close_tag|
423
- tag = type == :open ? open_tag : close_tag
424
- index = text.index(tag)
425
- { index: index, tag: tag, close_tag: close_tag } if index
426
- end
427
- matches.min_by { |match| match[:index] }
428
- end
429
-
430
- def stream_tag_tokens
431
- Responses::ThinkingExtractor::THINK_TAG_PAIRS.flat_map { |open_tag, close_tag| [open_tag, close_tag] }
347
+ [remaining.slice((close_match[:index] + close_match[:tag].length)..).to_s.sub(/\A[[:space:]]+/, ''), eaten]
432
348
  end
433
349
 
434
350
  def longest_suffix_prefix(text, tags)