lex-llm-openai 0.4.5 → 0.4.8

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: 4091bfdcfbcd60f52453e29e42e30a4564edd48dabcd9b0f8edadfa68440f52b
4
- data.tar.gz: 0de0b799f80f47f937a9ecdc797996c4c0d3e64784fc518bf0b5de8628df135b
3
+ metadata.gz: 9795e7797d7d5f8ce0994e16625e2ccd6d2ef54299166e9bbec86bd2ce7c002c
4
+ data.tar.gz: 4c5eb2fa507ae61c44893be81a7ef2a7b692542fb3bef191ece53e0d061b68e2
5
5
  SHA512:
6
- metadata.gz: 0abdd81c00439b7ccba758adbd7f8576e83ce5e367673ce2092708dc3f61290c406db462e0736e56402957c32f47b0fd781bdcf6486508a5b8486ab48f6d2edf
7
- data.tar.gz: 29b4b768e0bd91aee7cd05283b62caf93d4ffbd6215063c7fa480d3ed1a2b164341e669d6badcb4e7f804db55e9500e81c016eef5f4a7223c20039730942ec37
6
+ metadata.gz: f60720c95320eed7e1a2d02e3fd91857ad290c2ad06c525bf12321a309b09bb7529232bf0461a6ed7dbd38a81c6ac912f7025c6d3de35d3872e90ccc5675b326
7
+ data.tar.gz: 435f627775ea601c6d3c41f850d6bf9efcabea039692ac2c06161b990b0efde1b59d7c95ba837e6f2bfffd4ced82a4afe2fef1439fd5200c203e7b4dec7e416a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.8] - 2026-06-20
4
+
5
+ ### Fixed
6
+ - Stop bulk-publishing OpenAI model availability from `list_models`; discovery now emits one registry event per seen model from the shared `lex-llm` policy-filter path so blocked models stay observable without duplicate publishes.
7
+
8
+ ## [0.4.7] - 2026-06-20
9
+
10
+ ### Fixed
11
+ - Normalize OpenAI offering capabilities through the canonical `lex-llm` contract so `completion`, `embedding`, `thinking`, image, and audio capabilities survive discovery without provider-specific vocabulary drift.
12
+ - Move provider/instance/model capability override extraction onto the shared base provider implementation.
13
+
14
+ ## [0.4.6] - 2026-06-19
15
+
16
+ ### Changed
17
+ - Adopt `Legion::Extensions::Llm::Inventory::ScopedRefresher` mixin (lex-llm 0.6.0). Discovery
18
+ refresh actors now write directly to the live `Inventory` catalog via `Inventory.write_lane`.
19
+ - Pin `lex-llm >= 0.6.0` and `legion-llm >= 0.14.0` in gemspec.
20
+ - Standard `weight: 100` default added to provider instance settings schema.
21
+
3
22
  ## 0.4.5 - 2026-06-17
4
23
 
5
24
  ### 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.5.4'
30
+ spec.add_dependency 'lex-llm', '>= 0.6.0'
31
31
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'digest'
4
+
3
5
  begin
4
6
  require 'legion/extensions/actors/every'
5
7
  rescue LoadError => e
@@ -8,6 +10,12 @@ end
8
10
 
9
11
  return unless defined?(Legion::Extensions::Actors::Every)
10
12
 
13
+ begin
14
+ require 'legion/extensions/llm/inventory/scoped_refresher'
15
+ rescue LoadError => e
16
+ warn(e.message) if $VERBOSE
17
+ end
18
+
11
19
  module Legion
12
20
  module Extensions
13
21
  module Llm
@@ -17,7 +25,11 @@ module Legion
17
25
  class DiscoveryRefresh < Legion::Extensions::Actors::Every
18
26
  include Legion::Logging::Helper
19
27
 
20
- REFRESH_INTERVAL = 1800
28
+ if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
29
+ include Legion::Extensions::Llm::Inventory::ScopedRefresher
30
+ end
31
+
32
+ def self.every_seconds = 3600
21
33
 
22
34
  def runner_class = self.class
23
35
  def runner_function = 'manual'
@@ -27,26 +39,135 @@ module Legion
27
39
  def generate_task? = false
28
40
 
29
41
  def time
30
- return REFRESH_INTERVAL unless defined?(Legion::Settings)
42
+ return self.class.every_seconds unless defined?(Legion::Settings)
31
43
 
32
- Legion::Settings.dig(:extensions, :llm, :openai, :discovery_interval) || REFRESH_INTERVAL
44
+ Legion::Settings.dig(:extensions, :llm, :openai, :discovery_interval) || self.class.every_seconds
33
45
  end
34
46
 
