lex-llm 0.1.3 → 0.1.5

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: 375f971150ba508862d136d724dd61f99ffb49bf3076d6a5debe0e8e12dfe86b
4
- data.tar.gz: '092859a51545b6408d0b9342065fcabf32d77184fd7ebd9b2e5739e415c7a43f'
3
+ metadata.gz: aae636ae2e90a5bbbf5b11ba40ae3bd21ab628fa7754e0b9c78539f2535d03fc
4
+ data.tar.gz: 79a95d21375a4da155f768f8696d408917a8c37391ec5c986afce9dde2033f08
5
5
  SHA512:
6
- metadata.gz: fead7c175af6e409b349ac8c6654d2c8ddbc8ed66ac2a158483b2bdd4f78898881e4e5b6aa15728515dcfa46d8ce0c601a8c81d99eeabb87f681f93828e3ce31
7
- data.tar.gz: 69df8e7c7b0b09917d23b0de90d518dc9132dc9221a1ff9eef5dd1b30dc585beb0c1682d930a57335b29a9424a0b21f17b8994745e39515c1331dc9ff9a198ce
6
+ metadata.gz: 88bd2debf160491c93dbd275d332a4563f7d607142105d631114702b68cab98da103ce8b4e1b5a70f0da1aef3bc1727b384e2a1b1340bac3fe3079939e85377f
7
+ data.tar.gz: 49055e0945460d46444536b0fee9e1fabcba640a97f57b84486e75e5c014d765aaa1c7c33e6f67ae455fb43143b32a3fec80e0c507e20da5b29f34e714cd6d82
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5 - 2026-04-28
4
+
5
+ - Add the expanded provider-neutral model offering contract with offering IDs, provider instances, canonical model aliases, model families, and routing metadata.
6
+ - Add shared model alias normalization and an in-memory offering registry for common routing filters.
7
+
8
+ ## 0.1.4 - 2026-04-28
9
+
10
+ - Add non-live provider readiness metadata for routing without expensive health or model calls by default.
11
+ - Map OpenAI-compatible model listings to normalized capabilities and modalities for routing.
12
+
3
13
  ## 0.1.3 - 2026-04-27
4
14
 
5
15
  - Convert the gem to a standard Legion extension runtime under `Legion::Extensions::Llm`.
data/README.md CHANGED
@@ -48,7 +48,7 @@ gem 'lex-llm'
48
48
  Provider extensions should declare `lex-llm` as a gemspec dependency:
49
49
 
50
50
  ```ruby
51
- spec.add_dependency 'lex-llm', '>= 0.1.0'
51
+ spec.add_dependency 'lex-llm', '>= 0.1.5'
52
52
  ```
53
53
 
54
54
  For local development across LegionIO repos, prefer a local path override in the app or test `Gemfile`, not a permanent git dependency in the gemspec.
@@ -90,11 +90,14 @@ A model offering describes one concrete model made available by one provider ins
90
90
 
