lex-llm 0.6.16 → 0.7.2

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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -0
  3. data/lex-llm.gemspec +1 -1
  4. data/lib/legion/extensions/llm/auto_registration.rb +3 -0
  5. data/lib/legion/extensions/llm/canonical/message.rb +5 -1
  6. data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
  7. data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
  8. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
  9. data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
  10. data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
  11. data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
  12. data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
  13. data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
  14. data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
  15. data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
  16. data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
  17. data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
  18. data/lib/legion/extensions/llm/inventory/records.rb +599 -0
  19. data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
  20. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
  21. data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
  22. data/lib/legion/extensions/llm/provider.rb +55 -29
  23. data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
  24. data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
  25. data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
  26. data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
  27. data/lib/legion/extensions/llm/routing/records.rb +288 -0
  28. data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
  29. data/lib/legion/extensions/llm/taxonomies.rb +90 -0
  30. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
  31. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
  32. data/lib/legion/extensions/llm/version.rb +1 -1
  33. data/lib/legion/extensions/llm.rb +18 -0
  34. data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
  35. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
  36. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
  37. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
  38. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
  39. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
  40. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
  41. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
  42. data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
  43. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
  44. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
  46. data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
  47. data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
  48. data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
  49. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
  50. data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
  51. data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
  52. data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
  53. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
  54. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
  55. data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
  56. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
  57. data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
  58. data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
  59. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
  60. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
  61. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
  62. data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
  63. data/spec/legion/extensions/llm/provider_spec.rb +83 -0
  64. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
  65. data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
  66. data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
  67. data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
  68. data/spec/support/fake_llm_provider.rb +11 -0
  69. data/spec/support/fake_ssot_harness.rb +137 -0
  70. data/spec/support/ssot_registry_helpers.rb +75 -0
  71. metadata +40 -3
@@ -1,9 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
4
+ require 'legion/extensions/llm/inventory/errors'
5
+ require 'legion/extensions/llm/inventory/identity'
6
+ require 'legion/extensions/llm/inventory/snapshot'
7
+ require 'legion/extensions/llm/taxonomies'
8
+
3
9
  module Legion
4
10
  module Extensions
5
11
  module Llm
6
12
  module Inventory
13
+ # DEPRECATED (Phase 4 removal): the only Phase 1 file permitted to hold a
14
+ # direct `Legion::LLM::Inventory` reverse reference. Old released provider
15
+ # actors keep using ScopedRefresher#tick until their Phase 2 migration; a
16
+ # migrated actor instead injects ScopedRefresher::LegacyCoordinatorAdapter
17
+ # into Inventory::Publisher and never calls #tick. Neither old lane hashes
18
+ # nor old health feed the new registry. Phase 4 deletes this file once
19
+ # every provider and coordinator floor has migrated.
20
+ #
7
21
  # Mix into a Legion::Extensions::Llm::*::Actors::DiscoveryRefresh class.
8
22
  # The host class must include Legion::Extensions::Helpers::Lex (auto-injects
9
23
  # log / settings / handle_exception / cache_*) and define:
@@ -98,6 +112,198 @@ module Legion
98
112
 
99
113
  error.message&.match?(/unauthorized|invalid[_ ]api[_ ]key|invalid[_ ]credentials|forbidden/i)
100
114
  end
