lex-llm 0.7.3 → 0.8.0
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 +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +6 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +29 -17
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +10 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +98 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -31,6 +31,7 @@ module Legion
|
|
|
31
31
|
# Base class for LLM providers.
|
|
32
32
|
class Provider
|
|
33
33
|
include Streaming
|
|
34
|
+
include StopReasonMapping
|
|
34
35
|
include Legion::Logging::Helper
|
|
35
36
|
include Legion::Cache::Helper
|
|
36
37
|
|
|
@@ -75,6 +76,7 @@ module Legion
|
|
|
75
76
|
audio_speech_flag
|
|
76
77
|
audio_generation_flag
|
|
77
78
|
].freeze
|
|
79
|
+
HEALTHY_STATES = %w[ok ready healthy running].freeze
|
|
78
80
|
|
|
79
81
|
attr_reader :config, :connection
|
|
80
82
|
|
|
@@ -112,7 +114,8 @@ module Legion
|
|
|
112
114
|
hdrs['x-legion-identity-db-principal-id'] = id[:db_principal_id].to_s if id[:db_principal_id]
|
|
113
115
|
hdrs['x-legion-identity-db-identity-id'] = id[:db_identity_id].to_s if id[:db_identity_id]
|
|
114
116
|
hdrs
|
|
115
|
-
rescue StandardError
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.identity_headers')
|
|
116
119
|
{}
|
|
117
120
|
end
|
|
118
121
|
|
|
@@ -132,26 +135,69 @@ module Legion
|
|
|
132
135
|
self.class.configuration_requirements
|
|
133
136
|
end
|
|
134
137
|
|
|
138
|
+
# N x N law — the dispatch boundary contract. Pipeline dispatch (direct
|
|
139
|
+
# SelectionDispatch, fleet worker rehydration) delivers
|
|
140
|
+
# Canonical::Message objects; provider callables are the canonical
|
|
141
|
+
# boundary and must reject anything else LOUDLY. No coercion, no
|
|
142
|
+
# hash tolerance, no fallback — a half-translated legacy shape here is
|
|
143
|
+
# the defect class the N x N method exists to kill.
|
|
144
|
+
def enforce_canonical_messages!(messages)
|
|
145
|
+
Array(messages).each do |message|
|
|
146
|
+
next if message.is_a?(Canonical::Message)
|
|
147
|
+
|
|
148
|
+
raise ArgumentError,
|
|
149
|
+
"provider input must be Canonical::Message objects, got #{message.class} — " \
|
|
150
|
+
'non-canonical message shapes must not cross the dispatch boundary'
|
|
151
|
+
end
|
|
152
|
+
messages
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# N x N law — the tools half of the dispatch boundary contract (H3).
|
|
156
|
+
# Enforced HERE, once, like messages: a non-empty tools value must be
|
|
157
|
+
# Hash<name, Canonical::ToolDefinition>. Hash-tolerant renderers and
|
|
158
|
+
# legacy Lex::Llm::Tool values are the defect class this check kills —
|
|
159
|
+
# the shared ToolSchema extractor already refuses them (04 §6).
|
|
160
|
+
def enforce_canonical_tools!(tools)
|
|
161
|
+
return tools if tools.nil? || tools.empty?
|
|
162
|
+
|
|
163
|
+
unless tools.is_a?(::Hash)
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"provider tools must be Hash<name, Canonical::ToolDefinition>, got #{tools.class} — " \
|
|
166
|
+
'non-canonical tool shapes must not cross the dispatch boundary'
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
tools.each_value do |tool|
|
|
170
|
+
next if tool.is_a?(Canonical::ToolDefinition)
|
|
171
|
+
|
|
172
|
+
raise ArgumentError,
|
|
173
|
+
"provider tools values must be Canonical::ToolDefinition, got #{tool.class} — " \
|
|
174
|
+
'non-canonical tool shapes must not cross the dispatch boundary'
|
|
175
|
+
end
|
|
176
|
+
tools
|
|
177
|
+
end
|
|
178
|
+
|
|
135
179
|
# rubocop:disable Metrics/ParameterLists
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
180
|
+
# The single completion funnel (05 O1/O2): chat/stream_chat are thin
|
|
181
|
+
# delegates. Central enforcement — canonical input is checked HERE, once,
|
|
182
|
+
# before any rendering; providers never re-implement the check (08 F2).
|
|
183
|
+
# temperature lives only in Canonical::Params (05 O4).
|
|
184
|
+
def chat(messages, model:, tools: [], params: nil, headers: {}, schema: nil, thinking: nil, tool_prefs: nil)
|
|
185
|
+
complete(messages, tools:, model:, params:, headers:, schema:, thinking:, tool_prefs:)
|
|
139
186
|
end
|
|
140
187
|
|
|
141
|
-
def stream_chat(messages
|
|
188
|
+
def stream_chat(messages, model:, tools: [], params: nil, headers: {}, schema: nil,
|
|
142
189
|
thinking: nil, tool_prefs: nil, &)
|
|
143
|
-
complete(messages, tools:,
|
|
190
|
+
complete(messages, tools:, model:, params:, headers:, schema:, thinking:, tool_prefs:, &)
|
|
144
191
|
end
|
|
145
192
|
|
|
146
|
-
def complete(messages,
|
|
193
|
+
def complete(messages, model:, tools: [], params: nil, headers: {}, schema: nil, thinking: nil,
|
|
147
194
|
tool_prefs: nil, &)
|
|
148
195
|
enforce_model_allowed!(model)
|
|
149
|
-
|
|
196
|
+
enforce_canonical_messages!(messages)
|
|
197
|
+
enforce_canonical_tools!(tools)
|
|
150
198
|
log_provider_request(
|
|
151
199
|
messages: messages,
|
|
152
200
|
tools: tools,
|
|
153
|
-
temperature: temperature,
|
|
154
|
-
normalized_temperature: normalized_temperature,
|
|
155
201
|
model: model,
|
|
156
202
|
params: params,
|
|
157
203
|
headers: headers,
|
|
@@ -161,86 +207,77 @@ module Legion
|
|
|
161
207
|
streaming: block_given?
|
|
162
208
|
)
|
|
163
209
|
|
|
164
|
-
payload =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
thinking: thinking
|
|
174
|
-
),
|
|
175
|
-
params
|
|
210
|
+
payload = render_payload(
|
|
211
|
+
messages,
|
|
212
|
+
tools: tools,
|
|
213
|
+
tool_prefs: tool_prefs,
|
|
214
|
+
model: model,
|
|
215
|
+
stream: block_given?,
|
|
216
|
+
schema: schema,
|
|
217
|
+
thinking: thinking,
|
|
218
|
+
params: params
|
|
176
219
|
)
|
|
177
220
|
|
|
178
221
|
if block_given?
|
|
179
|
-
stream_response @connection, payload, headers, &
|
|
222
|
+
stream_response @connection, payload, headers, model: model, &
|
|
180
223
|
else
|
|
181
224
|
sync_response @connection, payload, headers
|
|
182
225
|
end
|
|
183
226
|
end
|
|
184
227
|
# rubocop:enable Metrics/ParameterLists
|
|
185
228
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
229
|
+
# Read path (07 C5): serves the activated inventory LANES for this
|
|
230
|
+
# provider instance from the SSOT registry snapshot — one LaneRecord
|
|
231
|
+
# per 5-tuple, in lexicographic id order. The stored inventory has no
|
|
232
|
+
# separate offering id: an offering IS a lane, keyed by the 5 tuple.
|
|
233
|
+
# A consumer needing per-model grouping groups by (instance_key, model)
|
|
234
|
+
# over the returned lanes. The per-gem writer is the sole publication
|
|
235
|
+
# path. H5: this read path performs NO transport — it is an in-memory
|
|
236
|
+
# snapshot lookup — so `live:` and `raise_on_unreachable:` are
|
|
237
|
+
# accepted for signature compatibility and have no effect here.
|
|
238
|
+
# `filters` select from the snapshot.
|
|
192
239
|
def discover_offerings(live: false, raise_on_unreachable: false, **filters)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
log.debug("[#{slug}] instance=#{provider_instance_id} action=model_discovered model=#{model.id} family=#{model.family}")
|
|
202
|
-
offering_from_model(model, health: provider_health)
|
|
203
|
-
end
|
|
204
|
-
log.info("[#{slug}] instance=#{provider_instance_id} action=discover_complete model_count=#{Array(@cached_offerings).size}")
|
|
205
|
-
@cached_offerings
|
|
206
|
-
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
207
|
-
log.warn("[#{slug}] instance=#{provider_instance_id} unreachable: #{e.message}")
|
|
208
|
-
raise if raise_on_unreachable
|
|
209
|
-
|
|
210
|
-
[]
|
|
240
|
+
_live = live
|
|
241
|
+
_raise_on_unreachable = raise_on_unreachable
|
|
242
|
+
instance_key = Inventory::Identity::InstanceKey.new(
|
|
243
|
+
provider_family: slug.to_sym, instance_id: provider_instance_id
|
|
244
|
+
)
|
|
245
|
+
record = Inventory::Registry.snapshot.instance(instance_key: instance_key)
|
|
246
|
+
lanes = record ? record.lanes_by_id.values.sort_by(&:lane_id) : []
|
|
247
|
+
filter_inventory_offerings(lanes, filters)
|
|
211
248
|
end
|
|
212
249
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
publisher.publish_models_async([model], readiness: discovery_registry_readiness(provider_health, live:))
|
|
218
|
-
rescue StandardError => e
|
|
219
|
-
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.publish_discovered_model')
|
|
220
|
-
end
|
|
250
|
+
# Read-path filter over inventory lanes: model/id/name match the lane
|
|
251
|
+
# model; instance/provider keys match the instance; unknown keys pass.
|
|
252
|
+
def filter_inventory_offerings(offerings, filters)
|
|
253
|
+
return offerings if filters.empty?
|
|
221
254
|
|
|
222
|
-
|
|
223
|
-
|
|
255
|
+
offerings.select do |offering|
|
|
256
|
+
filters.all? do |key, value|
|
|
257
|
+
next true if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
224
258
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
259
|
+
inventory_offering_matches_filter?(offering, key, value)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
228
262
|
end
|
|
229
263
|
|
|
230
|
-
def
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
264
|
+
def inventory_offering_matches_filter?(offering, key, value)
|
|
265
|
+
case key.to_sym
|
|
266
|
+
when :model, :id, :name
|
|
267
|
+
offering.model.to_s == value.to_s
|
|
268
|
+
when :instance, :instance_id, :provider_instance
|
|
269
|
+
offering.instance_key.instance_id.to_s == value.to_s
|
|
270
|
+
when :provider, :provider_family
|
|
271
|
+
offering.instance_key.provider_family.to_s == value.to_s
|
|
272
|
+
else
|
|
273
|
+
true
|
|
274
|
+
end
|
|
238
275
|
end
|
|
239
276
|
|
|
240
277
|
def health(live: false)
|
|
241
278
|
readiness_data = readiness(live:)
|
|
242
279
|
raw_health = readiness_data[:health] || readiness_data['health'] || {}
|
|
243
|
-
status =
|
|
280
|
+
status = healthy?(readiness_data, raw_health) ? 'healthy' : 'unhealthy'
|
|
244
281
|
latency_ms = (raw_health[:latency_ms] || raw_health['latency_ms'] if raw_health.is_a?(Hash))
|
|
245
282
|
{
|
|
246
283
|
provider: slug.to_sym,
|
|
@@ -264,39 +301,54 @@ module Legion
|
|
|
264
301
|
}
|
|
265
302
|
end
|
|
266
303
|
|
|
267
|
-
|
|
304
|
+
# The one health classifier (10 §1E): a readiness/health body is healthy
|
|
305
|
+
# when ready is true, or the status/state names a healthy state. No
|
|
306
|
+
# other implicit health (fail-closed — 0.8.x law).
|
|
307
|
+
def healthy?(readiness_data, raw_health)
|
|
308
|
+
return true if readiness_data.is_a?(Hash) && (readiness_data[:ready] == true || readiness_data['ready'] == true)
|
|
309
|
+
|
|
310
|
+
status = if raw_health.is_a?(Hash)
|
|
311
|
+
raw_health[:status] || raw_health['status'] || raw_health[:state] || raw_health['state']
|
|
312
|
+
else
|
|
313
|
+
raw_health
|
|
314
|
+
end
|
|
315
|
+
self.class::HEALTHY_STATES.include?(status.to_s.downcase)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def embed(text:, model:, dimensions: nil, params: nil, headers: {})
|
|
268
319
|
enforce_model_allowed!(model)
|
|
269
|
-
payload =
|
|
320
|
+
payload = render_embedding_payload(text, model:, dimensions:)
|
|
321
|
+
payload = Utils.deep_merge(payload, params.to_h) if params
|
|
270
322
|
response = @connection.post(embedding_url(model:), payload) do |req|
|
|
271
323
|
req.headers = headers.merge(req.headers) unless headers.empty?
|
|
272
324
|
end
|
|
273
325
|
parse_embedding_response(response, model:, text:)
|
|
274
326
|
end
|
|
275
327
|
|
|
276
|
-
def moderate(input
|
|
328
|
+
def moderate(input:, model:)
|
|
277
329
|
enforce_model_allowed!(model)
|
|
330
|
+
unless input.is_a?(::String) || (input.is_a?(::Array) && input.all?(Canonical::Message))
|
|
331
|
+
raise ArgumentError, "moderate input must be a String or Array<Canonical::Message>, got #{input.class}"
|
|
332
|
+
end
|
|
333
|
+
|
|
278
334
|
payload = render_moderation_payload(input, model:)
|
|
279
335
|
response = @connection.post moderation_url, payload
|
|
280
336
|
parse_moderation_response(response, model:)
|
|
281
337
|
end
|
|
282
338
|
|
|
283
|
-
def
|
|
339
|
+
def image(prompt:, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
|
|
284
340
|
enforce_model_allowed!(model)
|
|
285
|
-
|
|
341
|
+
validate_image_inputs!(with:, mask:)
|
|
286
342
|
payload = render_image_payload(prompt, model:, size:, with:, mask:, params:)
|
|
287
343
|
response = @connection.post images_url(with:, mask:), payload
|
|
288
344
|
parse_image_response(response, model:)
|
|
289
345
|
end
|
|
290
346
|
|
|
291
|
-
def
|
|
292
|
-
paint(prompt, model:, size:, with:, mask:, params:)
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def count_tokens(messages:, model:, params: {})
|
|
347
|
+
def count_tokens(messages:, model:, params: nil)
|
|
296
348
|
_ = [model, params]
|
|
349
|
+
enforce_canonical_messages!(messages)
|
|
297
350
|
Array(messages).sum do |message|
|
|
298
|
-
|
|
299
|
-
estimate_text_tokens(content)
|
|
351
|
+
estimate_text_tokens(message.content)
|
|
300
352
|
end
|
|
301
353
|
end
|
|
302
354
|
|
|
@@ -311,7 +363,7 @@ module Legion
|
|
|
311
363
|
# publish OperationEvidence(status: :unsupported) or :unknown; a provider
|
|
312
364
|
# may publish :supported only when its Phase 2 conformance spec exercises
|
|
313
365
|
# the actual callable path. Neither method reads configuration or infers a
|
|
314
|
-
# model.
|
|
366
|
+
# model.
|
|
315
367
|
def translate(audio_file, model:, language:, **provider_options)
|
|
316
368
|
_ = [audio_file, model, language, provider_options]
|
|
317
369
|
raise NotImplementedError, "#{self.class} does not implement translate"
|
|
@@ -329,25 +381,13 @@ module Legion
|
|
|
329
381
|
# Provider PRs override only when their wire semantics supply stronger
|
|
330
382
|
# evidence. The fallback reason is the bounded exception class name — never
|
|
331
383
|
# a response body, credential, endpoint, or exception object. It is a base
|
|
332
|
-
# method, not a REQUIRED_SIGNATURES reflection entry.
|
|
384
|
+
# method, not a REQUIRED_SIGNATURES reflection entry.
|
|
333
385
|
def normalize_dispatch_error(error:)
|
|
334
|
-
kind = case error
|
|
335
|
-
when OverloadedError then :overloaded
|
|
336
|
-
when RateLimitError then :rate_limited
|
|
337
|
-
when UnauthorizedError then :authentication
|
|
338
|
-
when PaymentRequiredError then :billing
|
|
339
|
-
when ForbiddenError then :authorization
|
|
340
|
-
when ContextLengthExceededError then :context_rejected
|
|
341
|
-
when BadRequestError then :invalid_request
|
|
342
|
-
when ModelNotFoundError then :model_missing
|
|
343
|
-
when ModelNotAllowedError then :policy
|
|
344
|
-
when Faraday::TimeoutError, Timeout::Error then :timeout
|
|
345
|
-
when Faraday::ConnectionFailed, Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError then :connection_failure
|
|
346
|
-
else :provider_error
|
|
347
|
-
end
|
|
348
386
|
reason = error.class.name
|
|
349
387
|
reason = 'UnknownError' if reason.nil? || reason.empty?
|
|
350
|
-
Legion::Extensions::Llm::Routing::ProviderOutcome.new(
|
|
388
|
+
Legion::Extensions::Llm::Routing::ProviderOutcome.new(
|
|
389
|
+
kind: Legion::Extensions::Llm::Routing::ProviderOutcome.kind_for(error), reason: reason
|
|
390
|
+
)
|
|
351
391
|
end
|
|
352
392
|
|
|
353
393
|
def configured?
|
|
@@ -383,10 +423,6 @@ module Legion
|
|
|
383
423
|
self.class.remote?
|
|
384
424
|
end
|
|
385
425
|
|
|
386
|
-
def assume_models_exist?
|
|
387
|
-
self.class.assume_models_exist?
|
|
388
|
-
end
|
|
389
|
-
|
|
390
426
|
def readiness(live: false)
|
|
391
427
|
metadata = {
|
|
392
428
|
provider: slug.to_sym,
|
|
@@ -403,7 +439,7 @@ module Legion
|
|
|
403
439
|
return metadata.merge(health: { checked: false }) unless live && metadata[:endpoints][:health]
|
|
404
440
|
|
|
405
441
|
response = @connection.get(metadata[:endpoints][:health])
|
|
406
|
-
metadata.merge(ready: configured? &&
|
|
442
|
+
metadata.merge(ready: configured? && healthy?(nil, response.body), health: response.body)
|
|
407
443
|
rescue StandardError => e
|
|
408
444
|
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.readiness')
|
|
409
445
|
metadata.merge(ready: false, health: { error: e.class.name, message: e.message })
|
|
@@ -415,7 +451,8 @@ module Legion
|
|
|
415
451
|
|
|
416
452
|
value = public_send(method_name)
|
|
417
453
|
result[key] = value unless value.nil?
|
|
418
|
-
rescue ArgumentError, NotImplementedError
|
|
454
|
+
rescue ArgumentError, NotImplementedError => e
|
|
455
|
+
handle_exception(e, level: :debug, handled: true, operation: 'llm.provider.endpoint_manifest', method: method_name)
|
|
419
456
|
next
|
|
420
457
|
end
|
|
421
458
|
end
|
|
@@ -510,7 +547,8 @@ module Legion
|
|
|
510
547
|
|
|
511
548
|
provider_conf = ext[:llm][provider_key]
|
|
512
549
|
provider_conf.is_a?(Hash) ? provider_conf[key] : nil
|
|
513
|
-
rescue StandardError
|
|
550
|
+
rescue StandardError => e
|
|
551
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.runtime_provider_setting', key:)
|
|
514
552
|
nil
|
|
515
553
|
end
|
|
516
554
|
|
|
@@ -520,7 +558,8 @@ module Legion
|
|
|
520
558
|
|
|
521
559
|
llm_conf = Legion::Settings.dig(:extensions, :llm)
|
|
522
560
|
llm_conf.is_a?(Hash) ? llm_conf[key] : nil
|
|
523
|
-
rescue StandardError
|
|
561
|
+
rescue StandardError => e
|
|
562
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.global_llm_setting', key:)
|
|
524
563
|
nil
|
|
525
564
|
end
|
|
526
565
|
|
|
@@ -545,14 +584,23 @@ module Legion
|
|
|
545
584
|
allowed
|
|
546
585
|
end
|
|
547
586
|
|
|
548
|
-
# Single source of truth for model-policy matching, usable
|
|
549
|
-
# (instance #model_allowed?)
|
|
550
|
-
#
|
|
551
|
-
#
|
|
552
|
-
#
|
|
553
|
-
#
|
|
587
|
+
# Single source of truth for model-policy matching, usable at runtime
|
|
588
|
+
# (instance #model_allowed?). Substring, case-insensitive: a whitelist
|
|
589
|
+
# permits models containing any pattern; a blacklist denies models
|
|
590
|
+
# containing any pattern; whitelist is applied before blacklist.
|
|
591
|
+
# Empty list = no restriction from that side.
|
|
592
|
+
# Model identity for policy matching: the canonical id string. An
|
|
593
|
+
# object that responds to #id matches by its #id — never by its
|
|
594
|
+
# inspect string; bare strings pass through unchanged.
|
|
595
|
+
def self.model_identity(model)
|
|
596
|
+
candidate = model.respond_to?(:id) ? model.id : model
|
|
597
|
+
candidate = model if candidate.nil?
|
|
598
|
+
|
|
599
|
+
candidate.to_s
|
|
600
|
+
end
|
|
601
|
+
|
|
554
602
|
def self.policy_allows?(model_name, whitelist: [], blacklist: [])
|
|
555
|
-
name = model_name.
|
|
603
|
+
name = model_identity(model_name).downcase
|
|
556
604
|
wl = Array(whitelist).map { |p| p.to_s.downcase }
|
|
557
605
|
bl = Array(blacklist).map { |p| p.to_s.downcase }
|
|
558
606
|
|
|
@@ -562,50 +610,6 @@ module Legion
|
|
|
562
610
|
true
|
|
563
611
|
end
|
|
564
612
|
|
|
565
|
-
# Effective whitelist/blacklist for an instance config at build time
|
|
566
|
-
# (before provider instance exists). Same specificity cascade:
|
|
567
|
-
# 1. Per-instance (config hash — extensions.llm.<provider>.instances.<id>.model_whitelist)
|
|
568
|
-
# 2. Provider-level (extensions.llm.<provider>.model_whitelist)
|
|
569
|
-
# 3. Global (extensions.llm.model_whitelist)
|
|
570
|
-
def self.model_policy(config, provider_family)
|
|
571
|
-
cfg = config.is_a?(Hash) ? config : {}
|
|
572
|
-
provider_conf = CredentialSources.setting(:extensions, :llm, provider_family)
|
|
573
|
-
provider_conf = {} unless provider_conf.is_a?(Hash)
|
|
574
|
-
global_conf = (::Legion::Settings.dig(:extensions, :llm) if defined?(::Legion::Settings))
|
|
575
|
-
global_conf = {} unless global_conf.is_a?(Hash)
|
|
576
|
-
|
|
577
|
-
{
|
|
578
|
-
whitelist: resolve_policy_value(cfg, provider_conf, global_conf, :model_whitelist),
|
|
579
|
-
blacklist: resolve_policy_value(cfg, provider_conf, global_conf, :model_blacklist)
|
|
580
|
-
}
|
|
581
|
-
end
|
|
582
|
-
|
|
583
|
-
# Resolve a single policy value with the shared cascade
|
|
584
|
-
# (SettingsCascade: instance > provider, model leg skipped — no model
|
|
585
|
-
# exists at policy-build time) plus the legacy global
|
|
586
|
-
# extensions.llm.<key> leg. Empty values fall through.
|
|
587
|
-
def self.resolve_policy_value(cfg, provider_conf, global_conf, key)
|
|
588
|
-
value = SettingsCascade.resolve_value(provider_conf: provider_conf, instance_cfg: cfg, key: key)
|
|
589
|
-
return value unless value.nil?
|
|
590
|
-
|
|
591
|
-
global_conf = {} unless global_conf.is_a?(::Hash)
|
|
592
|
-
global_conf[key] || global_conf[key.to_s]
|
|
593
|
-
end
|
|
594
|
-
|
|
595
|
-
# Choose a default_model that never violates the model policy: prefer an
|
|
596
|
-
# explicitly-configured default when permitted; else a provider fallback when
|
|
597
|
-
# permitted; else nil, so routing resolves an allowed discovered model rather
|
|
598
|
-
# than forcing a policy-forbidden default. Keeps a whitelist/blacklist
|
|
599
|
-
# authoritative over any hardcoded provider default.
|
|
600
|
-
def self.policy_safe_default_model(configured:, fallback:, whitelist: [], blacklist: [])
|
|
601
|
-
return configured if configured && !configured.to_s.empty? &&
|
|
602
|
-
policy_allows?(configured, whitelist:, blacklist:)
|
|
603
|
-
return fallback if fallback && !fallback.to_s.empty? &&
|
|
604
|
-
policy_allows?(fallback, whitelist:, blacklist:)
|
|
605
|
-
|
|
606
|
-
nil
|
|
607
|
-
end
|
|
608
|
-
|
|
609
613
|
# Compliance guard: refuse to dispatch any request for a model excluded by
|
|
610
614
|
# the configured model_whitelist / model_blacklist. Invoked at every
|
|
611
615
|
# dispatch entry point (the last line before the model API call) so a
|
|
@@ -621,10 +625,6 @@ module Legion
|
|
|
621
625
|
|
|
622
626
|
# ── Offering defaults ─────────────────────────────────────────────
|
|
623
627
|
|
|
624
|
-
def offering_transport
|
|
625
|
-
config.respond_to?(:transport) ? config.transport : self.class.default_transport
|
|
626
|
-
end
|
|
627
|
-
|
|
628
628
|
def offering_tier
|
|
629
629
|
config.respond_to?(:tier) ? config.tier : self.class.default_tier
|
|
630
630
|
end
|
|
@@ -681,10 +681,7 @@ module Legion
|
|
|
681
681
|
# ── Cache helpers with local/shared tier selection ────────────────
|
|
682
682
|
|
|
683
683
|
def cache_local_instance?
|
|
684
|
-
Array(config_base_url).any?
|
|
685
|
-
host = url.to_s.downcase
|
|
686
|
-
host.include?('localhost') || host.include?('127.0.0.1') || host.include?('::1')
|
|
687
|
-
end
|
|
684
|
+
Array(config_base_url).any? { |url| Utils.localhost_url?(url) }
|
|
688
685
|
end
|
|
689
686
|
|
|
690
687
|
def model_cache_get(key)
|
|
@@ -726,10 +723,11 @@ module Legion
|
|
|
726
723
|
end
|
|
727
724
|
end
|
|
728
725
|
|
|
726
|
+
# M6: the instance identity is CARRIED from the owner (R6) — the
|
|
727
|
+
# single config→id derivation lives in Inventory::Identity. No local
|
|
728
|
+
# re-derivation (node names, family fallbacks) exists here.
|
|
729
729
|
def provider_instance_id
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
:default
|
|
730
|
+
Inventory::Identity.instance_id(config).to_sym
|
|
733
731
|
end
|
|
734
732
|
|
|
735
733
|
class << self
|
|
@@ -769,10 +767,6 @@ module Legion
|
|
|
769
767
|
!local?
|
|
770
768
|
end
|
|
771
769
|
|
|
772
|
-
def assume_models_exist?
|
|
773
|
-
false
|
|
774
|
-
end
|
|
775
|
-
|
|
776
770
|
def resolve_model_id(model_id, config: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
777
771
|
model_id
|
|
778
772
|
end
|
|
@@ -815,7 +809,8 @@ module Legion
|
|
|
815
809
|
return false unless defined?(Legion::Settings)
|
|
816
810
|
|
|
817
811
|
Legion::Settings.dig(:llm, :prompt_caching, :enabled) == true
|
|
818
|
-
rescue StandardError
|
|
812
|
+
rescue StandardError => e
|
|
813
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.global_prompt_caching')
|
|
819
814
|
false
|
|
820
815
|
end
|
|
821
816
|
|
|
@@ -843,10 +838,10 @@ module Legion
|
|
|
843
838
|
Digest::SHA256.hexdigest(cred.to_s)[0, 8]
|
|
844
839
|
end
|
|
845
840
|
|
|
846
|
-
def
|
|
841
|
+
def validate_image_inputs!(with:, mask:)
|
|
847
842
|
return if with.nil? && mask.nil?
|
|
848
843
|
|
|
849
|
-
raise UnsupportedAttachmentError, "#{name} does not support image references in
|
|
844
|
+
raise UnsupportedAttachmentError, "#{name} does not support image references in image"
|
|
850
845
|
end
|
|
851
846
|
|
|
852
847
|
def extract_capability_config(source)
|
|
@@ -857,133 +852,25 @@ module Legion
|
|
|
857
852
|
|
|
858
853
|
value = source.public_send(key)
|
|
859
854
|
result[key] = value unless value.nil?
|
|
860
|
-
rescue StandardError
|
|
855
|
+
rescue StandardError => e
|
|
856
|
+
handle_exception(e, level: :debug, handled: true, operation: "#{slug}.extract_capability_config", key: key)
|
|
861
857
|
next
|
|
862
858
|
end
|
|
863
859
|
end
|
|
864
860
|
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
[cap.to_sym, { value: true, source: :model_metadata }]
|
|
868
|
-
end
|
|
869
|
-
|
|
870
|
-
Routing::ModelOffering.new(
|
|
871
|
-
provider_family: slug.to_sym,
|
|
872
|
-
provider_instance: model.instance || provider_instance_id,
|
|
873
|
-
transport: offering_transport,
|
|
874
|
-
tier: offering_tier,
|
|
875
|
-
model: model.id,
|
|
876
|
-
canonical_model_alias: model.name,
|
|
877
|
-
model_family: model.family,
|
|
878
|
-
usage_type: offering_usage_type(model),
|
|
879
|
-
capabilities: model.capabilities,
|
|
880
|
-
capability_sources: capability_sources,
|
|
881
|
-
limits: offering_limits(model),
|
|
882
|
-
health:,
|
|
883
|
-
metadata: offering_metadata(model)
|
|
884
|
-
)
|
|
885
|
-
end
|
|
886
|
-
|
|
887
|
-
def offering_usage_type(model)
|
|
888
|
-
model.embedding? ? :embedding : :inference
|
|
889
|
-
end
|
|
890
|
-
|
|
891
|
-
def offering_limits(model)
|
|
892
|
-
{
|
|
893
|
-
context_window: model.context_length,
|
|
894
|
-
max_output_tokens: model.max_output_tokens
|
|
895
|
-
}.compact
|
|
896
|
-
end
|
|
897
|
-
|
|
898
|
-
def offering_metadata(model)
|
|
899
|
-
{
|
|
900
|
-
raw_model: model.id,
|
|
901
|
-
parameter_count: model.parameter_count,
|
|
902
|
-
parameter_size: model.parameter_size,
|
|
903
|
-
quantization: model.quantization,
|
|
904
|
-
size_bytes: model.size_bytes,
|
|
905
|
-
modalities_input: model.modalities_input,
|
|
906
|
-
modalities_output: model.modalities_output
|
|
907
|
-
}.merge(model.metadata || {}).compact
|
|
908
|
-
end
|
|
909
|
-
|
|
910
|
-
def model_matches_filters?(model, filters)
|
|
911
|
-
return true if filters.empty?
|
|
912
|
-
|
|
913
|
-
filters.all? do |key, value|
|
|
914
|
-
blank_filter_value?(value) || model_matches_filter?(model, key, value)
|
|
915
|
-
end
|
|
916
|
-
end
|
|
917
|
-
|
|
918
|
-
def blank_filter_value?(value)
|
|
919
|
-
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
920
|
-
end
|
|
921
|
-
|
|
922
|
-
def model_matches_filter?(model, key, value)
|
|
923
|
-
case key.to_sym
|
|
924
|
-
when :capability, :capabilities
|
|
925
|
-
Array(value).all? { |capability| model.supports?(capability) }
|
|
926
|
-
when :type, :usage_type, :purpose
|
|
927
|
-
offering_usage_type(model).to_s == value.to_s || model.type.to_s == value.to_s
|
|
928
|
-
when :model, :id, :name
|
|
929
|
-
[model.id, model.name].map(&:to_s).include?(value.to_s)
|
|
930
|
-
when :instance, :instance_id, :provider_instance
|
|
931
|
-
provider_instance_id.to_s == value.to_s || model.instance.to_s == value.to_s
|
|
932
|
-
else
|
|
933
|
-
true
|
|
934
|
-
end
|
|
935
|
-
end
|
|
936
|
-
|
|
937
|
-
def filter_cached_offerings(offerings, filters)
|
|
938
|
-
return offerings if filters.empty?
|
|
939
|
-
|
|
940
|
-
offerings.select do |offering|
|
|
941
|
-
filters.all? do |key, value|
|
|
942
|
-
blank_filter_value?(value) || offering_matches_filter?(offering, key, value)
|
|
943
|
-
end
|
|
944
|
-
end
|
|
945
|
-
end
|
|
946
|
-
|
|
947
|
-
def offering_matches_filter?(offering, key, value)
|
|
948
|
-
case key.to_sym
|
|
949
|
-
when :provider, :provider_family
|
|
950
|
-
offering.provider_family.to_s == value.to_s
|
|
951
|
-
when :capability, :capabilities
|
|
952
|
-
Array(value).all? { |capability| offering.supports?(capability) }
|
|
953
|
-
when :type, :usage_type, :purpose
|
|
954
|
-
offering.usage_type.to_s == value.to_s
|
|
955
|
-
when :model, :id, :name
|
|
956
|
-
[offering.model, offering.canonical_model_alias].compact.map(&:to_s).include?(value.to_s)
|
|
957
|
-
when :instance, :instance_id, :provider_instance
|
|
958
|
-
[offering.provider_instance, offering.instance_id].compact.map(&:to_s).include?(value.to_s)
|
|
959
|
-
else
|
|
960
|
-
true
|
|
961
|
-
end
|
|
962
|
-
end
|
|
963
|
-
|
|
964
|
-
def health_status(readiness_data, raw_health)
|
|
965
|
-
return 'healthy' if readiness_data[:ready] == true || readiness_data['ready'] == true
|
|
966
|
-
|
|
967
|
-
status = if raw_health.is_a?(Hash)
|
|
968
|
-
raw_health[:status] || raw_health['status'] || raw_health[:state] || raw_health['state']
|
|
969
|
-
else
|
|
970
|
-
raw_health
|
|
971
|
-
end
|
|
972
|
-
return 'healthy' if %w[ok ready healthy running].include?(status.to_s.downcase)
|
|
973
|
-
|
|
974
|
-
'unhealthy'
|
|
975
|
-
end
|
|
976
|
-
|
|
861
|
+
# Canonical content only (05 §2): String | ContentBlock |
|
|
862
|
+
# Array<ContentBlock> | nil — one estimator code path.
|
|
977
863
|
def estimate_text_tokens(content)
|
|
978
864
|
text = case content
|
|
979
|
-
when
|
|
980
|
-
|
|
865
|
+
when String then content
|
|
866
|
+
when Canonical::ContentBlock
|
|
867
|
+
content.text.to_s
|
|
981
868
|
when Array
|
|
982
|
-
content.
|
|
983
|
-
|
|
869
|
+
content.filter_map do |block|
|
|
870
|
+
block.is_a?(Canonical::ContentBlock) && block.text? ? block.text : nil
|
|
984
871
|
end.join(' ')
|
|
985
872
|
else
|
|
986
|
-
|
|
873
|
+
''
|
|
987
874
|
end
|
|
988
875
|
[(text.length / 4.0).ceil, 1].max
|
|
989
876
|
end
|
|
@@ -1003,7 +890,8 @@ module Legion
|
|
|
1003
890
|
return maybe_json unless maybe_json.is_a?(String)
|
|
1004
891
|
|
|
1005
892
|
Legion::JSON.parse(maybe_json, symbolize_names: false)
|
|
1006
|
-
rescue Legion::JSON::ParseError
|
|
893
|
+
rescue Legion::JSON::ParseError => e
|
|
894
|
+
handle_exception(e, level: :debug, handled: true, operation: 'llm.provider.try_parse_json')
|
|
1007
895
|
maybe_json
|
|
1008
896
|
end
|
|
1009
897
|
|
|
@@ -1014,8 +902,11 @@ module Legion
|
|
|
1014
902
|
raise ConfigurationError, "Missing configuration for #{name}: #{missing.join(', ')}"
|
|
1015
903
|
end
|
|
1016
904
|
|
|
1017
|
-
|
|
1018
|
-
|
|
905
|
+
# One home for temperature (05 O4): it lives in Canonical::Params.
|
|
906
|
+
# Provider renderers that need per-model normalization read
|
|
907
|
+
# params.temperature and call this hook from their render path.
|
|
908
|
+
def maybe_normalize_temperature(params)
|
|
909
|
+
params&.temperature
|
|
1019
910
|
end
|
|
1020
911
|
|
|
1021
912
|
def log_provider_request(context)
|
|
@@ -1023,9 +914,7 @@ module Legion
|
|
|
1023
914
|
"Preparing provider completion: provider=#{slug} model=#{debug_model_id(context[:model])} " \
|
|
1024
915
|
"streaming=#{context[:streaming]} messages=#{Array(context[:messages]).size} " \
|
|
1025
916
|
"tools=#{debug_tool_names(context[:tools]).inspect} " \
|
|
1026
|
-
"
|
|
1027
|
-
"normalized_temperature=#{context[:normalized_temperature].inspect} " \
|
|
1028
|
-
"param_keys=#{debug_hash_keys(context[:params]).inspect} " \
|
|
917
|
+
"params=#{debug_value_summary(context[:params])} " \
|
|
1029
918
|
"header_keys=#{debug_hash_keys(context[:headers]).inspect} " \
|
|
1030
919
|
"schema=#{debug_value_summary(context[:schema])} " \
|
|
1031
920
|
"thinking=#{debug_value_summary(context[:thinking])} " \
|
|
@@ -1039,17 +928,14 @@ module Legion
|
|
|
1039
928
|
model
|
|
1040
929
|
end
|
|
1041
930
|
|
|
931
|
+
# H3: the funnel enforces Canonical::ToolDefinition before logging,
|
|
932
|
+
# so the Hash-tolerance branch is deleted — only canonical names or
|
|
933
|
+
# a class name for anything that slips a direct private call.
|
|
1042
934
|
def debug_tool_names(tools)
|
|
1043
935
|
tool_definitions = tools.is_a?(Hash) ? tools.values : Array(tools)
|
|
1044
936
|
|
|
1045
937
|
tool_definitions.filter_map do |tool|
|
|
1046
|
-
|
|
1047
|
-
tool.name
|
|
1048
|
-
elsif tool.is_a?(Hash)
|
|
1049
|
-
tool[:name] || tool['name']
|
|
1050
|
-
else
|
|
1051
|
-
tool.class.name
|
|
1052
|
-
end
|
|
938
|
+
tool.respond_to?(:name) ? tool.name : tool.class.name
|
|
1053
939
|
end
|
|
1054
940
|
end
|
|
1055
941
|
|
|
@@ -1079,15 +965,6 @@ module Legion
|
|
|
1079
965
|
}
|
|
1080
966
|
end
|
|
1081
967
|
|
|
1082
|
-
def health_ready?(body)
|
|
1083
|
-
return body unless body.is_a?(Hash)
|
|
1084
|
-
|
|
1085
|
-
status = body['status'] || body[:status] || body['state'] || body[:state]
|
|
1086
|
-
return true if status.nil?
|
|
1087
|
-
|
|
1088
|
-
%w[ok ready healthy running].include?(status.to_s.downcase)
|
|
1089
|
-
end
|
|
1090
|
-
|
|
1091
968
|
def sync_response(connection, payload, additional_headers = {})
|
|
1092
969
|
response = connection.post completion_url, payload do |req|
|
|
1093
970
|
req.headers = additional_headers.merge(req.headers) unless additional_headers.empty?
|