91
91
  ```ruby
92
92
  offering = Legion::Extensions::Llm::Routing::ModelOffering.new(
93
+ offering_id: 'ollama:macbook_m4_max:inference:qwen3-6-27b-q4-k-m',
93
94
  provider_family: :ollama,
94
- instance_id: :macbook_m4_max,
95
+ provider_instance: :macbook_m4_max,
95
96
  transport: :local,
96
97
  tier: :local,
97
98
  model: 'qwen3.6:27b-q4_K_M',
99
+ canonical_model_alias: 'qwen3.6:27b-q4_K_M',
100
+ model_family: :qwen,
98
101
  usage_type: :inference,
99
102
  capabilities: %i[chat tools vision thinking],
100
103
  limits: {
@@ -106,6 +109,10 @@ offering = Legion::Extensions::Llm::Routing::ModelOffering.new(
106
109
  latency_ms: 180
107
110
  },
108
111
  policy_tags: %i[internal_only phi_allowed],
112
+ routing_metadata: {
113
+ region: :local,
114
+ accelerator: :metal
115
+ },
109
116
  metadata: {
110
117
  enabled: true,
111
118
  eligibility: {
@@ -125,18 +132,45 @@ offering.eligible_for?(
125
132
 
126
133
  Common offering fields:
127
134
 
135
+ - `offering_id`: stable identifier for the concrete offering; generated from provider, instance, usage type, and canonical alias when omitted
128
136
  - `provider_family`: provider implementation family, such as `:ollama`, `:vllm`, `:bedrock`, `:anthropic`, or `:openai`
129
- - `instance_id`: concrete provider instance, account, node, region, or local runtime
137
+ - `provider_instance`: concrete provider instance, account, node, region, or local runtime
138
+ - `instance_id`: compatibility alias for `provider_instance`
139
+ - `model_family`: provider-neutral family such as `:openai`, `:anthropic`, `:gemini`, `:qwen`, or `:llama`
130
140
  - `transport`: `:local`, `:http`, `:rabbitmq`, `:sdk`, or another provider-supported transport
131
141
  - `tier`: `:local`, `:private`, `:fleet`, `:cloud`, `:frontier`, or deployment-specific policy tier
132
142
  - `model`: provider model name or normalized model alias
143
+ - `canonical_model_alias`: provider-neutral alias used by routers and shared fleet lane keys when a provider deployment hides the base model
133
144
  - `usage_type`: `:inference` or `:embedding`
134
145
  - `capabilities`: normalized feature flags such as `:chat`, `:tools`, `:json_schema`, `:vision`, `:thinking`, or `:embedding`
135
146
  - `limits`: context window, output token limits, rate limits, concurrency limits, and provider-specific bounds
136
147
  - `health`: readiness, latency, recent failures, and provider-specific health metadata
137
148
  - `policy_tags`: routing and compliance tags such as `:internal_only`, `:phi_allowed`, or `:hipaa`
149
+ - `routing_metadata`: provider-neutral scheduling metadata for routers; persistence is intentionally out of scope
138
150
  - `metadata`: extension-specific metadata; sensitive values are excluded from fleet eligibility fingerprints
139
151
 
152
+ Provider gems that still pass `instance_id`, or that store `model_family`, `canonical_model_alias`, or `alias` under `metadata`, remain compatible. `ModelOffering` lifts those values into first-class readers for routers.
153
+
154
+ `Legion::Extensions::Llm::Aliases.canonical_model_alias(model, provider)` provides shared alias normalization from `aliases.json`, with an explicit model string fallback.
155
+
156
+ ## Offering Registry
157
+
158
+ `Legion::Extensions::Llm::Routing::OfferingRegistry` is an in-memory index for discovered or configured offerings. It does not persist state.
159
+
160
+ ```ruby
161
+ registry = Legion::Extensions::Llm::Routing::OfferingRegistry.new
162
+ registry.register(offering)
163
+
164
+ registry.find(offering.offering_id)
165
+ registry.find_by_model_alias('qwen3.6:27b-q4_K_M')
166
+ registry.filter(
167
+ provider_family: :ollama,
168
+ provider_instance: :macbook_m4_max,
169
+ model_family: :qwen,
170
+ capability: :tools
171
+ )
172
+ ```
173
+
140
174
  ## Fleet Lanes
141
175
 
142
176
  Fleet routing uses shared work lanes derived from model offerings. A lane describes the work required, not the worker that happens to do it.
@@ -233,6 +267,8 @@ At minimum, a provider extension should define:
233
267
 
234
268
  Provider extensions should avoid duplicating shared classes, schema logic, fleet lane construction, JSON handling, or common request/response objects.
235
269
 
270
+ All providers inherit `#readiness(live: false)`, which returns configured state, provider locality, API base, endpoint helpers, and non-live health metadata without probing remote services. Providers with a cheap health endpoint can pass `live: true` to include that endpoint response. OpenAI-compatible providers also inherit shared model-list parsing that maps discovered models into normalized capabilities and modalities for Legion routing.
271
+
236
272
  ## Schema Status
