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.
- checksums.yaml +4 -4
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +73 -0
- data/Gemfile +2 -0
- data/lex-llm-mlx.gemspec +6 -2
- data/lib/legion/extensions/llm/mlx/actors/discovery_refresh.rb +788 -94
- data/lib/legion/extensions/llm/mlx/actors/fleet_worker.rb +6 -1
- data/lib/legion/extensions/llm/mlx/provider.rb +169 -150
- data/lib/legion/extensions/llm/mlx/runners/fleet_worker.rb +23 -4
- data/lib/legion/extensions/llm/mlx/version.rb +1 -1
- data/lib/legion/extensions/llm/mlx.rb +59 -25
- metadata +19 -5
|
@@ -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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
644
|
+
interval = settings.dig(:discovery, :interval_seconds)
|
|
645
|
+
interval.is_a?(Integer) && interval.positive? ? interval : DEFAULT_DISCOVERY_INTERVAL_SECONDS
|
|
646
|
+
end
|
|
42
647
|
|
|
43
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
return [] unless defined?(Legion::LLM::Call::Registry)
|
|
660
|
+
private
|
|
52
661
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
705
|
+
def remove_all_instances
|
|
706
|
+
return unless @instance_states
|
|
71
707
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return [] unless adapter.respond_to?(:discover_offerings)
|
|
745
|
+
def call_count
|
|
746
|
+
@inference_calls
|
|
747
|
+
end
|
|
91
748
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
749
|
+
def disconnected?
|
|
750
|
+
@disconnected
|
|
751
|
+
end
|
|
95
752
|
|
|
96
|
-
|
|
97
|
-
|
|
753
|
+
def disconnect
|
|
754
|
+
@disconnected = true
|
|
755
|
+
@provider&.disconnect
|
|
756
|
+
@logger.debug { '[mlx][callable] disconnected' }
|
|
98
757
|
end
|
|
99
758
|
|
|
100
|
-
|
|
101
|
-
return nil if offering.nil?
|
|
102
|
-
return offering if offering.is_a?(Hash)
|
|
759
|
+
# ── Fleet dispatch operations ───────────────────────────────────
|
|
103
760
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
145
|
-
|
|
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
|
-
|
|
149
|
-
|
|
795
|
+
private
|
|
796
|
+
|
|
797
|
+
def record_inference
|
|
798
|
+
@inference_calls += 1
|
|
799
|
+
end
|
|
150
800
|
|
|
151
|
-
|
|
801
|
+
def provider
|
|
802
|
+
@provider ||= Legion::Extensions::Llm::Mlx::Provider.new(@instance_cfg)
|
|
152
803
|
end
|
|
153
804
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|