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.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. 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 v2 envelopes.
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
- LEGACY_OPTIONS.each do |key|
17
- raise ArgumentError, "#{key} is not supported by fleet protocol v2" if @options.key?(key) || @options.key?(key.to_s)
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.fetch(:protocol_version, Fleet::Protocol::VERSION)
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}"