lex-llm 0.6.12 → 0.6.14

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: 2fcdfa125fb151f6cac7d0468881476c99b8145738282c20e52d630a7b739702
4
- data.tar.gz: 57c2ded197c24f6d05c0227d3bbdd9d8ea843e04df69c5fa4fa1b33b934ad0d7
3
+ metadata.gz: b28d2162e2284642dcbb0e39384957962b8b609aa982c2dc1c085f84f560243f
4
+ data.tar.gz: a68c5e7d429b6ba04c2f431a4140d44c93542993430fe5b36e921347b1d9a728
5
5
  SHA512:
6
- metadata.gz: cf38e525d5124d5e17a063ff88e7a14d083830f326b90ce9212e8558d8106e934afd456bba8d37a34c3f822f66f7ad772f34300706006d909619063f36a13199
7
- data.tar.gz: a98036763e921140f0a1e32e66640826de90b3c211ec7e2fc19aa5bbd2496015996eac6c29798fa2ef073ff9f276ec58bc5b581c4edf019aa8402f75e5c6273a
6
+ metadata.gz: 4a76155cd1c0b1ac8952647afff1ed38728bdb130a241ec4b416b752fd19c07074db18cf787c9dca6f2e4ffe1afe3be144800093837add92311e68a63219593b
7
+ data.tar.gz: 9eeadc0a087bb111ccaaea7feffc55aae57b44a0f60f331b3f7e0b7a43ed75737b70338319b6d1b8aa62de668a41b04b605444a9989e7776b2d315debd59ca38
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.14 - 2026-07-31
4
+
5
+ ### Added
6
+ - **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)
7
+ - **Provider#disconnect closes the connection and clears the reference.** Callers (legion-llm registry shutdown) can now explicitly tear down provider connections during graceful shutdown
8
+
9
+ ## 0.6.13 - 2026-07-24
10
+
11
+ ### Fixed
12
+ - **StreamAccumulator now captures `stop_reason` from provider done chunks.** Previously, the real `finish_reason` from the provider was silently discarded — the accumulator had no field for it, and the legacy `Message` class couldn't carry it. Downstream code always synthesized `:end_turn` regardless of what the provider actually said. Now `stop_reason` propagates through accumulator → Message → `normalize_response`.
13
+ - **Canonical::Chunk factory methods accept `stop_reason:` and `usage:` kwargs.** `text_delta`, `thinking_delta`, and `tool_call_delta` factories now pass through stop_reason and usage when present on the SSE event, enabling translators to propagate finish_reason on non-empty chunks without buffering.
14
+ - **Streaming handler iterates array results from `build_chunk`.** When a translator returns multiple chunks from a single SSE event (e.g. both thinking and content on the same delta), the handler now yields each one to the accumulator instead of expecting a single chunk. Prevents content loss at thinking/content boundaries.
15
+
3
16
  ## 0.6.12 - 2026-07-15
4
17
 
5
18
  ### Fixed
@@ -16,39 +16,42 @@ module Legion
16
16
  ) do
17
17
  # Build a text delta chunk.
18
18
  def self.text_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
19
- index: 0, block_index: nil, item_id: nil)
19
+ index: 0, block_index: nil, item_id: nil,
20
+ stop_reason: nil, usage: nil)
20
21
  new(
21
22
  type: :text_delta, delta: delta, index: index,
22
23
  request_id: request_id, conversation_id: conversation_id,
23
24
  exchange_id: exchange_id, block_index: block_index,
24
25
  item_id: item_id, tool_call: nil, signature: nil,
25
- usage: nil, stop_reason: nil, metadata: {},
26
+ usage: usage, stop_reason: stop_reason, metadata: {},
26
27
  timestamp: ::Time.now
27
28
  )
28
29
  end
29
30
 
30
31
  # Build a thinking delta chunk.
31
32
  def self.thinking_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
32
- index: 0, block_index: nil, item_id: nil, signature: nil)
33
+ index: 0, block_index: nil, item_id: nil, signature: nil,
34
+ stop_reason: nil, usage: nil)
33
35
  new(
34
36
  type: :thinking_delta, delta: delta, index: index,
35
37
  request_id: request_id, conversation_id: conversation_id,
36
38
  exchange_id: exchange_id, block_index: block_index,
37
39
  item_id: item_id, tool_call: nil, signature: signature,
38
- usage: nil, stop_reason: nil, metadata: {},
40
+ usage: usage, stop_reason: stop_reason, metadata: {},
39
41
  timestamp: ::Time.now
40
42
  )
41
43
  end
42
44
 
43
45
  # Build a tool_call_delta chunk (supports multiple in-flight tool calls via tool_call.id).
