lex-llm-mlx 0.3.13 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'time'
5
+ require 'uri'
6
+ require 'faraday'
4
7
 
5
8
  begin
6
9
  require 'legion/extensions/actors/every'
@@ -8,27 +11,627 @@ rescue LoadError => e
8
11
  warn(e.message) if $VERBOSE
9
12
  end
10
13
 
11
- begin
12
- require 'legion/extensions/llm/inventory/scoped_refresher'
13
- rescue LoadError => e
14
- warn(e.message) if $VERBOSE
14
+ unless defined?(Legion::Extensions::Actors::Every)
15
+ raise LoadError, 'LegionIO actor runtime is required for MLX discovery refresh'
15
16
  end
16
17
 
17
- return unless defined?(Legion::Extensions::Actors::Every)
18
+ require 'legion/extensions/llm/inventory/publisher'
19
+ require 'legion/extensions/llm/inventory/scoped_refresher'
20
+ require 'legion/extensions/llm/inventory/identity'
21
+ require 'legion/extensions/llm/inventory/records'
22
+ require 'legion/extensions/llm/inventory/evidence'
23
+ require 'legion/extensions/llm/inventory/probe_coordinator'
24
+ require 'legion/extensions/llm/routing/provider_outcome'
25
+ require 'legion/extensions/llm/taxonomies'
26
+ require 'legion/extensions/llm/capabilities'
27
+ require 'legion/extensions/llm/mlx/provider'
18
28
 
19
29
  module Legion
20
30
  module Extensions
21
31
  module Llm
22
32
  module Mlx
23
33
  module Actor
24
- class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation, Metrics/ClassLength
25
- include Legion::Logging::Helper
34
+ # Evidence and offering-draft construction included by DiscoveryRefresh.
35
+ module EvidenceBuilding
36
+ EMBEDDING_PATTERN = /embed|bge|e5|nomic/i
37
+ # Protocol-required evidence source: the default_false taxonomy member.
38
+ UNKNOWN_EVIDENCE_SRC = :default_false
39
+
40
+ private
41
+
42
+ def embedding_model?(model_id:)
43
+ model_id.to_s.match?(EMBEDDING_PATTERN)
44
+ end
26
45
 
27
- if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
28
- include Legion::Extensions::Llm::Inventory::ScopedRefresher
46
+ def build_offering_draft(model_id:, model_data:, instance_cfg:, instance_key:)
47
+ tier = instance_cfg[:tier] || :local
48
+ embed_supported = embedding_model?(model_id: model_id)
49
+
50
+ Legion::Extensions::Llm::Inventory::OfferingDraft.new(
51
+ provider_native_key: model_id,
52
+ model: model_id,
53
+ tier: tier,
54
+ operation_evidence: build_operation_evidence(embed_supported: embed_supported, model_id: model_id),
55
+ capability_evidence: build_capability_evidence(model_id: model_id),
56
+ context_evidence: build_context_evidence(model_data: model_data),
57
+ max_output_evidence: build_max_output_evidence(model_data: model_data),
58
+ embedding_dimensions_evidence: build_embedding_dimensions_evidence(
59
+ model_data: model_data, embed_supported: embed_supported
60
+ ),
61
+ model_revision_evidence: absent_value_evidence,
62
+ tokenizer_evidence: absent_value_evidence,
63
+ quota_domains: {},
64
+ metadata: build_offering_metadata(model_data: model_data, instance_key: instance_key),
65
+ publication_source: :provider_catalog
66
+ )
67
+ end
68
+
69
+ def build_operation_evidence(embed_supported:, **)
70
+ now = Time.now.freeze
71
+ is_embedding = embed_supported
72
+ {
73
+ chat: op_evidence(operation: :chat, status: is_embedding ? :unsupported : :supported, observed_at: now),
74
+ stream_chat: op_evidence(operation: :stream_chat, status: is_embedding ? :unsupported : :supported,
75
+ observed_at: now),
76
+ embed: op_evidence(operation: :embed, status: is_embedding ? :supported : :unsupported,
77
+ observed_at: now),
78
+ image: op_evidence(operation: :image, status: :unsupported, observed_at: now),
79
+ transcribe: op_evidence(operation: :transcribe, status: :unsupported, observed_at: now),
80
+ translate: op_evidence(operation: :translate, status: :unsupported, observed_at: now),
81
+ speak: op_evidence(operation: :speak, status: :unsupported, observed_at: now),
82
+ moderate: op_evidence(operation: :moderate, status: :unsupported, observed_at: now),
83
+ count_tokens: op_evidence(operation: :count_tokens, status: :unknown, observed_at: now)
84
+ }
29
85
  end
30
86
 
