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
@@ -0,0 +1,288 @@
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/identity'
6
+ require 'legion/extensions/llm/inventory/callable_handle'
7
+ require 'legion/extensions/llm/inventory/records'
8
+ require 'legion/extensions/llm/taxonomies'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Llm
13
+ module Routing
14
+ # Documentation namespace anchor for the request-local routing records
15
+ # defined in this file: AttemptTargetKey, QuotaDomainKey, Exclusion,
16
+ # Selection, Rejection, and BodyModelHintDecision. The records live
17
+ # directly under Routing so their canonical constant paths stay flat.
18
+ module Records; end
19
+
20
+ # The canonical request-local exclusion target: provider family, exact
21
+ # instance ID, and model. No operation, offering/lane ID, generation,
22
+ # tier, weight, or affinity. See section 15.1.
23
+ AttemptTargetKey = ::Data.define(:provider_family, :instance_id, :model) do
24
+ def initialize(provider_family:, instance_id:, model:)
25
+ key = Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(
26
+ provider_family: provider_family, instance_id: instance_id
27
+ )
28
+ model_text = Legion::Extensions::Llm::Inventory::Identity.normalize_text(value: model, field: :model)
29
+ super(provider_family: key.provider_family, instance_id: key.instance_id, model: model_text)
30
+ end
31
+ end
32
+
33
+ # A non-secret, provider-scoped quota domain. Equality never crosses
34
+ # provider family. See section 15.2.
35
+ QuotaDomainKey = ::Data.define(:provider_family, :opaque_id) do
36
+ def initialize(provider_family:, opaque_id:)
37
+ super(
38
+ provider_family: Legion::Extensions::Llm::Inventory::RecordSupport.normalize_provider_family(
39
+ value: provider_family
40
+ ),
41
+ opaque_id: Legion::Extensions::Llm::Inventory::Identity.normalize_text(value: opaque_id, field: :opaque_id)
42
+ )
43
+ end
44
+ end
45
+
46
+ # A request-local or attempt-group exclusion of a typed target. See 15.3.
47
+ Exclusion = ::Data.define(:target_kind, :target, :reason, :evidence, :lifetime) do
48
+ def initialize(target_kind:, target:, reason:, evidence:, lifetime:)
49
+ raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'invalid target_kind' unless Legion::Extensions::Llm::Taxonomies::EXCLUSION_TARGET_KINDS.include?(target_kind)
50
+ raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'invalid lifetime' unless Legion::Extensions::Llm::Taxonomies::EXCLUSION_LIFETIMES.include?(lifetime)
51
+
52
+ super(
53
+ target_kind: target_kind,
54
+ target: normalize_target(target_kind, target),
55
+ reason: Legion::Extensions::Llm::Inventory::RecordSupport.sanitized_reason(value: reason, field: :reason),
56
+ evidence: Legion::Extensions::Llm::Inventory::ImmutableValue.copy_and_freeze(value: evidence, field: :evidence),
57
+ lifetime: lifetime
58
+ )
59
+ end
60
+
61
+ def normalize_target(target_kind, target)
62
+ errors = Legion::Extensions::Llm::Inventory::Errors
63
+ case target_kind
64
+ when :attempt_target
65
+ raise errors::ValidationError, 'attempt_target target must be an AttemptTargetKey' unless target.is_a?(AttemptTargetKey)
66
+
67
+ target
68
+ when :instance
69
+ raise errors::ValidationError, 'instance target must be an InstanceKey' unless target.is_a?(Legion::Extensions::Llm::Inventory::Identity::InstanceKey)
70
+
71
+ target
72
+ when :quota_domain
73
+ raise errors::ValidationError, 'quota_domain target must be a QuotaDomainKey' unless target.is_a?(QuotaDomainKey)
74
+
75
+ target
76
+ when :provider
77
+ Legion::Extensions::Llm::Inventory::RecordSupport.normalize_provider_family(value: target, field: :target)
78
+ else # :lane, :offering, :model
79
+ Legion::Extensions::Llm::Inventory::Identity.normalize_text(value: target, field: target_kind)
80
+ end
81
+ end
82
+ end
83
+
84
+ # An immutable selected target produced by legion-llm's selector and
85
+ # consumed by dispatch. lex-llm validates cross-field identity only; it
86
+ # does not compute routing or choose a lane. See section 15.4.
87
+ Selection = ::Data.define(
88
+ :inventory_generation, :lane_id, :instance_key, :offering_id, :provider_family, :instance_id,
89
+ :model, :operation, :callable_handle, :publisher_token_id, :capability_evidence, :context_evidence,
90
+ :weight_inputs, :base_weight, :preference_ppm, :effective_weight, :rendezvous_score
91
+ ) do
92
+ def initialize(**kwargs)
93
+ Legion::Extensions::Llm::Inventory::RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
94
+ super(**identity_attributes(kwargs).merge(scoring_attributes(kwargs)))
95
+ end
96
+
97
+ def identity_attributes(kwargs)
98
+ errors = Legion::Extensions::Llm::Inventory::Errors
99
+ identity = Legion::Extensions::Llm::Inventory::Identity
100
+ instance_key = kwargs[:instance_key]
101
+ callable_handle = kwargs[:callable_handle]
102
+ raise errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(identity::InstanceKey)
103
+ raise errors::ValidationError, 'callable_handle must be a CallableHandle' unless callable_handle.is_a?(Legion::Extensions::Llm::Inventory::CallableHandle)
104
+
105
+ canonical_model = identity.normalize_text(value: kwargs[:model], field: :model)
106
+ canonical_operation = Legion::Extensions::Llm::Taxonomies.normalize_operation(value: kwargs[:operation], allow_aliases: false)
107
+ family = Legion::Extensions::Llm::Inventory::RecordSupport.normalize_provider_family(value: kwargs[:provider_family])
108
+ normalized_instance = identity.normalize_text(value: kwargs[:instance_id], field: :instance_id)
109
+ unless family == instance_key.provider_family && normalized_instance == instance_key.instance_id
110
+ raise errors::ValidationError, 'provider_family and instance_id must equal instance_key'
111
+ end
112
+
113
+ offering_id = kwargs[:offering_id]
114
+ validate_offering_id_shape!(offering_id)
115
+ identity.validate_lane_id!(
116
+ value: kwargs[:lane_id], instance_key: instance_key, operation: canonical_operation,
117
+ model: canonical_model, offering_id: offering_id
118
+ )
119
+ validate_publisher_token_id!(kwargs[:publisher_token_id])
120
+
121
+ {
122
+ lane_id: kwargs[:lane_id].dup.freeze, instance_key: instance_key, offering_id: offering_id.dup.freeze,
123
+ provider_family: family, instance_id: normalized_instance, model: canonical_model,
124
+ operation: canonical_operation, callable_handle: callable_handle,
125
+ publisher_token_id: kwargs[:publisher_token_id].dup.freeze
126
+ }
127
+ end
128
+
129
+ def scoring_attributes(kwargs)
130
+ support = Legion::Extensions::Llm::Inventory::RecordSupport
131
+ base_weight = kwargs[:base_weight]
132
+ frozen_weight_inputs = validate_weight_inputs!(kwargs[:weight_inputs], base_weight)
133
+ validate_weights!(base_weight, kwargs[:preference_ppm], kwargs[:effective_weight], kwargs[:rendezvous_score])
134
+
135
+ {
136
+ inventory_generation: support.nonnegative_integer(value: kwargs[:inventory_generation], field: :inventory_generation),
137
+ capability_evidence: support.validate_capability_evidence!(kwargs[:capability_evidence]),
138
+ context_evidence: support.positive_int_value_evidence!(kwargs[:context_evidence], :context_evidence),
139
+ weight_inputs: frozen_weight_inputs, base_weight: base_weight, preference_ppm: kwargs[:preference_ppm],
140
+ effective_weight: kwargs[:effective_weight], rendezvous_score: kwargs[:rendezvous_score]
141
+ }
142
+ end
143
+
144
+ def attempt_target_key
145
+ AttemptTargetKey.new(provider_family: provider_family, instance_id: instance_id, model: model)
146
+ end
147
+
148
+ def validate_offering_id_shape!(offering_id)
149
+ return if offering_id.is_a?(::String) && offering_id.match?(/\Aoff:v1:[0-9a-f]{64}\z/)
150
+
151
+ raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'offering_id must have the off:v1: shape'
152
+ end
153
+
154
+ def validate_publisher_token_id!(publisher_token_id)
155
+ return if publisher_token_id.is_a?(::String) && publisher_token_id.start_with?('ptok:v1:') &&
156
+ publisher_token_id.length > 'ptok:v1:'.length
157
+
158
+ raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'publisher_token_id must be a ptok:v1: String'
159
+ end
160
+
161
+ def validate_weight_inputs!(weight_inputs, base_weight)
162
+ errors = Legion::Extensions::Llm::Inventory::Errors
163
+ unless weight_inputs.is_a?(::Hash) && weight_inputs.keys.sort == %i[instance model_or_offering provider tier]
164
+ raise errors::ValidationError, 'weight_inputs must have keys tier/provider/instance/model_or_offering'
165
+ end
166
+ raise errors::ValidationError, 'weight_inputs values must be positive Integers' unless weight_inputs.values.all? { |value| value.is_a?(::Integer) && value.positive? }
167
+
168
+ product = weight_inputs.values.reduce(1, :*)
169
+ raise errors::ValidationError, 'base_weight must equal the product of weight_inputs' unless base_weight == product
170
+
171
+ weight_inputs.dup.freeze
172
+ end
173
+
174
+ def validate_weights!(base_weight, preference_ppm, effective_weight, rendezvous_score)
175
+ errors = Legion::Extensions::Llm::Inventory::Errors
176
+ raise errors::ValidationError, 'preference_ppm must be an Integer in 500_000..1_500_000' unless preference_ppm.is_a?(::Integer) && (500_000..1_500_000).cover?(preference_ppm)
177
+ raise errors::ValidationError, 'effective_weight must equal base_weight * preference_ppm' unless effective_weight == base_weight * preference_ppm
178
+ return if rendezvous_score.is_a?(::Integer) && (0...(2**256)).cover?(rendezvous_score)
179
+
180
+ raise errors::ValidationError, 'rendezvous_score must be an Integer in 0...(2**256)'
181
+ end
182
+ end
183
+
184
+ # A routing rejection diagnosis. lex-llm validates/holds the value;
185
+ # legion-llm constructs routing diagnoses and HTTP mappings. See 15.5.
186
+ Rejection = ::Data.define(
187
+ :kind, :reason, :inventory_generation, :candidate_counts, :explicit_pins, :http_status, :code
188
+ ) do
189
+ def initialize(**kwargs)
190
+ kwargs = { explicit_pins: {}, http_status: nil, code: nil }.merge(kwargs)
191
+ Legion::Extensions::Llm::Inventory::RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
192
+ support = Legion::Extensions::Llm::Inventory::RecordSupport
193
+ errors = Legion::Extensions::Llm::Inventory::Errors
194
+ raise errors::ValidationError, 'kind must be a Taxonomies::REJECTION_KINDS value' unless Legion::Extensions::Llm::Taxonomies::REJECTION_KINDS.include?(kwargs[:kind])
195
+
196
+ super(
197
+ kind: kwargs[:kind],
198
+ reason: support.sanitized_reason(value: kwargs[:reason], field: :reason),
199
+ inventory_generation: support.nonnegative_integer(value: kwargs[:inventory_generation], field: :inventory_generation),
200
+ candidate_counts: validate_candidate_counts!(kwargs[:candidate_counts]),
201
+ explicit_pins: validate_explicit_pins!(kwargs[:explicit_pins]),
202
+ http_status: validate_http_status!(kwargs[:http_status]),
203
+ code: validate_code!(kwargs[:code])
204
+ )
205
+ end
206
+
207
+ def validate_candidate_counts!(candidate_counts)
208
+ errors = Legion::Extensions::Llm::Inventory::Errors
209
+ raise errors::ValidationError, 'candidate_counts must be a Hash' unless candidate_counts.is_a?(::Hash)
210
+
211
+ candidate_counts.each do |key, value|
212
+ raise errors::ValidationError, 'candidate_counts keys must be Symbols' unless key.is_a?(::Symbol)
213
+ raise errors::ValidationError, 'candidate_counts values must be nonnegative Integers' unless value.is_a?(::Integer) && !value.negative?
214
+ end
215
+ candidate_counts.dup.freeze
216
+ end
217
+
218
+ def validate_explicit_pins!(explicit_pins)
219
+ errors = Legion::Extensions::Llm::Inventory::Errors
220
+ raise errors::ValidationError, 'explicit_pins must be a Hash' unless explicit_pins.is_a?(::Hash)
221
+ unless explicit_pins.keys.all? { |key| %i[provider instance model tier].include?(key) }
222
+ raise errors::ValidationError, 'explicit_pins may contain only provider/instance/model/tier'
223
+ end
224
+
225
+ Legion::Extensions::Llm::Inventory::ImmutableValue.copy_and_freeze(value: explicit_pins, field: :explicit_pins)
226
+ end
227
+
228
+ def validate_http_status!(http_status)
229
+ return nil if http_status.nil?
230
+ return http_status if http_status.is_a?(::Integer) && (100..599).cover?(http_status)
231
+
232
+ raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'http_status must be nil or 100..599'
233
+ end
234
+
235
+ def validate_code!(code)
236
+ return nil if code.nil?
237
+ return code if code.is_a?(::Symbol)
238
+ return code.dup.freeze if code.is_a?(::String) && !code.strip.empty?
239
+
240
+ raise Legion::Extensions::Llm::Inventory::Errors::ValidationError, 'code must be nil or a nonempty Symbol/String'
241
+ end
242
+ end
243
+
244
+ # The disposition of a body-supplied model hint. The record performs no
245
+ # matching and cannot dispatch. See section 15.6.
246
+ BodyModelHintDecision = ::Data.define(
247
+ :requested_model, :disposition, :model_constraint, :matched_whitelist, :matched_blacklist, :settings_generation
248
+ ) do
249
+ def initialize(**kwargs)
250
+ kwargs = { model_constraint: nil, matched_whitelist: nil, matched_blacklist: nil }.merge(kwargs)
251
+ Legion::Extensions::Llm::Inventory::RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
252
+ support = Legion::Extensions::Llm::Inventory::RecordSupport
253
+ errors = Legion::Extensions::Llm::Inventory::Errors
254
+ disposition = kwargs[:disposition]
255
+ raise errors::ValidationError, 'invalid disposition' unless Legion::Extensions::Llm::Taxonomies::BODY_MODEL_HINT_DISPOSITIONS.include?(disposition)
256
+
257
+ normalized_requested = optional_text(kwargs[:requested_model], :requested_model)
258
+ normalized_constraint = optional_text(kwargs[:model_constraint], :model_constraint)
259
+ validate_disposition_rules!(disposition, normalized_requested, normalized_constraint)
260
+
261
+ super(
262
+ requested_model: normalized_requested,
263
+ disposition: disposition,
264
+ model_constraint: normalized_constraint,
265
+ matched_whitelist: optional_text(kwargs[:matched_whitelist], :matched_whitelist),
266
+ matched_blacklist: optional_text(kwargs[:matched_blacklist], :matched_blacklist),
267
+ settings_generation: support.nonnegative_integer(value: kwargs[:settings_generation], field: :settings_generation)
268
+ )
269
+ end
270
+
271
+ def optional_text(value, field)
272
+ return nil if value.nil?
273
+
274
+ Legion::Extensions::Llm::Inventory::Identity.normalize_text(value: value, field: field)
275
+ end
276
+
277
+ def validate_disposition_rules!(disposition, requested_model, model_constraint)
278
+ errors = Legion::Extensions::Llm::Inventory::Errors
279
+ raise errors::ValidationError, 'only honored may carry a model_constraint' if disposition != :honored && !model_constraint.nil?
280
+ return unless disposition == :absent && !requested_model.nil?
281
+
282
+ raise errors::ValidationError, 'absent must carry a nil requested_model'
283
+ end
284
+ end
285
+ end
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ # The shared 3-level settings cascade: provider -> instance -> model,
7
+ # most-specific-first. Single home for resolving operator config
8
+ # (enable_* overrides, weight, preferred_min/max_context_tokens,
9
+ # model_whitelist/blacklist, ...) for a
10
+ # (provider_family, instance, model, key) request.
11
+ #
12
+ # `instance` is the operator's CONFIG NAME — the same identity
13
+ # Identity::InstanceKey#instance_id and the router key settings lookups
14
+ # by — never a derived host:port/credential value.
15
+ #
16
+ # Lookup order (first meaningful value wins):
17
+ # 1. model: extensions.llm.<provider>.instances.<instance>.models.<model>.<key>
18
+ # then extensions.llm.<provider>.models.<model>.<key>
19
+ # 2. instance: extensions.llm.<provider>.instances.<instance>.<key>
20
+ # 3. provider: extensions.llm.<provider>.<key>
21
+ #
22
+ # Empty values (nil, blank String, empty Array) never resolve; the
23
+ # cascade falls through to the next scope and returns nil when no scope
24
+ # carries a meaningful value.
25
+ #
26
+ # The cascade READS config only. It never publishes evidence: config and
27
+ # override sources remain unknown-only (Taxonomies::
28
+ # UNKNOWN_ONLY_EVIDENCE_SOURCES), and enable_* overrides are consumed
29
+ # router-side, not published as capability evidence.
30
+ module SettingsCascade
31
+ module_function
32
+
33
+ # Resolve `key` for (provider_family, instance, model) from the live
34
+ # Legion::Settings[:extensions][:llm][<provider>] path.
35
+ def resolve(provider_family:, instance:, key:, model: nil)
36
+ text_name!(provider_family, :provider_family)
37
+ text_name!(instance, :instance)
38
+ text_name!(key, :key)
39
+ text_name!(model, :model) if model
40
+
41
+ llm_conf = Legion::Settings.dig(:extensions, :llm)
42
+ resolve_from(llm_conf: llm_conf, provider_family: provider_family, instance: instance, key: key, model: model)
43
+ end
44
+
45
+ # Resolve against a pre-fetched extensions.llm subtree (plain Hash),
46
+ # e.g. a router settings snapshot. Same cascade as .resolve.
47
+ def resolve_from(llm_conf:, provider_family:, instance:, key:, model: nil)
48
+ text_name!(provider_family, :provider_family)
49
+ text_name!(instance, :instance)
50
+ text_name!(key, :key)
51
+
52
+ provider_conf = lookup(llm_conf, provider_family)
53
+ instances = lookup(provider_conf, :instances)
54
+ instance_cfg = lookup(instances, instance)
55
+ resolve_value(provider_conf: provider_conf, instance_cfg: instance_cfg, key: key, model: model)
56
+ end
57
+
58
+ # The pure 3-level cascade over pre-fetched scope hashes:
59
+ # model scopes (instance-scoped first, then provider-scoped) > instance
60
+ # > provider. `model: nil` skips the model leg. Returns the first
61
+ # meaningful value or nil.
62
+ def resolve_value(provider_conf:, instance_cfg:, key:, model: nil)
63
+ text_name!(key, :key)
64
+ text_name!(model, :model) if model
65
+
66
+ scopes = []
67
+ scopes << model_scope(instance_cfg, model) if model
68
+ scopes << model_scope(provider_conf, model) if model
69
+ scopes << instance_cfg
70
+ scopes << provider_conf
71
+
72
+ scopes.each do |scope|
73
+ value = lookup(scope, key)
74
+ return value if meaningful?(value)
75
+ end
76
+
77
+ nil
78
+ end
79
+
80
+ # The merged model-scope config hash for one model: the provider's
81
+ # models.<model> entry with the instance's models.<model> entry
82
+ # overriding it (the merge the capability feeders use).
83
+ def merge_model_scopes(provider_conf:, instance_cfg:, model:)
84
+ text_name!(model, :model)
85
+
86
+ model_scope(provider_conf, model).merge(model_scope(instance_cfg, model))
87
+ end
88
+
89
+ def model_scope(scope_conf, model)
90
+ models = lookup(scope_conf, :models)
91
+ return {} unless models.is_a?(::Hash)
92
+
93
+ entry = lookup(models, model)
94
+ entry.is_a?(::Hash) ? entry : {}
95
+ end
96
+
97
+ def meaningful?(value)
98
+ !value.nil? && !value.to_s.empty? && (!value.is_a?(::Array) || value.any?)
99
+ end
100
+
101
+ # Tries the name as-given, as a Symbol, then as a String, preserving
102
+ # meaningful falsy values (false, 0) when the key is present.
103
+ def lookup(scope, name)
104
+ return nil unless scope.is_a?(::Hash)
105
+
106
+ return scope[name] if scope.key?(name)
107
+ return scope[name.to_sym] if scope.key?(name.to_sym)
108
+
109
+ scope[name.to_s]
110
+ end
111
+
112
+ def text_name!(name, field)
113
+ return if name.is_a?(::String) || name.is_a?(::Symbol)
114
+
115
+ raise ::ArgumentError, "#{field} must be a String or Symbol"
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -1,13 +1,103 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/llm/inventory/errors'
4
+
3
5
  module Legion