115
+
116
+ # One-way, post-commit projection of an already-committed new Snapshot
117
+ # into the old Legion::LLM::Inventory coordinator, for the mixed-version
118
+ # window only. It never rediscovers, selects, authorizes new state, or
119
+ # feeds an old fact back into the new Registry. See section 13.3.
120
+ class LegacyCoordinatorAdapter
121
+ include Legion::Logging::Helper
122
+
123
+ HEALTHY_COMPAT = { circuit_state: :closed, denied: false, available: true, adjustment: 0 }.freeze
124
+ LEGACY_TYPES = { embed: :embedding, image: :image, transcribe: :audio, translate: :audio, speak: :audio }.freeze
125
+ LEGACY_CAPABILITIES = {
126
+ chat: :completion, stream_chat: :streaming, embed: :embedding, image: :image,
127
+ transcribe: :audio_transcription, translate: :audio_transcription, speak: :audio_speech, moderate: :moderation
128
+ }.freeze
129
+
130
+ def initialize(provider_family:, legacy_instance_ids_by_instance: {})
131
+ @provider_family = canonical_family(provider_family)
132
+ @legacy_instance_ids = deep_freeze_legacy_ids(legacy_instance_ids_by_instance)
133
+ @published_ids_by_instance = {}.freeze
134
+ @mutex = Mutex.new
135
+ end
136
+
137
+ def sync_snapshot(snapshot:, instance_key:, mutation_reason:)
138
+ validate_inputs!(snapshot, instance_key, mutation_reason)
139
+ return :not_loaded unless legacy_inventory_available?
140
+
141
+ project(snapshot, instance_key)
142
+ end
143
+
144
+ private
145
+
146
+ def project(snapshot, instance_key)
147
+ @mutex.synchronize { project_locked(snapshot, instance_key) }
148
+ :applied
149
+ rescue StandardError => e
150
+ handle_exception(
151
+ e, handled: true, level: :warn, operation: 'inventory.scoped_refresher.legacy_projection',
152
+ provider_family: @provider_family, instance_id: instance_key.instance_id,
153
+ source_generation: snapshot.generation
154
+ )
155
+ :failed
156
+ end
157
+
158
+ def project_locked(snapshot, instance_key)
159
+ desired = desired_lanes(snapshot, instance_key)
160
+ written = write_desired(desired)
161
+ reconcile_deletions(instance_key, written)
162
+ @published_ids_by_instance = @published_ids_by_instance.merge(
163
+ instance_key.instance_id => written.to_a.freeze
164
+ ).freeze
165
+ end
166
+
167
+ def desired_lanes(snapshot, instance_key)
168
+ record = snapshot.instance(instance_key: instance_key)
169
+ return {} if record.nil? || record.availability.state != :available
170
+
171
+ build_desired(group_lanes(snapshot, instance_key), snapshot.generation)
172
+ end
173
+
174
+ def group_lanes(snapshot, instance_key)
175
+ groups = {}
176
+ snapshot.lanes_for(instance_key: instance_key).each do |lane|
177
+ type = LEGACY_TYPES.fetch(lane.operation, :inference)
178
+ key = [lane.tier, instance_key.provider_family, instance_key.instance_id, type, lane.model]
179
+ group = (groups[key] ||= new_group(instance_key, lane, type))
180
+ accumulate_group(group, lane)
181
+ end
182
+ groups
183
+ end
184
+
185
+ def new_group(instance_key, lane, type)
186
+ {
187
+ tier: lane.tier, provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
188
+ type: type, model: lane.model, offering_ids: Set.new, capabilities: Set.new,
189
+ context_values: Set.new, max_output_values: Set.new
190
+ }
191
+ end
192
+
193
+ def accumulate_group(group, lane)
194
+ group[:offering_ids] << lane.offering_id
195
+ mapped = LEGACY_CAPABILITIES[lane.operation]
196
+ group[:capabilities] << mapped if mapped
197
+ lane.capability_evidence.each { |capability, evidence| group[:capabilities] << capability if evidence.supported? }
198
+ group[:context_values] << lane.context_evidence.value if lane.context_evidence.known?
199
+ group[:max_output_values] << lane.max_output_evidence.value if lane.max_output_evidence.known?
200
+ end
201
+
202
+ def build_desired(groups, generation)
203
+ by_id = {}
204
+ groups.each_value do |group|
205
+ legacy_id = ScopedRefresher.compose_id(identity_fields(group))
206
+ (by_id[legacy_id] ||= []) << group
207
+ end
208
+
209
+ by_id.each_with_object({}) do |(legacy_id, colliding), desired|
210
+ if colliding.size > 1 || colliding.first[:offering_ids].size != 1
211
+ log.warn("[llm][inventory][legacy_adapter] action=fail_closed reason=ambiguous_group legacy_id=#{legacy_id}")
212
+ next
213
+ end
214
+ desired[legacy_id] = build_lane_hash(legacy_id, colliding.first, generation)
215
+ end
216
+ end
217
+
218
+ def identity_fields(group)
219
+ {
220
+ tier: group[:tier], provider_family: group[:provider_family],
221
+ instance_id: group[:instance_id], type: group[:type], model: group[:model]
222
+ }.freeze
223
+ end
224
+
225
+ def build_lane_hash(legacy_id, group, generation)
226
+ {
227
+ id: legacy_id, tier: group[:tier], provider_family: group[:provider_family],
228
+ provider_instance: group[:instance_id], instance_id: group[:instance_id], type: group[:type],
229
+ model: group[:model], offering_id: group[:offering_ids].first,
230
+ capabilities: group[:capabilities].to_a.sort, limits: build_limits(group),
231
+ metadata: { ssot_v3_compatibility_projection: true, source_generation: generation }, enabled: true
232
+ }
233
+ end
234
+
235
+ def build_limits(group)
236
+ limits = {}
237
+ limits[:context_window] = group[:context_values].first if group[:context_values].size == 1
238
+ limits[:max_output_tokens] = group[:max_output_values].first if group[:max_output_values].size == 1
239
+ limits
240
+ end
241
+
242
+ def write_desired(desired)
243
+ written = Set.new
244
+ desired.each do |legacy_id, lane_hash|
245
+ result = ::Legion::LLM::Inventory.write_lane(lane: lane_hash, health: HEALTHY_COMPAT)
246
+ written << legacy_id unless result.nil?
247
+ end
248
+ written
249
+ end
250
+
251
+ def reconcile_deletions(instance_key, written)
252
+ tracked = @published_ids_by_instance.fetch(instance_key.instance_id, [])
253
+ candidates = (tracked.to_a + read_current_old_ids(instance_key).to_a).uniq
254
+ candidates.each do |old_id|
255
+ next if written.include?(old_id)
256
+
257
+ ::Legion::LLM::Inventory.delete_lane(id: old_id)
258
+ end
259
+ end
260
+
261
+ def read_current_old_ids(instance_key)
262
+ instance_ids_to_read(instance_key).each_with_object(Set.new) do |instance_id, ids|
263
+ lanes = ::Legion::LLM::Inventory.lanes_for(provider: @provider_family, instance: instance_id)
264
+ Array(lanes).each { |lane| ids << (lane.is_a?(::Hash) ? lane[:id] : lane) }
265
+ end
266
+ end
267
+
268
+ def instance_ids_to_read(instance_key)
269
+ ([instance_key.instance_id] + @legacy_instance_ids.fetch(instance_key.instance_id, [])).uniq
270
+ end
271
+
272
+ def validate_inputs!(snapshot, instance_key, mutation_reason)
273
+ raise Errors::ValidationError, 'snapshot must be an Inventory::Snapshot' unless snapshot.is_a?(Snapshot)
274
+ unless instance_key.is_a?(Identity::InstanceKey) && instance_key.provider_family == @provider_family
275
+ raise Errors::ValidationError, 'instance_key must belong to the adapter provider family'
276
+ end
277
+ return if Taxonomies::MUTATION_REASONS.include?(mutation_reason)
278
+
279
+ raise Errors::ValidationError, 'mutation_reason must be a Taxonomies::MUTATION_REASONS value'
280
+ end
281
+
282
+ def legacy_inventory_available?
283
+ defined?(::Legion::LLM::Inventory) &&
284
+ ::Legion::LLM::Inventory.respond_to?(:lanes_for) &&
285
+ ::Legion::LLM::Inventory.respond_to?(:write_lane) &&
286
+ ::Legion::LLM::Inventory.respond_to?(:delete_lane)
287
+ end
288
+
289
+ def canonical_family(value)
290
+ text = Identity.normalize_text(value: value, field: :provider_family).downcase
291
+ raise Errors::ValidationError, 'provider_family must be snake-case ASCII' unless text.match?(/\A[a-z][a-z0-9_]*\z/)
292
+
293
+ text.to_sym
294
+ end
295
+
296
+ def deep_freeze_legacy_ids(mapping)
297
+ raise Errors::ValidationError, 'legacy_instance_ids_by_instance must be a Hash' unless mapping.is_a?(::Hash)
298
+
299
+ mapping.each_with_object({}) do |(key, values), frozen|
300
+ raise Errors::ValidationError, 'legacy instance id keys must be Strings' unless key.is_a?(::String)
301
+ raise Errors::ValidationError, 'legacy superseded ids must be a nonempty Array of Strings' unless values.is_a?(::Array) && !values.empty? && values.all?(::String)
302
+
303
+ frozen[key.dup.freeze] = values.map { |value| value.dup.freeze }.freeze
304
+ end.freeze
305
+ end
306
+ end
101
307
  end
