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,174 +1,112 @@
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::Request do
6
- describe '.build' do
7
- it 'creates a request with required fields' do
8
- req = described_class.build(messages: [{ role: :user, content: 'hello' }])
9
-
10
- expect(req.id).to start_with('req_')
11
- expect(req.messages).to be_an(Array)
12
- expect(req.messages.first).to be_a(Legion::Extensions::Llm::Canonical::Message)
13
- expect(req.stream).to be false
14
- end
15
-
16
- it 'accepts all fields' do
17
- req = described_class.build(
18
- id: 'req-1',
19
- messages: [{ role: :user, content: 'hello' }],
20
- system: 'You are helpful',
21
- tools: { search: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'search') },
22
- tool_choice: :auto,
23
- params: Legion::Extensions::Llm::Canonical::Params.from_hash(max_tokens: 4096),
24
- thinking: Legion::Extensions::Llm::Canonical::Thinking::Config.build(effort: 'high'),
25
- stream: true,
26
- conversation_id: 'conv-1',
27
- caller: 'test',
28
- routing: { provider: 'anthropic' },
29
- metadata: { source: 'cli' }
30
- )
31
-
32
- expect(req.id).to eq('req-1')
33
- expect(req.system).to eq('You are helpful')
34
- expect(req.tools).to be_a(Hash)
35
- expect(req.tool_choice).to eq(:auto)
36
- expect(req.params).to be_a(Legion::Extensions::Llm::Canonical::Params)
37
- expect(req.thinking).to be_a(Legion::Extensions::Llm::Canonical::Thinking::Config)
38
- expect(req.stream).to be true
39
- expect(req.conversation_id).to eq('conv-1')
40
- expect(req.caller).to eq('test')
41
- expect(req.routing).to eq({ provider: 'anthropic' })
42
- expect(req.metadata).to eq({ source: 'cli' })
43
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [:id] }
9
+ let(:type_source) do
10
+ {
11
+ id: 'req_1',
12
+ messages: [{ role: 'user', content: 'how are you' }],
13
+ system: 'be brief',
14
+ tools: [{ name: 'get_weather', description: 'd', parameters: { type: 'object', properties: {} } }],
15
+ tool_choice: 'auto',
16
+ params: { temperature: 0.5 },
17
+ thinking: { effort: 'low' },
18
+ stream: false,
19
+ conversation_id: 'conv_1',
20
+ caller: 'e2e',
21
+ routing: { provider: 'fake' },
22
+ metadata: { origin: 'client' }
23
+ }
24
+ end
44
25
 
45
- it 'normalizes tool_choice string to symbol' do
46
- req = described_class.build(tool_choice: 'auto')
26
+ it_behaves_like 'a canonical type'
47
27
 
48
- expect(req.tool_choice).to eq(:auto)
28
+ describe 'H1 — .new is as strict as the factories' do
29
+ it 'rejects non-canonical messages in the member contract' do
30
+ expect { described_class.new(messages: [42]) }
31
+ .to raise_error(ArgumentError, /expected Hash, got Integer/)
49
32
  end
50
33
 
51
- it 'normalizes params from hash' do
52
- req = described_class.build(params: { max_tokens: 4096, temperature: 0.7 })
53
-
54
- expect(req.params).to be_a(Legion::Extensions::Llm::Canonical::Params)
55
- expect(req.params.max_tokens).to eq(4096)
34
+ it 'normalizes Hash tools to ToolDefinition like the factories' do
35
+ request = described_class.new(tools: [{ name: 'f', parameters: {} }])
36
+ expect(request.tools.values.first).to be_a(Legion::Extensions::Llm::Canonical::ToolDefinition)
56
37
  end
38
+ end
57
39
 
