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.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. 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