102
308
  end
103
309
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm/inventory/errors'
4
+ require 'legion/extensions/llm/inventory/identity'
5
+ require 'legion/extensions/llm/inventory/records'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Llm
10
+ module Inventory
11
+ # A structurally immutable, generation-tagged read model of the registry.
12
+ # Lookups return the frozen record or nil and never synthesize a default.
13
+ # `instances_by_key` contains only activated instances;
14
+ # `publication_status_by_key` also contains initializing claims. A caller
15
+ # cannot observe a later inventory-record or generation mutation through
16
+ # an older snapshot; the captured CallableHandle lifecycle is the single
17
+ # intentional exception. See phase-1-lex-llm-additive.md section 11.3.
18
+ class Snapshot
19
+ attr_reader :generation
20
+
21
+ def initialize(generation:, instances_by_key:, offerings_by_id:, lanes_by_id:, publication_status_by_key:)
22
+ @generation = generation
23
+ @instances_by_key = order_instances(instances_by_key).freeze
24
+ @offerings_by_id = offerings_by_id.dup.freeze
25
+ @lanes_by_id = lanes_by_id.dup.freeze
26
+ @publication_status_by_key = order_instances(publication_status_by_key).freeze
27
+ @ordered_offerings = @offerings_by_id.keys.sort.map { |id| @offerings_by_id[id] }.freeze
28
+ @ordered_lanes = @lanes_by_id.keys.sort.map { |id| @lanes_by_id[id] }.freeze
29
+ freeze
30
+ end
31
+
32
+ def instance(instance_key:)
33
+ @instances_by_key[instance_key]
34
+ end
35
+
36
+ def offering(offering_id:)
37
+ @offerings_by_id[offering_id]
38
+ end
39
+
40
+ def lane(lane_id:)
41
+ @lanes_by_id[lane_id]
42
+ end
43
+
44
+ def lanes_for(instance_key:)
45
+ @ordered_lanes.select { |lane| lane.instance_key == instance_key }.freeze
46
+ end
47
+
48
+ def offerings_for(instance_key:)
49
+ @ordered_offerings.select { |offering| offering.instance_key == instance_key }.freeze
50
+ end
51
+
52
+ def publication_status(instance_key:)
53
+ @publication_status_by_key[instance_key]
54
+ end
55
+
56
+ def each_lane(&block)
57
+ return to_enum(:each_lane) unless block
58
+
59
+ @ordered_lanes.each(&block)
60
+ end
61
+
62
+ def each_offering(&block)
63
+ return to_enum(:each_offering) unless block
64
+
65
+ @ordered_offerings.each(&block)
66
+ end
67
+
68
+ def each_instance(&block)
69
+ return to_enum(:each_instance) unless block
70
+
71
+ @instances_by_key.each_value(&block)
72
+ end
73
+
74
+ def each_publication_status(&block)
75
+ return to_enum(:each_publication_status) unless block
76
+
77
+ @publication_status_by_key.each_value(&block)
78
+ end
79
+
80
+ private
81
+
82
+ # Orders InstanceKey entries by [provider_family bytes, instance_id bytes].
83
+ def order_instances(by_key)
84
+ by_key.keys.sort_by { |key| [key.provider_family.to_s.b, key.instance_id.b] }
85
+ .to_h { |key| [key, by_key[key]] }
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -307,6 +307,49 @@ module Legion
307
307
  parse_transcription_response(response, model:)