35
- def manual
36
- log.debug('[openai][discovery_refresh] refreshing model list')
37
- return unless defined?(Legion::LLM::Discovery)
47
+ def scope_key
48
+ { provider: :openai }
49
+ end
38
50
 
39
- Legion::LLM::Discovery.refresh_discovered_models!(provider: :openai)
51
+ def compute_lanes_for_scope
52
+ return [] unless defined?(Legion::LLM::Call::Registry)
40
53
 
41
- if defined?(Legion::LLM::Router) && Legion::LLM::Router.respond_to?(:populate_auto_rules)
42
- Legion::LLM::Router.populate_auto_rules(Legion::LLM::Discovery.discovered_instances)
43
- end
44
- if defined?(Legion::LLM::Inventory) && Legion::LLM::Inventory.respond_to?(:invalidate_offerings_cache!)
45
- Legion::LLM::Inventory.invalidate_offerings_cache!
54
+ instances = Legion::LLM::Call::Registry.all_instances.select do |e|
55
+ (e[:provider] || '').to_sym == :openai
46
56
  end
57
+
58
+ lanes = []
59
+ instances.each { |entry| lanes.concat(lanes_for_instance(entry)) }
60
+ lanes
61
+ rescue StandardError => e
62
+ handle_exception(e, level: :warn, handled: true,
63
+ operation: 'openai.actor.discovery_refresh.compute_lanes')
64
+ []
65
+ end
66
+
67
+ def credential_hash
68
+ settings = Legion::Settings.dig(:extensions, :llm, :openai) || {}
69
+ Digest::SHA256.hexdigest(settings[:api_key].to_s + settings[:instances].to_s)[0, 16]
70
+ rescue StandardError
71
+ 'unknown'
72
+ end
73
+
74
+ def manual
75
+ tick_if_scoped_refresher
47
76
  rescue StandardError => e
48
77
  handle_exception(e, level: :warn, handled: true, operation: 'openai.actor.discovery_refresh')
49
78
  end
79
+
80
+ private
81
+
82
+ def tick_if_scoped_refresher
83
+ return unless defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
84
+ return unless self.class.ancestors.include?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
85
+
86
+ tick
87
+ end
88
+
89
+ def lanes_for_instance(instance_entry) # rubocop:disable Metrics/CyclomaticComplexity
90
+ adapter = instance_entry[:adapter]
91
+ return [] unless adapter.respond_to?(:discover_offerings)
92
+
93
+ instance_id = instance_entry[:instance] || instance_entry[:instance_id] ||
94
+ instance_entry[:id] || :default
95
+ lanes = []
96
+
97
+ Array(adapter.discover_offerings(live: true)).each do |raw_offering|
98
+ offering = offering_to_hash(raw_offering)
99
+ next unless offering
100
+
101
+ lane = build_lane(offering, instance_id)
102
+ lanes << lane
103
+ fleet_lane = maybe_fleet_lane(offering, lane)
104
+ lanes << fleet_lane if fleet_lane
105
+ end
106
+
107
+ lanes
108
+ end
109
+
110
+ def offering_to_hash(offering)
111
+ return nil if offering.nil?
112
+ return offering if offering.is_a?(Hash)
113
+
114
+ hash = offering.to_h
115
+ hash[:type] ||= hash[:usage_type]
116
+ hash[:enabled] = offering.respond_to?(:enabled?) ? offering.enabled? : true
117
+ hash
118
+ end
119
+
120
+ def build_lane(offering, instance_id)
121
+ tier = offering[:tier] || :frontier
122
+ type = offering_type(offering)
123
+ lane_fields = { tier: tier, provider_family: :openai, instance_id: instance_id,
124
+ type: type, model: offering[:model] }
125
+ {
126
+ id: Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(lane_fields),
127
+ tier: tier,
128
+ provider_family: :openai,
129
+ instance_id: instance_id,
130
+ model: offering[:model],
131
+ canonical_model_alias: offering[:canonical_model_alias],
132
+ type: type,
133
+ capabilities: normalize_capabilities(offering[:capabilities]),
134
+ limits: offering[:limits] || {},
135
+ enabled: offering.fetch(:enabled, true),
136
+ cost: offering[:cost]
137
+ }
138
+ end
139
+
140
+ def maybe_fleet_lane(offering, lane)
141
+ return nil unless offering_type(offering) == :inference
142
+
143
+ settings = Legion::Settings.dig(:extensions, :llm, :openai) || {}
144
+ return nil unless settings[:fleet]&.dig(:dispatch, :enabled)
145
+
146
+ fleet_fields = {
147
+ tier: :fleet,
148
+ provider_family: lane[:provider_family],
149
+ instance_id: lane[:instance_id],
150
+ type: lane[:type],
151
+ model: lane[:model]
152
+ }
153
+ lane.merge(
154
+ id: Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(fleet_fields),
155
+ tier: :fleet
156
+ )
157
+ end
158
+
159
+ def offering_type(offering)
160
+ %i[embed embedding].include?(offering[:type]) ? :embedding : :inference
161
+ end
162
+
163
+ def normalize_capabilities(caps)
164
+ if defined?(Legion::Extensions::Llm::Inventory::Capabilities) &&
165
+ Legion::Extensions::Llm::Inventory::Capabilities.respond_to?(:normalize)
166
+ Legion::Extensions::Llm::Inventory::Capabilities.normalize(caps)
167
+ else
168
+ Array(caps)
169
+ end
170
+ end
50
171
  end
