lex-llm 0.1.4 → 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: acd86ca9d14268a164c9492297186706a93770c93f4e312f45d6e54cec8bd565
4
- data.tar.gz: b78f52a9e0cebfedb102b7809aaf3affc20440e1fcd68a9f815e00f4a129b440
3
+ metadata.gz: aae636ae2e90a5bbbf5b11ba40ae3bd21ab628fa7754e0b9c78539f2535d03fc
4
+ data.tar.gz: 79a95d21375a4da155f768f8696d408917a8c37391ec5c986afce9dde2033f08
5
5
  SHA512:
6
- metadata.gz: cb2b53cfc698777af6dbfbd735a8d12c0ed5eb94a3dbea88fb91a4951946ac2c3f42790b316bfa34e9f56f822ce3c1be6a07cdc8298525f8d317895269fb2348
7
- data.tar.gz: b7392fe404a9bee6f2cde122522016b43f193c4df546a69732f9d238161f61a8a6dcb93b685fc2de5a1f9703ecf2c14c433e1a790039d8489b302afbb8cce2ef
6
+ metadata.gz: 88bd2debf160491c93dbd275d332a4563f7d607142105d631114702b68cab98da103ce8b4e1b5a70f0da1aef3bc1727b384e2a1b1340bac3fe3079939e85377f
7
+ data.tar.gz: 49055e0945460d46444536b0fee9e1fabcba640a97f57b84486e75e5c014d765aaa1c7c33e6f67ae455fb43143b32a3fec80e0c507e20da5b29f34e714cd6d82
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## 0.1.4 - 2026-04-28
4
9
 
5
10
  - Add non-live provider readiness metadata for routing without expensive health or model calls by default.
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.4'
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.
@@ -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
@@ -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.4'
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.4
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