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
@@ -1,108 +1,87 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rubocop:disable-next 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,10 +130,21 @@ 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
@@ -2,41 +2,37 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
5
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
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,16 +116,25 @@ 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
155
140
  end
156
- # rubocop:enable Metrics/ParameterLists
@@ -3,49 +3,51 @@
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
- :max_tokens, :max_thinking_tokens, :temperature, :top_p, :top_k,
14
+ :max_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, 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:, 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
+ temperature: hash[:temperature],
43
+ top_p: hash[:top_p],
44
+ top_k: hash[:top_k],
45
+ stop_sequences: hash[:stop_sequences],
46
+ seed: hash[:seed],
47
+ frequency_penalty: hash[:frequency_penalty],
48
+ presence_penalty: hash[:presence_penalty],
49
+ response_format: hash[:response_format],
50
+ metadata:
49
51
  )
50
52
  end
51
53
 
@@ -62,8 +64,30 @@ module Legion
62
64
  def to_json(*)
63
65
  to_h.to_json(*)
64
66
  end
67
+
68
+ # H1/M3: the single strict constructor. .new validates every member
69
+ # against its wire type (Integer token counts and seeds, Numeric
70
+ # ratios and penalties, String|Array stop sequences, String|Hash
71
+ # response format) — garbage is rejected here, not on the provider
72
+ # wire. The factories fill their defaults and delegate here.
73
+ Strict.install_strict_new!(self) do |values, site|
74
+ values[:max_tokens] = Strict.expect_type!(values[:max_tokens], [::Integer], site, :max_tokens)
75
+ values[:temperature] = Strict.expect_type!(values[:temperature], [::Numeric], site, :temperature)
76
+ values[:top_p] = Strict.expect_type!(values[:top_p], [::Numeric], site, :top_p)
77
+ values[:top_k] = Strict.expect_type!(values[:top_k], [::Integer], site, :top_k)
78
+ values[:stop_sequences] = Strict.expect_type!(values[:stop_sequences], [::String, ::Array], site, :stop_sequences)
79
+ values[:seed] = Strict.expect_type!(values[:seed], [::Integer], site, :seed)
80
+ values[:frequency_penalty] = Strict.expect_type!(values[:frequency_penalty], [::Numeric], site, :frequency_penalty)
81
+ values[:presence_penalty] = Strict.expect_type!(values[:presence_penalty], [::Numeric], site, :presence_penalty)
82
+ values[:response_format] = Strict.expect_type!(values[:response_format], [::String, ::Hash], site, :response_format)
83
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
84
+ values
85
+ end
65
86
  end
66
- # rubocop:enable Lint/ConstantDefinitionInBlock
87
+
88
+ Params::BUILD_SITE = 'Canonical::Params.build'
89
+ Params::FROM_HASH_SITE = 'Canonical::Params.from_hash'
90
+ Params::NEW_SITE = 'Canonical::Params.new'
67
91
  end
68
92
  end
69
93
  end
@@ -2,13 +2,13 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
5
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
6
6
  module Legion
7
7
  module Extensions
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,26 +115,24 @@ 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
125
138
  end
126
- # rubocop:enable Metrics/ParameterLists