58
- it 'normalizes thinking config from hash' do
59
- req = described_class.build(thinking: { effort: 'high', budget: 10_000 })
60
-
61
- expect(req.thinking).to be_a(Legion::Extensions::Llm::Canonical::Thinking::Config)
62
- expect(req.thinking.effort).to eq('high')
40
+ describe 'F2 fix — strict message map (no silent drops)' do
41
+ it 'normalizes Hash messages and passes Canonical through (T7)' do
42
+ canonical = Legion::Extensions::Llm::Canonical::Message.build(role: :user, content: 'hi')
43
+ raw = { role: 'user', content: 'there' }
44
+ via_build = described_class.build(messages: [canonical, raw])
45
+ via_hash = described_class.from_hash(messages: [canonical, raw])
46
+ expect(via_build.messages.map { |m| [m.role, m.content, m.class] })
47
+ .to eq(via_hash.messages.map { |m| [m.role, m.content, m.class] })
48
+ expect(via_build.messages).to all(be_a(Legion::Extensions::Llm::Canonical::Message))
63
49
  end
64
50
 
65
- it 'accepts tools as array' do
66
- req = described_class.build(
67
- tools: [{ name: 'search', description: 'Search' }]
68
- )
69
-
70
- expect(req.tools).to be_a(Hash)
71
- expect(req.tools.keys).to include('search')
51
+ it 'raises on non-Message/non-Hash elements (the filter_map drop is deleted)' do
52
+ expect { described_class.build(messages: [42]) }
53
+ .to raise_error(ArgumentError, /expected Hash, got Integer/)
54
+ expect { described_class.build(messages: [nil]) }
55
+ .to raise_error(ArgumentError, /expected Hash, got NilClass/)
56
+ expect { described_class.from_hash(messages: ['str']) }
57
+ .to raise_error(ArgumentError, /expected Hash, got String/)
72
58
  end
73
59
 
74
- it 'accepts tools as hash' do
75
- req = described_class.build(
76
- tools: { search: { name: 'search', description: 'Search' } }
77
- )
78
-
79
- expect(req.tools).to be_a(Hash)
80
- expect(req.tools[:search]).to be_a(Legion::Extensions::Llm::Canonical::ToolDefinition)
60
+ it 'raises on a wrong-class messages member' do
61
+ expect { described_class.build(messages: 'nope') }
62
+ .to raise_error(ArgumentError, /messages expected Array, got String/)
81
63
  end
82
64
  end
83
65
 
84
- describe '.from_hash' do
85
- it 'parses from hash with symbol keys' do
86
- req = described_class.from_hash(
87
- messages: [{ role: :user, content: 'hello' }],
88
- system: 'You are helpful',
89
- stream: true
90
- )
91
-
92
- expect(req.system).to eq('You are helpful')
93
- expect(req.stream).to be true
94
- expect(req.messages.first).to be_a(Legion::Extensions::Llm::Canonical::Message)
66
+ describe 'L2 — tools/params/thinking normalizers shared by both factories' do
67
+ it 'normalizes tools to Hash<name, ToolDefinition> (T7)' do
68
+ via_build = described_class.build(tools: [{ name: 'get_weather', parameters: {} }])
69
+ via_hash = described_class.from_hash(tools: [{ name: 'get_weather', parameters: {} }])
70
+ expect(via_build.tools).to eq(via_hash.tools)
71
+ expect(via_build.tools.keys).to eq(%w[get_weather])
72
+ expect(via_build.tools.values.first).to be_a(Legion::Extensions::Llm::Canonical::ToolDefinition)
95
73
  end
96
74
 
97
- it 'moves unknown keys to metadata' do
98
- req = described_class.from_hash(
99
- messages: [{ role: :user, content: 'hello' }],
100
- custom_field: 'value',
101
- another_custom: 123
102
- )
103
-
104
- expect(req.metadata[:custom_field]).to eq('value')
105
- expect(req.metadata[:another_custom]).to eq(123)
106
- end
107
-
108
- it 'merges unknown keys with existing metadata' do
109
- req = described_class.from_hash(
110
- messages: [{ role: :user, content: 'hello' }],
111
- metadata: { existing: 'data' },
112
- custom_field: 'value'
113
- )
114
-
115
- expect(req.metadata[:existing]).to eq('data')
116
- expect(req.metadata[:custom_field]).to eq('value')
75
+ it 'normalizes params to Canonical::Params and thinking to Thinking::Config' do
76
+ request = described_class.build(params: { temperature: 1 }, thinking: { effort: 'high' })
77
+ expect(request.params).to be_a(Legion::Extensions::Llm::Canonical::Params)
78
+ expect(request.thinking).to be_a(Legion::Extensions::Llm::Canonical::Thinking::Config)
79
+ expect(request.thinking.effort).to eq('high')
117
80
  end
