lex-llm 0.7.6 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,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