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,599 @@
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/evidence'
7
+ require 'legion/extensions/llm/inventory/callable_handle'
8
+ require 'legion/extensions/llm/taxonomies'
9
+ require 'legion/extensions/llm/capabilities'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Llm
14
+ module Inventory
15
+ # Documentation namespace anchor for the immutable inventory records
16
+ # defined in this file: OfferingDraft, OfferingRecord, LaneRecord,
17
+ # AvailabilityFact, ReadinessResult, InstanceRecord, PublicationStatus,
18
+ # and MutationResult. The records themselves live directly under
19
+ # Inventory so their canonical constant paths stay flat.
20
+ module Records; end
21
+
22
+ # Shared normalization/validation used by the immutable inventory and
23
+ # routing records. Kept internal to the records so no record implements a
24
+ # competing validator. See phase-1-lex-llm-additive.md sections 10 and 15.
25
+ module RecordSupport
26
+ module_function
27
+
28
+ SECRET_KEY_TOKENS = %w[
29
+ credential authorization token secret password apikey prompt endpointurl callable
30
+ ].freeze
31
+
32
+ PUBLICATION_SOURCES = %i[provider_catalog provider_static_catalog provider_control_plane].freeze
33
+
34
+ def check_unknown_kwargs!(kwargs:, members:)
35
+ unknown = kwargs.keys - members
36
+ raise Errors::ValidationError, "unknown keyword(s): #{unknown.join(', ')}" unless unknown.empty?
37
+ end
38
+
39
+ def valid_utf8?(string)
40
+ [::Encoding::UTF_8, ::Encoding::US_ASCII].include?(string.encoding) && string.valid_encoding?
41
+ end
42
+
43
+ def sanitized_reason(value:, field:, max_bytes: 1024)
44
+ raise Errors::ValidationError, "#{field} must be a String" unless value.is_a?(::String)
45
+
46
+ # Coerce any source encoding to valid UTF-8 rather than raising: provider
47
+ # error messages can be ASCII-8BIT/binary (raw response bodies, Ruby kernel
48
+ # error messages). A reason is diagnostic text, not operator input to reject —
49
+ # raising here would mask the real dispatch error as an unclassifiable 500 and
50
+ # defeat fail-forward. Undecodable bytes are replaced.
51
+ coerced = value.dup.force_encoding(::Encoding::UTF_8)
52
+ coerced = coerced.scrub('?') unless coerced.valid_encoding?
53
+
54
+ trimmed = coerced.strip
55
+ raise Errors::ValidationError, "#{field} must not be empty" if trimmed.empty?
56
+ raise Errors::ValidationError, "#{field} exceeds #{max_bytes} UTF-8 bytes" if trimmed.bytesize > max_bytes
57
+
58
+ trimmed.b.dup.force_encoding(::Encoding::UTF_8).freeze
59
+ end
60
+
61
+ def optional_sanitized_reason(value:, field:, max_bytes: 1024)
62
+ return nil if value.nil?
63
+
64
+ sanitized_reason(value: value, field: field, max_bytes: max_bytes)
65
+ end
66
+
67
+ def nonnegative_integer(value:, field:)
68
+ raise Errors::ValidationError, "#{field} must be a nonnegative Integer" unless nonnegative_integer?(value)
69
+
70
+ value
71
+ end
72
+
73
+ def nonnegative_integer?(value)
74
+ value.is_a?(::Integer) && !value.negative?
75
+ end
76
+
77
+ def boolean!(value:, field:)
78
+ return value if [true, false].include?(value)
79
+
80
+ raise Errors::ValidationError, "#{field} must be true or false"
81
+ end
82
+
83
+ def normalize_provider_family(value:, field: :provider_family)
84
+ text = Identity.normalize_text(value: value, field: field).downcase
85
+ raise Errors::ValidationError, "#{field} must be snake-case ASCII" unless text.match?(/\A[a-z][a-z0-9_]*\z/)
86
+
87
+ text.to_sym
88
+ end
89
+
90
+ def optional_time(value:, field:)
91
+ return nil if value.nil?
92
+ raise Errors::ValidationError, "#{field} must be a Time" unless value.is_a?(::Time)
93
+
94
+ value.dup.freeze
95
+ end
96
+
97
+ def frozen_metadata(value:, field: :metadata)
98
+ reject_secret_keys!(value, field)
99
+ ImmutableValue.copy_and_freeze(value: value, field: field)
100
+ end
101
+
102
+ def reject_secret_keys!(value, field)
103
+ case value
104
+ when ::Hash
105
+ value.each do |key, nested|
106
+ raise Errors::ValidationError, "#{field} contains a secret-like key" if secret_key?(key)
107
+
108
+ reject_secret_keys!(nested, field)
109
+ end
110
+ when ::Array
111
+ value.each { |element| reject_secret_keys!(element, field) }
112
+ end
113
+ end
114
+
115
+ def secret_key?(key)
116
+ normalized = key.to_s.downcase.gsub(/[^a-z0-9]/, '')
117
+ SECRET_KEY_TOKENS.any? { |token| normalized.include?(token) }
118
+ end
119
+
120
+ def validate_operation_evidence!(evidence)
121
+ raise Errors::ValidationError, 'operation_evidence must be a Hash' unless evidence.is_a?(::Hash)
122
+
123
+ normalized = {}
124
+ evidence.each do |key, value|
125
+ op = Taxonomies.normalize_operation(value: key, allow_aliases: false)
126
+ raise Errors::ValidationError, "operation_evidence[#{op}] must be an OperationEvidence" unless value.is_a?(OperationEvidence)
127
+ raise Errors::ValidationError, "operation_evidence[#{op}] operation must match key" unless value.operation == op
128
+ raise Errors::ValidationError, "duplicate operation_evidence key #{op}" if normalized.key?(op)
129
+
130
+ normalized[op] = value
131
+ end
132
+ raise Errors::ValidationError, 'operation_evidence keys must equal Taxonomies::OPERATIONS exactly' unless normalized.keys.sort == Taxonomies::OPERATIONS.sort
133
+
134
+ normalized.freeze
135
+ end
136
+
137
+ def validate_capability_evidence!(evidence)
138
+ raise Errors::ValidationError, 'capability_evidence must be a Hash' unless evidence.is_a?(::Hash)
139
+
140
+ normalized = {}
141
+ evidence.each do |key, value|
142
+ cap = Legion::Extensions::Llm::Capabilities.canonical(key)
143
+ raise Errors::ValidationError, "capability_evidence key #{cap} is not canonical" unless Legion::Extensions::Llm::Capabilities::CANONICAL.include?(cap)
144
+ raise Errors::ValidationError, "capability_evidence[#{cap}] must be a CapabilityEvidence" unless value.is_a?(CapabilityEvidence)
145
+ raise Errors::ValidationError, "capability_evidence[#{cap}] capability must match key" unless value.capability == cap
146
+ raise Errors::ValidationError, "duplicate capability_evidence key #{cap}" if normalized.key?(cap)
147
+
148
+ normalized[cap] = value
149
+ end
150
+ normalized.freeze
151
+ end
152
+
153
+ def validate_quota_domains!(quota_domains)
154
+ raise Errors::ValidationError, 'quota_domains must be a Hash' unless quota_domains.is_a?(::Hash)
155
+
156
+ normalized = {}
157
+ quota_domains.each do |key, value|
158
+ op = Taxonomies.normalize_operation(value: key, allow_aliases: false)
159
+ normalized[op] = validate_quota_domain_value!(value: value, field: "quota_domains[#{op}]")
160
+ end
161
+ normalized.freeze
162
+ end
163
+
164
+ def validate_quota_domain_value!(value:, field:)
165
+ raise Errors::ValidationError, "#{field} must be a nonempty opaque String" unless value.is_a?(::String) && valid_utf8?(value) && !value.strip.empty?
166
+
167
+ value.dup.freeze
168
+ end
169
+
170
+ # Validates and returns the five scalar ValueEvidence fields shared by
171
+ # OfferingDraft, OfferingRecord, and LaneRecord as a member Hash.
172
+ def scalar_value_evidences(kwargs)
173
+ {
174
+ context_evidence: positive_int_value_evidence!(kwargs[:context_evidence], :context_evidence),
175
+ max_output_evidence: positive_int_value_evidence!(kwargs[:max_output_evidence], :max_output_evidence),
176
+ embedding_dimensions_evidence: embedding_dimensions_value_evidence!(
177
+ kwargs[:embedding_dimensions_evidence], :embedding_dimensions_evidence
178
+ ),
179
+ model_revision_evidence: model_revision_value_evidence!(
180
+ kwargs[:model_revision_evidence], :model_revision_evidence
181
+ ),
182
+ tokenizer_evidence: tokenizer_value_evidence!(kwargs[:tokenizer_evidence], :tokenizer_evidence)
183
+ }
184
+ end
185
+
186
+ def value_evidence!(evidence, field)
187
+ raise Errors::ValidationError, "#{field} must be a ValueEvidence" unless evidence.is_a?(ValueEvidence)
188
+
189
+ evidence
190
+ end
191
+
192
+ def positive_int_value_evidence!(evidence, field)
193
+ value_evidence!(evidence, field)
194
+ raise Errors::ValidationError, "known #{field} must be a positive Integer" if evidence.known? && !(evidence.value.is_a?(::Integer) && evidence.value.positive?)
195
+
196
+ evidence
197
+ end
198
+
199
+ def embedding_dimensions_value_evidence!(evidence, field)
200
+ value_evidence!(evidence, field)
201
+ return evidence unless evidence.known?
202
+
203
+ dims = evidence.value
204
+ valid = dims.is_a?(::Array) && !dims.empty? &&
205
+ dims.all? { |dim| dim.is_a?(::Integer) && dim.positive? } &&
206
+ dims == dims.uniq && dims == dims.sort
207
+ raise Errors::ValidationError, "known #{field} must be a sorted, unique Array of positive Integers" unless valid
208
+
209
+ evidence
210
+ end
211
+
212
+ def model_revision_value_evidence!(evidence, field)
213
+ value_evidence!(evidence, field)
214
+ return evidence unless evidence.known?
215
+
216
+ revision = evidence.value
217
+ valid = revision.is_a?(::String) && !revision.strip.empty? &&
218
+ valid_utf8?(revision) && revision.unicode_normalized?(:nfc)
219
+ raise Errors::ValidationError, "known #{field} must be a nonempty NFC String" unless valid
220
+
221
+ evidence
222
+ end
223
+
224
+ def tokenizer_value_evidence!(evidence, field)
225
+ value_evidence!(evidence, field)
226
+ return evidence unless evidence.known?
227
+
228
+ descriptor = evidence.value
229
+ unless descriptor.is_a?(::Hash) && descriptor.keys.sort == %i[estimator parameters version]
230
+ raise Errors::ValidationError, "known #{field} must be exactly {estimator:, version:, parameters:}"
231
+ end
232
+
233
+ nonempty_nfc!(value: descriptor[:estimator], field: "#{field}.estimator")
234
+ nonempty_nfc!(value: descriptor[:version], field: "#{field}.version")
235
+ raise Errors::ValidationError, "#{field}.parameters must be a Hash" unless descriptor[:parameters].is_a?(::Hash)
236
+
237
+ evidence
238
+ end
239
+
240
+ def nonempty_nfc!(value:, field:)
241
+ valid = value.is_a?(::String) && !value.strip.empty? && valid_utf8?(value) && value.unicode_normalized?(:nfc)
242
+ raise Errors::ValidationError, "#{field} must be a nonempty NFC String" unless valid
243
+
244
+ value
245
+ end
246
+
247
+ def publication_source!(value:)
248
+ raise Errors::ValidationError, "publication_source must be one of #{PUBLICATION_SOURCES.inspect}" unless PUBLICATION_SOURCES.include?(value)
249
+
250
+ value
251
+ end
252
+ end
253
+
254
+ # An off-registry provider draft of one offering. Carries no provider
255
+ # family, instance ID, offering ID, lane ID, callable, health, weight, or
256
+ # default model. See section 10.1.
257
+ OfferingDraft = ::Data.define(
258
+ :provider_native_key, :model, :tier, :operation_evidence, :capability_evidence,
259
+ :context_evidence, :max_output_evidence, :embedding_dimensions_evidence,
260
+ :model_revision_evidence, :tokenizer_evidence, :quota_domains, :metadata, :publication_source
261
+ ) do
262
+ def initialize(**kwargs)
263
+ kwargs = { capability_evidence: {}, quota_domains: {}, metadata: {} }.merge(kwargs)
264
+ RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
265
+
266
+ super(
267
+ provider_native_key: Identity.normalize_text(value: kwargs[:provider_native_key], field: :provider_native_key),
268
+ model: Identity.normalize_text(value: kwargs[:model], field: :model),
269
+ tier: Identity.normalize_enum(value: kwargs[:tier], field: :tier, allowed: Taxonomies::TIERS),
270
+ operation_evidence: RecordSupport.validate_operation_evidence!(kwargs[:operation_evidence]),
271
+ capability_evidence: RecordSupport.validate_capability_evidence!(kwargs[:capability_evidence]),
272
+ quota_domains: RecordSupport.validate_quota_domains!(kwargs[:quota_domains]),
273
+ metadata: RecordSupport.frozen_metadata(value: kwargs[:metadata]),
274
+ publication_source: RecordSupport.publication_source!(value: kwargs[:publication_source]),
275
+ **RecordSupport.scalar_value_evidences(kwargs)
276
+ )
277
+ end
278
+ end
279
+
280
+ # A registry-owned offering with canonical identity and captured callable.
281
+ # Only Inventory::Publisher/Registry constructs it. See section 10.2.
282
+ OfferingRecord = ::Data.define(
283
+ :offering_id, :provider_native_key, :instance_key, :model, :tier, :operation_evidence,
284
+ :capability_evidence, :context_evidence, :max_output_evidence, :embedding_dimensions_evidence,
285
+ :model_revision_evidence, :tokenizer_evidence, :quota_domains, :metadata, :callable_handle,
286
+ :publication_source
287
+ ) do
288
+ def initialize(**kwargs)
289
+ RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
290
+ instance_key = kwargs[:instance_key]
291
+ callable_handle = kwargs[:callable_handle]
292
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
293
+ raise Errors::ValidationError, 'callable_handle must be a CallableHandle' unless callable_handle.is_a?(CallableHandle)
294
+
295
+ native_key = Identity.normalize_text(value: kwargs[:provider_native_key], field: :provider_native_key)
296
+ Identity.validate_offering_id!(value: kwargs[:offering_id], instance_key: instance_key, provider_native_key: native_key)
297
+
298
+ super(
299
+ offering_id: kwargs[:offering_id].dup.freeze,
300
+ provider_native_key: native_key,
301
+ instance_key: instance_key,
302
+ model: Identity.normalize_text(value: kwargs[:model], field: :model),
303
+ tier: Identity.normalize_enum(value: kwargs[:tier], field: :tier, allowed: Taxonomies::TIERS),
304
+ operation_evidence: RecordSupport.validate_operation_evidence!(kwargs[:operation_evidence]),
305
+ capability_evidence: RecordSupport.validate_capability_evidence!(kwargs[:capability_evidence]),
306
+ quota_domains: RecordSupport.validate_quota_domains!(kwargs[:quota_domains]),
307
+ metadata: RecordSupport.frozen_metadata(value: kwargs[:metadata]),
308
+ callable_handle: callable_handle,
309
+ publication_source: RecordSupport.publication_source!(value: kwargs[:publication_source]),
310
+ **RecordSupport.scalar_value_evidences(kwargs)
311
+ )
312
+ end
313
+
314
+ def supported_operations
315
+ operation_evidence.select { |_op, evidence| evidence.supported? }.keys.sort
316
+ end
317
+
318
+ def unsupported_operations
319
+ operation_evidence.select { |_op, evidence| evidence.unsupported? }.keys.sort
320
+ end
321
+
322
+ def unknown_operations
323
+ operation_evidence.select { |_op, evidence| evidence.unknown? }.keys.sort
324
+ end
325
+
326
+ def operation_status(operation:)
327
+ operation_evidence.fetch(Taxonomies.normalize_operation(value: operation, allow_aliases: false)).status
328
+ end
329
+
330
+ def capability_evidence_for(capability:)
331
+ capability_evidence[Legion::Extensions::Llm::Capabilities.canonical(capability)]
332
+ end
333
+
334
+ def capability_status(capability:)
335
+ evidence = capability_evidence_for(capability: capability)
336
+ evidence.nil? ? :unknown : evidence.status
337
+ end
338
+
339
+ def quota_domain(operation:)
340
+ quota_domains[Taxonomies.normalize_operation(value: operation, allow_aliases: false)]
341
+ end
342
+ end
343
+
344
+ # An executable lane derived by the registry from one supported operation
345
+ # of an OfferingRecord. See section 10.3.
346
+ LaneRecord = ::Data.define(
347
+ :lane_id, :offering_id, :instance_key, :provider_family, :instance_id, :model, :tier, :operation,
348
+ :capability_evidence, :context_evidence, :max_output_evidence, :embedding_dimensions_evidence,
349
+ :model_revision_evidence, :tokenizer_evidence, :quota_domain, :metadata, :callable_handle,
350
+ :publication_source
351
+ ) do
352
+ def initialize(**kwargs)
353
+ RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
354
+ instance_key = kwargs[:instance_key]
355
+ callable_handle = kwargs[:callable_handle]
356
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
357
+ raise Errors::ValidationError, 'callable_handle must be a CallableHandle' unless callable_handle.is_a?(CallableHandle)
358
+
359
+ family = RecordSupport.normalize_provider_family(value: kwargs[:provider_family])
360
+ resolved_instance = Identity.normalize_text(value: kwargs[:instance_id], field: :instance_id)
361
+ unless family == instance_key.provider_family && resolved_instance == instance_key.instance_id
362
+ raise Errors::ValidationError, 'provider_family and instance_id must equal instance_key'
363
+ end
364
+
365
+ canonical_model = Identity.normalize_text(value: kwargs[:model], field: :model)
366
+ canonical_operation = Taxonomies.normalize_operation(value: kwargs[:operation], allow_aliases: false)
367
+ Identity.validate_lane_id!(
368
+ value: kwargs[:lane_id], instance_key: instance_key, operation: canonical_operation,
369
+ model: canonical_model, offering_id: kwargs[:offering_id]
370
+ )
371
+
372
+ super(
373
+ lane_id: kwargs[:lane_id].dup.freeze,
374
+ offering_id: kwargs[:offering_id].dup.freeze,
375
+ instance_key: instance_key,
376
+ provider_family: family,
377
+ instance_id: resolved_instance,
378
+ model: canonical_model,
379
+ tier: Identity.normalize_enum(value: kwargs[:tier], field: :tier, allowed: Taxonomies::TIERS),
380
+ operation: canonical_operation,
381
+ capability_evidence: RecordSupport.validate_capability_evidence!(kwargs[:capability_evidence]),
382
+ quota_domain: kwargs[:quota_domain].nil? ? nil : RecordSupport.validate_quota_domain_value!(value: kwargs[:quota_domain], field: :quota_domain),
383
+ metadata: RecordSupport.frozen_metadata(value: kwargs[:metadata]),
384
+ callable_handle: callable_handle,
385
+ publication_source: RecordSupport.publication_source!(value: kwargs[:publication_source]),
386
+ **RecordSupport.scalar_value_evidences(kwargs)
387
+ )
388
+ end
389
+ end
390
+
391
+ # The availability state of an exact instance. Carries no cooldown,
392
+ # half-open, latency, success-count, or clock-expiry field. See 10.4.
393
+ AvailabilityFact = ::Data.define(
394
+ :state, :availability_revision, :source, :reason, :observed_at,
395
+ :last_probe_started_at, :last_probe_completed_at, :last_probe_outcome, :unavailable_revision
396
+ ) do
397
+ def initialize(**kwargs)
398
+ kwargs = {
399
+ last_probe_started_at: nil, last_probe_completed_at: nil, last_probe_outcome: nil, unavailable_revision: nil
400
+ }.merge(kwargs)
401
+ RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
402
+
403
+ state = kwargs[:state]
404
+ availability_revision = kwargs[:availability_revision]
405
+ unavailable_revision = kwargs[:unavailable_revision]
406
+ raise Errors::ValidationError, 'state must be a Taxonomies::AVAILABILITY_STATES value' unless Taxonomies::AVAILABILITY_STATES.include?(state)
407
+ raise Errors::ValidationError, 'source must be a Taxonomies::AVAILABILITY_SOURCES value' unless Taxonomies::AVAILABILITY_SOURCES.include?(kwargs[:source])
408
+
409
+ RecordSupport.nonnegative_integer(value: availability_revision, field: :availability_revision)
410
+ validate_unavailable_revision!(state, availability_revision, unavailable_revision)
411
+ resolved_outcome = validate_probe_telemetry!(
412
+ kwargs[:last_probe_started_at], kwargs[:last_probe_completed_at], kwargs[:last_probe_outcome]
413
+ )
414
+
415
+ super(
416
+ state: state,
417
+ availability_revision: availability_revision,
418
+ source: kwargs[:source],
419
+ reason: RecordSupport.sanitized_reason(value: kwargs[:reason], field: :reason),
420
+ observed_at: RecordSupport.optional_time(value: kwargs[:observed_at], field: :observed_at),
421
+ last_probe_started_at: RecordSupport.optional_time(value: kwargs[:last_probe_started_at], field: :last_probe_started_at),
422
+ last_probe_completed_at: RecordSupport.optional_time(value: kwargs[:last_probe_completed_at], field: :last_probe_completed_at),
423
+ last_probe_outcome: resolved_outcome,
424
+ unavailable_revision: unavailable_revision
425
+ )
426
+ end
427
+
428
+ def validate_unavailable_revision!(state, availability_revision, unavailable_revision)
429
+ if state == :unavailable
430
+ raise Errors::ValidationError, 'unavailable state requires unavailable_revision == availability_revision' unless unavailable_revision == availability_revision
431
+ elsif !unavailable_revision.nil?
432
+ raise Errors::ValidationError, 'non-unavailable state requires unavailable_revision: nil'
433
+ end
434
+ end
435
+
436
+ def validate_probe_telemetry!(started_at, completed_at, outcome)
437
+ return nil if outcome.nil?
438
+ raise Errors::ValidationError, 'last_probe_outcome must be nil, :success, or :failure' unless Taxonomies::PROBE_OUTCOMES.include?(outcome)
439
+ raise Errors::ValidationError, 'a non-nil last_probe_outcome requires both probe timestamps' unless started_at.is_a?(::Time) && completed_at.is_a?(::Time)
440
+ raise Errors::ValidationError, 'probe completion cannot precede start' if completed_at < started_at
441
+
442
+ outcome
443
+ end
444
+ end
445
+
446
+ # The result of a provider-specific safe readiness operation. Contains no
447
+ # model output, prompt, tokens, callable, exception, or transition
448
+ # instruction. See section 10.5.
449
+ ReadinessResult = ::Data.define(:ready, :reason, :metadata) do
450
+ def initialize(ready:, reason:, metadata: {})
451
+ super(
452
+ ready: RecordSupport.boolean!(value: ready, field: :ready),
453
+ reason: RecordSupport.sanitized_reason(value: reason, field: :reason),
454
+ metadata: RecordSupport.frozen_metadata(value: metadata)
455
+ )
456
+ end
457
+
458
+ def ready?
459
+ ready
460
+ end
461
+ end
462
+
463
+ # An activated exact instance's complete published state. Its availability
464
+ # is available or unavailable, never initializing. See section 10.6.
465
+ InstanceRecord = ::Data.define(
466
+ :instance_key, :callable_handle, :availability, :offerings_by_id,
467
+ :publisher_id, :publisher_token_id, :published_sequence, :published_at
468
+ ) do
469
+ def initialize(**kwargs)
470
+ RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
471
+ instance_key = kwargs[:instance_key]
472
+ callable_handle = kwargs[:callable_handle]
473
+ availability = kwargs[:availability]
474
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
475
+ raise Errors::ValidationError, 'callable_handle must be a CallableHandle' unless callable_handle.is_a?(CallableHandle)
476
+ unless availability.is_a?(AvailabilityFact) && %i[available unavailable].include?(availability.state)
477
+ raise Errors::ValidationError, 'availability must be an available/unavailable AvailabilityFact'
478
+ end
479
+
480
+ super(
481
+ instance_key: instance_key,
482
+ callable_handle: callable_handle,
483
+ availability: availability,
484
+ offerings_by_id: validate_offerings!(kwargs[:offerings_by_id], instance_key, callable_handle),
485
+ publisher_id: nonempty_id!(kwargs[:publisher_id], :publisher_id),
486
+ publisher_token_id: nonempty_id!(kwargs[:publisher_token_id], :publisher_token_id),
487
+ published_sequence: RecordSupport.nonnegative_integer(value: kwargs[:published_sequence], field: :published_sequence),
488
+ published_at: require_time!(kwargs[:published_at], :published_at)
489
+ )
490
+ end
491
+
492
+ def validate_offerings!(offerings_by_id, instance_key, callable_handle)
493
+ raise Errors::ValidationError, 'offerings_by_id must be a Hash' unless offerings_by_id.is_a?(::Hash)
494
+
495
+ offerings_by_id.each do |offering_id, offering|
496
+ raise Errors::ValidationError, "offering #{offering_id} must be an OfferingRecord" unless offering.is_a?(OfferingRecord)
497
+ raise Errors::ValidationError, "offering #{offering_id} key mismatch" unless offering.offering_id == offering_id
498
+ unless offering.instance_key == instance_key && offering.callable_handle.equal?(callable_handle)
499
+ raise Errors::ValidationError, "offering #{offering_id} must share the instance callable/key"
500
+ end
501
+ end
502
+ offerings_by_id.dup.freeze
503
+ end
504
+
505
+ def nonempty_id!(value, field)
506
+ raise Errors::ValidationError, "#{field} must be a nonempty String" unless value.is_a?(::String) && !value.empty?
507
+
508
+ value.dup.freeze
509
+ end
510
+
511
+ def require_time!(value, field)
512
+ raise Errors::ValidationError, "#{field} must be a Time" unless value.is_a?(::Time)
513
+
514
+ value.dup.freeze
515
+ end
516
+ end
517
+
518
+ # Publication-scope status distinguishing an initializing claim from an
519
+ # authoritative complete catalog. See section 10.7.
520
+ PublicationStatus = ::Data.define(
521
+ :instance_key, :state, :publisher_token_id, :published_sequence,
522
+ :last_probe_started_at, :last_probe_completed_at, :last_probe_outcome, :last_error
523
+ ) do
524
+ def initialize(**kwargs)
525
+ kwargs = {
526
+ last_probe_started_at: nil, last_probe_completed_at: nil, last_probe_outcome: nil, last_error: nil
527
+ }.merge(kwargs)
528
+ RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
529
+
530
+ instance_key = kwargs[:instance_key]
531
+ published_sequence = kwargs[:published_sequence]
532
+ last_probe_outcome = kwargs[:last_probe_outcome]
533
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
534
+ raise Errors::ValidationError, 'state must be :initializing or :complete' unless Taxonomies::PUBLICATION_STATES.include?(kwargs[:state])
535
+ raise Errors::ValidationError, 'published_sequence must be nil or a nonnegative Integer' unless published_sequence.nil? || RecordSupport.nonnegative_integer?(published_sequence)
536
+ raise Errors::ValidationError, 'last_probe_outcome must be nil, :success, or :failure' unless last_probe_outcome.nil? || Taxonomies::PROBE_OUTCOMES.include?(last_probe_outcome)
537
+
538
+ super(
539
+ instance_key: instance_key,
540
+ state: kwargs[:state],
541
+ publisher_token_id: kwargs[:publisher_token_id].nil? ? nil : kwargs[:publisher_token_id].dup.freeze,
542
+ published_sequence: published_sequence,
543
+ last_probe_started_at: RecordSupport.optional_time(value: kwargs[:last_probe_started_at], field: :last_probe_started_at),
544
+ last_probe_completed_at: RecordSupport.optional_time(value: kwargs[:last_probe_completed_at], field: :last_probe_completed_at),
545
+ last_probe_outcome: last_probe_outcome,
546
+ last_error: RecordSupport.optional_sanitized_reason(value: kwargs[:last_error], field: :last_error)
547
+ )
548
+ end
549
+ end
550
+
551
+ # The outcome of a registry mutation. Expected stale results use
552
+ # applied: false; every other reason requires applied: true. See 10.8.
553
+ MutationResult = ::Data.define(
554
+ :applied, :reason, :generation, :instance_key, :publication_status, :instance_record
555
+ ) do
556
+ def initialize(**kwargs)
557
+ kwargs = { publication_status: nil, instance_record: nil }.merge(kwargs)
558
+ RecordSupport.check_unknown_kwargs!(kwargs: kwargs, members: self.class.members)
559
+
560
+ applied = kwargs[:applied]
561
+ reason = kwargs[:reason]
562
+ instance_key = kwargs[:instance_key]
563
+ publication_status = kwargs[:publication_status]
564
+ instance_record = kwargs[:instance_record]
565
+ RecordSupport.boolean!(value: applied, field: :applied)
566
+ raise Errors::ValidationError, 'reason must be a Taxonomies::MUTATION_REASONS value' unless Taxonomies::MUTATION_REASONS.include?(reason)
567
+
568
+ validate_applied_reason!(applied, reason)
569
+ raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
570
+ raise Errors::ValidationError, 'publication_status must be a PublicationStatus or nil' unless publication_status.nil? || publication_status.is_a?(PublicationStatus)
571
+ raise Errors::ValidationError, 'instance_record must be an InstanceRecord or nil' unless instance_record.nil? || instance_record.is_a?(InstanceRecord)
572
+
573
+ super(
574
+ applied: applied,
575
+ reason: reason,
576
+ generation: RecordSupport.nonnegative_integer(value: kwargs[:generation], field: :generation),
577
+ instance_key: instance_key,
578
+ publication_status: publication_status,
579
+ instance_record: instance_record
580
+ )
581
+ end
582
+
583
+ def applied?
584
+ applied
585
+ end
586
+
587
+ def validate_applied_reason!(applied, reason)
588
+ non_applied = %i[stale_publisher stale_probe already_removed]
589
+ if applied
590
+ raise Errors::ValidationError, "applied result cannot use reason #{reason}" if non_applied.include?(reason)
591
+ elsif !non_applied.include?(reason)
592
+ raise Errors::ValidationError, "non-applied result requires a stale/already-removed reason, got #{reason}"
593
+ end
594
+ end
595
+ end
596
+ end
597
+ end
598
+ end
599
+ end