308
308
  end
309
309
 
310
+ # Fail-loud base audio operations. Unsupported providers inherit these and
311
+ # publish OperationEvidence(status: :unsupported) or :unknown; a provider
312
+ # may publish :supported only when its Phase 2 conformance spec exercises
313
+ # the actual callable path. Neither method reads configuration or infers a
314
+ # model. See phase-1-lex-llm-additive.md section 14.1.
315
+ def translate(audio_file, model:, language:, **provider_options)
316
+ _ = [audio_file, model, language, provider_options]
317
+ raise NotImplementedError, "#{self.class} does not implement translate"
318
+ end
319
+
320
+ def speak(text, model:, voice: nil, **provider_options)
321
+ _ = [text, model, voice, provider_options]
322
+ raise NotImplementedError, "#{self.class} does not implement speak"
323
+ end
324
+
325
+ # Runtime error-to-outcome normalization consumed by the common
326
+ # classifier. The inherited base is deliberately conservative: raw 503,
327
+ # Anthropic-style 529, ServiceUnavailableError, ServerError, and every
328
+ # unrecognized error return :provider_error, never :instance_unavailable.
329
+ # Provider PRs override only when their wire semantics supply stronger
330
+ # evidence. The fallback reason is the bounded exception class name — never
331
+ # a response body, credential, endpoint, or exception object. It is a base
332
+ # method, not a REQUIRED_SIGNATURES reflection entry. See Phase 2 §4.5.2.
333
+ def normalize_dispatch_error(error:)
334
+ kind = case error
335
+ when OverloadedError then :overloaded
336
+ when RateLimitError then :rate_limited
337
+ when UnauthorizedError then :authentication
338
+ when PaymentRequiredError then :billing
339
+ when ForbiddenError then :authorization
340
+ when ContextLengthExceededError then :context_rejected
341
+ when BadRequestError then :invalid_request
342
+ when ModelNotFoundError then :model_missing
343
+ when ModelNotAllowedError then :policy
344
+ when Faraday::TimeoutError, Timeout::Error then :timeout
345
+ when Faraday::ConnectionFailed, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError then :connection_failure
346
+ else :provider_error
347
+ end
348
+ reason = error.class.name
349
+ reason = 'UnknownError' if reason.nil? || reason.empty?
350
+ Legion::Extensions::Llm::Routing::ProviderOutcome.new(kind: kind, reason: reason)
351
+ end
352
+
310
353
  def configured?
