lex-llm 0.4.8 → 0.4.10

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: 1babd0169f0795262a38d54afd99e737b647e9dffd3a6066a480cdabd45ba072
4
- data.tar.gz: f6b1433ab12a083ace1c22814a4b0525c57c0ce52ebddf710fdd4358bb92203e
3
+ metadata.gz: a203372d751b290a71cc289e382d80a49fafcc0687925c02594b8c5cfe6ef7aa
4
+ data.tar.gz: 95cfd5a03c002a16da80bac58914f1fb808a940db378d99035f97b6256240863
5
5
  SHA512:
6
- metadata.gz: aebc2f2c85f5d06c4c5d9409d071f6043a6e3e53fbe23f474f0cfea9b452dc5ae2da44c7c44281562797d09621dc9287062c37f9d3710e76b02ea8d79d6712ec
7
- data.tar.gz: 698e2b35ef850a75d845860ac5342b45f2eb4d0a570f77e1a3e76ea0e93bbe9cd6b44d9f7b1d1beecb1936c6cd4df58989a3ea394a3394f2978cd057b3b60935
6
+ metadata.gz: 645bde1f8e4b6701fa5092f2b92e2867f63d243c239341e691921efa9cc74a861b3382f00665efd8f4d1420976c462f9c47eaa89bdae93ae60655983681bcddc
7
+ data.tar.gz: f180a90275c427970e6129ae3f0ef285fabb68fa92a97059687fb37fcf9282f5e083b159f3757293a79ae4bf71f263a54fb9387469f55da25a23959e295d2371
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.10 - 2026-05-13
4
+
5
+ - Add cache-backed `model_detail` lookup with 24-hour TTL; nil results are not cached; `fetch_model_detail` hook for subclasses to override with live API calls.
6
+ - Build `model_detail_cache_key` from tier, slug, instance, and credential fingerprint so remote providers never share model detail entries across credentials.
7
+ - Add `credential_cache_fragment` — includes an 8-char SHA-256 credential fingerprint in cache keys for non-local providers.
8
+ - Add `source_tag`, `credential_fingerprint`, and `config_fingerprint` to `CredentialSources` for provenance tracking across discovered instances.
9
+ - Suppress Faraday raw stacktrace dumps on connection failures by setting `errors: false` on the response logger middleware.
10
+ - Rescue `Faraday::ConnectionFailed` in `discover_offerings` and return an empty list with a concise warning instead of propagating the exception.
11
+ - Wire `model_allowed?` filtering into `discover_offerings` so whitelist/blacklist settings are enforced during live discovery (was dead code before).
12
+ - Check instance config first for `model_whitelist`/`model_blacklist` before falling back to provider settings, enabling per-instance override.
13
+ - Add `legion-cache >= 1.3.0` as a runtime dependency and include `Legion::Cache::Helper` in the base `Provider` class.
14
+
15
+ ## 0.4.9 - 2026-05-13
16
+
17
+ - Route provider, tool, streaming, model, attachment, connection, credential, and fleet diagnostics through `Legion::Logging::Helper`.
18
+ - Replace temporary provider and stream probes with helper-backed debug logs that preserve model, tool, parameter, and header-key context without stdout or fatal-level noise.
19
+ - Add handled debug exception logging around provider discovery, credential probes, and fleet cleanup fallbacks.
20
+ - Fix provider request debug logging when callers pass tools as a hash.
21
+
3
22
  ## 0.4.8 - 2026-05-11
4
23
 
5
24
  - Set `remote_invocable?` to false — this extension does not need remote AMQP topology (exchanges, queues, DLX).
data/lex-llm.gemspec CHANGED
@@ -35,6 +35,7 @@ Gem::Specification.new do |spec|
35
35
  spec.add_dependency 'faraday-multipart', '>= 1'
36
36
  spec.add_dependency 'faraday-net_http', '>= 1'
37
37
  spec.add_dependency 'faraday-retry', '>= 1'
38
+ spec.add_dependency 'legion-cache', '>= 1.3.0'
38
39
  spec.add_dependency 'legion-crypt', '>= 1.5.1'
39
40
  spec.add_dependency 'legion-json', '>= 1.2.1'
