lex-llm 0.6.13 → 0.6.15
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 +15 -0
- data/lib/legion/extensions/llm/connection.rb +7 -0
- data/lib/legion/extensions/llm/provider.rb +5 -0
- data/lib/legion/extensions/llm/stream_accumulator.rb +13 -9
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/spec/legion/extensions/llm/connection_close_spec.rb +50 -0
- data/spec/legion/extensions/llm/provider_spec.rb +25 -0
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +56 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07f6e605230a187e17667abdf947db738dab8b823783cde124766278159c49f1
|
|
4
|
+
data.tar.gz: e1858e04a213ed78175c8647c74f05a5c300db4a8a54caadaba523cb3a1e71ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 10216387247f307ee05fe4e06a199cbf6cc762f6869636b16043adb18e1fdae355127f99e433e67614580e3c85b3d18b9d6199291d2703167184ee93e8810745
|
|
7
|
+
data.tar.gz: f010c914eddd807fc13593692a6fc20353ea1c12487838eed859493088b908a64a6459ee1866d5ffaaa9e4b3924e521b8ed3e79fce9e94fe27364283fb7be62e
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.15 - 2026-07-31
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- **Remove dead `find_tool_call` method from StreamAccumulator.** Zero callers anywhere in lex-llm (confirmed via grep). The method referenced an undefined ivar `@latest_tool_call` (the actual ivar is `@latest_tool_call_id`) — it was broken dead code that could only ever return nil.
|
|
7
|
+
- **StreamAccumulator no longer drops tool_call opening fragments that arrive with `id: nil`.** When a tool_call chunk has no `id` but carries a `name` (indicating it's an opening fragment, not a continuation), the accumulator now generates a UUID and starts the call instead of silently dropping it via `append_tool_call_fragment`. This is defensive hardening — normal OpenAI-compatible streaming always assigns an id to the opening fragment, but non-conforming backends or edge cases could produce id-less openers. Existing id-based correlation is unchanged.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Contract specs asserting streaming `stop_reason` propagates through the StreamAccumulator.** Three regression specs encoding the accumulator's stop_reason behavior: single stop_reason captured from a chunk and propagated to the assembled Message, last-wins semantics when multiple chunks carry non-nil stop_reason, and nil-when-absent (no chunks carry stop_reason). This is the lex-llm half of the cross-gem streaming finish_reason fix — paired with lex-llm-vllm v0.3.15 (#16) which wires `stop_reason: canonical.stop_reason` through `to_legacy_chunk`.
|
|
11
|
+
|
|
12
|
+
## 0.6.14 - 2026-07-31
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- **Connection#close tears down the underlying Faraday connection.** Previously `Connection` had no lifecycle method — persistent HTTP connections were never explicitly closed, causing CLOSE_WAIT socket accumulation when provider peers sent FIN (e.g. during daemon shutdown or provider restarts)
|
|
16
|
+
- **Provider#disconnect closes the connection and clears the reference.** Callers (legion-llm registry shutdown) can now explicitly tear down provider connections during graceful shutdown
|
|
17
|
+
|
|
3
18
|
## 0.6.13 - 2026-07-24
|
|
4
19
|
|
|
5
20
|
### Fixed
|
|
@@ -149,6 +149,8 @@ module Legion
|
|
|
149
149
|
new_tool_calls.each_value do |tool_call|
|
|
150
150
|
if tool_call.id
|
|
151
151
|
start_tool_call(tool_call)
|
|
152
|
+
elsif tool_call.name && @latest_tool_call_id.nil?
|
|
153
|
+
start_tool_call_without_id(tool_call)
|
|
152
154
|
else
|
|
153
155
|
append_tool_call_fragment(tool_call)
|
|
154
156
|
end
|
|
@@ -175,6 +177,17 @@ module Legion
|
|
|
175
177
|
end
|
|
176
178
|
end
|
|
177
179
|
|
|
180
|
+
def start_tool_call_without_id(tool_call)
|
|
181
|
+
generated_id = SecureRandom.uuid
|
|
182
|
+
@tool_calls[generated_id] = ToolCall.new(
|
|
183
|
+
id: generated_id,
|
|
184
|
+
name: tool_call.name,
|
|
185
|
+
arguments: mutable_tool_arguments(tool_call.arguments),
|
|
186
|
+
thought_signature: tool_call.thought_signature
|
|
187
|
+
)
|
|
188
|
+
@latest_tool_call_id = generated_id
|
|
189
|
+
end
|
|
190
|
+
|
|
178
191
|
def append_tool_call_fragment(tool_call)
|
|
179
192
|
existing = @tool_calls[@latest_tool_call_id]
|
|
180
193
|
return unless existing
|
|
@@ -185,15 +198,6 @@ module Legion
|
|
|
185
198
|
existing.thought_signature = tool_call.thought_signature
|
|
186
199
|
end
|
|
187
200
|
|
|
188
|
-
def find_tool_call(tool_call_id)
|
|
189
|
-
if tool_call_id.nil?
|
|
190
|
-
@tool_calls[@latest_tool_call]
|
|
191
|
-
else
|
|
192
|
-
@latest_tool_call_id = tool_call_id
|
|
193
|
-
@tool_calls[tool_call_id]
|
|
194
|
-
end
|
|
195
|
-
end
|
|
196
|
-
|
|
197
201
|
def count_tokens(chunk)
|
|
198
202
|
@input_tokens = chunk.input_tokens if chunk.input_tokens
|
|
199
203
|
@output_tokens = chunk.output_tokens if chunk.output_tokens
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Connection do
|
|
6
|
+
describe '#close' do
|
|
7
|
+
let(:provider) do
|
|
8
|
+
instance_double(
|
|
9
|
+
Legion::Extensions::Llm::Provider,
|
|
10
|
+
api_base: 'https://example.com',
|
|
11
|
+
configured?: true,
|
|
12
|
+
headers: {}
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
let(:config) do
|
|
17
|
+
instance_double(
|
|
18
|
+
Legion::Extensions::Llm::Configuration,
|
|
19
|
+
request_timeout: 300,
|
|
20
|
+
max_retries: 3,
|
|
21
|
+
retry_interval: 0.1,
|
|
22
|
+
retry_interval_randomness: 0.5,
|
|
23
|
+
retry_backoff_factor: 2,
|
|
24
|
+
http_proxy: nil,
|
|
25
|
+
log_regexp_timeout: nil
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'nils out the underlying Faraday connection' do
|
|
30
|
+
conn = described_class.new(provider, config)
|
|
31
|
+
expect(conn.connection).not_to be_nil
|
|
32
|
+
conn.close
|
|
33
|
+
expect(conn.connection).to be_nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'is safe to call multiple times' do
|
|
37
|
+
conn = described_class.new(provider, config)
|
|
38
|
+
conn.close
|
|
39
|
+
expect { conn.close }.not_to raise_error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'calls close on the Faraday connection when supported' do
|
|
43
|
+
conn = described_class.new(provider, config)
|
|
44
|
+
faraday = conn.connection
|
|
45
|
+
allow(faraday).to receive(:close)
|
|
46
|
+
conn.close
|
|
47
|
+
expect(faraday).to have_received(:close)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -717,4 +717,29 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
717
717
|
expect(key_v3).to include('schema3')
|
|
718
718
|
end
|
|
719
719
|
end
|
|
720
|
+
|
|
721
|
+
describe '#disconnect' do
|
|
722
|
+
let(:provider_class) do
|
|
723
|
+
Class.new(described_class) do
|
|
724
|
+
def api_base = 'https://test.invalid'
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
let(:provider) do
|
|
729
|
+
provider_class.new({ request_timeout: 60, max_retries: 0,
|
|
730
|
+
retry_interval: 0, retry_backoff_factor: 0,
|
|
731
|
+
retry_interval_randomness: 0 })
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
it 'closes the connection and nils the reference' do
|
|
735
|
+
expect(provider.connection).not_to be_nil
|
|
736
|
+
provider.disconnect
|
|
737
|
+
expect(provider.connection).to be_nil
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
it 'is safe to call multiple times' do
|
|
741
|
+
provider.disconnect
|
|
742
|
+
expect { provider.disconnect }.not_to raise_error
|
|
743
|
+
end
|
|
744
|
+
end
|
|
720
745
|
end
|
|
@@ -99,6 +99,62 @@ RSpec.describe Legion::Extensions::Llm::StreamAccumulator do
|
|
|
99
99
|
expect(message.content).to eq(expected)
|
|
100
100
|
expect(message.thinking).to be_nil
|
|
101
101
|
end
|
|
102
|
+
|
|
103
|
+
it 'captures stop_reason from a chunk and propagates it to the assembled Message' do
|
|
104
|
+
accumulator = described_class.new
|
|
105
|
+
chunk = Legion::Extensions::Llm::Chunk.new(
|
|
106
|
+
role: :assistant, content: 'partial', model_id: 'm', stop_reason: :max_tokens
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
accumulator.add(chunk)
|
|
110
|
+
expect(accumulator.stop_reason).to eq(:max_tokens)
|
|
111
|
+
|
|
112
|
+
message = accumulator.to_message(nil)
|
|
113
|
+
expect(message.stop_reason).to eq(:max_tokens)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'uses the last non-nil stop_reason when multiple chunks carry one' do
|
|
117
|
+
accumulator = described_class.new
|
|
118
|
+
accumulator.add(Legion::Extensions::Llm::Chunk.new(
|
|
119
|
+
role: :assistant, content: 'hello', model_id: 'm', stop_reason: :end_turn
|
|
120
|
+
))
|
|
121
|
+
accumulator.add(Legion::Extensions::Llm::Chunk.new(
|
|
122
|
+
role: :assistant, content: nil, model_id: 'm', stop_reason: :max_tokens
|
|
123
|
+
))
|
|
124
|
+
|
|
125
|
+
expect(accumulator.stop_reason).to eq(:max_tokens)
|
|
126
|
+
expect(accumulator.to_message(nil).stop_reason).to eq(:max_tokens)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it 'leaves stop_reason nil when no chunk carries one' do
|
|
130
|
+
accumulator = described_class.new
|
|
131
|
+
accumulator.add(Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'hi', model_id: 'm'))
|
|
132
|
+
|
|
133
|
+
expect(accumulator.stop_reason).to be_nil
|
|
134
|
+
expect(accumulator.to_message(nil).stop_reason).to be_nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'starts a tool call from an opening fragment with id nil but name present' do
|
|
138
|
+
accumulator = described_class.new
|
|
139
|
+
tool_call = Legion::Extensions::Llm::ToolCall.new(id: nil, name: 'weather', arguments: '')
|
|
140
|
+
chunk = Legion::Extensions::Llm::Chunk.new(
|
|
141
|
+
role: :assistant, content: nil, tool_calls: { weather: tool_call }
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
accumulator.add(chunk)
|
|
145
|
+
|
|
146
|
+
frag_call = Legion::Extensions::Llm::ToolCall.new(id: nil, name: nil, arguments: '{"city":"SF"}')
|
|
147
|
+
frag_chunk = Legion::Extensions::Llm::Chunk.new(
|
|
148
|
+
role: :assistant, content: nil, tool_calls: { fragment: frag_call }
|
|
149
|
+
)
|
|
150
|
+
accumulator.add(frag_chunk)
|
|
151
|
+
|
|
152
|
+
message = accumulator.to_message(nil)
|
|
153
|
+
expect(message.tool_calls.size).to eq(1)
|
|
154
|
+
tc = message.tool_calls.values.first
|
|
155
|
+
expect(tc.name).to eq('weather')
|
|
156
|
+
expect(tc.arguments).to eq({ 'city' => 'SF' })
|
|
157
|
+
end
|
|
102
158
|
end
|
|
103
159
|
|
|
104
160
|
describe '#filtered_chunk' do
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LegionIO
|
|
@@ -395,6 +395,7 @@ files:
|
|
|
395
395
|
- spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json
|
|
396
396
|
- spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb
|
|
397
397
|
- spec/legion/extensions/llm/conformance/provider_translator_examples.rb
|
|
398
|
+
- spec/legion/extensions/llm/connection_close_spec.rb
|
|
398
399
|
- spec/legion/extensions/llm/connection_logging_spec.rb
|
|
399
400
|
- spec/legion/extensions/llm/connection_retry_spec.rb
|
|
400
401
|
- spec/legion/extensions/llm/context_spec.rb
|