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
@@ -3,209 +3,239 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe Legion::Extensions::Llm::StreamAccumulator do
6
- describe '#add' do
7
- it 'handles tool call deltas that omit arguments' do
8
- accumulator = described_class.new
9
- tool_call = Legion::Extensions::Llm::ToolCall.new(id: 'call_1', name: 'weather', arguments: nil)
10
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: nil, tool_calls: { 'call_1' => tool_call })
6
+ let(:canonical) { Legion::Extensions::Llm::Canonical }
11
7
 
12
- expect { accumulator.add(chunk) }.not_to raise_error
8
+ def text_chunk(delta)
9
+ canonical::Chunk.text_delta(delta: delta, request_id: 'req-1')
10
+ end
13
11
 
14
- message = accumulator.to_message(nil)
15
- expect(message.tool_calls['call_1'].arguments).to eq({})
16
- end
12
+ def thinking_chunk(delta, signature: nil)
13
+ canonical::Chunk.thinking_delta(delta: delta, request_id: 'req-1', signature:)
14
+ end
17
15
 
18
- it 'drops malformed accumulated tool arguments instead of raising' do
19
- accumulator = described_class.new
20
- tool_call = Legion::Extensions::Llm::ToolCall.new(id: 'call_1', name: 'weather', arguments: '{"city"')
21
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: nil, tool_calls: { 'call_1' => tool_call })
16
+ def tool_fragment(id: nil, name: nil, arguments: '', index: nil, signature: nil)
17
+ canonical::Chunk.tool_call_delta(tool_call: { id:, name:, arguments:, index:, signature: }, request_id: 'req-1')
18
+ end
22
19
 
23
- expect { accumulator.add(chunk) }.not_to raise_error
20
+ describe 'canonical chunk consumption (05 O5)' do
21
+ it 'emits text deltas and accumulates the final text' do
22
+ accumulator = described_class.new
23
+ emitted = accumulator.add(text_chunk('hello '))
24
+ emitted.concat(accumulator.add(text_chunk('world')))
25
+ response = accumulator.to_response(model: 'm')
24
26
 
25
- message = accumulator.to_message(nil)
26
- expect(message.tool_calls['call_1'].arguments).to eq({})
27
+ expect(emitted.map { |c| [c.type, c.delta] }).to eq([[:text_delta, 'hello '], [:text_delta, 'world']])
28
+ expect(response.text).to eq('hello world')
27
29
  end
28
30
 
29
- it 'treats content before an unmatched closing think tag as thinking' do
31
+ it 'emits provider thinking deltas and carries the signature into the response' do
30
32
  accumulator = described_class.new
31
- chunk = Legion::Extensions::Llm::Chunk.new(
32
- role: :assistant,
33
- content: "internal\n</think>\n\nHello"
34
- )
33
+ emitted = accumulator.add(thinking_chunk('hmm ', signature: 'sig-1'))
34
+ response = accumulator.to_response(model: 'm')
35
35
 
36
- accumulator.add(chunk)
37
-
38
- message = accumulator.to_message(nil)
39
- expect(message.content).to eq('Hello')
40
- expect(message.thinking.text).to eq("internal\n")
36
+ expect(emitted.map { |c| [c.type, c.delta] }).to eq([[:thinking_delta, 'hmm ']])
37
+ expect(response.thinking.content).to eq('hmm ')
38
+ expect(response.thinking.signature).to eq('sig-1')
41
39
  end
42
40
 
43
- it 'does not leak streamed thinking tag variants split across chunks' do
41
+ it 'passes tool_call_delta chunks through and accumulates fragments' do
44
42
  accumulator = described_class.new
45
- stream = ['<thinking>', 'internal', '</thinking>Hello']
46
-
47
- filtered = stream.filter_map do |content|
48
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: content)
49
- accumulator.add(chunk)
50
- accumulator.filtered_chunk(chunk)
51
- end
43
+ emitted = accumulator.add(tool_fragment(id: 'call-1', name: 'f', arguments: '{"a"'))
44
+ emitted.concat(accumulator.add(tool_fragment(arguments: ':1}')))
52
45
 
