lex-llm-azure-foundry 0.2.10 → 0.2.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: 7e151d90b17f955ff40511cc8e135f909ca7fd45d54147f357947bce824d04dd
4
- data.tar.gz: c6972df094e1e34636cea4762ed40dd75737080231a4af3c21f1f4062676056d
3
+ metadata.gz: 76e76ee4edefeb764f3d1829a900e1bf60bf9ccc598532feb0d6033680af329f
4
+ data.tar.gz: 778667f3720744c2b5ea38e96d52db29d22c4228f4247a55e9976fd2e1b85612
5
5
  SHA512:
6
- metadata.gz: 3354eab641cb8026d825eb32b2a014316daa17b8fbccc659b067efc7d52c2ff999d3a624ecbca54773c50e36a83f0cea3bacc52a7e903c268010dbc81bfe5c2b
7
- data.tar.gz: 15e2d75ed42617bdc0a9c49be74e42b09aaa70a5e9c45f12cae16320f78d33180058a233f64c043e012433b76021d69dd2dfdcd078502aa3727bfa203b64efb2
6
+ metadata.gz: f1facb1bad185f1b7d2429213869f637598802d078ea1bf453c670de3ff5426f56e763d0102e3b15ce78f4afb6bd36788bf7269918416191b0daa483e5ae6ca4
7
+ data.tar.gz: aa1a7c88250da3f1d676ef92fbb0f46522d44994e54d49707daad0ec38eb0e940341ea4f9b0661af9d117771b4fe5d3e4156a3c29d21a11b74446dd9fdbd9570
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.13] - 2026-06-20
4
+
5
+ ### Fixed
6
+ - Stub shared registry publishing through `RegistryPublisher#schedule` in specs so async availability-event coverage stays stable after the shared publisher moved off raw `Thread.new`.
7
+
8
+ ## [0.2.12] - 2026-06-20
9
+
10
+ ### Changed
11
+ - Align Azure Foundry instance discovery with the shared `lex-llm` contract by preserving explicit tier overrides while defaulting unconfigured instances to `:cloud`.
12
+ - Restore offline deployment-backed offering discovery and carry the configured provider instance id through Azure offering metadata.
13
+ - Normalize Azure Foundry capability and health metadata to the current shared offering contract.
14
+
15
+ ## [0.2.11] - 2026-06-19
16
+
17
+ ### Changed
18
+ - Adopt `Legion::Extensions::Llm::Inventory::ScopedRefresher` mixin (lex-llm 0.6.0). Discovery
19
+ refresh actors now write directly to the live `Inventory` catalog via `Inventory.write_lane`.
20
+ - Pin `lex-llm >= 0.6.0` and `legion-llm >= 0.14.0` in gemspec.
21
+ - Standard `weight: 100` default added to provider instance settings schema.
22
+
3
23
  ## 0.2.10 - 2026-06-16
4
24
 
5
25
  - Dependency updates and code quality improvements.
@@ -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.0'
30
+ spec.add_dependency 'lex-llm', '>= 0.6.0'
31
31
  end
@@ -1,11 +1,19 @@
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
6
8
  warn(e.message) if $VERBOSE
7
9
  end
8
10
 
11
+ begin
12
+ require 'legion/extensions/llm/inventory/scoped_refresher'
13
+ rescue LoadError => e
14
+ warn(e.message) if $VERBOSE
15
+ end
16
+
9
17
  return unless defined?(Legion::Extensions::Actors::Every)
10
18
 
11
19
  module Legion
@@ -13,10 +21,16 @@ module Legion
13
21
  module Llm
14
22
  module AzureFoundry
15
23
  module Actor
16
- class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation
24
+ class DiscoveryRefresh < Legion::Extensions::Actors::Every # rubocop:disable Style/Documentation,Metrics/ClassLength
17
25
  include Legion::Logging::Helper
18
26
 
19
- REFRESH_INTERVAL = 1800
27
+ if defined?(Legion::Extensions::Llm::Inventory::ScopedRefresher)
28
+ include Legion::Extensions::Llm::Inventory::ScopedRefresher
29
+ end
30
+
31
+ EMBED_TYPES = %i[embed embedding].freeze
32
+
33
+ def self.every_seconds = 3600
20
34
 
21
35
  def runner_class = self.class
22
36
  def runner_function = 'manual'
@@ -26,13 +40,15 @@ module Legion
26
40
  def generate_task? = false
27
41
 
28
42
  def time
29
- return REFRESH_INTERVAL unless defined?(Legion::Settings)
43
+ return self.class.every_seconds unless defined?(Legion::Settings)
30
44
 