51
172
  end
52
173
  end
@@ -202,8 +202,7 @@ module Legion
202
202
  log.debug('Listing OpenAI models')
203
203
  raw = connection.get(models_url)
204
204
  models = build_model_infos(raw.body)
205
- log.debug { "Discovered #{models.size} OpenAI models; publishing registry availability" }
206
- self.class.registry_publisher.publish_models_async(models, readiness: readiness(live: false))
205
+ log.debug { "Discovered #{models.size} OpenAI models" }
207
206
  models
208
207
  rescue StandardError => e
209
208
  handle_exception(e, level: :error, handled: true,
@@ -211,17 +210,17 @@ module Legion
211
210
  raise
212
211
  end
213
212
 
214
- def discover_offerings(live: false, **)
215
- models = if live
216
- @cached_models = list_models
217
- else
218
- Array(@cached_models)
219
- end
220
- offerings = models.filter_map { |model_info| offering_from_model(model_info) }
221
- log.debug { "built #{offerings.size} OpenAI offering(s) live=#{live}" }
222
- offerings
223
- rescue StandardError => e
224
- handle_exception(e, level: :warn, handled: true, operation: 'openai.discover_offerings')
213
+ def discover_offerings(live: false, raise_on_unreachable: false, **filters)
214
+ return filter_cached_offerings(Array(@cached_offerings), filters) unless live
215
+
216
+ provider_health = health(live:)
217
+ @cached_offerings = discover_live_offerings(filters, provider_health, live:)
218
+ log_discover_complete(@cached_offerings)
219
+ @cached_offerings
220
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
221
+ log.warn("[#{slug}] instance=#{provider_instance_id} unreachable: #{e.message}")
222
+ raise if raise_on_unreachable
223
+
225
224
  []
226
225
  end
227
226
 
@@ -229,20 +228,57 @@ module Legion
229
228
  # Maps raw CAPABILITY_MAP symbol arrays to the boolean hash format
230
229
  # that CapabilityPolicy.resolve expects as :provider_catalog.
231
230
  CATALOG_CAPABILITY_MAPPING = {
231
+ completion: :completion,
232
232
  streaming: :streaming,
233
233
  function_calling: :tools,
234
234
  tools: :tools,
235
235
  vision: :vision,
236
236
  structured_output: :structured_output,
237
237
  reasoning: :thinking,
238
- embedding: :embeddings,
238
+ embedding: :embedding,
239
239
  image_generation: :image,
240
240
  audio_transcription: :audio_transcription,
241
241
  audio_generation: :audio_speech
242
242
  }.freeze
243
243
 
244
+ def discover_live_offerings(filters, provider_health, live:)
245
+ readiness = discovery_registry_readiness(provider_health, live:)
246
+ Array(list_models(live:, **filters)).filter_map do |model|
247
+ self.class.registry_publisher.publish_models_async([model], readiness:)
248
+ next unless model_matches_filters?(model, filters)
249
+ next unless model_allowed?(model.id)
250
+
251
+ log_model_discovered(model)
252
+ offering_from_model(model, health: provider_health)
253
+ end
254
+ end
255
+
256
+ def log_model_discovered(model)
257
+ log.debug(
258
+ "[#{slug}] instance=#{provider_instance_id} action=model_discovered " \
259
+ "model=#{model.id} family=#{model.family}"
260
+ )
261
+ end
262
+
263
+ def log_discover_complete(offerings)
264
+ log.info(
265
+ "[#{slug}] instance=#{provider_instance_id} action=discover_complete " \
266
+ "model_count=#{Array(offerings).size}"
267
+ )
268
+ end
269
+
244
270
  private
245
271
 
272
+ def discovery_registry_readiness(provider_health, live:)
273
+ {
274
+ provider: slug.to_sym,
275
+ configured: configured?,
276
+ ready: provider_health[:ready] == true,
277
+ live: live,
278
+ health: provider_health
279
+ }
280
+ end
281
+
246
282
  def build_model_infos(body)
247
283
  body.fetch('data', []).map do |raw_model|
248
284
  id = raw_model.fetch('id')
@@ -278,11 +314,11 @@ module Legion
278
314
  }