53
- message = accumulator.to_message(nil)
54
- expect(filtered.filter_map(&:content)).to eq(['Hello'])
55
- expect(filtered.filter_map { |chunk| chunk.thinking&.text }.join).to eq('internal')
56
- expect(message.content).to eq('Hello')
57
- expect(message.thinking.text).to eq('internal')
46
+ expect(emitted.first).to be_a(canonical::Chunk)
47
+ expect(emitted.first.type).to eq(:tool_call_delta)
48
+ response = accumulator.to_response(model: 'm')
49
+ expect(response.tool_calls.first.arguments).to eq('a' => 1)
58
50
  end
59
51
 
60
- it 'does not stream untagged local-model reasoning preambles as content' do
52
+ it 'tracks usage from usage chunks into the response' do
61
53
  accumulator = described_class.new
62
- stream = [
63
- 'The user is just saying "test".',
64
- ' Let me respond simply and confirm things are working.',
65
- "\n\nHey! Things are working on my end."
66
- ]
67
-
68
- filtered = stream.filter_map do |content|
69
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: content)
70
- accumulator.add(chunk)
71
- accumulator.filtered_chunk(chunk)
72
- end
54
+ accumulator.add(canonical::Chunk.usage_chunk(
55
+ usage: canonical::Usage.build(input_tokens: 7, output_tokens: 3), request_id: 'req-1'
56
+ ))
57
+ response = accumulator.to_response(model: 'm')
73
58
 
74
- message = accumulator.to_message(nil)
75
- expect(filtered.filter_map(&:content)).to eq(['Hey! Things are working on my end.'])
76
- expect(filtered.filter_map { |chunk| chunk.thinking&.text }.join)
77
- .to eq('The user is just saying "test". Let me respond simply and confirm things are working.')
78
- expect(message.content).to eq('Hey! Things are working on my end.')
79
- expect(message.thinking.text)
80
- .to eq('The user is just saying "test". Let me respond simply and confirm things are working.')
59
+ expect(response.usage.input_tokens).to eq(7)
60
+ expect(response.usage.output_tokens).to eq(3)
81
61
  end
82
62
 
83
- it 'releases normal text that starts like a possible reasoning preamble' do
63
+ it 'keeps the last stop_reason and falls back to the selected model' do
84
64
  accumulator = described_class.new
85
- stream = [
86
- 'The user guide covers setup.',
87
- "\n\nFollow the install section first."
88
- ]
65
+ accumulator.add(canonical::Chunk.text_delta(delta: 'x', request_id: 'r', stop_reason: :end_turn))
66
+ response = accumulator.to_response(model: 'selected-model')
89
67
 
90
- filtered = stream.filter_map do |content|
91
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: content)
92
- accumulator.add(chunk)
93
- accumulator.filtered_chunk(chunk)
94
- end
68
+ expect(response.stop_reason).to eq(:end_turn)
69
+ expect(response.model).to eq('selected-model')
70
+ end
71
+ end
95
72
 
96
- message = accumulator.to_message(nil)
97
- expected = "The user guide covers setup.\n\nFollow the install section first."
98
- expect(filtered.filter_map(&:content).join).to eq(expected)
99
- expect(message.content).to eq(expected)
100
- expect(message.thinking).to be_nil
73
+ describe 'tool-call fragment correlation (wire law: index first, recency fallback)' do
74
+ it 'routes interleaved parallel fragments by wire index' do
75
+ accumulator = described_class.new
76
+ accumulator.add(tool_fragment(id: 'call-0', name: 'a', arguments: '{"x":', index: 0))
77
+ accumulator.add(tool_fragment(id: 'call-1', name: 'b', arguments: '{"y":', index: 1))
78
+ accumulator.add(tool_fragment(arguments: '1}', index: 0))
79
+ accumulator.add(tool_fragment(arguments: '2}', index: 1))
80
+
81
+ tool_calls = accumulator.to_response(model: 'm').tool_calls
82
+ by_name = tool_calls.to_h { |tc| [tc.name, tc] }
83
+ expect(by_name['a'].arguments).to eq('x' => 1)
84
+ expect(by_name['b'].arguments).to eq('y' => 2)
101
85
  end