237
273
 
238
274
  `lex-llm` still depends on `ruby_llm-schema` because the current schema bridge exposes:
@@ -16,6 +16,23 @@ module Legion
16
16
  end
17
17
  end
18
18
 
19
+ def normalize_model_alias(model_id)
20
+ model_id.to_s.strip
21
+ end
22
+
23
+ def canonical_model_alias(model_id, provider = nil)
24
+ normalized = normalize_model_alias(model_id)
25
+ provider_name = provider&.to_s
26
+
27
+ aliases.each do |alias_name, provider_map|
28
+ next unless alias_matches?(provider_map, normalized, provider_name)
29
+
30
+ return alias_name
31
+ end
32
+
33
+ normalized
34
+ end
35
+
19
36
  def aliases
20
37
  @aliases ||= load_aliases
21
38
  end
@@ -35,6 +52,14 @@ module Legion
35
52
  def reload!
36
53
  @aliases = load_aliases
37
54
  end
55
+
56
+ private
57
+
58
+ def alias_matches?(provider_map, model_id, provider)
59
+ return provider_map[provider] == model_id if provider
60
+
61
+ provider_map.value?(model_id)
62
+ end
38
63
  end
39
64
  end
40
65
  end
@@ -171,18 +171,54 @@ module Legion
171
171
  {}
172
172
  end
173
173
 
174
- def parse_list_models_response(response, provider, _capabilities)
174
+ def parse_list_models_response(response, provider, capabilities)
175
175
  response.body.fetch('data', []).map do |model|
176
+ critical_capabilities = critical_capabilities_for(capabilities, model)
176
177
  Legion::Extensions::Llm::Model::Info.new(
177
178
  id: model.fetch('id'),
178
179
  name: model['id'],
179
180
  provider: provider,
180
- created_at: model['created'],
181
+ created_at: model_created_at(model['created']),
182
+ capabilities: critical_capabilities,
183
+ modalities: modalities_for_capabilities(critical_capabilities),
181
184
  metadata: model
182
185
  )
183
186
  end
184
187
  end
185
188
 
189
+ def model_created_at(value)
190
+ value.is_a?(Numeric) ? Time.at(value).utc : value
191
+ end
192
+
193
+ def critical_capabilities_for(capabilities, model)
194
+ return [] unless capabilities
195
+ return capabilities.critical_capabilities_for(model) if capabilities.respond_to?(:critical_capabilities_for)
196
+
197
+ {
198
+ 'streaming' => :streaming?,
199
+ 'function_calling' => :functions?,
200
+ 'vision' => :vision?,
201
+ 'embeddings' => :embeddings?,
202
+ 'moderation' => :moderation?,
203
+ 'image' => :images?,
204
+ 'audio_transcription' => :audio_transcription?
205
+ }.filter_map do |capability, predicate|
206
+ capability if capabilities.respond_to?(predicate) && capabilities.public_send(predicate, model)
207
+ end
208
+ end
209
+
210
+ def modalities_for_capabilities(capabilities)
211
+ if capabilities.include?('embeddings') && (capabilities - ['embeddings']).empty?
212
+ { input: %w[text], output: %w[embeddings] }
213
+ elsif capabilities.include?('image')
214
+ { input: %w[text image], output: %w[image] }
215
+ elsif capabilities.include?('audio_transcription')
216
+ { input: %w[audio], output: %w[text] }
217
+ else
218
+ { input: %w[text image], output: %w[text] }
219
+ end
220
+ end
221
+
186
222
  def render_embedding_payload(text, model:, dimensions:)
187
223
  { model: model, input: text, dimensions: dimensions }.compact
188
224
  end
@@ -113,6 +113,38 @@ module Legion
113
113
  self.class.assume_models_exist?
114
114
  end
115
115
 
