lex-llm-mlx 0.5.2 → 0.5.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.
@@ -1,864 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'digest'
4
- require 'time'
5
- require 'uri'
6
- require 'faraday'
7
-
8
- begin
9
- require 'legion/extensions/actors/every'
10
- rescue LoadError => e
11
- warn(e.message) if $VERBOSE
12
- end
13
-
14
- unless defined?(Legion::Extensions::Actors::Every)
15
- raise LoadError, 'LegionIO actor runtime is required for MLX discovery refresh'
16
- end
17
-
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'
28
-
29
- module Legion
30
- module Extensions
31
- module Llm
32
- module Mlx
33
- module Actor
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
45
-
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
- }
85
- end
86
-
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
635
-
636
- def runner_class = self.class
637
- def runner_function = 'manual'
638
- def run_now? = true
639
- def use_runner? = false
640
- def check_subtask? = false
641
- def generate_task? = false
642
-
643
- def time
644
- interval = settings.dig(:discovery, :interval_seconds)
645
- interval.is_a?(Integer) && interval.positive? ? interval : DEFAULT_DISCOVERY_INTERVAL_SECONDS
646
- end
647
-
648
- def manual
649
- tick_refresh
650
- rescue StandardError => e
651
- handle_exception(e, level: :warn, operation: 'mlx.actor.discovery_refresh')
652
- end
653
-
654
- def shutdown
655
- remove_all_instances
656
- rescue StandardError => e
657
- handle_exception(e, level: :warn, operation: 'mlx.actor.discovery_refresh.shutdown')
658
- end
659
-
660
- private
661
-
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
- )
669
- end
670
-
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
- )
687
- end
688
-
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)
703
- end
704
-
705
- def remove_all_instances
706
- return unless @instance_states
707
-
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)
717
- end
718
- @instance_states.clear
719
- end
720
- end
721
-
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
743
- end
744
-
745
- def call_count
746
- @inference_calls
747
- end
748
-
749
- def disconnected?
750
- @disconnected
751
- end
752
-
753
- def disconnect
754
- @disconnected = true
755
- @provider&.disconnect
756
- @logger.debug { '[mlx][callable] disconnected' }
757
- end
758
-
759
- # ── Fleet dispatch operations ───────────────────────────────────
760
-
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)
765
- end
766
-
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, &)
771
- end
772
-
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)
777
- end
778
-
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)
783
- end
784
-
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
- )
793
- end
794
-
795
- private
796
-
797
- def record_inference
798
- @inference_calls += 1
799
- end
800
-
801
- def provider
802
- @provider ||= Legion::Extensions::Llm::Mlx::Provider.new(@instance_cfg)
803
- end
804
-
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
813
- )
814
- end
815
-
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
858
- end
859
- end
860
- end
861
- end
862
- end
863
- end
864
- end