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
@@ -1,108 +1,87 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable Metrics/ParameterLists -- factory methods have many params
3
4
  module Legion
4
5
  module Extensions
5
6
  module Llm
6
7
  module Canonical
7
8
  # Typed content block with media_type support per G20a.
8
- # Ports field vocabulary from Legion::LLM::Types::ContentBlock.
9
- # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
9
+ # Ports field vocabulary from Legion::LLM::Types::ContentBlock. # -- required for Data.define block scope
10
10
  ContentBlock = ::Data.define(
11
11
  :type, :text, :data, :source_type, :media_type,
12
12
  :detail, :name, :file_id,
13
13
  :id, :input, :tool_use_id, :is_error,
14
14
  :source, :start_index, :end_index,
15
- :code, :message, :cache_control
15
+ :code, :message, :cache_control, :metadata
16
16
  ) do
17
- TEXT_TYPE_ALIASES = %i[text output_text input_text].freeze
17
+ # Build from keyword args (primary constructor).
18
+ def self.build(
19
+ type: nil, text: nil, data: nil, source_type: nil, media_type: nil,
20
+ detail: nil, name: nil, file_id: nil,
21
+ id: nil, input: nil, tool_use_id: nil, is_error: nil,
22
+ source: nil, start_index: nil, end_index: nil,
23
+ code: nil, message: nil, cache_control: nil, metadata: {}
24
+ )
25
+ new(
26
+ type: normalize_type!(type, self::BUILD_SITE),
27
+ text:, data:, source_type:, media_type:,
28
+ detail:, name:, file_id:,
29
+ id:, input:, tool_use_id:, is_error:,
30
+ source:, start_index:, end_index:,
31
+ code:, message:, cache_control:,
32
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE)
33
+ )
34
+ end
35
+
36
+ # Build from a Hash (raw provider response or deserialized wire payload).
37
+ # Canonical keys only (O03a); unknown keys fold into metadata (L5).
38
+ def self.from_hash(source)
39
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
40
+ hash = Strict.symbolize_keys(source)
41
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
42
+ build(
43
+ type: hash[:type], text: hash[:text], data: hash[:data],
44
+ source_type: hash[:source_type], media_type: hash[:media_type],
45
+ detail: hash[:detail], name: hash[:name], file_id: hash[:file_id],
46
+ id: hash[:id], input: hash[:input], tool_use_id: hash[:tool_use_id],
47
+ is_error: hash[:is_error], source: hash[:source],
48
+ start_index: hash[:start_index], end_index: hash[:end_index],
49
+ code: hash[:code], message: hash[:message],
50
+ cache_control: hash[:cache_control], metadata:
51
+ )
52
+ end
53
+
54
+ # L6: validate against the declared block types when present.
55
+ def self.normalize_type!(type, site)
56
+ return nil if type.nil?
57
+
58
+ type_sym = type.is_a?(::String) ? type.to_sym : type
59
+ Strict.enum!(type_sym, self::CONTENT_BLOCK_TYPES, site, :type)
60
+ end
18
61
 
19
62
  # Build a text content block.
20
63
  def self.text(content, cache_control: nil)
21
- new(
22
- type: :text, text: content, data: nil, source_type: nil, media_type: nil,
23
- detail: nil, name: nil, file_id: nil, id: nil, input: nil,
24
- tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
25
- end_index: nil, code: nil, message: nil, cache_control: cache_control
26
- )
64
+ build(type: :text, text: content, cache_control:)
27
65
  end
28
66
 
29
67
  # Build a thinking content block.
30
68
  def self.thinking(content)
31
- new(
32
- type: :thinking, text: content, data: nil, source_type: nil, media_type: nil,
33
- detail: nil, name: nil, file_id: nil, id: nil, input: nil,
34
- tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
35
- end_index: nil, code: nil, message: nil, cache_control: nil
36
- )
69
+ build(type: :thinking, text: content)
37
70
  end
38
71
 
39
72
  # Build a tool_use content block.
40
73
  def self.tool_use(id:, name:, input:)
41
- new(
42
- type: :tool_use, text: nil, data: nil, source_type: nil, media_type: nil,
43
- detail: nil, name: name, file_id: nil, id: id, input: input,
44
- tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
45
- end_index: nil, code: nil, message: nil, cache_control: nil
46
- )
74
+ build(type: :tool_use, id:, name:, input:)
47
75
  end
