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,250 +1,135 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::Message do
6
- describe '.build' do
7
- it 'creates a message with required fields' do
8
- msg = described_class.build(role: :user, content: 'hello')
9
-
10
- expect(msg.role).to eq(:user)
11
- expect(msg.content).to eq('hello')
12
- expect(msg.id).to start_with('msg_')
13
- expect(msg.status).to eq(:created)
14
- expect(msg.version).to eq(1)
15
- end
16
-
17
- it 'accepts all fields' do
18
- msg = described_class.build(
19
- id: 'msg-1',
20
- parent_id: 'msg-0',
21
- role: :assistant,
22
- content: 'response',
23
- tool_calls: [],
24
- conversation_id: 'conv-1'
25
- )
26
-
27
- expect(msg.id).to eq('msg-1')
28
- expect(msg.parent_id).to eq('msg-0')
29
- expect(msg.role).to eq(:assistant)
30
- expect(msg.conversation_id).to eq('conv-1')
31
- end
32
-
33
- it 'normalizes string role to symbol' do
34
- msg = described_class.build(role: 'user', content: 'hello')
35
-
36
- expect(msg.role).to eq(:user)
37
- end
38
-
39
- it 'rejects invalid roles' do
40
- expect do
41
- described_class.build(role: :invalid, content: 'hello')
42
- end.to raise_error(ArgumentError, /Invalid role/)
43
- end
44
-
45
- it 'defaults to user role' do
46
- msg = described_class.build(content: 'hello')
47
-
48
- expect(msg.role).to eq(:user)
49
- end
50
-
51
- it 'sets timestamp automatically' do
52
- msg = described_class.build(content: 'hello')
53
-
54
- expect(msg.timestamp).to be_a(Time)
55
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { %i[id timestamp] }
9
+ let(:type_source) do
10
+ {
11
+ id: 'msg_1',
12
+ role: 'user',
13
+ content: 'hello there',
14
+ tool_call_id: 'call_1',
15
+ conversation_id: 'conv_1',
16
+ cache_control: { type: 'ephemeral' },
17
+ metadata: { origin: 'client' }
18
+ }
56
19
  end
57
20
 
58
- describe '.from_hash' do
59
- it 'parses from hash with symbol keys' do
60
- msg = described_class.from_hash(role: :user, content: 'hello')
21
+ it_behaves_like 'a canonical type'
61
22
 
62
- expect(msg.role).to eq(:user)
63
- expect(msg.content).to eq('hello')
23
+ describe 'H1 — .new is as strict as the factories (the bypass is closed)' do
24
+ it 'rejects a poison role that would pass every is_a? check' do
25
+ expect { described_class.new(role: :banana, content: 'x') }
26
+ .to raise_error(ArgumentError, /Invalid role: :banana/)
64
27
  end
65
28
 
66
- it 'normalizes string role to symbol' do
67
- msg = described_class.from_hash(role: 'assistant', content: 'response')
68
-
69
- expect(msg.role).to eq(:assistant)
29
+ it 'rejects a poison content member' do
30
+ expect { described_class.new(role: :user, content: :symbol) }
31
+ .to raise_error(ArgumentError, /content expected String \| ContentBlock \| Array, got Symbol/)
70
32
  end
71
33
 
72
- it 'parses content blocks from array of hashes' do
73
- msg = described_class.from_hash(
74
- role: :user,
75
- content: [{ type: 'text', text: 'hello' }]
76
- )
77
-
78
- expect(msg.content).to be_an(Array)
79
- expect(msg.content.first).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
80
- expect(msg.content.first.type).to eq(:text)
34
+ it 'rejects the legacy Hash tool_calls shape' do
35
+ expect { described_class.new(role: :assistant, tool_calls: { a: { name: 'x' } }) }
36
+ .to raise_error(ArgumentError, /tool_calls expected Array, got Hash/)
81
37
  end
82
38
 
