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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0307598766a4f750febc8f0e72b2ebfa344291eb7feb3f134b2f7f1fffcb7620'
4
- data.tar.gz: 713d7789cc022b01300ab4cc75811c43ea297ec817bb2780ce046426328155e9
3
+ metadata.gz: 07f6e605230a187e17667abdf947db738dab8b823783cde124766278159c49f1
4
+ data.tar.gz: e1858e04a213ed78175c8647c74f05a5c300db4a8a54caadaba523cb3a1e71ef
5
5
  SHA512:
6
- metadata.gz: ef02b0c8ac4f2850a2823982d564ea6ca0a9f6e9c5fb23180231080ff2291f22b242848ad700b889fe7a721d555c2a8d10f55b9f057e11ea0f4c67f3348df6a4
7
- data.tar.gz: cb4a22c7c6c8e4f151f09c7cb4f6b6b445a8faa085936344c747d7a90b648620f79673435731f9fe3f311e95b804e16c2af439b577aaa180ad2ae1d20b052721
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
@@ -65,6 +65,13 @@ module Legion
65
65
  end
66
66
  end
67
67
 
68
+ def close
69
+ return unless @connection
70
+
71
+ @connection.close if @connection.respond_to?(:close)
72
+ @connection = nil
73
+ end
74
+
68
75
  def instance_variables
69
76
  super - %i[@config @connection]
70
77
  end
@@ -84,6 +84,11 @@ module Legion
84
84
  @connection = Connection.new(self, @config)
85
85
  end
86
86
 
87
+ def disconnect
88
+ @connection&.close
89
+ @connection = nil
90
+ end
91
+
87
92
  def api_base
88
93
  raise NotImplementedError
89
94
  end
@@ -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
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.6.13'
6
+ VERSION = '0.6.15'
7
7
  end
8
8
  end
9
9
  end
@@ -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.13
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