118
81
 
119
- it 'returns nil for nil source' do
120
- expect(described_class.from_hash(nil)).to be_nil
82
+ it 'raises on wrong-class params/thinking' do
83
+ expect { described_class.build(params: 'nope') }
84
+ .to raise_error(ArgumentError, /params expected Hash, got String/)
85
+ expect { described_class.from_hash(thinking: 42) }
86
+ .to raise_error(ArgumentError, /thinking expected Hash, got Integer/)
121
87
  end
122
88
  end
123
89
 
124
- describe '#to_h' do
125
- it 'serializes to hash with nested objects' do
126
- req = described_class.build(
127
- messages: [{ role: :user, content: 'hello' }],
128
- system: 'You are helpful',
129
- params: Legion::Extensions::Llm::Canonical::Params.from_hash(max_tokens: 4096)
130
- )
131
- hash = req.to_h
132
-
133
- expect(hash[:id]).to start_with('req_')
134
- expect(hash[:system]).to eq('You are helpful')
135
- expect(hash[:params]).to eq({ max_tokens: 4096 })
136
- end
137
-
138
- it 'serializes messages as hashes' do
139
- req = described_class.build(
140
- messages: [{ role: :user, content: 'hello' }]
141
- )
142
- hash = req.to_h
143
-
144
- expect(hash[:messages]).to be_an(Array)
145
- expect(hash[:messages].first).to be_a(Hash)
146
- end
147
-
148
- it 'serializes tools as hashes' do
149
- req = described_class.build(
150
- tools: { search: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'search') }
90
+ describe 'T4 — member survival through the wire' do
91
+ it 'survives build → to_h → JSON → from_hash' do
92
+ request = described_class.build(
93
+ messages: [Legion::Extensions::Llm::Canonical::Message.build(role: :user, content: 'hi',
94
+ cache_control: { type: 'ephemeral' })],
95
+ params: { temperature: 0.25 }
151
96
  )
152
- hash = req.to_h
153
-
154
- expect(hash[:tools]).to be_a(Hash)
97
+ wire = Legion::JSON.load(Legion::JSON.dump(request.to_h))
98
+ round_tripped = described_class.from_hash(wire)
99
+ expect(round_tripped.messages.first.content).to eq('hi')
100
+ expect(round_tripped.messages.first.cache_control).to eq(type: 'ephemeral')
101
+ expect(round_tripped.params.temperature).to eq(0.25)
155
102
  end
156
103
  end
157
104
 
158
- describe 'round-trip' do
159
- it 'preserves values through from_hash/to_h' do
160
- original = {
161
- messages: [{ role: 'user', content: 'hello' }],
162
- system: 'You are helpful',
163
- stream: true,
164
- conversation_id: 'conv-1'
165
- }
166
- req = described_class.from_hash(original)
167
- serialized = req.to_h
168
-
169
- expect(serialized[:system]).to eq('You are helpful')
170
- expect(serialized[:stream]).to be true
171
- expect(serialized[:conversation_id]).to eq('conv-1')
172
- end
105
+ it 'defaults stream false, routing {} and auto-generates req ids' do
106
+ request = described_class.build(messages: [])
107
+ expect(request.stream).to be(false)
108
+ expect(request.routing).to eq({})
109
+ expect(request.id).to match(/\Areq_[0-9a-f]{24}\z/)
110
+ expect(request.messages).to eq([])
173
111
  end
174
112
  end