83
- it 'parses single content block from hash' do
84
- msg = described_class.from_hash(
85
- role: :user,
86
- content: { type: 'text', text: 'hello' }
87
- )
88
-
89
- expect(msg.content).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
90
- end
91
-
92
- it 'parses tool calls from array of hashes' do
93
- msg = described_class.from_hash(
94
- role: :assistant,
95
- tool_calls: [{ name: 'search', arguments: { query: 'test' } }]
96
- )
97
-
98
- expect(msg.tool_calls).to be_an(Array)
99
- expect(msg.tool_calls.first).to be_a(Legion::Extensions::Llm::Canonical::ToolCall)
100
- end
101
-
102
- it 'handles string keys' do
103
- msg = described_class.from_hash('role' => 'user', 'content' => 'hello')
104
-
105
- expect(msg.role).to eq(:user)
106
- expect(msg.content).to eq('hello')
107
- end
108
-
109
- it 'drops the transport-only :cache_control key instead of raising ArgumentError' do
110
- # Regression guard (root cause B): the prompt-cache step injects :cache_control
111
- # onto every >=2-message request. from_hash previously did build(**h) with the
112
- # whole hash, raising ArgumentError 'unknown keyword: :cache_control' before HTTP.
113
- # It now projects onto the known member keys, so the transport-only key is dropped.
114
- expect do
115
- described_class.from_hash(
116
- role: 'user',
117
- content: 'hi',
118
- cache_control: { type: 'ephemeral' }
119
- )
120
- end.not_to raise_error
121
-
122
- msg = described_class.from_hash(
123
- role: 'user',
124
- content: 'hi',
125
- cache_control: { type: 'ephemeral' }
126
- )
127
-
128
- expect(msg).to be_a(described_class)
129
- expect(msg.role).to eq(:user)
130
- expect(msg.text).to eq('hi')
131
- end
132
-
133
- it 'returns nil for nil source' do
134
- expect(described_class.from_hash(nil)).to be_nil
39
+ it 'still constructs valid data (consumer .new calls keep working)' do
40
+ expect(described_class.new(role: :assistant, content: 'ok')).to be_a(described_class)
135
41
  end
136
42
  end
137
43
 
138
- describe '.wrap' do
139
- it 'passes through existing Message instances' do
140
- msg = described_class.build(role: :user, content: 'hello')
141
- wrapped = described_class.wrap(msg)
142
-
143
- expect(wrapped).to eq(msg)
44
+ describe 'T5 — role enum (validated in both factories)' do
45
+ it 'accepts the four roles as symbols or strings' do
46
+ described_class::ROLES.each do |role|
47
+ expect(described_class.build(role:).role).to eq(role)
48
+ expect(described_class.from_hash(role: role.to_s).role).to eq(role)
49
+ end
144
50
  end
145
51
 
146
- it 'parses Hash input' do
147
- wrapped = described_class.wrap({ role: :user, content: 'hello' })
148
-
149
- expect(wrapped).to be_a(described_class)
150
- expect(wrapped.content).to eq('hello')
151
- end
152
-
153
- it 'returns nil for unsupported input' do
154
- expect(described_class.wrap('hello')).to be_nil
52
+ it 'raises on an unknown role in both factories' do
53
+ expect { described_class.build(role: :bogus) }
54
+ .to raise_error(ArgumentError, /Invalid role: :bogus/)
55
+ expect { described_class.from_hash(role: 'bogus') }
56
+ .to raise_error(ArgumentError, /Invalid role: :bogus/)
155
57
  end
156
58
  end
157
59
 
158
- describe '#text' do
159
- it 'returns content when it is a String' do
160
- msg = described_class.build(role: :user, content: 'hello')
161
-
162
- expect(msg.text).to eq('hello')
60
+ describe 'L2 — one content/tool_calls normalizer shared by build and from_hash' do
61
+ it 'normalizes raw-Hash tool_calls to Array<ToolCall> in both factories (T7)' do
62
+ raw = [{ id: 'tc_1', name: 'get_weather', arguments: { location: 'sf' } }]
63
+ via_build = described_class.build(role: :assistant, tool_calls: raw)
64
+ via_hash = described_class.from_hash(role: 'assistant', tool_calls: raw)
65
+ expect(via_build.tool_calls).to eq(via_hash.tool_calls)
66
+ expect(via_build.tool_calls).to all(be_a(Legion::Extensions::Llm::Canonical::ToolCall))
67
+ expect(via_build.tool_calls.first.name).to eq('get_weather')
163
68
  end