44
46
  def self.tool_call_delta(tool_call:, request_id:, conversation_id: nil, exchange_id: nil,
45
- index: 0, block_index: nil, item_id: nil)
47
+ index: 0, block_index: nil, item_id: nil,
48
+ stop_reason: nil, usage: nil)
46
49
  new(
47
50
  type: :tool_call_delta, index: index,
48
51
  request_id: request_id, conversation_id: conversation_id,
49
52
  exchange_id: exchange_id, block_index: block_index,
50
53
  item_id: item_id, delta: nil, tool_call: tool_call, signature: nil,
51
- usage: nil, stop_reason: nil, metadata: {},
54
+ usage: usage, stop_reason: stop_reason, metadata: {},
52
55
  timestamp: ::Time.now
53
56
  )
54
57
  end
@@ -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
@@ -7,7 +7,7 @@ module Legion
7
7
  class Message
8
8
  ROLES = %i[system user assistant tool].freeze
9
9
 
10
- attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :thinking, :tokens
10
+ attr_reader :role, :model_id, :tool_calls, :tool_call_id, :raw, :thinking, :tokens, :stop_reason
11
11
  attr_writer :content
12
12
 
13
13
  def initialize(options = {})
@@ -26,6 +26,7 @@ module Legion
26
26
  )
27
27
  @raw = options[:raw]
28
28
  @thinking = options[:thinking]
29
+ @stop_reason = options[:stop_reason]
29
30
 
30
31
  ensure_valid_role
31
32
  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
@@ -7,13 +7,14 @@ module Legion
7
7
  class StreamAccumulator
8
8
  include Legion::Logging::Helper
9
9
 
10
- attr_reader :content, :model_id, :tool_calls
10
+ attr_reader :content, :model_id, :tool_calls, :stop_reason
11
11
 
12
12
  def initialize
13
13
  @content = +''
14
14
  @thinking_text = +''
15
15
  @thinking_signature = nil
16
16
  @tool_calls = {}
17
+ @stop_reason = nil
17
18
  @input_tokens = nil
18
19
  @output_tokens = nil
19
20
  @cached_tokens = nil
@@ -36,6 +37,7 @@ module Legion
36
37
  handle_chunk_content(chunk)
37
38
  append_thinking_from_chunk(chunk)
38
39
  count_tokens chunk
40
+ @stop_reason = chunk.stop_reason if chunk.respond_to?(:stop_reason) && chunk.stop_reason
39
41
  log.debug { inspect } if Legion::Extensions::Llm.config.log_stream_debug
40
42
  end
41
43
 
@@ -81,6 +83,21 @@ module Legion
81
83
  def to_message(response)
82
84
  flush_pending_untagged_preamble
83
85
 
86
+ if content.length < 50
87
+ log.debug '[llm][stream_accumulator] action=short_content_debug ' \
88
+ "content=#{content.inspect} thinking_chars=#{@thinking_text.length} " \
89
+ "tool_calls=#{tool_calls.size} " \
90
+ "inside_think_tag=#{@inside_think_tag} " \
91
+ "pending_think_tag=#{@pending_think_tag.inspect} " \
92
+ "untagged_preamble_pending=#{@untagged_preamble_pending} " \
93
+ "thinking_start=#{@thinking_text[0, 200].inspect} " \
94
+ "thinking_end=#{@thinking_text[-200..].inspect}"
95
+ if content.length < 5 && @thinking_text.length > 100 && tool_calls.empty?
96
+ log.debug '[llm][stream_accumulator] action=POSSIBLE_CONTENT_EATEN ' \
97
+ "full_thinking=#{@thinking_text.inspect}"
98
+ end
99
+ end
100
+
84
101
  Message.new(
85
102
  role: :assistant,
86
103
  content: content.empty? ? nil : content,
@@ -97,6 +114,7 @@ module Legion
97
114
  ),
98
115
  model_id: model_id,
99
116
  tool_calls: tool_calls_from_stream,
117
+ stop_reason: @stop_reason,
100
118
  raw: response
101
119
  )
102
120
  end
@@ -197,10 +215,21 @@ module Legion
197
215
 
198
216
  def append_text_with_thinking(text)
199
217
  content_chunk, thinking_chunk = extract_think_tags(text)
218
+ if @content.empty? && !content_chunk.empty?
219
+ log.debug '[llm][stream_accumulator] action=first_content_after_extract ' \
220
+ "content_chunk_start=#{content_chunk[0, 50].inspect} " \
221
+ "thinking_chunk_present=#{!thinking_chunk.nil?} " \
222
+ "thinking_text_so_far=#{@thinking_text.length} " \
223
+ "raw_text_start=#{text[0, 50].inspect}"
224
+ end
200
225
  content_chunk, untagged_thinking = extract_untagged_preamble(content_chunk)
201
226
  @content << content_chunk
202
227
  @last_content_delta << content_chunk
203
228
  if untagged_thinking
229
+ log.debug '[llm][stream_accumulator] action=untagged_thinking_from_chunk ' \
230
+ "content_kept=#{content_chunk[0, 50].inspect} " \
231
+ "untagged_thinking=#{untagged_thinking[0, 100].inspect} " \
232
+ "inside_think_tag=#{@inside_think_tag}"
204
233
  @thinking_text << untagged_thinking