116
+ def readiness(live: false)
117
+ metadata = {
118
+ provider: slug.to_sym,
119
+ name: name,
120
+ configured: configured?,
121
+ ready: configured?,
122
+ local: local?,
123
+ remote: remote?,
124
+ api_base: api_base,
125
+ endpoints: endpoint_manifest,
126
+ live: live
127
+ }
128
+
129
+ return metadata.merge(health: { checked: false }) unless live && metadata[:endpoints][:health]
130
+
131
+ response = @connection.get(metadata[:endpoints][:health])
132
+ metadata.merge(ready: configured? && health_ready?(response.body), health: response.body)
133
+ rescue StandardError => e
134
+ metadata.merge(ready: false, health: { error: e.class.name, message: e.message })
135
+ end
136
+
137
+ def endpoint_manifest
138
+ endpoint_methods.each_with_object({}) do |(key, method_name), result|
139
+ next unless respond_to?(method_name)
140
+
141
+ value = public_send(method_name)
142
+ result[key] = value unless value.nil?
143
+ rescue ArgumentError, NotImplementedError
144
+ next
145
+ end
146
+ end
147
+
116
148
  def parse_error(response)
117
149
  return if response.body.empty?
118
150
 
@@ -270,6 +302,29 @@ module Legion
270
302
  temperature
271
303
  end
272
304
 
305
+ def endpoint_methods
306
+ {
307
+ completion: :completion_url,
308
+ stream: :stream_url,
309
+ models: :models_url,
310
+ embeddings: :embedding_url,
311
+ moderation: :moderation_url,
312
+ images: :images_url,
313
+ transcription: :transcription_url,
314
+ health: :health_url,
315
+ version: :version_url
316
+ }
317
+ end
318
+
319
+ def health_ready?(body)
320
+ return body unless body.is_a?(Hash)
321
+
322
+ status = body['status'] || body[:status] || body['state'] || body[:state]
323
+ return true if status.nil?
324
+
325
+ %w[ok ready healthy running].include?(status.to_s.downcase)
326
+ end
327
+
273
328
  def sync_response(connection, payload, additional_headers = {})
274
329
  response = connection.post completion_url, payload do |req|
275
330
  req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
@@ -9,7 +9,7 @@ module Legion
9
9
  module_function
10
10
 
11
11
  def for(offering, prefix: 'llm.fleet', include_context: true, include_fingerprint: false)
12
- parts = [prefix, lane_kind(offering), model_slug(offering.model)]
12
+ parts = [prefix, lane_kind(offering), model_slug(lane_model(offering))]
13
13
  if include_context && offering.inference? && offering.context_window
14
14
  parts << "ctx#{offering.context_window}"
15
15
  end
@@ -17,6 +17,13 @@ module Legion
17
17
  parts.join('.')
18
18
  end
19
19
 
20
+ def lane_model(offering)
21
+ return offering.canonical_model_alias if offering.respond_to?(:canonical_model_alias) &&
22
+ offering.canonical_model_alias.to_s != ''
23
+
24
+ offering.model
25
+ end
26
+
20
27
  def lane_kind(offering)
21
28
  offering.embedding? ? 'embed' : 'inference'
22
29
  end
@@ -6,15 +6,23 @@ module Legion
6
6
  module Routing
7
7
  # Describes one concrete model made available by one provider instance.
8
8
  class ModelOffering
9
- attr_reader :provider_family, :instance_id, :transport, :tier, :model, :usage_type, :capabilities, :limits,
9
+ attr_reader :offering_id, :provider_family, :model_family, :provider_instance, :instance_id, :transport,
10
+ :tier, :model, :canonical_model_alias, :routing_metadata, :usage_type, :capabilities, :limits,
10
11
  :credentials, :health, :cost, :policy_tags, :metadata
11
12
 
12
13
  def initialize(data)
14
+ @metadata = normalize_hash(fetch_value(data, :metadata))
13
15
  @provider_family = normalize_symbol(fetch_value(data, :provider_family, fetch_value(data, :provider)))