@@ -1,234 +1,98 @@
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::Response do
6
- describe '.from_hash' do
7
- it 'returns a Response instance with canonical fields' do
8
- resp = described_class.from_hash(
9
- text: 'Hello world',
10
- stop_reason: 'end_turn',
11
- model: 'claude-sonnet-4-6'
12
- )
13
-
14
- expect(resp).to be_a(described_class)
15
- expect(resp.text).to eq('Hello world')
16
- expect(resp.stop_reason).to eq(:end_turn)
17
- expect(resp.model).to eq('claude-sonnet-4-6')
18
- expect(resp.tool_calls).to eq([])
19
- end
20
-
21
- it 'normalizes stop_reason to symbol' do
22
- resp = described_class.from_hash(stop_reason: 'tool_use')
23
-
24
- expect(resp.stop_reason).to eq(:tool_use)
25
- end
26
-
27
- it 'parses thinking from nested hash' do
28
- resp = described_class.from_hash(
29
- text: 'answer',
30
- thinking: { content: 'reasoning', signature: 'sig-1' }
31
- )
32
-
33
- expect(resp.thinking).to be_a(Legion::Extensions::Llm::Canonical::Thinking)
34
- expect(resp.thinking.content).to eq('reasoning')
35
- end
36
-
37
- it 'parses usage from nested hash' do
38
- resp = described_class.from_hash(
39
- text: 'answer',
40
- usage: { input_tokens: 100, output_tokens: 50 }
41
- )
42
-
43
- expect(resp.usage).to be_a(Legion::Extensions::Llm::Canonical::Usage)
44
- expect(resp.usage.input_tokens).to eq(100)
45
- end
46
-
47
- it 'parses tool_calls from array of hashes' do
48
- resp = described_class.from_hash(
49
- text: '',
50
- tool_calls: [{ name: 'search', arguments: { query: 'test' } }]
51
- )
52
-
53
- expect(resp.tool_calls).to be_an(Array)
54
- expect(resp.tool_calls.first).to be_a(Legion::Extensions::Llm::Canonical::ToolCall)
55
- end
56
-
57
- it 'accepts finish_reason as alias for stop_reason' do
58
- resp = described_class.from_hash(finish_reason: 'max_tokens')
59
-
60
- expect(resp.stop_reason).to eq(:max_tokens)
61
- end
62
-
63
- it 'accepts content as alias for text' do
64
- resp = described_class.from_hash(content: 'Hello world')
65
-
66
- expect(resp.text).to eq('Hello world')
67
- end
68
-
69
- it 'moves unknown keys to metadata' do
70
- resp = described_class.from_hash(
71
- text: 'answer',
72
- cache_creation_input_tokens: 25,
73
- custom_field: 'value'
74
- )
75
-
76
- expect(resp.metadata[:cache_creation_input_tokens]).to eq(25)
77
- expect(resp.metadata[:custom_field]).to eq('value')
78
- end
79
-
80
- it 'merges unknown keys with existing metadata' do
81
- resp = described_class.from_hash(
82
- text: 'answer',
83
- metadata: { existing: 'data' },
84
- custom_field: 'value'
85
- )
86
-
87
- expect(resp.metadata[:existing]).to eq('data')
88
- expect(resp.metadata[:custom_field]).to eq('value')
89
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [] }
9
+ let(:type_source) do
10
+ {
11
+ text: 'I am done',
12
+ thinking: { content: 'reasoning', signature: 'sig-1' },
13
+ tool_calls: [{ name: 'get_weather', arguments: { location: 'sf' } }],
14
+ usage: { input_tokens: 12, output_tokens: 10 },
15
+ stop_reason: 'end_turn',
16
+ model: 'fake-model',
17
+ routing: { lane_id: 'local:vllm:h200:inference:gemma4' },
18
+ metadata: { origin: 'provider' }
19
+ }
20
+ end
90
21
 
91
- it 'rejects invalid stop_reasons' do
92
- expect do
93
- described_class.from_hash(stop_reason: 'invalid_reason')
94
- end.to raise_error(ArgumentError, /Invalid stop_reason/)
95
- end
22
+ it_behaves_like 'a canonical type'
96
23
 
97
- it 'returns nil for nil source' do
98
- expect(described_class.from_hash(nil)).to be_nil
24
+ describe 'H1 — .new is as strict as the factories' do
25
+ it 'rejects a poison stop_reason' do
26
+ expect { described_class.new(text: 'x', stop_reason: :bogus_reason) }
27
+ .to raise_error(ArgumentError, /Invalid stop_reason/)
99
28
  end
100
29
 