40
41
  spec.add_dependency 'legion-logging', '>= 1.3.2'
@@ -8,6 +8,8 @@ module Legion
8
8
  module Llm
9
9
  # A class representing a file attachment.
10
10
  class Attachment
11
+ include Legion::Logging::Helper
12
+
11
13
  attr_reader :source, :filename, :mime_type
12
14
 
13
15
  def initialize(source, filename: nil)
@@ -50,9 +52,7 @@ module Legion
50
52
  elsif io_like?
51
53
  load_content_from_io
52
54
  else
53
- Legion::Extensions::Llm.logger.warn(
54
- "Source is neither a URL, path, ActiveStorage, nor IO-like: #{@source.class}"
55
- )
55
+ log.warn { "Source is neither a URL, path, ActiveStorage, nor IO-like: #{@source.class}" }
56
56
  nil
57
57
  end
58
58
 
@@ -5,6 +5,8 @@ module Legion
5
5
  module Llm
6
6
  # Global configuration for Legion::Extensions::Llm
7
7
  class Configuration
8
+ include Legion::Logging::Helper
9
+
8
10
  class << self
9
11
  # Declare a single configuration option.
10
12
  def option(key, default = nil)
@@ -68,7 +70,7 @@ module Legion
68
70
  elsif Regexp.respond_to?(:timeout)
69
71
  @log_regexp_timeout = value
70
72
  else
71
- Legion::Extensions::Llm.logger.warn("log_regexp_timeout is not supported on Ruby #{RUBY_VERSION}")
73
+ log.warn { "log_regexp_timeout is not supported on Ruby #{RUBY_VERSION}" }
72
74
  @log_regexp_timeout = value
73
75
  end
74
76
  end
@@ -5,18 +5,34 @@ module Legion
5
5
  module Llm
6
6
  # Connection class for managing API connections to various providers.
7
7
  class Connection
8
+ include Legion::Logging::Helper
9
+
8
10
  attr_reader :provider, :connection, :config
9
11
 
10
- def self.basic(&)
11
- Faraday.new do |f|
12
- f.response :logger,
13
- Legion::Extensions::Llm.logger,
14
- bodies: false,
15
- errors: true,
16
- headers: false,
17
- log_level: :debug
18
- f.response :raise_error
19
- yield f if block_given?
12
+ class << self
13
+ include Legion::Logging::Helper
14
+
15
+ def basic(&)
16
+ logger = faraday_logger
17
+ Faraday.new do |f|
18
+ f.response :logger,
19
+ logger,
20
+ bodies: false,
21
+ errors: true,
22
+ headers: false,
23
+ log_level: :debug
24
+ f.response :raise_error
25
+ yield f if block_given?
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ def faraday_logger
32
+ config = Legion::Extensions::Llm.config
33
+ return config.logger if config.respond_to?(:logger) && config.logger
34
+
35
+ log
20
36
  end
21
37
  end
22
38
 
@@ -59,10 +75,11 @@ module Legion
59
75
  end
60
76
 
61
77
  def setup_logging(faraday)
78
+ logger = faraday_logger
62
79
  faraday.response :logger,
63
- Legion::Extensions::Llm.logger,
64
- bodies: Legion::Extensions::Llm.logger.debug?,
65
- errors: true,
80
+ logger,
81
+ bodies: debug_logger?(logger),
82
+ errors: false,
66
83
  headers: false,
67
84
  log_level: :debug do |logger|
68
85
  logger.filter(logging_regexp('[A-Za-z0-9+/=]{100,}'), '[BASE64 DATA]')
@@ -70,6 +87,19 @@ module Legion
70
87
  end
71
88
  end
72
89
 
90
+ def faraday_logger
91
+ return config.logger if config.respond_to?(:logger) && config.logger
92
+
93
+ log
94
+ end
95
+
96
+ def debug_logger?(logger)
97
+ return logger.debug? if logger.respond_to?(:debug?)
98
+ return logger.level.to_i <= Logger::DEBUG if logger.respond_to?(:level)
99
+
100
+ false
101
+ end
102
+
73
103
  def logging_regexp(pattern)
74
104
  return Regexp.new(pattern) if @config.log_regexp_timeout.nil? || !Regexp.respond_to?(:timeout)