31
- def self.every_seconds = 60
87
+ def op_evidence(operation:, status:, observed_at:)
88
+ source = status == :unknown ? UNKNOWN_EVIDENCE_SRC : :provider_implementation
89
+ Legion::Extensions::Llm::Inventory::OperationEvidence.new(
90
+ operation: operation, status: status, source: source, observed_at: observed_at
91
+ )
92
+ end
93
+
94
+ def build_capability_evidence(model_id:)
95
+ is_embedding = embedding_model?(model_id: model_id)
96
+ caps = {
97
+ completion: cap_evidence(capability: :completion,
98
+ status: is_embedding ? :unsupported : :supported,
99
+ source: :provider_implementation),
100
+ streaming: cap_evidence(capability: :streaming,
101
+ status: is_embedding ? :unsupported : :supported,
102
+ source: :provider_implementation),
103
+ tools: cap_evidence(capability: :tools, status: :unknown, source: UNKNOWN_EVIDENCE_SRC),
104
+ thinking: cap_evidence(capability: :thinking, status: :unknown, source: UNKNOWN_EVIDENCE_SRC)
105
+ }
106
+
107
+ if is_embedding
108
+ caps[:embedding] = cap_evidence(
109
+ capability: :embedding, status: :supported, source: :provider_implementation
110
+ )
111
+ end
112
+
113
+ caps
114
+ end
115
+
116
+ def cap_evidence(capability:, status:, source:)
117
+ Legion::Extensions::Llm::Inventory::CapabilityEvidence.new(
118
+ capability: capability, status: status, source: source, observed_at: Time.now.freeze
119
+ )
120
+ end
121
+ end
122
+
123
+ # Value-level evidence builders (context, output, dimensions, metadata) — included by DiscoveryRefresh.
124
+ module ValueEvidenceBuilding
125
+ private
126
+
127
+ def build_context_evidence(model_data:)
128
+ ctx = model_data[:max_model_len] || model_data[:context_length]
129
+ if ctx.is_a?(Integer) && ctx.positive?
130
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(
131
+ status: :known, value: ctx, source: :provider_catalog
132
+ )
133
+ else
134
+ absent_value_evidence
135
+ end
136
+ end
137
+
138
+ def build_max_output_evidence(model_data:)
139
+ max_out = model_data[:max_output_tokens] || model_data[:max_completion_tokens]
140
+ if max_out.is_a?(Integer) && max_out.positive?
141
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(
142
+ status: :known, value: max_out, source: :provider_catalog
143
+ )
144
+ else
145
+ absent_value_evidence
146
+ end
147
+ end
148
+
149
+ def build_embedding_dimensions_evidence(model_data:, embed_supported:)
150
+ unless embed_supported
151
+ return Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
152
+ end
153
+
154
+ dims = model_data[:embedding_dimensions]
155
+ if dims.is_a?(Array) && !dims.empty? && dims.all? { |d| d.is_a?(Integer) && d.positive? }
156
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(
157
+ status: :known, value: dims.uniq.sort, source: :provider_catalog
158
+ )
159
+ else
160
+ absent_value_evidence
161
+ end
162
+ end
163
+
164
+ def absent_value_evidence
165
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
166
+ end
167
+
168
+ def build_offering_metadata(model_data:, instance_key:)
169
+ meta = { raw_model: model_data[:id].to_s }
170
+ meta[:parameter_count] = model_data[:parameter_count] if model_data[:parameter_count]
171
+ meta[:quantization] = model_data[:quantization].to_s if model_data[:quantization]
172
+ meta[:instance_id] = instance_key.instance_id
173
+ meta
174
+ end
175
+ end
176
+
177
+ # Model-discovery and offering-assembly — included by DiscoveryRefresh.
178
+ #
179
+ # Rescue discipline (D16): only network and response-parse errors
180
+ # are runtime conditions that may yield no offerings — an
181
+ # unreachable /v1/models is a probe outcome, not a bug. Programming
182
+ # errors (NameError/NoMethodError/ArgumentError) are NOT rescued
183
+ # here: converting them to [] would publish zero offerings and make
184
+ # a healthy instance invisible. They propagate to the per-instance
185
+ # isolation in the tick, which logs and retries next tick.
186
+ module OfferingAssembly
187
+ private
188
+
189
+ def discover_offerings_for_instance(instance_cfg:, instance_key:)
190
+ fetch_models(instance_cfg: instance_cfg).filter_map do |model_data|
191
+ next unless model_data.is_a?(Hash)
192
+
193
+ model_id = model_data[:id].to_s
194
+ next if model_id.empty?
195
+
196
+ build_offering_draft(
197
+ model_id: model_id, model_data: model_data,
198
+ instance_cfg: instance_cfg, instance_key: instance_key
199
+ )
200
+ end
201
+ end
202
+
203
+ def fetch_models(instance_cfg:)
204
+ base_url = normalize_api_base(instance_cfg[:mlx_api_base] || instance_cfg[:endpoint])
205
+ conn = build_api_connection(base_url: base_url, instance_cfg: instance_cfg)
206
+ parsed = Legion::JSON.load(conn.get('/v1/models').body)
207
+ data = parsed.is_a?(Hash) ? parsed[:data] : nil
208
+ data.is_a?(Array) ? data : []
209
+ rescue Faraday::Error, Legion::JSON::ParseError => e
210
+ handle_exception(e, level: :warn, handled: true, operation: 'mlx.actor.fetch_models')
211
+ []
212
+ end
213
+ end
214
+
215
+ # Health checking and readiness probe lifecycle — included by DiscoveryRefresh.
216
+ module HealthProbing
217
+ private
218
+
219
+ def check_health(instance_cfg:)
220
+ base_url = normalize_api_base(instance_cfg[:mlx_api_base] || instance_cfg[:endpoint])
221
+ conn = build_health_connection(base_url: base_url, instance_cfg: instance_cfg)
222
+ response = conn.get('/health')
223
+ build_readiness_from_response(response: response, base_url: base_url)
224
+ rescue Faraday::ConnectionFailed => e
225
+ handle_exception(e, level: :warn, handled: true, operation: 'mlx.actor.check_health')
226
+ readiness_failure(reason: "MLX /health connection failed: #{e.message}", error: e)
227
+ rescue StandardError => e
228
+ handle_exception(e, level: :warn, handled: true, operation: 'mlx.actor.check_health')
229
+ readiness_failure(reason: "MLX /health error: #{e.message}", error: e)
230
+ end
231
+
232
+ def build_readiness_from_response(response:, base_url:)
233
+ Legion::Extensions::Llm::Inventory::ReadinessResult.new(
234
+ ready: response.status == 200,
235
+ reason: "MLX /health returned #{response.status}",
236
+ metadata: { status: response.status, base_url: base_url }
237
+ )
238
+ end
239
+
240
+ def readiness_failure(reason:, error:)
241
+ Legion::Extensions::Llm::Inventory::ReadinessResult.new(
242
+ ready: false, reason: reason,
243
+ metadata: { error_class: error.class.name }
244
+ )
245
+ end
246
+
247
+ def run_cadence_probe(instance_id:, state:)
248
+ coordinator = state[:probe_coordinator]
249
+ return unless coordinator.begin_probe
250
+
251
+ probe_token = publisher.readiness_probe_started(
252
+ instance_id: instance_id, physical_id: state[:physical_id],
253
+ publisher_token: state[:publisher_token]
254
+ )
255
+ readiness = check_health(instance_cfg: state[:instance_cfg])
256
+ coordinator.finish_probe
257
+ report_probe_result(
258
+ instance_id: instance_id, physical_id: state[:physical_id],
259
+ probe_token: probe_token, readiness: readiness
260
+ )
261
+ rescue StandardError => e
262
+ begin
263
+ coordinator&.finish_probe
264
+ rescue StandardError => finish_err
265
+ handle_exception(finish_err, level: :warn, operation: 'mlx.actor.finish_probe')
266
+ end
267
+ handle_exception(e, level: :warn, operation: 'mlx.actor.cadence_probe', instance_id: instance_id)
268
+ end
269
+
270
+ def handle_reactive_probe(instance_id:, request:)
271
+ state = @instance_states[instance_id]
272
+ return unless state
273
+
274
+ coordinator = state[:probe_coordinator]
275
+ return unless coordinator.begin_probe(request: request)
276
+
277
+ probe_token = publisher.readiness_probe_started(
278
+ instance_id: instance_id, physical_id: state[:physical_id],
279
+ publisher_token: state[:publisher_token]
280
+ )
281
+ readiness = check_health(instance_cfg: state[:instance_cfg])
282
+ coordinator.finish_probe(request: request)
283
+ report_probe_result(
284
+ instance_id: instance_id, physical_id: state[:physical_id],
285
+ probe_token: probe_token, readiness: readiness
286
+ )
287
+ rescue StandardError => e
288
+ begin
289
+ coordinator&.finish_probe(request: request)
290
+ rescue StandardError => finish_err
291
+ handle_exception(finish_err, level: :warn, operation: 'mlx.actor.finish_probe')
292
+ end
293
+ handle_exception(e, level: :warn, operation: 'mlx.actor.reactive_probe', instance_id: instance_id)
294
+ end
295
+
296
+ def report_probe_result(instance_id:, physical_id:, probe_token:, readiness:)
297
+ if readiness.ready?
298
+ publisher.readiness_succeeded(
299
+ instance_id: instance_id, physical_id: physical_id, probe_token: probe_token
300
+ )
301
+ else
302
+ publisher.readiness_failed(
303
+ instance_id: instance_id, physical_id: physical_id, probe_token: probe_token, reason: readiness.reason
304
+ )
305
+ end
306
+ end
307
+
308
+ def build_probe_enqueue(instance_id:)
309
+ proc do |request:|
310
+ handle_reactive_probe(instance_id: instance_id, request: request)
311
+ true
312
+ rescue StandardError => e
313
+ handle_exception(e, level: :warn, operation: 'mlx.actor.probe_enqueue', instance_id: instance_id)
314
+ false
315
+ end
316
+ end
317
+ end
318
+
319
+ # Periodic refresh cycle — included by DiscoveryRefresh. Each tick
320
+ # re-scans the configured instances (late configuration appears
321
+ # without a restart; removed instances are reconciled out),
322
+ # re-activates instances still initializing after an initial
323
+ # readiness failure, and refreshes offerings + cadence probes for
324
+ # activated instances.
325
+ module TickCycle
326
+ private
327
+
328
+ def tick_refresh
329
+ @instance_states ||= {}
330
+ configured_ids = {}
331
+ configured_instances.each do |name, instance_cfg|
332
+ # Identity is the operator's CONFIG NAME — the key the
333
+ # frozen config uses and the router keys instances.<name>
334
+ # settings lookups by. Two names at the same endpoint stay
335
+ # distinct instances (the physical id is secondary).
336
+ instance_id = name.to_s
337
+ configured_ids[instance_id] = true
338
+ state = @instance_states[instance_id]
339
+ if state.nil?
340
+ claim_and_activate_instance(name: name, instance_cfg: instance_cfg)
341
+ else
342
+ refresh_instance(instance_id: instance_id, name: name, state: state)
343
+ end
344
+ rescue StandardError => e
345
+ handle_exception(e, level: :warn, operation: 'mlx.actor.tick_refresh', instance_name: name.to_s)
346
+ end
347
+
348
+ remove_unconfigured_instances(configured_ids: configured_ids)
349
+ end
350
+
351
+ def remove_unconfigured_instances(configured_ids:)
352
+ @instance_states.each do |instance_id, state|
353
+ next if configured_ids.key?(instance_id)
354
+
355
+ remove_instance_state(instance_id: instance_id, state: state)
356
+ rescue StandardError => e
357
+ handle_exception(e, level: :warn, operation: 'mlx.actor.remove_instance_state',
358
+ instance_id: instance_id)
359
+ end
360
+ end
361
+
362
+ def remove_instance_state(instance_id:, state:)
363
+ publisher.remove_instance(
364
+ instance_id: instance_id, physical_id: state[:physical_id],
365
+ publisher_token: state[:publisher_token]
366
+ )
367
+ clear_instance_health(config_name: state[:name])
368
+ @instance_states.delete(instance_id)
369
+ end
370
+
371
+ def refresh_instance(instance_id:, name:, state:)
372
+ status = publisher.snapshot.publication_status(instance_key: state[:instance_key])
373
+ if status.state == :initializing
374
+ reactivate_if_ready(instance_id: instance_id, name: name, state: state)
375
+ return
376
+ end
377
+
378
+ replace_offerings_if_changed(instance_id: instance_id, state: state)
379
+ run_cadence_probe(instance_id: instance_id, state: state)
380
+ write_instance_health(config_name: name, state: state)
381
+ end
382
+
383
+ def replace_offerings_if_changed(instance_id:, state:)
384
+ new_offerings = discover_offerings_for_instance(
385
+ instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
386
+ )
387
+ return if new_offerings == state[:offerings]
388
+
389
+ state[:sequence] += 1
390
+ publisher.replace_instance_snapshot(
391
+ instance_id: instance_id, physical_id: state[:physical_id],
392
+ publisher_token: state[:publisher_token],
393
+ offerings: new_offerings, sequence: state[:sequence]
394
+ )
395
+ state[:offerings] = new_offerings
396
+ end
397
+
398
+ # Initial-failure recovery: an instance stuck at :initializing
399
+ # (readiness failed at boot, e.g. transient outage) re-activates
400
+ # on the first healthy probe. While :initializing,
401
+ # replace_instance_snapshot and readiness_succeeded are invalid
402
+ # transitions — activate_instance_snapshot is the only legal
403
+ # commit, so the cadence probe path is not usable here.
404
+ def reactivate_if_ready(instance_id:, name:, state:)
405
+ offerings = discover_offerings_for_instance(
406
+ instance_cfg: state[:instance_cfg], instance_key: state[:instance_key]
407
+ )
408
+ probe_token = publisher.readiness_probe_started(
409
+ instance_id: instance_id, publisher_token: state[:publisher_token]
410
+ )
411
+ readiness = check_health(instance_cfg: state[:instance_cfg])
412
+ commit_readiness(instance_id: instance_id, offerings: offerings,
413
+ probe_token: probe_token, readiness: readiness, state: state)
414
+ write_instance_health(config_name: name, state: state)
415
+ end
416
+
417
+ # Shared commit for initial and recovery activation: on a
418
+ # healthy probe activate the snapshot (the only legal commit
419
+ # from :initializing), otherwise record the failed readiness.
420
+ # The sequence is the instance state's sequence (0 until the
421
+ # first replace after activation).
422
+ def commit_readiness(instance_id:, offerings:, probe_token:, readiness:, state:)
423
+ if readiness.ready?
424
+ publisher.activate_instance_snapshot(
425
+ instance_id: instance_id, physical_id: state[:physical_id],
426
+ publisher_token: state[:publisher_token],
427
+ offerings: offerings, sequence: state[:sequence], probe_token: probe_token
428
+ )
429
+ state[:offerings] = offerings
430
+ else
431
+ publisher.readiness_failed(
432
+ instance_id: instance_id, physical_id: state[:physical_id],
433
+ probe_token: probe_token, reason: readiness.reason
434
+ )
435
+ end
436
+ end
437
+ end
438
+
439
+ # Instance configuration, ID derivation and settings — included by DiscoveryRefresh.
440
+ module InstanceConfig
441
+ private
442
+
443
+ def settings
444
+ Legion::Settings.dig(:extensions, :llm, :mlx) || {}
445
+ end
446
+
447
+ def configured_instances
448
+ Legion::Extensions::Llm::Mlx.configured_instances
449
+ end
450
+
451
+ # The SECONDARY physical id (host:port, or host:port/ak:<fp>
452
+ # when the instance is keyed). It is carried by InstanceKey
453
+ # for dedup and diagnostics only — never identity. Identity
454
+ # is the operator's config name (see tick_refresh).
455
+ def derive_physical_id(instance_cfg:)
456
+ base_url = instance_cfg[:mlx_api_base] || instance_cfg[:endpoint] || 'http://localhost:8000'
457
+ host_port = extract_host_port(url: base_url)
458
+ api_key = instance_cfg[:mlx_api_key] || instance_cfg.dig(:credentials, :api_key)
459
+
460
+ if api_key.is_a?(String) && !api_key.strip.empty?
461
+ fingerprint = ::Digest::SHA256.hexdigest(api_key)[0, 6]
462
+ "#{host_port}/ak:#{fingerprint}"
463
+ else
464
+ host_port
465
+ end
466
+ end
467
+
468
+ def extract_host_port(url:)
469
+ uri = URI.parse(url.to_s)
470
+ host = uri.host || 'localhost'
471
+ port = uri.port
472
+ "#{host}:#{port}"
473
+ rescue URI::InvalidURIError => e
474
+ handle_exception(e, level: :warn, operation: 'mlx.actor.extract_host_port', url: url.to_s)
475
+ raise
476
+ end
477
+
478
+ def build_instance_key(instance_id:, physical_id:)
479
+ Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(
480
+ provider_family: :mlx, instance_id: instance_id, physical_id: physical_id
481
+ )
482
+ end
483
+
484
+ def build_probe_coordinator(instance_id:, instance_key:)
485
+ Legion::Extensions::Llm::Inventory::ProbeCoordinator.new(
486
+ instance_key: instance_key,
487
+ enqueue: build_probe_enqueue(instance_id: instance_id)
488
+ )
489
+ end
490
+
491
+ def build_instance_state(**attrs)
492
+ attrs.merge(sequence: 0)
493
+ end
494
+ end
495
+
496
+ # Display-only health/capabilities written into the settings tree
497
+ # after each registry commit. Routing authority stays in the
498
+ # in-memory Registry; this hash exists so the status API
499
+ # (legion-llm /api/llm/providers) renders per-instance health.
500
+ # Keyed by the operator's config name, not the derived instance_id.
501
+ module HealthDisplay
502
+ HEALTH_SOURCE = :provider_probe
503
+
504
+ private
505
+
506
+ def write_instance_health(config_name:, state:)
507
+ instance_settings = settings[:instances]
508
+ return unless instance_settings.is_a?(Hash) && instance_settings[config_name].is_a?(Hash)
509
+
510
+ instance_settings[config_name][:health] = build_health_hash(state: state)
511
+ instance_settings[config_name][:capabilities] = build_display_capabilities(state: state)
512
+ rescue StandardError => e
513
+ handle_exception(e, level: :warn, operation: 'mlx.actor.write_instance_health',
514
+ instance_name: config_name.to_s)
515
+ end
516
+
517
+ def clear_instance_health(config_name:)
518
+ instance_settings = settings[:instances]
519
+ return unless instance_settings.is_a?(Hash) && instance_settings[config_name].is_a?(Hash)
520
+
521
+ instance_settings[config_name].delete(:health)
522
+ instance_settings[config_name].delete(:capabilities)
523
+ rescue StandardError => e
524
+ handle_exception(e, level: :warn, operation: 'mlx.actor.clear_instance_health',
525
+ instance_name: config_name.to_s)
526
+ end
527
+
528
+ def build_health_hash(state:)
529
+ instance_key = state[:instance_key]
530
+ status = publisher.snapshot.publication_status(instance_key: instance_key)
531
+ availability = publisher.snapshot.instance(instance_key: instance_key)&.availability
532
+ {
533
+ circuit_state: health_circuit_state(availability),
534
+ denied: false,
535
+ available: health_available?(availability),
536
+ adjustment: health_adjustment(availability),
537
+ reason: health_display_reason(status: status, availability: availability),
538
+ observed_at: health_observed_at(status: status, availability: availability),
539
+ last_probe_outcome: status.last_probe_outcome,
540
+ source: HEALTH_SOURCE
541
+ }
542
+ end
543
+
544
+ def health_available?(availability)
545
+ !availability.nil? && availability.state == :available
546
+ end
547
+
548
+ def health_circuit_state(availability)
549
+ health_available?(availability) ? :closed : :open
550
+ end
551
+
552
+ def health_adjustment(availability)
553
+ health_available?(availability) ? 0 : -50
554
+ end
555
+
556
+ def health_observed_at(status:, availability:)
557
+ # getutc (not utc): the registry freezes its Time objects, and
558
+ # Time#utc mutates the receiver in place.
559
+ (availability&.observed_at || status.last_probe_completed_at || Time.now).getutc.iso8601
560
+ end
561
+
562
+ def health_display_reason(status:, availability:)
563
+ return availability.reason if availability&.reason
564
+ return status.last_error if status.last_error
565
+
566
+ 'awaiting initial readiness'
567
+ end
568
+
569
+ def build_display_capabilities(state:)
570
+ state[:offerings].each_with_object(Hash.new(false)) do |draft, supported|
571
+ draft.capability_evidence.each do |capability, evidence|
572
+ supported[capability] = true if evidence.supported?
573
+ end
574
+ end.keys.sort
575
+ end
576
+ end
577
+
578
+ # HTTP connection builders — included by DiscoveryRefresh.
579
+ module HttpConnections
580
+ private
581
+
582
+ def normalize_api_base(url)
583
+ (url || 'http://localhost:8000').to_s.sub(%r{/v1/?\z}, '')
584
+ end
585
+
586
+ def build_health_connection(base_url:, instance_cfg:)
587
+ Faraday.new(url: base_url) do |f|
588
+ f.options.timeout = 5
589
+ f.options.open_timeout = 3
590
+ apply_auth_header(faraday: f, instance_cfg: instance_cfg)
591
+ f.adapter Faraday.default_adapter
592
+ end
593
+ end
594
+
595
+ def build_api_connection(base_url:, instance_cfg:)
596
+ Faraday.new(url: base_url) do |f|
597
+ f.options.timeout = 15
598
+ f.options.open_timeout = 5
599
+ f.headers['Accept'] = 'application/json'
600
+ apply_auth_header(faraday: f, instance_cfg: instance_cfg)
601
+ f.adapter Faraday.default_adapter
602
+ end
603
+ end
604
+
605
+ def apply_auth_header(faraday:, instance_cfg:)
606
+ api_key = instance_cfg[:mlx_api_key] || instance_cfg.dig(:credentials, :api_key)
607
+ return unless api_key.is_a?(String) && !api_key.strip.empty?
608
+
609
+ faraday.headers['Authorization'] = "Bearer #{api_key}"
610
+ end
611
+ end
612
+
613
+ # SSOT v3 periodic discovery actor for MLX provider instances.
614
+ # Claims configured instances, discovers models via /v1/models,
615
+ # probes health via /health, and publishes complete OfferingDraft
616
+ # snapshots through the Inventory::Publisher. Supports coalesced
617
+ # reactive probes after dispatch-triggered instance_unavailable
618
+ # transitions.
619
+ class DiscoveryRefresh < Legion::Extensions::Actors::Every
620
+ include Legion::Logging::Helper
621
+ include EvidenceBuilding
622
+ include ValueEvidenceBuilding
623
+ include OfferingAssembly
624
+ include HealthProbing
625
+ include TickCycle
626
+ include InstanceConfig
627
+ include HealthDisplay
628
+ include HttpConnections
629
+
630
+ # Mirrors the registered lex-llm default
631
+ # (discovery.interval_seconds); used only when the settings tree
632
+ # has no discovery section. time must never return nil — a
633
+ # TimerTask with a nil interval fires exactly once and stops.
634
+ DEFAULT_DISCOVERY_INTERVAL_SECONDS = 300
32
635
 
