lex-llm 0.4.18 → 0.5.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/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +19 -0
- data/lex-llm.gemspec +2 -3
- data/lib/legion/extensions/llm/attachment.rb +1 -1
- data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
- data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
- data/lib/legion/extensions/llm/canonical/message.rb +125 -0
- data/lib/legion/extensions/llm/canonical/params.rb +61 -0
- data/lib/legion/extensions/llm/canonical/request.rb +117 -0
- data/lib/legion/extensions/llm/canonical/response.rb +124 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
- data/lib/legion/extensions/llm/canonical.rb +49 -0
- data/lib/legion/extensions/llm/chat.rb +3 -5
- data/lib/legion/extensions/llm/connection.rb +5 -1
- data/lib/legion/extensions/llm/error.rb +3 -7
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
- data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
- data/lib/legion/extensions/llm/model/info.rb +4 -6
- data/lib/legion/extensions/llm/models.rb +3 -3
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -3
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
- data/lib/legion/extensions/llm/streaming.rb +1 -3
- data/lib/legion/extensions/llm/tool.rb +1 -3
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +118 -35
- data/spec/fixtures/ruby.mp3 +0 -0
- data/spec/fixtures/ruby.mp4 +0 -0
- data/spec/fixtures/ruby.png +0 -0
- data/spec/fixtures/ruby.txt +1 -0
- data/spec/fixtures/ruby.wav +0 -0
- data/spec/fixtures/ruby.xml +1 -0
- data/spec/fixtures/sample.pdf +0 -0
- data/spec/legion/extensions/llm/agent_spec.rb +179 -0
- data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
- data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
- data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
- data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
- data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
- data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
- data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
- data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
- data/spec/legion/extensions/llm/context_spec.rb +127 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
- data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
- data/spec/legion/extensions/llm/error_spec.rb +87 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
- data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
- data/spec/legion/extensions/llm/message_spec.rb +64 -0
- data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
- data/spec/legion/extensions/llm/models_spec.rb +104 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
- data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
- data/spec/legion/extensions/llm/provider_spec.rb +592 -0
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
- data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
- data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
- data/spec/legion/extensions/llm/tool_spec.rb +94 -0
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
- data/spec/legion/extensions/llm/utils_spec.rb +113 -0
- data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
- data/spec/legion/extensions/llm_extension_spec.rb +78 -0
- data/spec/legion/extensions/llm_root_spec.rb +51 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/support/fake_llm_provider.rb +148 -0
- data/spec/support/llm_configuration.rb +21 -0
- data/spec/support/rspec_configuration.rb +19 -0
- data/spec/support/simplecov_configuration.rb +20 -0
- metadata +96 -15
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared examples for canonical provider translator conformance.
|
|
4
|
+
#
|
|
5
|
+
# Every provider translator must implement:
|
|
6
|
+
# - render_request(canonical_request) => wire Hash
|
|
7
|
+
# - parse_response(wire_hash) => Canonical::Response
|
|
8
|
+
# - parse_chunk(raw_chunk) => Canonical::Chunk | nil
|
|
9
|
+
# - capabilities => Hash
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# it_behaves_like 'a canonical provider translator', MyTranslatorClass
|
|
13
|
+
|
|
14
|
+
RSpec.shared_examples 'a canonical provider translator' do |translator_class|
|
|
15
|
+
let(:translator) { translator_class.new }
|
|
16
|
+
let(:canonical) { Legion::Extensions::Llm::Canonical }
|
|
17
|
+
let(:conformance) { Canonical::Conformance }
|
|
18
|
+
|
|
19
|
+
describe '#capabilities' do
|
|
20
|
+
it 'returns a Hash' do
|
|
21
|
+
expect(translator.capabilities).to be_a(Hash)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'includes a :provider key' do
|
|
25
|
+
expect(translator.capabilities).to have_key(:provider)
|
|
26
|
+
expect(translator.capabilities[:provider]).to be_a(String)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#render_request' do
|
|
31
|
+
context 'with a simple text request' do
|
|
32
|
+
let(:canonical_req) do
|
|
33
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_simple_text_request'))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'renders a non-empty wire payload' do
|
|
37
|
+
wire = translator.render_request(canonical_req)
|
|
38
|
+
expect(wire).to be_a(Hash)
|
|
39
|
+
expect(wire).not_to be_empty
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'includes model or messages' do
|
|
43
|
+
wire = translator.render_request(canonical_req)
|
|
44
|
+
expect(wire.keys & %i[model messages]).not_to be_empty
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'preserves message content' do
|
|
48
|
+
wire = translator.render_request(canonical_req)
|
|
49
|
+
wire_str = wire.to_s
|
|
50
|
+
expect(wire_str).to include('how are you')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
context 'with a system prompt' do
|
|
55
|
+
let(:canonical_req) do
|
|
56
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_system_prompt_request'))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'renders the system prompt in provider-appropriate format' do
|
|
60
|
+
wire = translator.render_request(canonical_req)
|
|
61
|
+
wire_str = wire.to_s.downcase
|
|
62
|
+
expect(wire_str).to match(/helpful|haiku/)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'with parameter mapping' do
|
|
67
|
+
let(:canonical_req) do
|
|
68
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_params_mapping_request'))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'renders params in provider-appropriate format' do
|
|
72
|
+
wire = translator.render_request(canonical_req)
|
|
73
|
+
expect(wire).to be_a(Hash)
|
|
74
|
+
wire_str = wire.to_s
|
|
75
|
+
expect(wire_str).to match(/[0-9]+/)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
context 'with tools defined' do
|
|
80
|
+
let(:canonical_req) do
|
|
81
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_tools_request'))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'renders tools in provider format' do
|
|
85
|
+
wire = translator.render_request(canonical_req)
|
|
86
|
+
wire_str = wire.to_s.downcase
|
|
87
|
+
expect(wire_str).to include('get_weather')
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'includes tool parameters' do
|
|
91
|
+
wire = translator.render_request(canonical_req)
|
|
92
|
+
wire_str = wire.to_s.downcase
|
|
93
|
+
expect(wire_str).to include('location')
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
context 'with tool results continuation (multi-turn)' do
|
|
98
|
+
let(:canonical_req) do
|
|
99
|
+
canonical::Request.from_hash(
|
|
100
|
+
conformance.fixture_symbolized('canonical_tool_results_continuation_request')
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'renders the full conversation history' do
|
|
105
|
+
wire = translator.render_request(canonical_req)
|
|
106
|
+
wire_str = wire.to_s.downcase
|
|
107
|
+
expect(wire_str).to include('weather')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'renders mixed client and registry tool calls' do
|
|
111
|
+
wire = translator.render_request(canonical_req)
|
|
112
|
+
wire_str = wire.to_s.downcase
|
|
113
|
+
expect(wire_str).to include('get_weather')
|
|
114
|
+
expect(wire_str).to include('summarize')
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
context 'with thinking enabled' do
|
|
119
|
+
let(:canonical_req) do
|
|
120
|
+
canonical::Request.from_hash(conformance.fixture_symbolized('canonical_thinking_request'))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'renders thinking configuration' do
|
|
124
|
+
wire = translator.render_request(canonical_req)
|
|
125
|
+
wire_str = wire.to_s.downcase
|
|
126
|
+
expect(wire_str).to match(/think|reason|budget|effort/)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
context 'with streaming request' do
|
|
131
|
+
let(:canonical_req) do
|
|
132
|
+
canonical::Request.from_hash(
|
|
133
|
+
conformance.fixture_symbolized('canonical_simple_text_request').merge({ 'stream' => true })
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'renders with streaming indicator' do
|
|
138
|
+
wire = translator.render_request(canonical_req)
|
|
139
|
+
wire_str = wire.to_s.downcase
|
|
140
|
+
expect(wire_str).to include('stream')
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# parse_response: tests translator.parse_response(wire_hash) => Canonical::Response
|
|
146
|
+
# For self-test (echo translator), wire == canonical-form (symbolized).
|
|
147
|
+
# Real provider translators convert provider-specific wire format to canonical.
|
|
148
|
+
describe '#parse_response' do
|
|
149
|
+
context 'with a simple text response' do
|
|
150
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_simple_text_response') }
|
|
151
|
+
|
|
152
|
+
it 'returns a Canonical::Response' do
|
|
153
|
+
response = translator.parse_response(wire_response)
|
|
154
|
+
expect(response).to be_a(canonical::Response)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
it 'preserves text content' do
|
|
158
|
+
response = translator.parse_response(wire_response)
|
|
159
|
+
expect(response.text).to eq("I'm doing well, thank you for asking!")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'sets stop_reason' do
|
|
163
|
+
response = translator.parse_response(wire_response)
|
|
164
|
+
expect(response.stop_reason).to eq(:end_turn)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'includes usage data' do
|
|
168
|
+
response = translator.parse_response(wire_response)
|
|
169
|
+
expect(response.usage).to be_a(canonical::Usage)
|
|
170
|
+
expect(response.usage.input_tokens).to eq(12)
|
|
171
|
+
expect(response.usage.output_tokens).to eq(10)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
context 'with a tool use response' do
|
|
176
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_tool_use_response') }
|
|
177
|
+
|
|
178
|
+
it 'parses tool calls correctly' do
|
|
179
|
+
response = translator.parse_response(wire_response)
|
|
180
|
+
expect(response).to be_a(canonical::Response)
|
|
181
|
+
expect(response.tool_call?).to be true
|
|
182
|
+
expect(response.tool_calls).to be_an(Array)
|
|
183
|
+
expect(response.tool_calls.first).to be_a(canonical::ToolCall)
|
|
184
|
+
expect(response.stop_reason).to eq(:tool_use)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'preserves tool call arguments as a Hash' do
|
|
188
|
+
response = translator.parse_response(wire_response)
|
|
189
|
+
args = response.tool_calls.first.arguments
|
|
190
|
+
expect(args).to be_a(Hash)
|
|
191
|
+
expect(args[:location]).to eq('San Francisco, CA')
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it 'has no text when response is tool-only' do
|
|
195
|
+
response = translator.parse_response(wire_response)
|
|
196
|
+
expect(response.text).to eq('')
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
context 'with thinking response' do
|
|
201
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_thinking_response') }
|
|
202
|
+
|
|
203
|
+
it 'parses thinking content and signature' do
|
|
204
|
+
response = translator.parse_response(wire_response)
|
|
205
|
+
expect(response.thinking).to be_a(canonical::Thinking)
|
|
206
|
+
expect(response.thinking.content).to include('quantum')
|
|
207
|
+
expect(response.thinking.signature).to be_a(String)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'preserves thinking tokens in usage' do
|
|
211
|
+
response = translator.parse_response(wire_response)
|
|
212
|
+
expect(response.usage).to be_a(canonical::Usage)
|
|
213
|
+
expect(response.usage.thinking_tokens).to eq(120)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
context 'with error response' do
|
|
218
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_error_response') }
|
|
219
|
+
|
|
220
|
+
it 'parses error responses without crashing' do
|
|
221
|
+
response = translator.parse_response(wire_response)
|
|
222
|
+
expect(response).to be_a(canonical::Response)
|
|
223
|
+
expect(response.error?).to be true
|
|
224
|
+
expect(response.stop_reason).to eq(:error)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'preserves error metadata' do
|
|
228
|
+
response = translator.parse_response(wire_response)
|
|
229
|
+
expect(response.metadata).to have_key(:error)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
context 'with empty response' do
|
|
234
|
+
let(:wire_response) { conformance.fixture_symbolized('canonical_empty_response') }
|
|
235
|
+
|
|
236
|
+
it 'handles empty responses gracefully' do
|
|
237
|
+
response = translator.parse_response(wire_response)
|
|
238
|
+
expect(response).to be_a(canonical::Response)
|
|
239
|
+
expect(response.text).to eq('')
|
|
240
|
+
expect(response.tool_calls).to eq([])
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
describe '#parse_chunk' do
|
|
246
|
+
context 'with text delta chunks' do
|
|
247
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_text_chunks') }
|
|
248
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
249
|
+
|
|
250
|
+
it 'parses text delta chunks' do
|
|
251
|
+
text_chunk = chunks.find { |c| c['type'] == 'text_delta' }
|
|
252
|
+
parsed = translator.parse_chunk(text_chunk)
|
|
253
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
254
|
+
expect(parsed.type).to eq(:text_delta)
|
|
255
|
+
expect(parsed.delta).to be_a(String)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'parses the done chunk' do
|
|
259
|
+
done_chunk = chunks.find { |c| c['type'] == 'done' }
|
|
260
|
+
parsed = translator.parse_chunk(done_chunk)
|
|
261
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
262
|
+
expect(parsed.type).to eq(:done)
|
|
263
|
+
expect(parsed.stop_reason).to eq(:end_turn)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
context 'with thinking delta chunks' do
|
|
268
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_thinking_chunks') }
|
|
269
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
270
|
+
|
|
271
|
+
it 'parses thinking delta chunks' do
|
|
272
|
+
thinking_chunk = chunks.find { |c| c['type'] == 'thinking_delta' }
|
|
273
|
+
parsed = translator.parse_chunk(thinking_chunk)
|
|
274
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
275
|
+
expect(parsed.type).to eq(:thinking_delta)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'preserves signature on thinking deltas' do
|
|
279
|
+
sig_chunk = chunks.find { |c| c['type'] == 'thinking_delta' && !c['signature'].nil? }
|
|
280
|
+
next if sig_chunk.nil?
|
|
281
|
+
|
|
282
|
+
parsed = translator.parse_chunk(sig_chunk)
|
|
283
|
+
expect(parsed.signature).to be_a(String)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
context 'with tool call delta chunks' do
|
|
288
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_tool_call_chunks') }
|
|
289
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
290
|
+
|
|
291
|
+
it 'parses tool call delta chunks' do
|
|
292
|
+
tool_chunk = chunks.find { |c| c['type'] == 'tool_call_delta' }
|
|
293
|
+
parsed = translator.parse_chunk(tool_chunk)
|
|
294
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
295
|
+
expect(parsed.type).to eq(:tool_call_delta)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
it 'preserves tool call identity across chunks' do
|
|
299
|
+
tool_chunks = chunks.select { |c| c['type'] == 'tool_call_delta' }
|
|
300
|
+
parsed_chunks = tool_chunks.map { |c| translator.parse_chunk(c) }
|
|
301
|
+
ids = parsed_chunks.map { |c| c.tool_call&.id }
|
|
302
|
+
expect(ids.uniq.length).to eq(1)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
context 'with error chunk' do
|
|
307
|
+
let(:stream_fixture) { conformance.fixture('canonical_streaming_error_chunks') }
|
|
308
|
+
let(:chunks) { stream_fixture['chunks'] }
|
|
309
|
+
|
|
310
|
+
it 'parses error chunks' do
|
|
311
|
+
error_chunk = chunks.find { |c| c['type'] == 'error' }
|
|
312
|
+
parsed = translator.parse_chunk(error_chunk)
|
|
313
|
+
expect(parsed).to be_a(canonical::Chunk)
|
|
314
|
+
expect(parsed.type).to eq(:error)
|
|
315
|
+
expect(parsed.error?).to be true
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
describe 'stop_reason mapping' do
|
|
321
|
+
let(:matrix) { conformance.fixture_symbolized('canonical_stop_reason_matrix') }
|
|
322
|
+
|
|
323
|
+
it 'maps all canonical stop reasons' do
|
|
324
|
+
canonical::Response::STOP_REASONS.each do |reason|
|
|
325
|
+
resp = canonical::Response.build(stop_reason: reason, text: 'test')
|
|
326
|
+
expect(resp.stop_reason).to eq(reason)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it 'rejects invalid stop reasons' do
|
|
331
|
+
expect { canonical::Response.build(stop_reason: :invalid_reason, text: 'test') }
|
|
332
|
+
.to raise_error(ArgumentError, /Invalid stop_reason/)
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
describe 'round-trip consistency' do
|
|
337
|
+
it 'accumulated chunks equal non-streaming response for text' do
|
|
338
|
+
stream_fixture = conformance.fixture('canonical_streaming_text_chunks')
|
|
339
|
+
chunks = stream_fixture['chunks']
|
|
340
|
+
|
|
341
|
+
accumulated_text = ''
|
|
342
|
+
final_stop_reason = nil
|
|
343
|
+
|
|
344
|
+
chunks.each do |raw_chunk|
|
|
345
|
+
chunk = translator.parse_chunk(raw_chunk)
|
|
346
|
+
next unless chunk
|
|
347
|
+
|
|
348
|
+
case chunk.type
|
|
349
|
+
when :text_delta
|
|
350
|
+
accumulated_text += chunk.delta
|
|
351
|
+
when :done
|
|
352
|
+
final_stop_reason = chunk.stop_reason
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
expect(accumulated_text).to eq('Hello, world! How can I help you today?')
|
|
357
|
+
expect(final_stop_reason).to eq(:end_turn)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
it 'accumulated chunks equal non-streaming response for thinking + text' do
|
|
361
|
+
stream_fixture = conformance.fixture('canonical_streaming_thinking_chunks')
|
|
362
|
+
chunks = stream_fixture['chunks']
|
|
363
|
+
|
|
364
|
+
accumulated_thinking = ''
|
|
365
|
+
accumulated_text = ''
|
|
366
|
+
final_stop_reason = nil
|
|
367
|
+
final_signature = nil
|
|
368
|
+
|
|
369
|
+
chunks.each do |raw_chunk|
|
|
370
|
+
chunk = translator.parse_chunk(raw_chunk)
|
|
371
|
+
next unless chunk
|
|
372
|
+
|
|
373
|
+
case chunk.type
|
|
374
|
+
when :thinking_delta
|
|
375
|
+
accumulated_thinking += chunk.delta
|
|
376
|
+
final_signature = chunk.signature if chunk.signature
|
|
377
|
+
when :text_delta
|
|
378
|
+
accumulated_text += chunk.delta
|
|
379
|
+
when :done
|
|
380
|
+
final_stop_reason = chunk.stop_reason
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
expect(accumulated_thinking).not_to be_empty
|
|
385
|
+
expect(accumulated_text).not_to be_empty
|
|
386
|
+
expect(final_signature).to be_a(String)
|
|
387
|
+
expect(final_stop_reason).to eq(:end_turn)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Connection do
|
|
6
|
+
describe 'logging middleware configuration' 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: 1.0
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'disables body logging when log level is above DEBUG' do
|
|
30
|
+
logger = Logger.new(File::NULL, level: Logger::INFO)
|
|
31
|
+
allow(config).to receive(:logger).and_return(logger)
|
|
32
|
+
|
|
33
|
+
connection = described_class.new(provider, config).connection
|
|
34
|
+
handler = connection.builder.handlers.find { |h| h.klass == Faraday::Response::Logger }
|
|
35
|
+
middleware = handler.build(->(_env) { Faraday::Response.new })
|
|
36
|
+
options = middleware.instance_variable_get(:@formatter).instance_variable_get(:@options)
|
|
37
|
+
|
|
38
|
+
expect(options[:bodies]).to be(false)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'enables body logging when log level is DEBUG' do
|
|
42
|
+
logger = Logger.new(File::NULL, level: Logger::DEBUG)
|
|
43
|
+
allow(config).to receive(:logger).and_return(logger)
|
|
44
|
+
|
|
45
|
+
connection = described_class.new(provider, config).connection
|
|
46
|
+
handler = connection.builder.handlers.find { |h| h.klass == Faraday::Response::Logger }
|
|
47
|
+
middleware = handler.build(->(_env) { Faraday::Response.new })
|
|
48
|
+
options = middleware.instance_variable_get(:@formatter).instance_variable_get(:@options)
|
|
49
|
+
|
|
50
|
+
expect(options[:bodies]).to be(true)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Connection do
|
|
6
|
+
describe 'retry middleware configuration' 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
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'retries POST requests for transient failures' do
|
|
29
|
+
connection = described_class.new(provider, config).connection
|
|
30
|
+
retry_handler = connection.builder.handlers.find { |handler| handler.klass == Faraday::Retry::Middleware }
|
|
31
|
+
retry_options = retry_handler.instance_variable_get(:@args).first
|
|
32
|
+
|
|
33
|
+
expect(retry_options[:methods]).to include(:post)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Context do
|
|
6
|
+
include_context 'with configured Legion::Extensions::Llm'
|
|
7
|
+
include_context 'with fake llm provider'
|
|
8
|
+
|
|
9
|
+
describe '#initialize' do
|
|
10
|
+
it 'creates a copy of the global configuration' do
|
|
11
|
+
# Get current config values
|
|
12
|
+
original_model = Legion::Extensions::Llm.config.default_model
|
|
13
|
+
original_log_regexp_timeout = Legion::Extensions::Llm.config.log_regexp_timeout
|
|
14
|
+
|
|
15
|
+
# Create context with modified config
|
|
16
|
+
context = Legion::Extensions::Llm.context do |config|
|
|
17
|
+
config.default_model = 'modified-model'
|
|
18
|
+
config.fake_llm_api_key = 'modified-key'
|
|
19
|
+
config.log_regexp_timeout = 5.0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Verify global config is unchanged
|
|
23
|
+
expect(Legion::Extensions::Llm.config.default_model).to eq(original_model)
|
|
24
|
+
expect(Legion::Extensions::Llm.config.log_regexp_timeout).to eq(original_log_regexp_timeout)
|
|
25
|
+
|
|
26
|
+
# Verify context has modified config
|
|
27
|
+
expect(context.config.default_model).to eq('modified-model')
|
|
28
|
+
expect(context.config.fake_llm_api_key).to eq('modified-key')
|
|
29
|
+
expect(context.config.log_regexp_timeout).to eq(5.0)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'preserves log_regexp_timeout when Regexp timeout is unavailable' do
|
|
33
|
+
allow(Regexp).to receive(:respond_to?).and_call_original
|
|
34
|
+
allow(Regexp).to receive(:respond_to?).with(:timeout).and_return(false)
|
|
35
|
+
allow(Legion::Extensions::Llm.logger).to receive(:warn)
|
|
36
|
+
|
|
37
|
+
context = Legion::Extensions::Llm.context do |config|
|
|
38
|
+
config.log_regexp_timeout = 5.0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
expect(context.config.log_regexp_timeout).to eq(5.0)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe 'context chat operations' do
|
|
46
|
+
it 'creates a chat with context-specific configuration' do
|
|
47
|
+
context = Legion::Extensions::Llm.context do |config|
|
|
48
|
+
config.default_model = 'fake-chat-model'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
chat = context.chat(provider: :fake_llm, assume_model_exists: true)
|
|
52
|
+
expect(chat.model.id).to eq('fake-chat-model')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'allows specifying a model when creating the chat' do
|
|
56
|
+
context = Legion::Extensions::Llm.context do |config|
|
|
57
|
+
config.default_model = 'fake-chat-model'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
chat = context.chat(model: 'other-fake-chat-model', provider: :fake_llm, assume_model_exists: true)
|
|
61
|
+
expect(chat.model.id).to eq('other-fake-chat-model')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe 'context embed operations' do
|
|
66
|
+
it 'respects context-specific embedding model' do
|
|
67
|
+
context = Legion::Extensions::Llm.context do |config|
|
|
68
|
+
config.default_embedding_model = 'fake-embed'
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
embedding = context.embed('Test embedding', provider: :fake_llm, assume_model_exists: true)
|
|
72
|
+
expect(embedding.model).to eq('fake-embed')
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'allows specifying a model at embed time' do
|
|
76
|
+
context = Legion::Extensions::Llm.context do |config|
|
|
77
|
+
config.default_embedding_model = 'fake-embed'
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
embedding = context.embed('Test embedding', model: 'override-embed', provider: :fake_llm,
|
|
81
|
+
assume_model_exists: true)
|
|
82
|
+
expect(embedding.model).to eq('override-embed')
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe 'multiple independent contexts' do
|
|
87
|
+
it 'allows multiple contexts with different configurations' do
|
|
88
|
+
context1 = Legion::Extensions::Llm.context do |config|
|
|
89
|
+
config.default_model = 'fake-chat-1'
|
|
90
|
+
config.log_regexp_timeout = 5.0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
context2 = Legion::Extensions::Llm.context do |config|
|
|
94
|
+
config.default_model = 'fake-chat-2'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
chat1 = context1.chat(provider: :fake_llm, assume_model_exists: true)
|
|
98
|
+
chat2 = context2.chat(provider: :fake_llm, assume_model_exists: true)
|
|
99
|
+
|
|
100
|
+
expect(chat1.model.id).to eq('fake-chat-1')
|
|
101
|
+
expect(context1.config.log_regexp_timeout).to eq(5.0)
|
|
102
|
+
|
|
103
|
+
expect(chat2.model.id).to eq('fake-chat-2')
|
|
104
|
+
expected_timeout = Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil
|
|
105
|
+
expect(context2.config.log_regexp_timeout).to eq(expected_timeout)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'ensures changes in one context do not affect another' do
|
|
109
|
+
context1 = Legion::Extensions::Llm.context do |config|
|
|
110
|
+
config.fake_llm_api_key = 'key1'
|
|
111
|
+
config.default_model = 'model1'
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
context2 = Legion::Extensions::Llm.context do |config|
|
|
115
|
+
config.fake_llm_api_key = 'key2'
|
|
116
|
+
config.default_model = 'model2'
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Modify context1 after creation
|
|
120
|
+
context1.config.fake_llm_api_key = 'modified-key1'
|
|
121
|
+
|
|
122
|
+
# Context2 should be unaffected
|
|
123
|
+
expect(context2.config.fake_llm_api_key).to eq('key2')
|
|
124
|
+
expect(context2.config.default_model).to eq('model2')
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|