102
86
 
103
- it 'captures stop_reason from a chunk and propagates it to the assembled Message' do
87
+ it 'falls back to recency when the provider emits no index' do
104
88
  accumulator = described_class.new
105
- chunk = Legion::Extensions::Llm::Chunk.new(
106
- role: :assistant, content: 'partial', model_id: 'm', stop_reason: :max_tokens
107
- )
89
+ accumulator.add(tool_fragment(name: 'only', arguments: '{"a":'))
90
+ accumulator.add(tool_fragment(arguments: '1}'))
108
91
 
109
- accumulator.add(chunk)
110
- expect(accumulator.stop_reason).to eq(:max_tokens)
92
+ response = accumulator.to_response(model: 'm')
93
+ expect(response.tool_calls.size).to eq(1)
94
+ expect(response.tool_calls.first.arguments).to eq('a' => 1)
95
+ end
111
96
 
112
- message = accumulator.to_message(nil)
113
- expect(message.stop_reason).to eq(:max_tokens)
97
+ it 'opens a name-only call only while nothing is in flight; drops fragments with no call' do
98
+ accumulator = described_class.new
99
+ # A bare fragment before any opener is dropped (no call to route to).
100
+ accumulator.add(tool_fragment(arguments: 'null'))
101
+ accumulator.add(tool_fragment(name: 'first', arguments: '{"a":'))
102
+ accumulator.add(tool_fragment(arguments: '1}'))
103
+
104
+ tool_calls = accumulator.to_response(model: 'm').tool_calls
105
+ expect(tool_calls.size).to eq(1)
106
+ expect(tool_calls.first.name).to eq('first')
107
+ expect(tool_calls.first.arguments).to eq('a' => 1)
114
108
  end
115
109
 
116
- it 'uses the last non-nil stop_reason when multiple chunks carry one' do
110
+ it 'generates an id when the wire omits one' do
117
111
  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
- ))
112
+ accumulator.add(tool_fragment(name: 'anon', arguments: '{}'))
113
+ response = accumulator.to_response(model: 'm')
114
+ expect(response.tool_calls.first.id).to match(/\A[0-9a-f-]{36}\z/)
115
+ end
124
116
 
125
- expect(accumulator.stop_reason).to eq(:max_tokens)
126
- expect(accumulator.to_message(nil).stop_reason).to eq(:max_tokens)
117
+ it 'carries the thought signature in the tool call metadata, never dropped' do
118
+ accumulator = described_class.new
119
+ accumulator.add(tool_fragment(id: 'c1', name: 'f', arguments: '{}', signature: 'thought-sig'))
120
+ response = accumulator.to_response(model: 'm')
121
+ expect(response.tool_calls.first.metadata).to eq(signature: 'thought-sig')
127
122
  end
128
123
 
129
- it 'leaves stop_reason nil when no chunk carries one' do
124
+ it 'raises a strict parse error for invalid assembled arguments (U2 — no rescue to {})' do
130
125
  accumulator = described_class.new
131
- accumulator.add(Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'hi', model_id: 'm'))
126
+ accumulator.add(tool_fragment(id: 'c1', name: 'f', arguments: '{"broken"'))
132
127
 
133
- expect(accumulator.stop_reason).to be_nil
134
- expect(accumulator.to_message(nil).stop_reason).to be_nil
128
+ expect { accumulator.to_response(model: 'm') }
129
+ .to raise_error(ArgumentError, /not valid JSON/)
135
130
  end
131
+ end
136
132
 
137
- it 'starts a tool call from an opening fragment with id nil but name present' do
133
+ describe 'think-tag state machine (U1 — shared segment core, cross-chunk buffering)' do
134
+ it 'splits think tags split across chunk boundaries without leaking' do
138
135
  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