48
76
 
49
77
  # Build a tool_result content block.
50
78
  def self.tool_result(tool_use_id:, content:, is_error: false)
51
- new(
52
- type: :tool_result, text: content, data: nil, source_type: nil, media_type: nil,
53
- detail: nil, name: nil, file_id: nil, id: nil, input: nil,
54
- tool_use_id: tool_use_id, is_error: is_error, source: nil, start_index: nil,
55
- end_index: nil, code: nil, message: nil, cache_control: nil
56
- )
79
+ build(type: :tool_result, text: content, tool_use_id:, is_error:)
57
80
  end
58
81
 
59
82
  # Build an image content block with media_type (G20a).
60
83
  def self.image(data:, media_type:, source_type: :base64, detail: nil)
61
- new(
62
- type: :image, text: nil, data: data, source_type: source_type, media_type: media_type,
63
- detail: detail, name: nil, file_id: nil, id: nil, input: nil,
64
- tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
65
- end_index: nil, code: nil, message: nil, cache_control: nil
66
- )
67
- end
68
-
69
- # Build from a Hash (raw provider response or deserialized wire payload).
70
- # Rescues NoMethodError from corrupted inputs (e.g. String elements from
71
- # prior serialization bugs where ContentBlock#inspect leaked into storage).
72
- def self.from_hash(source)
73
- return nil if source.nil?
74
-
75
- h = source.transform_keys(&:to_sym)
76
- type_raw = h.delete(:type)
77
- if type_raw
78
- type_sym = type_raw.to_sym
79
- h[:type] = TEXT_TYPE_ALIASES.include?(type_sym) ? :text : type_sym
80
- end
81
-
82
- new(
83
- type: h[:type],
84
- text: h[:text],
85
- data: h[:data],
86
- source_type: h[:source_type],
87
- media_type: h[:media_type],
88
- detail: h[:detail],
89
- name: h[:name],
90
- file_id: h[:file_id],
91
- id: h[:id],
92
- input: h[:input],
93
- tool_use_id: h[:tool_use_id],
94
- is_error: h[:is_error],
95
- source: h[:source],
96
- start_index: h[:start_index],
97
- end_index: h[:end_index],
98
- code: h[:code],
99
- message: h[:message],
100
- cache_control: h[:cache_control]
101
- )
102
- rescue NoMethodError => e
103
- Legion::Logging.log.warn('[canonical][content_block] from_hash received non-Hash input ' \
104
- "(#{source.class}): #{e.message}")
105
- text(source.to_s)
84
+ build(type: :image, data:, media_type:, source_type:, detail:)
106
85
  end
107
86
 
108
87
  # Serialize to a Hash for AMQP/fleet/wire transport.
@@ -134,7 +113,7 @@ module Legion
134
113
 
135
114
  # Whether this block carries textual content.
136
115
  def text?
137
- TEXT_TYPE_ALIASES.include?(type)
116
+ type == :text
138
117
  end
139
118
 
140
119
  # Whether this block carries thinking/reasoning content.
@@ -151,11 +130,23 @@ module Legion
151
130
  def tool_result?
152
131
  type == :tool_result
153
132
  end
133
+
134
+ # H1: the single strict constructor — .new runs the same member
135
+ # contract as the factories; the factories fill their defaults and
136
+ # delegate here.
137
+ Strict.install_strict_new!(self) do |values, site|
138
+ values[:type] = normalize_type!(values[:type], site)
139
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
140
+ values
141
+ end
154
142
  end
155
143
 
156
144
  ContentBlock::CONTENT_BLOCK_TYPES = %i[text thinking tool_use tool_result image audio video].freeze
157
- # rubocop:enable Lint/ConstantDefinitionInBlock
145
+ ContentBlock::BUILD_SITE = 'Canonical::ContentBlock.build'
146
+ ContentBlock::FROM_HASH_SITE = 'Canonical::ContentBlock.from_hash'
147
+ ContentBlock::NEW_SITE = 'Canonical::ContentBlock.new'
158
148
  end
159
149
  end
160
150
  end
161
151
  end
152
+ # rubocop:enable Metrics/ParameterLists
@@ -6,37 +6,33 @@ require 'securerandom'
6
6
  module Legion
