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
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
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/snapshot'
|
|
7
|
+
require 'legion/extensions/llm/taxonomies'
|
|
8
|
+
|
|
3
9
|
module Legion
|
|
4
10
|
module Extensions
|
|
5
11
|
module Llm
|
|
6
12
|
module Inventory
|
|
13
|
+
# DEPRECATED (Phase 4 removal): the only Phase 1 file permitted to hold a
|
|
14
|
+
# direct `Legion::LLM::Inventory` reverse reference. Old released provider
|
|
15
|
+
# actors keep using ScopedRefresher#tick until their Phase 2 migration; a
|
|
16
|
+
# migrated actor instead injects ScopedRefresher::LegacyCoordinatorAdapter
|
|
17
|
+
# into Inventory::Publisher and never calls #tick. Neither old lane hashes
|
|
18
|
+
# nor old health feed the new registry. Phase 4 deletes this file once
|
|
19
|
+
# every provider and coordinator floor has migrated.
|
|
20
|
+
#
|
|
7
21
|
# Mix into a Legion::Extensions::Llm::*::Actors::DiscoveryRefresh class.
|
|
8
22
|
# The host class must include Legion::Extensions::Helpers::Lex (auto-injects
|
|
9
23
|
# log / settings / handle_exception / cache_*) and define:
|
|
@@ -98,6 +112,198 @@ module Legion
|
|
|
98
112
|
|
|
99
113
|
error.message&.match?(/unauthorized|invalid[_ ]api[_ ]key|invalid[_ ]credentials|forbidden/i)
|
|
100
114
|
end
|
|
115
|
+
|
|
116
|
+
# One-way, post-commit projection of an already-committed new Snapshot
|
|
117
|
+
# into the old Legion::LLM::Inventory coordinator, for the mixed-version
|
|
118
|
+
# window only. It never rediscovers, selects, authorizes new state, or
|
|
119
|
+
# feeds an old fact back into the new Registry. See section 13.3.
|
|
120
|
+
class LegacyCoordinatorAdapter
|
|
121
|
+
include Legion::Logging::Helper
|
|
122
|
+
|
|
123
|
+
HEALTHY_COMPAT = { circuit_state: :closed, denied: false, available: true, adjustment: 0 }.freeze
|
|
124
|
+
LEGACY_TYPES = { embed: :embedding, image: :image, transcribe: :audio, translate: :audio, speak: :audio }.freeze
|
|
125
|
+
LEGACY_CAPABILITIES = {
|
|
126
|
+
chat: :completion, stream_chat: :streaming, embed: :embedding, image: :image,
|
|
127
|
+
transcribe: :audio_transcription, translate: :audio_transcription, speak: :audio_speech, moderate: :moderation
|
|
128
|
+
}.freeze
|
|
129
|
+
|
|
130
|
+
def initialize(provider_family:, legacy_instance_ids_by_instance: {})
|
|
131
|
+
@provider_family = canonical_family(provider_family)
|
|
132
|
+
@legacy_instance_ids = deep_freeze_legacy_ids(legacy_instance_ids_by_instance)
|
|
133
|
+
@published_ids_by_instance = {}.freeze
|
|
134
|
+
@mutex = Mutex.new
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def sync_snapshot(snapshot:, instance_key:, mutation_reason:)
|
|
138
|
+
validate_inputs!(snapshot, instance_key, mutation_reason)
|
|
139
|
+
return :not_loaded unless legacy_inventory_available?
|
|
140
|
+
|
|
141
|
+
project(snapshot, instance_key)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def project(snapshot, instance_key)
|
|
147
|
+
@mutex.synchronize { project_locked(snapshot, instance_key) }
|
|
148
|
+
:applied
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
handle_exception(
|
|
151
|
+
e, handled: true, level: :warn, operation: 'inventory.scoped_refresher.legacy_projection',
|
|
152
|
+
provider_family: @provider_family, instance_id: instance_key.instance_id,
|
|
153
|
+
source_generation: snapshot.generation
|
|
154
|
+
)
|
|
155
|
+
:failed
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def project_locked(snapshot, instance_key)
|
|
159
|
+
desired = desired_lanes(snapshot, instance_key)
|
|
160
|
+
written = write_desired(desired)
|
|
161
|
+
reconcile_deletions(instance_key, written)
|
|
162
|
+
@published_ids_by_instance = @published_ids_by_instance.merge(
|
|
163
|
+
instance_key.instance_id => written.to_a.freeze
|
|
164
|
+
).freeze
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def desired_lanes(snapshot, instance_key)
|
|
168
|
+
record = snapshot.instance(instance_key: instance_key)
|
|
169
|
+
return {} if record.nil? || record.availability.state != :available
|
|
170
|
+
|
|
171
|
+
build_desired(group_lanes(snapshot, instance_key), snapshot.generation)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def group_lanes(snapshot, instance_key)
|
|
175
|
+
groups = {}
|
|
176
|
+
snapshot.lanes_for(instance_key: instance_key).each do |lane|
|
|
177
|
+
type = LEGACY_TYPES.fetch(lane.operation, :inference)
|
|
178
|
+
key = [lane.tier, instance_key.provider_family, instance_key.instance_id, type, lane.model]
|
|
179
|
+
group = (groups[key] ||= new_group(instance_key, lane, type))
|
|
180
|
+
accumulate_group(group, lane)
|
|
181
|
+
end
|
|
182
|
+
groups
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def new_group(instance_key, lane, type)
|
|
186
|
+
{
|
|
187
|
+
tier: lane.tier, provider_family: instance_key.provider_family, instance_id: instance_key.instance_id,
|
|
188
|
+
type: type, model: lane.model, offering_ids: Set.new, capabilities: Set.new,
|
|
189
|
+
context_values: Set.new, max_output_values: Set.new
|
|
190
|
+
}
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def accumulate_group(group, lane)
|
|
194
|
+
group[:offering_ids] << lane.offering_id
|
|
195
|
+
mapped = LEGACY_CAPABILITIES[lane.operation]
|
|
196
|
+
group[:capabilities] << mapped if mapped
|
|
197
|
+
lane.capability_evidence.each { |capability, evidence| group[:capabilities] << capability if evidence.supported? }
|
|
198
|
+
group[:context_values] << lane.context_evidence.value if lane.context_evidence.known?
|
|
199
|
+
group[:max_output_values] << lane.max_output_evidence.value if lane.max_output_evidence.known?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def build_desired(groups, generation)
|
|
203
|
+
by_id = {}
|
|
204
|
+
groups.each_value do |group|
|
|
205
|
+
legacy_id = ScopedRefresher.compose_id(identity_fields(group))
|
|
206
|
+
(by_id[legacy_id] ||= []) << group
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
by_id.each_with_object({}) do |(legacy_id, colliding), desired|
|
|
210
|
+
if colliding.size > 1 || colliding.first[:offering_ids].size != 1
|
|
211
|
+
log.warn("[llm][inventory][legacy_adapter] action=fail_closed reason=ambiguous_group legacy_id=#{legacy_id}")
|
|
212
|
+
next
|
|
213
|
+
end
|
|
214
|
+
desired[legacy_id] = build_lane_hash(legacy_id, colliding.first, generation)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def identity_fields(group)
|
|
219
|
+
{
|
|
220
|
+
tier: group[:tier], provider_family: group[:provider_family],
|
|
221
|
+
instance_id: group[:instance_id], type: group[:type], model: group[:model]
|
|
222
|
+
}.freeze
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def build_lane_hash(legacy_id, group, generation)
|
|
226
|
+
{
|
|
227
|
+
id: legacy_id, tier: group[:tier], provider_family: group[:provider_family],
|
|
228
|
+
provider_instance: group[:instance_id], instance_id: group[:instance_id], type: group[:type],
|
|
229
|
+
model: group[:model], offering_id: group[:offering_ids].first,
|
|
230
|
+
capabilities: group[:capabilities].to_a.sort, limits: build_limits(group),
|
|
231
|
+
metadata: { ssot_v3_compatibility_projection: true, source_generation: generation }, enabled: true
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build_limits(group)
|
|
236
|
+
limits = {}
|
|
237
|
+
limits[:context_window] = group[:context_values].first if group[:context_values].size == 1
|
|
238
|
+
limits[:max_output_tokens] = group[:max_output_values].first if group[:max_output_values].size == 1
|
|
239
|
+
limits
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def write_desired(desired)
|
|
243
|
+
written = Set.new
|
|
244
|
+
desired.each do |legacy_id, lane_hash|
|
|
245
|
+
result = ::Legion::LLM::Inventory.write_lane(lane: lane_hash, health: HEALTHY_COMPAT)
|
|
246
|
+
written << legacy_id unless result.nil?
|
|
247
|
+
end
|
|
248
|
+
written
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def reconcile_deletions(instance_key, written)
|
|
252
|
+
tracked = @published_ids_by_instance.fetch(instance_key.instance_id, [])
|
|
253
|
+
candidates = (tracked.to_a + read_current_old_ids(instance_key).to_a).uniq
|
|
254
|
+
candidates.each do |old_id|
|
|
255
|
+
next if written.include?(old_id)
|
|
256
|
+
|
|
257
|
+
::Legion::LLM::Inventory.delete_lane(id: old_id)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def read_current_old_ids(instance_key)
|
|
262
|
+
instance_ids_to_read(instance_key).each_with_object(Set.new) do |instance_id, ids|
|
|
263
|
+
lanes = ::Legion::LLM::Inventory.lanes_for(provider: @provider_family, instance: instance_id)
|
|
264
|
+
Array(lanes).each { |lane| ids << (lane.is_a?(::Hash) ? lane[:id] : lane) }
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def instance_ids_to_read(instance_key)
|
|
269
|
+
([instance_key.instance_id] + @legacy_instance_ids.fetch(instance_key.instance_id, [])).uniq
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def validate_inputs!(snapshot, instance_key, mutation_reason)
|
|
273
|
+
raise Errors::ValidationError, 'snapshot must be an Inventory::Snapshot' unless snapshot.is_a?(Snapshot)
|
|
274
|
+
unless instance_key.is_a?(Identity::InstanceKey) && instance_key.provider_family == @provider_family
|
|
275
|
+
raise Errors::ValidationError, 'instance_key must belong to the adapter provider family'
|
|
276
|
+
end
|
|
277
|
+
return if Taxonomies::MUTATION_REASONS.include?(mutation_reason)
|
|
278
|
+
|
|
279
|
+
raise Errors::ValidationError, 'mutation_reason must be a Taxonomies::MUTATION_REASONS value'
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def legacy_inventory_available?
|
|
283
|
+
defined?(::Legion::LLM::Inventory) &&
|
|
284
|
+
::Legion::LLM::Inventory.respond_to?(:lanes_for) &&
|
|
285
|
+
::Legion::LLM::Inventory.respond_to?(:write_lane) &&
|
|
286
|
+
::Legion::LLM::Inventory.respond_to?(:delete_lane)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def canonical_family(value)
|
|
290
|
+
text = Identity.normalize_text(value: value, field: :provider_family).downcase
|
|
291
|
+
raise Errors::ValidationError, 'provider_family must be snake-case ASCII' unless text.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
292
|
+
|
|
293
|
+
text.to_sym
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def deep_freeze_legacy_ids(mapping)
|
|
297
|
+
raise Errors::ValidationError, 'legacy_instance_ids_by_instance must be a Hash' unless mapping.is_a?(::Hash)
|
|
298
|
+
|
|
299
|
+
mapping.each_with_object({}) do |(key, values), frozen|
|
|
300
|
+
raise Errors::ValidationError, 'legacy instance id keys must be Strings' unless key.is_a?(::String)
|
|
301
|
+
raise Errors::ValidationError, 'legacy superseded ids must be a nonempty Array of Strings' unless values.is_a?(::Array) && !values.empty? && values.all?(::String)
|
|
302
|
+
|
|
303
|
+
frozen[key.dup.freeze] = values.map { |value| value.dup.freeze }.freeze
|
|
304
|
+
end.freeze
|
|
305
|
+
end
|
|
306
|
+
end
|
|
101
307
|
end
|
|
102
308
|
end
|
|
103
309
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
4
|
+
require 'legion/extensions/llm/inventory/identity'
|
|
5
|
+
require 'legion/extensions/llm/inventory/records'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Llm
|
|
10
|
+
module Inventory
|
|
11
|
+
# A structurally immutable, generation-tagged read model of the registry.
|
|
12
|
+
# Lookups return the frozen record or nil and never synthesize a default.
|
|
13
|
+
# `instances_by_key` contains only activated instances;
|
|
14
|
+
# `publication_status_by_key` also contains initializing claims. A caller
|
|
15
|
+
# cannot observe a later inventory-record or generation mutation through
|
|
16
|
+
# an older snapshot; the captured CallableHandle lifecycle is the single
|
|
17
|
+
# intentional exception. See phase-1-lex-llm-additive.md section 11.3.
|
|
18
|
+
class Snapshot
|
|
19
|
+
attr_reader :generation
|
|
20
|
+
|
|
21
|
+
def initialize(generation:, instances_by_key:, offerings_by_id:, lanes_by_id:, publication_status_by_key:)
|
|
22
|
+
@generation = generation
|
|
23
|
+
@instances_by_key = order_instances(instances_by_key).freeze
|
|
24
|
+
@offerings_by_id = offerings_by_id.dup.freeze
|
|
25
|
+
@lanes_by_id = lanes_by_id.dup.freeze
|
|
26
|
+
@publication_status_by_key = order_instances(publication_status_by_key).freeze
|
|
27
|
+
@ordered_offerings = @offerings_by_id.keys.sort.map { |id| @offerings_by_id[id] }.freeze
|
|
28
|
+
@ordered_lanes = @lanes_by_id.keys.sort.map { |id| @lanes_by_id[id] }.freeze
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def instance(instance_key:)
|
|
33
|
+
@instances_by_key[instance_key]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def offering(offering_id:)
|
|
37
|
+
@offerings_by_id[offering_id]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def lane(lane_id:)
|
|
41
|
+
@lanes_by_id[lane_id]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def lanes_for(instance_key:)
|
|
45
|
+
@ordered_lanes.select { |lane| lane.instance_key == instance_key }.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def offerings_for(instance_key:)
|
|
49
|
+
@ordered_offerings.select { |offering| offering.instance_key == instance_key }.freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def publication_status(instance_key:)
|
|
53
|
+
@publication_status_by_key[instance_key]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def each_lane(&block)
|
|
57
|
+
return to_enum(:each_lane) unless block
|
|
58
|
+
|
|
59
|
+
@ordered_lanes.each(&block)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def each_offering(&block)
|
|
63
|
+
return to_enum(:each_offering) unless block
|
|
64
|
+
|
|
65
|
+
@ordered_offerings.each(&block)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def each_instance(&block)
|
|
69
|
+
return to_enum(:each_instance) unless block
|
|
70
|
+
|
|
71
|
+
@instances_by_key.each_value(&block)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def each_publication_status(&block)
|
|
75
|
+
return to_enum(:each_publication_status) unless block
|
|
76
|
+
|
|
77
|
+
@publication_status_by_key.each_value(&block)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Orders InstanceKey entries by [provider_family bytes, instance_id bytes].
|
|
83
|
+
def order_instances(by_key)
|
|
84
|
+
by_key.keys.sort_by { |key| [key.provider_family.to_s.b, key.instance_id.b] }
|
|
85
|
+
.to_h { |key| [key, by_key[key]] }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -307,6 +307,49 @@ module Legion
|
|
|
307
307
|
parse_transcription_response(response, model:)
|
|
308
308
|
end
|
|
309
309
|
|
|
310
|
+
# Fail-loud base audio operations. Unsupported providers inherit these and
|
|
311
|
+
# publish OperationEvidence(status: :unsupported) or :unknown; a provider
|
|
312
|
+
# may publish :supported only when its Phase 2 conformance spec exercises
|
|
313
|
+
# the actual callable path. Neither method reads configuration or infers a
|
|
314
|
+
# model. See phase-1-lex-llm-additive.md section 14.1.
|
|
315
|
+
def translate(audio_file, model:, language:, **provider_options)
|
|
316
|
+
_ = [audio_file, model, language, provider_options]
|
|
317
|
+
raise NotImplementedError, "#{self.class} does not implement translate"
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def speak(text, model:, voice: nil, **provider_options)
|
|
321
|
+
_ = [text, model, voice, provider_options]
|
|
322
|
+
raise NotImplementedError, "#{self.class} does not implement speak"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Runtime error-to-outcome normalization consumed by the common
|
|
326
|
+
# classifier. The inherited base is deliberately conservative: raw 503,
|
|
327
|
+
# Anthropic-style 529, ServiceUnavailableError, ServerError, and every
|
|
328
|
+
# unrecognized error return :provider_error, never :instance_unavailable.
|
|
329
|
+
# Provider PRs override only when their wire semantics supply stronger
|
|
330
|
+
# evidence. The fallback reason is the bounded exception class name — never
|
|
331
|
+
# a response body, credential, endpoint, or exception object. It is a base
|
|
332
|
+
# method, not a REQUIRED_SIGNATURES reflection entry. See Phase 2 §4.5.2.
|
|
333
|
+
def normalize_dispatch_error(error:)
|
|
334
|
+
kind = case error
|
|
335
|
+
when OverloadedError then :overloaded
|
|
336
|
+
when RateLimitError then :rate_limited
|
|
337
|
+
when UnauthorizedError then :authentication
|
|
338
|
+
when PaymentRequiredError then :billing
|
|
339
|
+
when ForbiddenError then :authorization
|
|
340
|
+
when ContextLengthExceededError then :context_rejected
|
|
341
|
+
when BadRequestError then :invalid_request
|
|
342
|
+
when ModelNotFoundError then :model_missing
|
|
343
|
+
when ModelNotAllowedError then :policy
|
|
344
|
+
when Faraday::TimeoutError, Timeout::Error then :timeout
|
|
345
|
+
when Faraday::ConnectionFailed, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError then :connection_failure
|
|
346
|
+
else :provider_error
|
|
347
|
+
end
|
|
348
|
+
reason = error.class.name
|
|
349
|
+
reason = 'UnknownError' if reason.nil? || reason.empty?
|
|
350
|
+
Legion::Extensions::Llm::Routing::ProviderOutcome.new(kind: kind, reason: reason)
|
|
351
|
+
end
|
|
352
|
+
|
|
310
353
|
def configured?
|
|
311
354
|
configuration_requirements.all? { |req| @config.send(req) }
|
|
312
355
|
end
|
|
@@ -537,17 +580,15 @@ module Legion
|
|
|
537
580
|
}
|
|
538
581
|
end
|
|
539
582
|
|
|
540
|
-
# Resolve a single policy value with
|
|
583
|
+
# Resolve a single policy value with the shared cascade
|
|
584
|
+
# (SettingsCascade: instance > provider, model leg skipped — no model
|
|
585
|
+
# exists at policy-build time) plus the legacy global
|
|
586
|
+
# extensions.llm.<key> leg. Empty values fall through.
|
|
541
587
|
def self.resolve_policy_value(cfg, provider_conf, global_conf, key)
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
|
|
588
|
+
value = SettingsCascade.resolve_value(provider_conf: provider_conf, instance_cfg: cfg, key: key)
|
|
589
|
+
return value unless value.nil?
|
|
545
590
|
|
|
546
|
-
|
|
547
|
-
val = provider_conf[key] || provider_conf[key.to_s]
|
|
548
|
-
return val if val && !val.to_s.empty? && (val.is_a?(Array) ? val.any? : true)
|
|
549
|
-
|
|
550
|
-
# Global
|
|
591
|
+
global_conf = {} unless global_conf.is_a?(::Hash)
|
|
551
592
|
global_conf[key] || global_conf[key.to_s]
|
|
552
593
|
end
|
|
553
594
|
|
|
@@ -760,11 +801,11 @@ module Legion
|
|
|
760
801
|
end
|
|
761
802
|
|
|
762
803
|
def model_capability_config(model_id)
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
804
|
+
SettingsCascade.merge_model_scopes(
|
|
805
|
+
provider_conf: provider_capability_config,
|
|
806
|
+
instance_cfg: config.respond_to?(:to_h) ? config.to_h : {},
|
|
807
|
+
model: model_id
|
|
808
|
+
)
|
|
768
809
|
rescue StandardError => e
|
|
769
810
|
handle_exception(e, level: :warn, handled: true, operation: "#{slug}.model_capability_config")
|
|
770
811
|
{}
|
|
@@ -821,21 +862,6 @@ module Legion
|
|
|
821
862
|
end
|
|
822
863
|
end
|
|
823
864
|
|
|
824
|
-
def extract_models_config(source)
|
|
825
|
-
return {} unless source.respond_to?(:models)
|
|
826
|
-
|
|
827
|
-
models = source.models
|
|
828
|
-
models.respond_to?(:to_h) ? models.to_h : {}
|
|
829
|
-
rescue StandardError
|
|
830
|
-
{}
|
|
831
|
-
end
|
|
832
|
-
|
|
833
|
-
def provider_capability_models
|
|
834
|
-
config = provider_capability_config
|
|
835
|
-
models = config[:models] || config['models']
|
|
836
|
-
models.respond_to?(:to_h) ? models.to_h : {}
|
|
837
|
-
end
|
|
838
|
-
|
|
839
865
|
def offering_from_model(model, health: {})
|
|
840
866
|
capability_sources = Array(model.capabilities).to_h do |cap|
|
|
841
867
|
[cap.to_sym, { value: true, source: :model_metadata }]
|
|
@@ -4,6 +4,10 @@ module Legion
|
|
|
4
4
|
module Extensions
|
|
5
5
|
module Llm
|
|
6
6
|
module Routing
|
|
7
|
+
# DEPRECATED (Phase 4 removal): retained only for protocol-v2 fleet
|
|
8
|
+
# compatibility. The new canonical execution identity comes solely from
|
|
9
|
+
# Inventory::Identity (off:v1:/lane:v1:); do not reinterpret an old
|
|
10
|
+
# five-part lane as a new lane:v1: identity.
|
|
7
11
|
# Builds stable fleet lane keys from provider-neutral model offerings.
|
|
8
12
|
module LaneKey
|
|
9
13
|
module_function
|
|
@@ -4,6 +4,9 @@ module Legion
|
|
|
4
4
|
module Extensions
|
|
5
5
|
module Llm
|
|
6
6
|
module Routing
|
|
7
|
+
# DEPRECATED (Phase 4 removal): kept unchanged for released consumers.
|
|
8
|
+
# New providers publish Inventory::OfferingDraft; the new registry accepts
|
|
9
|
+
# no default generated offering_id from this class.
|
|
7
10
|
# Describes one concrete model made available by one provider instance.
|
|
8
11
|
class ModelOffering
|
|
9
12
|
CAPABILITY_ALIASES = {
|
|
@@ -4,6 +4,8 @@ module Legion
|
|
|
4
4
|
module Extensions
|
|
5
5
|
module Llm
|
|
6
6
|
module Routing
|
|
7
|
+
# DEPRECATED (Phase 4 removal): the old mutable registry, kept only for
|
|
8
|
+
# old callers. It is never returned by Inventory::Registry.snapshot.
|
|
7
9
|
# In-memory index of provider-neutral model offerings.
|
|
8
10
|
class OfferingRegistry
|
|
9
11
|
include Enumerable
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
4
|
+
require 'legion/extensions/llm/inventory/immutable_value'
|
|
5
|
+
require 'legion/extensions/llm/inventory/records'
|
|
6
|
+
require 'legion/extensions/llm/routing/records'
|
|
7
|
+
require 'legion/extensions/llm/taxonomies'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Llm
|
|
12
|
+
module Routing
|
|
13
|
+
# A provider-neutral normalized outcome. Adapters translate wire errors
|
|
14
|
+
# into one of these kinds; the common classifier branches only on kind,
|
|
15
|
+
# never on provider identity. The value contains no provider object,
|
|
16
|
+
# callable, response body, routing weight, or health-mutation
|
|
17
|
+
# instruction. HTTP 503/529 alone is not an input and cannot manufacture
|
|
18
|
+
# instance_unavailable. See phase-1-lex-llm-additive.md section 14.2.
|
|
19
|
+
ProviderOutcome = ::Data.define(:kind, :reason, :quota_domain, :retry_after, :metadata) do
|
|
20
|
+
def initialize(kind:, reason:, quota_domain: nil, retry_after: nil, metadata: {})
|
|
21
|
+
support = Legion::Extensions::Llm::Inventory::RecordSupport
|
|
22
|
+
errors = Legion::Extensions::Llm::Inventory::Errors
|
|
23
|
+
raise errors::ValidationError, 'kind must be a Taxonomies::PROVIDER_OUTCOMES value' unless Legion::Extensions::Llm::Taxonomies::PROVIDER_OUTCOMES.include?(kind)
|
|
24
|
+
|
|
25
|
+
super(
|
|
26
|
+
kind: kind,
|
|
27
|
+
reason: support.sanitized_reason(value: reason, field: :reason),
|
|
28
|
+
quota_domain: validate_quota_domain!(kind, quota_domain),
|
|
29
|
+
retry_after: validate_retry_after!(retry_after),
|
|
30
|
+
metadata: support.frozen_metadata(value: metadata)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_quota_domain!(kind, quota_domain)
|
|
35
|
+
errors = Legion::Extensions::Llm::Inventory::Errors
|
|
36
|
+
return nil if quota_domain.nil?
|
|
37
|
+
raise errors::ValidationError, 'quota_domain must be a Routing::QuotaDomainKey' unless quota_domain.is_a?(QuotaDomainKey)
|
|
38
|
+
raise errors::ValidationError, 'quota_domain is allowed only for rate_limited' unless kind == :rate_limited
|
|
39
|
+
|
|
40
|
+
quota_domain
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_retry_after!(retry_after)
|
|
44
|
+
return nil if retry_after.nil?
|
|
45
|
+
return retry_after if retry_after.is_a?(::Numeric) && retry_after.finite? && !retry_after.negative?
|
|
46
|
+
|
|
47
|
+
raise Legion::Extensions::Llm::Inventory::Errors::ValidationError,
|
|
48
|
+
'retry_after must be nil or a finite nonnegative Numeric'
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|