lex-llm-mlx 0.3.7 → 0.3.13

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ddbc30a66deeb8a43b0801bdc7c63e3388ef12a6be22ef1ecf390c588af89be2
4
- data.tar.gz: eaecfe996293eec9607012f9e6aaa66af047021502d62eeb2cef34b309816bd8
3
+ metadata.gz: 5490746e9c970066d163970177812051c9793d6f25f036c94139b63d01b81e4b
4
+ data.tar.gz: b766dee30291fb2b73cdefc7807f07519776a3c3ba16e87b43c4ef0f0f9efc81
5
5
  SHA512:
6
- metadata.gz: '0218f8982051c9a54b16c36442545052b36b88670e01548487bcab93572c46f012b447c5936f267e9d5b0b54c321dd30c24a3aef2395fbd08e8f501cc9304900'
7
- data.tar.gz: 2763d809a2806f44fed3fb282c4679bfbb1c9b1c8d192fa41c90ac24d55cdd88fc435a8ce3e91822c9e79c595e94518eb49040ec2210e2beb4e070769e744d98
6
+ metadata.gz: 51dea6c8da8a5e79e54da1281bbcf396bc00b6ada942f1c439fa1bb5556a590f1e839d367f54660731a9eda493c383f03fd88de9feeb5c50f8e9bc57e4f9977c
7
+ data.tar.gz: 4a228ecb85b060a8eab56ddf1e99f089c9e061ffa106f40fdde158d215a50bd7729f80f44bf221e958c496566ece3913663ee015c31d3ec52ffc275de7bb164d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.13] - 2026-06-20
4
+
5
+ ### Changed
6
+ - Align MLX offerings to the current `lex-llm` contract: `discover_offerings` now works with the shared
7
+ provider flow, offerings honor configured tier/transport overrides, and provider health is carried onto
8
+ discovered offerings in the shared shape.
9
+ - Normalize MLX capability override expectations to the shared `:embedding` offering vocabulary.
10
+
11
+ ## [0.3.12] - 2026-06-19
12
+
13
+ ### Changed
14
+ - Adopt `Legion::Extensions::Llm::Inventory::ScopedRefresher` mixin (lex-llm 0.6.0). Discovery
15
+ refresh actors now write directly to the live `Inventory` catalog via `Inventory.write_lane`.
16
+ - Pin `lex-llm >= 0.6.0` and `legion-llm >= 0.14.0` in gemspec.
17
+ - Standard `weight: 100` default added to provider instance settings schema.
18
+
19
+ ## 0.3.11 - 2026-06-16
20
+
21
+ - Dependency updates and code quality improvements.
22
+
23
+ ## 0.3.10 - 2026-06-15
24
+
25
+ - **CapabilityPolicy integration** — Name-pattern heuristics tagged as `:provider_catalog`; streaming from `:provider_envelope`. Settings overrides at provider/instance/model level supported.
26
+
27
+ ## 0.3.9 - 2026-06-13
28
+
29
+ - **Gemfile cleanup** — Remove local path overrides; dependencies resolve from gemspec via rubygems.
30
+ - **Dependency bump** — Require `lex-llm >= 0.5.0` for canonical types support.
31
+ - **Canonical tool support** — Use `ToolSchema.extract` and add `:tools` capability.
32
+ - 20 examples, 0 failures; 13 files, 0 rubocop offenses.
33
+
34
+ ## 0.3.8 - 2026-06-02
35
+
36
+ - Add per-provider scoped discovery refresh actor
37
+
3
38
  ## 0.3.7 - 2026-05-21
4
39
 
5
40
  - Add `default_transport`/`default_tier` class declarations
data/Gemfile CHANGED
@@ -2,13 +2,6 @@
2
2
 
3
3
  source 'https://rubygems.org'
4
4
 
5
- group :test do
6
- llm_base_path = ENV.fetch('LEX_LLM_PATH', File.expand_path('../lex-llm', __dir__))
7
- transport_path = ENV.fetch('LEGION_TRANSPORT_PATH', File.expand_path('../../legion-transport', __dir__))
8
- gem 'legion-transport', path: transport_path if File.directory?(transport_path)
9
- gem 'lex-llm', path: llm_base_path if File.directory?(llm_base_path)
10
- end
11
-
12
5
  gemspec
13
6
 
14
7
  group :development do
data/lex-llm-mlx.gemspec CHANGED
@@ -27,5 +27,5 @@ Gem::Specification.new do |spec|
27
27
  spec.add_dependency 'legion-logging', '>= 1.3.2'