31
- Legion::Settings.dig(:extensions, :llm, :azure_foundry, :discovery_interval) || REFRESH_INTERVAL
45
+ Legion::Settings.dig(:extensions, :llm, :azure_foundry, :discovery_interval) || self.class.every_seconds
32
46
  end
33
47
 
34
- def manual
48
+ def manual # rubocop:disable Metrics/CyclomaticComplexity
35
49
  log.debug('[azure_foundry][discovery_refresh] refreshing model list')
50
+ tick if respond_to?(:tick)
51
+
36
52
  return unless defined?(Legion::LLM::Discovery)
37
53
 
38
54
  Legion::LLM::Discovery.refresh_discovered_models!(provider: :azure_foundry)
@@ -46,6 +62,109 @@ module Legion
46
62
  rescue StandardError => e
47
63
  handle_exception(e, level: :warn, handled: true, operation: 'azure_foundry.actor.discovery_refresh')
48
64
  end
65
+
66
+ def scope_key(**)
67
+ { provider: :azure_foundry }
68
+ end
69
+
70
+ def compute_lanes_for_scope(**)
71
+ return [] unless defined?(Legion::LLM::Call::Registry)
72
+
73
+ instances = Legion::LLM::Call::Registry.all_instances.select do |e|
74
+ (e[:provider] || '').to_sym == :azure_foundry
75
+ end
76
+
77
+ lanes = []
78
+ instances.each { |entry| collect_instance_lanes(entry, lanes) }
79
+ lanes
80
+ rescue StandardError => e
81
+ handle_exception(e, level: :warn, handled: true,
82
+ operation: 'azure_foundry.compute_lanes_for_scope')
83
+ []
84
+ end
85
+
86
+ def credential_hash(**)
87
+ settings = Legion::Settings.dig(:extensions, :llm, :azure_foundry) || {}
88
+ ::Digest::SHA256.hexdigest(settings[:api_key].to_s + settings[:instances].to_s)[0, 16]
89
+ rescue StandardError
90
+ 'unknown'
91
+ end
92
+
93
+ private
94
+
95
+ def collect_instance_lanes(instance_entry, lanes)
96
+ adapter = instance_entry[:adapter]
97
+ return unless adapter.respond_to?(:discover_offerings)
98
+
99
+ Array(adapter.discover_offerings(live: true)).each do |offering|
100
+ build_and_append_lanes(offering, instance_entry, lanes)
101
+ end
102
+ rescue StandardError => e
103
+ handle_exception(e, level: :warn, handled: true,
104
+ operation: 'azure_foundry.compute_lanes_for_scope.instance')
105
+ end
106
+
107
+ def build_and_append_lanes(offering, instance_entry, lanes)
108
+ raw = offering.respond_to?(:to_h) ? offering.to_h : offering
109
+ return unless raw.is_a?(Hash)
110
+
111
+ lane = build_lane(raw, instance_entry)
112
+ lanes << lane
113
+ lanes << lane.merge(id: fleet_id_for(lane), tier: :fleet) if fleet_enabled? && lane[:type] == :inference
114
+ end
115
+
116
+ def build_lane(raw, instance_entry) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
117
+ model = raw[:model] || raw['model']
118
+ instance_id = raw[:instance_id] || raw['instance_id'] ||
119
+ instance_entry[:instance] || instance_entry[:instance_id] ||
120
+ instance_entry[:id] || :default
121
+ pf = raw[:provider_family] || raw['provider_family'] || :azure_foundry
122
+ type = resolve_type(raw)
123
+ tier = (raw[:tier] || raw['tier'] || :cloud).to_sym
124
+ lane_id = compose_lane_id(tier: tier, provider_family: pf, instance_id: instance_id,
125
+ type: type, model: model)
126
+
127
+ {
128
+ id: lane_id,
129
+ tier: tier,
130
+ provider_family: pf,
131
+ instance_id: instance_id,
132
+ model: model,
133
+ canonical_model_alias: raw[:canonical_model_alias] || raw['canonical_model_alias'],
134
+ type: type,
135
+ capabilities: normalize_capabilities(raw[:capabilities] || raw['capabilities'] || []),
136
+ limits: raw[:limits] || raw['limits'] || {},
137
+ enabled: raw.fetch(:enabled, raw.fetch('enabled', true)),
138
+ cost: raw[:cost] || raw['cost'] || {}
139
+ }
140
+ end
141
+
142
+ def resolve_type(raw)
143
+ val = raw[:type] || raw['type'] || raw[:usage_type] || raw['usage_type']
144
+ EMBED_TYPES.include?(val&.to_sym) ? :embedding : :inference
145
+ end
146
+
147
+ def normalize_capabilities(raw)
148
+ if defined?(Legion::Extensions::Llm::Inventory::Capabilities)
149
+ Legion::Extensions::Llm::Inventory::Capabilities.normalize(raw)
150
+ else
151
+ Array(raw)
152
+ end
153
+ end
154
+
155
+ def compose_lane_id(**fields)
156
+ Legion::Extensions::Llm::Inventory::ScopedRefresher.compose_id(fields)
157
+ end
158
+
159
+ def fleet_id_for(lane)
160
+ compose_lane_id(tier: :fleet, provider_family: lane[:provider_family],
161
+ instance_id: lane[:instance_id], type: lane[:type], model: lane[:model])
162
+ end
163
+
164
+ def fleet_enabled?
165
+ settings = Legion::Settings.dig(:extensions, :llm, :azure_foundry) || {}
166
+ settings[:fleet]&.dig(:dispatch, :enabled)
167
+ end
49
168
  end
