lex-llm 0.7.6 → 0.8.4

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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -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 +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'legion/extensions/llm/settings_cascade'
4
4
  require 'legion/extensions/llm/inventory/identity'
5
+ require 'legion/extensions/llm/taxonomies'
5
6
 
6
7
  module Legion
7
8
  module Extensions
@@ -11,15 +12,17 @@ module Legion
11
12
  # reads the stored scalar; it is computed ONLY here, at write events, from a
12
13
  # live settings read. Each multiplicative axis reads ITS OWN scope — never a
13
14
  # fall-through cascade (a fall-through would double-count the other axes).
15
+ # The offering-scope settings key is the lane's 5 tuple — operator-readable,
16
+ # composed via Identity.compose_lane_id.
14
17
  module WeightSchema
15
18
  module_function
16
19
 
17
20
  IDENTITY = 100
18
21
 
19
- # Exact 4-key hash. offering_id derives from instance_key + the REAL
20
- # provider native key (NOT the model — azure's native key is
21
- # deployment_name, which can differ from model_id).
22
- def weight_inputs(settings:, instance_key:, provider_native_key:, model:, tier:)
22
+ # Exact 4-key hash. The offering-scope settings key is the 5 tuple of
23
+ # the lanes the draft's operation evidence supports (one per distinct
24
+ # lane type) — finally operator-usable, no digest.
25
+ def weight_inputs(settings:, instance_key:, model:, tier:, operation_evidence:)
23
26
  llm_conf = scope_hash(settings.dig(:extensions, :llm), path: 'extensions.llm')