28
28
  spec.add_dependency 'legion-settings', '>= 1.3.14'
29
29
  spec.add_dependency 'legion-transport', '>= 1.4.14'
30
- spec.add_dependency 'lex-llm', '>= 0.4.3'
30
+ spec.add_dependency 'lex-llm', '>= 0.6.0'
31
31
  end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ begin
6
+ require 'legion/extensions/actors/every'
7
+ rescue LoadError => e
8
+ warn(e.message) if $VERBOSE
9
+ end
10
+
11
+ begin
12
+ require 'legion/extensions/llm/inventory/scoped_refresher'
13
+ rescue LoadError => e
14
+ warn(e.message) if $VERBOSE
15
+ end
16
+
17
+ return unless defined?(Legion::Extensions::Actors::Every)
18
+
19
+ module Legion
20
+ module Extensions
21
+ module Llm
22
+ module Mlx
23
+ module Actor
24
+ class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation, Metrics/ClassLength
25
+ include Legion::Logging::Helper
26
+
27
+ if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
28
+ include Legion::Extensions::Llm::Inventory::ScopedRefresher
29
+ end
30
+
31
+ def self.every_seconds = 60
32
+
33
+ def runner_class = self.class
34
+ def runner_function = 'manual'
35
+ def run_now? = true
36
+ def use_runner? = false
37
+ def check_subtask? = false
38
+ def generate_task? = false
39
+
40
+ def time
41
+ return self.class.every_seconds unless defined?(Legion::Settings)
42
+
43
+ Legion::Settings.dig(:extensions, :llm, :mlx, :discovery_interval) || self.class.every_seconds
44
+ end
45
+
46
+ def scope_key(**)
47
+ { provider: :mlx }
48
+ end
49
+
50
+ def compute_lanes_for_scope(**)
51
+ return [] unless defined?(Legion::LLM::Call::Registry)
52
+
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
+ []
57
+ end
58
+
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]
62
+ end
63
+
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')
68
+ end
69
+
70
+ private
71
+
72
+ def mlx_instances
73
+ Legion::LLM::Call::Registry.all_instances.select do |e|
74
+ (e[:provider] || '').to_sym == :mlx
75
+ end
76
+ end
77
+
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
+ []
85
+ end
86
+
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)
91
+
92
+ offerings_for(adapter, instance_id).filter_map do |raw_offering|
93
+ offering = offering_to_hash(raw_offering)
94
+ next unless offering
95
+
96
+ build_lanes(offering, instance_id)
97
+ end.flatten
98
+ end
99
+
100
+ def offering_to_hash(offering)
101
+ return nil if offering.nil?
102
+ return offering if offering.is_a?(Hash)
103
+
104
+ hash = offering.to_h
105
+ hash[:type] ||= hash[:usage_type]
106
+ hash[:enabled] = offering.respond_to?(:enabled?) ? offering.enabled? : true
107
+ hash
108
+ end
109
+
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
117
+ end
118
+
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
+ }
134
+ end
135
+
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
+ )
142
+ end
143
+
144
+ def offering_type(raw)
145
+ %i[embed embedding].include?(raw.to_s.to_sym) ? :embedding : :inference
146
+ end
147
+
148
+ def normalize_capabilities(caps)
149
+ return [] unless defined?(Legion::LLM::Inventory::Capabilities)
150
+
151
+ Legion::LLM::Inventory::Capabilities.normalize(caps)
152
+ end
153
+
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
158
+ )
159
+ end
160
+
161
+ def fleet_enabled?
162
+ mlx_settings = Legion::Settings.dig(:extensions, :llm, :mlx) || {}
163
+ mlx_settings.dig(:fleet, :dispatch, :enabled)
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -7,7 +7,7 @@ module Legion
7
7
  module Llm
8
8
  module Mlx
9
9
  # MLX provider implementation for local OpenAI-compatible servers.
10
- class Provider < Legion::Extensions::Llm::Provider
10
+ class Provider < Legion::Extensions::Llm::Provider # rubocop:disable Metrics/ClassLength
11
11
  include Legion::Extensions::Llm::Provider::OpenAICompatible
12
12
 
13
13
  class << self
@@ -69,7 +69,19 @@ module Legion
69
69
 
70
70
  def health(live: false)
71
71
  log.info("Checking MLX health live=#{live} at #{api_base}#{health_url}")
