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 +4 -4
- data/CHANGELOG.md +19 -0
- data/lex-llm.gemspec +1 -0
- data/lib/legion/extensions/llm/attachment.rb +3 -3
- data/lib/legion/extensions/llm/configuration.rb +3 -1
- data/lib/legion/extensions/llm/connection.rb +43 -13
- data/lib/legion/extensions/llm/credential_sources.rb +46 -7
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +9 -1
- data/lib/legion/extensions/llm/fleet/publish_safety.rb +2 -0
- data/lib/legion/extensions/llm/fleet/settings.rb +8 -2
- data/lib/legion/extensions/llm/fleet/token_validator.rb +5 -0
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +6 -1
- data/lib/legion/extensions/llm/models.rb +4 -4
- data/lib/legion/extensions/llm/provider.rb +107 -14
- data/lib/legion/extensions/llm/stream_accumulator.rb +3 -5
- data/lib/legion/extensions/llm/streaming.rb +8 -4
- data/lib/legion/extensions/llm/tool.rb +4 -2
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +9 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a203372d751b290a71cc289e382d80a49fafcc0687925c02594b8c5cfe6ef7aa
|
|
4
|
+
data.tar.gz: 95cfd5a03c002a16da80bac58914f1fb808a940db378d99035f97b6256240863
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
64
|
-
bodies:
|
|
65
|
-
errors:
|
|
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,
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
360
|
-
|
|
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
|
-
|
|
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: :
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
log.debug { "Tool #{name} called with: #{normalized_args.inspect}" }
|
|
109
111
|
result = execute(**normalized_args)
|
|
110
|
-
|
|
112
|
+
log.debug { "Tool #{name} returned: #{result.inspect}" }
|
|
111
113
|
result
|
|
112
114
|
end
|
|
113
115
|
|
|
@@ -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.
|
|
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
|