33
636
  def runner_class = self.class
34
637
  def runner_function = 'manual'
@@ -38,129 +641,220 @@ module Legion
38
641
  def generate_task? = false
39
642
 
40
643
  def time
41
- return self.class.every_seconds unless defined?(Legion::Settings)
644
+ interval = settings.dig(:discovery, :interval_seconds)
645
+ interval.is_a?(Integer) && interval.positive? ? interval : DEFAULT_DISCOVERY_INTERVAL_SECONDS
646
+ end
42
647
 
43
- Legion::Settings.dig(:extensions, :llm, :mlx, :discovery_interval) || self.class.every_seconds
648
+ def manual
649
+ tick_refresh
650
+ rescue StandardError => e
651
+ handle_exception(e, level: :warn, operation: 'mlx.actor.discovery_refresh')
44
652
  end
45
653
 
46
- def scope_key(**)
47
- { provider: :mlx }
654
+ def shutdown
655
+ remove_all_instances
656
+ rescue StandardError => e
657
+ handle_exception(e, level: :warn, operation: 'mlx.actor.discovery_refresh.shutdown')
48
658
  end
49
659
 
50
- def compute_lanes_for_scope(**)
51
- return [] unless defined?(Legion::LLM::Call::Registry)
660
+ private
52
661
 