72
- connection.get(health_url).body
72
+ raw = connection.get(health_url).body
73
+ health_payload(raw)
74
+ rescue StandardError => e
75
+ handle_exception(e, level: :warn, handled: true, operation: 'mlx.provider.health')
76
+ {
77
+ provider: :mlx,
78
+ instance_id: provider_instance_id,
79
+ status: 'unhealthy',
80
+ ready: false,
81
+ circuit_state: 'open',
82
+ error: e.class.name,
83
+ message: e.message
84
+ }
73
85
  end
74
86
 
75
87
  def readiness(live: false)
@@ -86,6 +98,143 @@ module Legion
86
98
  self.class.registry_publisher.publish_models_async(models, readiness: readiness(live: false))
87
99
  end
88
100
  end
101
+
102
+ def offering_from_model(model_info, health: {}) # rubocop:disable Metrics/AbcSize
103
+ policy = resolve_capability_policy(model_info)
104
+ ctx = model_info.respond_to?(:context_length) ? model_info.context_length : nil
105
+
106
+ Legion::Extensions::Llm::Routing::ModelOffering.new(
107
+ provider_family: :mlx,
108
+ instance_id: config.respond_to?(:instance_id) ? config.instance_id : :default,
109
+ transport: offering_transport,
110
+ tier: offering_tier,
111
+ model: model_info.id,
112
+ canonical_model_alias: model_info.respond_to?(:name) ? model_info.name : nil,
113
+ model_family: model_info.respond_to?(:family) ? model_info.family : nil,
114
+ usage_type: embedding_model?(model_info.id) ? :embedding : :inference,
115
+ capabilities: policy[:capabilities],
116
+ capability_sources: policy[:sources],
117
+ limits: { context_window: ctx }.compact,
118
+ health: health,
119
+ metadata: offering_metadata_for(model_info).merge(capability_sources: policy[:sources])
120
+ )
121
+ end
122
+
123
+ def resolve_capability_policy(model_info)
124
+ Legion::Extensions::Llm::CapabilityPolicy.resolve(
125
+ real: extract_real_capabilities(model_info),
126
+ provider_catalog: extract_catalog_capabilities(model_info),
127
+ probe: {},
128
+ provider_envelope: provider_envelope_capabilities,
129
+ provider_config: provider_capability_config,
130
+ instance_config: instance_capability_config,
131
+ model_config: model_capability_config(model_info.id)
132
+ )
133
+ end
134
+
135
+ def extract_real_capabilities(model_info)
136
+ return {} unless model_info.respond_to?(:metadata)
137
+
138
+ meta = model_info.metadata
139
+ return {} unless meta.is_a?(Hash)
140
+
141
+ caps = meta[:capabilities]
142
+ caps.is_a?(Hash) ? caps : {}
143
+ end
144
+
145
+ def extract_catalog_capabilities(model_info)
146
+ model_id = model_info.respond_to?(:id) ? model_info.id.to_s : model_info.to_s
147
+ caps = {}
148
+ caps[:embeddings] = true if model_id.match?(/embed|bge|e5|nomic/i)
149
+ caps[:vision] = true if model_id.match?(/vlm|vision|llava|pixtral|qwen.*vl/i)
150
+ caps[:streaming] = true unless caps[:embeddings]
151
+ caps
152
+ end
153
+
154
+ def embedding_model?(model_id)
155
+ model_id.to_s.match?(/embed|bge|e5|nomic/i)
156
+ end
157
+
158
+ def provider_envelope_capabilities
159
+ { streaming: true }
160
+ end
161
+
162
+ def health_payload(raw)
163
+ ready = health_ready?(raw)
164
+ status = health_status(ready)
165
+
166
+ {
167
+ provider: :mlx,
168
+ instance_id: provider_instance_id,
169
+ status: status,
170
+ ready: ready,
171
+ circuit_state: circuit_state(status),
172
+ raw: raw
173
+ }
174
+ end
175
+
176
+ def health_ready?(raw)
177
+ raw.is_a?(Hash) ? raw.fetch('ready', raw.fetch(:ready, true)) : true
178
+ end
179
+
180
+ def health_status(ready)
181
+ ready ? 'healthy' : 'unhealthy'
182
+ end
183
+
184
+ def circuit_state(status)
185
+ status == 'healthy' ? 'closed' : 'open'
186
+ end
187
+
188
+ def provider_capability_config
189
+ conf = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, :mlx)
190
+ conf.is_a?(Hash) ? conf.to_h.except(:instances, 'instances') : {}
191
+ rescue StandardError => e
192
+ handle_exception(e, level: :warn, handled: true, operation: 'mlx.provider_capability_config')
193
+ {}
194
+ end
195
+
196
+ def instance_capability_config
197
+ cfg = config
198
+ result = {}
199
+ %i[capabilities enable_thinking enable_tools enable_streaming enable_vision enable_embeddings
200
+ thinking_flag tools_flag streaming_flag vision_flag embedding_flag embeddings_flag
201
+ tool_flag images_flag image_flag].each do |key|
202
+ next unless cfg.respond_to?(key)
203
+
204
+ val = cfg.send(key)
205
+ result[key] = val unless val.nil?
206
+ rescue StandardError
207
+ next
208
+ end
209
+ result
210
+ end
211
+
212
+ def model_capability_config(model_id)
213
+ models_conf = fetch_models_config
214
+ return {} unless models_conf
215
+
216
+ hash = models_conf.to_h
217
+ hash[model_id.to_s] || hash[model_id.to_sym] || {}
218
+ rescue StandardError => e
219
+ handle_exception(e, level: :warn, handled: true, operation: 'mlx.model_capability_config')
220
+ {}
221
+ end
222
+
223
+ def fetch_models_config
224
+ conf = config.models if config.respond_to?(:models)
225
+ conf ||= config[:models] if config.respond_to?(:[])
226
+ conf if conf.respond_to?(:to_h)
227
+ rescue StandardError
228
+ nil
229
+ end
230
+
231
+ def offering_metadata_for(model_info)
232
+ {
233
+ raw_model: model_info.id,
234
+ parameter_count: model_info.respond_to?(:parameter_count) ? model_info.parameter_count : nil,
235
+ quantization: model_info.respond_to?(:quantization) ? model_info.quantization : nil
236
+ }.compact
237
+ end
89
238
  end
