lex-llm 0.7.6 → 0.8.4
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 +139 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
- data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
- data/lib/legion/extensions/llm/canonical/message.rb +53 -69
- data/lib/legion/extensions/llm/canonical/params.rb +58 -34
- data/lib/legion/extensions/llm/canonical/request.rb +68 -56
- data/lib/legion/extensions/llm/canonical/response.rb +62 -78
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
- 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 +7 -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 -156
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
- 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 +36 -100
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -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 +48 -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 +14 -26
- 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 +8 -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 +55 -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/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
- 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/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -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 +173 -95
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- 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 +82 -47
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
- 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 +7 -3
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -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 +97 -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 -43
- 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
- metadata +13 -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 -310
- 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 -340
- 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
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'digest'
|
|
6
|
+
require 'faraday'
|
|
7
|
+
|
|
8
|
+
require 'legion/logging'
|
|
9
|
+
require 'legion/json'
|
|
10
|
+
require 'legion/extensions/llm/inventory/errors'
|
|
11
|
+
require 'legion/extensions/llm/inventory/identity'
|
|
12
|
+
require 'legion/extensions/llm/inventory/records'
|
|
13
|
+
require 'legion/extensions/llm/inventory/publisher'
|
|
14
|
+
require 'legion/extensions/llm/inventory/probe_coordinator'
|
|
15
|
+
require 'legion/extensions/llm/inventory/weight_reconciler'
|
|
16
|
+
|
|
17
|
+
module Legion
|
|
18
|
+
module Extensions
|
|
19
|
+
module Llm
|
|
20
|
+
module Discovery
|
|
21
|
+
# The shared discovery pipeline for EVERY lex-llm-* provider — the write
|
|
22
|
+
# half of the inventory interface. A provider mixes it into its own
|
|
23
|
+
# `<Provider>::Runners::Discovery` module and overrides ONLY the
|
|
24
|
+
# genuinely provider-specific methods (fetch_raw_models, check_health,
|
|
25
|
+
# build_offering_draft, build_callable, and — if its config keys differ —
|
|
26
|
+
# catalog_base_url / auth_token / derive_physical_id). Everything else
|
|
27
|
+
# (reconcile, claim, activate, probe, replace, weight publication, health
|
|
28
|
+
# display, dormant weight tracking) is inherited.
|
|
29
|
+
#
|
|
30
|
+
# module Vllm
|
|
31
|
+
# module Runners
|
|
32
|
+
# module Discovery
|
|
33
|
+
# extend self
|
|
34
|
+
# include Legion::Extensions::Llm::Discovery::Pipeline
|
|
35
|
+
# def fetch_raw_models(instance_cfg:) = ...
|
|
36
|
+
# def build_offering_draft(...) = ...
|
|
37
|
+
# def build_callable(instance_cfg:) = ...
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# It is a STATELESS module in the LegionIO sense: it is not itself a live
|
|
43
|
+
# runner and it lives OUTSIDE the framework-scanned `runners/` dir (a
|
|
44
|
+
# base in that dir is claimed by the builder as a live per-extension
|
|
45
|
+
# runner). Each provider's `<Provider>::Runners::Discovery` is the live,
|
|
46
|
+
# scanned runner; it is that module object which carries the per-instance
|
|
47
|
+
# working state (`states` — the claim handles + last built drafts +
|
|
48
|
+
# publish progress), one per provider. That state is the writer's working
|
|
49
|
+
# state, NOT a second inventory: the Inventory::Registry remains the
|
|
50
|
+
# single source of truth.
|
|
51
|
+
#
|
|
52
|
+
# The base actor (`Discovery::Actor`) fires on the discovery interval and
|
|
53
|
+
# dispatches `runner_class.refresh` each tick (manual, in-process — the
|
|
54
|
+
# state and the Registry are both process-local, so the pipeline must run
|
|
55
|
+
# on the owning node, not as a remote Runner task).
|
|
56
|
+
#
|
|
57
|
+
# WEIGHT IS NOT COMPUTED HERE. Drafts are built identity-weighted;
|
|
58
|
+
# Inventory::WeightReconciler recomputes the write-time weight from live
|
|
59
|
+
# settings at publish (commit/activate). One weight owner, no duplication.
|
|
60
|
+
module Pipeline
|
|
61
|
+
include Legion::Extensions::Helpers::Lex
|
|
62
|
+
|
|
63
|
+
# A failed catalog fetch is not a catalog fact: keep the last good
|
|
64
|
+
# snapshot rather than evaporating a live instance's lanes for a cycle.
|
|
65
|
+
class CatalogFetchFailure < StandardError; end
|
|
66
|
+
|
|
67
|
+
# Per-provider working state, carried by the including runner module
|
|
68
|
+
# (one per provider). Lazy module-ivar accessors — a module has no
|
|
69
|
+
# constructor. The Inventory::Registry is the source of truth; this is
|
|
70
|
+
# only the writer's in-flight working state for this provider.
|
|
71
|
+
def states
|
|
72
|
+
@states ||= Concurrent::Map.new
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def state_mutex
|
|
76
|
+
@state_mutex ||= Mutex.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def dormant_weight_tracker
|
|
80
|
+
@dormant_weight_tracker ||= Legion::Extensions::Llm::Inventory::DormantWeightTracker.new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Test hook: drop this provider's working state for a fresh run.
|
|
84
|
+
def reset_state!
|
|
85
|
+
@states = nil
|
|
86
|
+
@dormant_weight_tracker = nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Provider-specific hooks (override in the provider's runner) ─────
|
|
90
|
+
|
|
91
|
+
# Return an Array of raw model Hashes for this instance. Default is the
|
|
92
|
+
# OpenAI-compatible GET /v1/models -> body[:data]. A non-OpenAI provider
|
|
93
|
+
# overrides this entirely.
|
|
94
|
+
def fetch_raw_models(instance_cfg:)
|
|
95
|
+
conn = build_connection(base_url: catalog_base_url(instance_cfg: instance_cfg), instance_cfg: instance_cfg, timeout: 15, open_timeout: 5)
|
|
96
|
+
response = conn.get(models_path)
|
|
97
|
+
raise CatalogFetchFailure, "catalog fetch returned HTTP #{response.status}" unless response.status.between?(200, 299)
|
|
98
|
+
|
|
99
|
+
Array(Legion::JSON.load(response.body).fetch(:data, []))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The model's stable id within the catalog list. OpenAI-compatible
|
|
103
|
+
# /v1/models lists carry it under :id; Ollama's /api/tags carries it
|
|
104
|
+
# under :name.
|
|
105
|
+
def model_id_from(model_data)
|
|
106
|
+
model_data[:id].to_s
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Return an Inventory::ReadinessResult. Default is the OpenAI-compatible
|
|
110
|
+
# GET /health. A provider with a different readiness probe overrides it.
|
|
111
|
+
def check_health(instance_cfg:)
|
|
112
|
+
conn = build_connection(base_url: catalog_base_url(instance_cfg: instance_cfg), instance_cfg: instance_cfg, timeout: 5, open_timeout: 3)
|
|
113
|
+
response = conn.get(health_path)
|
|
114
|
+
Legion::Extensions::Llm::Inventory::ReadinessResult.new(
|
|
115
|
+
ready: response.status == 200, reason: "#{health_path} returned #{response.status}",
|
|
116
|
+
metadata: { status: response.status }
|
|
117
|
+
)
|
|
118
|
+
rescue Faraday::ConnectionFailed => e
|
|
119
|
+
handle_exception(e, level: :warn, handled: true, operation: "#{provider_family}.runner.discovery.health")
|
|
120
|
+
readiness_failure(error: e)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
raise e if discovery_programming_error?(e)
|
|
123
|
+
|
|
124
|
+
handle_exception(e, level: :warn, handled: true, operation: "#{provider_family}.runner.discovery.health")
|
|
125
|
+
readiness_failure(error: e)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Build the Inventory::OfferingDraft (evidence + metadata) for one model.
|
|
129
|
+
# NO weight — the reconciler computes it at publish. Abstract: a provider
|
|
130
|
+
# MUST implement its capability/operation evidence knowledge.
|
|
131
|
+
def build_offering_draft(instance_cfg:, instance_key:, model_id:, model_data:)
|
|
132
|
+
raise NotImplementedError, "#{name} must implement #build_offering_draft"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Build the provider's inference callable captured into the registry.
|
|
136
|
+
# Abstract: a provider MUST implement how it executes inference.
|
|
137
|
+
def build_callable(instance_cfg:)
|
|
138
|
+
raise NotImplementedError, "#{name} must implement #build_callable"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# OpenAI-compatible defaults — overridable per provider.
|
|
142
|
+
def models_path = '/v1/models'
|
|
143
|
+
def health_path = '/health'
|
|
144
|
+
|
|
145
|
+
def catalog_base_url(instance_cfg:)
|
|
146
|
+
normalize_api_base(instance_cfg[:base_url] || instance_cfg[:endpoint])
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def auth_token(instance_cfg:)
|
|
150
|
+
token = instance_cfg.dig(:credentials, :api_key) || instance_cfg[:api_key]
|
|
151
|
+
token if token.is_a?(String) && !token.strip.empty?
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ── Entrypoint (called by the actor's manual dispatch) ───────────────
|
|
155
|
+
|
|
156
|
+
def refresh(**)
|
|
157
|
+
reconcile_instances
|
|
158
|
+
{ success: true }
|
|
159
|
+
rescue StandardError => e
|
|
160
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.refresh")
|
|
161
|
+
{ success: false }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def remove_all_instances(**)
|
|
165
|
+
tracked = state_mutex.synchronize { states.keys }
|
|
166
|
+
tracked.each { |instance_id| remove_instance_state(instance_id) }
|
|
167
|
+
state_mutex.synchronize do
|
|
168
|
+
states.clear
|
|
169
|
+
dormant_weight_tracker.clear!
|
|
170
|
+
end
|
|
171
|
+
{ success: true }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# ── Provider family / module / instance catalog ─────────────────────
|
|
175
|
+
|
|
176
|
+
# Resolved from the INCLUDING runner module's own name, because the
|
|
177
|
+
# pipeline is a mixed-in module: here `self` IS the provider's
|
|
178
|
+
# <Provider>::Runners::Discovery module (a Module), so its name is
|
|
179
|
+
# `self.name`, not `self.class.name`.
|
|
180
|
+
# "Legion::Extensions::Llm::Vllm::Runners::Discovery" -> "Legion::Extensions::Llm::Vllm"
|
|
181
|
+
def provider_namespace
|
|
182
|
+
@provider_namespace ||= name.split('::')[0..-3].join('::')
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def provider_family
|
|
186
|
+
@provider_family ||= provider_namespace.split('::').last.to_s.downcase.to_sym
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def provider_module
|
|
190
|
+
@provider_module ||= Kernel.const_get(provider_namespace)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The provider module's public instance catalog — the single source of
|
|
194
|
+
# configured instances, shared with the dispatch path (legion-llm
|
|
195
|
+
# Call::Providers reads the same method). A provider that defines no
|
|
196
|
+
# discover_instances does not populate the inventory.
|
|
197
|
+
def discover_instances
|
|
198
|
+
return {} unless provider_module.respond_to?(:discover_instances)
|
|
199
|
+
|
|
200
|
+
provider_module.discover_instances
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# ── Reconcile (each tick) ───────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def reconcile_instances
|
|
206
|
+
configured = discover_instances
|
|
207
|
+
configured_ids = configured.keys.map(&:to_s)
|
|
208
|
+
|
|
209
|
+
tracked_ids = state_mutex.synchronize { states.keys }
|
|
210
|
+
tracked_ids.each do |instance_id|
|
|
211
|
+
remove_instance_state(instance_id) unless configured_ids.include?(instance_id)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
configured.each do |name, instance_cfg|
|
|
215
|
+
update_instance(name: name.to_s, instance_cfg: instance_cfg)
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.instance", instance_name: name.to_s)
|
|
218
|
+
end
|
|
219
|
+
observe_dormant_weights
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def update_instance(name:, instance_cfg:)
|
|
223
|
+
state = state_mutex.synchronize { states[name] }
|
|
224
|
+
if state && physical_id_changed?(state, instance_cfg)
|
|
225
|
+
remove_instance_state(name)
|
|
226
|
+
claim_and_activate_instance(name: name, instance_cfg: instance_cfg)
|
|
227
|
+
elsif state
|
|
228
|
+
refresh_instance(instance_id: name, instance_cfg: instance_cfg)
|
|
229
|
+
else
|
|
230
|
+
claim_and_activate_instance(name: name, instance_cfg: instance_cfg)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def physical_id_changed?(state, instance_cfg)
|
|
235
|
+
state[:instance_key].physical_id != derive_physical_id(instance_cfg: instance_cfg)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def remove_instance_state(instance_id)
|
|
239
|
+
state = state_mutex.synchronize do
|
|
240
|
+
tracked = states[instance_id]
|
|
241
|
+
next unless tracked
|
|
242
|
+
|
|
243
|
+
publisher.remove_instance(instance_id: instance_id, publisher_token: tracked[:publisher_token])
|
|
244
|
+
states.delete(instance_id) if states[instance_id].equal?(tracked)
|
|
245
|
+
tracked
|
|
246
|
+
end
|
|
247
|
+
return unless state
|
|
248
|
+
|
|
249
|
+
clear_settings_health(name: state[:name])
|
|
250
|
+
rescue StandardError => e
|
|
251
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.remove_instance", instance_id: instance_id)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# ── Claim / activate ────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
def publisher
|
|
257
|
+
@publisher ||= Legion::Extensions::Llm::Inventory::Publisher.new(provider_family: provider_family)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def claim_and_activate_instance(name:, instance_cfg:)
|
|
261
|
+
instance_id = name.to_s
|
|
262
|
+
instance_key = Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(
|
|
263
|
+
provider_family: provider_family, instance_id: instance_id,
|
|
264
|
+
physical_id: derive_physical_id(instance_cfg: instance_cfg)
|
|
265
|
+
)
|
|
266
|
+
begin
|
|
267
|
+
offerings = build_offerings(instance_cfg: instance_cfg, instance_key: instance_key)
|
|
268
|
+
rescue CatalogFetchFailure
|
|
269
|
+
log.debug { "#{provider_family} discovery: catalog fetch failed at claim for #{instance_id} — deferring readiness" }
|
|
270
|
+
offerings = nil
|
|
271
|
+
end
|
|
272
|
+
callable = build_callable(instance_cfg: instance_cfg)
|
|
273
|
+
probe_coordinator = Legion::Extensions::Llm::Inventory::ProbeCoordinator.new(
|
|
274
|
+
instance_key: instance_key, enqueue: build_probe_enqueue(instance_id: instance_id)
|
|
275
|
+
)
|
|
276
|
+
publisher_token = publisher.claim_instance(
|
|
277
|
+
instance_id: instance_id, physical_id: instance_key.physical_id,
|
|
278
|
+
callable: callable, probe_request_handle: probe_coordinator
|
|
279
|
+
)
|
|
280
|
+
state = {
|
|
281
|
+
name: instance_id, instance_key: instance_key, instance_cfg: instance_cfg,
|
|
282
|
+
callable: callable, probe_coordinator: probe_coordinator,
|
|
283
|
+
publisher_token: publisher_token, sequence: 0, offerings: offerings || []
|
|
284
|
+
}
|
|
285
|
+
Legion::Extensions::Llm::Inventory::WeightReconciler.track_initializing!(
|
|
286
|
+
states: states, state_key: instance_id, state: state, mutex: state_mutex
|
|
287
|
+
)
|
|
288
|
+
perform_readiness(instance_id: instance_id, state: state, offerings: offerings) if offerings
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def perform_readiness(instance_id:, state:, offerings:)
|
|
292
|
+
reconcile_weight_snapshot(instance_id: instance_id, state: state, discovered_offerings: offerings)
|
|
293
|
+
probe_token = publisher.readiness_probe_started(instance_id: instance_id, publisher_token: state[:publisher_token])
|
|
294
|
+
readiness = check_health(instance_cfg: state[:instance_cfg])
|
|
295
|
+
report_probe_result(instance_id: instance_id, state: state, probe_token: probe_token, readiness: readiness)
|
|
296
|
+
rescue StandardError => e
|
|
297
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.readiness", instance_id: instance_id)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def report_probe_result(instance_id:, state:, probe_token:, readiness:)
|
|
301
|
+
committed = if readiness.ready? && tracked_unpublished?(instance_id: instance_id, state: state)
|
|
302
|
+
activate_tracked_state(instance_id: instance_id, state: state, probe_token: probe_token)
|
|
303
|
+
else
|
|
304
|
+
report_tracked_readiness(instance_id: instance_id, state: state, probe_token: probe_token, readiness: readiness)
|
|
305
|
+
end
|
|
306
|
+
write_instance_health(state) if committed
|
|
307
|
+
committed
|
|
308
|
+
rescue StandardError => e
|
|
309
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.report_probe", instance_id: instance_id)
|
|
310
|
+
false
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def tracked_unpublished?(instance_id:, state:)
|
|
314
|
+
state_mutex.synchronize { states[instance_id].equal?(state) && !state.fetch(:published) }
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def report_tracked_readiness(instance_id:, state:, probe_token:, readiness:)
|
|
318
|
+
state_mutex.synchronize do
|
|
319
|
+
return false unless states[instance_id].equal?(state)
|
|
320
|
+
|
|
321
|
+
if readiness.ready?
|
|
322
|
+
publisher.readiness_succeeded(instance_id: instance_id, probe_token: probe_token)
|
|
323
|
+
else
|
|
324
|
+
publisher.readiness_failed(instance_id: instance_id, probe_token: probe_token, reason: readiness.reason)
|
|
325
|
+
end
|
|
326
|
+
true
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# ── Probe (refresh path) ────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
def refresh_instance(instance_id:, instance_cfg:)
|
|
333
|
+
state = state_mutex.synchronize { states[instance_id] }
|
|
334
|
+
return unless state
|
|
335
|
+
|
|
336
|
+
status = publisher.snapshot.publication_status(instance_key: state[:instance_key])
|
|
337
|
+
if status.state == :initializing
|
|
338
|
+
begin
|
|
339
|
+
offerings = build_offerings(instance_cfg: instance_cfg, instance_key: state[:instance_key])
|
|
340
|
+
rescue CatalogFetchFailure
|
|
341
|
+
log.debug { "#{provider_family} discovery: catalog fetch failed for #{instance_id} — staying :initializing" }
|
|
342
|
+
return
|
|
343
|
+
end
|
|
344
|
+
perform_readiness(instance_id: instance_id, state: state, offerings: offerings)
|
|
345
|
+
return
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
replace_if_changed(instance_id: instance_id, state: state, instance_cfg: instance_cfg)
|
|
349
|
+
run_cadence_probe(instance_id: instance_id, state: state)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def replace_if_changed(instance_id:, state:, instance_cfg:)
|
|
353
|
+
begin
|
|
354
|
+
new_offerings = build_offerings(instance_cfg: instance_cfg, instance_key: state[:instance_key])
|
|
355
|
+
rescue CatalogFetchFailure
|
|
356
|
+
log.debug { "#{provider_family} discovery: catalog fetch failed for #{instance_id} — keeping last snapshot" }
|
|
357
|
+
return
|
|
358
|
+
end
|
|
359
|
+
changed = reconcile_weight_snapshot(instance_id: instance_id, state: state, discovered_offerings: new_offerings)
|
|
360
|
+
write_instance_health(state) if changed
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def run_cadence_probe(instance_id:, state:)
|
|
364
|
+
coordinator = state[:probe_coordinator]
|
|
365
|
+
return unless coordinator.begin_probe
|
|
366
|
+
|
|
367
|
+
probe_token = publisher.readiness_probe_started(instance_id: instance_id, publisher_token: state[:publisher_token])
|
|
368
|
+
readiness = check_health(instance_cfg: state[:instance_cfg])
|
|
369
|
+
coordinator.finish_probe
|
|
370
|
+
report_probe_result(instance_id: instance_id, state: state, probe_token: probe_token, readiness: readiness)
|
|
371
|
+
rescue StandardError => e
|
|
372
|
+
finish_probe_safely(coordinator)
|
|
373
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.cadence_probe", instance_id: instance_id)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def handle_reactive_probe(instance_id:, request:)
|
|
377
|
+
state = state_mutex.synchronize { states[instance_id] }
|
|
378
|
+
return unless state
|
|
379
|
+
|
|
380
|
+
coordinator = state[:probe_coordinator]
|
|
381
|
+
return unless coordinator.begin_probe(request: request)
|
|
382
|
+
|
|
383
|
+
probe_token = publisher.readiness_probe_started(instance_id: instance_id, publisher_token: state[:publisher_token])
|
|
384
|
+
readiness = check_health(instance_cfg: state[:instance_cfg])
|
|
385
|
+
coordinator.finish_probe(request: request)
|
|
386
|
+
report_probe_result(instance_id: instance_id, state: state, probe_token: probe_token, readiness: readiness)
|
|
387
|
+
rescue StandardError => e
|
|
388
|
+
begin
|
|
389
|
+
coordinator.finish_probe(request: request)
|
|
390
|
+
rescue StandardError => finish_e
|
|
391
|
+
handle_exception(finish_e, level: :warn, handled: true, operation: "#{provider_family}.runner.discovery.reactive_probe.finish", instance_id: instance_id)
|
|
392
|
+
end
|
|
393
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.reactive_probe", instance_id: instance_id)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def build_probe_enqueue(instance_id:)
|
|
397
|
+
proc do |request:|
|
|
398
|
+
handle_reactive_probe(instance_id: instance_id, request: request)
|
|
399
|
+
true
|
|
400
|
+
rescue StandardError => e
|
|
401
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.probe_enqueue", instance_id: instance_id)
|
|
402
|
+
false
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# ── Weight publication (delegates to the shared reconciler) ─────────
|
|
407
|
+
|
|
408
|
+
def reconcile_weight_snapshot(instance_id:, state:, discovered_offerings:)
|
|
409
|
+
Legion::Extensions::Llm::Inventory::WeightReconciler.commit_if_changed!(
|
|
410
|
+
settings: Legion::Settings, instance_id: instance_id, state: state,
|
|
411
|
+
discovered_offerings: discovered_offerings, mutex: state_mutex,
|
|
412
|
+
equivalent: lambda do |previous, current|
|
|
413
|
+
!states[instance_id].equal?(state) || offerings_equivalent?(previous, current)
|
|
414
|
+
end,
|
|
415
|
+
replace: method(:replace_weight_snapshot)
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def replace_weight_snapshot(instance_id:, state:, offerings:, sequence:)
|
|
420
|
+
publisher.replace_instance_snapshot(
|
|
421
|
+
instance_id: instance_id, publisher_token: state.fetch(:publisher_token),
|
|
422
|
+
offerings: offerings, sequence: sequence
|
|
423
|
+
)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def activate_weight_snapshot(instance_id:, state:, offerings:, sequence:, probe_token:)
|
|
427
|
+
publisher.activate_instance_snapshot(
|
|
428
|
+
instance_id: instance_id, publisher_token: state.fetch(:publisher_token),
|
|
429
|
+
offerings: offerings, sequence: sequence, probe_token: probe_token
|
|
430
|
+
)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def activate_tracked_state(instance_id:, state:, probe_token:)
|
|
434
|
+
Legion::Extensions::Llm::Inventory::WeightReconciler.activate_tracked!(
|
|
435
|
+
settings: Legion::Settings, instance_id: instance_id, state_key: state.fetch(:name),
|
|
436
|
+
state: state, states: states, mutex: state_mutex, probe_token: probe_token,
|
|
437
|
+
activate: method(:activate_weight_snapshot),
|
|
438
|
+
activation_sequence: ->(tracked) { tracked.fetch(:sequence) }
|
|
439
|
+
)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def observe_dormant_weights
|
|
443
|
+
Legion::Extensions::Llm::Inventory::WeightReconciler.observe_dormant!(
|
|
444
|
+
settings: Legion::Settings, provider_family: provider_family, states: states,
|
|
445
|
+
mutex: state_mutex, tracker: dormant_weight_tracker,
|
|
446
|
+
dormant_logger: ->(key) { log.info("[llm][#{provider_family}] action=dormant_weight weight_key=#{key.inspect} no_lane_published=true") }
|
|
447
|
+
)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# ── Offering build (weight-free draft; reconciler weights at publish) ─
|
|
451
|
+
|
|
452
|
+
def build_offerings(instance_cfg:, instance_key:)
|
|
453
|
+
models = fetch_raw_models(instance_cfg: instance_cfg)
|
|
454
|
+
models.filter_map do |model_data|
|
|
455
|
+
model_id = model_id_from(model_data)
|
|
456
|
+
next if model_id.empty?
|
|
457
|
+
|
|
458
|
+
build_offering_draft(instance_cfg: instance_cfg, instance_key: instance_key, model_id: model_id, model_data: model_data)
|
|
459
|
+
end
|
|
460
|
+
rescue StandardError => e
|
|
461
|
+
raise e if discovery_programming_error?(e)
|
|
462
|
+
raise e if e.is_a?(CatalogFetchFailure)
|
|
463
|
+
|
|
464
|
+
handle_exception(e, level: :warn, handled: false, operation: "#{provider_family}.runner.discovery.build_offerings")
|
|
465
|
+
raise CatalogFetchFailure, "catalog build failed (#{e.class.name})", cause: e
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# A programming bug in discovery must fail loud — swallowing it publishes
|
|
469
|
+
# ZERO offerings and makes an activated instance invisible.
|
|
470
|
+
def discovery_programming_error?(error)
|
|
471
|
+
error.is_a?(NameError) || error.is_a?(ArgumentError)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def offerings_equivalent?(previous, current)
|
|
475
|
+
offering_comparison_multiset(previous) == offering_comparison_multiset(current)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# ── Health display (write-back to settings, post-commit) ────────────
|
|
479
|
+
|
|
480
|
+
def write_instance_health(state)
|
|
481
|
+
instance_key = state[:instance_key]
|
|
482
|
+
snapshot = publisher.snapshot
|
|
483
|
+
display = display_fact(instance_key: instance_key, snapshot: snapshot)
|
|
484
|
+
|
|
485
|
+
instance_settings = settings.dig(:instances, state[:name].to_sym)
|
|
486
|
+
return unless instance_settings.is_a?(Hash)
|
|
487
|
+
|
|
488
|
+
instance_settings[:health] = health_hash(display)
|
|
489
|
+
instance_settings[:capabilities] = union_capabilities(instance_key)
|
|
490
|
+
rescue StandardError => e
|
|
491
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.write_health", instance_id: state[:instance_key].instance_id)
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def clear_settings_health(name:)
|
|
495
|
+
instance_settings = settings.dig(:instances, name.to_sym)
|
|
496
|
+
return unless instance_settings.is_a?(Hash)
|
|
497
|
+
|
|
498
|
+
instance_settings.delete(:health)
|
|
499
|
+
instance_settings.delete(:capabilities)
|
|
500
|
+
rescue StandardError => e
|
|
501
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.clear_health", instance_name: name.to_s)
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def union_capabilities(instance_key)
|
|
505
|
+
capabilities = Set.new
|
|
506
|
+
publisher.snapshot.lanes_for(instance_key: instance_key).each do |lane|
|
|
507
|
+
lane.capability_evidence.each do |capability, evidence|
|
|
508
|
+
capabilities << capability if evidence.supported?
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
capabilities.to_a.sort
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# ── Physical identity (secondary; dedup/diagnostics only) ───────────
|
|
515
|
+
|
|
516
|
+
def derive_physical_id(instance_cfg:)
|
|
517
|
+
host_port = extract_host_port(url: catalog_base_url(instance_cfg: instance_cfg))
|
|
518
|
+
token = auth_token(instance_cfg: instance_cfg)
|
|
519
|
+
return "#{host_port}/ak:#{::Digest::SHA256.hexdigest(token)[0, 6]}" if token
|
|
520
|
+
|
|
521
|
+
host_port
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
private
|
|
525
|
+
|
|
526
|
+
def offering_comparison_multiset(offerings)
|
|
527
|
+
Array(offerings).map { |draft| offering_comparison_state(draft) }.tally
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def offering_comparison_state(draft)
|
|
531
|
+
state = draft.to_h
|
|
532
|
+
state[:operation_evidence] = comparison_evidence_map(draft.operation_evidence)
|
|
533
|
+
state[:capability_evidence] = comparison_evidence_map(draft.capability_evidence)
|
|
534
|
+
%i[context_evidence max_output_evidence embedding_dimensions_evidence model_revision_evidence tokenizer_evidence].each do |field|
|
|
535
|
+
state[field] = draft.public_send(field).to_h.except(:observed_at)
|
|
536
|
+
end
|
|
537
|
+
state
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def comparison_evidence_map(evidence)
|
|
541
|
+
evidence.transform_values { |entry| entry.to_h.except(:observed_at) }
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def display_fact(instance_key:, snapshot:)
|
|
545
|
+
record = snapshot.instance(instance_key: instance_key)
|
|
546
|
+
if record
|
|
547
|
+
fact = record.availability
|
|
548
|
+
return { state: fact.state, reason: fact.reason, observed_at: fact.observed_at, last_probe_outcome: fact.last_probe_outcome, source: fact.source }
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
status = snapshot.publication_status(instance_key: instance_key)
|
|
552
|
+
{ state: :initializing, reason: status.last_error || 'instance initializing', observed_at: status.last_probe_completed_at, last_probe_outcome: status.last_probe_outcome,
|
|
553
|
+
source: :startup_readiness }
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def health_hash(fact)
|
|
557
|
+
{ state: fact[:state], reason: fact[:reason], observed_at: fact[:observed_at]&.iso8601, last_probe_outcome: fact[:last_probe_outcome], source: fact[:source] }
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def readiness_failure(error:)
|
|
561
|
+
Legion::Extensions::Llm::Inventory::ReadinessResult.new(
|
|
562
|
+
ready: false, reason: "#{health_path} failed (#{error.class.name})", metadata: { error_class: error.class.name }
|
|
563
|
+
)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def normalize_api_base(url)
|
|
567
|
+
url.to_s.sub(%r{/v1/?\z}, '')
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def extract_host_port(url:)
|
|
571
|
+
uri = URI.parse(url.to_s)
|
|
572
|
+
"#{uri.host || 'localhost'}:#{uri.port}"
|
|
573
|
+
rescue URI::InvalidURIError => e
|
|
574
|
+
handle_exception(e, level: :warn, handled: true, operation: "#{provider_family}.runner.discovery.extract_host_port", url: url.to_s)
|
|
575
|
+
raise
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
def build_connection(base_url:, instance_cfg:, timeout:, open_timeout:)
|
|
579
|
+
Faraday.new(url: base_url) do |f|
|
|
580
|
+
f.options.timeout = timeout
|
|
581
|
+
f.options.open_timeout = open_timeout
|
|
582
|
+
f.headers['Accept'] = 'application/json'
|
|
583
|
+
apply_auth_headers(faraday: f, instance_cfg: instance_cfg)
|
|
584
|
+
f.adapter Faraday.default_adapter
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Default auth: the bearer token. A provider with extra request
|
|
589
|
+
# headers (OpenAI org/project, ...) overrides this to add them.
|
|
590
|
+
def apply_auth_headers(faraday:, instance_cfg:)
|
|
591
|
+
token = auth_token(instance_cfg: instance_cfg)
|
|
592
|
+
faraday.headers['Authorization'] = "Bearer #{token}" if token
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
def finish_probe_safely(coordinator, request: nil)
|
|
596
|
+
coordinator.finish_probe(request: request)
|
|
597
|
+
rescue StandardError => e
|
|
598
|
+
handle_exception(e, level: :warn, operation: "#{provider_family}.runner.discovery.finish_probe")
|
|
599
|
+
end
|
|
600
|
+
end
|
|
601
|
+
end
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
end
|
|
@@ -42,20 +42,6 @@ module Legion
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# Backward-compatible unsupported-capability error alias.
|
|
46
|
-
class UnsupportedCapabilityError < Errors::UnsupportedCapability
|
|
47
|
-
def initialize(message = nil, provider: nil, capability: nil, model: nil)
|
|
48
|
-
if provider && capability
|
|
49
|
-
super(provider:, capability:, model:)
|
|
50
|
-
else
|
|
51
|
-
@provider = provider
|
|
52
|
-
@capability = capability
|
|
53
|
-
@model = model
|
|
54
|
-
StandardError.instance_method(:initialize).bind_call(self, message)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
45
|
# Error classes for different HTTP status codes
|
|
60
46
|
class BadRequestError < Error; end
|
|
61
47
|
class ForbiddenError < Error; end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Fleet
|
|
7
|
+
# Envelope/param/message-shape violations at the fleet worker (06 §5):
|
|
8
|
+
# non-Hash wire messages, duplicate param spellings, :model inside
|
|
9
|
+
# params, missing per-operation params, unknown operations. Contract
|
|
10
|
+
# violations are programming/contract errors — never retryable (F6).
|
|
11
|
+
class ContractError < StandardError; end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -6,15 +6,13 @@ module Legion
|
|
|
6
6
|
module Extensions
|
|
7
7
|
module Llm
|
|
8
8
|
module Fleet
|
|
9
|
-
# Shared validation helpers for strict fleet protocol
|
|
9
|
+
# Shared validation helpers for strict fleet protocol v3 envelopes.
|
|
10
10
|
module EnvelopeValidation
|
|
11
|
-
LEGACY_OPTIONS = %i[schema_version request_type fleet_correlation_id].freeze
|
|
12
|
-
|
|
13
11
|
private
|
|
14
12
|
|
|
15
13
|
def reject_legacy_options!
|
|
16
|
-
|
|
17
|
-
raise ArgumentError, "#{key} is not supported by fleet protocol
|
|
14
|
+
Fleet::Protocol::LEGACY_FIELDS.each do |key|
|
|
15
|
+
raise ArgumentError, "#{key} is not supported by fleet protocol v3" if @options.key?(key) || @options.key?(key.to_s)
|
|
18
16
|
end
|
|
19
17
|
end
|
|
20
18
|
|
|
@@ -24,8 +22,11 @@ module Legion
|
|
|
24
22
|
raise ArgumentError, "#{key} is required"
|
|
25
23
|
end
|
|
26
24
|
|
|
25
|
+
# P3: explicit version — a missing protocol_version raises; there is
|
|
26
|
+
# no default fill.
|
|
27
27
|
def require_protocol_version!
|
|
28
|
-
version = @options
|
|
28
|
+
version = @options[:protocol_version]
|
|
29
|
+
raise ArgumentError, 'protocol_version is required' if version.nil?
|
|
29
30
|
return if version == Fleet::Protocol::VERSION
|
|
30
31
|
|
|
31
32
|
raise ArgumentError, "protocol_version must be #{Fleet::Protocol::VERSION}"
|