- )
136
+ emitted = []
137
+ emitted.concat(accumulator.add(text_chunk('<thinking>')))
138
+ emitted.concat(accumulator.add(text_chunk('internal</thin')))
139
+ emitted.concat(accumulator.add(text_chunk('king>visible')))
140
+ response = accumulator.to_response(model: 'm')
141
+
142
+ expect(response.text).to eq('visible')
143
+ expect(response.thinking.content).to eq('internal')
144
+ # The split tag itself emits no visible text deltas.
145
+ expect(emitted.map(&:type)).to eq(%i[thinking_delta text_delta])
146
+ end
143
147
 
144
- accumulator.add(chunk)
148
+ it 'emits thinking and text deltas from a tagged stream' do
149
+ accumulator = described_class.new
150
+ emitted = []
151
+ emitted.concat(accumulator.add(text_chunk('<thinking>')))
152
+ emitted.concat(accumulator.add(text_chunk('reasoning here')))
153
+ emitted.concat(accumulator.add(text_chunk('</thinking>and text')))
154
+
155
+ expect(emitted.map(&:type)).to eq(%i[thinking_delta text_delta])
156
+ response = accumulator.to_response(model: 'm')
157
+ expect(response.text).to eq('and text')
158
+ expect(response.thinking.content).to eq('reasoning here')
159
+ end
145
160
 
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)
161
+ it 'treats content before an unmatched close as thinking (never visible)' do
162
+ accumulator = described_class.new
163
+ accumulator.add(text_chunk('preface</thinking>'))
164
+ accumulator.add(text_chunk('visible'))
165
+ response = accumulator.to_response(model: 'm')
151
166
 
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' })
167
+ expect(response.text).to eq('visible')
168
+ expect(response.thinking.content).to eq('preface')
157
169
  end
158
- end
159
170
 
160
- describe '#filtered_chunk' do
161
- it 'returns a chunk for a plain text delta' do
171
+ it 'keeps a stray open tag siphoning content into thinking until close' do
162
172
  accumulator = described_class.new
163
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'Hello', model_id: 'm')
164
-
165
- accumulator.add(chunk)
166
- filtered = accumulator.filtered_chunk(chunk)
173
+ accumulator.add(text_chunk('preamble<thinking>'))
174
+ accumulator.add(text_chunk('middle'))
175
+ accumulator.add(text_chunk('</thinking>tail'))
176
+ response = accumulator.to_response(model: 'm')
167
177
 
168
- expect(filtered).not_to be_nil
169
- expect(filtered.content.to_s).to eq('Hello')
178
+ expect(response.text).to eq('preambletail')
179
+ expect(response.thinking.content).to eq('middle')
170
180
  end
171
181
  end
172
182
 
173
- describe '#flush_pending_chunk' do
174
- it 'returns nil when nothing is buffered' do
183
+ describe 'untagged-preamble heuristic' do
184
+ it 'withholds a reasoning-looking preamble until a blank line, then emits it as thinking' do
175
185
  accumulator = described_class.new
176
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'Hello', model_id: 'm')
177
- accumulator.add(chunk)
178
-
179
- expect(accumulator.flush_pending_chunk).to be_nil
186
+ emitted = []
187
+ emitted.concat(accumulator.add(text_chunk("I need to answer this\n")))
188
+ emitted.concat(accumulator.add(text_chunk("\n\nHere is the answer")))
189
+
190
+ response = accumulator.to_response(model: 'm')
191
+ expect(response.thinking.content).to eq('I need to answer this')
192
+ expect(response.text).to eq('Here is the answer')
193
+ expect(emitted.map(&:type).include?(:thinking_delta)).to be(true)
180
194
  end
181
195
 
182
- it 'releases text held by the untagged-preamble heuristic as a final delta' do
196
+ it 'releases held text as content when the preamble is not reasoning' do
183
197
  accumulator = described_class.new
