lex-llm 0.7.3 → 0.8.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 +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +6 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +29 -17
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +10 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +98 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -5,6 +5,7 @@ require 'concurrent'
|
|
|
5
5
|
require_relative 'protocol'
|
|
6
6
|
require_relative 'settings'
|
|
7
7
|
require_relative 'token_validator'
|
|
8
|
+
require_relative 'fleet_envelope'
|
|
8
9
|
require 'legion/extensions/llm/taxonomies'
|
|
9
10
|
require 'legion/extensions/llm/inventory/errors'
|
|
10
11
|
require 'legion/extensions/llm/inventory/identity'
|
|
@@ -14,7 +15,9 @@ module Legion
|
|
|
14
15
|
module Extensions
|
|
15
16
|
module Llm
|
|
16
17
|
module Fleet
|
|
17
|
-
# Applies responder-side policy and
|
|
18
|
+
# Applies responder-side policy and executes a fleet request against
|
|
19
|
+
# the captured registry callable. Protocol v3: exact execution only —
|
|
20
|
+
# one dispatch path (06 W1/W3), no provider-object topology.
|
|
18
21
|
module WorkerExecution
|
|
19
22
|
include Legion::Logging::Helper
|
|
20
23
|
extend Legion::Logging::Helper
|
|
@@ -26,14 +29,17 @@ module Legion
|
|
|
26
29
|
|
|
27
30
|
module_function
|
|
28
31
|
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
# The single dispatch entry (06 W1).
|
|
33
|
+
def call(envelope:, registry:)
|
|
34
|
+
raise PolicyError, 'WorkerExecution.call requires registry' if registry.nil?
|
|
35
|
+
|
|
36
|
+
envelope = FleetEnvelope.wrap(envelope)
|
|
31
37
|
claims = nil
|
|
32
38
|
idempotency_key = nil
|
|
33
39
|
claims = validate_identity!(envelope)
|
|
34
40
|
validate_policy!(envelope)
|
|
35
41
|
idempotency_key = validate_idempotency!(envelope)
|
|
36
|
-
response = dispatch!(envelope
|
|
42
|
+
response = dispatch!(envelope:, registry:)
|
|
37
43
|
mark_idempotency_success!(idempotency_key) if idempotency_key
|
|
38
44
|
TokenValidator.mark_replay!(claims[:jti]) if claims.is_a?(Hash)
|
|
39
45
|
response
|
|
@@ -52,115 +58,51 @@ module Legion
|
|
|
52
58
|
def validate_identity!(envelope)
|
|
53
59
|
return true unless responder_setting(:require_auth, default: true)
|
|
54
60
|
|
|
55
|
-
TokenValidator.validate!(token:
|
|
61
|
+
TokenValidator.validate!(token: envelope.signed_token, envelope: envelope.to_h)
|
|
56
62
|
end
|
|
57
63
|
|
|
58
|
-
|
|
64
|
+
# W6 fail-closed: require_policy with no policy engine configured
|
|
65
|
+
# RAISES. The v2 warn-and-allow is deleted.
|
|
66
|
+
def validate_policy!(_envelope)
|
|
59
67
|
return true unless responder_setting(:require_policy, default: false)
|
|
60
68
|
|
|
61
|
-
|
|
62
|
-
true
|
|
69
|
+
raise PolicyError, 'require_policy is enabled but no policy engine is configured'
|
|
63
70
|
end
|
|
64
71
|
|
|
65
72
|
def validate_idempotency!(envelope)
|
|
66
73
|
return nil unless responder_setting(:require_idempotency, default: true)
|
|
67
74
|
|
|
68
|
-
key =
|
|
75
|
+
key = envelope.idempotency_key
|
|
69
76
|
raise PolicyError, 'fleet idempotency_key is required' if key.to_s.empty?
|
|
70
77
|
|
|
71
78
|
reserve_idempotency_key!(key.to_s)
|
|
72
79
|
key.to_s
|
|
73
80
|
end
|
|
74
81
|
|
|
75
|
-
def dispatch_local_provider!(envelope:, provider:)
|
|
76
|
-
provider = provider.call(envelope) if provider.respond_to?(:call) && !provider.respond_to?(:chat)
|
|
77
|
-
operation = envelope_value(envelope, :operation).to_sym
|
|
78
|
-
params = normalize_hash(envelope_value(envelope, :params) || {})
|
|
79
|
-
params = unpack_legacy_options(params)
|
|
80
|
-
model = envelope_value(envelope, :model)
|
|
81
|
-
|
|
82
|
-
case operation
|
|
83
|
-
when :chat
|
|
84
|
-
provider.chat(messages: params.fetch(:messages, []), model: model, **except(params, :messages))
|
|
85
|
-
when :stream
|
|
86
|
-
provider.stream_chat(messages: params.fetch(:messages, []), model: model, **except(params, :messages))
|
|
87
|
-
when :embed
|
|
88
|
-
provider.embed(text: params[:text], model: model, **except(params, :text))
|
|
89
|
-
when :count_tokens
|
|
90
|
-
provider.count_tokens(messages: params.fetch(:messages, []), model: model, **except(params, :messages))
|
|
91
|
-
else
|
|
92
|
-
raise PolicyError, "unsupported fleet operation: #{operation}"
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
82
|
ERRORS = Legion::Extensions::Llm::Inventory::Errors
|
|
97
83
|
IDENTITY = Legion::Extensions::Llm::Inventory::Identity
|
|
98
84
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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:)
|
|
85
|
+
# The one dispatcher (06 W3): the exact resolution chain (W2). The
|
|
86
|
+
# claim's offering_id value is the lane's 5 tuple (field name kept for
|
|
87
|
+
# protocol continuity, D4); the claim resolves a lane exactly. The
|
|
88
|
+
# REQUESTED operation (envelope) is what gets dispatched — the lane
|
|
89
|
+
# constrains type/model/instance, the operation is a request property.
|
|
90
|
+
def dispatch!(envelope:, registry:)
|
|
118
91
|
snapshot = registry.snapshot
|
|
119
|
-
instance_key =
|
|
92
|
+
instance_key = IDENTITY::InstanceKey.new(
|
|
93
|
+
provider_family: envelope.provider, instance_id: envelope.provider_instance
|
|
94
|
+
)
|
|
120
95
|
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
96
|
operation = exact_operation(envelope)
|
|
125
|
-
|
|
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])
|
|
97
|
+
lane = exact_lane!(snapshot, record, envelope, operation)
|
|
150
98
|
lease = registry.acquire(callable_handle: lane.callable_handle)
|
|
151
99
|
begin
|
|
152
|
-
dispatch_operation(lease.callable,
|
|
100
|
+
dispatch_operation(lease.callable, operation, lane.model, exact_params(envelope))
|
|
153
101
|
ensure
|
|
154
102
|
lease.release
|
|
155
103
|
end
|
|
156
104
|
end
|
|
157
105
|
|
|
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
106
|
def available_record!(snapshot, instance_key)
|
|
165
107
|
record = snapshot.instance(instance_key: instance_key)
|
|
166
108
|
raise ERRORS::ExactOfferingMismatchError, 'instance is absent, initializing, or unavailable' unless record && record.availability.state == :available
|
|
@@ -168,97 +110,134 @@ module Legion
|
|
|
168
110
|
record
|
|
169
111
|
end
|
|
170
112
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
113
|
+
# The exact lane the claim names: the 5-tuple claim value must parse
|
|
114
|
+
# and validate, resolve in the snapshot, and agree with the activated
|
|
115
|
+
# instance, the envelope's operation type, and the envelope's model.
|
|
116
|
+
# The operation is a request property matched against the lane type —
|
|
117
|
+
# the lane's operation member is the representative of its type, not
|
|
118
|
+
# the identity.
|
|
119
|
+
def exact_lane!(snapshot, record, envelope, operation)
|
|
120
|
+
IDENTITY.validate_lane_id!(value: envelope.offering_id)
|
|
121
|
+
lane = snapshot.lane(lane_id: envelope.offering_id)
|
|
122
|
+
raise ERRORS::ExactOfferingMismatchError, 'lane_id not on the activated instance' if lane.nil?
|
|
123
|
+
unless lane.instance_key == record.instance_key && lane.callable_handle.equal?(record.callable_handle)
|
|
124
|
+
raise ERRORS::ExactOfferingMismatchError, 'lane does not belong to the activated instance'
|
|
125
|
+
end
|
|
174
126
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
127
|
+
require_matching_operation!(lane, operation)
|
|
128
|
+
require_matching_model!(lane, envelope)
|
|
129
|
+
lane
|
|
130
|
+
end
|
|
178
131
|
|
|
179
|
-
|
|
132
|
+
# 06 §5: an unknown operation is a contract error at the worker.
|
|
133
|
+
def exact_operation(envelope)
|
|
134
|
+
Legion::Extensions::Llm::Taxonomies.normalize_operation(value: envelope.operation)
|
|
135
|
+
rescue Legion::Extensions::Llm::Inventory::Errors::ValidationError
|
|
136
|
+
raise ContractError, "unsupported exact operation: #{envelope.operation}"
|
|
180
137
|
end
|
|
181
138
|
|
|
182
|
-
def
|
|
183
|
-
|
|
139
|
+
def require_matching_operation!(lane, operation)
|
|
140
|
+
lane_type = Legion::Extensions::Llm::Taxonomies.lane_type_for(operation: lane.operation)
|
|
141
|
+
return if Legion::Extensions::Llm::Taxonomies.lane_type_for(operation: operation) == lane_type
|
|
184
142
|
|
|
185
|
-
raise ERRORS::ExactOfferingMismatchError, "operation #{operation}
|
|
143
|
+
raise ERRORS::ExactOfferingMismatchError, "operation #{operation} does not match the claimed lane type"
|
|
186
144
|
end
|
|
187
145
|
|
|
188
|
-
def
|
|
189
|
-
|
|
190
|
-
lane
|
|
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
|
|
146
|
+
def require_matching_model!(lane, envelope)
|
|
147
|
+
model = IDENTITY.normalize_text(value: envelope.model, field: :model)
|
|
148
|
+
raise ERRORS::ExactOfferingMismatchError, 'model does not match the lane' unless lane.model == model
|
|
194
149
|
|
|
195
|
-
|
|
150
|
+
model
|
|
196
151
|
end
|
|
197
152
|
|
|
153
|
+
# W5 params strictness: no :model key (the Selection-derived model is
|
|
154
|
+
# untouchable), no duplicate param spellings. Shape violations are
|
|
155
|
+
# contract errors (06 §5), not offering mismatches.
|
|
198
156
|
def exact_params(envelope)
|
|
199
|
-
raw =
|
|
157
|
+
raw = envelope.params || {}
|
|
200
158
|
params = {}
|
|
201
159
|
raw.each do |key, value|
|
|
202
160
|
sym = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
203
|
-
raise
|
|
161
|
+
raise ContractError, "duplicate param spelling for #{sym}" if params.key?(sym)
|
|
204
162
|
|
|
205
163
|
params[sym] = value
|
|
206
164
|
end
|
|
207
|
-
raise
|
|
165
|
+
raise ContractError, 'params must not contain model' if params.key?(:model)
|
|
208
166
|
|
|
209
167
|
params
|
|
210
168
|
end
|
|
211
169
|
|
|
170
|
+
# W4 — the named rehydration boundary: the ONLY place serialized wire
|
|
171
|
+
# messages become Canonical::Message. Non-Hash/non-Message elements
|
|
172
|
+
# are contract errors.
|
|
173
|
+
def rehydrate_wire_messages(value)
|
|
174
|
+
Array(value).map do |message|
|
|
175
|
+
next message if message.is_a?(Canonical::Message)
|
|
176
|
+
|
|
177
|
+
unless message.is_a?(Hash)
|
|
178
|
+
raise ContractError,
|
|
179
|
+
"fleet wire message must be a serialized Canonical::Message, got #{message.class}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
Canonical::Message.from_hash(message)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
212
186
|
def dispatch_operation(callable, operation, model, params)
|
|
213
187
|
case operation
|
|
214
|
-
when :chat
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
when :
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
when :
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
188
|
+
when :chat
|
|
189
|
+
callable.chat(rehydrate_wire_messages(require_param!(params, :messages, operation)), model: model,
|
|
190
|
+
**params.except(:messages))
|
|
191
|
+
when :stream_chat
|
|
192
|
+
callable.stream_chat(rehydrate_wire_messages(require_param!(params, :messages, operation)), model: model,
|
|
193
|
+
**params.except(:messages))
|
|
194
|
+
when :count_tokens
|
|
195
|
+
callable.count_tokens(messages: rehydrate_wire_messages(require_param!(params, :messages, operation)), model: model,
|
|
196
|
+
**params.except(:messages))
|
|
197
|
+
when :embed
|
|
198
|
+
callable.embed(text: require_param!(params, :text, operation), model: model, **params.except(:text))
|
|
199
|
+
when :image
|
|
200
|
+
dispatch_image(callable, model, params)
|
|
201
|
+
when :transcribe
|
|
202
|
+
dispatch_audio(callable, :transcribe, model, params)
|
|
203
|
+
when :translate
|
|
204
|
+
dispatch_audio(callable, :translate, model, params)
|
|
205
|
+
when :speak
|
|
206
|
+
dispatch_speak(callable, model, params)
|
|
207
|
+
when :moderate
|
|
208
|
+
callable.moderate(input: require_param!(params, :input, operation), model: model, **params.except(:input))
|
|
209
|
+
else
|
|
210
|
+
raise ContractError, "unsupported exact operation: #{operation}"
|
|
224
211
|
end
|
|
225
212
|
end
|
|
226
213
|
|
|
227
214
|
def dispatch_image(callable, model, params)
|
|
228
215
|
require_param!(params, :prompt, :image)
|
|
229
216
|
require_param!(params, :size, :image)
|
|
230
|
-
callable.image(prompt: params[:prompt], model: model, **except(
|
|
217
|
+
callable.image(prompt: params[:prompt], model: model, **params.except(:prompt))
|
|
231
218
|
end
|
|
232
219
|
|
|
233
220
|
def dispatch_audio(callable, operation, model, params)
|
|
234
221
|
require_param!(params, :audio_file, operation)
|
|
235
|
-
raise
|
|
222
|
+
raise ContractError, "#{operation} requires the language key" unless params.key?(:language)
|
|
236
223
|
|
|
237
224
|
callable.public_send(
|
|
238
225
|
operation, params[:audio_file], model: model, language: params[:language],
|
|
239
|
-
**except(
|
|
226
|
+
**params.except(:audio_file, :model, :language)
|
|
240
227
|
)
|
|
241
228
|
end
|
|
242
229
|
|
|
243
230
|
def dispatch_speak(callable, model, params)
|
|
244
231
|
require_param!(params, :text, :speak)
|
|
245
|
-
callable.speak(params[:text], model: model, voice: params[:voice], **except(
|
|
232
|
+
callable.speak(params[:text], model: model, voice: params[:voice], **params.except(:text, :model, :voice))
|
|
246
233
|
end
|
|
247
234
|
|
|
248
235
|
def require_param!(params, key, operation)
|
|
249
|
-
raise
|
|
236
|
+
raise ContractError, "#{operation} requires the #{key} param" unless params.key?(key)
|
|
250
237
|
|
|
251
238
|
params[key]
|
|
252
239
|
end
|
|
253
240
|
|
|
254
|
-
def unpack_legacy_options(params)
|
|
255
|
-
options = params.delete(:options)
|
|
256
|
-
return params unless options.is_a?(Hash)
|
|
257
|
-
|
|
258
|
-
normalize_hash(options).each { |key, value| params[key] = value unless params.key?(key) }
|
|
259
|
-
params
|
|
260
|
-
end
|
|
261
|
-
|
|
262
241
|
def reset_idempotency_cache!
|
|
263
242
|
@idempotency_keys = Concurrent::Map.new
|
|
264
243
|
@idempotency_mutex = Mutex.new
|
|
@@ -325,28 +304,6 @@ module Legion
|
|
|
325
304
|
def auth_required?
|
|
326
305
|
Settings.value(:fleet, :auth, :require_signed_token, default: true) != false
|
|
327
306
|
end
|
|
328
|
-
|
|
329
|
-
def envelope_value(envelope, key)
|
|
330
|
-
return nil unless envelope.respond_to?(:key?)
|
|
331
|
-
|
|
332
|
-
envelope[key] || envelope[key.to_s]
|
|
333
|
-
end
|
|
334
|
-
|
|
335
|
-
def normalize_hash(hash)
|
|
336
|
-
return {} unless hash.respond_to?(:each)
|
|
337
|
-
|
|
338
|
-
hash.each_with_object({}) do |(key, value), result|
|
|
339
|
-
result[key.respond_to?(:to_sym) ? key.to_sym : key] = value
|
|
340
|
-
end
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
def except(hash, *keys)
|
|
344
|
-
exclusions = keys.map(&:to_sym)
|
|
345
|
-
hash.each_with_object({}) do |(key, value), result|
|
|
346
|
-
normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
347
|
-
result[normalized_key] = value unless exclusions.include?(normalized_key)
|
|
348
|
-
end
|
|
349
|
-
end
|
|
350
307
|
end
|
|
351
308
|
end
|
|
352
309
|
end
|
|
@@ -51,7 +51,7 @@ module Legion
|
|
|
51
51
|
# canonicalizes into capability `streaming`.
|
|
52
52
|
OperationEvidence = ::Data.define(:operation, :status, :source, :observed_at, :metadata) do
|
|
53
53
|
def initialize(operation:, status:, source:, observed_at: nil, metadata: {})
|
|
54
|
-
canonical_operation = Taxonomies.normalize_operation(value: operation
|
|
54
|
+
canonical_operation = Taxonomies.normalize_operation(value: operation)
|
|
55
55
|
Evidence.validate_evidence_status(status: status, allowed: Taxonomies::OPERATION_EVIDENCE_STATES)
|
|
56
56
|
Evidence.validate_evidence_source(source: source)
|
|
57
57
|
Evidence.enforce_unknown_only_source!(status: status, source: source)
|
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'digest'
|
|
4
3
|
require 'legion/extensions/llm/inventory/errors'
|
|
4
|
+
require 'legion/extensions/llm/taxonomies'
|
|
5
5
|
|
|
6
6
|
module Legion
|
|
7
7
|
module Extensions
|
|
8
8
|
module Llm
|
|
9
9
|
module Inventory
|
|
10
|
-
# Canonical field normalization plus the
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
# or Ruby Hash#hash. Tier is never an identity input.
|
|
10
|
+
# Canonical field normalization plus the ONE 5-tuple lane-id composer.
|
|
11
|
+
# The lane id is `tier:provider_family:instance_id:type:model`, composed
|
|
12
|
+
# here and ONLY here (G22). Every surface that needs an id calls
|
|
13
|
+
# compose_lane_id; validation is shape/parse checks against Taxonomies
|
|
14
|
+
# (R1 boundary contract — it enforces the shape the code produces).
|
|
15
|
+
# The code produces ONLY 5-tuples; nothing else is guarded against.
|
|
17
16
|
module Identity
|
|
18
17
|
module_function
|
|
19
18
|
|
|
@@ -42,46 +41,63 @@ module Legion
|
|
|
42
41
|
candidate
|
|
43
42
|
end
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
# The ONE 5-tuple composer (G22): byte-identical to the v0.6.16
|
|
45
|
+
# ScopedRefresher.compose_id. A pure join of the five identity fields —
|
|
46
|
+
# no normalization here; the fields are normalized by their owners
|
|
47
|
+
# (TIERS/TYPES symbols, InstanceKey, normalize_text) before composing.
|
|
48
|
+
def compose_lane_id(tier:, provider_family:, instance_id:, type:, model:)
|
|
49
|
+
"#{tier}:#{provider_family}:#{instance_id}:#{type}:#{model}"
|
|
48
50
|
end
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
# Bounded parse: split(':', 5) — the 5th part keeps its colons, which
|
|
53
|
+
# is load-bearing: model names contain ':' (Ollama model:tag, Bedrock
|
|
54
|
+
# model ids). Structural only — field semantics are validate_lane_id!'s.
|
|
55
|
+
def parse_lane_id(id)
|
|
56
|
+
raise Errors::ValidationError, 'lane id must be a String or Symbol' unless text_input?(id)
|
|
57
|
+
|
|
58
|
+
parts = id.to_s.split(':', 5)
|
|
59
|
+
raise Errors::ValidationError, "lane id must have exactly 5 parts, got #{parts.length}" unless parts.length == 5
|
|
58
60
|
|
|
59
|
-
|
|
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}"
|
|
61
|
+
parts.freeze
|
|
69
62
|
end
|
|
70
63
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
64
|
+
# Shape validation (R1 boundary contract): exactly 5 parts (bounded
|
|
65
|
+
# split — the model keeps its colons), part 1 in TIERS, part 4 in
|
|
66
|
+
# TYPES, parts 2/3/5 nonempty NFC. Any value that is not a 5 tuple
|
|
67
|
+
# fails the part-count check on its own — there is no special case
|
|
68
|
+
# for anything else.
|
|
69
|
+
def validate_lane_id!(value:)
|
|
70
|
+
parts = parse_lane_id(value)
|
|
71
|
+
tier, provider_family, instance_id, type, model = parts
|
|
72
|
+
raise Errors::ValidationError, "lane id tier must be a Taxonomies::TIERS value, got #{tier}" \
|
|
73
|
+
unless Taxonomies::TIERS.include?(tier.to_sym)
|
|
74
|
+
raise Errors::ValidationError, "lane id type must be a Taxonomies::TYPES value, got #{type}" \
|
|
75
|
+
unless Taxonomies::TYPES.include?(type.to_sym)
|
|
76
|
+
raise Errors::ValidationError, 'lane id provider_family must be a nonempty NFC String' \
|
|
77
|
+
unless nonempty_nfc?(provider_family)
|
|
78
|
+
raise Errors::ValidationError, 'lane id instance_id must be a nonempty NFC String' \
|
|
79
|
+
unless nonempty_nfc?(instance_id)
|
|
80
|
+
raise Errors::ValidationError, 'lane id model must be a nonempty NFC String' \
|
|
81
|
+
unless nonempty_nfc?(model)
|
|
74
82
|
|
|
75
83
|
value
|
|
76
84
|
end
|
|
77
85
|
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
raise Errors::ValidationError, 'lane_id does not reproduce from its identity fields' unless value == expected
|
|
86
|
+
def nonempty_nfc?(string)
|
|
87
|
+
!string.empty? && string.unicode_normalized?(:nfc)
|
|
88
|
+
end
|
|
83
89
|
|
|
84
|
-
|
|
90
|
+
# M6: the ONE config→instance-id derivation (R6 owner law).
|
|
91
|
+
# Instance identity is the operator's CONFIG NAME from the provider
|
|
92
|
+
# config; when the operator configured no instance name, "default"
|
|
93
|
+
# is the ordinary label (no reserved values — see InstanceKey).
|
|
94
|
+
# Consumers CARRY this derivation; they never mint a local variant
|
|
95
|
+
# (node canonical names, family fallbacks, and read-time synthesis
|
|
96
|
+
# are deleted).
|
|
97
|
+
def instance_id(config)
|
|
98
|
+
return config.instance_id.to_s if config.respond_to?(:instance_id) && config.instance_id
|
|
99
|
+
|
|
100
|
+
'default'
|
|
85
101
|
end
|
|
86
102
|
|
|
87
103
|
def text_input?(value)
|
|
@@ -96,7 +112,7 @@ module Legion
|
|
|
96
112
|
string.encoding == ::Encoding::UTF_8 ? string.dup : string.b.dup.force_encoding(::Encoding::UTF_8)
|
|
97
113
|
end
|
|
98
114
|
|
|
99
|
-
private_class_method :text_input?, :valid_utf8?, :utf8
|
|
115
|
+
private_class_method :text_input?, :valid_utf8?, :utf8, :nonempty_nfc?
|
|
100
116
|
|
|
101
117
|
# A mandatory, immutable provider_family + instance_id pair. Provider
|
|
102
118
|
# family alone is never executable; two instances of the same provider
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'digest'
|
|
4
3
|
require 'securerandom'
|
|
5
4
|
require 'legion/extensions/llm/inventory/errors'
|
|
6
5
|
require 'legion/extensions/llm/inventory/identity'
|
|
@@ -12,24 +11,22 @@ module Legion
|
|
|
12
11
|
# An opaque fenced publisher claim token. The raw secret is never
|
|
13
12
|
# serialized, logged, included in a record/snapshot/exception, returned
|
|
14
13
|
# separately, or compared for order. `publisher_id` and
|
|
15
|
-
# `publisher_token_id` are safe correlation
|
|
16
|
-
#
|
|
14
|
+
# `publisher_token_id` are safe UUID correlation values — the id law
|
|
15
|
+
# forbids digest-encoded ids on any wire/DB/log surface, so the token
|
|
16
|
+
# id is a UUID like every other lifecycle handle, NOT a derivation of
|
|
17
|
+
# the secret. Fencing is the constant-time secret comparison; the
|
|
18
|
+
# public ids correlate, they do not authenticate.
|
|
19
|
+
# See phase-1-lex-llm-additive.md section 11.1.
|
|
17
20
|
class PublisherToken
|
|
18
|
-
TOKEN_ID_DOMAIN = "publisher-token-id-v1\x00"
|
|
19
|
-
|
|
20
21
|
attr_reader :instance_key, :publisher_id, :publisher_token_id
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Factory used by the registry: builds a fresh secret and derives the
|
|
27
|
-
# public correlation IDs from it.
|
|
23
|
+
# Factory used by the registry: builds a fresh secret and mints the
|
|
24
|
+
# public correlation IDs.
|
|
28
25
|
def self.issue(instance_key:)
|
|
29
26
|
secret = ::SecureRandom.hex(32)
|
|
30
27
|
new(
|
|
31
28
|
instance_key: instance_key, secret: secret,
|
|
32
|
-
publisher_id: "pub:v1:#{::SecureRandom.uuid}", publisher_token_id:
|
|
29
|
+
publisher_id: "pub:v1:#{::SecureRandom.uuid}", publisher_token_id: "ptok:v1:#{::SecureRandom.uuid}"
|
|
33
30
|
)
|
|
34
31
|
end
|
|
35
32
|
|