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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. 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
- def chat(messages:, model:, tools: [], temperature: nil, params: {}, headers: {}, schema: nil, thinking: nil,
137
- tool_prefs: nil)
138
- complete(messages, tools:, temperature:, model:, params:, headers:, schema:, thinking:, tool_prefs:)
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:, model:, tools: [], temperature: nil, params: {}, headers: {}, schema: nil,
188
+ def stream_chat(messages, model:, tools: [], params: nil, headers: {}, schema: nil,
142
189
  thinking: nil, tool_prefs: nil, &)
143
- complete(messages, tools:, temperature:, model:, params:, headers:, schema:, thinking:, tool_prefs:, &)
190
+ complete(messages, tools:, model:, params:, headers:, schema:, thinking:, tool_prefs:, &)
144
191
  end
145
192
 
146
- def complete(messages, tools:, temperature:, model:, params: {}, headers: {}, schema: nil, thinking: nil,
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
- normalized_temperature = maybe_normalize_temperature(temperature, model)
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 = Utils.deep_merge(
165
- render_payload(
166
- messages,
167
- tools: tools,
168
- tool_prefs: tool_prefs,
169
- temperature: normalized_temperature,
170
- model: model,
171
- stream: block_given?,
172
- schema: schema,
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
- def list_models(live: false, **filters)
187
- _ = [live, filters]
188
- response = @connection.get models_url
189
- parse_list_models_response response, slug, capabilities
190
- end
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
- return filter_cached_offerings(Array(@cached_offerings), filters) unless live
194
-
195
- provider_health = health(live:)
196
- @cached_offerings = Array(list_models(live:, **filters)).filter_map do |model|
197
- publish_discovered_model_to_registry(model, provider_health:, live:)
198
- next unless model_matches_filters?(model, filters)
199
- next unless model_allowed?(model.id)
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
- def publish_discovered_model_to_registry(model, provider_health:, live:)
214
- publisher = discovery_registry_publisher
215
- return unless publisher.respond_to?(:publish_models_async)
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
- def discovery_registry_publisher
223
- return unless self.class.respond_to?(:registry_publisher)
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
- self.class.registry_publisher
226
- rescue StandardError
227
- nil
259
+ inventory_offering_matches_filter?(offering, key, value)
260
+ end
261
+ end
228
262
  end
229
263
 
230
- def discovery_registry_readiness(provider_health, live:)
231
- {
232
- provider: slug.to_sym,
233
- configured: configured?,
234
- ready: provider_health[:ready] == true,
235
- live: live,
236
- health: provider_health
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 = health_status(readiness_data, raw_health)
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
- def embed(text:, model:, dimensions: nil, params: {}, headers: {})
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 = Utils.deep_merge(render_embedding_payload(text, model:, dimensions:), params)
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, model:)
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 paint(prompt, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
339
+ def image(prompt:, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
284
340
  enforce_model_allowed!(model)
285
- validate_paint_inputs!(with:, mask:)
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 image(prompt:, model:, size:, with: nil, mask: nil, params: {}) # rubocop:disable Metrics/ParameterLists
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
- content = message.respond_to?(:content) ? message.content : message[:content] || message['content']
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. See phase-1-lex-llm-additive.md section 14.1.
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. See Phase 2 §4.5.2.
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(kind: kind, reason: reason)
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? && health_ready?(response.body), health: response.body)
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 both at runtime
549
- # (instance #model_allowed?) and at instance-config build time (provider
550
- # extensions choosing a default_model that does not violate the policy).
551
- # Substring, case-insensitive: a whitelist permits models containing any
552
- # pattern; a blacklist denies models containing any pattern; whitelist is
553
- # applied before blacklist. Empty list = no restriction from that side.
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.to_s.downcase
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? do |url|
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
- return config.instance_id.to_sym if config.respond_to?(:instance_id) && config.instance_id
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 validate_paint_inputs!(with:, mask:)
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 paint"
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
- def offering_from_model(model, health: {})
866
- capability_sources = Array(model.capabilities).to_h do |cap|
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 Content
980
- [content.text, *content.attachments.map(&:to_s)].compact.join(' ')
865
+ when String then content
866
+ when Canonical::ContentBlock
867
+ content.text.to_s
981
868
  when Array
982
- content.map do |part|
983
- part.respond_to?(:[]) ? part[:text] || part['text'] || part.to_s : part.to_s
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
- content.to_s
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
- def maybe_normalize_temperature(temperature, _model)
1018
- temperature
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
- "temperature=#{context[:temperature].inspect} " \
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
- if tool.respond_to?(:name)
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?