lex-llm 0.6.16 → 0.7.0
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 +14 -0
- data/lib/legion/extensions/llm/auto_registration.rb +3 -0
- 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 +119 -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 +110 -0
- data/lib/legion/extensions/llm/inventory/records.rb +592 -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 +43 -0
- 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/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 +15 -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 +125 -0
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +227 -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 +104 -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 +59 -0
- data/spec/legion/extensions/llm/routing/records_spec.rb +173 -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 +36 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6ab83cce686d4d63038e65c45039320fd61abf117acbde7ea05e4bca8ce1878
|
|
4
|
+
data.tar.gz: 768282bf56c16956f91a2be653e460e7d961775b8d782ad3177d9a637fb3a032
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9026c5963387e0c286bcdc80d7dceb61610fc5be4c67acf026b5e3ff59f95475edfdca79fb61f5910e5b143a02b946f36601679acc827fed55f1c998fe4148c
|
|
7
|
+
data.tar.gz: 2b896edf5d53f9d90c8ac6bdc410b84129d23f50640bd5a981e145d900f097294b3eeb7ffc60d696575a16974a47987eb5d30f8441b55f28041495d80bcc891a
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 - 2026-08-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **SSOT v3 provider runtime contract (additive).** A fully typed, process-local inventory and routing contract that lets a migrated provider construct a mandatory `Inventory::InstanceKey`, claim an exact instance for an opaque fenced `PublisherToken`, build immutable `OfferingDraft` values off-registry, run an immediate safe (non-inference) readiness probe, and atomically activate a callable, complete offering snapshot, derived operation lanes, and `available` state only when startup readiness succeeds. Includes:
|
|
7
|
+
- `Inventory::Identity` with the length-framed SHA-256 `off:v1:`/`lane:v1:` encoders (tier is never an identity input) and binding identity fixtures.
|
|
8
|
+
- `Inventory` evidence, `OfferingDraft`/`OfferingRecord`/`LaneRecord`/`AvailabilityFact`/`ReadinessResult`/`InstanceRecord`/`PublicationStatus`/`MutationResult` immutable records, `CallableHandle`/`DispatchLease` lifecycle, `PublisherToken`/`ProbeToken`/`ProbeRequest`, `ProbeCoordinator`, and the synchronized `Registry` + generation-tagged `Snapshot`.
|
|
9
|
+
- `Inventory::Publisher` provider-facing wrapper plus the quarantined post-commit `ScopedRefresher::LegacyCoordinatorAdapter` old-coordinator projection.
|
|
10
|
+
- `Routing::AttemptTargetKey`/`Selection`/`Rejection`/`Exclusion`/`QuotaDomainKey`/`BodyModelHintDecision` and the provider-neutral `Routing::ProviderOutcome`.
|
|
11
|
+
- Fail-loud `Provider#speak`/`Provider#translate` base methods (no defaults; `REQUIRED_SIGNATURES` unchanged).
|
|
12
|
+
- Additive exact-offering fleet execution (`Fleet::Protocol` `exact_offering_v1` marker, signed `execution_contract`/`offering_id`, registry-backed exact dispatch) alongside the unchanged protocol-v2 path.
|
|
13
|
+
- Shared `'an SSOT v3 provider adapter'` conformance examples consumable by every provider PR.
|
|
14
|
+
|
|
15
|
+
The contract adds no dependency on `legion-llm`, LegionIO, a database, ORM, or timer, and preserves every existing provider signature and protocol-v2 fleet behavior. Only the quarantined `ScopedRefresher` retains a direct `Legion::LLM::Inventory` reverse reference (removed in Phase 4).
|
|
16
|
+
|
|
3
17
|
## 0.6.16 - 2026-08-04
|
|
4
18
|
|
|
5
19
|
### Fixed
|
|
@@ -10,6 +10,9 @@ module Legion
|
|
|
10
10
|
# Prerequisites on the extending module:
|
|
11
11
|
# - `PROVIDER_FAMILY` constant (Symbol, e.g. :ollama)
|
|
12
12
|
# - `provider_class` singleton method returning the Provider subclass
|
|
13
|
+
# DEPRECATED (Phase 4 removal): discover_instances/provider_aliases are kept
|
|
14
|
+
# for old consumers; no new registration side effect is added here. Migrated
|
|
15
|
+
# provider actors use Inventory::Publisher directly.
|
|
13
16
|
module AutoRegistration
|
|
14
17
|
# Override in each provider. Returns { instance_id => config_hash }.
|
|
15
18
|
def discover_instances
|
|
@@ -9,6 +9,18 @@ module Legion
|
|
|
9
9
|
REQUEST_TYPE = 'llm.fleet.request'
|
|
10
10
|
RESPONSE_TYPE = 'llm.fleet.response'
|
|
11
11
|
ERROR_TYPE = 'llm.fleet.error'
|
|
12
|
+
|
|
13
|
+
# Additive exact-execution marker (phase-1-lex-llm-additive.md section 16.1).
|
|
14
|
+
# An exact request is protocol v2 plus execution_contract == this marker
|
|
15
|
+
# and a signed offering_id. Marker absence means legacy v2; an unknown
|
|
16
|
+
# nonempty marker is rejected.
|
|
17
|
+
EXACT_EXECUTION_CONTRACT = 'exact_offering_v1'
|
|
18
|
+
EXACT_REQUIRED_FIELDS = %i[
|
|
19
|
+
execution_contract offering_id provider provider_instance model operation
|
|
20
|
+
].freeze
|
|
21
|
+
EXACT_SIGNED_SCALAR_CLAIMS = %i[
|
|
22
|
+
execution_contract offering_id
|
|
23
|
+
].freeze
|
|
12
24
|
end
|
|
13
25
|
end
|
|
14
26
|
end
|
|
@@ -5,6 +5,7 @@ require 'json'
|
|
|
5
5
|
require_relative 'protocol'
|
|
6
6
|
require_relative 'settings'
|
|
7
7
|
require_relative 'worker_execution'
|
|
8
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
8
9
|
|
|
9
10
|
module Legion
|
|
10
11
|
module Extensions
|
|
@@ -59,17 +60,19 @@ module Legion
|
|
|
59
60
|
def reply_to = self[:reply_to]
|
|
60
61
|
def message_context = self[:message_context] || {}
|
|
61
62
|
def trace_context = self[:trace_context] || {}
|
|
63
|
+
def execution_contract = self[:execution_contract]
|
|
64
|
+
def offering_id = self[:offering_id]
|
|
62
65
|
end
|
|
63
66
|
|
|
64
67
|
module_function
|
|
65
68
|
|
|
66
69
|
# Public runner entry point mirrors AMQP delivery callbacks, which carry both delivery and property metadata.
|
|
67
70
|
# rubocop:disable Metrics/ParameterLists
|
|
68
|
-
def call(payload:, provider_family:, provider_class:, provider_instances:,
|
|
71
|
+
def call(payload:, provider_family:, provider_class:, provider_instances:,
|
|
72
|
+
registry: ::Legion::Extensions::Llm::Inventory::Registry, delivery: nil, properties: nil)
|
|
69
73
|
envelope = parse_payload(payload)
|
|
70
74
|
check_envelope!(envelope, provider_family:)
|
|
71
|
-
|
|
72
|
-
response = WorkerExecution.call(envelope: envelope, provider: provider)
|
|
75
|
+
response = dispatch_request(envelope, provider_class, provider_instances, registry)
|
|
73
76
|
publish_response(envelope, response)
|
|
74
77
|
ack(delivery || properties)
|
|
75
78
|
response
|
|
@@ -109,6 +112,34 @@ module Legion
|
|
|
109
112
|
|
|
110
113
|
validate_protocol_version!(envelope)
|
|
111
114
|
validate_provider_family!(envelope, provider_family)
|
|
115
|
+
validate_execution_contract!(envelope)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Marker absence means legacy v2; an unknown nonempty marker is rejected;
|
|
119
|
+
# the exact marker additionally requires every EXACT_REQUIRED_FIELDS value.
|
|
120
|
+
def validate_execution_contract!(envelope)
|
|
121
|
+
marker = envelope.execution_contract
|
|
122
|
+
return if marker.nil?
|
|
123
|
+
raise ArgumentError, "unknown execution_contract: #{marker}" unless marker == Protocol::EXACT_EXECUTION_CONTRACT
|
|
124
|
+
|
|
125
|
+
Protocol::EXACT_REQUIRED_FIELDS.each do |field|
|
|
126
|
+
raise ArgumentError, "#{field} is required for #{Protocol::EXACT_EXECUTION_CONTRACT}" unless envelope.key?(field) && !envelope[field].nil?
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def exact?(envelope)
|
|
131
|
+
envelope.execution_contract == Protocol::EXACT_EXECUTION_CONTRACT
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Exact requests dispatch through the registry and never call
|
|
135
|
+
# build_provider; legacy v2 keeps the provider-object path.
|
|
136
|
+
def dispatch_request(envelope, provider_class, provider_instances, registry)
|
|
137
|
+
if exact?(envelope)
|
|
138
|
+
WorkerExecution.call(envelope: envelope, registry: registry)
|
|
139
|
+
else
|
|
140
|
+
provider = build_provider(envelope:, provider_class:, provider_instances:)
|
|
141
|
+
WorkerExecution.call(envelope: envelope, provider: provider)
|
|
142
|
+
end
|
|
112
143
|
end
|
|
113
144
|
|
|
114
145
|
def build_provider(envelope:, provider_class:, provider_instances:)
|
|
@@ -141,7 +172,9 @@ module Legion
|
|
|
141
172
|
tool_calls: response_field(response, :tool_calls) || [],
|
|
142
173
|
usage: response_usage(response),
|
|
143
174
|
finish_reason: response_field(response, :finish_reason),
|
|
144
|
-
metadata: response_metadata(response)
|
|
175
|
+
metadata: response_metadata(response),
|
|
176
|
+
execution_contract: exact?(envelope) ? envelope.execution_contract : nil,
|
|
177
|
+
offering_id: exact?(envelope) ? envelope.offering_id : nil
|
|
145
178
|
).publish
|
|
146
179
|
end
|
|
147
180
|
|
|
@@ -162,7 +195,9 @@ module Legion
|
|
|
162
195
|
message: error.message,
|
|
163
196
|
error_class: error.class.name,
|
|
164
197
|
retryable: retryable_error?(error),
|
|
165
|
-
metadata: {}
|
|
198
|
+
metadata: {},
|
|
199
|
+
execution_contract: exact?(envelope) ? envelope.execution_contract : nil,
|
|
200
|
+
offering_id: exact?(envelope) ? envelope.offering_id : nil
|
|
166
201
|
).publish
|
|
167
202
|
end
|
|
168
203
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'concurrent'
|
|
4
4
|
require 'time'
|
|
5
5
|
|
|
6
|
+
require_relative 'protocol'
|
|
6
7
|
require_relative 'settings'
|
|
7
8
|
require_relative 'token_error'
|
|
8
9
|
|
|
@@ -91,6 +92,20 @@ module Legion
|
|
|
91
92
|
actual_hash = claims[hash_key] || content_hash(claims[key])
|
|
92
93
|
raise TokenError, "fleet token #{key} hash mismatch" unless actual_hash == expected_hash
|
|
93
94
|
end
|
|
95
|
+
|
|
96
|
+
validate_exact_execution_claims!(claims, envelope)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# When the exact-offering marker is present on the envelope, both exact
|
|
100
|
+
# scalar claims must be signed into the verified JWT and match the
|
|
101
|
+
# envelope. An unsigned exact marker is never authoritative.
|
|
102
|
+
def validate_exact_execution_claims!(claims, envelope)
|
|
103
|
+
return unless envelope[:execution_contract] == Protocol::EXACT_EXECUTION_CONTRACT
|
|
104
|
+
|
|
105
|
+
Protocol::EXACT_SIGNED_SCALAR_CLAIMS.each do |key|
|
|
106
|
+
raise TokenError, "fleet token missing signed #{key}" if claims[key].to_s.empty?
|
|
107
|
+
raise TokenError, "fleet token #{key} claim mismatch" unless canonical_value(claims[key]) == canonical_value(envelope[key])
|
|
108
|
+
end
|
|
94
109
|
end
|
|
95
110
|
|
|
96
111
|
def content_hash(value)
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require 'concurrent'
|
|
4
4
|
|
|
5
|
+
require_relative 'protocol'
|
|
5
6
|
require_relative 'settings'
|
|
6
7
|
require_relative 'token_validator'
|
|
8
|
+
require 'legion/extensions/llm/taxonomies'
|
|
9
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
10
|
+
require 'legion/extensions/llm/inventory/identity'
|
|
11
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
7
12
|
|
|
8
13
|
module Legion
|
|
9
14
|
module Extensions
|
|
@@ -21,13 +26,14 @@ module Legion
|
|
|
21
26
|
|
|
22
27
|
module_function
|
|
23
28
|
|
|
24
|
-
def call(envelope:, provider:)
|
|
29
|
+
def call(envelope:, registry: nil, provider: nil)
|
|
30
|
+
validate_dispatch_target!(registry, provider)
|
|
25
31
|
claims = nil
|
|
26
32
|
idempotency_key = nil
|
|
27
33
|
claims = validate_identity!(envelope)
|
|
28
34
|
validate_policy!(envelope)
|
|
29
35
|
idempotency_key = validate_idempotency!(envelope)
|
|
30
|
-
response =
|
|
36
|
+
response = dispatch!(envelope: envelope, registry: registry, provider: provider)
|
|
31
37
|
mark_idempotency_success!(idempotency_key) if idempotency_key
|
|
32
38
|
TokenValidator.mark_replay!(claims[:jti]) if claims.is_a?(Hash)
|
|
33
39
|
response
|
|
@@ -87,6 +93,164 @@ module Legion
|
|
|
87
93
|
end
|
|
88
94
|
end
|
|
89
95
|
|
|
96
|
+
ERRORS = Legion::Extensions::Llm::Inventory::Errors
|
|
97
|
+
IDENTITY = Legion::Extensions::Llm::Inventory::Identity
|
|
98
|
+
|
|
99
|
+
def validate_dispatch_target!(registry, provider)
|
|
100
|
+
return unless registry.nil? == provider.nil?
|
|
101
|
+
|
|
102
|
+
raise PolicyError, 'WorkerExecution.call requires exactly one of registry or provider'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dispatch!(envelope:, registry:, provider:)
|
|
106
|
+
return dispatch_local_provider!(envelope: envelope, provider: provider) if provider
|
|
107
|
+
|
|
108
|
+
if envelope_value(envelope, :execution_contract) == Protocol::EXACT_EXECUTION_CONTRACT
|
|
109
|
+
exact_dispatch!(envelope: envelope, registry: registry)
|
|
110
|
+
else
|
|
111
|
+
legacy_registry_dispatch!(envelope: envelope, registry: registry)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Exact path: resolve by signed offering_id; no model resolution, provider
|
|
116
|
+
# scan, :default, or first value.
|
|
117
|
+
def exact_dispatch!(envelope:, registry:)
|
|
118
|
+
snapshot = registry.snapshot
|
|
119
|
+
instance_key = exact_instance_key(envelope)
|
|
120
|
+
record = available_record!(snapshot, instance_key)
|
|
121
|
+
offering = record.offerings_by_id[envelope_value(envelope, :offering_id)]
|
|
122
|
+
raise ERRORS::ExactOfferingMismatchError, 'offering_id not on the activated instance' if offering.nil?
|
|
123
|
+
|
|
124
|
+
operation = exact_operation(envelope)
|
|
125
|
+
model = require_matching_model!(offering, envelope)
|
|
126
|
+
require_supported!(offering, operation)
|
|
127
|
+
execute_via_lane(registry, snapshot,
|
|
128
|
+
{ record: record, offering: offering, operation: operation, model: model, envelope: envelope })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Registry-backed v2 compatibility path for a migrated provider: execute
|
|
132
|
+
# only when (provider_family, instance, operation, model) resolves to
|
|
133
|
+
# exactly one supported local offering.
|
|
134
|
+
def legacy_registry_dispatch!(envelope:, registry:)
|
|
135
|
+
snapshot = registry.snapshot
|
|
136
|
+
instance_key = exact_instance_key(envelope)
|
|
137
|
+
record = available_record!(snapshot, instance_key)
|
|
138
|
+
operation = Legion::Extensions::Llm::Taxonomies.normalize_operation(value: envelope_value(envelope, :operation), allow_aliases: true)
|
|
139
|
+
model = IDENTITY.normalize_text(value: envelope_value(envelope, :model), field: :model)
|
|
140
|
+
matches = record.offerings_by_id.values.select { |o| o.model == model && o.operation_status(operation: operation) == :supported }
|
|
141
|
+
raise ERRORS::ExactOfferingMismatchError, 'no matching local offering' if matches.empty?
|
|
142
|
+
raise ERRORS::AmbiguousLegacyOfferingError, 'multiple matching local offerings' if matches.size > 1
|
|
143
|
+
|
|
144
|
+
execute_via_lane(registry, snapshot,
|
|
145
|
+
{ record: record, offering: matches.first, operation: operation, model: model, envelope: envelope })
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def execute_via_lane(registry, snapshot, resolution)
|
|
149
|
+
lane = matching_lane!(snapshot, resolution[:record], resolution[:offering], resolution[:operation], resolution[:model])
|
|
150
|
+
lease = registry.acquire(callable_handle: lane.callable_handle)
|
|
151
|
+
begin
|
|
152
|
+
dispatch_operation(lease.callable, resolution[:operation], resolution[:model], exact_params(resolution[:envelope]))
|
|
153
|
+
ensure
|
|
154
|
+
lease.release
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def exact_instance_key(envelope)
|
|
159
|
+
IDENTITY::InstanceKey.new(
|
|
160
|
+
provider_family: envelope_value(envelope, :provider), instance_id: envelope_value(envelope, :provider_instance)
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def available_record!(snapshot, instance_key)
|
|
165
|
+
record = snapshot.instance(instance_key: instance_key)
|
|
166
|
+
raise ERRORS::ExactOfferingMismatchError, 'instance is absent, initializing, or unavailable' unless record && record.availability.state == :available
|
|
167
|
+
|
|
168
|
+
record
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def exact_operation(envelope)
|
|
172
|
+
Legion::Extensions::Llm::Taxonomies.normalize_operation(value: envelope_value(envelope, :operation), allow_aliases: false)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def require_matching_model!(offering, envelope)
|
|
176
|
+
model = IDENTITY.normalize_text(value: envelope_value(envelope, :model), field: :model)
|
|
177
|
+
raise ERRORS::ExactOfferingMismatchError, 'model does not match the offering' unless offering.model == model
|
|
178
|
+
|
|
179
|
+
model
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def require_supported!(offering, operation)
|
|
183
|
+
return if offering.operation_status(operation: operation) == :supported
|
|
184
|
+
|
|
185
|
+
raise ERRORS::ExactOfferingMismatchError, "operation #{operation} is not supported by the offering"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def matching_lane!(snapshot, record, offering, operation, model)
|
|
189
|
+
lane_id = IDENTITY.lane_id(instance_key: record.instance_key, operation: operation, model: model, offering_id: offering.offering_id)
|
|
190
|
+
lane = snapshot.lane(lane_id: lane_id)
|
|
191
|
+
valid = lane && lane.offering_id == offering.offering_id && lane.instance_key == record.instance_key &&
|
|
192
|
+
lane.model == model && lane.operation == operation && lane.callable_handle.equal?(record.callable_handle)
|
|
193
|
+
raise ERRORS::ExactOfferingMismatchError, 'no matching lane for the offering' unless valid
|
|
194
|
+
|
|
195
|
+
lane
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def exact_params(envelope)
|
|
199
|
+
raw = envelope_value(envelope, :params) || {}
|
|
200
|
+
params = {}
|
|
201
|
+
raw.each do |key, value|
|
|
202
|
+
sym = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
203
|
+
raise ERRORS::ExactOfferingMismatchError, "duplicate param spelling for #{sym}" if params.key?(sym)
|
|
204
|
+
|
|
205
|
+
params[sym] = value
|
|
206
|
+
end
|
|
207
|
+
raise ERRORS::ExactOfferingMismatchError, 'params must not contain model' if params.key?(:model)
|
|
208
|
+
|
|
209
|
+
params
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def dispatch_operation(callable, operation, model, params)
|
|
213
|
+
case operation
|
|
214
|
+
when :chat then callable.chat(messages: require_param!(params, :messages, operation), model: model, **except(params, :messages))
|
|
215
|
+
when :stream_chat then callable.stream_chat(messages: require_param!(params, :messages, operation), model: model, **except(params, :messages))
|
|
216
|
+
when :count_tokens then callable.count_tokens(messages: require_param!(params, :messages, operation), model: model, **except(params, :messages))
|
|
217
|
+
when :embed then callable.embed(text: require_param!(params, :text, operation), model: model, **except(params, :text))
|
|
218
|
+
when :image then dispatch_image(callable, model, params)
|
|
219
|
+
when :transcribe then dispatch_audio(callable, :transcribe, model, params)
|
|
220
|
+
when :translate then dispatch_audio(callable, :translate, model, params)
|
|
221
|
+
when :speak then dispatch_speak(callable, model, params)
|
|
222
|
+
when :moderate then callable.moderate(require_param!(params, :input, operation), model: model, **except(params, :input))
|
|
223
|
+
else raise ERRORS::ExactOfferingMismatchError, "unsupported exact operation: #{operation}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def dispatch_image(callable, model, params)
|
|
228
|
+
require_param!(params, :prompt, :image)
|
|
229
|
+
require_param!(params, :size, :image)
|
|
230
|
+
callable.image(prompt: params[:prompt], model: model, **except(params, :prompt))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def dispatch_audio(callable, operation, model, params)
|
|
234
|
+
require_param!(params, :audio_file, operation)
|
|
235
|
+
raise ERRORS::ExactOfferingMismatchError, "#{operation} requires the language key" unless params.key?(:language)
|
|
236
|
+
|
|
237
|
+
callable.public_send(
|
|
238
|
+
operation, params[:audio_file], model: model, language: params[:language],
|
|
239
|
+
**except(params, :audio_file, :model, :language)
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def dispatch_speak(callable, model, params)
|
|
244
|
+
require_param!(params, :text, :speak)
|
|
245
|
+
callable.speak(params[:text], model: model, voice: params[:voice], **except(params, :text, :model, :voice))
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def require_param!(params, key, operation)
|
|
249
|
+
raise ERRORS::ExactOfferingMismatchError, "#{operation} requires the #{key} param" unless params.key?(key)
|
|
250
|
+
|
|
251
|
+
params[key]
|
|
252
|
+
end
|
|
253
|
+
|
|
90
254
|
def unpack_legacy_options(params)
|
|
91
255
|
options = params.delete(:options)
|
|
92
256
|
return params unless options.is_a?(Hash)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'legion/logging'
|
|
5
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Llm
|
|
10
|
+
module Inventory
|
|
11
|
+
# A dispatch lease over a CallableHandle. Holds the exact provider
|
|
12
|
+
# callable for one in-flight request and releases the reference exactly
|
|
13
|
+
# once. See phase-1-lex-llm-additive.md section 11.4.
|
|
14
|
+
class DispatchLease
|
|
15
|
+
attr_reader :lease_id, :callable
|
|
16
|
+
|
|
17
|
+
def initialize(lease_id:, callable_handle:, callable:)
|
|
18
|
+
@lease_id = lease_id.dup.freeze
|
|
19
|
+
@callable_handle = callable_handle
|
|
20
|
+
@callable = callable
|
|
21
|
+
@released = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def released?
|
|
25
|
+
@released
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def release
|
|
29
|
+
@callable_handle.__send__(:release_lease, self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Set only by the owning CallableHandle under its private Mutex.
|
|
35
|
+
def mark_released!
|
|
36
|
+
@released = true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Owns the acquire/retire lifecycle for one provider callable. The
|
|
41
|
+
# handle's object identity is stable while its state moves
|
|
42
|
+
# ACTIVE -> RETIRING -> DISPOSED under a private Mutex. Retiring an old
|
|
43
|
+
# handle cannot disconnect a distinct new callable because a new claim
|
|
44
|
+
# must supply a distinct callable object. See section 11.4.
|
|
45
|
+
class CallableHandle
|
|
46
|
+
include Legion::Logging::Helper
|
|
47
|
+
|
|
48
|
+
attr_reader :handle_id
|
|
49
|
+
|
|
50
|
+
def initialize(handle_id:, callable:)
|
|
51
|
+
raise Errors::ValidationError, 'handle_id must be a non-empty String' unless nonempty_string?(handle_id)
|
|
52
|
+
raise Errors::ValidationError, 'callable must not be nil' if callable.nil?
|
|
53
|
+
|
|
54
|
+
@handle_id = handle_id.dup.freeze
|
|
55
|
+
@callable = callable
|
|
56
|
+
@state = :active
|
|
57
|
+
@reference_count = 0
|
|
58
|
+
@disposed_with_error = false
|
|
59
|
+
@mutex = Mutex.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def state
|
|
63
|
+
@mutex.synchronize { @state }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def reference_count
|
|
67
|
+
@mutex.synchronize { @reference_count }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def acquire
|
|
71
|
+
@mutex.synchronize do
|
|
72
|
+
case @state
|
|
73
|
+
when :retiring
|
|
74
|
+
raise Errors::StaleCallableError, "callable handle #{@handle_id} is retiring"
|
|
75
|
+
when :disposed
|
|
76
|
+
raise Errors::CallableDisposedError, "callable handle #{@handle_id} is disposed"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@reference_count += 1
|
|
80
|
+
DispatchLease.new(
|
|
81
|
+
lease_id: "lease:v1:#{SecureRandom.uuid}", callable_handle: self, callable: @callable
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def retire
|
|
87
|
+
@mutex.synchronize do
|
|
88
|
+
case @state
|
|
89
|
+
when :disposed
|
|
90
|
+
return @disposed_with_error ? :disposed_with_error : :disposed
|
|
91
|
+
when :retiring
|
|
92
|
+
return :retiring
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@state = :retiring
|
|
96
|
+
return :retiring if @reference_count.positive?
|
|
97
|
+
|
|
98
|
+
dispose_locked
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def release_lease(lease)
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
raise Errors::InvalidTransitionError, 'dispatch lease already released' if lease.released?
|
|
107
|
+
|
|
108
|
+
lease.__send__(:mark_released!)
|
|
109
|
+
@reference_count -= 1
|
|
110
|
+
dispose_locked if @reference_count.zero? && @state == :retiring
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Caller must hold @mutex. Calls disconnect exactly once and transitions
|
|
116
|
+
# to DISPOSED even when disconnect raises.
|
|
117
|
+
def dispose_locked
|
|
118
|
+
callable = @callable
|
|
119
|
+
callable_class = callable.class.name
|
|
120
|
+
@callable = nil
|
|
121
|
+
begin
|
|
122
|
+
callable.disconnect
|
|
123
|
+
@state = :disposed
|
|
124
|
+
:disposed
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
@disposed_with_error = true
|
|
127
|
+
@state = :disposed
|
|
128
|
+
handle_exception(
|
|
129
|
+
e, handled: true, level: :warn,
|
|
130
|
+
operation: 'llm.inventory.callable_handle.dispose',
|
|
131
|
+
handle_id: @handle_id, callable_class: callable_class
|
|
132
|
+
)
|
|
133
|
+
:disposed_with_error
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def nonempty_string?(value)
|
|
138
|
+
value.is_a?(::String) && !value.strip.empty?
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Inventory
|
|
7
|
+
# Typed contract/validation/fencing/transition/acquisition errors for the
|
|
8
|
+
# SSOT v3 runtime contract. See phase-1-lex-llm-additive.md section 7.
|
|
9
|
+
#
|
|
10
|
+
# ValidationError descends from ArgumentError because it signals a caller
|
|
11
|
+
# supplying an invalid field; every other error descends from Error so a
|
|
12
|
+
# consumer can rescue the whole inventory family with one class.
|
|
13
|
+
#
|
|
14
|
+
# Validation errors identify the invalid field but never include
|
|
15
|
+
# credentials, raw endpoints containing secrets, callable inspection, or
|
|
16
|
+
# publisher tokens.
|
|
17
|
+
module Errors
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
class ValidationError < ArgumentError; end
|
|
20
|
+
class UnknownInstanceError < Error; end
|
|
21
|
+
class FencedPublisherError < Error; end
|
|
22
|
+
class StaleSequenceError < Error; end
|
|
23
|
+
class InvalidProbeError < Error; end
|
|
24
|
+
class InvalidTransitionError < Error; end
|
|
25
|
+
class UnknownCallableError < Error; end
|
|
26
|
+
class StaleCallableError < Error; end
|
|
27
|
+
class CallableDisposedError < Error; end
|
|
28
|
+
class ExactOfferingMismatchError < Error; end
|
|
29
|
+
class AmbiguousLegacyOfferingError < Error; end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|