7
7
  module Extensions
8
8
  module Llm
9
+ # -- required for Data.define block scope
9
10
  module Canonical
10
- # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
11
11
  # Canonical message in a conversation.
12
- # Ports field vocabulary from Legion::LLM::Types::Message and lex-llm Message.
12
+ # Ports field vocabulary from Legion::LLM::Types::Message.
13
+ # Unknown keys fold into the metadata member (04 L5) — never dropped.
14
+ # :cache_control (prompt-cache breakpoints) IS a member and survives
15
+ # build/to_h/JSON round-trips, including the fleet wire.
13
16
  Message = ::Data.define(
14
17
  :id, :parent_id, :role, :content, :tool_calls, :tool_call_id,
15
18
  :name, :status, :version, :timestamp, :seq,
16
19
  :provider, :model, :input_tokens, :output_tokens,
17
- :conversation_id, :task_id
20
+ :conversation_id, :task_id, :cache_control, :metadata
18
21
  ) do
19
- ROLES = %i[system user assistant tool].freeze
20
-
21
22
  # Build from keyword args (primary constructor).
22
23
  def self.build(
23
24
  id: nil, parent_id: nil, role: :user, content: nil, tool_calls: nil,
24
25
  tool_call_id: nil, name: nil, status: :created, version: 1,
25
26
  timestamp: nil, seq: nil, provider: nil, model: nil,
26
- input_tokens: nil, output_tokens: nil, conversation_id: nil, task_id: nil
27
+ input_tokens: nil, output_tokens: nil, conversation_id: nil, task_id: nil,
28
+ cache_control: nil, metadata: {}
27
29
  )
28
- role_sym = role.is_a?(String) ? role.to_sym : role
29
- unless ROLES.include?(role_sym)
30
- raise ArgumentError,
31
- "Invalid role: #{role_sym}. Must be one of: #{ROLES.join(', ')}"
32
- end
33
-
34
30
  new(
35
31
  id: id || "msg_#{SecureRandom.hex(12)}",
36
32
  parent_id: parent_id,
37
- role: role_sym,
38
- content: content,
39
- tool_calls: tool_calls,
33
+ role: normalize_role!(role, self::BUILD_SITE),
34
+ content: normalize_content!(content, self::BUILD_SITE),
35
+ tool_calls: normalize_tool_calls!(tool_calls, self::BUILD_SITE),
40
36
  tool_call_id: tool_call_id,
41
37
  name: name,
42
38
  status: status,
@@ -48,64 +44,43 @@ module Legion
48
44
  input_tokens: input_tokens,
49
45
  output_tokens: output_tokens,
50
46
  conversation_id: conversation_id,
51
- task_id: task_id
47
+ task_id: task_id,
48
+ cache_control: cache_control.nil? ? nil : Strict.expect_type!(cache_control, [::Hash], self::BUILD_SITE, :cache_control),
49
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE)
52
50
  )
53
51
  end
54
52
 
55
53
  # Build from a Hash (raw provider response or deserialized wire payload).
56
- def self.from_hash(hash)
57
- return nil if hash.nil?
58
-
59
- h = hash.transform_keys(&:to_sym)
60
-
61
- # Normalize role to symbol
62
- role_raw = h[:role]
63
- h[:role] = role_raw&.to_sym if role_raw
64
-
65
- # Parse content blocks if they're an array of hashes
66
- content = h[:content]
67
- if content.is_a?(Array)
68
- h[:content] = content.map do |block|
69
- block.is_a?(ContentBlock) ? block : ContentBlock.from_hash(block)
70
- end
71
- elsif content.is_a?(Hash)
72
- h[:content] = ContentBlock.from_hash(content)
73
- end
74
-
75
- # Parse tool calls — Array is canonical; Hash is legacy lex-llm format (name → ToolCall)
76
- tool_calls = h[:tool_calls]
77
- if tool_calls.is_a?(Hash)
78
- h[:tool_calls] = tool_calls.values.map do |tc|
79
- next tc if tc.is_a?(ToolCall)
54
+ def self.from_hash(source)
55
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
56
+ hash = Strict.symbolize_keys(source)
57
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
58
+ build(**hash, metadata:)
59
+ end
80
60
 