164
69
 
165
- it 'extracts text from ContentBlock array' do
166
- blocks = [
167
- Legion::Extensions::Llm::Canonical::ContentBlock.text('hello'),
168
- Legion::Extensions::Llm::Canonical::ContentBlock.text(' world')
169
- ]
170
- msg = described_class.build(role: :user, content: blocks)
171
-
172
- expect(msg.text).to eq('hello world')
70
+ it 'normalizes content block hashes in both factories' do
71
+ via_build = described_class.build(role: :user, content: [{ type: 'text', text: 'hi' }])
72
+ via_hash = described_class.from_hash(role: 'user', content: [{ type: 'text', text: 'hi' }])
73
+ expect(via_build.content).to eq(via_hash.content)
74
+ expect(via_build.content.first).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
173
75
  end
174
76
 
175
- it 'skips non-text blocks' do
176
- blocks = [
177
- Legion::Extensions::Llm::Canonical::ContentBlock.text('hello'),
178
- Legion::Extensions::Llm::Canonical::ContentBlock.thinking('reasoning')
179
- ]
180
- msg = described_class.build(role: :user, content: blocks)
181
-
182
- expect(msg.text).to eq('hello')
77
+ it 'raises on wrong-class content (the build-accepts-anything gap is closed)' do
78
+ expect { described_class.build(content: 42) }
79
+ .to raise_error(ArgumentError, /content expected String \| ContentBlock \| Array, got Integer/)
80
+ expect { described_class.from_hash(content: 42) }
81
+ .to raise_error(ArgumentError, /content expected String \| ContentBlock \| Array, got Integer/)
183
82
  end
184
83
 
185
- it 'returns empty string for nil content' do
186
- msg = described_class.build(role: :user, content: nil)
187
-
188
- expect(msg.text).to eq('')
84
+ it 'rejects the legacy Hash tool_calls shape (Array is canonical)' do
85
+ expect { described_class.build(tool_calls: { a: { name: 'x' } }) }
86
+ .to raise_error(ArgumentError, /tool_calls expected Array, got Hash/)
189
87
  end
88
+ end
190
89
 
191
- it 'extracts text from output_text ContentBlock array (Responses API / Codex)' do
192
- msg = described_class.from_hash(
193
- role: :assistant,
194
- content: [{ type: 'output_text', text: "The seat templates don't" }]
195
- )
196
-
197
- expect(msg.text).to eq("The seat templates don't")
198
- expect(msg.text).not_to include('#<data')
199
- expect(msg.text).not_to include('ContentBlock')
90
+ describe 'E01 — cache_control survives the full round-trip' do
91
+ it 'survives build → to_h → JSON → from_hash (the fleet-wire variant)' do
92
+ message = described_class.build(role: :user, content: 'hi', cache_control: { type: 'ephemeral' })
93
+ wire = Legion::JSON.load(Legion::JSON.dump(message.to_h))
94
+ expect(wire).to include(cache_control: { type: 'ephemeral' })
95
+ expect(described_class.from_hash(wire).cache_control).to eq(type: 'ephemeral')
200
96
  end
201
97
 
202
- it 'extracts text from mixed output_text and text blocks' do
203
- msg = described_class.from_hash(
204
- role: :assistant,
205
- content: [
206
- { type: 'output_text', text: 'first ' },
207
- { type: 'text', text: 'second' }
208
- ]
209
- )
210
-
211
- expect(msg.text).to eq('first second')
98
+ it 'is carried by to_h (no projection drop)' do
99
+ message = described_class.build(role: :user, content: 'hi', cache_control: { type: 'ephemeral' })
100
+ expect(message.to_h).to include(cache_control: { type: 'ephemeral' })
212
101
  end