311
354
  configuration_requirements.all? { |req| @config.send(req) }
312
355
  end
@@ -537,17 +580,15 @@ module Legion
537
580
  }
538
581
  end
539
582
 
540
- # Resolve a single policy value with instance > provider > global precedence.
583
+ # Resolve a single policy value with the shared cascade
584
+ # (SettingsCascade: instance > provider, model leg skipped — no model
585
+ # exists at policy-build time) plus the legacy global
586
+ # extensions.llm.<key> leg. Empty values fall through.
541
587
  def self.resolve_policy_value(cfg, provider_conf, global_conf, key)
542
- # Instance-level
543
- val = cfg[key] || cfg[key.to_s]
544
- return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
588
+ value = SettingsCascade.resolve_value(provider_conf: provider_conf, instance_cfg: cfg, key: key)
589
+ return value unless value.nil?
545
590
 
546
- # Provider-level
547
- val = provider_conf[key] || provider_conf[key.to_s]
548
- return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
549
-
550
- # Global
591
+ global_conf = {} unless global_conf.is_a?(::Hash)
551
592
  global_conf[key] || global_conf[key.to_s]
552
593
  end
553
594
 
@@ -760,11 +801,11 @@ module Legion
760
801
  end
761
802
 
762
803
  def model_capability_config(model_id)
763
- provider_models = provider_capability_models
764
- instance_models = extract_models_config(config)
765
- provider_override = provider_models[model_id.to_s] || provider_models[model_id.to_sym] || {}
766
- instance_override = instance_models[model_id.to_s] || instance_models[model_id.to_sym] || {}
767
- provider_override.to_h.merge(instance_override.to_h)
804
+ SettingsCascade.merge_model_scopes(
805
+ provider_conf: provider_capability_config,
806
+ instance_cfg: config.respond_to?(:to_h) ? config.to_h : {},
807
+ model: model_id
808
+ )
768
809
  rescue StandardError => e
769
810
  handle_exception(e, level: :warn, handled: true, operation: "#{slug}.model_capability_config")
770
811
  {}
@@ -821,21 +862,6 @@ module Legion
821
862
  end
822
863
  end
823
864
 
