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
|
@@ -3,209 +3,239 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
RSpec.describe Legion::Extensions::Llm::StreamAccumulator do
|
|
6
|
-
|
|
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
|
-
|
|
8
|
+
def text_chunk(delta)
|
|
9
|
+
canonical::Chunk.text_delta(delta: delta, request_id: 'req-1')
|
|
10
|
+
end
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
def thinking_chunk(delta, signature: nil)
|
|
13
|
+
canonical::Chunk.thinking_delta(delta: delta, request_id: 'req-1', signature:)
|
|
14
|
+
end
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
expect(
|
|
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 '
|
|
31
|
+
it 'emits provider thinking deltas and carries the signature into the response' do
|
|
30
32
|
accumulator = described_class.new
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 '
|
|
41
|
+
it 'passes tool_call_delta chunks through and accumulates fragments' do
|
|
44
42
|
accumulator = described_class.new
|
|
45
|
-
|
|
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
|
-
|
|
54
|
-
expect(
|
|
55
|
-
|
|
56
|
-
expect(
|
|
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 '
|
|
52
|
+
it 'tracks usage from usage chunks into the response' do
|
|
61
53
|
accumulator = described_class.new
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
75
|
-
expect(
|
|
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 '
|
|
63
|
+
it 'keeps the last stop_reason and falls back to the selected model' do
|
|
84
64
|
accumulator = described_class.new
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 '
|
|
87
|
+
it 'falls back to recency when the provider emits no index' do
|
|
104
88
|
accumulator = described_class.new
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
)
|
|
89
|
+
accumulator.add(tool_fragment(name: 'only', arguments: '{"a":'))
|
|
90
|
+
accumulator.add(tool_fragment(arguments: '1}'))
|
|
108
91
|
|
|
109
|
-
accumulator.
|
|
110
|
-
expect(
|
|
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
|
-
|
|
113
|
-
|
|
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 '
|
|
110
|
+
it 'generates an id when the wire omits one' do
|
|
117
111
|
accumulator = described_class.new
|
|
118
|
-
accumulator.add(
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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 '
|
|
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(
|
|
126
|
+
accumulator.add(tool_fragment(id: 'c1', name: 'f', arguments: '{"broken"'))
|
|
132
127
|
|
|
133
|
-
expect
|
|
134
|
-
|
|
128
|
+
expect { accumulator.to_response(model: 'm') }
|
|
129
|
+
.to raise_error(ArgumentError, /not valid JSON/)
|
|
135
130
|
end
|
|
131
|
+
end
|
|
136
132
|
|
|
137
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
accumulator.
|
|
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
|
-
|
|
153
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
accumulator.add(
|
|
166
|
-
|
|
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(
|
|
169
|
-
expect(
|
|
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 '
|
|
174
|
-
it '
|
|
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
|
-
|
|
177
|
-
accumulator.add(
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
196
|
+
it 'releases held text as content when the preamble is not reasoning' do
|
|
183
197
|
accumulator = described_class.new
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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(
|
|
197
|
-
expect(
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
accumulator.add(
|
|
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.
|
|
206
|
-
|
|
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
|
-
|
|
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::
|
|
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 '
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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)
|
|
@@ -3,17 +3,36 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
RSpec.describe Legion::Extensions::Llm::Utils do
|
|
6
|
-
describe '.
|
|
7
|
-
it '
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
hash = { name: 'Legion::Extensions::Llm' }
|
|
32
|
+
merged = described_class.deep_merge(left, right)
|
|
15
33
|
|
|
16
|
-
expect(
|
|
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
|
|