53
- mlx_instances.flat_map { |entry| lanes_for_instance(entry) }
54
- rescue StandardError => e
55
- handle_exception(e, level: :warn, handled: true, operation: 'mlx.discovery_refresh.compute_lanes')
56
- []
662
+ def publisher
663
+ @publisher ||= Legion::Extensions::Llm::Inventory::Publisher.new(
664
+ provider_family: :mlx,
665
+ compatibility_adapter: Legion::Extensions::Llm::Inventory::ScopedRefresher::LegacyCoordinatorAdapter.new(
666
+ provider_family: :mlx
667
+ )
668
+ )
57
669
  end
58
670
 
59
- def credential_hash(**)
60
- mlx_settings = Legion::Settings.dig(:extensions, :llm, :mlx) || {}
61
- Digest::SHA256.hexdigest(mlx_settings[:api_key].to_s + mlx_settings[:instances].to_s)[0, 16]
671
+ def claim_and_activate_instance(name:, instance_cfg:)
672
+ instance_id = name.to_s
673
+ physical_id = derive_physical_id(instance_cfg: instance_cfg)
674
+ instance_key = build_instance_key(instance_id: instance_id, physical_id: physical_id)
675
+ callable = Legion::Extensions::Llm::Mlx::Actor::MlxCallable.new(instance_cfg: instance_cfg, logger: log)
676
+ probe_coordinator = build_probe_coordinator(instance_id: instance_id, instance_key: instance_key)
677
+ publisher_token = publisher.claim_instance(
678
+ instance_id: instance_id, physical_id: physical_id, callable: callable,
679
+ probe_request_handle: probe_coordinator
680
+ )
681
+ run_activation(
682
+ instance_id: instance_id, publisher_token: publisher_token,
683
+ instance_desc: { name: name, instance_id: instance_id, physical_id: physical_id,
684
+ instance_key: instance_key, instance_cfg: instance_cfg,
685
+ callable: callable, probe_coordinator: probe_coordinator }
686
+ )
62
687
  end