4
6
  module Extensions
5
7
  module Llm
8
+ # Frozen vocabularies shared across routing, inventory, and provider
9
+ # contracts: legacy tier/type/circuit enums plus the SSOT v3 operation,
10
+ # evidence, availability, outcome, and rejection enums.
6
11
  module Taxonomies
7
12
  TIERS = %i[direct local fleet cloud frontier].freeze
8
13
  TYPES = %i[inference embedding image audio].freeze
9
14
  CIRCUIT_STATES = %i[closed half_open open].freeze
10
15
  HEALTH_KEYS = %i[circuit_state denied available adjustment].freeze
16
+
17
+ # --- SSOT v3 runtime-contract enums (phase-1-lex-llm-additive.md section 6) ---
18
+ OPERATIONS = %i[
19
+ chat stream_chat embed image transcribe translate speak moderate count_tokens
20
+ ].freeze
21
+
22
+ CAPABILITY_EVIDENCE_STATES = %i[supported unsupported unknown].freeze
23
+ OPERATION_EVIDENCE_STATES = %i[supported unsupported unknown].freeze
24
+ VALUE_EVIDENCE_STATES = %i[known unknown].freeze
25
+ AVAILABILITY_STATES = %i[initializing available unavailable].freeze
26
+ AVAILABILITY_SOURCES = %i[startup_readiness readiness dispatch].freeze
27
+ PROBE_OUTCOMES = %i[success failure].freeze
28
+ PUBLICATION_STATES = %i[initializing complete].freeze
29
+ CALLABLE_STATES = %i[active retiring disposed].freeze
30
+
31
+ MUTATION_REASONS = %i[
32
+ claimed activated snapshot_replaced readiness_observed instance_unavailable
33
+ instance_recovered initial_readiness_failed removed stale_publisher stale_probe
34
+ already_removed
35
+ ].freeze
36
+
37
+ PROVIDER_OUTCOMES = %i[
38
+ success instance_unavailable overloaded model_not_ready rate_limited
39
+ authentication authorization billing policy invalid_request model_missing
40
+ context_rejected safety_refusal malformed_output tool_failure timeout
41
+ connection_failure provider_error cancelled client_disconnect
42
+ ].freeze
43
+
44
+ REJECTION_KINDS = %i[
45
+ invalid_routing_context invalid_request policy_denied failed_dependency
46
+ too_early service_unavailable context_rejected attempts_exhausted stale_selection
47
+ ].freeze
48
+
49
+ EXCLUSION_TARGET_KINDS = %i[
50
+ attempt_target lane offering instance provider model quota_domain
51
+ ].freeze
52
+
53
+ EXCLUSION_LIFETIMES = %i[request attempt_group].freeze
54
+
55
+ BODY_MODEL_HINT_DISPOSITIONS = %i[
56
+ absent auto superseded_by_explicit_model ignored_disabled
57
+ ignored_not_whitelisted ignored_blacklisted honored
58
+ ].freeze
59
+
60
+ EVIDENCE_SOURCES = %i[
61
+ provider_implementation model_metadata provider_catalog probe provider_envelope
62
+ model_override instance_override provider_override default_false absent guessed
63
+ failed_probe inconclusive_probe
64
+ ].freeze
65
+
66
+ UNKNOWN_ONLY_EVIDENCE_SOURCES = %i[
67
+ model_override instance_override provider_override default_false absent guessed
68
+ failed_probe inconclusive_probe
69
+ ].freeze
70
+
71
+ # Only these aliases are accepted, and only when allow_aliases: true.
72
+ OPERATION_ALIASES = {
73
+ stream: :stream_chat,
74
+ embedding: :embed,
75
+ moderation: :moderate,
76
+ audio_translation: :translate,
77
+ speech: :speak
78
+ }.freeze
79
+
80
+ module_function
81
+
82
+ # Normalize an operation to one of OPERATIONS. Ingress and protocol-v2
83
+ # compatibility adapters pass allow_aliases: true; registry records and
84
+ # exact-execution envelopes pass false. Unknown, empty, or invalid UTF-8
85
+ # input always raises Inventory::Errors::ValidationError.
86
+ def normalize_operation(value:, allow_aliases: false)
87
+ raise Inventory::Errors::ValidationError, 'operation must be a String or Symbol' unless value.is_a?(::String) || value.is_a?(::Symbol)
88
+
89
+ string = value.to_s
90
+ raise Inventory::Errors::ValidationError, 'operation is not valid UTF-8' unless [::Encoding::UTF_8, ::Encoding::US_ASCII].include?(string.encoding) && string.valid_encoding?
91
+
92
+ trimmed = string.strip
93
+ raise Inventory::Errors::ValidationError, 'operation must not be empty' if trimmed.empty?
94
+
95
+ candidate = trimmed.to_sym
96
+ return candidate if OPERATIONS.include?(candidate)
97
+ return OPERATION_ALIASES[candidate] if allow_aliases && OPERATION_ALIASES.key?(candidate)
98
+
99
+ raise Inventory::Errors::ValidationError, "operation is not a canonical operation (#{candidate})"
100
+ end
11
101
  end