184
- deltas = []
185
- ['I can help', ' with that.'].each do |text|
186
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: text, model_id: 'm')
187
- accumulator.add(chunk)
188
- filtered = accumulator.filtered_chunk(chunk)
189
- deltas << filtered.content.to_s if filtered
190
- end
198
+ emitted = []
199
+ emitted.concat(accumulator.add(text_chunk('Just a normal answer')))
200
+ expect(emitted.map(&:type)).to eq(%i[text_delta])
201
+ expect(accumulator.to_response(model: 'm').text).to eq('Just a normal answer')
202
+ end
191
203
 
192
- final = accumulator.flush_pending_chunk
193
- deltas << final.content.to_s if final
194
- message = accumulator.to_message(nil)
204
+ it 'flushes still-held preamble text at stream end (short responses still stream)' do
205
+ accumulator = described_class.new
206
+ accumulator.add(text_chunk("Let me answer\nbriefly"))
207
+ expect(accumulator.add(text_chunk('more')).map(&:type)).to eq([])
208
+ emitted = accumulator.flush_pending_chunk
195
209
 
196
- expect(deltas.join).to eq('I can help with that.')
197
- expect(message.content).to eq('I can help with that.')
210
+ expect(emitted.map(&:type)).to eq(%i[text_delta])
211
+ expect(accumulator.to_response(model: 'm').text).to eq("Let me answer\nbrieflymore")
198
212
  end
213
+ end
199
214
 
200
- it 'does not double-append flushed text in to_message' do
215
+ describe 'response assembly' do
216
+ it 'assembles a canonical response with usage, thinking, and tool calls' do
201
217
  accumulator = described_class.new
202
- chunk = Legion::Extensions::Llm::Chunk.new(role: :assistant, content: 'The answer', model_id: 'm')
203
- accumulator.add(chunk)
218
+ accumulator.add(text_chunk('done'))
219
+ accumulator.add(thinking_chunk('thought', signature: 'sig-9'))
220
+ accumulator.add(tool_fragment(id: 'c1', name: 'f', arguments: '{"k":"v"}'))
221
+ accumulator.add(canonical::Chunk.usage_chunk(
222
+ usage: canonical::Usage.build(input_tokens: 1, output_tokens: 2), request_id: 'r'
223
+ ))
204
224
 
205
- accumulator.flush_pending_chunk
206
- message = accumulator.to_message(nil)
225
+ response = accumulator.to_response(model: 'm-1')
226
+ expect(response).to be_a(canonical::Response)
227
+ expect(response.text).to eq('done')
228
+ expect(response.thinking.content).to eq('thought')
229
+ expect(response.thinking.signature).to eq('sig-9')
230
+ expect(response.tool_calls.first.arguments).to eq('k' => 'v')
231
+ expect(response.usage.input_tokens).to eq(1)
232
+ end
207
233
 
208
- expect(message.content).to eq('The answer')
234
+ it 'leaves thinking and usage nil when the stream carried none' do
235
+ response = described_class.new.to_response(model: 'm')
236
+ expect(response.thinking).to be_nil
237
+ expect(response.usage).to be_nil
238
+ expect(response.text).to eq('')
209
239
  end
210
240
  end
211
241
  end
@@ -7,6 +7,17 @@ RSpec.describe Legion::Extensions::Llm::Streaming do
7
7
  Object.new.tap do |obj|
8
8
  obj.extend(described_class)
9
9
  obj.define_singleton_method(:build_chunk) { |data| "chunk:#{data['x']}" }
10
+ # Streaming only ships inside Provider in production; the U8 delegation
11
+ # passes self to ErrorMiddleware.parse_error, which calls parse_error.
12
+ obj.define_singleton_method(:parse_error) do |response|
13
+ body = response.body
14
+ error = body['error'] if body.is_a?(Hash)
15
+ if error.is_a?(String)
16
+ error
17
+ else
18
+ (error.is_a?(Hash) ? error['message'] : nil)
19
+ end
20
+ end
10
21
  end
11
22
  end
12
23
 
