lex-llm-openai 0.4.5 → 0.4.9

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: 137435c2996f9aa9c56c884f616c824561ef2493c30a3c113a2a8261eb37c88c
4
+ data.tar.gz: 81b9d2b79bdeb8f708338ce9bd66d923a5165643f2416be5bfe730db53c41a61
5
5
  SHA512:
6
- metadata.gz: 0abdd81c00439b7ccba758adbd7f8576e83ce5e367673ce2092708dc3f61290c406db462e0736e56402957c32f47b0fd781bdcf6486508a5b8486ab48f6d2edf
7
- data.tar.gz: 29b4b768e0bd91aee7cd05283b62caf93d4ffbd6215063c7fa480d3ed1a2b164341e669d6badcb4e7f804db55e9500e81c016eef5f4a7223c20039730942ec37
6
+ metadata.gz: 5cf576c1d1bae53ce4acaae572a984ebe2c02032b570ed720fc596f6b820567905c0de57592d9addbf47ba72d30867847a18213ec11be7a81cf315699c88bc3c
7
+ data.tar.gz: 151b3e313e710f56678eb1a2e042d6208cf35dd3a00a4b93a27107ec11e2aa74200aa5e3b492beba1db89096a5e3dd1b4fb5ae7165a6ce4fa54b84b12ac26d3c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.9] - 2026-07-24
4
+
5
+ ### Fixed
6
+ - **Translator no longer discards content when `finish_reason` is present on SSE chunk.** Previously returned a done chunk immediately when finish_reason was set, silently dropping any content, reasoning, or tool_call data on the same event. Now checks for content fields first and only emits done when the delta is truly empty. Passes `stop_reason` and `usage` through to content/tool_call chunks.
7
+
8
+ ## [0.4.8] - 2026-06-20
9
+
10
+ ### Fixed
11
+ - 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.
12
+
13
+ ## [0.4.7] - 2026-06-20
14
+
15
+ ### Fixed
16
+ - 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.
17
+ - Move provider/instance/model capability override extraction onto the shared base provider implementation.
18
+
19
+ ## [0.4.6] - 2026-06-19
20
+
21
+ ### Changed
22
+ - Adopt `Legion::Extensions::Llm::Inventory::ScopedRefresher` mixin (lex-llm 0.6.0). Discovery
23
+ refresh actors now write directly to the live `Inventory` catalog via `Inventory.write_lane`.
24
+ - Pin `lex-llm >= 0.6.0` and `legion-llm >= 0.14.0` in gemspec.
25
+ - Standard `weight: 100` default added to provider instance settings schema.
26
+
3
27
  ## 0.4.5 - 2026-06-17
4
28
 
5
29
  ### 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
@@ -116,16 +116,26 @@ module Legion
116
116
  finish_reason = choice['finish_reason']
117
117
  usage_raw = data['usage']
118
118
 
119
- return build_done_chunk(data, finish_reason, usage_raw) if finish_reason
119
+ chunk_stop_reason = finish_reason ? map_stop_reason(finish_reason) : nil
120
+ chunk_usage = finish_reason && usage_raw ? parse_usage(usage_raw) : nil
121
+
120
122
  return parse_error_chunk(data) if data['error']
121
123
 
122
124
  reasoning = delta['reasoning_content'] || delta['reasoning']
123
125
  content = delta['content']
124
126
 
125
- return build_thinking_chunk(reasoning, data['id']) if reasoning
126
- return build_text_chunk(content, data['id']) if content
127
+ if reasoning
128
+ return build_thinking_chunk(reasoning, data['id'], stop_reason: chunk_stop_reason,
129
+ usage: chunk_usage)
130
+ end
131
+ return build_text_chunk(content, data['id'], stop_reason: chunk_stop_reason, usage: chunk_usage) if content
132
+
133
+ tc_chunk = parse_tool_call_delta(delta, data, stop_reason: chunk_stop_reason, usage: chunk_usage)
134
+ return tc_chunk if tc_chunk
135
+
136
+ return build_done_chunk(data, finish_reason, usage_raw) if finish_reason
127
137
 
128
- parse_tool_call_delta(delta, data)
138
+ nil
129
139
  rescue StandardError => e
130
140
  handle_exception(e, level: :error, handled: true, operation: 'openai.translator.parse_chunk')
131
141
  Canonical::Chunk.error_chunk(
@@ -403,21 +413,25 @@ module Legion
403
413
  )
404
414
  end
405
415
 
406
- def build_thinking_chunk(reasoning, request_id)
416
+ def build_thinking_chunk(reasoning, request_id, stop_reason: nil, usage: nil)
407
417
  Canonical::Chunk.thinking_delta(
408
418
  delta: reasoning,
409
- request_id: request_id
419
+ request_id: request_id,
420
+ stop_reason: stop_reason,
421
+ usage: usage
410
422
  )
411
423
  end
412
424
 
413
- def build_text_chunk(content, request_id)
425
+ def build_text_chunk(content, request_id, stop_reason: nil, usage: nil)
414
426
  Canonical::Chunk.text_delta(
415
427
  delta: content,
416
- request_id: request_id
428
+ request_id: request_id,
429
+ stop_reason: stop_reason,
430
+ usage: usage
417
431
  )
418
432
  end
419
433
 
420
- def parse_tool_call_delta(delta, data)
434
+ def parse_tool_call_delta(delta, data, stop_reason: nil, usage: nil)
421
435
  raw_tc = delta['tool_calls']
422
436
  return nil unless raw_tc&.any?
423
437
 
@@ -432,7 +446,9 @@ module Legion
432
446
 
433
447
  Canonical::Chunk.tool_call_delta(
434
448
  tool_call: tool_call,
435
- request_id: data['id']
449
+ request_id: data['id'],
450
+ stop_reason: stop_reason,
451
+ usage: usage
436
452
  )
437
453
  end
438
454
 
@@ -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.9'
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.9
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