101
- it 'handles string keys' do
102
- resp = described_class.from_hash(
103
- 'text' => 'Hello',
104
- 'stop_reason' => 'end_turn',
105
- 'model' => 'claude-sonnet-4-6'
106
- )
107
-
108
- expect(resp.text).to eq('Hello')
109
- expect(resp.stop_reason).to eq(:end_turn)
30
+ it 'rejects a wrong-class usage member' do
31
+ expect { described_class.new(usage: 'nope') }
32
+ .to raise_error(ArgumentError, /usage expected Hash, got String/)
110
33
  end
111
34
  end
112
35
 
113
- describe '.build' do
114
- it 'creates a response with defaults' do
115
- resp = described_class.build
116
-
117
- expect(resp.text).to eq('')
118
- expect(resp.tool_calls).to eq([])
119
- expect(resp.routing).to eq({})
120
- expect(resp.metadata).to eq({})
36
+ describe 'T5 — stop_reason enum (validated in both factories)' do
37
+ it 'accepts every canonical stop reason in both factories' do
38
+ described_class::STOP_REASONS.each do |reason|
39
+ expect(described_class.build(text: 'x', stop_reason: reason).stop_reason).to eq(reason)
40
+ expect(described_class.from_hash(text: 'x', stop_reason: reason.to_s).stop_reason).to eq(reason)
41
+ end
121
42
  end
122
43
 
123
- it 'creates a response with all fields' do
124
- thinking = Legion::Extensions::Llm::Canonical::Thinking.new(content: 'reasoning', signature: 'sig')
125
- usage = Legion::Extensions::Llm::Canonical::Usage.new(
126
- input_tokens: 100, output_tokens: 50,
127
- cache_read_tokens: nil, cache_write_tokens: nil,
128
- thinking_tokens: nil, units: {}
129
- )
130
- resp = described_class.build(
131
- text: 'answer',
132
- thinking: thinking,
133
- tool_calls: [],
134
- usage: usage,
135
- stop_reason: :end_turn,
136
- model: 'claude-sonnet-4-6'
137
- )
138
-
139
- expect(resp.text).to eq('answer')
140
- expect(resp.thinking).to eq(thinking)
141
- expect(resp.usage).to eq(usage)
142
- expect(resp.stop_reason).to eq(:end_turn)
143
- expect(resp.model).to eq('claude-sonnet-4-6')
144
- end
145
-
146
- it 'rejects invalid stop_reasons' do
147
- expect do
148
- described_class.build(stop_reason: :invalid_reason)
149
- end.to raise_error(ArgumentError, /Invalid stop_reason/)
44
+ it 'raises on an invalid stop reason in both factories' do
45
+ expect { described_class.build(stop_reason: :invalid_reason, text: 'test') }
46
+ .to raise_error(ArgumentError, /Invalid stop_reason/)
47
+ expect { described_class.from_hash(stop_reason: 'bogus', text: 'test') }
48
+ .to raise_error(ArgumentError, /Invalid stop_reason/)
150
49
  end
151
50
  end
152
51
 
153
- describe '#to_h' do
154
- it 'serializes to compact hash' do
155
- resp = described_class.build(text: 'answer', stop_reason: :end_turn, model: 'claude-sonnet-4-6')
156
- hash = resp.to_h
157
-
158
- expect(hash).to eq(
159
- text: 'answer',
160
- stop_reason: :end_turn,
161
- model: 'claude-sonnet-4-6'
162
- )
163
- end
164
-
165
- it 'serializes nested objects' do
166
- thinking = Legion::Extensions::Llm::Canonical::Thinking.new(content: 'reasoning', signature: 'sig')
167
- usage = Legion::Extensions::Llm::Canonical::Usage.new(
168
- input_tokens: 100, output_tokens: 50,
169
- cache_read_tokens: nil, cache_write_tokens: nil,
170
- thinking_tokens: nil, units: {}
171
- )
172
- resp = described_class.build(text: 'answer', thinking: thinking, usage: usage)
173
- hash = resp.to_h
174
-
175
- expect(hash[:thinking]).to eq(content: 'reasoning', signature: 'sig')
176
- expect(hash[:usage]).to include(input_tokens: 100, output_tokens: 50)
52
+ describe 'O03a — finish_reason alias deleted' do
53
+ it 'does not translate finish_reason (it folds into metadata)' do
54
+ response = described_class.from_hash(text: 'x', finish_reason: 'stop')
55
+ expect(response.stop_reason).to be_nil
56
+ expect(response.metadata).to eq(finish_reason: 'stop')
177
57
  end