75
105
 
@@ -11,6 +11,9 @@ module Legion
11
11
  # network probes). All methods are pure readers — the calling provider
12
12
  # decides what to do with the result.
13
13
  module CredentialSources
14
+ include Legion::Logging::Helper
15
+ extend Legion::Logging::Helper
16
+
14
17
  CLAUDE_SETTINGS = File.expand_path('~/.claude/settings.json')
15
18
  CLAUDE_PROJECT = File.join(Dir.pwd, '.claude', 'settings.json')
16
19
  CODEX_AUTH = File.expand_path('~/.codex/auth.json')
@@ -79,7 +82,9 @@ module Legion
79
82
  return nil unless defined?(::Legion::Settings)
80
83
 
81
84
  ::Legion::Settings.dig(*path)
82
- rescue StandardError
85
+ rescue StandardError => e
86
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.credential_sources.setting',
87
+ path: path.map(&:to_s))
83
88
  nil
84
89
  end
85
90
 
@@ -104,7 +109,9 @@ module Legion
104
109
  end
105
110
  end
106
111
  true
107
- rescue StandardError
112
+ rescue StandardError => e
113
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.credential_sources.socket_open',
114
+ host:, port:)
108
115
  false
109
116
  ensure
110
117
  sock&.close
@@ -120,7 +127,9 @@ module Legion
120
127
  end
121
128
  response = conn.get(path)
122
129
  response.status >= 200 && response.status < 300
123
- rescue StandardError
130
+ rescue StandardError => e
131
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.credential_sources.http_ok',
132
+ path:)
124
133
  false
125
134
  ensure
126
135
  conn&.close if conn.respond_to?(:close)
@@ -158,6 +167,30 @@ module Legion
158
167
  Digest::SHA256.hexdigest(val.to_s)
159
168
  end
160
169
 
170
+ # Build a human-readable source tag describing where a credential was found.
171
+ # Format: "type:location:key" e.g. "env:ANTHROPIC_API_KEY", "file:~/.claude/settings.json:anthropicApiKey"
172
+ def source_tag(type, location, key = nil)
173
+ parts = [type.to_s, location.to_s]
174
+ parts << key.to_s if key && !key.to_s.empty?
175
+ parts.join(':')
176
+ end
177
+
178
+ # Generate a short fingerprint (first 8 chars of SHA-256) for a credential value.
179
+ # Stable for the lifetime of the credential; safe to log and include in audit events.
180
+ def credential_fingerprint(value)
181
+ return nil if value.nil? || value.to_s.strip.empty?
182
+
183
+ Digest::SHA256.hexdigest(value.to_s)[0, 8]
184
+ end
185
+
186
+ # Extract fingerprint from a config hash by finding the first credential field.
187
+ def config_fingerprint(config)
188
+ val = config[:api_key] || config['api_key'] ||
189
+ config[:bearer_token] || config['bearer_token'] ||
190
+ config[:access_token] || config['access_token']
191
+ credential_fingerprint(val)
192
+ end
193
+
161
194
  # Returns true when the URL points to localhost / 127.0.0.1 / ::1.
162
195
  def localhost?(url)
163
196
  return false if url.nil?
@@ -168,14 +201,17 @@ module Legion
168
201
 
169
202
  normalized = host.delete_prefix('[').delete_suffix(']')
170
203
  %w[localhost 127.0.0.1 ::1].include?(normalized)
171
- rescue URI::InvalidURIError
204
+ rescue URI::InvalidURIError => e
205
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.credential_sources.localhost')
172
206
  false
173
207
  end
174
208
 
175
209
  module_function :env, :claude_config, :claude_config_value,
176
210
  :claude_env_value, :codex_token, :codex_openai_key,
177
211
  :setting, :socket_open?, :http_ok?,
178
- :dedup_credentials, :credential_hash, :localhost?
212
+ :dedup_credentials, :credential_hash,
213
+ :source_tag, :credential_fingerprint, :config_fingerprint,
214
+ :localhost?
179
215
 
180
216
  # --- private helpers -----------------------------------------------
181
217
 
@@ -199,7 +235,9 @@ module Legion
199
235
  else
200
236
  ::JSON.parse(raw, symbolize_names: true)
