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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -0
- data/lex-llm.gemspec +1 -1
- data/lib/legion/extensions/llm/auto_registration.rb +3 -0
- data/lib/legion/extensions/llm/canonical/message.rb +5 -1
- data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
- data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
- data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
- data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
- data/lib/legion/extensions/llm/inventory/identity.rb +141 -0
- data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
- data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
- data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
- data/lib/legion/extensions/llm/inventory/publisher.rb +116 -0
- data/lib/legion/extensions/llm/inventory/records.rb +599 -0
- data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
- data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
- data/lib/legion/extensions/llm/provider.rb +55 -29
- data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
- data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
- data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
- data/lib/legion/extensions/llm/routing/records.rb +288 -0
- data/lib/legion/extensions/llm/settings_cascade.rb +120 -0
- data/lib/legion/extensions/llm/taxonomies.rb +90 -0
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +18 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +24 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +1 -0
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
- data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
- data/spec/legion/extensions/llm/inventory/evidence_spec.rb +138 -0
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +280 -0
- data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
- data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +144 -0
- data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
- data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
- data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
- data/spec/legion/extensions/llm/provider_spec.rb +83 -0
- data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +73 -0
- data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
- data/spec/legion/extensions/llm/settings_cascade_spec.rb +208 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
- data/spec/support/fake_llm_provider.rb +11 -0
- data/spec/support/fake_ssot_harness.rb +137 -0
- data/spec/support/ssot_registry_helpers.rb +75 -0
- 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
|
|
@@ -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
|