81
- raw = tc.respond_to?(:to_h) ? tc.to_h : tc
82
- ToolCall.from_hash(raw)
83
- end
84
- elsif tool_calls.is_a?(Array)
85
- h[:tool_calls] = tool_calls.map do |tc|
86
- next tc if tc.is_a?(ToolCall)
61
+ # L6: role validated at construction, in both factories.
62
+ def self.normalize_role!(role, site)
63
+ role_sym = role.is_a?(::String) ? role.to_sym : role
64
+ Strict.enum!(role_sym, self::ROLES, site, :role)
65
+ end
87
66
 
88
- if tc.is_a?(Hash)
89
- ToolCall.from_hash(tc)
90
- else
91
- ToolCall.from_hash(tc.respond_to?(:to_h) ? tc.to_h : tc)
92
- end
93
- end
94
- end
67
+ # L2: one content normalizer shared by build and from_hash.
68
+ # String | ContentBlock | Array<ContentBlock> | nil — anything else raises.
69
+ def self.normalize_content!(content, site)
70
+ return nil if content.nil?
71
+ return content if content.is_a?(::String) || content.is_a?(ContentBlock)
72
+ raise ArgumentError, "#{site}: content expected String | ContentBlock | Array, got #{content.class}" unless content.is_a?(::Array)
95
73
 
96
- # Tolerate transport-only keys (e.g. the prompt-cache step's :cache_control,
97
- # injected onto every >=2-message request) by projecting onto the known
98
- # member set — mirrors Canonical::Request.from_hash, which folds unknown keys
99
- # instead of raising. Message has no metadata field, so unknown keys are dropped.
100
- build(**h.slice(*members))
74
+ content.map { |block| block.is_a?(ContentBlock) ? block : ContentBlock.from_hash(block) }
101
75
  end
102
76
 
103
- # Wrap input: pass through if already a Message, parse if Hash.
104
- def self.wrap(input)
105
- return input if input.is_a?(Message)
106
- return from_hash(input) if input.is_a?(Hash)
77
+ # L2: one tool-call normalizer shared by build and from_hash.
78
+ # Array<ToolCall> | nil (Array is canonical; the legacy Hash shape is gone).
79
+ def self.normalize_tool_calls!(tool_calls, site)
80
+ return nil if tool_calls.nil?
107
81
 
108
- nil
82
+ Strict.expect_type!(tool_calls, [::Array], site, :tool_calls)
83
+ tool_calls.map { |tc| tc.is_a?(ToolCall) ? tc : ToolCall.from_hash(tc) }
109
84
  end
110
85
 
111
86
  # Extract plain text from content (String or ContentBlock array).
@@ -141,14 +116,24 @@ module Legion
141
116
  text
142
117
  end
143
118
 
144
- # Minimal provider-facing hash (role + text content).
145
- def to_provider_hash
146
- { role: role.to_s, content: text }.compact
119
+ # H1: the single strict constructor .new runs the same member
120
+ # contract as the factories (normalize or raise); the factories
121
+ # fill their defaults and delegate here. Missing members follow the
122
+ # nil contract of each member.
123
+ Strict.install_strict_new!(self) do |values, site|
124
+ values[:role] = normalize_role!(values[:role], site)
125
+ values[:content] = normalize_content!(values[:content], site)
126
+ values[:tool_calls] = normalize_tool_calls!(values[:tool_calls], site)
127
+ values[:cache_control] = values[:cache_control].nil? ? nil : Strict.expect_type!(values[:cache_control], [::Hash], site, :cache_control)
128
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
129
+ values
147
130
  end
148
131
  end
149
132
 
150
133
  Message::ROLES = %i[system user assistant tool].freeze
151
- # rubocop:enable Lint/ConstantDefinitionInBlock
134
+ Message::BUILD_SITE = 'Canonical::Message.build'
135
+ Message::FROM_HASH_SITE = 'Canonical::Message.from_hash'
136
+ Message::NEW_SITE = 'Canonical::Message.new'
152
137
  end
153
138
  end
154
139
  end
@@ -3,49 +3,52 @@
3
3
  # -- from_hash normalization is intentional
4
4
  module Legion
5
5
  module Extensions
6
+ # -- module doc is in canonical.rb entry point
6
7
  module Llm
8
+ # -- required for Data.define block scope
7
9
  module Canonical