201
237
  end
202
- rescue StandardError
238
+ rescue StandardError => e
239
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.credential_sources.read_json',
240
+ path:)
203
241
  {}
204
242
  end
205
243
 
@@ -220,7 +258,8 @@ module Legion
220
258
  return true if exp.nil?
221
259
 
222
260
  exp.to_i > Time.now.to_i
223
- rescue StandardError
261
+ rescue StandardError => e
262
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.credential_sources.token_valid')
224
263
  true
225
264
  end
226
265
 
@@ -22,6 +22,9 @@ module Legion
22
22
  module Fleet
23
23
  # Shared implementation for provider-owned fleet responder runners.
24
24
  module ProviderResponder
25
+ include Legion::Logging::Helper
26
+ extend Legion::Logging::Helper
27
+
25
28
  class ConfigurationError < StandardError; end
26
29
 
27
30
  REQUIRED_FIELDS = %i[
@@ -71,6 +74,8 @@ module Legion
71
74
  ack(delivery || properties)
72
75
  response
73
76
  rescue StandardError => e
77
+ handle_exception(e, level: :warn, handled: false, operation: 'llm.fleet.provider_responder.call',
78
+ provider_family:)
74
79
  safe_publish_error(envelope, e) if defined?(envelope) && envelope
75
80
  reject(delivery || properties, requeue: requeue_error?(e))
76
81
  raise
@@ -165,7 +170,10 @@ module Legion
165
170
 
166
171
  def safe_publish_error(envelope, error)
167
172
  publish_error(envelope, error)
168
- rescue StandardError
173
+ rescue StandardError => e
174
+ handle_exception(e, level: :debug, handled: true,
175
+ operation: 'llm.fleet.provider_responder.safe_publish_error',
176
+ error_class: error.class.name)
169
177
  nil
170
178
  end
171
179
 
@@ -6,6 +6,8 @@ module Legion
6
6
  module Fleet
7
7
  # Publish-result helpers kept local to fleet messages so they work with older legion-transport releases.
8
8
  module PublishSafety
9
+ include Legion::Logging::Helper
10
+
9
11
  private
10
12
 
11
13
  def return_publish_result?(options)
@@ -6,6 +6,9 @@ module Legion
6
6
  module Fleet
7
7
  # Reads fleet settings from Legion::Settings when available, falling back to lex-llm defaults.
8
8
  module Settings
9
+ include Legion::Logging::Helper
10
+ extend Legion::Logging::Helper
11
+
9
12
  module_function
10
13
 
11
14
  def value(*path, default:)
@@ -29,7 +32,8 @@ module Legion
29
32
  llm = safe_fetch(::Legion::Settings, :llm)
30
33
  configured << llm if llm.respond_to?(:key?)
31
34
  configured
32
- rescue StandardError
35
+ rescue StandardError => e
36
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.fleet.settings.configured')
33
37
  []
34
38
  end
35
39
 
@@ -49,7 +53,9 @@ module Legion
49
53
 
50
54
  def safe_fetch(source, key)
51
55
  source[key] || source[key.to_s]
52
- rescue StandardError
56
+ rescue StandardError => e
57
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.fleet.settings.safe_fetch',
58
+ key: key.to_s)
53
59
  nil
54
60
  end
55
61
 
@@ -12,6 +12,9 @@ module Legion
12
12
  module Fleet
13
13
  # Verifies responder-side fleet JWTs and prevents replay on provider nodes.
14
14
  module TokenValidator
15
+ include Legion::Logging::Helper
16
+ extend Legion::Logging::Helper
17
+
15
18
  @seen_jtis = Concurrent::Map.new
16
19
  @replay_mutex = Mutex.new
17
20
 
@@ -35,6 +38,7 @@ module Legion
35
38
  rescue TokenError
36
39
  raise
37
40
  rescue StandardError => e
41
+ handle_exception(e, level: :warn, handled: false, operation: 'llm.fleet.token_validator.validate')
38
42
  raise TokenError, "fleet token verification failed: #{e.message}"
39
43
  end
40
44
 
@@ -167,6 +171,7 @@ module Legion
167
171
  rescue TokenError
168
172
  raise
169
173
  rescue StandardError => e
