lex-llm 0.4.16 → 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 +33 -0
- data/README.md +349 -153
- data/lex-llm.gemspec +3 -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 +14 -2
- 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 +12 -4
- 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 +6 -4
- 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 +110 -15
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Provider do
|
|
6
|
+
describe 'Hash config support' do
|
|
7
|
+
let(:provider_class) do
|
|
8
|
+
Class.new(described_class) do
|
|
9
|
+
def api_base = 'https://test.invalid'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'accepts a plain Hash and wraps it so method-style access works' do
|
|
14
|
+
provider = provider_class.new({ request_timeout: 60, max_retries: 2,
|
|
15
|
+
retry_interval: 0, retry_backoff_factor: 0,
|
|
16
|
+
retry_interval_randomness: 0,
|
|
17
|
+
anthropic_api_key: 'sk-test-123' })
|
|
18
|
+
expect(provider.config.anthropic_api_key).to eq('sk-test-123')
|
|
19
|
+
expect(provider.config.request_timeout).to eq(60)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'converts string keys to symbols' do
|
|
23
|
+
provider = provider_class.new({ 'request_timeout' => 120, 'max_retries' => 1,
|
|
24
|
+
'retry_interval' => 0, 'retry_backoff_factor' => 0,
|
|
25
|
+
'retry_interval_randomness' => 0,
|
|
26
|
+
'some_key' => 'value' })
|
|
27
|
+
expect(provider.config.some_key).to eq('value')
|
|
28
|
+
expect(provider.config.request_timeout).to eq(120)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'returns nil for missing keys instead of raising' do
|
|
32
|
+
provider = provider_class.new({ request_timeout: 30, max_retries: 0,
|
|
33
|
+
retry_interval: 0, retry_backoff_factor: 0,
|
|
34
|
+
retry_interval_randomness: 0 })
|
|
35
|
+
expect(provider.config.nonexistent_key).to be_nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'supports respond_to_missing? for present keys' do
|
|
39
|
+
provider = provider_class.new({ request_timeout: 30, max_retries: 0,
|
|
40
|
+
retry_interval: 0, retry_backoff_factor: 0,
|
|
41
|
+
retry_interval_randomness: 0,
|
|
42
|
+
ollama_api_base: 'http://localhost:11434' })
|
|
43
|
+
expect(provider.config.respond_to?(:ollama_api_base)).to be true
|
|
44
|
+
expect(provider.config.respond_to?(:nonexistent_key)).to be false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'supports setter methods' do
|
|
48
|
+
provider = provider_class.new({ request_timeout: 30, max_retries: 0,
|
|
49
|
+
retry_interval: 0, retry_backoff_factor: 0,
|
|
50
|
+
retry_interval_randomness: 0 })
|
|
51
|
+
provider.config.new_value = 'hello'
|
|
52
|
+
expect(provider.config.new_value).to eq('hello')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'still works with a Configuration object' do
|
|
56
|
+
provider = provider_class.new(Legion::Extensions::Llm.config)
|
|
57
|
+
expect(provider.config).to be_a(Legion::Extensions::Llm::Configuration)
|
|
58
|
+
expect(provider.config.request_timeout).to be_a(Numeric)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe '#readiness' do
|
|
63
|
+
it 'returns non-live routing readiness metadata without calling provider endpoints' do
|
|
64
|
+
provider_class = Class.new(described_class) do
|
|
65
|
+
def api_base = 'https://provider.invalid'
|
|
66
|
+
def completion_url = '/v1/chat/completions'
|
|
67
|
+
def models_url = '/v1/models'
|
|
68
|
+
def health_url = '/health'
|
|
69
|
+
end
|
|
70
|
+
provider = provider_class.new(Legion::Extensions::Llm.config)
|
|
71
|
+
|
|
72
|
+
expect(provider.readiness).to include(
|
|
73
|
+
provider: provider.slug.to_sym,
|
|
74
|
+
configured: true,
|
|
75
|
+
ready: true,
|
|
76
|
+
api_base: 'https://provider.invalid',
|
|
77
|
+
endpoints: { completion: '/v1/chat/completions', models: '/v1/models', health: '/health' },
|
|
78
|
+
health: { checked: false }
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '#parse_error' do
|
|
84
|
+
let(:provider_class) do
|
|
85
|
+
Class.new(described_class) do
|
|
86
|
+
def api_base = 'https://provider.invalid'
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
|
|
91
|
+
|
|
92
|
+
it 'extracts provider message text from incomplete JSON error bodies' do
|
|
93
|
+
response = Struct.new(:status, :body).new(
|
|
94
|
+
500,
|
|
95
|
+
'{"error":{"message":"The model rejected chat_template_kwargs'
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
expect(provider.parse_error(response)).to eq('The model rejected chat_template_kwargs')
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe 'canonical provider contract' do
|
|
103
|
+
let(:model) do
|
|
104
|
+
Legion::Extensions::Llm::Model::Info.new(
|
|
105
|
+
id: 'test-model',
|
|
106
|
+
provider: :contract,
|
|
107
|
+
instance: :primary,
|
|
108
|
+
capabilities: %i[completion streaming tools],
|
|
109
|
+
context_length: 8192,
|
|
110
|
+
metadata: { max_output_tokens: 2048 }
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
let(:provider_class) do
|
|
115
|
+
model_info = model
|
|
116
|
+
Class.new(described_class) do
|
|
117
|
+
def self.name = 'Provider'
|
|
118
|
+
|
|
119
|
+
define_method(:api_base) { 'https://contract.invalid' }
|
|
120
|
+
define_method(:models_url) { '/v1/models' }
|
|
121
|
+
attr_reader :list_model_calls
|
|
122
|
+
|
|
123
|
+
define_method(:list_models) do |live: false, **filters|
|
|
124
|
+
@list_model_calls ||= []
|
|
125
|
+
@list_model_calls << { live: live, filters: filters }
|
|
126
|
+
[model_info]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render_payload(_messages, **)
|
|
130
|
+
{}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def parse_completion_response(_response)
|
|
134
|
+
Legion::Extensions::Llm::Message.new(role: :assistant, content: 'ok')
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
let(:provider) do
|
|
140
|
+
provider_class.new({ request_timeout: 30, max_retries: 0,
|
|
141
|
+
retry_interval: 0, retry_backoff_factor: 0,
|
|
142
|
+
retry_interval_randomness: 0,
|
|
143
|
+
instance_id: :primary })
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'exposes a canonical chat alias over complete' do
|
|
147
|
+
allow(provider).to receive(:complete).and_return('ok')
|
|
148
|
+
|
|
149
|
+
expect(provider.chat(messages: [], model: model)).to eq('ok')
|
|
150
|
+
expect(provider).to have_received(:complete).with(
|
|
151
|
+
[], tools: [], temperature: nil, model: model, params: {}, headers: {},
|
|
152
|
+
schema: nil, thinking: nil, tool_prefs: nil
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'exposes a canonical stream_chat alias over complete' do
|
|
157
|
+
seen = []
|
|
158
|
+
allow(provider).to receive(:complete) { |_messages, **_opts, &block| block.call('chunk') }
|
|
159
|
+
|
|
160
|
+
provider.stream_chat(messages: [], model: model) { |chunk| seen << chunk }
|
|
161
|
+
|
|
162
|
+
expect(seen).to eq(['chunk'])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'converts live list_models results into model offerings' do
|
|
166
|
+
offerings = provider.discover_offerings(live: true)
|
|
167
|
+
offering = offerings.first
|
|
168
|
+
|
|
169
|
+
expect(offerings.size).to eq(1)
|
|
170
|
+
expect(offering.provider_family).to eq(:provider)
|
|
171
|
+
expect(offering.provider_instance).to eq(:primary)
|
|
172
|
+
expect(offering.model).to eq('test-model')
|
|
173
|
+
expect(offering.usage_type).to eq(:inference)
|
|
174
|
+
expect(offering.capabilities).to include(:completion, :streaming, :tools)
|
|
175
|
+
expect(offering.context_window).to eq(8192)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'passes live discovery filters through to list_models' do
|
|
179
|
+
provider.discover_offerings(live: true, capability: :tools, instance: :primary)
|
|
180
|
+
|
|
181
|
+
expect(provider.list_model_calls).to include(
|
|
182
|
+
live: true,
|
|
183
|
+
filters: { capability: :tools, instance: :primary }
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'filters generated offerings by capability and instance' do
|
|
188
|
+
provider.discover_offerings(live: true)
|
|
189
|
+
|
|
190
|
+
expect(provider.discover_offerings(capability: :tools, instance: :primary)).not_to be_empty
|
|
191
|
+
expect(provider.discover_offerings(capability: :embedding)).to be_empty
|
|
192
|
+
expect(provider.discover_offerings(instance: :other)).to be_empty
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'does not perform live discovery for uncached non-live offerings reads' do
|
|
196
|
+
allow(provider).to receive(:list_models).and_raise('unexpected live discovery')
|
|
197
|
+
|
|
198
|
+
expect(provider.discover_offerings).to eq([])
|
|
199
|
+
expect(provider).not_to have_received(:list_models)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'serves non-live offerings reads from the live discovery cache' do
|
|
203
|
+
provider.discover_offerings(live: true)
|
|
204
|
+
allow(provider).to receive(:list_models).and_raise('unexpected live discovery')
|
|
205
|
+
|
|
206
|
+
expect(provider.discover_offerings(capability: :tools, instance: :primary)).not_to be_empty
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'returns normalized health metadata' do
|
|
210
|
+
expect(provider.health).to include(
|
|
211
|
+
provider: :provider,
|
|
212
|
+
instance_id: :primary,
|
|
213
|
+
status: 'healthy',
|
|
214
|
+
ready: true,
|
|
215
|
+
circuit_state: 'closed'
|
|
216
|
+
)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'provides a deterministic token estimate fallback' do
|
|
220
|
+
expect(provider.count_tokens(messages: [{ content: 'hello world' }], model: model)).to be >= 1
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
it 'summarizes hash-backed tools for debug logging' do
|
|
224
|
+
tools = {
|
|
225
|
+
current: { name: 'current' },
|
|
226
|
+
legacy: { 'name' => 'legacy' }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
expect(provider.send(:debug_tool_names, tools)).to eq(%w[current legacy])
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
it 'deep merges embedding params into the provider payload' do
|
|
233
|
+
captured_payload = nil
|
|
234
|
+
response = instance_double(Faraday::Response)
|
|
235
|
+
connection = instance_double(Legion::Extensions::Llm::Connection)
|
|
236
|
+
embedding_provider_class = Class.new(described_class) do
|
|
237
|
+
def api_base = 'https://contract.invalid'
|
|
238
|
+
def embedding_url(model:) = "/v1/#{model}/embeddings"
|
|
239
|
+
|
|
240
|
+
def render_embedding_payload(text, model:, dimensions:)
|
|
241
|
+
{
|
|
242
|
+
model: model,
|
|
243
|
+
input: text,
|
|
244
|
+
options: {
|
|
245
|
+
dimensions: dimensions,
|
|
246
|
+
normalize: false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def parse_embedding_response(response, model:, text:)
|
|
252
|
+
[response, model, text]
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
embedding_provider = embedding_provider_class.new(provider.config)
|
|
256
|
+
embedding_provider.instance_variable_set(:@connection, connection)
|
|
257
|
+
|
|
258
|
+
allow(connection).to receive(:post) do |_url, payload, &_block|
|
|
259
|
+
captured_payload = payload
|
|
260
|
+
response
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
result = embedding_provider.embed(
|
|
264
|
+
text: 'hello',
|
|
265
|
+
model: 'embed-model',
|
|
266
|
+
dimensions: 1024,
|
|
267
|
+
params: {
|
|
268
|
+
options: { normalize: true },
|
|
269
|
+
encoding_format: 'float'
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
expect(result).to eq([response, 'embed-model', 'hello'])
|
|
274
|
+
expect(connection).to have_received(:post).with('/v1/embed-model/embeddings', kind_of(Hash))
|
|
275
|
+
expect(captured_payload).to eq(
|
|
276
|
+
model: 'embed-model',
|
|
277
|
+
input: 'hello',
|
|
278
|
+
options: {
|
|
279
|
+
dimensions: 1024,
|
|
280
|
+
normalize: true
|
|
281
|
+
},
|
|
282
|
+
encoding_format: 'float'
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
describe '#model_allowed?' do
|
|
288
|
+
let(:provider_class) do
|
|
289
|
+
Class.new(described_class) do
|
|
290
|
+
attr_writer :settings
|
|
291
|
+
|
|
292
|
+
def api_base = 'https://test.invalid'
|
|
293
|
+
|
|
294
|
+
def settings
|
|
295
|
+
@settings || {}
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
|
|
301
|
+
|
|
302
|
+
context 'with no whitelist or blacklist' do
|
|
303
|
+
it 'allows all models' do
|
|
304
|
+
expect(provider.model_allowed?('gpt-5')).to be true
|
|
305
|
+
expect(provider.model_allowed?('claude-opus')).to be true
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
context 'with whitelist' do
|
|
310
|
+
before { provider.settings = { model_whitelist: %w[gpt claude] } }
|
|
311
|
+
|
|
312
|
+
it 'allows models matching whitelist patterns' do
|
|
313
|
+
expect(provider.model_allowed?('gpt-5')).to be true
|
|
314
|
+
expect(provider.model_allowed?('claude-opus-4')).to be true
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it 'blocks models not matching whitelist patterns' do
|
|
318
|
+
expect(provider.model_allowed?('llama-3')).to be false
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
context 'with blacklist' do
|
|
323
|
+
before { provider.settings = { model_blacklist: %w[deprecated preview] } }
|
|
324
|
+
|
|
325
|
+
it 'blocks models matching blacklist patterns' do
|
|
326
|
+
expect(provider.model_allowed?('gpt-5-preview')).to be false
|
|
327
|
+
expect(provider.model_allowed?('deprecated-model')).to be false
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
it 'allows models not matching blacklist patterns' do
|
|
331
|
+
expect(provider.model_allowed?('gpt-5')).to be true
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
context 'with both whitelist and blacklist' do
|
|
336
|
+
before do
|
|
337
|
+
provider.settings = {
|
|
338
|
+
model_whitelist: %w[gpt],
|
|
339
|
+
model_blacklist: %w[preview]
|
|
340
|
+
}
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
it 'applies whitelist first, then blacklist' do
|
|
344
|
+
expect(provider.model_allowed?('gpt-5')).to be true
|
|
345
|
+
expect(provider.model_allowed?('gpt-5-preview')).to be false
|
|
346
|
+
expect(provider.model_allowed?('llama-3')).to be false
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
context 'with case-insensitive matching' do
|
|
351
|
+
before { provider.settings = { model_whitelist: %w[GPT] } }
|
|
352
|
+
|
|
353
|
+
it 'matches case-insensitively' do
|
|
354
|
+
expect(provider.model_allowed?('GPT-5')).to be true
|
|
355
|
+
expect(provider.model_allowed?('gpt-5')).to be true
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
describe 'multi-host URL resolution' do
|
|
361
|
+
let(:provider_class) do
|
|
362
|
+
Class.new(described_class) do
|
|
363
|
+
attr_writer :settings
|
|
364
|
+
|
|
365
|
+
def api_base = resolve_base_url || 'https://fallback.invalid'
|
|
366
|
+
|
|
367
|
+
def settings
|
|
368
|
+
@settings || {}
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
|
|
374
|
+
|
|
375
|
+
describe '#config_base_url' do
|
|
376
|
+
it 'returns the base_url from settings' do
|
|
377
|
+
provider.settings = { base_url: 'http://localhost:11434' }
|
|
378
|
+
expect(provider.config_base_url).to eq('http://localhost:11434')
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it 'returns nil when no settings' do
|
|
382
|
+
expect(provider.config_base_url).to be_nil
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
describe '#strip_scheme' do
|
|
387
|
+
it 'strips http scheme' do
|
|
388
|
+
expect(provider.strip_scheme('http://localhost:11434')).to eq('localhost:11434')
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it 'strips https scheme' do
|
|
392
|
+
expect(provider.strip_scheme('https://api.example.com')).to eq('api.example.com')
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
it 'returns as-is when no scheme' do
|
|
396
|
+
expect(provider.strip_scheme('localhost:11434')).to eq('localhost:11434')
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
describe '#tls_enabled?' do
|
|
401
|
+
it 'returns false by default' do
|
|
402
|
+
expect(provider.tls_enabled?).to be false
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
it 'returns true when tls.enabled is true' do
|
|
406
|
+
provider.settings = { tls: { enabled: true } }
|
|
407
|
+
expect(provider.tls_enabled?).to be true
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
it 'returns false when tls.enabled is false' do
|
|
411
|
+
provider.settings = { tls: { enabled: false } }
|
|
412
|
+
expect(provider.tls_enabled?).to be false
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
describe '#resolve_base_url' do
|
|
417
|
+
it 'returns nil when no config_base_url' do
|
|
418
|
+
expect(provider.resolve_base_url).to be_nil
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
it 'returns the single URL when unreachable (falls back to first)' do
|
|
422
|
+
provider.settings = { base_url: 'unreachable.invalid:9999' }
|
|
423
|
+
allow(provider).to receive(:url_reachable?).and_return(false)
|
|
424
|
+
|
|
425
|
+
expect(provider.resolve_base_url).to eq('http://unreachable.invalid:9999')
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
it 'handles array of URLs and picks first reachable' do
|
|
429
|
+
provider.settings = { base_url: ['unreachable.invalid:9999', 'reachable.invalid:8080'] }
|
|
430
|
+
allow(provider).to receive(:url_reachable?).and_return(false, true)
|
|
431
|
+
|
|
432
|
+
result = provider.resolve_base_url
|
|
433
|
+
expect(result).to eq('http://reachable.invalid:8080')
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
it 'falls back to first URL if none are reachable' do
|
|
437
|
+
provider.settings = { base_url: ['a.invalid:1', 'b.invalid:2'] }
|
|
438
|
+
allow(provider).to receive(:url_reachable?).and_return(false, false)
|
|
439
|
+
|
|
440
|
+
expect(provider.resolve_base_url).to eq('http://a.invalid:1')
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
describe '#url_reachable?' do
|
|
445
|
+
it 'returns false for unreachable URLs' do
|
|
446
|
+
expect(provider.url_reachable?('http://unreachable.invalid:9999')).to be false
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
describe 'cache tier selection' do
|
|
452
|
+
let(:provider_class) do
|
|
453
|
+
Class.new(described_class) do
|
|
454
|
+
attr_writer :settings
|
|
455
|
+
|
|
456
|
+
def api_base = 'https://test.invalid'
|
|
457
|
+
|
|
458
|
+
def settings
|
|
459
|
+
@settings || {}
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
|
|
465
|
+
|
|
466
|
+
describe '#cache_local_instance?' do
|
|
467
|
+
it 'returns true for localhost URLs' do
|
|
468
|
+
provider.settings = { base_url: 'http://localhost:11434' }
|
|
469
|
+
expect(provider.cache_local_instance?).to be true
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
it 'returns true for 127.0.0.1 URLs' do
|
|
473
|
+
provider.settings = { base_url: 'http://127.0.0.1:11434' }
|
|
474
|
+
expect(provider.cache_local_instance?).to be true
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
it 'returns true for ::1 URLs' do
|
|
478
|
+
provider.settings = { base_url: 'http://[::1]:11434' }
|
|
479
|
+
expect(provider.cache_local_instance?).to be true
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
it 'returns false for remote URLs' do
|
|
483
|
+
provider.settings = { base_url: 'https://api.openai.com' }
|
|
484
|
+
expect(provider.cache_local_instance?).to be false
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
it 'returns true if any URL in array is local' do
|
|
488
|
+
provider.settings = { base_url: ['https://api.openai.com', 'http://localhost:11434'] }
|
|
489
|
+
expect(provider.cache_local_instance?).to be true
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
it 'returns false when no base_url configured' do
|
|
493
|
+
expect(provider.cache_local_instance?).to be false
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
describe '#cache_instance_key' do
|
|
498
|
+
it 'returns instance_id for local instances' do
|
|
499
|
+
provider.settings = { base_url: 'http://localhost:11434' }
|
|
500
|
+
expect(provider.cache_instance_key).to eq('default')
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
it 'returns SHA256 prefix for remote instances' do
|
|
504
|
+
provider.settings = { base_url: 'https://api.openai.com' }
|
|
505
|
+
key = provider.cache_instance_key
|
|
506
|
+
expect(key.length).to eq(12)
|
|
507
|
+
expect(key).to match(/\A[0-9a-f]+\z/)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
it 'produces deterministic keys for same URLs' do
|
|
511
|
+
provider.settings = { base_url: 'https://api.openai.com' }
|
|
512
|
+
key1 = provider.cache_instance_key
|
|
513
|
+
key2 = provider.cache_instance_key
|
|
514
|
+
expect(key1).to eq(key2)
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
describe '#model_cache_get' do
|
|
519
|
+
it 'returns nil when Legion::Cache is not defined' do
|
|
520
|
+
expect(provider.model_cache_get('key')).to be_nil
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
describe '#model_detail' do
|
|
525
|
+
it 'returns nil by default (no fetch_model_detail override)' do
|
|
526
|
+
expect(provider.model_detail('test-model')).to be_nil
|
|
527
|
+
end
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
describe 'prompt caching' do
|
|
532
|
+
let(:provider_class) do
|
|
533
|
+
Class.new(described_class) do
|
|
534
|
+
attr_writer :settings
|
|
535
|
+
|
|
536
|
+
def api_base = 'https://test.invalid'
|
|
537
|
+
|
|
538
|
+
def settings
|
|
539
|
+
@settings || {}
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
describe '#cache_enabled?' do
|
|
545
|
+
it 'returns true when llm_cache_enabled is true in config' do
|
|
546
|
+
Legion::Extensions::Llm.config.llm_cache_enabled = true
|
|
547
|
+
provider = provider_class.new(Legion::Extensions::Llm.config)
|
|
548
|
+
|
|
549
|
+
expect(provider.cache_enabled?).to be true
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
it 'returns false when llm_cache_enabled is false in config' do
|
|
553
|
+
Legion::Extensions::Llm.config.llm_cache_enabled = false
|
|
554
|
+
provider = provider_class.new(Legion::Extensions::Llm.config)
|
|
555
|
+
|
|
556
|
+
expect(provider.cache_enabled?).to be false
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
it 'returns false when llm_cache_enabled is not set on config' do
|
|
560
|
+
config = { request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0,
|
|
561
|
+
retry_interval_randomness: 0 }
|
|
562
|
+
provider = provider_class.new(config)
|
|
563
|
+
|
|
564
|
+
expect(provider.cache_enabled?).to be false
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
describe '#cache_control_prefix_tokens' do
|
|
569
|
+
it 'returns the configured value when set' do
|
|
570
|
+
Legion::Extensions::Llm.config.cache_control_prefix_tokens = 6
|
|
571
|
+
provider = provider_class.new(Legion::Extensions::Llm.config)
|
|
572
|
+
|
|
573
|
+
expect(provider.cache_control_prefix_tokens).to eq(6)
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
it 'defaults to 4 when not explicitly set' do
|
|
577
|
+
Legion::Extensions::Llm.config.cache_control_prefix_tokens = 4
|
|
578
|
+
provider = provider_class.new(Legion::Extensions::Llm.config)
|
|
579
|
+
|
|
580
|
+
expect(provider.cache_control_prefix_tokens).to eq(4)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
it 'defaults to 4 when config does not respond to the option' do
|
|
584
|
+
config = { request_timeout: 30, max_retries: 0, retry_interval: 0, retry_backoff_factor: 0,
|
|
585
|
+
retry_interval_randomness: 0 }
|
|
586
|
+
provider = provider_class.new(config)
|
|
587
|
+
|
|
588
|
+
expect(provider.cache_control_prefix_tokens).to eq(4)
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::RegistryEventBuilder do
|
|
6
|
+
subject(:builder) { described_class.new(provider_family: :ollama) }
|
|
7
|
+
|
|
8
|
+
describe '#provider_family' do
|
|
9
|
+
it 'normalizes to a downcased symbol' do
|
|
10
|
+
b = described_class.new(provider_family: 'Anthropic')
|
|
11
|
+
expect(b.provider_family).to eq(:anthropic)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#model_available' do
|
|
16
|
+
let(:model) do
|
|
17
|
+
Legion::Extensions::Llm::Model::Info.from_hash(
|
|
18
|
+
id: 'llama-3.1-8b',
|
|
19
|
+
name: 'Llama 3.1 8B',
|
|
20
|
+
provider: 'ollama',
|
|
21
|
+
capabilities: %w[completion streaming],
|
|
22
|
+
modalities: { input: %w[text], output: %w[text] },
|
|
23
|
+
context_window: 128_000,
|
|
24
|
+
max_output_tokens: 8192
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
let(:readiness) { { ready: true, configured: true } }
|
|
29
|
+
|
|
30
|
+
it 'builds a RegistryEvent with offering data' do
|
|
31
|
+
event = builder.model_available(model, readiness: readiness)
|
|
32
|
+
expect(event).to be_a(Legion::Extensions::Llm::Routing::RegistryEvent)
|
|
33
|
+
expect(event.event_type).to eq(:offering_available)
|
|
34
|
+
expect(event.offering.model).to eq('llama-3.1-8b')
|
|
35
|
+
expect(event.offering.provider_family).to eq(:ollama)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'includes model health from readiness' do
|
|
39
|
+
event = builder.model_available(model, readiness: readiness)
|
|
40
|
+
expect(event.health[:ready]).to be true
|
|
41
|
+
expect(event.health[:status]).to eq(:available)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'includes extension metadata' do
|
|
45
|
+
event = builder.model_available(model, readiness: readiness)
|
|
46
|
+
expect(event.metadata[:extension]).to eq(:llm_ollama)
|
|
47
|
+
expect(event.metadata[:provider]).to eq(:ollama)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#readiness' do
|
|
52
|
+
it 'builds an available event when ready' do
|
|
53
|
+
event = builder.readiness({ ready: true, configured: true })
|
|
54
|
+
expect(event.event_type).to eq(:offering_available)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'builds an unavailable event when not ready' do
|
|
58
|
+
event = builder.readiness({ ready: false, configured: true })
|
|
59
|
+
expect(event.event_type).to eq(:offering_unavailable)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'preserves error details from health' do
|
|
63
|
+
event = builder.readiness({ ready: false, health: { error: 'ConnectionRefused', message: 'refused' } })
|
|
64
|
+
expect(event.health[:error_class]).to eq('ConnectionRefused')
|
|
65
|
+
expect(event.health[:error]).to eq('refused')
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::RegistryPublisher do
|
|
6
|
+
subject(:publisher) { described_class.new(provider_family: :ollama, builder: builder) }
|
|
7
|
+
|
|
8
|
+
let(:builder) { instance_double(Legion::Extensions::Llm::RegistryEventBuilder) }
|
|
9
|
+
|
|
10
|
+
describe '#app_id' do
|
|
11
|
+
it 'includes the provider family' do
|
|
12
|
+
expect(publisher.app_id).to eq('lex-llm-ollama')
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#provider_family' do
|
|
17
|
+
it 'normalizes to a downcased symbol' do
|
|
18
|
+
pub = described_class.new(provider_family: 'Anthropic', builder: builder)
|
|
19
|
+
expect(pub.provider_family).to eq(:anthropic)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|