213
102
  end
214
103
 
215
- describe '#to_h' do
216
- it 'serializes to compact hash' do
217
- msg = described_class.build(role: :user, content: 'hello')
218
- hash = msg.to_h
219
-
220
- expect(hash).to include(id: msg.id, role: :user, content: 'hello')
104
+ describe 'F1 fix — unknown keys fold into metadata (never dropped)' do
105
+ it 'folds unknowns on from_hash' do
106
+ message = described_class.from_hash(role: 'user', content: 'hi', fleet_wire_key: 'kept')
107
+ expect(message.metadata).to eq(fleet_wire_key: 'kept')
221
108
  end
222
109
  end
223
110
 
224
- describe '#to_provider_hash' do
225
- it 'returns minimal provider-facing hash' do
226
- msg = described_class.build(role: :user, content: 'hello')
227
- hash = msg.to_provider_hash
228
-
229
- expect(hash).to eq(role: 'user', content: 'hello')
111
+ describe 'F2 fix — wrap and to_provider_hash are deleted' do
112
+ it 'exposes no wrap factory' do
113
+ expect(described_class.respond_to?(:wrap)).to be(false)
230
114
  end
231
- end
232
115
 
233
- describe 'ROLES' do
234
- it 'includes all expected roles' do
235
- expect(described_class::ROLES).to eq(%i[system user assistant tool])
116
+ it 'exposes no to_provider_hash projection (L9)' do
117
+ expect(described_class.build(role: :user, content: 'x')).not_to respond_to(:to_provider_hash)
236
118
  end
237
119
  end
238
120
 
239
- describe 'round-trip' do
240
- it 'preserves values through from_hash/to_h' do
241
- original = { role: 'user', content: 'hello', conversation_id: 'conv-1' }
242
- msg = described_class.from_hash(original)
243
- serialized = msg.to_h
121
+ it 'auto-generates msg_<24-hex> ids and timestamps when absent' do
122
+ message = described_class.build(role: :user, content: 'x')
123
+ expect(message.id).to match(/\Amsg_[0-9a-f]{24}\z/)
124
+ expect(message.timestamp).to be_a(Time)
125
+ end
244
126
 
245
- expect(serialized[:role]).to eq(:user)
246
- expect(serialized[:content]).to eq('hello')
247
- expect(serialized[:conversation_id]).to eq('conv-1')
248
- end
127
+ it 'extracts plain text from String and ContentBlock content' do
128
+ expect(described_class.build(role: :user, content: 'plain').text).to eq('plain')
129
+ blocks = [
130
+ Legion::Extensions::Llm::Canonical::ContentBlock.text('a '),
131
+ Legion::Extensions::Llm::Canonical::ContentBlock.thinking('hidden')
132
+ ]
133
+ expect(described_class.build(role: :user, content: blocks).text).to eq('a ')
249
134
  end
250
135
  end
