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,134 @@
|
|
|
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/taxonomies'
|
|
6
|
+
require 'legion/extensions/llm/capabilities'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Llm
|
|
11
|
+
module Inventory
|
|
12
|
+
# Immutable operation, capability, and value evidence with
|
|
13
|
+
# authoritative/unknown semantics. See phase-1-lex-llm-additive.md
|
|
14
|
+
# section 9.
|
|
15
|
+
#
|
|
16
|
+
# For every evidence record `observed_at` is nil or a duplicated frozen
|
|
17
|
+
# Time used only for telemetry. It never participates in authority,
|
|
18
|
+
# ordering, freshness, recovery, or selection.
|
|
19
|
+
module Evidence
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def normalize_observed_at(observed_at)
|
|
23
|
+
return nil if observed_at.nil?
|
|
24
|
+
raise Errors::ValidationError, 'observed_at must be a Time' unless observed_at.is_a?(::Time)
|
|
25
|
+
|
|
26
|
+
observed_at.dup.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def validate_evidence_status(status:, allowed:)
|
|
30
|
+
raise Errors::ValidationError, "status must be one of #{allowed.inspect}" unless allowed.include?(status)
|
|
31
|
+
|
|
32
|
+
status
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def validate_evidence_source(source:)
|
|
36
|
+
raise Errors::ValidationError, 'source is not one of Taxonomies::EVIDENCE_SOURCES' unless Taxonomies::EVIDENCE_SOURCES.include?(source)
|
|
37
|
+
|
|
38
|
+
source
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def enforce_unknown_only_source!(status:, source:)
|
|
42
|
+
return unless Taxonomies::UNKNOWN_ONLY_EVIDENCE_SOURCES.include?(source)
|
|
43
|
+
return if status == :unknown
|
|
44
|
+
|
|
45
|
+
raise Errors::ValidationError, "source #{source} requires status :unknown"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Evidence that a provider instance supports (or is not known to support)
|
|
50
|
+
# a canonical operation. `stream_chat` stays `stream_chat`; it never
|
|
51
|
+
# canonicalizes into capability `streaming`.
|
|
52
|
+
OperationEvidence = ::Data.define(:operation, :status, :source, :observed_at, :metadata) do
|
|
53
|
+
def initialize(operation:, status:, source:, observed_at: nil, metadata: {})
|
|
54
|
+
canonical_operation = Taxonomies.normalize_operation(value: operation, allow_aliases: false)
|
|
55
|
+
Evidence.validate_evidence_status(status: status, allowed: Taxonomies::OPERATION_EVIDENCE_STATES)
|
|
56
|
+
Evidence.validate_evidence_source(source: source)
|
|
57
|
+
Evidence.enforce_unknown_only_source!(status: status, source: source)
|
|
58
|
+
|
|
59
|
+
super(
|
|
60
|
+
operation: canonical_operation,
|
|
61
|
+
status: status,
|
|
62
|
+
source: source,
|
|
63
|
+
observed_at: Evidence.normalize_observed_at(observed_at),
|
|
64
|
+
metadata: ImmutableValue.copy_and_freeze(value: metadata, field: :metadata)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def supported? = status == :supported
|
|
69
|
+
def unsupported? = status == :unsupported
|
|
70
|
+
def unknown? = status == :unknown
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Evidence that a provider instance supports (or is not known to support)
|
|
74
|
+
# a canonical capability. Config permission, absent fields, default_false,
|
|
75
|
+
# failed probes, and guesses must be constructed as :unknown, never
|
|
76
|
+
# :unsupported.
|
|
77
|
+
CapabilityEvidence = ::Data.define(:capability, :status, :source, :observed_at, :metadata) do
|
|
78
|
+
def initialize(capability:, status:, source:, observed_at: nil, metadata: {})
|
|
79
|
+
canonical_capability = Legion::Extensions::Llm::Capabilities.canonical(capability)
|
|
80
|
+
raise Errors::ValidationError, "capability #{canonical_capability} is not canonical" unless Legion::Extensions::Llm::Capabilities::CANONICAL.include?(canonical_capability)
|
|
81
|
+
|
|
82
|
+
Evidence.validate_evidence_status(status: status, allowed: Taxonomies::CAPABILITY_EVIDENCE_STATES)
|
|
83
|
+
Evidence.validate_evidence_source(source: source)
|
|
84
|
+
Evidence.enforce_unknown_only_source!(status: status, source: source)
|
|
85
|
+
|
|
86
|
+
super(
|
|
87
|
+
capability: canonical_capability,
|
|
88
|
+
status: status,
|
|
89
|
+
source: source,
|
|
90
|
+
observed_at: Evidence.normalize_observed_at(observed_at),
|
|
91
|
+
metadata: ImmutableValue.copy_and_freeze(value: metadata, field: :metadata)
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def supported? = status == :supported
|
|
96
|
+
def unsupported? = status == :unsupported
|
|
97
|
+
def unknown? = status == :unknown
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Evidence carrying a known scalar value (context limit, maximum output,
|
|
101
|
+
# embedding dimensions, tokenizer/estimator, immutable model revision) or
|
|
102
|
+
# an explicit :unknown. Each field uses a separate instance so one known
|
|
103
|
+
# field cannot make another field authoritative.
|
|
104
|
+
ValueEvidence = ::Data.define(:status, :value, :source, :observed_at, :metadata) do
|
|
105
|
+
def initialize(status:, source:, value: nil, observed_at: nil, metadata: {})
|
|
106
|
+
Evidence.validate_evidence_status(status: status, allowed: Taxonomies::VALUE_EVIDENCE_STATES)
|
|
107
|
+
Evidence.validate_evidence_source(source: source)
|
|
108
|
+
Evidence.enforce_unknown_only_source!(status: status, source: source)
|
|
109
|
+
|
|
110
|
+
case status
|
|
111
|
+
when :known
|
|
112
|
+
raise Errors::ValidationError, 'known ValueEvidence requires a non-nil value' if value.nil?
|
|
113
|
+
when :unknown
|
|
114
|
+
raise Errors::ValidationError, 'unknown ValueEvidence requires value: nil' unless value.nil?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
copied_value = value.nil? ? nil : ImmutableValue.copy_and_freeze(value: value, field: :value)
|
|
118
|
+
|
|
119
|
+
super(
|
|
120
|
+
status: status,
|
|
121
|
+
value: copied_value,
|
|
122
|
+
source: source,
|
|
123
|
+
observed_at: Evidence.normalize_observed_at(observed_at),
|
|
124
|
+
metadata: ImmutableValue.copy_and_freeze(value: metadata, field: :metadata)
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def known? = status == :known
|
|
129
|
+
def unknown? = status == :unknown
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
module Inventory
|
|
10
|
+
# Canonical field normalization plus the `off:v1:` and `lane:v1:`
|
|
11
|
+
# length-framed SHA-256 encoders/validators. See
|
|
12
|
+
# phase-1-lex-llm-additive.md section 8.
|
|
13
|
+
#
|
|
14
|
+
# Framing is binary: decimal UTF-8 byte length, ASCII ':', then the exact
|
|
15
|
+
# UTF-8 bytes. It never uses a delimiter join, Ruby hash order, object ID,
|
|
16
|
+
# or Ruby Hash#hash. Tier is never an identity input.
|
|
17
|
+
module Identity
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def normalize_text(value:, field:)
|
|
21
|
+
raise Errors::ValidationError, "#{field} must be a String or Symbol" unless text_input?(value)
|
|
22
|
+
|
|
23
|
+
string = value.to_s
|
|
24
|
+
raise Errors::ValidationError, "#{field} is not valid UTF-8" unless valid_utf8?(string)
|
|
25
|
+
|
|
26
|
+
trimmed = utf8(string).strip
|
|
27
|
+
raise Errors::ValidationError, "#{field} must not be empty" if trimmed.empty?
|
|
28
|
+
raise Errors::ValidationError, "#{field} must be NFC-normalized" unless trimmed.unicode_normalized?(:nfc)
|
|
29
|
+
|
|
30
|
+
trimmed.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def normalize_enum(value:, field:, allowed:)
|
|
34
|
+
raise Errors::ValidationError, "#{field} must be a String or Symbol" unless text_input?(value)
|
|
35
|
+
|
|
36
|
+
trimmed = value.to_s.strip
|
|
37
|
+
raise Errors::ValidationError, "#{field} must not be empty" if trimmed.empty?
|
|
38
|
+
|
|
39
|
+
candidate = trimmed.to_sym
|
|
40
|
+
raise Errors::ValidationError, "#{field} is not one of the allowed values" unless allowed.include?(candidate)
|
|
41
|
+
|
|
42
|
+
candidate
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def length_frame(value:)
|
|
46
|
+
bytes = value.to_s.b
|
|
47
|
+
"#{bytes.bytesize}:".b + bytes
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def offering_id(instance_key:, provider_native_key:)
|
|
51
|
+
digest = ::Digest::SHA256.hexdigest(
|
|
52
|
+
length_frame(value: instance_key.provider_family) +
|
|
53
|
+
length_frame(value: instance_key.instance_id) +
|
|
54
|
+
length_frame(value: provider_native_key)
|
|
55
|
+
)
|
|
56
|
+
"off:v1:#{digest}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def lane_id(instance_key:, operation:, model:, offering_id:)
|
|
60
|
+
digest = ::Digest::SHA256.hexdigest(
|
|
61
|
+
"lane-v1\x00".b +
|
|
62
|
+
length_frame(value: instance_key.provider_family) +
|
|
63
|
+
length_frame(value: instance_key.instance_id) +
|
|
64
|
+
length_frame(value: operation) +
|
|
65
|
+
length_frame(value: model) +
|
|
66
|
+
length_frame(value: offering_id)
|
|
67
|
+
)
|
|
68
|
+
"lane:v1:#{digest}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def validate_offering_id!(value:, instance_key:, provider_native_key:)
|
|
72
|
+
expected = offering_id(instance_key: instance_key, provider_native_key: provider_native_key)
|
|
73
|
+
raise Errors::ValidationError, 'offering_id does not reproduce from its identity fields' unless value == expected
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_lane_id!(value:, instance_key:, operation:, model:, offering_id:)
|
|
79
|
+
expected = lane_id(
|
|
80
|
+
instance_key: instance_key, operation: operation, model: model, offering_id: offering_id
|
|
81
|
+
)
|
|
82
|
+
raise Errors::ValidationError, 'lane_id does not reproduce from its identity fields' unless value == expected
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def text_input?(value)
|
|
88
|
+
value.is_a?(::String) || value.is_a?(::Symbol)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def valid_utf8?(string)
|
|
92
|
+
[::Encoding::UTF_8, ::Encoding::US_ASCII].include?(string.encoding) && string.valid_encoding?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def utf8(string)
|
|
96
|
+
string.encoding == ::Encoding::UTF_8 ? string.dup : string.b.dup.force_encoding(::Encoding::UTF_8)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private_class_method :text_input?, :valid_utf8?, :utf8
|
|
100
|
+
|
|
101
|
+
# A mandatory, immutable provider_family + instance_id pair. Provider
|
|
102
|
+
# family alone is never executable; two instances of the same provider
|
|
103
|
+
# are independent targets. See section 8.1.
|
|
104
|
+
#
|
|
105
|
+
# instance_id is the operator's CONFIG NAME — the identity the router
|
|
106
|
+
# keys settings lookups (instances.<name>) by. physical_id is an
|
|
107
|
+
# optional SECONDARY field carrying the physical/derived id
|
|
108
|
+
# (e.g. "host:port" or "host:port/ak:<digest>") preserved for dedup
|
|
109
|
+
# and diagnostics. It is NOT identity: it never participates in
|
|
110
|
+
# equality, hashing, or registry-scope identity, so two config names
|
|
111
|
+
# pointing at the same endpoint stay distinct instances.
|
|
112
|
+
InstanceKey = ::Data.define(:provider_family, :instance_id, :physical_id) do
|
|
113
|
+
def initialize(provider_family:, instance_id:, physical_id: nil)
|
|
114
|
+
family = Identity.normalize_text(value: provider_family, field: :provider_family).downcase
|
|
115
|
+
raise Errors::ValidationError, 'provider_family must match /\A[a-z][a-z0-9_]*\z/' unless family.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
116
|
+
|
|
117
|
+
instance = Identity.normalize_text(value: instance_id, field: :instance_id)
|
|
118
|
+
raise Errors::ValidationError, 'instance_id must not be the reserved value "default"' if instance == 'default'
|
|
119
|
+
|
|
120
|
+
physical = physical_id.nil? ? nil : Identity.normalize_text(value: physical_id, field: :physical_id)
|
|
121
|
+
|
|
122
|
+
super(provider_family: family.to_sym, instance_id: instance, physical_id: physical)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Identity is exactly (provider_family, instance_id). The
|
|
126
|
+
# secondary physical_id never affects equality, hashing, or
|
|
127
|
+
# registry-scope identity.
|
|
128
|
+
def ==(other)
|
|
129
|
+
other.is_a?(self.class) && other.provider_family == provider_family && other.instance_id == instance_id
|
|
130
|
+
end
|
|
131
|
+
alias_method :eql?, :==
|
|
132
|
+
|
|
133
|
+
def hash
|
|
134
|
+
[self.class, provider_family, instance_id].hash
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Llm
|
|
8
|
+
module Inventory
|
|
9
|
+
# The single strict recursive copier/freezer for JSON-like metadata and
|
|
10
|
+
# collection fields. See phase-1-lex-llm-additive.md section 7.1.
|
|
11
|
+
#
|
|
12
|
+
# Records call this helper before `super`; they never implement a
|
|
13
|
+
# competing recursive-freeze helper. It recursively copies and freezes
|
|
14
|
+
# only nil, true/false, Symbol, Integer, finite Float, String, Time,
|
|
15
|
+
# Array, and Hash. Hash keys must be String or Symbol. Strings must be
|
|
16
|
+
# valid UTF-8. Time values are duplicated and frozen. Arrays and Hashes
|
|
17
|
+
# preserve order but never preserve a mutable nested reference. NaN,
|
|
18
|
+
# infinity, an unsupported object, a callable, or a cyclic Array/Hash
|
|
19
|
+
# raises ValidationError naming the field path.
|
|
20
|
+
module ImmutableValue
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def copy_and_freeze(value:, field:)
|
|
24
|
+
deep_copy(value, field.to_s, [])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def deep_copy(value, path, ancestors)
|
|
28
|
+
case value
|
|
29
|
+
when nil, true, false, ::Symbol, ::Integer
|
|
30
|
+
value
|
|
31
|
+
when ::Float
|
|
32
|
+
raise Errors::ValidationError, "#{path} is not a finite Float" unless value.finite?
|
|
33
|
+
|
|
34
|
+
value
|
|
35
|
+
when ::String
|
|
36
|
+
raise Errors::ValidationError, "#{path} is not valid UTF-8" unless valid_utf8?(value)
|
|
37
|
+
|
|
38
|
+
value.dup.freeze
|
|
39
|
+
when ::Time
|
|
40
|
+
value.dup.freeze
|
|
41
|
+
when ::Array
|
|
42
|
+
copy_array(value, path, ancestors)
|
|
43
|
+
when ::Hash
|
|
44
|
+
copy_hash(value, path, ancestors)
|
|
45
|
+
else
|
|
46
|
+
raise Errors::ValidationError, "#{path} is an unsupported value type (#{value.class})"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
# rubocop:enable Metrics/MethodLength
|
|
50
|
+
|
|
51
|
+
def copy_array(value, path, ancestors)
|
|
52
|
+
raise Errors::ValidationError, "#{path} contains a cyclic Array reference" if cyclic?(value, ancestors)
|
|
53
|
+
|
|
54
|
+
next_ancestors = ancestors + [value]
|
|
55
|
+
value.each_with_index.map do |element, index|
|
|
56
|
+
deep_copy(element, "#{path}[#{index}]", next_ancestors)
|
|
57
|
+
end.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def copy_hash(value, path, ancestors)
|
|
61
|
+
raise Errors::ValidationError, "#{path} contains a cyclic Hash reference" if cyclic?(value, ancestors)
|
|
62
|
+
|
|
63
|
+
next_ancestors = ancestors + [value]
|
|
64
|
+
copied = {}
|
|
65
|
+
value.each do |key, element|
|
|
66
|
+
frozen_key = copy_key(key, path)
|
|
67
|
+
copied[frozen_key] = deep_copy(element, "#{path}.#{key}", next_ancestors)
|
|
68
|
+
end
|
|
69
|
+
copied.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def copy_key(key, path)
|
|
73
|
+
case key
|
|
74
|
+
when ::Symbol
|
|
75
|
+
key
|
|
76
|
+
when ::String
|
|
77
|
+
raise Errors::ValidationError, "#{path} has a key that is not valid UTF-8" unless valid_utf8?(key)
|
|
78
|
+
|
|
79
|
+
key.dup.freeze
|
|
80
|
+
else
|
|
81
|
+
raise Errors::ValidationError, "#{path} has a non-String/Symbol key (#{key.class})"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def cyclic?(value, ancestors)
|
|
86
|
+
ancestors.any? { |ancestor| ancestor.equal?(value) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def valid_utf8?(string)
|
|
90
|
+
[::Encoding::UTF_8, ::Encoding::US_ASCII].include?(string.encoding) && string.valid_encoding?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/logging'
|
|
4
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
5
|
+
require 'legion/extensions/llm/inventory/identity'
|
|
6
|
+
require 'legion/extensions/llm/inventory/probe_token'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Llm
|
|
11
|
+
module Inventory
|
|
12
|
+
# Enqueue-only probe handle with per-instance/revision coalescing and
|
|
13
|
+
# single-flight state. It owns no timer, thread, thread pool, provider
|
|
14
|
+
# API call, or registry transition. It is the `probe_request_handle`
|
|
15
|
+
# passed to Registry.claim_instance. See phase-1-lex-llm-additive.md
|
|
16
|
+
# section 13.2.
|
|
17
|
+
class ProbeCoordinator
|
|
18
|
+
include Legion::Logging::Helper
|
|
19
|
+
|
|
20
|
+
def initialize(instance_key:, enqueue:)
|
|
21
|
+
raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
|
|
22
|
+
raise Errors::ValidationError, 'enqueue must be provided' if enqueue.nil?
|
|
23
|
+
|
|
24
|
+
@instance_key = instance_key
|
|
25
|
+
@enqueue = enqueue
|
|
26
|
+
@pending = nil
|
|
27
|
+
@in_flight_form = nil
|
|
28
|
+
@in_flight_request = nil
|
|
29
|
+
@mutex = Mutex.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Registry calls this after a root swap on dispatch_instance_unavailable.
|
|
33
|
+
def enqueue_probe_request(instance_key:, publisher_token_id:, unavailable_revision:, reason:)
|
|
34
|
+
request = ProbeRequest.new(
|
|
35
|
+
instance_key: instance_key, publisher_token_id: publisher_token_id,
|
|
36
|
+
unavailable_revision: unavailable_revision, reason: reason
|
|
37
|
+
)
|
|
38
|
+
raise Errors::ValidationError, 'instance_key mismatch' unless request.instance_key == @instance_key
|
|
39
|
+
|
|
40
|
+
to_enqueue = nil
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
had_pending = !@pending.nil?
|
|
43
|
+
retain_greater(request)
|
|
44
|
+
to_enqueue = @pending if @in_flight_form.nil? && !had_pending
|
|
45
|
+
end
|
|
46
|
+
return true if to_enqueue.nil?
|
|
47
|
+
|
|
48
|
+
run_enqueue(to_enqueue)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def begin_probe(request: nil)
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
return false unless @in_flight_form.nil?
|
|
54
|
+
|
|
55
|
+
if request.nil?
|
|
56
|
+
@in_flight_form = :cadence
|
|
57
|
+
@in_flight_request = nil
|
|
58
|
+
else
|
|
59
|
+
raise Errors::ValidationError, 'request must be a ProbeRequest' unless request.is_a?(ProbeRequest)
|
|
60
|
+
return false unless @pending && request.equal?(@pending)
|
|
61
|
+
|
|
62
|
+
@in_flight_form = :request
|
|
63
|
+
@in_flight_request = request
|
|
64
|
+
@pending = nil
|
|
65
|
+
end
|
|
66
|
+
true
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def finish_probe(request: nil)
|
|
71
|
+
still_pending = nil
|
|
72
|
+
@mutex.synchronize do
|
|
73
|
+
validate_finish_form!(request)
|
|
74
|
+
@in_flight_form = nil
|
|
75
|
+
@in_flight_request = nil
|
|
76
|
+
still_pending = @pending
|
|
77
|
+
end
|
|
78
|
+
run_enqueue(still_pending) unless still_pending.nil?
|
|
79
|
+
still_pending
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def pending?(unavailable_revision:)
|
|
83
|
+
@mutex.synchronize { !@pending.nil? && @pending.unavailable_revision == unavailable_revision }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def in_flight?
|
|
87
|
+
@mutex.synchronize { !@in_flight_form.nil? }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def retain_greater(request)
|
|
93
|
+
@pending = request if @pending.nil? || request.unavailable_revision > @pending.unavailable_revision
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_finish_form!(request)
|
|
97
|
+
if request.nil?
|
|
98
|
+
raise Errors::InvalidTransitionError, 'no cadence probe in flight' unless @in_flight_form == :cadence
|
|
99
|
+
else
|
|
100
|
+
raise Errors::InvalidTransitionError, 'finish request does not match the in-flight probe' unless @in_flight_form == :request && @in_flight_request.equal?(request)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def run_enqueue(request)
|
|
105
|
+
@enqueue.call(request: request) ? true : false
|
|
106
|
+
rescue StandardError => e
|
|
107
|
+
handle_exception(
|
|
108
|
+
e, handled: true, level: :warn, operation: 'llm.inventory.probe_coordinator.enqueue',
|
|
109
|
+
provider_family: @instance_key.provider_family, instance_id: @instance_key.instance_id
|
|
110
|
+
)
|
|
111
|
+
false
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
6
|
+
require 'legion/extensions/llm/inventory/identity'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Llm
|
|
11
|
+
module Inventory
|
|
12
|
+
# An opaque fenced publisher claim token. The raw secret is never
|
|
13
|
+
# serialized, logged, included in a record/snapshot/exception, returned
|
|
14
|
+
# separately, or compared for order. `publisher_id` and
|
|
15
|
+
# `publisher_token_id` are safe correlation/fencing values derived from
|
|
16
|
+
# the secret. See phase-1-lex-llm-additive.md section 11.1.
|
|
17
|
+
class PublisherToken
|
|
18
|
+
TOKEN_ID_DOMAIN = "publisher-token-id-v1\x00"
|
|
19
|
+
|
|
20
|
+
attr_reader :instance_key, :publisher_id, :publisher_token_id
|
|
21
|
+
|
|
22
|
+
def self.derive_token_id(secret)
|
|
23
|
+
"ptok:v1:#{::Digest::SHA256.hexdigest(TOKEN_ID_DOMAIN.b + secret.b)}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Factory used by the registry: builds a fresh secret and derives the
|
|
27
|
+
# public correlation IDs from it.
|
|
28
|
+
def self.issue(instance_key:)
|
|
29
|
+
secret = ::SecureRandom.hex(32)
|
|
30
|
+
new(
|
|
31
|
+
instance_key: instance_key, secret: secret,
|
|
32
|
+
publisher_id: "pub:v1:#{::SecureRandom.uuid}", publisher_token_id: derive_token_id(secret)
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(instance_key:, secret:, publisher_id:, publisher_token_id:)
|
|
37
|
+
raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
|
|
38
|
+
raise Errors::ValidationError, 'secret must be a nonempty String' unless nonempty_string?(secret)
|
|
39
|
+
raise Errors::ValidationError, 'publisher_id must be a pub:v1: String' unless publisher_id.to_s.start_with?('pub:v1:')
|
|
40
|
+
raise Errors::ValidationError, 'publisher_token_id must be a ptok:v1: String' unless publisher_token_id.to_s.start_with?('ptok:v1:')
|
|
41
|
+
|
|
42
|
+
@instance_key = instance_key
|
|
43
|
+
@secret = secret.b.dup.freeze
|
|
44
|
+
@publisher_id = publisher_id.dup.freeze
|
|
45
|
+
@publisher_token_id = publisher_token_id.dup.freeze
|
|
46
|
+
freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Fenced validation: same InstanceKey, same public token ID, and a
|
|
50
|
+
# constant-time equal-length secret comparison using a local XOR
|
|
51
|
+
# accumulator (no ActiveSupport).
|
|
52
|
+
def authenticates?(other)
|
|
53
|
+
other.is_a?(PublisherToken) &&
|
|
54
|
+
instance_key == other.instance_key &&
|
|
55
|
+
publisher_token_id == other.publisher_token_id &&
|
|
56
|
+
secrets_match?(other)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inspect
|
|
60
|
+
"#<#{self.class.name} instance_key=#{@instance_key.inspect} " \
|
|
61
|
+
"publisher_id=#{@publisher_id} publisher_token_id=#{@publisher_token_id} secret=[REDACTED]>"
|
|
62
|
+
end
|
|
63
|
+
alias to_s inspect
|
|
64
|
+
|
|
65
|
+
protected
|
|
66
|
+
|
|
67
|
+
def secret_bytes
|
|
68
|
+
@secret
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def secrets_match?(other)
|
|
74
|
+
mine = @secret
|
|
75
|
+
theirs = other.secret_bytes
|
|
76
|
+
return false unless mine.bytesize == theirs.bytesize
|
|
77
|
+
|
|
78
|
+
accumulator = 0
|
|
79
|
+
mine.bytes.each_with_index { |byte, index| accumulator |= byte ^ theirs.getbyte(index) }
|
|
80
|
+
accumulator.zero?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def nonempty_string?(value)
|
|
84
|
+
value.is_a?(::String) && !value.empty?
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A single-use safe-readiness probe token. Carries no secret. See 11.2.
|
|
89
|
+
ProbeToken = ::Data.define(:token_id, :instance_key, :publisher_token_id, :started_availability_revision, :started_at) do
|
|
90
|
+
def self.issue(instance_key:, publisher_token_id:, started_availability_revision:, started_at:)
|
|
91
|
+
new(
|
|
92
|
+
token_id: "probe:v1:#{::SecureRandom.uuid}", instance_key: instance_key,
|
|
93
|
+
publisher_token_id: publisher_token_id, started_availability_revision: started_availability_revision,
|
|
94
|
+
started_at: started_at
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def initialize(token_id:, instance_key:, publisher_token_id:, started_availability_revision:, started_at:)
|
|
99
|
+
raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
|
|
100
|
+
raise Errors::ValidationError, 'token_id must be a probe:v1: String' unless token_id.to_s.start_with?('probe:v1:')
|
|
101
|
+
raise Errors::ValidationError, 'publisher_token_id must be a ptok:v1: String' unless publisher_token_id.is_a?(::String) && publisher_token_id.start_with?('ptok:v1:')
|
|
102
|
+
unless started_availability_revision.is_a?(::Integer) && !started_availability_revision.negative?
|
|
103
|
+
raise Errors::ValidationError, 'started_availability_revision must be a nonnegative Integer'
|
|
104
|
+
end
|
|
105
|
+
raise Errors::ValidationError, 'started_at must be a Time' unless started_at.is_a?(::Time)
|
|
106
|
+
|
|
107
|
+
super(
|
|
108
|
+
token_id: token_id.dup.freeze, instance_key: instance_key,
|
|
109
|
+
publisher_token_id: publisher_token_id.dup.freeze,
|
|
110
|
+
started_availability_revision: started_availability_revision, started_at: started_at.dup.freeze
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# A process-local enqueue value asking the owning provider actor to run
|
|
116
|
+
# its safe readiness operation. Grants no registry mutation authority and
|
|
117
|
+
# carries no authorization secret. See section 11.2.
|
|
118
|
+
ProbeRequest = ::Data.define(:instance_key, :publisher_token_id, :unavailable_revision, :reason) do
|
|
119
|
+
def initialize(instance_key:, publisher_token_id:, unavailable_revision:, reason:)
|
|
120
|
+
raise Errors::ValidationError, 'instance_key must be an InstanceKey' unless instance_key.is_a?(Identity::InstanceKey)
|
|
121
|
+
raise Errors::ValidationError, 'publisher_token_id must be a nonempty ptok:v1: String' unless publisher_token_id.is_a?(::String) && publisher_token_id.start_with?('ptok:v1:')
|
|
122
|
+
raise Errors::ValidationError, 'unavailable_revision must be a positive Integer' unless unavailable_revision.is_a?(::Integer) && unavailable_revision.positive?
|
|
123
|
+
|
|
124
|
+
super(
|
|
125
|
+
instance_key: instance_key, publisher_token_id: publisher_token_id.dup.freeze,
|
|
126
|
+
unavailable_revision: unavailable_revision, reason: sanitized_reason(reason)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sanitized_reason(reason)
|
|
131
|
+
unless reason.is_a?(::String) &&
|
|
132
|
+
[::Encoding::UTF_8, ::Encoding::US_ASCII].include?(reason.encoding) && reason.valid_encoding?
|
|
133
|
+
raise Errors::ValidationError, 'reason must be a valid UTF-8 String'
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
trimmed = reason.strip
|
|
137
|
+
raise Errors::ValidationError, 'reason must not be empty' if trimmed.empty?
|
|
138
|
+
raise Errors::ValidationError, 'reason exceeds 1024 UTF-8 bytes' if trimmed.bytesize > 1024
|
|
139
|
+
|
|
140
|
+
trimmed.b.dup.force_encoding(::Encoding::UTF_8).freeze
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|