174
+ handle_exception(e, level: :warn, handled: false, operation: 'llm.fleet.token_validator.signing_key')
170
175
  raise TokenError, "no signing key available: #{e.message}"
171
176
  end
172
177
 
@@ -11,6 +11,9 @@ module Legion
11
11
  module Fleet
12
12
  # Applies responder-side policy and dispatches a fleet request to a local lex-llm provider.
13
13
  module WorkerExecution
14
+ include Legion::Logging::Helper
15
+ extend Legion::Logging::Helper
16
+
14
17
  class PolicyError < StandardError; end
15
18
 
16
19
  @idempotency_keys = Concurrent::Map.new
@@ -29,10 +32,12 @@ module Legion
29
32
  TokenValidator.mark_replay!(claims[:jti]) if claims.is_a?(Hash)
30
33
  response
31
34
  rescue TokenError => e
35
+ handle_exception(e, level: :warn, handled: false, operation: 'llm.fleet.worker_execution.identity')
32
36
  release_idempotency!(idempotency_key) if idempotency_key
33
37
  release_replay!(claims)
34
38
  raise PolicyError, e.message
35
- rescue StandardError
39
+ rescue StandardError => e
40
+ handle_exception(e, level: :warn, handled: false, operation: 'llm.fleet.worker_execution.call')
36
41
  release_idempotency!(idempotency_key) if idempotency_key
37
42
  release_replay!(claims)
38
43
  raise
@@ -169,7 +169,7 @@ module Legion
169
169
  end
170
170
 
171
171
  def fetch_models_dev_models(existing_models) # rubocop:disable Metrics/PerceivedComplexity
172
- Legion::Extensions::Llm.logger.info 'Fetching models from models.dev API...'
172
+ log.info 'Fetching models from models.dev API...'
173
173
 
174
174
  connection = Connection.basic do |f|
175
175
  f.request :json
@@ -202,11 +202,11 @@ module Legion
202
202
  end
203
203
 
204
204
  def log_provider_fetch(provider_fetch)