63
688
 
64
- def manual(**)
65
- tick if respond_to?(:tick)
66
- rescue StandardError => e
67
- handle_exception(e, level: :warn, handled: true, operation: 'mlx.actor.discovery_refresh')
689
+ def run_activation(instance_id:, publisher_token:, instance_desc:)
690
+ instance_cfg = instance_desc[:instance_cfg]
691
+ instance_key = instance_desc[:instance_key]
692
+ offerings = discover_offerings_for_instance(instance_cfg: instance_cfg, instance_key: instance_key)
693
+ probe_token = publisher.readiness_probe_started(instance_id: instance_id,
694
+ publisher_token: publisher_token)
695
+ readiness = check_health(instance_cfg: instance_cfg)
696
+ state = build_instance_state(
697
+ **instance_desc, publisher_token: publisher_token, offerings: offerings
698
+ )
699
+ commit_readiness(instance_id: instance_id, offerings: offerings,
700
+ probe_token: probe_token, readiness: readiness, state: state)
701
+ @instance_states[instance_id] = state
702
+ write_instance_health(config_name: instance_desc[:name], state: state)
68
703
  end
69
704
 
70
- private
705
+ def remove_all_instances
706
+ return unless @instance_states
71
707
 
72
- def mlx_instances
73
- Legion::LLM::Call::Registry.all_instances.select do |e|
74
- (e[:provider] || '').to_sym == :mlx
708
+ @instance_states.each do |instance_id, state|
709
+ publisher.remove_instance(
710
+ instance_id: instance_id, physical_id: state[:physical_id],
711
+ publisher_token: state[:publisher_token]
712
+ )
713
+ clear_instance_health(config_name: state[:name])
714
+ rescue StandardError => e
715
+ handle_exception(e, level: :warn, operation: 'mlx.actor.remove_instance',
716
+ instance_id: instance_id)
75
717
  end
718
+ @instance_states.clear
76
719
  end
720
+ end
77
721
 
78
- def offerings_for(adapter, instance_id)
79
- Array(adapter.discover_offerings(live: true))
80
- rescue StandardError => e
81
- handle_exception(e, level: :warn, handled: true,
82
- operation: 'mlx.discovery_refresh.discover_offerings',
83
- instance: instance_id)
84
- []
722
+ # Callable wrapper for an MLX provider instance. It is the
723
+ # exact-execution dispatch target: it implements the fleet dispatch
724
+ # operations (chat, stream_chat, embed, count_tokens) by delegating
725
+ # to a per-instance Mlx::Provider built from the instance config,
726
+ # plus the `disconnect` and `normalize_dispatch_error(error:)`
727
+ # contracts required by Inventory::CallableHandle and
728
+ # Routing::ProviderOutcome. Provider and Faraday errors are NOT
729
+ # rescued here so the coordinator's normalize_dispatch_error can
730
+ # classify them.
731
+ class MlxCallable
732
+ # Keys the base Provider exposes as named kwargs for the
733
+ # completion operations. Anything else the fleet passes is folded
734
+ # into the payload `params` hash.
735
+ COMPLETION_NAMED_KEYS = %i[tools temperature schema thinking tool_prefs headers].freeze
736
+ EMBED_NAMED_KEYS = %i[dimensions headers].freeze
737
+
738
+ def initialize(instance_cfg:, logger:)
739
+ @instance_cfg = instance_cfg
740
+ @logger = logger
741
+ @disconnected = false
742
+ @inference_calls = 0
85
743
  end
86
744
 
87
- def lanes_for_instance(entry)
88
- adapter = entry[:adapter]
89
- instance_id = entry[:instance] || entry[:instance_id] || entry[:id]
90
- return [] unless adapter.respond_to?(:discover_offerings)
745
+ def call_count
746
+ @inference_calls
747
+ end
91
748
 
92
- offerings_for(adapter, instance_id).filter_map do |raw_offering|
93
- offering = offering_to_hash(raw_offering)
94
- next unless offering
749
+ def disconnected?
750
+ @disconnected
751
+ end
95
752
 
96
- build_lanes(offering, instance_id)
97
- end.flatten
753
+ def disconnect
754
+ @disconnected = true
755
+ @provider&.disconnect
756
+ @logger.debug { '[mlx][callable] disconnected' }
98
757
  end
99
758
 
100
- def offering_to_hash(offering)
101
- return nil if offering.nil?
102
- return offering if offering.is_a?(Hash)
759
+ # ── Fleet dispatch operations ───────────────────────────────────
103
760
 
104
- hash = offering.to_h
105
- hash[:type] ||= hash[:usage_type]
106
- hash[:enabled] = offering.respond_to?(:enabled?) ? offering.enabled? : true
107
- hash
761
+ def chat(messages:, model:, **rest)
762
+ record_inference
763
+ named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
764
+ provider.chat(messages: messages, model: model_info(model), params: params, **named)
108
765
  end
109
766
 
110
- def build_lanes(offering, instance_id)
111
- type = offering_type(offering[:type])
112
- tier = offering[:tier] || :local
113
- lane = build_lane(offering, instance_id, type, tier)
114
- lanes = [lane]
115
- lanes << fleet_lane(lane, instance_id, type) if fleet_enabled? && type == :inference
116
- lanes
767
+ def stream_chat(messages:, model:, **rest, &)
768
+ record_inference
769
+ named, params = split_fleet_kwargs(rest, COMPLETION_NAMED_KEYS)
770
+ provider.stream_chat(messages: messages, model: model_info(model), params: params, **named, &)
117
771
  end
