lex-llm 0.1.7 → 0.1.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 +4 -4
- data/CHANGELOG.md +20 -0
- data/lib/legion/extensions/llm/chat.rb +3 -2
- data/lib/legion/extensions/llm/model/info.rb +201 -63
- data/lib/legion/extensions/llm/models.rb +17 -14
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +3 -2
- data/lib/legion/extensions/llm/provider.rb +112 -0
- data/lib/legion/extensions/llm/registry_event_builder.rb +140 -0
- data/lib/legion/extensions/llm/registry_publisher.rb +104 -0
- data/lib/legion/extensions/llm/streaming.rb +6 -6
- data/lib/legion/extensions/llm/transport/exchanges/llm_registry.rb +25 -0
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +44 -0
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +3 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ca7422981b9d63c85ebe052990cd12af77c9e625ce7597d67e8161418a94ea3
|
|
4
|
+
data.tar.gz: 1cd31a58aa6bc9f35a9e8d45c1bed00c05b7dbdcfaaddb81bb29f644787a7703
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d0de80f09c6820c95b51297bc3a6b0bdebfdaaedb007668c635b670c45668e80c1ed4a9eb0c2cff5db48aef602c1aa4e9f325b734ed63f661fbec4248589aa26
|
|
7
|
+
data.tar.gz: 8bfed0a1cec4ec06062c6797afed865f7fec9757b7906022dfd04bd9650b2b3bd5bc67c6cda65f3464bc3f8da1c0a59543d573ae30231bcc2874b307a0fb6e85
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.9 - 2026-04-30
|
|
4
|
+
|
|
5
|
+
- Replace Model::Info class with immutable Data.define value object supporting new fields: instance, parameter_count, parameter_size, quantization, size_bytes, modalities_input, modalities_output
|
|
6
|
+
- Add Model::Info.from_hash factory for backward-compatible construction from legacy hash format
|
|
7
|
+
- Add backward-compatible accessors on Model::Info for context_window, max_output_tokens, created_at, knowledge_cutoff, modalities, pricing, type, and legacy capability predicates
|
|
8
|
+
- Add model_allowed? to base Provider with whitelist/blacklist filtering from settings
|
|
9
|
+
- Add multi-host base_url resolution with TLS awareness and reachability probing
|
|
10
|
+
- Add cache tier selection helpers: cache_local_instance?, model_cache_get/set/fetch, cache_instance_key for local vs shared cache routing
|
|
11
|
+
- Add shared transport classes for llm.registry exchange and registry event messages (guarded by defined? for optional legion-transport)
|
|
12
|
+
- Add shared RegistryPublisher parameterized by provider_family for all lex-llm-* gems
|
|
13
|
+
- Add shared RegistryEventBuilder parameterized by provider_family for all lex-llm-* gems
|
|
14
|
+
- Mark Provider.register, .resolve, .for, .providers with @deprecated annotations for future removal in favor of the extension registry
|
|
15
|
+
|
|
16
|
+
## 0.1.8 - 2026-04-30
|
|
17
|
+
|
|
18
|
+
- Audit all rescue blocks for handle_exception compliance
|
|
19
|
+
- Add Legion::Logging::Helper to Provider, Chat, and Models for structured exception reporting
|
|
20
|
+
- Replace ad-hoc logger.debug/warn calls in rescue blocks with handle_exception across streaming, chat, models, and provider modules
|
|
21
|
+
- Add require for legion/logging in the main entrypoint
|
|
22
|
+
|
|
3
23
|
## 0.1.7 - 2026-04-30
|
|
4
24
|
|
|
5
25
|
- Add thinking extraction from OpenAI-compatible streaming chunks (reasoning_content, reasoning, think tags)
|
|
@@ -6,6 +6,7 @@ module Legion
|
|
|
6
6
|
# Represents a conversation with an AI model
|
|
7
7
|
class Chat
|
|
8
8
|
include Enumerable
|
|
9
|
+
include Legion::Logging::Helper
|
|
9
10
|
|
|
10
11
|
attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
|
|
11
12
|
|
|
@@ -157,8 +158,8 @@ module Legion
|
|
|
157
158
|
if @schema && response.content.is_a?(String) && !response.tool_call?
|
|
158
159
|
begin
|
|
159
160
|
response.content = Legion::JSON.parse(response.content, symbolize_names: false)
|
|
160
|
-
rescue Legion::JSON::ParseError
|
|
161
|
-
|
|
161
|
+
rescue Legion::JSON::ParseError => e
|
|
162
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.chat.complete')
|
|
162
163
|
end
|
|
163
164
|
end
|
|
164
165
|
|
|
@@ -4,71 +4,92 @@ module Legion
|
|
|
4
4
|
module Extensions
|
|
5
5
|
module Llm
|
|
6
6
|
module Model
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
7
|
+
Info = Data.define(
|
|
8
|
+
:id, :name, :provider, :instance, :family,
|
|
9
|
+
:capabilities, :context_length, :parameter_count,
|
|
10
|
+
:parameter_size, :quantization, :size_bytes,
|
|
11
|
+
:modalities_input, :modalities_output, :metadata
|
|
12
|
+
) do
|
|
13
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
14
|
+
def initialize(
|
|
15
|
+
id:, name: nil, provider: nil, instance: :default,
|
|
16
|
+
family: nil, capabilities: [], context_length: nil,
|
|
17
|
+
parameter_count: nil, parameter_size: nil, quantization: nil,
|
|
18
|
+
size_bytes: nil, modalities_input: [], modalities_output: [],
|
|
19
|
+
metadata: {}
|
|
20
|
+
)
|
|
21
|
+
normalized_family = family.nil? ? nil : family.to_s.downcase.strip
|
|
22
|
+
|
|
23
|
+
super(
|
|
24
|
+
id: id.to_s.strip,
|
|
25
|
+
name: (name || id).to_s.strip,
|
|
26
|
+
provider: provider.to_s.downcase.to_sym,
|
|
27
|
+
instance: (instance || :default).to_s.downcase.to_sym,
|
|
28
|
+
family: normalized_family,
|
|
29
|
+
capabilities: normalize_symbols(capabilities),
|
|
30
|
+
context_length: to_int(context_length),
|
|
31
|
+
parameter_count: to_int(parameter_count),
|
|
32
|
+
parameter_size: parameter_size&.to_s&.strip,
|
|
33
|
+
quantization: quantization&.to_s&.strip,
|
|
34
|
+
size_bytes: to_int(size_bytes),
|
|
35
|
+
modalities_input: normalize_symbols(modalities_input),
|
|
36
|
+
modalities_output: normalize_symbols(modalities_output),
|
|
37
|
+
metadata: metadata.is_a?(Hash) ? metadata : {}
|
|
21
38
|
)
|
|
22
39
|
end
|
|
40
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/PerceivedComplexity
|
|
23
41
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@max_output_tokens = data[:max_output_tokens]
|
|
32
|
-
@knowledge_cutoff = Utils.to_date(data[:knowledge_cutoff])
|
|
33
|
-
@modalities = Modalities.new(data[:modalities] || {})
|
|
34
|
-
@capabilities = data[:capabilities] || []
|
|
35
|
-
@pricing = Pricing.new(data[:pricing] || {})
|
|
36
|
-
@metadata = data[:metadata] || {}
|
|
37
|
-
end
|
|
42
|
+
# ── Capability predicates ─────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def completion? = capabilities.include?(:completion)
|
|
45
|
+
def embedding? = capabilities.include?(:embedding)
|
|
46
|
+
def vision? = capabilities.include?(:vision)
|
|
47
|
+
def tools? = capabilities.include?(:tools)
|
|
48
|
+
def thinking? = capabilities.include?(:thinking)
|
|
38
49
|
|
|
39
50
|
def supports?(capability)
|
|
40
|
-
capabilities.include?(capability.to_s)
|
|
51
|
+
capabilities.include?(capability.to_s.downcase.to_sym)
|
|
41
52
|
end
|
|
42
53
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
end
|
|
54
|
+
# ── Backward-compatible accessors ─────────────────────────────
|
|
55
|
+
# These bridge the legacy Model::Info class API used by Models,
|
|
56
|
+
# OpenAICompatible, and provider gems. They read from metadata
|
|
57
|
+
# where the old fields were stored.
|
|
48
58
|
|
|
49
|
-
def
|
|
50
|
-
|
|
59
|
+
def context_window
|
|
60
|
+
context_length || metadata[:context_window]
|
|
51
61
|
end
|
|
52
62
|
|
|
53
|
-
def
|
|
54
|
-
|
|
55
|
-
"#{provider_name} - #{display_name}"
|
|
63
|
+
def max_output_tokens
|
|
64
|
+
metadata[:max_output_tokens]
|
|
56
65
|
end
|
|
57
66
|
|
|
58
67
|
def max_tokens
|
|
59
68
|
max_output_tokens
|
|
60
69
|
end
|
|
61
70
|
|
|
62
|
-
def
|
|
63
|
-
|
|
71
|
+
def created_at
|
|
72
|
+
metadata[:created_at]
|
|
64
73
|
end
|
|
65
74
|
|
|
66
|
-
def
|
|
67
|
-
|
|
75
|
+
def knowledge_cutoff
|
|
76
|
+
metadata[:knowledge_cutoff]
|
|
68
77
|
end
|
|
69
78
|
|
|
70
|
-
def
|
|
71
|
-
|
|
79
|
+
def modalities
|
|
80
|
+
Modalities.new(input: modalities_input.map(&:to_s), output: modalities_output.map(&:to_s))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def pricing
|
|
84
|
+
Pricing.new(metadata[:pricing] || {})
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def display_name
|
|
88
|
+
name
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def label
|
|
92
|
+
"#{provider} - #{display_name}"
|
|
72
93
|
end
|
|
73
94
|
|
|
74
95
|
def input_price_per_million
|
|
@@ -79,13 +100,28 @@ module Legion
|
|
|
79
100
|
pricing.text_tokens.output
|
|
80
101
|
end
|
|
81
102
|
|
|
82
|
-
def
|
|
83
|
-
|
|
103
|
+
def supports_vision?
|
|
104
|
+
vision? || modalities_input.include?(:image)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def supports_video?
|
|
108
|
+
modalities_input.include?(:video)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def supports_functions?
|
|
112
|
+
tools? || capabilities.include?(:function_calling)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Legacy capability predicates (string-based)
|
|
116
|
+
%w[function_calling structured_output batch reasoning citations streaming].each do |cap|
|
|
117
|
+
define_method "#{cap}?" do
|
|
118
|
+
supports?(cap)
|
|
119
|
+
end
|
|
84
120
|
end
|
|
85
121
|
|
|
86
122
|
def type
|
|
87
|
-
output =
|
|
88
|
-
return 'embedding' if output.include?('embeddings')
|
|
123
|
+
output = modalities_output.map(&:to_s)
|
|
124
|
+
return 'embedding' if output.include?('embeddings') || embedding?
|
|
89
125
|
return 'moderation' if output.include?('moderation')
|
|
90
126
|
return 'image' if output.include?('image')
|
|
91
127
|
return 'audio' if output.include?('audio')
|
|
@@ -94,21 +130,123 @@ module Legion
|
|
|
94
130
|
'chat'
|
|
95
131
|
end
|
|
96
132
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
133
|
+
# Factory for assumed-to-exist models without full metadata.
|
|
134
|
+
def self.default(model_id, provider)
|
|
135
|
+
new(
|
|
136
|
+
id: model_id,
|
|
137
|
+
name: model_id.tr('-', ' ').capitalize,
|
|
101
138
|
provider: provider,
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
139
|
+
capabilities: %w[function_calling streaming vision structured_output],
|
|
140
|
+
modalities_input: %w[text image],
|
|
141
|
+
modalities_output: %w[text],
|
|
142
|
+
metadata: { warning: 'Assuming model exists, capabilities may not be accurate' }
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Factory that accepts both legacy and new-style hashes and maps
|
|
147
|
+
# them to the new struct fields. Handles round-tripping through to_h.
|
|
148
|
+
def self.from_hash(data)
|
|
149
|
+
data = data.transform_keys(&:to_sym) if data.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
input_mods, output_mods = extract_modalities(data)
|
|
152
|
+
|
|
153
|
+
new(
|
|
154
|
+
id: data[:id],
|
|
155
|
+
name: data[:name],
|
|
156
|
+
provider: data[:provider],
|
|
157
|
+
instance: data[:instance],
|
|
158
|
+
family: data[:family],
|
|
159
|
+
capabilities: data[:capabilities] || [],
|
|
160
|
+
context_length: data[:context_length] || data[:context_window],
|
|
161
|
+
parameter_count: data[:parameter_count],
|
|
162
|
+
parameter_size: data[:parameter_size],
|
|
163
|
+
quantization: data[:quantization],
|
|
164
|
+
size_bytes: data[:size_bytes],
|
|
165
|
+
modalities_input: input_mods,
|
|
166
|
+
modalities_output: output_mods,
|
|
167
|
+
metadata: build_metadata(data)
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
def normalize_symbols(value)
|
|
174
|
+
Array(value).map { |v| v.to_s.downcase.strip.to_sym }.uniq
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def to_int(value)
|
|
178
|
+
return nil if value.nil?
|
|
179
|
+
|
|
180
|
+
value.to_i
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Class-level helpers for from_hash normalization
|
|
184
|
+
class << self
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def extract_modalities(data) # rubocop:disable Metrics/PerceivedComplexity
|
|
188
|
+
# New-style keys take priority (round-trip from to_h)
|
|
189
|
+
if data.key?(:modalities_input) || data.key?(:modalities_output)
|
|
190
|
+
return [Array(data[:modalities_input]), Array(data[:modalities_output])]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Legacy: modalities is a hash or Modalities object
|
|
194
|
+
modalities_data = data[:modalities]
|
|
195
|
+
input_mods = if modalities_data.respond_to?(:input)
|
|
196
|
+
modalities_data.input
|
|
197
|
+
elsif modalities_data.is_a?(Hash)
|
|
198
|
+
Array(modalities_data[:input])
|
|
199
|
+
else
|
|
200
|
+
[]
|
|
201
|
+
end
|
|
202
|
+
output_mods = if modalities_data.respond_to?(:output)
|
|
203
|
+
modalities_data.output
|
|
204
|
+
elsif modalities_data.is_a?(Hash)
|
|
205
|
+
Array(modalities_data[:output])
|
|
206
|
+
else
|
|
207
|
+
[]
|
|
208
|
+
end
|
|
209
|
+
[input_mods, output_mods]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def build_metadata(data)
|
|
213
|
+
extra = {}
|
|
214
|
+
extra[:created_at] = normalize_created_at(data[:created_at]) if data.key?(:created_at)
|
|
215
|
+
if data.key?(:knowledge_cutoff)
|
|
216
|
+
extra[:knowledge_cutoff] =
|
|
217
|
+
normalize_knowledge_cutoff(data[:knowledge_cutoff])
|
|
218
|
+
end
|
|
219
|
+
extra[:max_output_tokens] = data[:max_output_tokens] if data.key?(:max_output_tokens)
|
|
220
|
+
extra[:pricing] = normalize_pricing(data[:pricing]) if data.key?(:pricing)
|
|
221
|
+
|
|
222
|
+
base = data[:metadata] || {}
|
|
223
|
+
base.merge(extra).compact
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def normalize_created_at(value)
|
|
227
|
+
return nil if value.nil?
|
|
228
|
+
return value if value.is_a?(Time)
|
|
229
|
+
|
|
230
|
+
Utils.to_time(value)&.utc
|
|
231
|
+
rescue StandardError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def normalize_knowledge_cutoff(value)
|
|
236
|
+
return nil if value.nil?
|
|
237
|
+
return value if value.is_a?(Date)
|
|
238
|
+
|
|
239
|
+
Utils.to_date(value)
|
|
240
|
+
rescue StandardError
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def normalize_pricing(value)
|
|
245
|
+
return nil if value.nil?
|
|
246
|
+
return value.to_h if value.respond_to?(:to_h)
|
|
247
|
+
|
|
248
|
+
value
|
|
249
|
+
end
|
|
112
250
|
end
|
|
113
251
|
end
|
|
114
252
|
end
|
|
@@ -35,6 +35,8 @@ module Legion
|
|
|
35
35
|
].freeze
|
|
36
36
|
|
|
37
37
|
class << self
|
|
38
|
+
include Legion::Logging::Helper
|
|
39
|
+
|
|
38
40
|
def instance
|
|
39
41
|
@instance ||= new
|
|
40
42
|
end
|
|
@@ -49,9 +51,10 @@ module Legion
|
|
|
49
51
|
|
|
50
52
|
def read_from_json(file = Legion::Extensions::Llm.config.model_registry_file)
|
|
51
53
|
data = File.exist?(file) ? File.read(file) : '[]'
|
|
52
|
-
models = Legion::JSON.parse(data, symbolize_names: true).map { |model| Model::Info.
|
|
54
|
+
models = Legion::JSON.parse(data, symbolize_names: true).map { |model| Model::Info.from_hash(model) }
|
|
53
55
|
filter_models(models)
|
|
54
|
-
rescue Legion::JSON::ParseError
|
|
56
|
+
rescue Legion::JSON::ParseError => e
|
|
57
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.models.read_from_json')
|
|
55
58
|
[]
|
|
56
59
|
end
|
|
57
60
|
|
|
@@ -92,6 +95,8 @@ module Legion
|
|
|
92
95
|
result[:models].concat(provider_class.new(config).list_models)
|
|
93
96
|
result[:fetched_providers] << provider_class.slug
|
|
94
97
|
rescue StandardError => e
|
|
98
|
+
handle_exception(e, level: :warn, handled: true,
|
|
99
|
+
operation: 'llm.models.fetch_provider_models')
|
|
95
100
|
result[:failed] << { name: provider_class.name, slug: provider_class.slug, error: e }
|
|
96
101
|
end
|
|
97
102
|
end
|
|
@@ -165,14 +170,12 @@ module Legion
|
|
|
165
170
|
next [] unless provider_slug
|
|
166
171
|
|
|
167
172
|
(provider_data[:models] || {}).values.map do |model_data|
|
|
168
|
-
Model::Info.
|
|
173
|
+
Model::Info.from_hash(models_dev_model_to_info(model_data, provider_slug, provider_key.to_s))
|
|
169
174
|
end
|
|
170
175
|
end
|
|
171
176
|
{ models: models.reject { |model| model.provider.nil? || model.id.nil? }, fetched: true }
|
|
172
177
|
rescue StandardError => e
|
|
173
|
-
|
|
174
|
-
"Failed to fetch models.dev (#{e.class}: #{e.message}). Keeping existing."
|
|
175
|
-
)
|
|
178
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.models.fetch_models_dev')
|
|
176
179
|
{
|
|
177
180
|
models: existing_models.select { |model| model.metadata[:source] == 'models.dev' },
|
|
178
181
|
fetched: false
|
|
@@ -264,7 +267,7 @@ module Legion
|
|
|
264
267
|
if bedrock_model
|
|
265
268
|
data = bedrock_model.to_h.merge(id: model_id)
|
|
266
269
|
data[:context_window] = context_override if context_override
|
|
267
|
-
return Model::Info.
|
|
270
|
+
return Model::Info.from_hash(data)
|
|
268
271
|
end
|
|
269
272
|
end
|
|
270
273
|
|
|
@@ -275,7 +278,7 @@ module Legion
|
|
|
275
278
|
return unless gemini_model
|
|
276
279
|
|
|
277
280
|
# Return Gemini's models.dev data but with VertexAI as provider
|
|
278
|
-
Model::Info.
|
|
281
|
+
Model::Info.from_hash(gemini_model.to_h.merge(provider: 'vertexai'))
|
|
279
282
|
end
|
|
280
283
|
|
|
281
284
|
def index_by_key(models)
|
|
@@ -296,7 +299,7 @@ module Legion
|
|
|
296
299
|
data[:metadata] = provider_model.metadata.merge(data[:metadata] || {})
|
|
297
300
|
data[:capabilities] = (models_dev_model.capabilities + provider_model.capabilities).uniq
|
|
298
301
|
normalize_embedding_modalities(data)
|
|
299
|
-
Model::Info.
|
|
302
|
+
Model::Info.from_hash(data)
|
|
300
303
|
end
|
|
301
304
|
|
|
302
305
|
def normalize_embedding_modalities(data)
|
|
@@ -458,11 +461,11 @@ module Legion
|
|
|
458
461
|
end
|
|
459
462
|
|
|
460
463
|
def by_family(family)
|
|
461
|
-
self.class.new(all.select { |m| m.family == family.to_s })
|
|
464
|
+
self.class.new(all.select { |m| m.family.to_s == family.to_s })
|
|
462
465
|
end
|
|
463
466
|
|
|
464
467
|
def by_provider(provider)
|
|
465
|
-
self.class.new(all.select { |m| m.provider == provider.to_s })
|
|
468
|
+
self.class.new(all.select { |m| m.provider.to_s == provider.to_s })
|
|
466
469
|
end
|
|
467
470
|
|
|
468
471
|
def refresh!(remote_only: false)
|
|
@@ -477,8 +480,8 @@ module Legion
|
|
|
477
480
|
|
|
478
481
|
def find_with_provider(model_id, provider)
|
|
479
482
|
resolved_id = provider_resolved_model_id(Aliases.resolve(model_id, provider), provider)
|
|
480
|
-
all.find { |m| m.id == resolved_id && m.provider == provider.to_s } ||
|
|
481
|
-
all.find { |m| m.id == model_id && m.provider == provider.to_s } ||
|
|
483
|
+
all.find { |m| m.id == resolved_id && m.provider.to_s == provider.to_s } ||
|
|
484
|
+
all.find { |m| m.id == model_id && m.provider.to_s == provider.to_s } ||
|
|
482
485
|
raise(ModelNotFoundError, "Unknown model: #{model_id} for provider: #{provider}")
|
|
483
486
|
end
|
|
484
487
|
|
|
@@ -504,7 +507,7 @@ module Legion
|
|
|
504
507
|
return candidates.first if candidates.size == 1
|
|
505
508
|
|
|
506
509
|
candidates.min_by do |model|
|
|
507
|
-
index = PROVIDER_PREFERENCE.index(model.provider)
|
|
510
|
+
index = PROVIDER_PREFERENCE.index(model.provider.to_s)
|
|
508
511
|
index || PROVIDER_PREFERENCE.length
|
|
509
512
|
end
|
|
510
513
|
end
|
|
@@ -218,14 +218,15 @@ module Legion
|
|
|
218
218
|
return arguments if arguments.is_a?(Hash)
|
|
219
219
|
|
|
220
220
|
Legion::JSON.parse(arguments, symbolize_names: false)
|
|
221
|
-
rescue Legion::JSON::ParseError
|
|
221
|
+
rescue Legion::JSON::ParseError => e
|
|
222
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.parse_tool_arguments')
|
|
222
223
|
{}
|
|
223
224
|
end
|
|
224
225
|
|
|
225
226
|
def parse_list_models_response(response, provider, capabilities)
|
|
226
227
|
response.body.fetch('data', []).map do |model|
|
|
227
228
|
critical_capabilities = critical_capabilities_for(capabilities, model)
|
|
228
|
-
Legion::Extensions::Llm::Model::Info.
|
|
229
|
+
Legion::Extensions::Llm::Model::Info.from_hash(
|
|
229
230
|
id: model.fetch('id'),
|
|
230
231
|
name: model['id'],
|
|
231
232
|
provider: provider,
|
|
@@ -6,6 +6,7 @@ module Legion
|
|
|
6
6
|
# Base class for LLM providers.
|
|
7
7
|
class Provider
|
|
8
8
|
include Streaming
|
|
9
|
+
include Legion::Logging::Helper
|
|
9
10
|
|
|
10
11
|
attr_reader :config, :connection
|
|
11
12
|
|
|
@@ -131,6 +132,7 @@ module Legion
|
|
|
131
132
|
response = @connection.get(metadata[:endpoints][:health])
|
|
132
133
|
metadata.merge(ready: configured? && health_ready?(response.body), health: response.body)
|
|
133
134
|
rescue StandardError => e
|
|
135
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.readiness')
|
|
134
136
|
metadata.merge(ready: false, health: { error: e.class.name, message: e.message })
|
|
135
137
|
end
|
|
136
138
|
|
|
@@ -182,6 +184,112 @@ module Legion
|
|
|
182
184
|
nil
|
|
183
185
|
end
|
|
184
186
|
|
|
187
|
+
# ── Model allow-list / deny-list filtering ────────────────────────
|
|
188
|
+
|
|
189
|
+
def model_whitelist
|
|
190
|
+
wl = settings[:model_whitelist] if respond_to?(:settings)
|
|
191
|
+
Array(wl).map { |p| p.to_s.downcase }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def model_blacklist
|
|
195
|
+
bl = settings[:model_blacklist] if respond_to?(:settings)
|
|
196
|
+
Array(bl).map { |p| p.to_s.downcase }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def model_allowed?(model_name)
|
|
200
|
+
name = model_name.to_s.downcase
|
|
201
|
+
wl = model_whitelist
|
|
202
|
+
bl = model_blacklist
|
|
203
|
+
|
|
204
|
+
return false if wl.any? && wl.none? { |p| name.include?(p) }
|
|
205
|
+
return false if bl.any? && bl.any? { |p| name.include?(p) }
|
|
206
|
+
|
|
207
|
+
true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# ── Multi-host base_url resolution ────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def resolve_base_url
|
|
213
|
+
urls = Array(config_base_url)
|
|
214
|
+
@resolve_base_url ||= find_reachable_url(urls) || urls.first
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def config_base_url
|
|
218
|
+
respond_to?(:settings) ? settings[:base_url] : nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def find_reachable_url(urls)
|
|
222
|
+
urls.each do |url|
|
|
223
|
+
normalized = strip_scheme(url)
|
|
224
|
+
scheme = tls_enabled? ? 'https' : 'http'
|
|
225
|
+
full = "#{scheme}://#{normalized}"
|
|
226
|
+
return full if url_reachable?(full)
|
|
227
|
+
end
|
|
228
|
+
nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def strip_scheme(url)
|
|
232
|
+
url.to_s.sub(%r{^https?://}, '')
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def url_reachable?(url)
|
|
236
|
+
require 'uri'
|
|
237
|
+
require 'socket'
|
|
238
|
+
uri = URI.parse(url)
|
|
239
|
+
Socket.tcp(uri.host, uri.port, connect_timeout: 1).close
|
|
240
|
+
true
|
|
241
|
+
rescue StandardError
|
|
242
|
+
false
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def tls_enabled?
|
|
246
|
+
tls = respond_to?(:settings) ? settings[:tls] : nil
|
|
247
|
+
tls.is_a?(Hash) && tls[:enabled] == true
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# ── Cache helpers with local/shared tier selection ────────────────
|
|
251
|
+
|
|
252
|
+
def cache_local_instance?
|
|
253
|
+
Array(config_base_url).any? do |url|
|
|
254
|
+
host = url.to_s.downcase
|
|
255
|
+
host.include?('localhost') || host.include?('127.0.0.1') || host.include?('::1')
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def model_cache_get(key)
|
|
260
|
+
return nil unless defined?(Legion::Cache)
|
|
261
|
+
|
|
262
|
+
cache_local_instance? ? local_cache_get(key) : cache_get(key)
|
|
263
|
+
rescue StandardError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def model_cache_set(key, value, ttl:)
|
|
268
|
+
return unless defined?(Legion::Cache)
|
|
269
|
+
|
|
270
|
+
cache_local_instance? ? local_cache_set(key, value, ttl: ttl) : cache_set(key, value, ttl: ttl)
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
handle_exception(e, level: :debug, handled: true, operation: 'lex.provider.model_cache_set')
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def model_cache_fetch(key, ttl:, &)
|
|
276
|
+
return yield unless defined?(Legion::Cache)
|
|
277
|
+
|
|
278
|
+
cache_local_instance? ? local_cache_fetch(key, ttl: ttl, &) : cache_fetch(key, ttl: ttl, &)
|
|
279
|
+
rescue StandardError
|
|
280
|
+
yield
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def cache_instance_key
|
|
284
|
+
if cache_local_instance?
|
|
285
|
+
(respond_to?(:instance_id) ? instance_id : :default).to_s
|
|
286
|
+
else
|
|
287
|
+
require 'digest'
|
|
288
|
+
urls = Array(config_base_url).map { |u| strip_scheme(u).downcase.chomp('/') }.sort
|
|
289
|
+
Digest::SHA256.hexdigest(urls.join('|'))[0, 12]
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
185
293
|
class << self
|
|
186
294
|
def name
|
|
187
295
|
to_s.split('::').last
|
|
@@ -223,22 +331,26 @@ module Legion
|
|
|
223
331
|
configuration_requirements.all? { |req| config.send(req) }
|
|
224
332
|
end
|
|
225
333
|
|
|
334
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
226
335
|
def register(name, provider_class)
|
|
227
336
|
providers[name.to_sym] = provider_class
|
|
228
337
|
Legion::Extensions::Llm::Configuration.register_provider_options(provider_class.configuration_options)
|
|
229
338
|
end
|
|
230
339
|
|
|
340
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
231
341
|
def resolve(name)
|
|
232
342
|
return nil if name.nil?
|
|
233
343
|
|
|
234
344
|
providers[name.to_sym]
|
|
235
345
|
end
|
|
236
346
|
|
|
347
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
237
348
|
def for(model)
|
|
238
349
|
model_info = Models.find(model)
|
|
239
350
|
resolve model_info.provider
|
|
240
351
|
end
|
|
241
352
|
|
|
353
|
+
# @deprecated Use the extension registry instead. Will be removed in 1.0.
|
|
242
354
|
def providers
|
|
243
355
|
@providers ||= {}
|
|
244
356
|
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Builds sanitized lex-llm registry envelopes for provider state.
|
|
7
|
+
# Parameterized by `provider_family` so each lex-llm-* gem can reuse this
|
|
8
|
+
# class without defining its own copy.
|
|
9
|
+
class RegistryEventBuilder
|
|
10
|
+
include Legion::Logging::Helper
|
|
11
|
+
|
|
12
|
+
attr_reader :provider_family
|
|
13
|
+
|
|
14
|
+
def initialize(provider_family:)
|
|
15
|
+
@provider_family = provider_family.to_s.downcase.to_sym
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def readiness(readiness)
|
|
19
|
+
registry_event_class.public_send(
|
|
20
|
+
readiness[:ready] ? :available : :unavailable,
|
|
21
|
+
provider_offering(readiness),
|
|
22
|
+
runtime: runtime_metadata,
|
|
23
|
+
health: readiness_health(readiness),
|
|
24
|
+
metadata: readiness_metadata(readiness)
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def model_available(model, readiness:)
|
|
29
|
+
registry_event_class.available(
|
|
30
|
+
model_offering(model),
|
|
31
|
+
runtime: runtime_metadata,
|
|
32
|
+
health: model_health(readiness),
|
|
33
|
+
metadata: model_metadata(model)
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def provider_offering(readiness)
|
|
40
|
+
{
|
|
41
|
+
provider_family: provider_family,
|
|
42
|
+
provider_instance: provider_instance,
|
|
43
|
+
transport: :http,
|
|
44
|
+
model: 'provider-readiness',
|
|
45
|
+
usage_type: :inference,
|
|
46
|
+
capabilities: [],
|
|
47
|
+
health: readiness_health(readiness),
|
|
48
|
+
metadata: { lex: extension_sym, provider_readiness: true }
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def model_offering(model)
|
|
53
|
+
{
|
|
54
|
+
provider_family: provider_family,
|
|
55
|
+
provider_instance: provider_instance,
|
|
56
|
+
transport: :http,
|
|
57
|
+
model: model.id,
|
|
58
|
+
usage_type: usage_type_for(model),
|
|
59
|
+
capabilities: Array(model.capabilities).map(&:to_sym),
|
|
60
|
+
limits: model_limits(model),
|
|
61
|
+
metadata: { lex: extension_sym, model_name: model.name }.compact
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def readiness_health(readiness)
|
|
66
|
+
health = {
|
|
67
|
+
ready: readiness[:ready] == true,
|
|
68
|
+
status: readiness[:ready] ? :available : :unavailable,
|
|
69
|
+
checked: readiness.dig(:health, :checked) != false
|
|
70
|
+
}
|
|
71
|
+
add_readiness_error(health, readiness[:health])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_readiness_error(health, source)
|
|
75
|
+
error = source.is_a?(Hash) ? source : {}
|
|
76
|
+
error_class = error[:error] || error['error']
|
|
77
|
+
error_message = error[:message] || error['message']
|
|
78
|
+
health[:error_class] = error_class if error_class
|
|
79
|
+
health[:error] = error_message if error_message
|
|
80
|
+
health
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def model_health(readiness)
|
|
84
|
+
ready = readiness.fetch(:ready, true) == true
|
|
85
|
+
{ ready:, status: ready ? :available : :degraded }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def readiness_metadata(readiness)
|
|
89
|
+
{
|
|
90
|
+
extension: extension_sym,
|
|
91
|
+
provider: provider_family,
|
|
92
|
+
configured: readiness[:configured] == true,
|
|
93
|
+
live: readiness[:live] == true
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def model_metadata(model)
|
|
98
|
+
{ extension: extension_sym, provider: provider_family, model_type: model_type_for(model) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def runtime_metadata
|
|
102
|
+
{ node: provider_instance }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def model_limits(model)
|
|
106
|
+
limits = {}
|
|
107
|
+
limits[:context_window] = model.context_window if model.respond_to?(:context_window)
|
|
108
|
+
limits[:max_output_tokens] = model.max_output_tokens if model.respond_to?(:max_output_tokens)
|
|
109
|
+
limits.compact
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def usage_type_for(model)
|
|
113
|
+
model_type_for(model) == 'embedding' ? :embedding : :inference
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def model_type_for(model)
|
|
117
|
+
model.respond_to?(:type) ? model.type : 'chat'
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extension_sym
|
|
121
|
+
:"llm_#{provider_family}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def provider_instance
|
|
125
|
+
configured_node = (::Legion::Settings.dig(:node, :canonical_name) if defined?(::Legion::Settings))
|
|
126
|
+
value = configured_node.to_s.strip
|
|
127
|
+
value.empty? ? provider_family : value.to_sym
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
handle_exception(e, level: :debug, handled: true,
|
|
130
|
+
operation: "#{provider_family}.registry.provider_instance")
|
|
131
|
+
provider_family
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def registry_event_class
|
|
135
|
+
::Legion::Extensions::Llm::Routing::RegistryEvent
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
# Best-effort publisher for LLM provider availability events.
|
|
7
|
+
# Parameterized by `provider_family` so each lex-llm-* gem can reuse this
|
|
8
|
+
# class without defining its own copy.
|
|
9
|
+
class RegistryPublisher
|
|
10
|
+
include Legion::Logging::Helper
|
|
11
|
+
|
|
12
|
+
attr_reader :provider_family
|
|
13
|
+
|
|
14
|
+
def initialize(provider_family:, builder: nil)
|
|
15
|
+
@provider_family = provider_family.to_s.downcase.to_sym
|
|
16
|
+
@builder = builder || RegistryEventBuilder.new(provider_family: @provider_family)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def app_id
|
|
20
|
+
"lex-llm-#{provider_family}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def publish_readiness_async(readiness)
|
|
24
|
+
log.info { "publishing readiness event to llm.registry for #{provider_family}" }
|
|
25
|
+
schedule { publish_event(@builder.readiness(readiness)) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def publish_models_async(models, readiness:)
|
|
29
|
+
log.info { "publishing #{Array(models).size} model event(s) to llm.registry for #{provider_family}" }
|
|
30
|
+
schedule do
|
|
31
|
+
Array(models).each do |model|
|
|
32
|
+
publish_event(@builder.model_available(model, readiness:))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def schedule(&)
|
|
40
|
+
return false unless publishing_available?
|
|
41
|
+
|
|
42
|
+
Thread.new do
|
|
43
|
+
Thread.current.abort_on_exception = false
|
|
44
|
+
yield
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
handle_exception(e, level: :debug, handled: true,
|
|
47
|
+
operation: "#{provider_family}.registry.schedule_thread")
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
handle_exception(e, level: :debug, handled: true,
|
|
51
|
+
operation: "#{provider_family}.registry.schedule")
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def publish_event(event)
|
|
56
|
+
return false unless publishing_available?
|
|
57
|
+
|
|
58
|
+
message_class.new(event:, provider_family: provider_family, app_id: app_id).publish(spool: false)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
handle_exception(e, level: :warn, handled: true,
|
|
61
|
+
operation: "#{provider_family}.registry.publish_event")
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def publishing_available?
|
|
66
|
+
return false unless registry_event_available?
|
|
67
|
+
return false unless transport_message_available?
|
|
68
|
+
return true unless defined?(::Legion::Transport::Connection)
|
|
69
|
+
return true unless ::Legion::Transport::Connection.respond_to?(:session_open?)
|
|
70
|
+
|
|
71
|
+
::Legion::Transport::Connection.session_open?
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
handle_exception(e, level: :debug, handled: true,
|
|
74
|
+
operation: "#{provider_family}.registry.publishing_available?")
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def registry_event_available?
|
|
79
|
+
defined?(::Legion::Extensions::Llm::Routing::RegistryEvent)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def transport_message_available?
|
|
83
|
+
return true if message_class_defined?
|
|
84
|
+
return false unless defined?(::Legion::Transport::Message) && defined?(::Legion::Transport::Exchange)
|
|
85
|
+
|
|
86
|
+
require 'legion/extensions/llm/transport/messages/registry_event'
|
|
87
|
+
message_class_defined?
|
|
88
|
+
rescue LoadError => e
|
|
89
|
+
handle_exception(e, level: :debug, handled: true,
|
|
90
|
+
operation: "#{provider_family}.registry.transport_load")
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def message_class_defined?
|
|
95
|
+
defined?(::Legion::Extensions::Llm::Transport::Messages::RegistryEvent)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def message_class
|
|
99
|
+
::Legion::Extensions::Llm::Transport::Messages::RegistryEvent
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -91,8 +91,8 @@ module Legion
|
|
|
91
91
|
buffer << chunk
|
|
92
92
|
error_data = Legion::JSON.parse(buffer, symbolize_names: false)
|
|
93
93
|
handle_parsed_error(error_data, env)
|
|
94
|
-
rescue Legion::JSON::ParseError
|
|
95
|
-
|
|
94
|
+
rescue Legion::JSON::ParseError => e
|
|
95
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.handle_failed_response')
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def handle_sse(chunk, parser, env, &)
|
|
@@ -112,7 +112,7 @@ module Legion
|
|
|
112
112
|
|
|
113
113
|
handle_parsed_error(parsed, env)
|
|
114
114
|
rescue Legion::JSON::ParseError => e
|
|
115
|
-
|
|
115
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.handle_data')
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def handle_error_event(data, env)
|
|
@@ -123,7 +123,7 @@ module Legion
|
|
|
123
123
|
error_data = Legion::JSON.parse(data, symbolize_names: false)
|
|
124
124
|
[500, error_data['message'] || 'Unknown streaming error']
|
|
125
125
|
rescue Legion::JSON::ParseError => e
|
|
126
|
-
|
|
126
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.parse_streaming_error')
|
|
127
127
|
[500, "Failed to parse error: #{data}"]
|
|
128
128
|
end
|
|
129
129
|
|
|
@@ -133,11 +133,11 @@ module Legion
|
|
|
133
133
|
ErrorMiddleware.parse_error(provider: self, response: error_response)
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
-
def parse_error_from_json(data, env,
|
|
136
|
+
def parse_error_from_json(data, env, _error_message)
|
|
137
137
|
parsed_data = Legion::JSON.parse(data, symbolize_names: false)
|
|
138
138
|
handle_parsed_error(parsed_data, env)
|
|
139
139
|
rescue Legion::JSON::ParseError => e
|
|
140
|
-
|
|
140
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.streaming.parse_error_from_json')
|
|
141
141
|
end
|
|
142
142
|
|
|
143
143
|
def build_stream_error_response(parsed_data, env, status)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Legion::Transport::Exchange)
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Llm
|
|
8
|
+
module Transport
|
|
9
|
+
module Exchanges
|
|
10
|
+
# Shared topic exchange for LLM provider availability events.
|
|
11
|
+
# All lex-llm-* providers publish to the same `llm.registry` exchange.
|
|
12
|
+
class LlmRegistry < ::Legion::Transport::Exchange
|
|
13
|
+
def exchange_name
|
|
14
|
+
'llm.registry'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def default_type
|
|
18
|
+
'topic'
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
return unless defined?(Legion::Transport::Message)
|
|
4
|
+
|
|
5
|
+
require_relative '../exchanges/llm_registry'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Llm
|
|
10
|
+
module Transport
|
|
11
|
+
module Messages
|
|
12
|
+
# Publishes lex-llm RegistryEvent envelopes to the shared llm.registry exchange.
|
|
13
|
+
# Accepts a `provider_family` for constructing the app_id and routing key.
|
|
14
|
+
class RegistryEvent < ::Legion::Transport::Message
|
|
15
|
+
def initialize(event:, provider_family: nil, **options)
|
|
16
|
+
@provider_family = provider_family
|
|
17
|
+
super(**event.to_h.merge(options))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def exchange
|
|
21
|
+
Exchanges::LlmRegistry
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def routing_key
|
|
25
|
+
@options[:routing_key] || "llm.registry.#{@options.fetch(:event_type)}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def type
|
|
29
|
+
'llm.registry.event'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def app_id
|
|
33
|
+
@options[:app_id] || "lex-llm-#{@provider_family || 'unknown'}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def persistent # rubocop:disable Naming/PredicateMethod
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -8,6 +8,7 @@ require 'faraday'
|
|
|
8
8
|
require 'faraday/multipart'
|
|
9
9
|
require 'faraday/retry'
|
|
10
10
|
require 'legion/json'
|
|
11
|
+
require 'legion/logging'
|
|
11
12
|
require 'logger'
|
|
12
13
|
require 'marcel'
|
|
13
14
|
require 'ruby_llm/schema'
|
|
@@ -30,6 +31,8 @@ module Legion
|
|
|
30
31
|
'ui' => 'UI'
|
|
31
32
|
)
|
|
32
33
|
loader.ignore("#{__dir__}/llm/version.rb")
|
|
34
|
+
loader.ignore("#{__dir__}/llm/transport/exchanges")
|
|
35
|
+
loader.ignore("#{__dir__}/llm/transport/messages")
|
|
33
36
|
loader.push_dir("#{__dir__}/llm", namespace: self)
|
|
34
37
|
loader.setup
|
|
35
38
|
|
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.1.
|
|
4
|
+
version: 0.1.9
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- LegionIO
|
|
@@ -225,6 +225,8 @@ files:
|
|
|
225
225
|
- lib/legion/extensions/llm/provider.rb
|
|
226
226
|
- lib/legion/extensions/llm/provider/open_ai_compatible.rb
|
|
227
227
|
- lib/legion/extensions/llm/provider_settings.rb
|
|
228
|
+
- lib/legion/extensions/llm/registry_event_builder.rb
|
|
229
|
+
- lib/legion/extensions/llm/registry_publisher.rb
|
|
228
230
|
- lib/legion/extensions/llm/routing.rb
|
|
229
231
|
- lib/legion/extensions/llm/routing/lane_key.rb
|
|
230
232
|
- lib/legion/extensions/llm/routing/model_offering.rb
|
|
@@ -237,7 +239,9 @@ files:
|
|
|
237
239
|
- lib/legion/extensions/llm/tool.rb
|
|
238
240
|
- lib/legion/extensions/llm/tool_call.rb
|
|
239
241
|
- lib/legion/extensions/llm/transcription.rb
|
|
242
|
+
- lib/legion/extensions/llm/transport/exchanges/llm_registry.rb
|
|
240
243
|
- lib/legion/extensions/llm/transport/fleet_lane.rb
|
|
244
|
+
- lib/legion/extensions/llm/transport/messages/registry_event.rb
|
|
241
245
|
- lib/legion/extensions/llm/utils.rb
|
|
242
246
|
- lib/legion/extensions/llm/version.rb
|
|
243
247
|
homepage: https://github.com/LegionIO/lex-llm
|