90
239
  end
91
240
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Mlx
7
- VERSION = '0.3.7'
7
+ VERSION = '0.3.13'
8
8
  end
9
9
  end
10
10
  end
@@ -3,6 +3,7 @@
3
3
  require 'legion/extensions/llm'
4
4
  require 'legion/extensions/llm/mlx/provider'
5
5
  require 'legion/extensions/llm/mlx/version'
6
+ require_relative 'mlx/actors/discovery_refresh'
6
7
 
7
8
  module Legion
8
9
  module Extensions
@@ -28,10 +29,7 @@ module Legion
28
29
  fleet: {
29
30
  enabled: false,
30
31
  respond_to_requests: false,
31
- capabilities: %i[chat stream_chat embed],
32
- lanes: [],
33
- concurrency: 1,
34
- queue_suffix: nil
32
+ capabilities: %i[chat stream_chat embed]
35
33
  }
36
34
  }
37
35
  )
@@ -49,10 +47,10 @@ module Legion
49
47
  end
50
48
 
51
49
  def self.discover_local_instance(instances)
52
- return unless CredentialSources.socket_open?('localhost', 8080, timeout: 0.1)
50
+ return unless CredentialSources.socket_open?('localhost', 8000, timeout: 0.1)
53
51
 
54
52
  instances[:local] = {
55
- base_url: 'http://localhost:8080',
53
+ base_url: 'http://localhost:8000',
56
54
  tier: :local,
57
55
  capabilities: [:completion]
58
56
  }
@@ -84,8 +82,7 @@ module Legion
84
82
  private_class_method :discover_local_instance, :discover_settings_instances,
85
83
  :normalize_instance_config, :normalize_api_base
86
84
 
87
- Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options) if
88
- Legion::Extensions::Llm::Configuration.respond_to?(:register_provider_options)
85
+ Legion::Extensions::Llm::Configuration.register_provider_options(Provider.configuration_options)
89
86
  end
90
87
  end
91
88
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-mlx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.7
4
+ version: 0.3.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -71,14 +71,14 @@ dependencies:
71
71
  requirements:
72
72
  - - ">="
73
73
  - !ruby/object:Gem::Version
74
- version: 0.4.3
74
+ version: 0.6.0
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - ">="
80
80
  - !ruby/object:Gem::Version
81
- version: 0.4.3
81
+ version: 0.6.0
82
82
  description: MLX provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com
@@ -97,6 +97,7 @@ files:
97
97
  - README.md
98
98
  - lex-llm-mlx.gemspec
99
99
  - lib/legion/extensions/llm/mlx.rb
100
+ - lib/legion/extensions/llm/mlx/actors/discovery_refresh.rb
100
101
  - lib/legion/extensions/llm/mlx/actors/fleet_worker.rb
101
102
  - lib/legion/extensions/llm/mlx/provider.rb
102
103
  - lib/legion/extensions/llm/mlx/runners/fleet_worker.rb