118
772
 
119
- def build_lane(offering, instance_id, type, tier)
120
- {
121
- id: compose_lane_id(tier: tier, instance_id: instance_id,
122
- type: type, model: offering[:model]),
123
- tier: tier,
124
- provider_family: :mlx,
125
- instance_id: instance_id,
126
- model: offering[:model],
127
- canonical_model_alias: offering[:canonical_model_alias],
128
- type: type,
129
- capabilities: normalize_capabilities(offering[:capabilities]),
130
- limits: offering[:limits] || {},
131
- enabled: offering.fetch(:enabled, true),
132
- cost: offering[:cost] || {}
133
- }
773
+ def embed(text:, model:, **rest)
774
+ record_inference
775
+ named, params = split_fleet_kwargs(rest, EMBED_NAMED_KEYS)
776
+ provider.embed(text: text, model: model_info(model), params: params, **named)
134
777
  end
135
778
 
136
- def fleet_lane(lane, instance_id, type)
137
- lane.merge(
138
- tier: :fleet,
139
- id: compose_lane_id(tier: :fleet, instance_id: instance_id,
140
- type: type, model: lane[:model])
141
- )
779
+ def count_tokens(messages:, model:, **rest)
780
+ record_inference
781
+ _named, params = split_fleet_kwargs(rest, [])
782
+ provider.count_tokens(messages: messages, model: model, params: params)
142
783
  end
143
784
 
144
- def offering_type(raw)
145
- %i[embed embedding].include?(raw.to_s.to_sym) ? :embedding : :inference
785
+ def normalize_dispatch_error(error:)
786
+ reason = error.message.to_s[0, 512]
787
+ kind = classify_error_kind(error: error)
788
+
789
+ Legion::Extensions::Llm::Routing::ProviderOutcome.new(
790
+ kind: kind,
791
+ reason: reason.empty? ? 'unknown dispatch error' : reason
792
+ )
146
793
  end
147
794
 
148
- def normalize_capabilities(caps)
149
- return [] unless defined?(Legion::LLM::Inventory::Capabilities)
795
+ private
796
+
797
+ def record_inference
798
+ @inference_calls += 1
799
+ end
150
800
 
151
- Legion::LLM::Inventory::Capabilities.normalize(caps)
801
+ def provider
802
+ @provider ||= Legion::Extensions::Llm::Mlx::Provider.new(@instance_cfg)
152
803
  end
153
804
 
154
- def compose_lane_id(tier:, instance_id:, type:, model:)
155
- Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(
156
- tier: tier, provider_family: :mlx, instance_id: instance_id,
157
- type: type, model: model
805
+ # The fleet passes the model as a bare string; the base Provider's
806
+ # payload renderer needs a Model::Info (model.id). Wrap strings
807
+ # only pass through anything already carrying model identity.
808
+ def model_info(model)
809
+ return model if model.respond_to?(:id)
810
+
811
+ Legion::Extensions::Llm::Model::Info.new(
812
+ id: model.to_s, provider: Legion::Extensions::Llm::Mlx::PROVIDER_FAMILY
158
813
  )
159
814
  end
160
815
 
161
- def fleet_enabled?
162
- mlx_settings = Legion::Settings.dig(:extensions, :llm, :mlx) || {}
163
- mlx_settings.dig(:fleet, :dispatch, :enabled)
816
+ # Split the fleet's **rest into the base Provider's named kwargs
817
+ # and a payload params hash (any passed :params merged with
818
+ # unknown keys).
819
+ def split_fleet_kwargs(rest, named_keys)
820
+ named = rest.slice(*named_keys)
821
+ extra = rest.reject { |key, _| named.key?(key) }
822
+ params = (extra.delete(:params) || {}).to_h.merge(extra)
823
+ [named, params]
824
+ end
825
+
826
+ def classify_error_kind(error:)
827
+ case error
828
+ when Faraday::ConnectionFailed then :connection_failure
829
+ when Faraday::TimeoutError then :timeout
830
+ when Faraday::ClientError then classify_client_error(error: error)
831
+ when Faraday::ServerError then classify_server_error(error: error)
832
+ when Legion::Extensions::Llm::OverloadedError then :overloaded
833
+ else :provider_error
834
+ end
835
+ end
836
+
837
+ def classify_client_error(error:)
838
+ status = error.respond_to?(:response_status) ? error.response_status : nil
839
+ case status
840
+ when 401 then :authentication
841
+ when 403 then :authorization
842
+ when 404 then :model_missing
843
+ when 429 then :rate_limited
844
+ else :invalid_request
845
+ end
846
+ end
847
+
848
+ def classify_server_error(error:)
849
+ # NEVER classify raw 503/529/5xx as instance_unavailable by status alone.
850
+ # Only an explicit flat MLX service/instance-unavailable signal (which MLX
851
+ # does not produce) would justify instance_unavailable. For MLX, connection
852
+ # failure (port unreachable) is the signal the instance is down.
853
+ status = error.respond_to?(:response_status) ? error.response_status : nil
854
+ case status
855
+ when 503, 529 then :overloaded
856
+ else :provider_error
857
+ end
164
858
  end
165
859
  end
166
860
  end