@@ -114,4 +125,20 @@ RSpec.describe Legion::Extensions::Llm::Streaming do
114
125
  .to raise_error(Legion::Extensions::Llm::ServerError, /Provider error.*incomplete/)
115
126
  end
116
127
  end
128
+
129
+ describe 'M1 — unparseable error content fails loud (the fail-open is deleted)' do
130
+ it 'an unparseable error event raises a classified ServerError, never a silent nil' do
131
+ expect { test_obj.send(:parse_error_from_json, 'not-json{{{', env, 'Failed to parse error chunk') }
132
+ .to raise_error(Legion::Extensions::Llm::ServerError, /unparseable error event/)
133
+ end
134
+
135
+ it 'an unparseable mid-stream data frame raises a classified ServerError' do
136
+ expect { test_obj.send(:handle_data, 'not-json{{{', env) }
137
+ .to raise_error(Legion::Extensions::Llm::ServerError, /unparseable data frame/)
138
+ end
139
+
140
+ it 'an empty data frame is a no-op, not an error' do
141
+ expect { test_obj.send(:handle_data, ' ', env) }.not_to raise_error
142
+ end
143
+ end
117
144
  end
@@ -16,14 +16,48 @@ RSpec.describe Legion::Extensions::Llm::Taxonomies do
16
16
  expect(described_class::TYPES).to include(:inference, :embedding)
17
17
  end
18
18
 
19
- it 'CIRCUIT_STATES contains three states' do
20
- expect(described_class::CIRCUIT_STATES).to contain_exactly(:closed, :half_open, :open)
21
- end
22
-
23
19
  it 'all constants are frozen' do
24
20
  expect(described_class::TIERS).to be_frozen
25
21
  expect(described_class::TYPES).to be_frozen
26
- expect(described_class::CIRCUIT_STATES).to be_frozen
22
+ expect(described_class::OPERATIONS).to be_frozen
23
+ expect(described_class::OPERATION_TO_LANE_TYPE).to be_frozen
24
+ end
25
+
26
+ it 'removes the legacy enums (0.8.0 rip)' do
27
+ expect(described_class.const_defined?(:CIRCUIT_STATES, false)).to be(false)
28
+ expect(described_class.const_defined?(:HEALTH_KEYS, false)).to be(false)
29
+ expect(described_class.const_defined?(:OPERATION_ALIASES, false)).to be(false)
30
+ end
31
+
32
+ describe 'OPERATION_TO_LANE_TYPE (the 5-tuple 4th part)' do
33
+ it 'maps every canonical operation to a documented lane type' do
34
+ expect(described_class::OPERATION_TO_LANE_TYPE).to eq(
35
+ {
36
+ chat: :inference,
37
+ stream_chat: :inference,
38
+ embed: :embedding,
39
+ image: :image,
40
+ transcribe: :audio,
41
+ translate: :audio,
42
+ speak: :audio,
43
+ moderate: :inference,
44
+ count_tokens: :inference
45
+ }
46
+ )
47
+ end
48
+
49
+ it 'covers exactly OPERATIONS with values inside TYPES' do
50
+ expect(described_class::OPERATION_TO_LANE_TYPE.keys.sort).to eq(described_class::OPERATIONS.sort)
51
+ expect(described_class::OPERATION_TO_LANE_TYPE.values.all? { |value| described_class::TYPES.include?(value) })
52
+ .to be(true)
53
+ end
54
+
55
+ it 'lane_type_for canonicalizes first and resolves the single table' do
56
+ expect(described_class.lane_type_for(operation: 'chat')).to eq(:inference)
57
+ expect(described_class.lane_type_for(operation: :embed)).to eq(:embedding)
58
+ expect { described_class.lane_type_for(operation: :nope) }
59
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
60
+ end
27
61
  end
28
62
 
29
63
  describe 'SSOT v3 enums' do
@@ -53,20 +87,13 @@ RSpec.describe Legion::Extensions::Llm::Taxonomies do
53
87
  expect(described_class.normalize_operation(value: :stream_chat)).to eq(:stream_chat)