14
- @instance_id = normalize_symbol(fetch_value(data, :instance_id, @provider_family))
16
+ @model_family = normalize_symbol(fetch_value(data, :model_family, @metadata[:model_family]))
17
+ @provider_instance = normalize_symbol(fetch_value(data, :provider_instance,
18
+ fetch_value(data, :instance_id, @provider_family)))
19
+ @instance_id = @provider_instance
15
20
  @transport = normalize_symbol(fetch_value(data, :transport, :http))
16
21
  @tier = normalize_symbol(fetch_value(data, :tier, default_tier))
17
22
  @model = fetch_value(data, :model).to_s
23
+ @canonical_model_alias = normalize_model_alias(fetch_value(data, :canonical_model_alias,
24
+ metadata_canonical_model_alias))
25
+ @routing_metadata = normalize_hash(fetch_value(data, :routing_metadata))
18
26
  @usage_type = normalize_usage_type(fetch_value(data, :usage_type,
19
27
  fetch_value(data, :type) ||
20
28
  fetch_value(data, :kind) ||
@@ -25,7 +33,7 @@ module Legion
25
33
  @health = normalize_hash(fetch_value(data, :health))
26
34
  @cost = normalize_hash(fetch_value(data, :cost))
27
35
  @policy_tags = normalize_array(fetch_value(data, :policy_tags)).map(&:to_sym)
28
- @metadata = normalize_hash(fetch_value(data, :metadata))
36
+ @offering_id = normalize_offering_id(fetch_value(data, :offering_id, default_offering_id))
29
37
  end
30
38
 
31
39
  def enabled?
@@ -70,13 +78,23 @@ module Legion
70
78
  LaneKey.eligibility_fingerprint(self)
71
79
  end
72
80
 
81
+ def model_alias?(alias_name)
82
+ normalized = normalize_model_alias(alias_name)
83
+ [canonical_model_alias, model].compact.any? { |candidate| normalize_model_alias(candidate) == normalized }
84
+ end
85
+
73
86
  def to_h
74
87
  {
88
+ offering_id: offering_id,
75
89
  provider_family: provider_family,
90
+ model_family: model_family,
91
+ provider_instance: provider_instance,
76
92
  instance_id: instance_id,
77
93
  transport: transport,
78
94
  tier: tier,
79
95
  model: model,
96
+ canonical_model_alias: canonical_model_alias,
97
+ routing_metadata: routing_metadata,
80
98
  usage_type: usage_type,
81
99
  capabilities: capabilities,
82
100
  limits: limits,
@@ -166,6 +184,28 @@ module Legion
166
184
  rescue ArgumentError, TypeError
167
185
  nil
168
186
  end
187
+
188
+ def metadata_canonical_model_alias
189
+ metadata[:canonical_model_alias] || metadata[:alias] ||
190
+ Legion::Extensions::Llm::Aliases.canonical_model_alias(@model, @provider_family)
191
+ end
192
+
193
+ def normalize_model_alias(value)
194
+ Legion::Extensions::Llm::Aliases.normalize_model_alias(value)
195
+ end
196
+
197
+ def normalize_offering_id(value)
198
+ value.to_s.strip
199
+ end
200
+
201
+ def default_offering_id
202
+ [
203
+ provider_family,
204
+ provider_instance,
205
+ usage_type,
206
+ canonical_model_alias || model
207
+ ].compact.map { |part| LaneKey.model_slug(part) }.join(':')
208
+ end
169
209
  end
170
210
  end
171
211
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Llm
6
+ module Routing
7
+ # In-memory index of provider-neutral model offerings.
8
+ class OfferingRegistry
9
+ include Enumerable
10
+
11
+ def initialize(offerings = [])
12
+ @offerings = []
13
+ Array(offerings).each { |offering| register(offering) }
14
+ end
15
+
16
+ def register(offering)
17
+ normalized = normalize_offering(offering)
18
+ @offerings.reject! { |existing| existing.offering_id == normalized.offering_id }
19
+ @offerings << normalized
20
+ normalized
21
+ end
22
+
23
+ def each(&)
24
+ @offerings.each(&)
25
+ end
26
+
27
+ def all
28
+ @offerings.dup
29
+ end
30
+ alias list all
31
+
32
+ def find(offering_id)
33
+ @offerings.find { |offering| offering.offering_id == offering_id.to_s }
34
+ end
35
+
36
+ def find_by_model_alias(alias_name)
37
+ @offerings.find { |offering| offering.model_alias?(alias_name) }
38
+ end
39
+
40
+ def filter(**criteria)
41
+ @offerings.select do |offering|
42
+ matches_symbol?(offering.provider_family, criteria[:provider_family]) &&
43
+ matches_symbol?(offering.model_family, criteria[:model_family]) &&
44
+ matches_symbol?(offering.provider_instance, criteria[:provider_instance]) &&
45
+ matches_capability?(offering, criteria[:capability]) &&
46
+ matches_model_alias?(offering, criteria[:model_alias]) &&
47
+ matches_model?(offering, criteria[:model]) &&
48
+ matches_usage_type?(offering, criteria[:usage_type])
49
+ end
50
+ end
51
+
52
+ def by_provider_family(provider_family)
53
+ filter(provider_family:)
54
+ end
55
+
56
+ def by_model_family(model_family)
57
+ filter(model_family:)
58
+ end
59
+
60
+ def by_provider_instance(provider_instance)
61
+ filter(provider_instance:)
62
+ end
63
+
64
+ def by_capability(capability)
65
+ filter(capability:)
66
+ end
67
+
68
+ private
69
+
70
+ def normalize_offering(offering)
71
+ return offering if offering.is_a?(ModelOffering)
72
+
73
+ ModelOffering.new(offering)
74
+ end
75
+
76
+ def matches_symbol?(actual, expected)
77
+ expected.nil? || actual == expected.to_sym
78
+ end
79
+
80
+ def matches_capability?(offering, capability)
81
+ capability.nil? || offering.supports?(capability)
82
+ end
83
+
84
+ def matches_model_alias?(offering, model_alias)
85
+ model_alias.nil? || offering.model_alias?(model_alias)
86
+ end
87
+
88
+ def matches_model?(offering, model)
89
+ model.nil? || offering.model == model.to_s
90
+ end
91
+
92
+ def matches_usage_type?(offering, usage_type)
93
+ usage_type.nil? || offering.usage_type == usage_type.to_sym
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.1.3'
6
+ VERSION = '0.1.5'
7
7
  end
8
8
  end
9
9
  end
@@ -38,11 +38,14 @@ module Legion
38
38
  # Provider-neutral value objects exposed under the Legion extension namespace.
39
39
  module Types
40
40
  ModelOffering = Routing::ModelOffering unless const_defined?(:ModelOffering, false)
41
+ OfferingRegistry = Routing::OfferingRegistry unless const_defined?(:OfferingRegistry, false)
41
42
  end
42
43
 
43
44
  # Shared routing helpers exposed under the Legion extension namespace.
44
45
  module Routing
45
46
  LaneKey = ::Legion::Extensions::Llm::Routing::LaneKey unless const_defined?(:LaneKey, false)
47
+ OfferingRegistry = ::Legion::Extensions::Llm::Routing::OfferingRegistry unless const_defined?(:OfferingRegistry,
48
+ false)
46
49
  end
47
50
 
48
51
  class << self
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -228,6 +228,7 @@ files:
228
228
  - lib/legion/extensions/llm/routing.rb
229
229
  - lib/legion/extensions/llm/routing/lane_key.rb
230
230
  - lib/legion/extensions/llm/routing/model_offering.rb
231
+ - lib/legion/extensions/llm/routing/offering_registry.rb
231
232
  - lib/legion/extensions/llm/stream_accumulator.rb
232
233
  - lib/legion/extensions/llm/streaming.rb
233
234
  - lib/legion/extensions/llm/thinking.rb