@@ -1,159 +1,80 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::Params do
6
- describe '.from_hash' do
7
- it 'returns a Params instance with canonical fields' do
8
- params = described_class.from_hash(max_tokens: 4096, temperature: 0.7)
9
-
10
- expect(params).to be_a(described_class)
11
- expect(params.max_tokens).to eq(4096)
12
- expect(params.temperature).to eq(0.7)
13
- expect(params.top_p).to be_nil
14
- end
15
-
16
- it 'normalizes max_output_tokens to max_tokens' do
17
- params = described_class.from_hash(max_output_tokens: 2048)
18
-
19
- expect(params.max_tokens).to eq(2048)
20
- end
21
-
22
- it 'normalizes num_predict to max_tokens' do
23
- params = described_class.from_hash(num_predict: 1024)
24
-
25
- expect(params.max_tokens).to eq(1024)
26
- end
27
-
28
- it 'normalizes budget_tokens to max_thinking_tokens' do
29
- params = described_class.from_hash(budget_tokens: 5000)
30
-
31
- expect(params.max_thinking_tokens).to eq(5000)
32
- end
33
-
34
- it 'normalizes thinking_budget to max_thinking_tokens' do
35
- params = described_class.from_hash(thinking_budget: 8000)
36
-
37
- expect(params.max_thinking_tokens).to eq(8000)
38
- end
39
-
40
- it 'normalizes stop to stop_sequences' do
41
- params = described_class.from_hash(stop: ['\n', 'END'])
42
-
43
- expect(params.stop_sequences).to eq(['\n', 'END'])
44
- end
45
-
46
- it 'handles string keys' do
47
- params = described_class.from_hash('max_tokens' => '4096', 'temperature' => '0.5')
48
-
49
- expect(params.max_tokens).to eq('4096')
50
- expect(params.temperature).to eq('0.5')
51
- end
52
-
53
- it 'returns nil for nil source' do
54
- expect(described_class.from_hash(nil)).to be_nil
55
- end
56
-
57
- it 'returns nil for empty hash' do
58
- expect(described_class.from_hash({})).to be_nil
59
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [] }
9
+ let(:type_source) do
10
+ {
11
+ max_tokens: 1024,
12
+ max_thinking_tokens: 512,
13
+ temperature: 0.7,
14
+ top_p: 0.9,
15
+ top_k: 40,
16
+ stop_sequences: ['\n\n'],
17
+ seed: 42,
18
+ frequency_penalty: 0.1,
19
+ presence_penalty: 0.2,
20
+ response_format: { type: 'json_object' },
21
+ metadata: { source: 'client' }
22
+ }
23
+ end
60
24
 
61
- it 'returns nil when no known keys present' do
62
- result = described_class.from_hash(unknown_key: 'value')
25
+ it_behaves_like 'a canonical type'
63
26
 
64
- expect(result).to be_nil
27
+ describe 'O03a — canonical keys only' do
28
+ it 'does not translate provider spellings (they fold into metadata)' do
29
+ params = described_class.from_hash(max_output_tokens: 999, num_predict: 888, stop: ['x'])
30
+ expect(params.max_tokens).to be_nil
31
+ expect(params.max_thinking_tokens).to be_nil
32
+ expect(params.stop_sequences).to be_nil
33
+ expect(params.metadata).to eq(max_output_tokens: 999, num_predict: 888, stop: ['x'])
65
34
  end
35
+ end
66
36
 
67
- it 'filters unknown keys' do
68
- params = described_class.from_hash(
69
- max_tokens: 4096,
70
- temperature: 0.7,
71
- unknown_param: 'should_be_filtered',
72
- another_unknown: 123
73
- )
74
-
75
- expect(params.max_tokens).to eq(4096)
76
- expect(params.temperature).to eq(0.7)
37
+ describe 'H1/M3 — wire-type validation in every constructor' do
38
+ it 'rejects garbage numeric members in build, from_hash, and .new' do
39
+ expect { described_class.build(max_tokens: 'abc') }
40
+ .to raise_error(ArgumentError, /max_tokens expected Integer, got String/)
41
+ expect { described_class.from_hash(temperature: :hot) }
42
+ .to raise_error(ArgumentError, /temperature expected Numeric, got Symbol/)
43
+ expect { described_class.new(seed: -3.5) }
44
+ .to raise_error(ArgumentError, /seed expected Integer, got Float/)
77
45
  end
78
46
 
79
- it 'accepts all G18 standard params' do
80
- params = described_class.from_hash(
81
- max_tokens: 4096,
82
- max_thinking_tokens: 10_000,
83
- temperature: 0.7,
84
- top_p: 0.95,
85
- top_k: 50,
86
- stop_sequences: ['END'],
87
- seed: 42,
88
- frequency_penalty: 0.5,
89
- presence_penalty: 0.1,
90
- response_format: { type: 'json_object' }
91
- )
92
-
93
- expect(params.max_tokens).to eq(4096)
94
- expect(params.max_thinking_tokens).to eq(10_000)
95
- expect(params.temperature).to eq(0.7)
96
- expect(params.top_p).to eq(0.95)
97
- expect(params.top_k).to eq(50)
98
- expect(params.stop_sequences).to eq(['END'])
99
- expect(params.seed).to eq(42)
100
- expect(params.frequency_penalty).to eq(0.5)
101
- expect(params.presence_penalty).to eq(0.1)
102
- expect(params.response_format).to eq({ type: 'json_object' })
47
+ it 'rejects wrong-class stop_sequences and response_format' do
48
+ expect { described_class.new(stop_sequences: 42) }
49
+ .to raise_error(ArgumentError, /stop_sequences expected String \| Array, got Integer/)
50
+ expect { described_class.build(response_format: 42) }
51
+ .to raise_error(ArgumentError, /response_format expected String \| Hash, got Integer/)
103
52
  end
104
53
  end
105
54
 
106
- describe '#to_h' do
107
- it 'serializes to compact hash' do
108
- params = described_class.new(
109
- max_tokens: 4096, temperature: 0.7,
110
- max_thinking_tokens: nil, top_p: nil, top_k: nil,
111
- stop_sequences: nil, seed: nil,
112
- frequency_penalty: nil, presence_penalty: nil,
113
- response_format: nil
114
- )
115
- hash = params.to_h
116
-
117
- expect(hash).to eq(max_tokens: 4096, temperature: 0.7)
55
+ describe 'construction law' do
56
+ it 'from_hash({}) is a valid all-nil Params (never nil)' do
57
+ params = described_class.from_hash({})
58
+ expect(params).to be_a(described_class)
59
+ expect(params.max_tokens).to be_nil
60
+ expect(params.temperature).to be_nil
118
61
  end
119
62
 
120
- it 'includes all non-nil fields' do
121
- params = described_class.new(
122
- max_tokens: 4096, max_thinking_tokens: 10_000,
123
- temperature: 0.7, top_p: 0.95, top_k: 50,
124
- stop_sequences: ['END'], seed: 42,
125
- frequency_penalty: 0.5, presence_penalty: 0.1,
126
- response_format: { type: 'json_object' }
127
- )
128
- hash = params.to_h
129
-
130
- expect(hash).to include(
131
- max_tokens: 4096, max_thinking_tokens: 10_000,
132
- temperature: 0.7, top_p: 0.95, top_k: 50,
133
- stop_sequences: ['END'], seed: 42,
134
- frequency_penalty: 0.5, presence_penalty: 0.1,
135
- response_format: { type: 'json_object' }
136
- )
63
+ it 'builds from keyword args' do
64
+ params = described_class.build(temperature: 1.0)
65
+ expect(params.temperature).to eq(1.0)
137
66
  end
138
- end
139
67
 
140
- describe 'round-trip' do
141
- it 'preserves values through from_hash/to_h' do
142
- original = { max_tokens: 4096, temperature: 0.7, top_p: 0.95 }
143
- params = described_class.from_hash(original)
144
- serialized = params.to_h
145
-
146
- expect(serialized).to eq(original)
68
+ it 'build and from_hash share one normalization path (T7)' do
69
+ via_build = described_class.build(temperature: 0.5, metadata: { a: 1 })
70
+ via_hash = described_class.from_hash(temperature: 0.5, metadata: { a: 1 })
71
+ expect(via_build).to eq(via_hash)
147
72
  end
148
73
 
149
- it 'normalizes provider keys through round-trip' do
150
- original = { max_output_tokens: 2048, budget_tokens: 5000, stop: ['END'] }
151
- params = described_class.from_hash(original)
152
- serialized = params.to_h
153
-
154
- expect(serialized[:max_tokens]).to eq(2048)
155
- expect(serialized[:max_thinking_tokens]).to eq(5000)
156
- expect(serialized[:stop_sequences]).to eq(['END'])
74
+ it 'survives build → to_h → JSON → from_hash (T4)' do
75
+ params = described_class.build(temperature: 0.5, stop_sequences: ['stop'])
76
+ round_tripped = described_class.from_hash(Legion::JSON.load(Legion::JSON.dump(params.to_h)))
77
+ expect(round_tripped).to eq(params)
157
78
  end
158
79
  end
159
80
  end