12
102
  end
13
103
  end
@@ -54,7 +54,9 @@ module Legion
54
54
  message: @options[:message],
55
55
  error_class: @options[:error_class],
56
56
  retryable: @options[:retryable],
57
- metadata: @options[:metadata] || {}
57
+ metadata: @options[:metadata] || {},
58
+ execution_contract: @options[:execution_contract],
59
+ offering_id: @options[:offering_id]
58
60
  ).compact
59
61
  end
60
62
  end
@@ -53,7 +53,9 @@ module Legion
53
53
  tool_calls: @options[:tool_calls],
54
54
  usage: @options[:usage] || {},
55
55
  finish_reason: @options[:finish_reason],
56
- metadata: @options[:metadata] || {}
56
+ metadata: @options[:metadata] || {},
57
+ execution_contract: @options[:execution_contract],
58
+ offering_id: @options[:offering_id]
57
59
  ).compact
58
60
  end
59
61
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.6.16'
6
+ VERSION = '0.7.2'
7
7
  end
8
8
  end
9
9
  end
@@ -45,6 +45,24 @@ module Legion
45
45
  # --- Capability resolution policy (no internal deps) ---
46
46
  require_relative 'llm/capability_policy'
47
47
 
48
+ # --- Shared 3-level settings cascade (provider -> instance -> model) ---
49
+ require_relative 'llm/settings_cascade'
50
+
51
+ # --- SSOT v3 runtime contract (process-local; no legion-llm/LegionIO/transport/db/timer) ---
52
+ require_relative 'llm/inventory/errors'
53
+ require_relative 'llm/inventory/immutable_value'
54
+ require_relative 'llm/inventory/identity'
55
+ require_relative 'llm/inventory/evidence'
56
+ require_relative 'llm/inventory/callable_handle'
57
+ require_relative 'llm/inventory/probe_token'
58
+ require_relative 'llm/inventory/records'
59
+ require_relative 'llm/inventory/snapshot'
60
+ require_relative 'llm/inventory/registry'
61
+ require_relative 'llm/inventory/publisher'
62
+ require_relative 'llm/inventory/probe_coordinator'
63
+ require_relative 'llm/routing/records'
64
+ require_relative 'llm/routing/provider_outcome'
65
+
48
66
  # --- Base value objects (no internal deps) ---