205
- Legion::Extensions::Llm.logger.info(
205
+ log.info(
206
206
  "Fetching models from providers: #{provider_fetch[:configured_names].join(', ')}"
207
207
  )
208
208
  provider_fetch[:failed].each do |failure|
209
- Legion::Extensions::Llm.logger.warn(
209
+ log.warn(
210
210
  "Failed to fetch #{failure[:name]} models (#{failure[:error].class}: #{failure[:error].message}). " \
211
211
  'Keeping existing.'
212
212
  )
@@ -216,7 +216,7 @@ module Legion
216
216
  def log_models_dev_fetch(models_dev_fetch)
217
217
  return if models_dev_fetch[:fetched]
218
218
 
219
- Legion::Extensions::Llm.logger.warn('Using cached models.dev data due to fetch failure.')
219
+ log.warn('Using cached models.dev data due to fetch failure.')
220
220
  end
221
221
 
222
222
  def merge_with_existing(existing_models, provider_fetch, models_dev_fetch)
@@ -28,6 +28,7 @@ module Legion
28
28
  class Provider
29
29
  include Streaming
30
30
  include Legion::Logging::Helper
31
+ include Legion::Cache::Helper
31
32
 
32
33
  attr_reader :config, :connection
33
34
 
@@ -75,6 +76,19 @@ module Legion
75
76
  def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
76
77
  tool_prefs: nil, &)
77
78
  normalized_temperature = maybe_normalize_temperature(temperature, model)
79
+ log_provider_request(
80
+ messages: messages,
81
+ tools: tools,
82
+ temperature: temperature,
83
+ normalized_temperature: normalized_temperature,
84
+ model: model,
85
+ params: params,
86
+ headers: headers,
87
+ schema: schema,
88
+ thinking: thinking,
89
+ tool_prefs: tool_prefs,
90
+ streaming: block_given?
91
+ )
78
92
 
79
93
  payload = Utils.deep_merge(
80
94
  render_payload(
@@ -110,10 +124,14 @@ module Legion
110
124
  provider_health = health(live:)
111
125
  @cached_offerings = Array(list_models(live:, **filters)).filter_map do |model|
112
126
  next unless model_matches_filters?(model, filters)
127
+ next unless model_allowed?(model.id)
113
128
 
114
129
  offering_from_model(model, health: provider_health)
115
130
  end
116
131
  @cached_offerings
132
+ rescue Faraday::ConnectionFailed => e
133
+ log.warn("[#{slug}] instance=#{provider_instance_id} unreachable: #{e.message}")
134
+ []
117
135
  end
118
136
 
119
137
  def health(live: false)
@@ -271,12 +289,14 @@ module Legion
271
289
  # ── Model allow-list / deny-list filtering ────────────────────────
272
290
 
273
291
  def model_whitelist
274
- wl = settings[:model_whitelist] if respond_to?(:settings)
292
+ wl = config.model_whitelist if config.respond_to?(:model_whitelist)
293
+ wl ||= settings[:model_whitelist] if respond_to?(:settings)
275
294
  Array(wl).map { |p| p.to_s.downcase }
276
295
  end
277
296
 
278
297
  def model_blacklist
279
- bl = settings[:model_blacklist] if respond_to?(:settings)
298
+ bl = config.model_blacklist if config.respond_to?(:model_blacklist)
299
+ bl ||= settings[:model_blacklist] if respond_to?(:settings)
280
300
  Array(bl).map { |p| p.to_s.downcase }
281
301
  end
282
302
 
@@ -330,7 +350,8 @@ module Legion
330
350
  uri = URI.parse(url)
331
351
  Socket.tcp(uri.host, uri.port, connect_timeout: 1).close
332
352
  true
333
- rescue StandardError
353
+ rescue StandardError => e
354
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.provider.url_reachable', url:)
334
355
  false
335
356
  end
336
357
 
@@ -352,24 +373,29 @@ module Legion
352
373
  return nil unless defined?(Legion::Cache)
353
374
 
354
375
  cache_local_instance? ? local_cache_get(key) : cache_get(key)
355
- rescue StandardError
376
+ rescue StandardError => e
377
+ handle_exception(e, level: :debug, handled: true, operation: 'llm.provider.model_cache_get', key:)
356
378
  nil
357
379
  end
358
380
 
359
- def model_cache_set(key, value, ttl:)
360
- return unless defined?(Legion::Cache)
381
+ def model_detail(model_name)
382
+ key = model_detail_cache_key(model_name)
383
+ cached = cache_get(key)
384
+ return cached if cached
361
385
 
362
- cache_local_instance? ? local_cache_set(key, value, ttl: ttl) : cache_set(key, value, ttl: ttl)
386
+ result = fetch_model_detail(model_name)
387
+ cache_set(key, result, ttl: 86_400) if result
388
+ result
363
389
  rescue StandardError => e
364
- handle_exception(e, level: :debug, handled: true, operation: 'lex.provider.model_cache_set')
390
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.model_detail',
391
+ model: model_name)
392
+ nil
365
393
  end
366
394
 
367
- def model_cache_fetch(key, ttl:, &)
368
- return yield unless defined?(Legion::Cache)
369
-
370
- cache_local_instance? ? local_cache_fetch(key, ttl: ttl, &) : cache_fetch(key, ttl: ttl, &)
371
- rescue StandardError
372
- yield
395
+ # Override in subclasses to make a live API call for model detail.
396
+ # Must return a Hash with symbol keys (e.g. { context_window: 128000 }).
397
+ def fetch_model_detail(_model_name)
398
+ nil
373
399
  end
374
400
 
375
401
  def cache_instance_key
@@ -432,6 +458,26 @@ module Legion
432
458
 
433
459
  private
434
460
 
461
+ def model_detail_cache_key(model_name)
462
+ tier = offering_tier
463
+ instance_key = cache_instance_key
464
+ cred_fp = credential_cache_fragment
465
+ key_parts = ['model_info', tier, slug, instance_key, cred_fp, model_name].compact
466
+ key_parts.join('.')
467
+ end
468
+
469
+ def credential_cache_fragment
470
+ return nil if cache_local_instance?
471
+
472
+ cred = config.respond_to?(:bearer_token) && config.bearer_token
473
+ cred ||= config.respond_to?(:api_key) && config.api_key
474
+ cred ||= config.respond_to?(:bedrock_access_key_id) && config.bedrock_access_key_id
475
+ return nil unless cred
476
+
477
+ require 'digest'
478
+ Digest::SHA256.hexdigest(cred.to_s)[0, 8]
479
+ end
480
+
435
481
  def validate_paint_inputs!(with:, mask:)
436
482
  return if with.nil? && mask.nil?
437
483
 
@@ -593,6 +639,53 @@ module Legion
593
639
  temperature
594
640
  end
595
641
 
642
+ def log_provider_request(context)
643
+ log.debug do
644
+ "Preparing provider completion: provider=#{slug} model=#{debug_model_id(context[:model])} " \
645
+ "streaming=#{context[:streaming]} messages=#{Array(context[:messages]).size} " \
646
+ "tools=#{debug_tool_names(context[:tools]).inspect} " \
647
+ "temperature=#{context[:temperature].inspect} " \
648
+ "normalized_temperature=#{context[:normalized_temperature].inspect} " \
649
+ "param_keys=#{debug_hash_keys(context[:params]).inspect} " \
650
+ "header_keys=#{debug_hash_keys(context[:headers]).inspect} " \
651
+ "schema=#{debug_value_summary(context[:schema])} " \
652
+ "thinking=#{debug_value_summary(context[:thinking])} " \
653
+ "tool_prefs=#{debug_value_summary(context[:tool_prefs])}"
654
+ end
655
+ end
656
+
657
+ def debug_model_id(model)
658
+ return model.id if model.respond_to?(:id)
659
+
660
+ model
661
+ end
662
+
663
+ def debug_tool_names(tools)
664
+ tool_definitions = tools.is_a?(Hash) ? tools.values : Array(tools)
665
+
666
+ tool_definitions.filter_map do |tool|
667
+ if tool.respond_to?(:name)
668
+ tool.name
669
+ elsif tool.is_a?(Hash)
670
+ tool[:name] || tool['name']
671
+ else
672
+ tool.class.name
673
+ end
674
+ end
675
+ end
676
+
677
+ def debug_hash_keys(value)
678
+ value.respond_to?(:keys) ? value.keys.map(&:to_s).sort : []
679
+ end
680
+
681
+ def debug_value_summary(value)
682
+ return 'nil' if value.nil?
683
+ return "#{value.class}(keys=#{debug_hash_keys(value).inspect})" if value.respond_to?(:keys)
684
+ return "#{value.class}(size=#{value.size})" if value.respond_to?(:size)
685
+
686
+ value.class.name
687
+ end
688
+
596
689
  def endpoint_methods
597
690
  {
598
691
  completion: :completion_url,
@@ -25,7 +25,7 @@ module Legion
25
25
  end
26
26
 
27
27
  def add(chunk)
28
- Legion::Extensions::Llm.logger.debug { chunk.inspect } if Legion::Extensions::Llm.config.log_stream_debug
28
+ log.debug { chunk.inspect } if Legion::Extensions::Llm.config.log_stream_debug
29
29
  @model_id ||= chunk.model_id
30
30
 
31
31
  @last_content_delta = +''
@@ -33,7 +33,7 @@ module Legion
33
33
  handle_chunk_content(chunk)
34
34
  append_thinking_from_chunk(chunk)
35
35
  count_tokens chunk
36
- Legion::Extensions::Llm.logger.debug { inspect } if Legion::Extensions::Llm.config.log_stream_debug
36
+ log.debug { inspect } if Legion::Extensions::Llm.config.log_stream_debug
37
37
  end
38
38
 
39
39
  def filtered_chunk(chunk) # rubocop:disable Metrics/PerceivedComplexity
@@ -101,9 +101,7 @@ module Legion
101
101
  end
102
102
 
103
103
  def accumulate_tool_calls(new_tool_calls)
104
- if Legion::Extensions::Llm.config.log_stream_debug
105
- Legion::Extensions::Llm.logger.debug { "Accumulating tool calls: #{new_tool_calls}" }
106
- end
104
+ log.debug { "Accumulating tool calls: #{new_tool_calls}" } if Legion::Extensions::Llm.config.log_stream_debug
107
105
  new_tool_calls.each_value do |tool_call|
108
106
  if tool_call.id
109
107
  start_tool_call(tool_call)
@@ -5,6 +5,9 @@ module Legion
5
5
  module Llm
6
6
  # Handles streaming responses from AI providers.
7
7
  module Streaming
8
+ include Legion::Logging::Helper
9
+ extend Legion::Logging::Helper
10
+
8
11
  module_function
9
12
 
10
13
  def stream_response(connection, payload, additional_headers = {}, &block)
@@ -13,6 +16,9 @@ module Legion
13
16
  response = connection.post stream_url, payload do |req|
14
17
  req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
15
18
  on_chunk = build_stream_callback(accumulator, block)
19
+ if Legion::Extensions::Llm.config.log_stream_debug
20
+ log.debug { "Stream callback prepared: #{on_chunk.inspect}" }
21
+ end
16
22
  if faraday_1?
17
23
  req.options[:on_data] = handle_stream(&on_chunk)
18
24
  else
@@ -21,7 +27,7 @@ module Legion
21
27
  end
22
28
 
23
29
  message = accumulator.to_message(response)
24
- Legion::Extensions::Llm.logger.debug { "Stream completed: #{message.content}" }
30
+ log.debug { "Stream completed: #{message.content}" }
25
31
  message
26
32
  end
27
33
 
@@ -57,9 +63,7 @@ module Legion
57
63
  end
58
64
 
59
65
  def process_stream_chunk(chunk, parser, env, &)
60
- if Legion::Extensions::Llm.config.log_stream_debug
61
- Legion::Extensions::Llm.logger.debug { "Received chunk: #{chunk}" }
62
- end
66
+ log.debug { "Received chunk: #{chunk}" } if Legion::Extensions::Llm.config.log_stream_debug
63
67
 
64
68
  if error_chunk?(chunk)
65
69
  handle_error_chunk(chunk, env)
@@ -19,6 +19,8 @@ module Legion
19
19
 
20
20
  # Base class for creating tools that AI models can use
21
21
  class Tool
22
+ include Legion::Logging::Helper
23
+
22
24
  # Stops conversation continuation after tool execution
23
25
  class Halt
24
26
  attr_reader :content
@@ -105,9 +107,9 @@ module Legion
105
107
  validation_error = validate_keyword_arguments(normalized_args)
106
108
  return { error: "Invalid tool arguments: #{validation_error}" } if validation_error
107
109
 
108
- Legion::Extensions::Llm.logger.debug { "Tool #{name} called with: #{normalized_args.inspect}" }
110
+ log.debug { "Tool #{name} called with: #{normalized_args.inspect}" }
109
111
  result = execute(**normalized_args)
110
- Legion::Extensions::Llm.logger.debug { "Tool #{name} returned: #{result.inspect}" }
112
+ log.debug { "Tool #{name} returned: #{result.inspect}" }
111
113
  result
112
114
  end
113
115
 
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Llm
6
- VERSION = '0.4.8'
6
+ VERSION = '0.4.10'
7
7
  end
8
8
  end
9
9
  end
@@ -9,6 +9,15 @@ require 'faraday/multipart'
9
9
  require 'faraday/retry'
10
10
  require 'legion/json'
11
11
  require 'legion/logging'
12
+ # legion/cache writes DEBUG lines to $stdout on first load; suppress them here
13
+ # so callers that capture our stdout (e.g. Open3-based integration tests) are unaffected.
14
+ begin
15
+ old_stdout = $stdout
16
+ $stdout = File.open(File::NULL, 'w')
17
+ require 'legion/cache'
18
+ ensure
19
+ $stdout = old_stdout
20
+ end
12
21
  require 'logger'
13
22
  require 'marcel'
14
23
  require 'ruby_llm/schema'
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.4.8
4
+ version: 0.4.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - LegionIO
@@ -108,6 +108,20 @@ dependencies:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
110
  version: '1'
111
+ - !ruby/object:Gem::Dependency
112
+ name: legion-cache
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: 1.3.0
118
+ type: :runtime
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - ">="
123
+ - !ruby/object:Gem::Version
124
+ version: 1.3.0
111
125
  - !ruby/object:Gem::Dependency
112
126
  name: legion-crypt
113
127
  requirement: !ruby/object:Gem::Requirement