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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +6 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +29 -17
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +10 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +98 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- 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
|
|
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 :
|
|
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
|
-
@
|
|
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.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
#
|
|
64
|
-
#
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
85
|
-
flush_pending_untagged_preamble
|
|
97
|
+
private
|
|
86
98
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
"
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
arguments = parse_accumulated_arguments(tc.arguments)
|
|
142
|
+
def accumulated_thinking
|
|
143
|
+
return nil if @thinking_text.empty?
|
|
128
144
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
139
|
-
return
|
|
140
|
-
return {} if arguments.empty?
|
|
161
|
+
def accumulated_usage
|
|
162
|
+
return nil if @usage.empty?
|
|
141
163
|
|
|
142
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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(
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
166
|
-
arguments:
|
|
167
|
-
|
|
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[
|
|
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(
|
|
211
|
+
def start_tool_call_without_id(fragment)
|
|
184
212
|
generated_id = SecureRandom.uuid
|
|
185
|
-
@tool_calls[generated_id] =
|
|
213
|
+
@tool_calls[generated_id] = {
|
|
186
214
|
id: generated_id,
|
|
187
|
-
name:
|
|
188
|
-
arguments:
|
|
189
|
-
|
|
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[
|
|
220
|
+
@index_to_id[fragment[:index]] = generated_id if fragment[:index]
|
|
193
221
|
end
|
|
194
222
|
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
209
|
-
|
|
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
|
-
|
|
290
|
-
|
|
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
|
-
|
|
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=#{
|
|
372
|
-
"consumed_start=#{
|
|
373
|
-
"total_thinking=#{thinking.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 =
|
|
384
|
-
start_match =
|
|
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,
|
|
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
|
-
|
|
408
|
-
if
|
|
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=#{
|
|
412
|
-
"eaten_start=#{
|
|
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
|
-
|
|
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)
|