54
88
  end
55
89
 
56
- it 'accepts only the documented aliases when allow_aliases is true' do
57
- {
58
- stream: :stream_chat, embedding: :embed, moderation: :moderate,
59
- audio_translation: :translate, speech: :speak
60
- }.each do |input, canonical|
61
- expect(described_class.normalize_operation(value: input, allow_aliases: true)).to eq(canonical)
90
+ it 'rejects every legacy alias — one canonical spelling (06 P5)' do
91
+ %i[stream embedding moderation audio_translation speech].each do |input|
92
+ expect { described_class.normalize_operation(value: input) }
93
+ .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
62
94
  end
63
95
  end
64
96
 
65
- it 'raises on an alias when allow_aliases is false' do
66
- expect { described_class.normalize_operation(value: :stream) }
67
- .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
68
- end
69
-
70
97
  it 'raises on unknown, empty, and invalid UTF-8 input' do
71
98
  expect { described_class.normalize_operation(value: :nope) }
72
99
  .to raise_error(Legion::Extensions::Llm::Inventory::Errors::ValidationError)
@@ -17,7 +17,7 @@ RSpec.describe Legion::Extensions::Llm::Transport::FleetLane do
17
17
  'x-message-ttl' => 120_000,
18
18
  'x-max-length' => 100,
19
19
  'x-delivery-limit' => 3,
20
- 'x-consumer-timeout' => 300_000
20
+ 'x-consumer-timeout' => 90_000
21
21
  )
22
22
  end
23
23
 
@@ -3,17 +3,36 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe Legion::Extensions::Llm::Utils do
6
- describe '.hash_get' do
7
- it 'fetches a value using a symbol when the hash key is stored as a string' do
8
- hash = { 'name' => 'Legion::Extensions::Llm' }
6
+ describe '.localhost_url?' do
7
+ it 'detects loopback hosts in URLs and bare host[:port] values' do
8
+ expect(described_class.localhost_url?('http://localhost:11434/v1')).to be(true)
9
+ expect(described_class.localhost_url?('http://127.0.0.1:11434/v1')).to be(true)
10
+ expect(described_class.localhost_url?('http://[::1]:11434/v1')).to be(true)
11
+ expect(described_class.localhost_url?('localhost:11434')).to be(true)
12
+ expect(described_class.localhost_url?('127.0.0.1:11434')).to be(true)
13
+ end
14
+
15
+ it 'rejects non-loopback hosts and garbage' do
16
+ expect(described_class.localhost_url?('http://apollo:11434/v1')).to be(false)
17
+ expect(described_class.localhost_url?('not a url at all')).to be(false)
18
+ expect(described_class.localhost_url?('')).to be(false)
19
+ end
9
20
 
10
- expect(described_class.hash_get(hash, :name)).to eq('Legion::Extensions::Llm')
21
+ it 'is the one shared host-locality detector (U15)' do
22
+ expect(Legion::Extensions::Llm::CredentialSources.localhost?('http://localhost:11434')).to be(true)
23
+ expect(Legion::Extensions::Llm::CredentialSources.localhost?('http://apollo:11434')).to be(false)
11
24
  end
25
+ end
26
+
27
+ describe '.deep_merge (U7 — dup-before-merge policy)' do
28
+ it 'merges nested hashes without mutating the inputs' do
29
+ left = { a: { b: 1 }, keep: 1 }
30
+ right = { a: { c: 2 } }
12
31
 
13
- it 'fetches a value using a string when the hash key is stored as a symbol' do
14
- hash = { name: 'Legion::Extensions::Llm' }
32
+ merged = described_class.deep_merge(left, right)
15
33
 
16
- expect(described_class.hash_get(hash, 'name')).to eq('Legion::Extensions::Llm')
34
+ expect(merged).to eq(a: { b: 1, c: 2 }, keep: 1)
35
+ expect(left).to eq(a: { b: 1 }, keep: 1)
17
36
  end
18
37
  end
19
38