24
27
  provider_conf = scope_hash(
25
28
  SettingsCascade.lookup(llm_conf, instance_key.provider_family),
@@ -35,10 +38,10 @@ module Legion
35
38
  tier_weights = scope_hash(
36
39
  settings.dig(:llm, :routing, :tier_weights), path: 'llm.routing.tier_weights'
37
40
  )
38
- offering_id = Identity.offering_id(instance_key: instance_key, provider_native_key: provider_native_key)
39
41
  offerings = scope_hash(SettingsCascade.lookup(provider_conf, :offerings), path: 'provider.offerings')
40
- offering_conf = scope_hash(
41
- SettingsCascade.lookup(offerings, offering_id), path: "provider.offerings.#{offering_id}"
42
+ offering_w = offering_scope_weight(
43
+ offerings: offerings, instance_key: instance_key, model: model, tier: tier,
44
+ operation_evidence: operation_evidence
42
45
  )
43
46
  provider_models = scope_hash(
44
47
  SettingsCascade.lookup(provider_conf, :models), path: 'provider.models'
@@ -59,7 +62,6 @@ module Legion
59
62
  path: "merged model scope #{model}"
60
63
  )
61
64
 
62
- offering_w = SettingsCascade.lookup(offering_conf, :weight)
63
65
  model_w = SettingsCascade.lookup(model_scopes, :weight)
64
66
  {
65
67
  tier: component(SettingsCascade.lookup(tier_weights, tier), IDENTITY),
@@ -72,6 +74,43 @@ module Legion
72
74
  }
73
75
  end
74
76
 
77
+ # The configured offering-scope weight for the draft: the operator keys
78
+ # `extensions.llm.<provider>.offerings` by the lane's 5 tuple. The lanes
79
+ # a draft supports are one 5 tuple per distinct lane type; the draft
80
+ # carries a SINGLE weight pair, so configured weights across its lanes
81
+ # must agree — a disagreement is an ambiguous operator input and RAISES
82
+ # instead of silently picking one.
83
+ def offering_scope_weight(offerings:, instance_key:, model:, tier:, operation_evidence:)
84
+ lane_ids = supported_lane_ids(
85
+ instance_key: instance_key, model: model, tier: tier, operation_evidence: operation_evidence
86
+ )
87
+ weights = lane_ids.filter_map do |lane_id|
88
+ scope = scope_hash(SettingsCascade.lookup(offerings, lane_id), path: "provider.offerings.#{lane_id}")
89
+ SettingsCascade.lookup(scope, :weight)
90
+ end
91
+ return nil if weights.empty?
92
+
93
+ unless weights.uniq.length == 1
94
+ raise ArgumentError, "offering weight scope is ambiguous: the draft's lanes " \
95
+ "#{lane_ids.join(', ')} carry differing configured weights"
96
+ end
97
+
98
+ weights.first
99
+ end
100
+
101
+ def supported_lane_ids(instance_key:, model:, tier:, operation_evidence:)
102
+ operation_evidence.each_value.with_object([]) do |evidence, lane_ids|
103
+ next unless evidence.supported?
104
+
105
+ lane_id = Identity.compose_lane_id(
106
+ tier: tier, provider_family: instance_key.provider_family,
107
+ instance_id: instance_key.instance_id,
108
+ type: Taxonomies.lane_type_for(operation: evidence.operation), model: model
109
+ )
110
+ lane_ids << lane_id unless lane_ids.include?(lane_id)
111
+ end
112
+ end
113
+
75
114
  # nil → default; an explicit 0 passes through (0 = operator disable —
76
115
  # 0 is TRUTHY in Ruby and must not be defaulted); any other non-Integer
77
116
  # RAISES instead of silently applying a different weight.
@@ -4,7 +4,12 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  class Provider
7
- # Shared OpenAI-compatible HTTP payload and response adapter.
7
+ # Shared OpenAI-compatible HTTP payload and response adapter — the
8
+ # reference implementation for the OpenAI wire dialect (08 R3).
9
+ # Renders FROM Canonical (render_payload) and parses TO Canonical
10
+ # (parse_completion_response / build_chunk). Provider-dialect
11
+ # translation (usage spellings, JSON-string arguments, reasoning
12
+ # fields) lives here, at the edge (03 O03a).
8
13
  module OpenAICompatible
9
14
  def stream_usage_supported? = false
10
15
  def completion_url = '/v1/chat/completions'
@@ -20,68 +25,94 @@ module Legion
20
25
 
21
26
  private
22
27
 
23
- def render_payload(messages, tools:, temperature:, model:, stream:, schema:, thinking:, tool_prefs:) # rubocop:disable Metrics/ParameterLists
28
+ def render_payload(messages, tools:, model:, stream:, schema:, thinking:, params:, tool_prefs:) # rubocop:disable Metrics/ParameterLists
24
29
  payload = {
25
- model: model.id,
30
+ model: model,
26
31
  messages: format_openai_messages(messages),
27
- temperature: temperature,
32
+ temperature: maybe_normalize_temperature(params),
28
33
  stream: stream,
29
34
  tools: format_openai_tools(tools),
30
35
  tool_choice: openai_tool_choice(tool_prefs),
31
36
  response_format: openai_response_format(schema),
32
37
  reasoning_effort: openai_reasoning_effort(thinking)
33
38
  }.compact
39
+ payload.merge!(openai_payload_params(params))
34
40
  payload[:stream_options] = { include_usage: true } if stream && stream_usage_supported?
35
41
  payload
36
42
  end
37
43
 
44
+ # Canonical Params → OpenAI wire keys (edge translation, O03a).
45
+ def openai_payload_params(params)
46
+ return {} unless params
47
+
48
+ {
49
+ max_tokens: params.max_tokens,
50
+ top_p: params.top_p,
51
+ top_k: params.top_k,
52
+ stop: params.stop_sequences,
53
+ seed: params.seed,
54
+ frequency_penalty: params.frequency_penalty,
55
+ presence_penalty: params.presence_penalty,
56
+ response_format: openai_response_format_value(params.response_format)
57
+ }.compact
58
+ end
59
+
60
+ # response_format travels in its provider wire shape (client
61
+ # translator concern): a wire Hash passes through, a mode String is
62
+ # wrapped.
63
+ def openai_response_format_value(response_format)
64
+ return nil if response_format.nil?
65
+ return response_format if response_format.is_a?(::Hash)
66
+
67
+ { type: response_format.to_s }
68
+ end
69
+
38
70
  def format_openai_messages(messages)
39
71
  messages.map do |message|
40
72
  {
41
73
  role: message.role.to_s,
42
- content: openai_content(message.content, role: message.role),
74
+ content: openai_content(message.content),
43
75
  tool_call_id: message.tool_call_id,
44
76
  tool_calls: format_openai_tool_calls(message.tool_calls)
45
77
  }.compact
46
78
  end
47
79
  end
48
80
 
49
- def openai_content(content, role:)
50
- return content.format if content.is_a?(Legion::Extensions::Llm::Content::Raw)
51
- return sanitize_openai_text(content, role:) unless content.respond_to?(:attachments)
52
- return sanitize_openai_text(content.text.to_s, role:) if content.attachments.empty?
81
+ # L10: the role-parameterized sanitizer is deleted — text content
82
+ # passes through verbatim in every role (the decision comment
83
+ # below documents why).
84
+ def openai_content(content)
85
+ return content.map { |block| openai_content(block) } if content.is_a?(::Array)
86
+ return content.to_s if content.nil? || content.is_a?(::String)
53
87
 
54
- openai_content_parts(content)
55
- end
88
+ return content.text.to_s if content.text?
56
89
 
57
- def openai_content_parts(content)
58
- parts = []
59
- parts << { type: 'text', text: content.text.to_s } if content.text
60
- content.attachments.each do |attachment|
61
- parts << { type: 'image_url', image_url: { url: attachment.for_llm } } if attachment.image?
62
- end
63
- parts
90
+ {
91
+ type: content.type.to_s,
92
+ text: content.text,
93
+ data: content.data,
94
+ media_type: content.media_type,
95
+ source_type: content.source_type,
96
+ name: content.name,
97
+ id: content.id,
98
+ input: content.input,
99
+ tool_use_id: content.tool_use_id,
100
+ is_error: content.is_error
101
+ }.compact
64
102
  end
65
103
 
66
- def sanitize_openai_text(text, role:)
67
- return text unless role.to_sym == :assistant && text.is_a?(String)
68
-
69
- # Preserve thinking tags in the content — qwen3.6 outputs thinking in
70
- # <think> tags and expects to see its own reasoning on subsequent rounds.
71
- # The Anthropic API layer separates thinking into distinct content blocks
72
- # for client-facing responses; the OpenAI compat layer passes them through.
73
- text
74
- end
104
+ # Thinking-tag pass-through decision (L10: the vestigial sanitizer
105
+ # is deleted, the decision stays): qwen3.6 outputs thinking in
106
+ # tags and expects to see its own reasoning on subsequent rounds.
107
+ # The Anthropic API layer separates thinking into distinct content
108
+ # blocks for client-facing responses; the OpenAI compat layer passes
109
+ # them through untouched.
75
110
 
76
111
  def format_openai_tool_calls(tool_calls)
77
112
  return nil unless tool_calls&.any?
78
113
 
79
- # Array is the canonical shape (per canonical/message.rb); Hash
80
- # is the legacy lex-llm shape (id => ToolCall). Both flow through
81
- # this renderer depending on caller.
82
- calls = tool_calls.is_a?(Hash) ? tool_calls.values : Array(tool_calls)
83
-
84
- calls.map do |tool_call|
114
+ # Array<Canonical::ToolCall> only (the legacy Hash shape is deleted).
115
+ tool_calls.map do |tool_call|
85
116
  {
86
117
  id: tool_call.id,
87
118
  type: 'function',
@@ -94,7 +125,7 @@ module Legion
94
125
  end
95
126
 
96
127
  def format_openai_tools(tools)
97
- return nil if tools.empty?
128
+ return nil if tools.nil? || tools.empty?
98
129
 
99
130
  tools.values.map do |tool|
100
131
  {
@@ -123,176 +154,128 @@ module Legion
123
154
  { type: 'json_schema', json_schema: schema_hash }
124
155
  end
125
156
 
157
+ # Transpose canonical thinking -> OpenAI `reasoning_effort` (edge, O03a).
158
+ # Omit the key when there is no thinking or it is explicitly disabled
159
+ # (a reasoning model then uses its own default). resolved_effort fills
160
+ # the effort axis from budget when the client supplied only a budget,
161
+ # so a budget-only request is never silently dropped.
126
162
  def openai_reasoning_effort(thinking)
127
- return nil unless thinking.is_a?(Hash)
163
+ return nil unless thinking.is_a?(Canonical::Thinking::Config)
164
+ return nil unless thinking.enabled
128
165
 
129
- thinking[:effort] || thinking['effort']
166
+ thinking.resolved_effort
130
167
  end
131
168
 
169
+ # One response-parse boundary (08 R2): returns Canonical::Response.
132
170
  def parse_completion_response(response)
133
171
  body = response.body
134
172
  choice = Array(body['choices']).first || {}
135
173
  message = choice['message'] || {}
136
- usage = body['usage'] || {}
137
- content, thinking = extract_thinking_from_completion(message)
138
-
139
- Legion::Extensions::Llm::Message.new(
140
- role: :assistant,
141
- content: content,
142
- model_id: body['model'],
143
- tool_calls: parse_tool_calls(message['tool_calls']),
144
- thinking: thinking,
145
- input_tokens: usage['prompt_tokens'],
146
- output_tokens: usage['completion_tokens'],
147
- reasoning_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens'),
148
- raw: body
149
- )
150
- end
151
-
152
- def extract_thinking_from_completion(message)
153
174
  extraction = Responses::ThinkingExtractor.extract(
154
175
  message['content'],
155
176
  metadata: thinking_metadata(message)
156
177
  )
157
178
 
158
- [
159
- extraction.content,
160
- Thinking.build(
161
- text: extraction.thinking,
162
- signature: extraction.signature
163
- )
164
- ]
179
+ Canonical::Response.build(
180
+ text: extraction.content,
181
+ thinking: thinking_value(extraction),
182
+ tool_calls: parse_tool_calls(message['tool_calls']),
183
+ usage: openai_usage_to_canonical(body['usage']),
184
+ stop_reason: stop_reason_lookup(body.dig('choices', 0, 'finish_reason')),
185
+ model: body['model']
186
+ )
165
187
  end
166
188
 
167
- def thinking_metadata(message)
168
- {
169
- reasoning_content: message['reasoning_content'],
170
- reasoning: message['reasoning'],
171
- thinking: message['thinking'],
172
- thinking_text: message['thinking_text'],
173
- thinking_signature: message['thinking_signature'],
174
- reasoning_signature: message['reasoning_signature']
175
- }.compact
189
+ # One thinking-metadata key list (10 U3): both the sync and chunk
190
+ # paths extract through the shared ThinkingExtractor core.
191
+ def thinking_metadata(wire_message)
192
+ Responses::ThinkingExtractor::THINKING_METADATA_KEYS.each_with_object({}) do |key, hash|
193
+ value = wire_message[key.to_s]
194
+ hash[key] = value unless value.nil?
195
+ end
176
196
  end
177
197
 
178
- def build_chunk(data)
179
- choice = Array(data['choices']).first || {}
180
- delta = choice['delta'] || {}
181
- usage = data['usage'] || {}
182
- content, thinking = extract_thinking_from_chunk(delta)
183
-
184
- Legion::Extensions::Llm::Chunk.new(
185
- role: :assistant,
186
- content: content,
187
- model_id: data['model'],
188
- tool_calls: parse_streaming_tool_calls(delta['tool_calls']),
189
- thinking: thinking,
198
+ def thinking_value(extraction)
199
+ return nil if extraction.thinking.nil? && extraction.signature.nil?
200
+
201
+ Canonical::Thinking.build(content: extraction.thinking, signature: extraction.signature)
202
+ end
203
+
204
+ # OpenAI wire usage → canonical keys (edge translation, O03a).
205
+ def openai_usage_to_canonical(usage)
206
+ return nil if usage.to_h.empty?
207
+
208
+ Canonical::Usage.build(
190
209
  input_tokens: usage['prompt_tokens'],
191
210
  output_tokens: usage['completion_tokens'],
192
- raw: data
211
+ cache_read_tokens: usage.dig('prompt_tokens_details', 'cached_tokens') || usage.dig('input_tokens_details', 'cached_tokens'),
212
+ thinking_tokens: usage.dig('completion_tokens_details', 'reasoning_tokens') || usage.dig('output_tokens_details', 'reasoning_tokens')
193
213
  )
194
214
  end
195
215
 
196
- def parse_streaming_tool_calls(tool_calls)
197
- return nil unless tool_calls&.any?
216
+ # One chunk-parse boundary (08 R2): returns a Canonical::Chunk or an
217
+ # Array of them. In-band think tags are NOT split here — tags split
218
+ # across chunks are the accumulator's stateful job (U1); per-chunk
219
+ # metadata (e.g. reasoning_content) is separated here, statelessly.
220
+ def build_chunk(data)
221
+ choice = Array(data['choices']).first || {}
222
+ delta = choice['delta'] || {}
223
+ usage = data['usage'] || {}
198
224
 
199
- tool_calls.to_h do |call|
200
- function = call.fetch('function', {})
201
- name = function['name']
202
- id = call['id']
203
- key = (name || id || call['index']).to_s.to_sym
204
- [
205
- key,
206
- Legion::Extensions::Llm::ToolCall.new(id: id, name: name, arguments: function['arguments'] || '')
207
- ]
225
+ chunks = []
226
+ metadata = thinking_metadata(delta)
227
+ if metadata.any?
228
+ extraction = Responses::ThinkingExtractor.extract(nil, metadata:)
229
+ if extraction.thinking
230
+ chunks << Canonical::Chunk.thinking_delta(
231
+ delta: extraction.thinking, request_id: nil, signature: extraction.signature
232
+ )
233
+ end
208
234
  end
209
- end
210
-
211
- def extract_thinking_from_chunk(delta)
212
- reasoning = delta['reasoning_content'] || delta['reasoning']
213
235
  content = delta['content']
236
+ chunks << Canonical::Chunk.text_delta(delta: content.to_s, request_id: nil) if content
237
+ chunks.concat(parse_streaming_tool_calls(delta['tool_calls']))
238
+ canonical_usage = openai_usage_to_canonical(usage)
239
+ chunks << Canonical::Chunk.usage_chunk(usage: canonical_usage, request_id: nil) if canonical_usage
214
240
 
215
- if reasoning
216
- [content, Thinking.build(text: reasoning)]
241
+ if chunks.empty?
242
+ nil
217
243
  else
218
- [content, nil]
244
+ (chunks.size == 1 ? chunks.first : chunks)
219
245
  end
220
246
  end
221
247
 
222
- def parse_tool_calls(tool_calls)
223
- return nil unless tool_calls&.any?
248
+ def parse_streaming_tool_calls(tool_calls)
249
+ return [] unless tool_calls&.any?
224
250
 
225
- tool_calls.to_h do |call|
251
+ tool_calls.filter_map do |call|
226
252
  function = call.fetch('function', {})
227
253
  name = function['name']
228
- id = call['id'] || name || call['index']
229
- key = name || id
230
- [
231
- key.to_s.to_sym,
232
- Legion::Extensions::Llm::ToolCall.new(
233
- id: id&.to_s,
234
- name: name,
235
- arguments: parse_tool_arguments(function['arguments'])
236
- )
237
- ]
238
- end
239
- end
240
-
241
- def parse_tool_arguments(arguments)
242
- return {} if arguments.nil? || arguments == ''
243
- return arguments if arguments.is_a?(Hash)
244
-
245
- Legion::JSON.parse(arguments, symbolize_names: false)
246
- rescue Legion::JSON::ParseError => e
247
- handle_exception(e, level: :warn, handled: true, operation: 'llm.provider.parse_tool_arguments')
248
- {}
249
- end
254
+ id = call['id']
255
+ index = call['index']
256
+ arguments_fragment = function['arguments'].to_s
257
+ next nil if id.nil? && name.nil? && index.nil? && arguments_fragment.empty?
250
258
 
251
- def parse_list_models_response(response, provider, capabilities)
252
- response.body.fetch('data', []).map do |model|
253
- critical_capabilities = critical_capabilities_for(capabilities, model)
254
- Legion::Extensions::Llm::Model::Info.from_hash(
255
- id: model.fetch('id'),
256
- name: model['id'],
257
- provider: provider,
258
- created_at: model_created_at(model['created']),
259
- capabilities: critical_capabilities,
260
- modalities: modalities_for_capabilities(critical_capabilities),
261
- metadata: model
259
+ Canonical::Chunk.tool_call_delta(
260
+ tool_call: { id: id, name: name, arguments: arguments_fragment, index: index },
261
+ request_id: nil
262
262
  )
263
263
  end
264
264
  end
265
265
 
266
- def model_created_at(value)
267
- value.is_a?(Numeric) ? Time.at(value).utc : value
268
- end
269
-
270
- def critical_capabilities_for(capabilities, model)
271
- return [] unless capabilities
272
- return capabilities.critical_capabilities_for(model) if capabilities.respond_to?(:critical_capabilities_for)
273
-
274
- {
275
- 'streaming' => :streaming?,
276
- 'function_calling' => :functions?,
277
- 'vision' => :vision?,
278
- 'embeddings' => :embeddings?,
279
- 'moderation' => :moderation?,
280
- 'image' => :images?,
281
- 'audio_transcription' => :audio_transcription?
282
- }.filter_map do |capability, predicate|
283
- capability if capabilities.respond_to?(predicate) && capabilities.public_send(predicate, model)
284
- end
285
- end
266
+ # Sync tool calls: the ONE strict arguments parser (10 U2).
267
+ def parse_tool_calls(tool_calls)
268
+ return [] unless tool_calls&.any?
286
269
 
287
- def modalities_for_capabilities(capabilities)
288
- if capabilities.include?('embeddings') && (capabilities - ['embeddings']).empty?
289
- { input: %w[text], output: %w[embeddings] }
290
- elsif capabilities.include?('image')
291
- { input: %w[text image], output: %w[image] }
292
- elsif capabilities.include?('audio_transcription')
293
- { input: %w[audio], output: %w[text] }
294
- else
295
- { input: %w[text image], output: %w[text] }
270
+ tool_calls.map do |call|
271
+ function = call.fetch('function', {})
272
+ name = function['name']
273
+ id = call['id'] || name || call['index']
274
+ Canonical::ToolCall.build(
275
+ name: name.to_s,
276
+ id: id&.to_s,
277
+ arguments: Responses::ToolArguments.parse!(function['arguments'])
278
+ )
296
279
  end
297
280
  end
298
281
 
@@ -300,51 +283,64 @@ module Legion
300
283
  { model: model.respond_to?(:id) ? model.id : model, input: text, dimensions: dimensions }.compact
301
284
  end
302
285
 
286
+ # 05 §3 documented artifact: { text:, model:, embedding: Array<Float>,
287
+ # usage: Canonical::Usage }.
303
288
  def parse_embedding_response(response, model:, text:)
304
289
  vectors = response.body.fetch('data', []).map { |item| item['embedding'] }
305
290
  vectors = vectors.first unless text.is_a?(Array)
306
291
  usage = response.body['usage'] || {}
307
292
 
308
- Legion::Extensions::Llm::Embedding.new(vectors: vectors, model: model,
309
- input_tokens: usage['prompt_tokens'].to_i)
293
+ {
294
+ text: text,
295
+ model: model.respond_to?(:id) ? model.id : model,
296
+ embedding: vectors,
297
+ usage: openai_usage_to_canonical(usage)
298
+ }.compact
310
299
  end
311
300
 
312
301
  def render_moderation_payload(input, model:)
313
- { model: model, input: input }.compact
302
+ input_text = input.is_a?(::Array) ? input.map(&:text).join("\n") : input
303
+ { model: model, input: input_text }.compact
314
304
  end
315
305
 
306
+ # 05 §3 documented artifact: { model:, result: { flagged:, categories: } }.
316
307
  def parse_moderation_response(response, model:)
317
- Legion::Extensions::Llm::Moderation.new(id: response.body['id'], model: response.body['model'] || model,
318
- results: response.body.fetch('results', []))
308
+ result = Array(response.body['results']).first || {}
309
+ {
310
+ model: response.body['model'] || model,
311
+ result: {
312
+ flagged: result['flagged'] == true,
313
+ categories: result['categories'] || {}
314
+ }
315
+ }
319
316
  end
320
317
 
321
318
  def render_image_payload(prompt, model:, size:, with:, mask:, params:) # rubocop:disable Metrics/ParameterLists
322
319
  { model: model, prompt: prompt, size: size, image: with, mask: mask }.merge(params).compact
323
320
  end
324
321
 
322
+ # 05 §3 documented artifact: { model:, image:, size: }.
325
323
  def parse_image_response(response, model:)
326
324
  image = response.body.fetch('data', []).first || {}
327
- Legion::Extensions::Llm::Image.new(
328
- url: image['url'],
329
- data: image['b64_json'],
330
- revised_prompt: image['revised_prompt'],
331
- model_id: model,
332
- usage: response.body['usage'] || {}
333
- )
325
+ {
326
+ model: model,
327
+ image: image['url'] || image['b64_json'],
328
+ size: image['size']
329
+ }.compact
334
330
  end
335
331
 
336
332
  def render_transcription_payload(file_part, model:, language:, **options)
337
333
  { model: model, file: file_part, language: language }.merge(options).compact
338
334
  end
339
335
 
336
+ # 05 §3 documented artifact: { model:, text:, language:, duration: }.
340
337
  def parse_transcription_response(response, model:)
341
- Legion::Extensions::Llm::Transcription.new(
342
- text: response.body['text'],
338
+ {
343
339
  model: model,
340
+ text: response.body['text'],
344
341
  language: response.body['language'],
345
- duration: response.body['duration'],
346
- segments: response.body['segments']
347
- )
342
+ duration: response.body['duration']
343
+ }.compact
348
344
  end
349
345
  end
350
346
  end