8
- # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
9
10
  # Canonical sampling and limit parameters for a request.
10
11
  # Per G18: all standard/useful params are first-class, mapped per provider by translators.
12
+ # Canonical keys only (O03a): provider spellings are translated at the edges.
11
13
  Params = ::Data.define(
12
14
  :max_tokens, :max_thinking_tokens, :temperature, :top_p, :top_k,
13
15
  :stop_sequences, :seed, :frequency_penalty, :presence_penalty,
14
- :response_format
16
+ :response_format, :metadata
15
17
  ) do
16
- PARAMS_KNOWN_KEYS = %i[max_tokens max_thinking_tokens temperature top_p top_k
17
- stop_sequences seed frequency_penalty presence_penalty
18
- response_format].freeze
18
+ # rubocop:disable Metrics/ParameterLists -- factory methods have many params
19
+ # Build from keyword args (primary constructor).
20
+ def self.build(
21
+ max_tokens: nil, max_thinking_tokens: nil, temperature: nil, top_p: nil, top_k: nil,
22
+ stop_sequences: nil, seed: nil, frequency_penalty: nil, presence_penalty: nil,
23
+ response_format: nil, metadata: {}
24
+ )
25
+ new(
26
+ max_tokens:, max_thinking_tokens:, temperature:, top_p:, top_k:,
27
+ stop_sequences:, seed:, frequency_penalty:, presence_penalty:,
28
+ response_format:, metadata: Strict.metadata!(metadata, self::BUILD_SITE)
29
+ )
30
+ end
31
+ # rubocop:enable Metrics/ParameterLists
19
32
 
20
33
  # Build from a Hash (raw client request or deserialized wire payload).
21
- # Accepts both canonical key names and common provider spellings.
34
+ # Canonical member keys only; unknown keys fold into metadata (04 L5).
35
+ # from_hash({}) is a valid all-nil Params (the no-params object), never nil.
22
36
  def self.from_hash(source)
