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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/lib/legion/extensions/llm/auto_registration.rb +3 -0
  4. data/lib/legion/extensions/llm/fleet/protocol.rb +12 -0
  5. data/lib/legion/extensions/llm/fleet/provider_responder.rb +40 -5
  6. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -0
  7. data/lib/legion/extensions/llm/fleet/worker_execution.rb +166 -2
  8. data/lib/legion/extensions/llm/inventory/callable_handle.rb +144 -0
  9. data/lib/legion/extensions/llm/inventory/errors.rb +34 -0
  10. data/lib/legion/extensions/llm/inventory/evidence.rb +134 -0
  11. data/lib/legion/extensions/llm/inventory/identity.rb +119 -0
  12. data/lib/legion/extensions/llm/inventory/immutable_value.rb +96 -0
  13. data/lib/legion/extensions/llm/inventory/probe_coordinator.rb +117 -0
  14. data/lib/legion/extensions/llm/inventory/probe_token.rb +146 -0
  15. data/lib/legion/extensions/llm/inventory/publisher.rb +110 -0
  16. data/lib/legion/extensions/llm/inventory/records.rb +592 -0
  17. data/lib/legion/extensions/llm/inventory/registry.rb +636 -0
  18. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +206 -0
  19. data/lib/legion/extensions/llm/inventory/snapshot.rb +91 -0
  20. data/lib/legion/extensions/llm/provider.rb +43 -0
  21. data/lib/legion/extensions/llm/routing/lane_key.rb +4 -0
  22. data/lib/legion/extensions/llm/routing/model_offering.rb +3 -0
  23. data/lib/legion/extensions/llm/routing/offering_registry.rb +2 -0
  24. data/lib/legion/extensions/llm/routing/provider_outcome.rb +54 -0
  25. data/lib/legion/extensions/llm/routing/records.rb +288 -0
  26. data/lib/legion/extensions/llm/taxonomies.rb +90 -0
  27. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -1
  28. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +3 -1
  29. data/lib/legion/extensions/llm/version.rb +1 -1
  30. data/lib/legion/extensions/llm.rb +15 -0
  31. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +84 -0
  32. data/spec/legion/extensions/llm/conformance/ssot_provider_conformance_spec.rb +13 -0
  33. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +196 -0
  34. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +179 -0
  35. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +26 -0
  36. data/spec/legion/extensions/llm/fleet_messages_spec.rb +14 -0
  37. data/spec/legion/extensions/llm/inventory/boot_spec.rb +80 -0
  38. data/spec/legion/extensions/llm/inventory/callable_handle_spec.rb +127 -0
  39. data/spec/legion/extensions/llm/inventory/evidence_spec.rb +125 -0
  40. data/spec/legion/extensions/llm/inventory/identity_spec.rb +227 -0
  41. data/spec/legion/extensions/llm/inventory/immutable_value_spec.rb +132 -0
  42. data/spec/legion/extensions/llm/inventory/probe_coordinator_spec.rb +107 -0
  43. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +107 -0
  44. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +104 -0
  45. data/spec/legion/extensions/llm/inventory/records_spec.rb +300 -0
  46. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +140 -0
  47. data/spec/legion/extensions/llm/inventory/registry_availability_spec.rb +152 -0
  48. data/spec/legion/extensions/llm/inventory/registry_callable_spec.rb +63 -0
  49. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +69 -0
  50. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +128 -0
  51. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +109 -0
  52. data/spec/legion/extensions/llm/provider_contract_spec.rb +20 -0
  53. data/spec/legion/extensions/llm/provider_spec.rb +83 -0
  54. data/spec/legion/extensions/llm/routing/provider_outcome_spec.rb +59 -0
  55. data/spec/legion/extensions/llm/routing/records_spec.rb +173 -0
  56. data/spec/legion/extensions/llm/taxonomies_spec.rb +51 -0
  57. data/spec/support/fake_llm_provider.rb +11 -0
  58. data/spec/support/fake_ssot_harness.rb +137 -0
  59. data/spec/support/ssot_registry_helpers.rb +75 -0
  60. metadata +36 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3ba576c21739b35e60594706468f7a05769871b25e5acd466ae01cbc451eec70
4
- data.tar.gz: e0ac7077fdc03270059abe385f202b681928e62d667f0cefad10a0a7d1b79760
3
+ metadata.gz: b6ab83cce686d4d63038e65c45039320fd61abf117acbde7ea05e4bca8ce1878
4
+ data.tar.gz: 768282bf56c16956f91a2be653e460e7d961775b8d782ad3177d9a637fb3a032
5
5
  SHA512:
6
- metadata.gz: 997f024896ce8d0eb54d8574615312c0280efea81ecae75859b3bd702b8dd65aae0fbd5f615801e3e43b9d50cf6079ce4638d9a39488edb7e3a86887ee94a977
7
- data.tar.gz: 3a33857e99d99a8f3358e781d46903560cb6932026d6889be2a922e23b865f3c2dacef4e38bd6a34e52af35474eab7f7e935d4c68360a8c56e2758836648d589
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:, delivery: nil, properties: nil)
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
- provider = build_provider(envelope:, provider_class:, provider_instances:)
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 = dispatch_local_provider!(envelope: envelope, provider: provider)
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