lex-llm 0.7.6 → 0.8.4
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 +139 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
- data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
- data/lib/legion/extensions/llm/canonical/message.rb +53 -69
- data/lib/legion/extensions/llm/canonical/params.rb +58 -34
- data/lib/legion/extensions/llm/canonical/request.rb +68 -56
- data/lib/legion/extensions/llm/canonical/response.rb +62 -78
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
- 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 +7 -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 -156
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
- 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 +36 -100
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -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 +48 -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 +14 -26
- 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 +8 -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 +55 -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/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
- 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/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -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 +173 -95
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- 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 +82 -47
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
- 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 +7 -3
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -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 +97 -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 -43
- 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
- metadata +13 -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 -310
- 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 -340
- 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
|
@@ -1,340 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'spec_helper'
|
|
4
|
-
require 'legion/extensions/llm/inventory/scoped_refresher'
|
|
5
|
-
require 'legion/extensions/llm/inventory/registry'
|
|
6
|
-
require_relative '../../../../support/ssot_registry_helpers'
|
|
7
|
-
|
|
8
|
-
RSpec.describe Legion::Extensions::Llm::Inventory::ScopedRefresher do
|
|
9
|
-
describe '.compose_id' do
|
|
10
|
-
it 'builds a 5-part colon-separated id (G22)' do
|
|
11
|
-
id = described_class.compose_id(
|
|
12
|
-
tier: :direct, provider_family: :vllm, instance_id: :apollo,
|
|
13
|
-
type: :inference, model: 'gemma-12b'
|
|
14
|
-
)
|
|
15
|
-
expect(id).to eq('direct:vllm:apollo:inference:gemma-12b')
|
|
16
|
-
expect(id.split(':').size).to eq(5)
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
describe '#tick write-then-delete-orphans (G7)' do
|
|
21
|
-
let(:inventory_writes) { [] }
|
|
22
|
-
let(:inventory_deletes) { [] }
|
|
23
|
-
|
|
24
|
-
before do
|
|
25
|
-
stub_const('Legion::LLM::Inventory', Module.new)
|
|
26
|
-
allow(Legion::LLM::Inventory).to receive(:write_lane) { |lane:, **| inventory_writes << lane[:id] }
|
|
27
|
-
allow(Legion::LLM::Inventory).to receive(:delete_lane) { |id:, **| inventory_deletes << id }
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def make_actor(models)
|
|
31
|
-
klass = Class.new do
|
|
32
|
-
include Legion::Extensions::Llm::Inventory::ScopedRefresher
|
|
33
|
-
|
|
34
|
-
def self.every_seconds = 60
|
|
35
|
-
def scope_key = { provider: :test }
|
|
36
|
-
def credential_hash = 'testhash'
|
|
37
|
-
|
|
38
|
-
attr_accessor :models
|
|
39
|
-
|
|
40
|
-
def compute_lanes_for_scope
|
|
41
|
-
@models.map do |m|
|
|
42
|
-
{
|
|
43
|
-
id: Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(
|
|
44
|
-
tier: :direct, provider_family: :test, instance_id: :default,
|
|
45
|
-
type: :inference, model: m
|
|
46
|
-
),
|
|
47
|
-
tier: :direct, provider_family: :test, instance_id: :default,
|
|
48
|
-
model: m, type: :inference
|
|
49
|
-
}
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def log = Logger.new(File::NULL)
|
|
54
|
-
def handle_exception(_err, **) = nil
|
|
55
|
-
end
|
|
56
|
-
actor = klass.new
|
|
57
|
-
actor.models = models
|
|
58
|
-
actor
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
it 'writes new lanes before deleting orphans (zero-results race window eliminated)' do
|
|
62
|
-
actor = make_actor(%w[gemma-12b])
|
|
63
|
-
actor.tick
|
|
64
|
-
expect(inventory_writes).to include('direct:test:default:inference:gemma-12b')
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
it 'deletes orphaned lanes (present on previous tick, absent on current)' do
|
|
68
|
-
actor = make_actor(%w[gemma-12b gemma-31b])
|
|
69
|
-
actor.tick
|
|
70
|
-
actor.models = %w[gemma-31b]
|
|
71
|
-
actor.tick
|
|
72
|
-
expect(inventory_deletes).to include('direct:test:default:inference:gemma-12b')
|
|
73
|
-
expect(inventory_writes.last).to eq('direct:test:default:inference:gemma-31b')
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
it 'writes nothing when compute raises, leaving previous lanes in place' do
|
|
77
|
-
actor = make_actor(%w[gemma-12b])
|
|
78
|
-
actor.tick
|
|
79
|
-
allow(actor).to receive(:compute_lanes_for_scope).and_raise(StandardError, 'net error')
|
|
80
|
-
actor.tick
|
|
81
|
-
expect(inventory_writes.count('direct:test:default:inference:gemma-12b')).to eq(1)
|
|
82
|
-
expect(inventory_deletes).to be_empty
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
describe 'auth-failure cooldown circuit (P2 commit 3)' do
|
|
87
|
-
let(:cache_store) { {} }
|
|
88
|
-
let(:cooldown_key) { 'llm_auth_failed:testhash' }
|
|
89
|
-
|
|
90
|
-
before do
|
|
91
|
-
stub_const('Legion::LLM::Inventory', Module.new)
|
|
92
|
-
allow(Legion::LLM::Inventory).to receive(:write_lane)
|
|
93
|
-
allow(Legion::LLM::Inventory).to receive(:delete_lane)
|
|
94
|
-
|
|
95
|
-
stub_const('Legion::Cache::Local', Module.new)
|
|
96
|
-
allow(Legion::Cache::Local).to receive(:get) { |k| cache_store[k] }
|
|
97
|
-
allow(Legion::Cache::Local).to receive(:set) do |k, v, **|
|
|
98
|
-
cache_store[k] = v
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def make_auth_fail_actor(**)
|
|
103
|
-
klass = Class.new do
|
|
104
|
-
include Legion::Extensions::Llm::Inventory::ScopedRefresher
|
|
105
|
-
|
|
106
|
-
def self.every_seconds = 60
|
|
107
|
-
def scope_key = { provider: :test }
|
|
108
|
-
def credential_hash = 'testhash'
|
|
109
|
-
|
|
110
|
-
attr_accessor :should_raise, :raise_error
|
|
111
|
-
|
|
112
|
-
def compute_lanes_for_scope
|
|
113
|
-
raise @raise_error if @should_raise
|
|
114
|
-
|
|
115
|
-
[]
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def log
|
|
119
|
-
@log ||= begin
|
|
120
|
-
l = Object.new
|
|
121
|
-
def l.warn(_msg) = nil
|
|
122
|
-
def l.info(_msg) = nil
|
|
123
|
-
def l.debug(_msg) = nil
|
|
124
|
-
l
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def handle_exception(_err, **) = nil
|
|
129
|
-
end
|
|
130
|
-
actor = klass.new
|
|
131
|
-
actor.should_raise = false
|
|
132
|
-
actor.raise_error = nil
|
|
133
|
-
actor
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
it 'writes auth cooldown key when compute raises with HTTP 401 status' do
|
|
137
|
-
actor = make_auth_fail_actor
|
|
138
|
-
err = StandardError.new('Unauthorized')
|
|
139
|
-
err.define_singleton_method(:status_code) { 401 }
|
|
140
|
-
actor.raise_error = err
|
|
141
|
-
actor.should_raise = true
|
|
142
|
-
|
|
143
|
-
actor.tick
|
|
144
|
-
|
|
145
|
-
expect(cache_store).to have_key(cooldown_key)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
it 'writes auth cooldown key when compute raises with HTTP 403 status' do
|
|
149
|
-
actor = make_auth_fail_actor
|
|
150
|
-
err = StandardError.new('Forbidden')
|
|
151
|
-
err.define_singleton_method(:http_status) { 403 }
|
|
152
|
-
actor.raise_error = err
|
|
153
|
-
actor.should_raise = true
|
|
154
|
-
|
|
155
|
-
actor.tick
|
|
156
|
-
|
|
157
|
-
expect(cache_store).to have_key(cooldown_key)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
it 'writes auth cooldown key when compute raises with unauthorized message' do
|
|
161
|
-
actor = make_auth_fail_actor
|
|
162
|
-
actor.raise_error = StandardError.new('invalid_api_key: bad credentials')
|
|
163
|
-
actor.should_raise = true
|
|
164
|
-
|
|
165
|
-
actor.tick
|
|
166
|
-
|
|
167
|
-
expect(cache_store).to have_key(cooldown_key)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
it 'skips compute_lanes_for_scope when cooldown key is present' do
|
|
171
|
-
actor = make_auth_fail_actor
|
|
172
|
-
actor.should_raise = false
|
|
173
|
-
cache_store[cooldown_key] = 1 # simulate active cooldown
|
|
174
|
-
|
|
175
|
-
compute_called = false
|
|
176
|
-
actor.define_singleton_method(:compute_lanes_for_scope) do
|
|
177
|
-
compute_called = true
|
|
178
|
-
[]
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
actor.tick
|
|
182
|
-
expect(compute_called).to be false
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
it 'calls compute_lanes_for_scope after cooldown TTL expires' do
|
|
186
|
-
actor = make_auth_fail_actor
|
|
187
|
-
actor.should_raise = false
|
|
188
|
-
# Cooldown expired — key absent
|
|
189
|
-
cache_store.delete(cooldown_key)
|
|
190
|
-
|
|
191
|
-
compute_called = false
|
|
192
|
-
actor.define_singleton_method(:compute_lanes_for_scope) do
|
|
193
|
-
compute_called = true
|
|
194
|
-
[]
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
actor.tick
|
|
198
|
-
expect(compute_called).to be true
|
|
199
|
-
end
|
|
200
|
-
|
|
201
|
-
it 'does NOT write cooldown key for non-auth errors' do
|
|
202
|
-
actor = make_auth_fail_actor
|
|
203
|
-
actor.raise_error = StandardError.new('connection timeout: net unreachable')
|
|
204
|
-
actor.should_raise = true
|
|
205
|
-
|
|
206
|
-
actor.tick
|
|
207
|
-
|
|
208
|
-
expect(cache_store).not_to have_key(cooldown_key)
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
describe Legion::Extensions::Llm::Inventory::ScopedRefresher::LegacyCoordinatorAdapter do
|
|
213
|
-
include SsotRegistryHelpers
|
|
214
|
-
|
|
215
|
-
inventory = Legion::Extensions::Llm::Inventory
|
|
216
|
-
errors = inventory::Errors
|
|
217
|
-
|
|
218
|
-
let(:fake_inventory) do
|
|
219
|
-
Class.new do
|
|
220
|
-
attr_reader :lanes
|
|
221
|
-
|
|
222
|
-
def initialize
|
|
223
|
-
@lanes = {}
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
def write_lane(lane:, **)
|
|
227
|
-
@lanes[lane[:id]] = lane
|
|
228
|
-
lane
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
def delete_lane(id:, **)
|
|
232
|
-
@lanes.delete(id)
|
|
233
|
-
:deleted
|
|
234
|
-
end
|
|
235
|
-
|
|
236
|
-
def lanes_for(provider:, instance:, **)
|
|
237
|
-
@lanes.values.select { |l| l[:provider_family].to_s == provider.to_s && l[:instance_id].to_s == instance.to_s }
|
|
238
|
-
end
|
|
239
|
-
end.new
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
let(:key) { instance_key(family: 'vllm', instance: 'h200') }
|
|
243
|
-
let(:adapter) { described_class.new(provider_family: :vllm) }
|
|
244
|
-
|
|
245
|
-
before do
|
|
246
|
-
inventory::Registry.reset!
|
|
247
|
-
stub_const('Legion::LLM::Inventory', fake_inventory)
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def activate_chat
|
|
251
|
-
claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
|
|
252
|
-
Legion::Extensions::Llm::Inventory::Registry.snapshot
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
it 'projects an available instance into an exact five-field legacy lane and round-trips' do
|
|
256
|
-
allow(Legion::Extensions::Llm::Taxonomies).to receive(:lane_type_for).and_call_original
|
|
257
|
-
|
|
258
|
-
result = adapter.sync_snapshot(snapshot: activate_chat, instance_key: key, mutation_reason: :activated)
|
|
259
|
-
expect(result).to eq(:applied)
|
|
260
|
-
expect(Legion::Extensions::Llm::Taxonomies).to have_received(:lane_type_for).with(operation: :chat)
|
|
261
|
-
|
|
262
|
-
legacy_id = 'local:vllm:h200:inference:gemma4'
|
|
263
|
-
expect(fake_inventory.lanes).to have_key(legacy_id)
|
|
264
|
-
lane = fake_inventory.lanes[legacy_id]
|
|
265
|
-
expect(lane[:id]).to eq(legacy_id)
|
|
266
|
-
expect(lane[:provider_instance]).to eq('h200')
|
|
267
|
-
expect(lane[:enabled]).to be(true)
|
|
268
|
-
expect(lane[:capabilities]).to include(:completion)
|
|
269
|
-
expect(lane[:metadata]).to include(ssot_v3_compatibility_projection: true)
|
|
270
|
-
round_trip = fake_inventory.lanes_for(provider: :vllm, instance: 'h200', type: :inference, model: 'gemma4')
|
|
271
|
-
expect(round_trip.map { |l| l[:id] }).to eq([legacy_id])
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
it 'deletes the old projection when the instance is removed (desired set empty)' do
|
|
275
|
-
token = claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
|
|
276
|
-
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
|
|
277
|
-
expect(fake_inventory.lanes).not_to be_empty
|
|
278
|
-
|
|
279
|
-
inventory::Registry.remove_instance(instance_key: key, publisher_token: token)
|
|
280
|
-
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :removed)
|
|
281
|
-
expect(fake_inventory.lanes).to be_empty
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
it 'deletes the old projection when the instance becomes unavailable' do
|
|
285
|
-
token = claim_and_activate(key: key, callable: fake_callable, coordinator: probe_coordinator(key))
|
|
286
|
-
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
|
|
287
|
-
inventory::Registry.dispatch_instance_unavailable(instance_key: key, publisher_token_id: token.publisher_token_id, reason: 'down')
|
|
288
|
-
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :instance_unavailable)
|
|
289
|
-
expect(fake_inventory.lanes).to be_empty
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
it 'fails closed and omits an ambiguous group that collapses multiple offerings' do
|
|
293
|
-
token = inventory::Registry.claim_instance(instance_key: key, callable: fake_callable, probe_request_handle: probe_coordinator(key))
|
|
294
|
-
probe = inventory::Registry.readiness_probe_started(instance_key: key, publisher_token: token)
|
|
295
|
-
two = drafts(native: 'a', model: 'gemma4') + drafts(native: 'b', model: 'gemma4')
|
|
296
|
-
inventory::Registry.activate_instance_snapshot(publisher_token: token, instance_key: key, offerings: two, sequence: 0, probe_token: probe)
|
|
297
|
-
adapter.sync_snapshot(snapshot: inventory::Registry.snapshot, instance_key: key, mutation_reason: :activated)
|
|
298
|
-
expect(fake_inventory.lanes).to be_empty
|
|
299
|
-
end
|
|
300
|
-
|
|
301
|
-
it 'returns :not_loaded when Legion::LLM::Inventory is absent' do
|
|
302
|
-
hide_const('Legion::LLM::Inventory')
|
|
303
|
-
expect(adapter.sync_snapshot(snapshot: activate_chat, instance_key: key, mutation_reason: :activated)).to eq(:not_loaded)
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
it 'rejects a different provider family, an invalid snapshot, and an unknown reason' do
|
|
307
|
-
snapshot = activate_chat
|
|
308
|
-
other = instance_key(family: 'openai', instance: 'h200')
|
|
309
|
-
expect { adapter.sync_snapshot(snapshot: snapshot, instance_key: other, mutation_reason: :activated) }
|
|
310
|
-
.to raise_error(errors::ValidationError)
|
|
311
|
-
expect { adapter.sync_snapshot(snapshot: {}, instance_key: key, mutation_reason: :activated) }
|
|
312
|
-
.to raise_error(errors::ValidationError)
|
|
313
|
-
expect { adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :made_up) }
|
|
314
|
-
.to raise_error(errors::ValidationError)
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
it 'returns :failed and preserves the prior tracked set when a write raises' do
|
|
318
|
-
snapshot = activate_chat
|
|
319
|
-
allow(fake_inventory).to receive(:write_lane).and_raise(StandardError, 'coordinator down')
|
|
320
|
-
expect(adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :activated)).to eq(:failed)
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
it 'never lets an old five-part id be accepted as a canonical lane:v1: identity' do
|
|
324
|
-
offering_id = inventory::Identity.offering_id(instance_key: key, provider_native_key: 'gemma4')
|
|
325
|
-
expect do
|
|
326
|
-
inventory::Identity.validate_lane_id!(
|
|
327
|
-
value: 'local:vllm:h200:inference:gemma4', instance_key: key,
|
|
328
|
-
operation: :chat, model: 'gemma4', offering_id: offering_id
|
|
329
|
-
)
|
|
330
|
-
end.to raise_error(errors::ValidationError)
|
|
331
|
-
end
|
|
332
|
-
|
|
333
|
-
it 'leaves the new Registry unchanged when the legacy projection runs' do
|
|
334
|
-
snapshot = activate_chat
|
|
335
|
-
generation = inventory::Registry.snapshot.generation
|
|
336
|
-
adapter.sync_snapshot(snapshot: snapshot, instance_key: key, mutation_reason: :activated)
|
|
337
|
-
expect(inventory::Registry.snapshot.generation).to eq(generation)
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
end
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'spec_helper'
|
|
4
|
-
|
|
5
|
-
RSpec.describe Legion::Extensions::Llm::Message do
|
|
6
|
-
describe '#content' do
|
|
7
|
-
it 'normalizes nil content to empty string for assistant tool-call messages' do
|
|
8
|
-
tool_call = Legion::Extensions::Llm::ToolCall.new(id: 'call_1', name: 'weather', arguments: {})
|
|
9
|
-
message = described_class.new(role: :assistant, content: nil, tool_calls: { 'call_1' => tool_call })
|
|
10
|
-
|
|
11
|
-
expect(message.content).to eq('')
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
it 'keeps nil content for messages without tool calls' do
|
|
15
|
-
message = described_class.new(role: :assistant, content: nil, tool_calls: nil)
|
|
16
|
-
|
|
17
|
-
expect(message.content).to be_nil
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
describe '#to_h' do
|
|
22
|
-
it 'does not expose hidden thinking text in public message serialization' do
|
|
23
|
-
message = described_class.new(
|
|
24
|
-
role: :assistant,
|
|
25
|
-
content: 'visible',
|
|
26
|
-
thinking: Legion::Extensions::Llm::Thinking.build(text: 'hidden', signature: 'sig-1'),
|
|
27
|
-
raw: { reasoning_content: 'raw hidden' }
|
|
28
|
-
)
|
|
29
|
-
|
|
30
|
-
expect(message.to_h).to eq(role: :assistant, content: 'visible')
|
|
31
|
-
expect(Legion::JSON.dump(message.to_h)).not_to include('hidden', 'reasoning_content', 'sig-1')
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
it 'keeps hidden thinking text available through internal message serialization' do
|
|
35
|
-
message = described_class.new(
|
|
36
|
-
role: :assistant,
|
|
37
|
-
content: 'visible',
|
|
38
|
-
thinking: Legion::Extensions::Llm::Thinking.build(text: 'hidden', signature: 'sig-1'),
|
|
39
|
-
raw: { reasoning_content: 'raw hidden' }
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
expect(message.to_internal_h).to include(
|
|
43
|
-
role: :assistant,
|
|
44
|
-
content: 'visible',
|
|
45
|
-
thinking: 'hidden',
|
|
46
|
-
thinking_signature: 'sig-1',
|
|
47
|
-
raw: { reasoning_content: 'raw hidden' }
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
describe Legion::Extensions::Llm::Chunk do
|
|
53
|
-
it 'does not expose hidden thinking text in public chunk serialization' do
|
|
54
|
-
chunk = described_class.new(
|
|
55
|
-
role: :assistant,
|
|
56
|
-
content: 'visible',
|
|
57
|
-
thinking: Legion::Extensions::Llm::Thinking.build(text: 'hidden')
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
expect(chunk.to_h).to eq(role: :assistant, content: 'visible')
|
|
61
|
-
expect(chunk.to_internal_h).to include(thinking: 'hidden')
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'spec_helper'
|
|
4
|
-
|
|
5
|
-
RSpec.describe Legion::Extensions::Llm::Model::Info do
|
|
6
|
-
describe 'construction' do
|
|
7
|
-
it 'creates an Info with all fields' do
|
|
8
|
-
info = described_class.new(
|
|
9
|
-
id: 'llama-3.1-8b',
|
|
10
|
-
name: 'Llama 3.1 8B',
|
|
11
|
-
provider: 'Ollama',
|
|
12
|
-
instance: :worker_one,
|
|
13
|
-
family: 'Llama',
|
|
14
|
-
capabilities: %i[completion vision tools],
|
|
15
|
-
context_length: 128_000,
|
|
16
|
-
parameter_count: 8_000_000_000,
|
|
17
|
-
parameter_size: '8B',
|
|
18
|
-
quantization: 'Q4_K_M',
|
|
19
|
-
size_bytes: 4_370_000_000,
|
|
20
|
-
modalities_input: %w[text image],
|
|
21
|
-
modalities_output: %w[text],
|
|
22
|
-
metadata: { source: 'ollama' }
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
expect(info).to have_attributes(
|
|
26
|
-
id: 'llama-3.1-8b',
|
|
27
|
-
name: 'Llama 3.1 8B',
|
|
28
|
-
provider: :ollama,
|
|
29
|
-
instance: :worker_one,
|
|
30
|
-
family: 'llama',
|
|
31
|
-
context_length: 128_000,
|
|
32
|
-
parameter_count: 8_000_000_000,
|
|
33
|
-
parameter_size: '8B',
|
|
34
|
-
quantization: 'Q4_K_M',
|
|
35
|
-
size_bytes: 4_370_000_000
|
|
36
|
-
)
|
|
37
|
-
expect(info.capabilities).to eq(%i[completion vision tools])
|
|
38
|
-
expect(info.modalities_input).to eq(%i[text image])
|
|
39
|
-
expect(info.modalities_output).to eq([:text])
|
|
40
|
-
expect(info.metadata).to eq({ source: 'ollama' })
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
it 'requires only id' do
|
|
44
|
-
info = described_class.new(id: 'gpt-5')
|
|
45
|
-
|
|
46
|
-
expect(info.id).to eq('gpt-5')
|
|
47
|
-
expect(info.name).to eq('gpt-5')
|
|
48
|
-
expect(info.provider).to eq(:'')
|
|
49
|
-
expect(info.instance).to eq(:default)
|
|
50
|
-
expect(info.family).to be_nil
|
|
51
|
-
expect(info.capabilities).to eq([])
|
|
52
|
-
expect(info.context_length).to be_nil
|
|
53
|
-
expect(info.parameter_count).to be_nil
|
|
54
|
-
expect(info.parameter_size).to be_nil
|
|
55
|
-
expect(info.quantization).to be_nil
|
|
56
|
-
expect(info.size_bytes).to be_nil
|
|
57
|
-
expect(info.modalities_input).to eq([])
|
|
58
|
-
expect(info.modalities_output).to eq([])
|
|
59
|
-
expect(info.metadata).to eq({})
|
|
60
|
-
end
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
describe 'normalization' do
|
|
64
|
-
it 'strips whitespace from id and name' do
|
|
65
|
-
info = described_class.new(id: ' gpt-5 ', name: ' GPT 5 ')
|
|
66
|
-
expect(info.id).to eq('gpt-5')
|
|
67
|
-
expect(info.name).to eq('GPT 5')
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
it 'converts provider to downcased symbol' do
|
|
71
|
-
info = described_class.new(id: 'x', provider: 'Anthropic')
|
|
72
|
-
expect(info.provider).to eq(:anthropic)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
it 'converts instance to downcased symbol' do
|
|
76
|
-
info = described_class.new(id: 'x', instance: 'Worker_Two')
|
|
77
|
-
expect(info.instance).to eq(:worker_two)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
it 'defaults instance to :default when nil' do
|
|
81
|
-
info = described_class.new(id: 'x', instance: nil)
|
|
82
|
-
expect(info.instance).to eq(:default)
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
it 'downcases and strips family' do
|
|
86
|
-
info = described_class.new(id: 'x', family: ' Llama ')
|
|
87
|
-
expect(info.family).to eq('llama')
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
it 'normalizes capabilities to downcased symbols and deduplicates' do
|
|
91
|
-
info = described_class.new(id: 'x', capabilities: ['Completion', :completion, 'VISION'])
|
|
92
|
-
expect(info.capabilities).to eq(%i[completion vision])
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
it 'adds the canonical tools capability for function-calling aliases' do
|
|
96
|
-
info = described_class.new(id: 'x', capabilities: %w[completion function_calling functions])
|
|
97
|
-
|
|
98
|
-
expect(info.capabilities).to include(:completion, :function_calling, :functions, :tools)
|
|
99
|
-
expect(info.tools?).to be true
|
|
100
|
-
expect(info.supports?(:tools)).to be true
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
it 'converts context_length to integer' do
|
|
104
|
-
info = described_class.new(id: 'x', context_length: '128000')
|
|
105
|
-
expect(info.context_length).to eq(128_000)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
it 'converts parameter_count to integer' do
|
|
109
|
-
info = described_class.new(id: 'x', parameter_count: '8000000000')
|
|
110
|
-
expect(info.parameter_count).to eq(8_000_000_000)
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
it 'converts size_bytes to integer' do
|
|
114
|
-
info = described_class.new(id: 'x', size_bytes: '4370000000')
|
|
115
|
-
expect(info.size_bytes).to eq(4_370_000_000)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
it 'normalizes modalities to downcased symbols' do
|
|
119
|
-
info = described_class.new(id: 'x', modalities_input: %w[Text IMAGE], modalities_output: ['Text'])
|
|
120
|
-
expect(info.modalities_input).to eq(%i[text image])
|
|
121
|
-
expect(info.modalities_output).to eq([:text])
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
it 'coerces non-hash metadata to empty hash' do
|
|
125
|
-
info = described_class.new(id: 'x', metadata: 'invalid')
|
|
126
|
-
expect(info.metadata).to eq({})
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
describe 'capability predicates' do
|
|
131
|
-
let(:info) do
|
|
132
|
-
described_class.new(
|
|
133
|
-
id: 'test',
|
|
134
|
-
capabilities: %i[completion embedding vision tools thinking]
|
|
135
|
-
)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
it { expect(info.completion?).to be true }
|
|
139
|
-
it { expect(info.embedding?).to be true }
|
|
140
|
-
it { expect(info.vision?).to be true }
|
|
141
|
-
it { expect(info.tools?).to be true }
|
|
142
|
-
it { expect(info.thinking?).to be true }
|
|
143
|
-
|
|
144
|
-
it 'returns false for missing capabilities' do
|
|
145
|
-
minimal = described_class.new(id: 'test')
|
|
146
|
-
expect(minimal.completion?).to be false
|
|
147
|
-
expect(minimal.embedding?).to be false
|
|
148
|
-
expect(minimal.vision?).to be false
|
|
149
|
-
expect(minimal.tools?).to be false
|
|
150
|
-
expect(minimal.thinking?).to be false
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
describe '#to_h' do
|
|
155
|
-
it 'returns a hash with all fields' do
|
|
156
|
-
info = described_class.new(
|
|
157
|
-
id: 'llama-3.1',
|
|
158
|
-
provider: 'ollama',
|
|
159
|
-
capabilities: [:completion],
|
|
160
|
-
context_length: 128_000
|
|
161
|
-
)
|
|
162
|
-
hash = info.to_h
|
|
163
|
-
|
|
164
|
-
expect(hash[:id]).to eq('llama-3.1')
|
|
165
|
-
expect(hash[:provider]).to eq(:ollama)
|
|
166
|
-
expect(hash[:capabilities]).to eq([:completion])
|
|
167
|
-
expect(hash[:context_length]).to eq(128_000)
|
|
168
|
-
expect(hash).to have_key(:metadata)
|
|
169
|
-
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
describe 'value equality' do
|
|
173
|
-
it 'considers two identical infos equal' do
|
|
174
|
-
a = described_class.new(id: 'model-a', provider: 'ollama', capabilities: [:completion])
|
|
175
|
-
b = described_class.new(id: 'model-a', provider: 'ollama', capabilities: [:completion])
|
|
176
|
-
|
|
177
|
-
expect(a).to eq(b)
|
|
178
|
-
expect(a.hash).to eq(b.hash)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
it 'considers different infos not equal' do
|
|
182
|
-
a = described_class.new(id: 'model-a', provider: 'ollama')
|
|
183
|
-
b = described_class.new(id: 'model-b', provider: 'ollama')
|
|
184
|
-
|
|
185
|
-
expect(a).not_to eq(b)
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
describe 'immutability' do
|
|
190
|
-
it 'is frozen' do
|
|
191
|
-
info = described_class.new(id: 'test')
|
|
192
|
-
expect(info).to be_frozen
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
describe '#supports_prompt_caching?' do
|
|
197
|
-
it 'returns true when prompt_caching is in capabilities' do
|
|
198
|
-
info = described_class.new(id: 'claude-sonnet-4-0', capabilities: %w[prompt_caching tools])
|
|
199
|
-
expect(info.supports_prompt_caching?).to be true
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
it 'returns false when prompt_caching is not in capabilities' do
|
|
203
|
-
info = described_class.new(id: 'gpt-5', capabilities: %w[tools vision])
|
|
204
|
-
expect(info.supports_prompt_caching?).to be false
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
it 'returns false for a model with no capabilities' do
|
|
208
|
-
info = described_class.new(id: 'basic-model')
|
|
209
|
-
expect(info.supports_prompt_caching?).to be false
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
it 'supports the legacy string-based predicate' do
|
|
213
|
-
info = described_class.new(id: 'claude-sonnet-4-0', capabilities: %w[prompt_caching])
|
|
214
|
-
expect(info.prompt_caching?).to be true
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
it 'returns false for legacy string-based predicate when absent' do
|
|
218
|
-
info = described_class.new(id: 'gpt-5', capabilities: %w[tools])
|
|
219
|
-
expect(info.prompt_caching?).to be false
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
end
|