205
234
  @last_thinking_delta << untagged_thinking
206
235
  end
@@ -245,6 +274,9 @@ module Legion
245
274
 
246
275
  content, thinking = Responses::ThinkingExtractor.extract_untagged_preamble(@untagged_preamble_buffer)
247
276
  if thinking
277
+ log.debug '[llm][stream_accumulator] action=untagged_preamble_classified_as_thinking ' \
278
+ "buffer=#{@untagged_preamble_buffer[0, 100].inspect} " \
279
+ "content_kept=#{content[0, 50].inspect} thinking_extracted=#{thinking[0, 100].inspect}"
248
280
  @content << content
249
281
  @thinking_text << thinking
250
282
  else
@@ -290,6 +322,13 @@ module Legion
290
322
  output = +''
291
323
  thinking = +''
292
324
 
325
+ if @inside_think_tag && text.length > 10
326
+ log.debug '[llm][stream_accumulator] action=chunk_arrives_inside_think ' \
327
+ "text_length=#{text.length} text=#{text[0, 200].inspect} " \
328
+ "active_close_tag=#{@active_think_close_tag.inspect} " \
329
+ "content_so_far=#{@content.length} thinking_so_far=#{@thinking_text.length}"
330
+ end
331
+
293
332
  until remaining.empty?
294
333
  remaining = if @inside_think_tag
295
334
  consume_think_content(remaining, @active_think_close_tag, thinking)
@@ -309,6 +348,13 @@ module Legion
309
348
  @active_think_close_tag = nil
310
349
  remaining.slice((end_index + end_tag.length)..) || +''
311
350
  else
351
+ consumed = remaining.slice(0, remaining.length - longest_suffix_prefix(remaining, [end_tag]))
352
+ if @content.length.positive? && consumed.length > 20
353
+ log.debug '[llm][stream_accumulator] action=think_consuming_without_close ' \
354
+ "end_tag=#{end_tag.inspect} consumed_chars=#{consumed.length} " \
355
+ "consumed_start=#{consumed[0, 80].inspect} " \
356
+ "total_thinking=#{thinking.length + consumed.length}"
357
+ end
312
358
  suffix_len = longest_suffix_prefix(remaining, [end_tag])
313
359
  thinking << remaining.slice(0, remaining.length - suffix_len)
314
360
  @pending_think_tag = remaining.slice(-suffix_len, suffix_len)
@@ -322,6 +368,12 @@ module Legion
322
368
  if unmatched_close && (start_match.nil? || unmatched_close[:index] < start_match[:index])
323
369
  consume_unmatched_think_close(remaining, unmatched_close)
324
370
  elsif start_match
371
+ if @content.length > 10 || output.length > 10
372
+ log.debug '[llm][stream_accumulator] action=think_tag_opened_mid_content ' \
373
+ "tag=#{start_match[:tag].inspect} " \
374
+ "content_before_tag=#{remaining.slice(0, start_match[:index])[0, 50].inspect} " \
375
+ "content_accumulated=#{@content.length} output_accumulated=#{output.length}"
376
+ end
325
377
  output << remaining.slice(0, start_match[:index])
326
378
  @inside_think_tag = true
327
379
  @active_think_close_tag = start_match[:close_tag]
@@ -336,6 +388,14 @@ module Legion
336
388
 
337
389
  def consume_unmatched_think_close(remaining, close_match)
338
390
  thinking = remaining.slice(0, close_match[:index])
391
+ if thinking.length > 5
392
+ log.debug '[llm][stream_accumulator] action=unmatched_close_eating_content ' \
393
+ "close_tag=#{close_match[:tag].inspect} " \
394
+ "eaten_chars=#{thinking.length} " \
395
+ "eaten_start=#{thinking[0, 80].inspect} " \
396
+ "inside_think_tag=#{@inside_think_tag} " \
397
+ "content_so_far=#{@content.length}"
398
+ end
339
399
  @thinking_text << thinking
340
400
  @last_thinking_delta << thinking
341
401
  remaining.slice((close_match[:index] + close_match[:tag].length)..).to_s.sub(/\A[[:space:]]+/, '')
@@ -48,8 +48,14 @@ module Legion
48
48
  build_on_data_handler do |data|
49
49
  next unless data.is_a?(Hash)
50
50
 
51
- chunk = build_chunk(data)
52
- block.call(chunk) if chunk
51
+ result = build_chunk(data)
52
+ next unless result
53
+
54
+ if result.is_a?(Array)
55
+ result.each { |chunk| block.call(chunk) if chunk }
56
+ else
57
+ block.call(result)
58
+ end
53
59
  end
54
60
  end
55
61
 
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.6.12'
6
+ VERSION = '0.6.14'
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
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.12
4
+ version: 0.6.14
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