178
58
  end
179
59
 
180
- describe 'predicates' do
181
- it 'identifies tool call responses' do
182
- resp = described_class.build(
183
- text: '',
184
- tool_calls: [Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'search')]
60
+ describe 'L2 — build and from_hash share the member normalizers (T7)' do
61
+ it 'normalizes tool_calls / usage / thinking identically' do
62
+ raw_tool_calls = [{ id: 'tc_1', name: 'x', arguments: { a: 1 } }]
63
+ via_build = described_class.build(
64
+ tool_calls: raw_tool_calls,
65
+ usage: { input_tokens: 3 },
66
+ thinking: { content: 'hmm' }
185
67
  )
186
-
187
- expect(resp.tool_call?).to be true
188
- end
189
-
190
- it 'returns false for empty tool calls' do
191
- resp = described_class.build(text: 'answer', tool_calls: [])
192
-
193
- expect(resp.tool_call?).to be false
194
- end
195
-
196
- it 'identifies error responses' do
197
- resp = described_class.build(stop_reason: :error)
198
-
199
- expect(resp.error?).to be true
68
+ via_hash = described_class.from_hash(
69
+ tool_calls: raw_tool_calls,
70
+ usage: { input_tokens: 3 },
71
+ thinking: { content: 'hmm' }
72
+ )
73
+ expect(via_build.tool_calls).to eq(via_hash.tool_calls)
74
+ expect(via_build.usage).to eq(via_hash.usage)
75
+ expect(via_build.thinking).to eq(via_hash.thinking)
200
76
  end
201
77
 
202
- it 'returns false for non-error responses' do
203
- resp = described_class.build(stop_reason: :end_turn)
204
-
205
- expect(resp.error?).to be false
78
+ it 'raises on wrong-class members' do
79
+ expect { described_class.build(usage: 'nope') }
80
+ .to raise_error(ArgumentError, /usage expected Hash, got String/)
81
+ expect { described_class.from_hash(tool_calls: 'nope') }
82
+ .to raise_error(ArgumentError, /tool_calls expected Array, got String/)
206
83
  end
207
84
  end
208
85
 
209
- describe 'STOP_REASONS' do
210
- it 'includes all expected stop reasons' do
211
- expect(described_class::STOP_REASONS).to eq(
212
- %i[end_turn tool_use max_tokens stop_sequence content_filter error]
213
- )
214
- end
86
+ it 'defaults to an empty canonical response from {}' do
87
+ response = described_class.from_hash({})
88
+ expect(response.text).to eq('')
89
+ expect(response.tool_calls).to eq([])
90
+ expect(response.stop_reason).to be_nil
215
91
  end
216
92
 
217
- describe 'round-trip' do
218
- it 'preserves values through from_hash/to_h' do
219
- original = {
220
- text: 'Hello world',
221
- stop_reason: 'end_turn',
222
- model: 'claude-sonnet-4-6',
223
- usage: { input_tokens: 100, output_tokens: 50 }
224
- }
225
- resp = described_class.from_hash(original)
226
- serialized = resp.to_h
227
-
228
- expect(serialized[:text]).to eq('Hello world')
229
- expect(serialized[:stop_reason]).to eq(:end_turn)
230
- expect(serialized[:model]).to eq('claude-sonnet-4-6')
231
- expect(serialized[:usage]).to include(input_tokens: 100, output_tokens: 50)
232
- end
93
+ it 'is the provider-boundary contract: error? and tool_call? predicates' do
94
+ expect(described_class.build(stop_reason: :error).error?).to be(true)
95
+ tc = Legion::Extensions::Llm::Canonical::ToolCall.build(name: 'x')
96
+ expect(described_class.build(tool_calls: [tc]).tool_call?).to be(true)
233
97
  end
234
98
  end