23
- return nil if source.nil? || source.empty?
24
-
25
- h = source.transform_keys(&:to_sym)
26
-
27
- # Normalize common provider key variations
28
- h[:max_tokens] ||= h.delete(:max_output_tokens) || h.delete(:num_predict)
29
- h[:max_thinking_tokens] ||= h.delete(:budget_tokens) || h.delete(:thinking_budget)
30
- h[:stop_sequences] ||= h.delete(:stop)
31
-
32
- # Filter to known keys only
33
- filtered = h.slice(*PARAMS_KNOWN_KEYS)
34
-
35
- # Return nil if all known values are nil
36
- return nil if filtered.all? { |_, v| v.nil? }
37
-
38
- new(
39
- max_tokens: filtered[:max_tokens],
40
- max_thinking_tokens: filtered[:max_thinking_tokens],
41
- temperature: filtered[:temperature],
42
- top_p: filtered[:top_p],
43
- top_k: filtered[:top_k],
44
- stop_sequences: filtered[:stop_sequences],
45
- seed: filtered[:seed],
46
- frequency_penalty: filtered[:frequency_penalty],
47
- presence_penalty: filtered[:presence_penalty],
48
- response_format: filtered[:response_format]
37
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
38
+ hash = Strict.symbolize_keys(source)
39
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
40
+ build(
41
+ max_tokens: hash[:max_tokens],
42
+ max_thinking_tokens: hash[:max_thinking_tokens],
43
+ temperature: hash[:temperature],
44
+ top_p: hash[:top_p],
45
+ top_k: hash[:top_k],
46
+ stop_sequences: hash[:stop_sequences],
47
+ seed: hash[:seed],
48
+ frequency_penalty: hash[:frequency_penalty],
49
+ presence_penalty: hash[:presence_penalty],
50
+ response_format: hash[:response_format],
51
+ metadata:
49
52
  )
50
53
  end
51
54
 
@@ -62,8 +65,31 @@ module Legion
62
65
  def to_json(*)
63
66
  to_h.to_json(*)
64
67
  end
68
+
69
+ # H1/M3: the single strict constructor. .new validates every member
70
+ # against its wire type (Integer token counts and seeds, Numeric
71
+ # ratios and penalties, String|Array stop sequences, String|Hash
72
+ # response format) — garbage is rejected here, not on the provider
73
+ # wire. The factories fill their defaults and delegate here.
74
+ Strict.install_strict_new!(self) do |values, site|
75
+ values[:max_tokens] = Strict.expect_type!(values[:max_tokens], [::Integer], site, :max_tokens)
76
+ values[:max_thinking_tokens] = Strict.expect_type!(values[:max_thinking_tokens], [::Integer], site, :max_thinking_tokens)
77
+ values[:temperature] = Strict.expect_type!(values[:temperature], [::Numeric], site, :temperature)
78
+ values[:top_p] = Strict.expect_type!(values[:top_p], [::Numeric], site, :top_p)
79
+ values[:top_k] = Strict.expect_type!(values[:top_k], [::Integer], site, :top_k)
80
+ values[:stop_sequences] = Strict.expect_type!(values[:stop_sequences], [::String, ::Array], site, :stop_sequences)
81
+ values[:seed] = Strict.expect_type!(values[:seed], [::Integer], site, :seed)
82
+ values[:frequency_penalty] = Strict.expect_type!(values[:frequency_penalty], [::Numeric], site, :frequency_penalty)
83
+ values[:presence_penalty] = Strict.expect_type!(values[:presence_penalty], [::Numeric], site, :presence_penalty)
84
+ values[:response_format] = Strict.expect_type!(values[:response_format], [::String, ::Hash], site, :response_format)
85
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
86
+ values
87
+ end
65
88
  end
66
- # rubocop:enable Lint/ConstantDefinitionInBlock
89
+
90
+ Params::BUILD_SITE = 'Canonical::Params.build'
91
+ Params::FROM_HASH_SITE = 'Canonical::Params.from_hash'
92
+ Params::NEW_SITE = 'Canonical::Params.new'
67
93
  end
68
94
  end
69
95
  end
@@ -8,7 +8,7 @@ module Legion
8
8
  module Llm
9
9
  module Canonical
10
10
  # Canonical request shape — the single contract between client translators
11
- # and the inference executor. Per R3 and G18.
11
+ # and the inference executor. Per R3 and G18. # -- required for Data.define block scope
12
12
  Request = ::Data.define(
13
13
  :id, :messages, :system, :tools, :tool_choice,
14
14
  :params, :thinking, :stream,
@@ -18,60 +18,74 @@ module Legion
18
18
  def self.build(
19
19
  id: nil, messages: nil, system: nil, tools: nil, tool_choice: nil,
20
20
  params: nil, thinking: nil, stream: false,
21
- conversation_id: nil, caller: nil, routing: nil, metadata: nil
21
+ conversation_id: nil, caller: nil, routing: nil, metadata: {}
22
22
  )
23
- # Normalize messages to Canonical::Message array
24
- msg_array = Array(messages).filter_map do |msg|
25
- msg.is_a?(Message) ? msg : Message.from_hash(msg)
26
- end
27
-
28
- # Normalize tools to Hash<name, ToolDefinition>
29
- tool_hash = normalize_tools(tools)
30
-
31
- # Normalize params
32
- params_obj = case params
33
- when Params then params
34
- when Hash then Params.from_hash(params)
35
- end
36
-
37
- # Normalize thinking config
38
- thinking_obj = case thinking
39
- when Thinking::Config then thinking
40
- when Hash then Thinking::Config.new(**thinking.transform_keys(&:to_sym))
41
- end
42
-
43
23
  new(
44
24
  id: id || "req_#{SecureRandom.hex(12)}",
45
- messages: msg_array,
25
+ messages: normalize_messages!(messages, self::BUILD_SITE),
46
26
  system: system,
47
- tools: tool_hash,
48
- tool_choice: tool_choice.is_a?(String) ? tool_choice.to_sym : tool_choice,
49
- params: params_obj,
50
- thinking: thinking_obj,
27
+ tools: normalize_tools(tools, self::BUILD_SITE),
28
+ tool_choice: tool_choice.is_a?(::String) ? tool_choice.to_sym : tool_choice,
29
+ params: normalize_params!(params, self::BUILD_SITE),
30
+ thinking: normalize_thinking!(thinking, self::BUILD_SITE),
51
31
  stream: stream,
52
32
  conversation_id: conversation_id,
53
33
  caller: caller,
54
34
  routing: routing || {},
55
- metadata: metadata || {}
35
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE)
56
36
  )
57
37
  end
58
38
 
59
39
  # Build from a Hash (raw client request or deserialized wire payload).
40
+ # Unknown keys fold into metadata — the model for 04 L5.
60
41
  def self.from_hash(source)
61
- return nil if source.nil?
42
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
43
+ hash = Strict.symbolize_keys(source)
44
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
45
+ build(**hash, metadata:)
46
+ end
47
+
48
+ # 04 §9: strict message map — each element must be a Message (pass) or a
49
+ # Hash (normalize); anything else raises. No silent drops (F2 fix).
50
+ def self.normalize_messages!(messages, site)
51
+ return [] if messages.nil?
52
+
53
+ Strict.expect_type!(messages, [::Array], site, :messages)
54
+ messages.map { |msg| msg.is_a?(Message) ? msg : Message.from_hash(msg) }
55
+ end
62
56
 
63
- h = source.transform_keys(&:to_sym)
57
+ # L2: the single tools normalizer, shared by build and from_hash.
58
+ # Hash<name, ToolDefinition> or Array<ToolDefinition|Hash>; anything else raises.
59
+ def self.normalize_tools(tools, site)
60
+ return {} if tools.nil? || tools.empty?
64
61
 
65
- # Extract metadata from unknown keys
66
- metadata = h[:metadata] || {}
67
- known_keys = %i[id messages system tools tool_choice params thinking
68
- stream conversation_id caller routing metadata]
69
- (h.keys - known_keys).each do |key|
70
- metadata[key] = h.delete(key)
62
+ case tools
63
+ when Hash
64
+ tools.transform_values { |tool| tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool) }
65
+ when Array
66
+ tools.each_with_object({}) do |tool, hash|
67
+ td = tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool)
68
+ hash[td.name] = td
69
+ end
70
+ else
71
+ Strict.expect_type!(tools, [::Hash, ::Array], site, :tools)
71
72
  end