824
- def extract_models_config(source)
825
- return {} unless source.respond_to?(:models)
826
-
827
- models = source.models
828
- models.respond_to?(:to_h) ? models.to_h : {}
829
- rescue StandardError
830
- {}
831
- end
832
-
833
- def provider_capability_models
834
- config = provider_capability_config
835
- models = config[:models] || config['models']
836
- models.respond_to?(:to_h) ? models.to_h : {}
837
- end
838
-
839
865
  def offering_from_model(model, health: {})
840
866
  capability_sources = Array(model.capabilities).to_h do |cap|
841
867
  [cap.to_sym, { value: true, source: :model_metadata }]
@@ -4,6 +4,10 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Routing
7
+ # DEPRECATED (Phase 4 removal): retained only for protocol-v2 fleet
8
+ # compatibility. The new canonical execution identity comes solely from
9
+ # Inventory::Identity (off:v1:/lane:v1:); do not reinterpret an old
10
+ # five-part lane as a new lane:v1: identity.
7
11
  # Builds stable fleet lane keys from provider-neutral model offerings.
8
12
  module LaneKey
9
13
  module_function
@@ -4,6 +4,9 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Routing
7
+ # DEPRECATED (Phase 4 removal): kept unchanged for released consumers.
8
+ # New providers publish Inventory::OfferingDraft; the new registry accepts
9
+ # no default generated offering_id from this class.
7
10
  # Describes one concrete model made available by one provider instance.
8
11
  class ModelOffering
9
12
  CAPABILITY_ALIASES = {
@@ -4,6 +4,8 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Routing
7
+ # DEPRECATED (Phase 4 removal): the old mutable registry, kept only for
8
+ # old callers. It is never returned by Inventory::Registry.snapshot.
7
9
  # In-memory index of provider-neutral model offerings.
8
10
  class OfferingRegistry
9
11
  include Enumerable
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm/inventory/errors'
4
+ require 'legion/extensions/llm/inventory/immutable_value'
5
+ require 'legion/extensions/llm/inventory/records'
6
+ require 'legion/extensions/llm/routing/records'
7
+ require 'legion/extensions/llm/taxonomies'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Llm
12
+ module Routing
13
+ # A provider-neutral normalized outcome. Adapters translate wire errors
14
+ # into one of these kinds; the common classifier branches only on kind,
15
+ # never on provider identity. The value contains no provider object,
16
+ # callable, response body, routing weight, or health-mutation
17
+ # instruction. HTTP 503/529 alone is not an input and cannot manufacture
18
+ # instance_unavailable. See phase-1-lex-llm-additive.md section 14.2.
19
+ ProviderOutcome = ::Data.define(:kind, :reason, :quota_domain, :retry_after, :metadata) do
20
+ def initialize(kind:, reason:, quota_domain: nil, retry_after: nil, metadata: {})
21
+ support = Legion::Extensions::Llm::Inventory::RecordSupport
22
+ errors = Legion::Extensions::Llm::Inventory::Errors
23
+ raise errors::ValidationError, 'kind must be a Taxonomies::PROVIDER_OUTCOMES value' unless Legion::Extensions::Llm::Taxonomies::PROVIDER_OUTCOMES.include?(kind)
24
+
25
+ super(
26
+ kind: kind,
27
+ reason: support.sanitized_reason(value: reason, field: :reason),
28
+ quota_domain: validate_quota_domain!(kind, quota_domain),
29
+ retry_after: validate_retry_after!(retry_after),
30
+ metadata: support.frozen_metadata(value: metadata)
31
+ )
32
+ end
33
+
34
+ def validate_quota_domain!(kind, quota_domain)
35
+ errors = Legion::Extensions::Llm::Inventory::Errors
36
+ return nil if quota_domain.nil?
37
+ raise errors::ValidationError, 'quota_domain must be a Routing::QuotaDomainKey' unless quota_domain.is_a?(QuotaDomainKey)
38
+ raise errors::ValidationError, 'quota_domain is allowed only for rate_limited' unless kind == :rate_limited
39
+
40
+ quota_domain
41
+ end
42
+
43
+ def validate_retry_after!(retry_after)
44
+ return nil if retry_after.nil?
45
+ return retry_after if retry_after.is_a?(::Numeric) && retry_after.finite? && !retry_after.negative?
46
+
47
+ raise Legion::Extensions::Llm::Inventory::Errors::ValidationError,
48
+ 'retry_after must be nil or a finite nonnegative Numeric'
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end