49
67
  require_relative 'llm/mime_type'
50
68
  require_relative 'llm/model/info'
@@ -106,6 +106,30 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Message do
106
106
  expect(msg.content).to eq('hello')
107
107
  end
108
108
 
109
+ it 'drops the transport-only :cache_control key instead of raising ArgumentError' do
110
+ # Regression guard (root cause B): the prompt-cache step injects :cache_control
111
+ # onto every >=2-message request. from_hash previously did build(**h) with the
112
+ # whole hash, raising ArgumentError 'unknown keyword: :cache_control' before HTTP.
113
+ # It now projects onto the known member keys, so the transport-only key is dropped.
114
+ expect do
115
+ described_class.from_hash(
116
+ role: 'user',
117
+ content: 'hi',
118
+ cache_control: { type: 'ephemeral' }
119
+ )
120
+ end.not_to raise_error
121
+
122
+ msg = described_class.from_hash(
123
+ role: 'user',
124
+ content: 'hi',
125
+ cache_control: { type: 'ephemeral' }
126
+ )
127
+
128
+ expect(msg).to be_a(described_class)
129
+ expect(msg.role).to eq(:user)
130
+ expect(msg.text).to eq('hi')
131
+ end
132
+
109
133
  it 'returns nil for nil source' do
110
134
  expect(described_class.from_hash(nil)).to be_nil
111
135
  end
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_params_mapping_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_simple_text_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_system_prompt_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "system": "You are a helpful assistant that always responds in haiku form.",
4
5
  "messages": [
5
6
  {
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_thinking_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "description": "Full multi-turn history: mixed client/server tool calls + thinking block per G4",
3
3
  "id": "req_tool_continuation_001",
4
+ "metadata": { "model": "test-fixture-model" },
4
5
  "system": "You are a helpful assistant.",
5
6
  "messages": [
6
7
  {
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "id": "req_tools_001",
3
+ "metadata": { "model": "test-fixture-model" },
3
4
  "messages": [
4
5
  {
5
6
  "role": "user",