279
315
  end
280
316
 
281
- def offering_from_model(model_info)
317
+ def offering_from_model(model_info, health: {})
282
318
  policy = resolve_model_policy(model_info)
283
319
 
284
320
  Legion::Extensions::Llm::Routing::ModelOffering.new(
285
- offering_attrs_for(model_info, policy)
321
+ offering_attrs_for(model_info, policy, health:)
286
322
  )
287
323
  end
288
324
 
@@ -300,23 +336,32 @@ module Legion
300
336
  )
301
337
  end
302
338
 
303
- def offering_attrs_for(model_info, policy)
339
+ def offering_attrs_for(model_info, policy, health: {})
304
340
  {
305
341
  provider_family: :openai,
306
- instance_id: config.respond_to?(:instance_id) ? config.instance_id : :default,
307
- transport: :http,
308
- tier: :frontier,
342
+ instance_id: offering_instance_id,
343
+ transport: offering_transport,
344
+ tier: offering_tier,
309
345
  model: model_info.id,
310
- canonical_model_alias: model_info.respond_to?(:name) ? model_info.name : nil,
346
+ canonical_model_alias: offering_alias(model_info),
311
347
  model_family: infer_model_family(model_info.id),
312
348
  usage_type: infer_usage_type(model_info),
313
349
  capabilities: policy[:capabilities],
314
350
  capability_sources: policy[:sources],
315
351
  limits: { context_window: model_info.context_length }.compact,
352
+ health: health,
316
353
  metadata: { capability_sources: policy[:sources] }
317
354
  }
318
355
  end
319
356
 
357
+ def offering_instance_id
358
+ config.respond_to?(:instance_id) ? config.instance_id : :default
359
+ end
360
+
361
+ def offering_alias(model_info)
362
+ model_info.respond_to?(:name) ? model_info.name : nil
363
+ end
364
+
320
365
  def capabilities_to_boolean_hash(capability_symbols)
321
366
  return {} unless capability_symbols.is_a?(Array)
322
367
 
@@ -328,49 +373,6 @@ module Legion
328
373
  result
329
374
  end
330
375
 
331
- def provider_capability_config
332
- return {} unless defined?(Legion::Extensions::Llm::CredentialSources)
333
-
334
- conf = Legion::Extensions::Llm::CredentialSources.setting(:extensions, :llm, :openai)
335
- conf.is_a?(Hash) ? conf.to_h.except(:instances, 'instances') : {}
336
- rescue StandardError => e
337
- handle_exception(e, level: :debug, handled: true, operation: 'openai.provider_capability_config')
338
- {}
339
- end
340
-
341
- def instance_capability_config
342
- cfg = config
343
- result = {}
344
- %i[capabilities enable_thinking enable_tools enable_streaming enable_vision enable_embeddings
345
- thinking_flag tools_flag streaming_flag vision_flag embedding_flag embeddings_flag
346
- tool_flag images_flag image_flag].each do |key|
347
- next unless cfg.respond_to?(key)
348
-
349
- val = cfg.send(key)
350
- result[key] = val unless val.nil?
351
- rescue StandardError
352
- next
353
- end
354
- result
355
- end
356
-
357
- def model_capability_config(model_id)
358
- models_conf = fetch_models_config
359
- return {} unless models_conf.respond_to?(:to_h)
360
-
361
- models_conf.to_h[model_id.to_s] || models_conf.to_h[model_id.to_sym] || {}
362
- rescue StandardError => e
363
- handle_exception(e, level: :debug, handled: true, operation: 'openai.model_capability_config')
364
- {}
365
- end
366
-
367
- def fetch_models_config
368
- return config.models if config.respond_to?(:models)
369
- return config[:models] if config.respond_to?(:[])
370
-
371
- nil
372
- end
373
-
374
376
  def infer_model_family(model_id)
375
377
  CAPABILITY_MAP.each_key do |prefix|
376
378
  return prefix.tr('-', '_').to_sym if model_id.start_with?(prefix)
@@ -382,7 +384,7 @@ module Legion
382
384
  caps = model_info.respond_to?(:capabilities) ? Array(model_info.capabilities) : []
383
385
  return :embedding if caps.include?(:embedding)
384
386
  return :moderation if caps.include?(:moderation)
385
- return :image if caps.include?(:image_generation)
387
+ return :image if caps.include?(:image)
386
388
 
387
389
  :inference
388
390
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Openai
7
- VERSION = '0.4.5'
7
+ VERSION = '0.4.8'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-openai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.4.8
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.5.4
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.5.4
81
+ version: 0.6.0
82
82
  description: OpenAI provider integration for the LegionIO LLM routing framework.
83
83
  email:
84
84
  - matthewdiverson@gmail.com