50
169
  end
51
170
  end
@@ -121,6 +121,10 @@ module Legion
121
121
 
122
122
  def stream_usage_supported? = true
123
123
 
124
+ def settings
125
+ AzureFoundry.default_settings.dig(:instances, :default)
126
+ end
127
+
124
128
  def api_base
125
129
  endpoint = config.azure_foundry_endpoint.to_s.sub(%r{/*\z}, '')
126
130
  return "#{endpoint}/openai/v1" if surface == OPENAI_V1_SURFACE && !endpoint.end_with?('/openai/v1')
@@ -143,29 +147,17 @@ module Legion
143
147
  def embedding_url(**) = path_for('embeddings')
144
148
  def health_url = models_url
145
149
 
146
- def discover_offerings(live: false, **filters)
147
- log.info { "discovering offerings live=#{live} from #{api_base}" }
148
- offerings = filter_offerings(allowed_offerings, **filters)
149
- return offerings unless live
150
-
151
- offerings.map do |offering|
152
- with_live_metadata(offering)
153
- rescue StandardError => e
154
- handle_exception(e, level: :warn, handled: true, operation: 'azure_foundry.discover_offerings')
155
- with_health(offering, ready: false, checked: true, error: e)
156
- end
157
- end
158
-
159
- def offering_for(model:, model_family: nil, canonical_model_alias: nil, instance_id: :default, # rubocop:disable Metrics/ParameterLists
150
+ def offering_for(model:, model_family: nil, canonical_model_alias: nil, instance_id: nil, # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize
160
151
  usage_type: nil, **metadata)
161
152
  deployment = self.class.deployment_config(model, config:)
162
153
  model_id = self.class.resolve_model_id(model, config:)
163
154
  configured_family = value_for(deployment, :model_family)
164
155
  configured_alias = value_for(deployment, :canonical_model_alias)
156
+ resolved_instance_id = instance_id || provider_instance_id
165
157
 
166
158
  build_offering(
167
159
  model: model_id,
168
- instance_id: instance_id,
160
+ instance_id: resolved_instance_id,
169
161
  model_family: normalize_family(model_family || configured_family || infer_model_family(model_id)),
170
162
  canonical_model_alias: canonical_model_alias || configured_alias,
171
163
  usage_type: usage_type || value_for(deployment, :usage_type) || usage_type_for(model_id),
@@ -179,10 +171,12 @@ module Legion
179
171
  return baseline.merge(checked: false) unless live
180
172
 
181
173
  response = connection.get(health_url)
182
- baseline.merge(checked: true, model_info: response.body)
174
+ baseline.merge(checked: true, ready: true, status: 'healthy', circuit_state: 'closed',
175
+ raw: response.body)
183
176
  rescue StandardError => e
184
177
  handle_exception(e, level: :warn, handled: true, operation: 'azure_foundry.health')
185
- baseline.merge(checked: true, ready: false, error: e.class.name, message: e.message)
178
+ baseline.merge(checked: true, ready: false, status: 'unhealthy', circuit_state: 'open',
179
+ error: e.class.name, message: e.message)
186
180
  end
187
181
 
188
182
  def readiness(live: false)
@@ -258,11 +252,15 @@ module Legion
258
252
  end
259
253
 
260
254
  def health_baseline(live)
255
+ ready = configured?
261
256
  {
262
257
  provider: :azure_foundry,
263
- configured: configured?,
264
- ready: configured?,
258
+ instance_id: provider_instance_id,
259
+ configured: ready,
260
+ ready: ready,
265
261
  live: live,
262
+ status: ready ? 'healthy' : 'unhealthy',
263
+ circuit_state: ready ? 'closed' : 'open',
266
264
  api_base: api_base,
267
265
  surface: surface
268
266
  }
@@ -298,6 +296,20 @@ module Legion
298
296
  token ? "Bearer #{token}" : nil
299
297
  end
300
298
 
299
+ def discover_offerings(live: false, raise_on_unreachable: false, **filters)
300
+ offerings = allowed_offerings
301
+ return filter_offerings(offerings, **filters) unless live
302
+
303
+ resolved = offerings.map { |offering| with_live_metadata(offering) }
304
+ filter_offerings(resolved, **filters)
305
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
306
+ log.warn("[#{slug}] instance=#{provider_instance_id} unreachable: #{e.message}")
307
+ raise if raise_on_unreachable
308
+
309
+ []
310
+ end
311
+ public :discover_offerings
312
+
301
313
  def configured_deployments
302
314
  self.class.normalize_deployments(config.azure_foundry_deployments)
303
315
  end
@@ -315,16 +327,34 @@ module Legion
315
327
  end
316
328
 
317
329
  def offering_from_config(deployment)
318
- deployment_name = value_for(deployment, :deployment) || value_for(deployment, :model)
319
- return nil if deployment_name.to_s.empty?
320
-
321
330
  offering_for(
322
- model: deployment_name,
331
+ model: value_for(deployment, :deployment) || value_for(deployment, :model),
323
332
  model_family: value_for(deployment, :model_family),
324
333
  canonical_model_alias: value_for(deployment, :canonical_model_alias),
325
- instance_id: value_for(deployment, :instance_id) || :default,
326
- usage_type: value_for(deployment, :usage_type),
327
- configured: true
334
+ instance_id: provider_instance_id,
335
+ usage_type: value_for(deployment, :usage_type) || :inference,
336
+ metadata: deployment_metadata(deployment)
337
+ )
338
+ end
339
+
340
+ def offering_from_model(model_info, _health: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
341
+ model_id = model_info.respond_to?(:id) ? model_info.id : model_info['id']
342
+ name = model_info.respond_to?(:name) ? model_info.name : model_info['name'] || model_id
343
+ family = model_info.respond_to?(:family) ? model_info.family : model_info['model_family']
344
+ usage = model_info.respond_to?(:embedding?) && model_info.embedding? ? :embedding : :inference
345
+ meta = if model_info.respond_to?(:metadata)
346
+ model_info.metadata
347
+ else
348
+ model_info.is_a?(Hash) ? model_info : {}
349
+ end
350
+
351
+ offering_for(
352
+ model: model_id,
353
+ model_family: family,
354
+ canonical_model_alias: name,
355
+ instance_id: provider_instance_id,
356
+ usage_type: usage,
357
+ metadata: meta
328
358
  )
329
359
  end
330
360
 
@@ -376,7 +406,7 @@ module Legion
376
406
 
377
407
  def resolve_capability_policy(model, usage_type)
378
408
  if usage_type.to_sym == :embedding
379
- return { capabilities: %i[embeddings], sources: { embeddings: { value: true, source: :model_metadata } } }
409
+ return { capabilities: %i[embedding], sources: { embedding: { value: true, source: :model_metadata } } }
380
410
  end
381
411
 
382
412
  real_caps = real_capabilities_for(model)
@@ -420,7 +450,7 @@ module Legion
420
450
 
421
451
  cfg.except(:instances, 'instances')
422
452
  rescue StandardError => e
423
- handle_exception(e, level: :debug, handled: true, operation: 'azure_foundry.provider_level_config')
453
+ handle_exception(e, level: :warn, handled: true, operation: 'azure_foundry.provider_level_config')
424
454
  {}
425
455
  end
426
456
 
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module AzureFoundry
7
- VERSION = '0.2.10'
7
+ VERSION = '0.2.13'
8
8
  end
9
9
  end
10
10
  end
@@ -66,7 +66,7 @@ module Legion
66
66
  cfg['api_base']
67
67
  return if endpoint.nil? || endpoint.to_s.strip.empty?
68
68
 
69
- instances[:settings] = normalize_instance_config(cfg).merge(tier: :cloud)
69
+ instances[:settings] = normalize_instance_config(cfg).merge(tier: cfg[:tier] || cfg['tier'] || :cloud)
70
70
  end
71
71
 
72
72
  def self.discover_named_instances(instances)
@@ -86,7 +86,8 @@ module Legion
86
86
  config[:api_base] || config['api_base']
87
87
  return if endpoint.nil? || endpoint.to_s.strip.empty?
88
88
 
89
- instances[name.to_sym] = normalize_instance_config(config).merge(tier: :cloud)
89
+ instances[name.to_sym] =
90
+ normalize_instance_config(config).merge(tier: config[:tier] || config['tier'] || :cloud)
90
91
  end
91
92
 
92
93
  def self.normalize_instance_config(config) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-llm-azure-foundry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.10
4
+ version: 0.2.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.5.0
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.0
81
+ version: 0.6.0
82
82
  description: Azure AI Foundry and Azure OpenAI hosted provider integration for LegionIO
83
83
  LLM routing.
84
84
  email: