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,285 +1,99 @@
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::Chunk do
6
- describe '.text_delta' do
7
- it 'creates a text delta chunk' do
8
- chunk = described_class.text_delta(delta: 'hello', request_id: 'req-1')
9
-
10
- expect(chunk.type).to eq(:text_delta)
11
- expect(chunk.delta).to eq('hello')
12
- expect(chunk.request_id).to eq('req-1')
13
- expect(chunk.index).to eq(0)
14
- expect(chunk.timestamp).to be_a(Time)
15
- end
16
-
17
- it 'accepts block_index and item_id' do
18
- chunk = described_class.text_delta(
19
- delta: 'hello',
20
- request_id: 'req-1',
21
- block_index: 0,
22
- item_id: 'item-1'
23
- )
24
-
25
- expect(chunk.block_index).to eq(0)
26
- expect(chunk.item_id).to eq('item-1')
27
- end
28
- end
29
-
30
- describe '.thinking_delta' do
31
- it 'creates a thinking delta chunk' do
32
- chunk = described_class.thinking_delta(
33
- delta: 'reasoning',
34
- request_id: 'req-1',
35
- signature: 'sig-partial'
36
- )
37
-
38
- expect(chunk.type).to eq(:thinking_delta)
39
- expect(chunk.delta).to eq('reasoning')
40
- expect(chunk.signature).to eq('sig-partial')
41
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [:timestamp] }
9
+ let(:type_source) do
10
+ {
11
+ request_id: 'req_1',
12
+ conversation_id: 'conv_1',
13
+ exchange_id: 'exch_1',
14
+ index: 3,
15
+ type: 'text_delta',
16
+ delta: 'hello',
17
+ metadata: { origin: 'provider' }
18
+ }
42
19
  end
43
20
 
44
- describe '.tool_call_delta' do
45
- it 'creates a tool call delta chunk' do
46
- tool_call = Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'search')
47
- chunk = described_class.tool_call_delta(tool_call: tool_call, request_id: 'req-1')
21
+ it_behaves_like 'a canonical type'
48
22
 
49
- expect(chunk.type).to eq(:tool_call_delta)
50
- expect(chunk.tool_call).to eq(tool_call)
23
+ describe 'G20d — produce side strict, consume side lenient' do
24
+ it 'produces valid CHUNK_TYPES via the named factories' do
25
+ expect(described_class.text_delta(delta: 'x', request_id: 'r').type).to eq(:text_delta)
26
+ expect(described_class.thinking_delta(delta: 'x', request_id: 'r').type).to eq(:thinking_delta)
27
+ expect(described_class.tool_call_delta(tool_call: { id: 'i' }, request_id: 'r').type).to eq(:tool_call_delta)
28
+ expect(described_class.usage_chunk(usage: Legion::Extensions::Llm::Canonical::Usage.build(input_tokens: 1),
29
+ request_id: 'r').type).to eq(:usage)
30
+ expect(described_class.done(request_id: 'r').type).to eq(:done)
31
+ expect(described_class.error_chunk(error: 'boom', request_id: 'r').type).to eq(:error)
51
32
  end
52
- end
53
33
 
54
- describe '.usage_chunk' do
55
- it 'creates a usage chunk' do
56
- usage = Legion::Extensions::Llm::Canonical::Usage.new(
57
- input_tokens: 100, output_tokens: 50,
58
- cache_read_tokens: nil, cache_write_tokens: nil,
59
- thinking_tokens: nil, units: {}
60
- )
61
- chunk = described_class.usage_chunk(usage: usage, request_id: 'req-1')
62
-
63
- expect(chunk.type).to eq(:usage)
64
- expect(chunk.usage).to eq(usage)
34
+ it 'the generic produce path validates type against CHUNK_TYPES' do
35
+ expect { described_class.build(type: :bogus, request_id: 'r') }
36
+ .to raise_error(ArgumentError, /Invalid type: :bogus/)
37
+ expect(described_class.build(type: 'usage', request_id: 'r').type).to eq(:usage)
65
38
  end
66
- end
67
39
 
68
- describe '.done' do
69
- it 'creates a done chunk' do
70
- chunk = described_class.done(request_id: 'req-1', stop_reason: :end_turn)
71
-
72
- expect(chunk.type).to eq(:done)
73
- expect(chunk.stop_reason).to eq(:end_turn)
40
+ it 'consume passes unknown types through unchanged (ignore-unknown on consume)' do
41
+ chunk = described_class.from_hash(type: 'weird_custom_type', delta: 'x')
42
+ expect(chunk.type).to eq(:weird_custom_type)
74
43
  end
75
44
 
76
- it 'includes usage when provided' do
77
- usage = Legion::Extensions::Llm::Canonical::Usage.new(
78
- input_tokens: 100, output_tokens: 50,
79
- cache_read_tokens: nil, cache_write_tokens: nil,
80
- thinking_tokens: nil, units: {}
81
- )
82
- chunk = described_class.done(request_id: 'req-1', usage: usage, stop_reason: :end_turn)
83
-
84
- expect(chunk.usage).to eq(usage)
45
+ it 'H1: .new validates member shapes (type is String|Symbol, never a Hash)' do
46
+ expect { described_class.new(type: { not: :a_type }, request_id: 'r') }
47
+ .to raise_error(ArgumentError, /type expected String or Symbol, got Hash/)
48
+ expect(described_class.new(type: 'text_delta', request_id: 'r').type).to eq(:text_delta)
85
49
  end
86
50
  end
87
51
 
88
- describe '.error_chunk' do
89
- it 'creates an error chunk' do
90
- chunk = described_class.error_chunk(error: 'timeout', request_id: 'req-1')
91
-
92
- expect(chunk.type).to eq(:error)
93
- expect(chunk.stop_reason).to eq(:error)
94
- expect(chunk.metadata[:error]).to eq('timeout')
95
- end
96
-
97
- it 'merges additional metadata' do
98
- chunk = described_class.error_chunk(
99
- error: 'timeout',
100
- request_id: 'req-1',
101
- metadata: { provider: 'anthropic' }
102
- )
103
-
104
- expect(chunk.metadata[:error]).to eq('timeout')
105
- expect(chunk.metadata[:provider]).to eq('anthropic')
52
+ describe 'O03a — no finish_reason alias' do
53
+ it 'does not translate finish_reason (it folds into metadata)' do
54
+ chunk = described_class.from_hash(type: 'done', stop_reason: nil, finish_reason: 'stop')
55
+ expect(chunk.stop_reason).to be_nil
56
+ expect(chunk.metadata).to eq(finish_reason: 'stop')
106
57
  end
107
58
  end
108
59
 
109
- describe '.from_hash' do
110
- it 'parses from hash with symbol keys' do
111
- chunk = described_class.from_hash(
112
- type: :text_delta,
113
- delta: 'hello',
114
- request_id: 'req-1'
115
- )
116
-
117
- expect(chunk.type).to eq(:text_delta)
118
- expect(chunk.delta).to eq('hello')
119
- expect(chunk.request_id).to eq('req-1')
120
- end
121
-
122
- it 'normalizes type string to symbol' do
123
- chunk = described_class.from_hash(type: 'text_delta', delta: 'hello', request_id: 'req-1')
124
-
125
- expect(chunk.type).to eq(:text_delta)
126
- end
127
-
128
- it 'parses nested tool_call' do
129
- chunk = described_class.from_hash(
130
- type: :tool_call_delta,
131
- request_id: 'req-1',
132
- tool_call: { name: 'search', arguments: { query: 'test' } }
133
- )
134
-
135
- expect(chunk.tool_call).to be_a(Legion::Extensions::Llm::Canonical::ToolCall)
136
- expect(chunk.tool_call.name).to eq('search')
137
- end
138
-
139
- it 'parses nested usage' do
60
+ describe 'nested member normalization (L2)' do
61
+ it 'normalizes Hash usage and tool_call fragments' do
140
62
  chunk = described_class.from_hash(
141
- type: :usage,
142
- request_id: 'req-1',
143
- usage: { input_tokens: 100, output_tokens: 50 }
63
+ type: 'usage',
64
+ usage: { input_tokens: 5 },
65
+ tool_call: { id: 'i1', name: 'f', arguments: '{"a"', index: 0 }
144
66
  )
145
-
146
67
  expect(chunk.usage).to be_a(Legion::Extensions::Llm::Canonical::Usage)
68
+ expect(chunk.usage.input_tokens).to eq(5)
69
+ expect(chunk.tool_call).to eq(id: 'i1', name: 'f', arguments: '{"a"', index: 0)
147
70
  end
148
71
 
149
- it 'normalizes stop_reason' do
150
- chunk = described_class.from_hash(
151
- type: :done,
152
- request_id: 'req-1',
153
- stop_reason: 'end_turn'
154
- )
155
-
156
- expect(chunk.stop_reason).to eq(:end_turn)
157
- end
158
-
159
- it 'accepts finish_reason as alias' do
160
- chunk = described_class.from_hash(
161
- type: :done,
162
- request_id: 'req-1',
163
- finish_reason: 'tool_use'
164
- )
165
-
166
- expect(chunk.stop_reason).to eq(:tool_use)
167
- end
168
-
169
- it 'returns nil for nil source' do
170
- expect(described_class.from_hash(nil)).to be_nil
171
- end
172
-
173
- it 'handles string keys' do
174
- chunk = described_class.from_hash(
175
- 'type' => 'text_delta',
176
- 'delta' => 'hello',
177
- 'request_id' => 'req-1'
178
- )
179
-
180
- expect(chunk.type).to eq(:text_delta)
181
- expect(chunk.delta).to eq('hello')
72
+ it 'raises on wrong-class nested members' do
73
+ expect { described_class.from_hash(type: 'usage', usage: 'nope') }
74
+ .to raise_error(ArgumentError, /usage expected Hash, got String/)
75
+ expect { described_class.build(tool_call: 42, type: :tool_call_delta, request_id: 'r') }
76
+ .to raise_error(ArgumentError, /tool_call expected Hash \| .+ToolCall, got Integer/)
182
77
  end
183
78
  end
184
79
 
185
- describe '#to_h' do
186
- it 'serializes to compact hash' do
187
- chunk = described_class.text_delta(delta: 'hello', request_id: 'req-1')
188
- hash = chunk.to_h
189
-
190
- expect(hash).to include(type: :text_delta, delta: 'hello', request_id: 'req-1')
191
- end
192
-
193
- it 'serializes nested objects' do
194
- tool_call = Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'search')
195
- chunk = described_class.tool_call_delta(tool_call: tool_call, request_id: 'req-1')
196
- hash = chunk.to_h
197
-
198
- expect(hash[:tool_call]).to be_a(Hash)
199
- expect(hash[:tool_call][:name]).to eq('search')
80
+ describe 'T4 — signature and usage survive the wire' do
81
+ it 'survives build → to_h → JSON → from_hash' do
82
+ chunk = described_class.thinking_delta(delta: 'hmm', request_id: 'r', signature: 'sig-9')
83
+ wire = Legion::JSON.load(Legion::JSON.dump(chunk.to_h))
84
+ round_tripped = described_class.from_hash(wire)
85
+ expect(round_tripped.signature).to eq('sig-9')
86
+ expect(round_tripped.delta).to eq('hmm')
87
+ expect(round_tripped.type).to eq(:thinking_delta)
200
88
  end
201
89
  end
202
90
 
203
- describe 'type predicates' do
204
- it 'identifies text_delta chunks' do
205
- chunk = described_class.text_delta(delta: 'hello', request_id: 'req-1')
206
- expect(chunk.text_delta?).to be true
207
- expect(chunk.content?).to be true
208
- end
209
-
210
- it 'identifies thinking_delta chunks' do
211
- chunk = described_class.thinking_delta(delta: 'reasoning', request_id: 'req-1')
212
- expect(chunk.thinking_delta?).to be true
213
- expect(chunk.content?).to be true
214
- end
215
-
216
- it 'identifies tool_call_delta chunks' do
217
- tc = Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'search')
218
- chunk = described_class.tool_call_delta(tool_call: tc, request_id: 'req-1')
219
- expect(chunk.tool_call_delta?).to be true
220
- expect(chunk.content?).to be false
221
- end
222
-
223
- it 'identifies usage chunks' do
224
- usage = Legion::Extensions::Llm::Canonical::Usage.new(
225
- input_tokens: 100, output_tokens: 50,
226
- cache_read_tokens: nil, cache_write_tokens: nil,
227
- thinking_tokens: nil, units: {}
228
- )
229
- chunk = described_class.usage_chunk(usage: usage, request_id: 'req-1')
230
- expect(chunk.usage?).to be true
231
- end
232
-
233
- it 'identifies done chunks' do
234
- chunk = described_class.done(request_id: 'req-1')
235
- expect(chunk.done?).to be true
236
- end
237
-
238
- it 'identifies error chunks' do
239
- chunk = described_class.error_chunk(error: 'timeout', request_id: 'req-1')
240
- expect(chunk.error?).to be true
241
- end
242
- end
243
-
244
- describe 'CHUNK_TYPES' do
245
- it 'includes all expected chunk types' do
246
- expect(described_class::CHUNK_TYPES).to eq(
247
- %i[text_delta thinking_delta tool_call_delta usage done error]
248
- )
249
- end
250
- end
251
-
252
- describe 'round-trip' do
253
- it 'preserves text_delta through from_hash/to_h' do
254
- original = {
255
- type: 'text_delta',
256
- delta: 'hello',
257
- request_id: 'req-1',
258
- block_index: 0,
259
- item_id: 'item-1'
260
- }
261
- chunk = described_class.from_hash(original)
262
- serialized = chunk.to_h
263
-
264
- expect(serialized[:type]).to eq(:text_delta)
265
- expect(serialized[:delta]).to eq('hello')
266
- expect(serialized[:block_index]).to eq(0)
267
- expect(serialized[:item_id]).to eq('item-1')
268
- end
269
-
270
- it 'preserves done chunk through from_hash/to_h' do
271
- original = {
272
- type: 'done',
273
- request_id: 'req-1',
274
- stop_reason: 'end_turn',
275
- usage: { input_tokens: 100, output_tokens: 50 }
276
- }
277
- chunk = described_class.from_hash(original)
278
- serialized = chunk.to_h
279
-
280
- expect(serialized[:type]).to eq(:done)
281
- expect(serialized[:stop_reason]).to eq(:end_turn)
282
- expect(serialized[:usage]).to include(input_tokens: 100, output_tokens: 50)
283
- end
91
+ it 'auto-stamps timestamp when absent and exposes the predicates' do
92
+ chunk = described_class.text_delta(delta: 'x', request_id: 'r')
93
+ expect(chunk.timestamp).to be_a(Time)
94
+ expect(chunk.text_delta?).to be(true)
95
+ expect(chunk.content?).to be(true)
96
+ expect(described_class.done(request_id: 'r').done?).to be(true)
97
+ expect(described_class.error_chunk(error: 'e', request_id: 'r').metadata[:error]).to eq('e')
284
98
  end
285
99
  end
@@ -1,225 +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::ContentBlock do
6
- describe '.text' do
7
- it 'creates a text content block' do
8
- block = described_class.text('hello world')
9
-
10
- expect(block.type).to eq(:text)
11
- expect(block.text).to eq('hello world')
12
- end
13
-
14
- it 'accepts cache_control' do
15
- block = described_class.text('hello', cache_control: { type: 'ephemeral' })
16
-
17
- expect(block.cache_control).to eq({ type: 'ephemeral' })
18
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [] }
9
+ let(:type_source) do
10
+ { type: 'text', text: 'hello block', metadata: { origin: 'provider' } }
19
11
  end
20
12
 
21
- describe '.thinking' do
22
- it 'creates a thinking content block' do
23
- block = described_class.thinking('reasoning here')
13
+ it_behaves_like 'a canonical type'
24
14
 
25
- expect(block.type).to eq(:thinking)
26
- expect(block.text).to eq('reasoning here')
15
+ describe 'O03a — no type aliases' do
16
+ it 'does not translate output_text/input_text — untranslated dialect types raise (L4/L6)' do
17
+ expect { described_class.from_hash(type: 'output_text', text: 'x') }
18
+ .to raise_error(ArgumentError, /Invalid type: :output_text/)
19
+ expect { described_class.from_hash(type: 'input_text', text: 'x') }
20
+ .to raise_error(ArgumentError, /Invalid type: :input_text/)
27
21
  end
28
- end
29
22
 
30
- describe '.tool_use' do
31
- it 'creates a tool_use content block' do
32
- block = described_class.tool_use(id: 'toolu-1', name: 'search', input: { query: 'test' })
33
-
34
- expect(block.type).to eq(:tool_use)
35
- expect(block.id).to eq('toolu-1')
36
- expect(block.name).to eq('search')
37
- expect(block.input).to eq({ query: 'test' })
23
+ it 'raises on an unknown type value (L6)' do
24
+ expect { described_class.build(type: :bogus) }
25
+ .to raise_error(ArgumentError, /Invalid type: :bogus/)
38
26
  end
39
27
  end
40
28
 
41
- describe '.tool_result' do
42
- it 'creates a tool_result content block' do
43
- block = described_class.tool_result(tool_use_id: 'toolu-1', content: 'result')
44
-
45
- expect(block.type).to eq(:tool_result)
46
- expect(block.tool_use_id).to eq('toolu-1')
47
- expect(block.text).to eq('result')
48
- expect(block.is_error).to be false
49
- end
50
-
51
- it 'marks error results' do
52
- block = described_class.tool_result(tool_use_id: 'toolu-1', content: 'error', is_error: true)
53
-
54
- expect(block.is_error).to be true
29
+ describe 'H1 — .new is as strict as the factories' do
30
+ it 'rejects an unknown type in .new' do
31
+ expect { described_class.new(type: :bogus, text: 'x') }
32
+ .to raise_error(ArgumentError, /Invalid type: :bogus/)
55
33
  end
56
34
  end
57
35
 
58
- describe '.image' do
59
- it 'creates an image content block with media_type (G20a)' do
60
- block = described_class.image(data: 'base64data', media_type: 'image/png')
61
-
62
- expect(block.type).to eq(:image)
63
- expect(block.data).to eq('base64data')
64
- expect(block.media_type).to eq('image/png')
65
- expect(block.source_type).to eq(:base64)
66
- end
67
-
68
- it 'accepts custom source_type and detail' do
69
- block = described_class.image(data: 'img.png', media_type: 'image/png', source_type: :url, detail: 'high')
70
-
71
- expect(block.source_type).to eq(:url)
72
- expect(block.detail).to eq('high')
73
- end
74
- end
75
-
76
- describe '.from_hash' do
77
- it 'parses a content block from hash' do
78
- block = described_class.from_hash(type: 'text', text: 'hello')
79
-
36
+ describe 'named factories (produce side)' do
37
+ it 'builds a text block' do
38
+ block = described_class.text('hi', cache_control: { type: :ephemeral })
80
39
  expect(block.type).to eq(:text)
81
- expect(block.text).to eq('hello')
82
- end
83
-
84
- it 'handles string keys' do
85
- block = described_class.from_hash('type' => 'thinking', 'text' => 'reasoning')
86
-
87
- expect(block.type).to eq(:thinking)
88
- expect(block.text).to eq('reasoning')
89
- end
90
-
91
- it 'returns nil for nil source' do
92
- expect(described_class.from_hash(nil)).to be_nil
93
- end
94
-
95
- it 'preserves all fields' do
96
- hash = {
97
- type: 'tool_use',
98
- id: 'toolu-1',
99
- name: 'search',
100
- input: { query: 'test' },
101
- cache_control: { type: 'ephemeral' }
102
- }
103
- block = described_class.from_hash(hash)
104
-
105
- expect(block.type).to eq(:tool_use)
106
- expect(block.id).to eq('toolu-1')
107
- expect(block.name).to eq('search')
108
- expect(block.input).to eq({ query: 'test' })
109
- expect(block.cache_control).to eq({ type: 'ephemeral' })
40
+ expect(block.text).to eq('hi')
41
+ expect(block.cache_control).to eq(type: :ephemeral)
42
+ end
43
+
44
+ it 'builds thinking / tool_use / tool_result / image blocks' do
45
+ expect(described_class.thinking('hmm').thinking?).to be(true)
46
+ use = described_class.tool_use(id: 'tu_1', name: 'f', input: { a: 1 })
47
+ expect(use.tool_use?).to be(true)
48
+ expect(use.input).to eq(a: 1)
49
+ result = described_class.tool_result(tool_use_id: 'tu_1', content: 'ok', is_error: true)
50
+ expect(result.tool_result?).to be(true)
51
+ expect(result.is_error).to be(true)
52
+ image = described_class.image(data: 'AAA=', media_type: 'image/png')
53
+ expect(image.type).to eq(:image)
54
+ expect(image.media_type).to eq('image/png')
110
55
  end
111
56
  end
112
57
 
113
- describe '#to_h' do
114
- it 'serializes to compact hash' do
115
- block = described_class.text('hello')
116
- hash = block.to_h
117
-
118
- expect(hash).to eq(type: :text, text: 'hello')
119
- end
120
-
121
- it 'omits nil values' do
122
- block = described_class.new(
123
- type: :text, text: 'hello', data: nil, source_type: nil,
124
- media_type: nil, detail: nil, name: nil, file_id: nil,
125
- id: nil, input: nil, tool_use_id: nil, is_error: nil,
126
- source: nil, start_index: nil, end_index: nil,
127
- code: nil, message: nil, cache_control: nil
128
- )
129
- hash = block.to_h
130
-
131
- expect(hash).to eq(type: :text, text: 'hello')
58
+ describe 'F2 fix — no rescue-and-repair' do
59
+ it 'raises on non-Hash from_hash input instead of fabricating a text block' do
60
+ expect { described_class.from_hash('corrupted') }
61
+ .to raise_error(ArgumentError, /expected Hash, got String/)
62
+ expect { described_class.from_hash(nil) }
63
+ .to raise_error(ArgumentError, /expected Hash, got NilClass/)
132
64
  end
133
65
  end
134
66
 
135
- describe 'type predicates' do
136
- it 'identifies text blocks' do
137
- block = described_class.text('hello')
138
- expect(block.text?).to be true
139
- expect(block.thinking?).to be false
140
- expect(block.tool_use?).to be false
141
- expect(block.tool_result?).to be false
142
- end
143
-
144
- it 'identifies thinking blocks' do
145
- block = described_class.thinking('reasoning')
146
- expect(block.thinking?).to be true
147
- expect(block.text?).to be false
148
- end
149
-
150
- it 'identifies tool_use blocks' do
151
- block = described_class.tool_use(id: '1', name: 'search', input: {})
152
- expect(block.tool_use?).to be true
153
- end
154
-
155
- it 'identifies tool_result blocks' do
156
- block = described_class.tool_result(tool_use_id: '1', content: 'result')
157
- expect(block.tool_result?).to be true
158
- end
159
- end
160
-
161
- describe 'CONTENT_BLOCK_TYPES' do
162
- it 'includes all expected types' do
163
- expect(described_class::CONTENT_BLOCK_TYPES).to include(
164
- :text, :thinking, :tool_use, :tool_result, :image, :audio, :video
165
- )
166
- end
167
- end
168
-
169
- describe 'Responses API type normalization (output_text/input_text)' do
170
- it 'normalizes output_text to :text via from_hash' do
171
- block = described_class.from_hash(type: 'output_text', text: 'The seat templates')
172
-
173
- expect(block.type).to eq(:text)
174
- expect(block.text).to eq('The seat templates')
175
- expect(block.text?).to be true
176
- end
177
-
178
- it 'normalizes input_text to :text via from_hash' do
179
- block = described_class.from_hash(type: 'input_text', text: 'user message')
180
-
181
- expect(block.type).to eq(:text)
182
- expect(block.text?).to be true
183
- end
184
-
185
- it 'returns text content from to_s for output_text blocks' do
186
- block = described_class.from_hash(type: 'output_text', text: "The seat templates don't")
187
-
188
- expect(block.to_s).to eq("The seat templates don't")
189
- end
190
-
191
- it 'does not leak #inspect struct into Array#to_s' do
192
- blocks = [described_class.from_hash(type: 'output_text', text: 'hello')]
193
-
194
- expect(blocks.inspect).not_to include('data Legion::Extensions')
195
- expect(blocks.inspect).not_to include('source_type=nil')
196
- end
197
- end
198
-
199
- describe '#to_s' do
200
- it 'returns text for text blocks' do
201
- expect(described_class.text('hello').to_s).to eq('hello')
202
- end
203
-
204
- it 'returns placeholder for tool_use blocks' do
205
- block = described_class.tool_use(id: '1', name: 'bash', input: {})
206
- expect(block.to_s).to eq('[tool_use:bash]')
207
- end
208
-
209
- it 'returns placeholder for image blocks' do
210
- block = described_class.image(data: 'x', media_type: 'image/png')
211
- expect(block.to_s).to eq('[image]')
67
+ describe 'T4 — member survival' do
68
+ it 'survives build → to_h → JSON → from_hash' do
69
+ block = described_class.image(data: 'AAA=', media_type: 'image/png', source_type: :base64)
70
+ round_tripped = described_class.from_hash(Legion::JSON.load(Legion::JSON.dump(block.to_h)))
71
+ expect(round_tripped.data).to eq('AAA=')
72
+ expect(round_tripped.media_type).to eq('image/png')
212
73
  end
213
74
  end
214
75
 
215
- describe 'round-trip' do
216
- it 'preserves text block through from_hash/to_h' do
217
- original = { type: 'text', text: 'hello world' }
218
- block = described_class.from_hash(original)
219
- serialized = block.to_h
220
-
221
- expect(serialized[:type]).to eq(:text)
222
- expect(serialized[:text]).to eq('hello world')
223
- end
76
+ it 'text? is type == :text only (alias list deleted)' do
77
+ expect(described_class.text('x').text?).to be(true)
78
+ expect(described_class.thinking('x').text?).to be(false)
224
79
  end
225
80
  end