72
- h[:metadata] = metadata
73
+ end
74
+
75
+ def self.normalize_params!(params, site)
76
+ return nil if params.nil?
77
+ return params if params.is_a?(Params)
78
+
79
+ Strict.expect_type!(params, [::Hash], site, :params)
80
+ Params.from_hash(params)
81
+ end
82
+
83
+ def self.normalize_thinking!(thinking, site)
84
+ return nil if thinking.nil?
85
+ return thinking if thinking.is_a?(Thinking::Config)
73
86
 
74
- build(**h)
87
+ Strict.expect_type!(thinking, [::Hash], site, :thinking)
88
+ Thinking::Config.from_hash(thinking)
75
89
  end
76
90
 
77
91
  # Serialize to a Hash for AMQP/fleet/wire transport.
@@ -101,24 +115,23 @@ module Legion
101
115
  to_h.to_json(*)
102
116
  end
103
117
 
104
- def self.normalize_tools(tools)
105
- return {} if tools.nil? || tools.empty?
106
-
107
- case tools
108
- when Hash
109
- tools.transform_values do |tool|
110
- tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool)
111
- end
112
- when Array
113
- tools.each_with_object({}) do |tool, hash|
114
- td = tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool)
115
- hash[td.name] = td
116
- end
117
- else
118
- {}
119
- end
118
+ # H1: the single strict constructor — .new runs the same member
119
+ # contract as the factories; the factories fill their defaults and
120
+ # delegate here.
121
+ Strict.install_strict_new!(self) do |values, site|
122
+ values[:messages] = normalize_messages!(values[:messages], site)
123
+ values[:tools] = normalize_tools(values[:tools], site)
124
+ values[:tool_choice] = values[:tool_choice].is_a?(::String) ? values[:tool_choice].to_sym : values[:tool_choice]
125
+ values[:params] = normalize_params!(values[:params], site)
126
+ values[:thinking] = normalize_thinking!(values[:thinking], site)
127
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
128
+ values
120
129
  end
121
130
  end
131
+
132
+ Request::BUILD_SITE = 'Canonical::Request.build'
133
+ Request::FROM_HASH_SITE = 'Canonical::Request.from_hash'
134
+ Request::NEW_SITE = 'Canonical::Request.new'
122
135
  end
123
136
  end
124
137
  end