lex-llm 0.4.18 → 0.5.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -2
  3. data/B1b-conformance-kit.md +79 -0
  4. data/CHANGELOG.md +19 -0
  5. data/lex-llm.gemspec +2 -3
  6. data/lib/legion/extensions/llm/attachment.rb +1 -1
  7. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  8. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  9. data/lib/legion/extensions/llm/canonical/message.rb +125 -0
  10. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  11. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  12. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  13. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
  16. data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
  17. data/lib/legion/extensions/llm/canonical.rb +49 -0
  18. data/lib/legion/extensions/llm/chat.rb +3 -5
  19. data/lib/legion/extensions/llm/connection.rb +5 -1
  20. data/lib/legion/extensions/llm/error.rb +3 -7
  21. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  22. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  24. data/lib/legion/extensions/llm/model/info.rb +4 -6
  25. data/lib/legion/extensions/llm/models.rb +3 -3
  26. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -3
  27. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  28. data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
  29. data/lib/legion/extensions/llm/streaming.rb +1 -3
  30. data/lib/legion/extensions/llm/tool.rb +1 -3
  31. data/lib/legion/extensions/llm/version.rb +1 -1
  32. data/lib/legion/extensions/llm.rb +118 -35
  33. data/spec/fixtures/ruby.mp3 +0 -0
  34. data/spec/fixtures/ruby.mp4 +0 -0
  35. data/spec/fixtures/ruby.png +0 -0
  36. data/spec/fixtures/ruby.txt +1 -0
  37. data/spec/fixtures/ruby.wav +0 -0
  38. data/spec/fixtures/ruby.xml +1 -0
  39. data/spec/fixtures/sample.pdf +0 -0
  40. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  41. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  42. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  43. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  44. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  45. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  46. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  47. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  48. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  49. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  50. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  51. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
  52. data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
  53. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  54. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
  55. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  56. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  57. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  58. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  59. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  60. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  61. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  77. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  78. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  79. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  80. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  81. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  82. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  83. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  84. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  85. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  86. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  87. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  88. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  89. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  90. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  91. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  92. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  93. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  94. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  95. data/spec/legion/extensions/llm/provider_spec.rb +592 -0
  96. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  97. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  98. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  99. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  100. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  101. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  102. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  103. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
  104. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  105. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  106. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  107. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  108. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  109. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  110. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  111. data/spec/spec_helper.rb +24 -0
  112. data/spec/support/fake_llm_provider.rb +148 -0
  113. data/spec/support/llm_configuration.rb +21 -0
  114. data/spec/support/rspec_configuration.rb +19 -0
  115. data/spec/support/simplecov_configuration.rb +20 -0
  116. metadata +96 -15
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::Params do
6
+ describe '.from_hash' do
7
+ it 'returns a Params instance with canonical fields' do
8
+ params = described_class.from_hash(max_tokens: 4096, temperature: 0.7)
9
+
10
+ expect(params).to be_a(described_class)
11
+ expect(params.max_tokens).to eq(4096)
12
+ expect(params.temperature).to eq(0.7)
13
+ expect(params.top_p).to be_nil
14
+ end
15
+
16
+ it 'normalizes max_output_tokens to max_tokens' do
17
+ params = described_class.from_hash(max_output_tokens: 2048)
18
+
19
+ expect(params.max_tokens).to eq(2048)
20
+ end
21
+
22
+ it 'normalizes num_predict to max_tokens' do
23
+ params = described_class.from_hash(num_predict: 1024)
24
+
25
+ expect(params.max_tokens).to eq(1024)
26
+ end
27
+
28
+ it 'normalizes budget_tokens to max_thinking_tokens' do
29
+ params = described_class.from_hash(budget_tokens: 5000)
30
+
31
+ expect(params.max_thinking_tokens).to eq(5000)
32
+ end
33
+
34
+ it 'normalizes thinking_budget to max_thinking_tokens' do
35
+ params = described_class.from_hash(thinking_budget: 8000)
36
+
37
+ expect(params.max_thinking_tokens).to eq(8000)
38
+ end
39
+
40
+ it 'normalizes stop to stop_sequences' do
41
+ params = described_class.from_hash(stop: ['\n', 'END'])
42
+
43
+ expect(params.stop_sequences).to eq(['\n', 'END'])
44
+ end
45
+
46
+ it 'handles string keys' do
47
+ params = described_class.from_hash('max_tokens' => '4096', 'temperature' => '0.5')
48
+
49
+ expect(params.max_tokens).to eq('4096')
50
+ expect(params.temperature).to eq('0.5')
51
+ end
52
+
53
+ it 'returns nil for nil source' do
54
+ expect(described_class.from_hash(nil)).to be_nil
55
+ end
56
+
57
+ it 'returns nil for empty hash' do
58
+ expect(described_class.from_hash({})).to be_nil
59
+ end
60
+
61
+ it 'returns nil when no known keys present' do
62
+ result = described_class.from_hash(unknown_key: 'value')
63
+
64
+ expect(result).to be_nil
65
+ end
66
+
67
+ it 'filters unknown keys' do
68
+ params = described_class.from_hash(
69
+ max_tokens: 4096,
70
+ temperature: 0.7,
71
+ unknown_param: 'should_be_filtered',
72
+ another_unknown: 123
73
+ )
74
+
75
+ expect(params.max_tokens).to eq(4096)
76
+ expect(params.temperature).to eq(0.7)
77
+ end
78
+
79
+ it 'accepts all G18 standard params' do
80
+ params = described_class.from_hash(
81
+ max_tokens: 4096,
82
+ max_thinking_tokens: 10_000,
83
+ temperature: 0.7,
84
+ top_p: 0.95,
85
+ top_k: 50,
86
+ stop_sequences: ['END'],
87
+ seed: 42,
88
+ frequency_penalty: 0.5,
89
+ presence_penalty: 0.1,
90
+ response_format: { type: 'json_object' }
91
+ )
92
+
93
+ expect(params.max_tokens).to eq(4096)
94
+ expect(params.max_thinking_tokens).to eq(10_000)
95
+ expect(params.temperature).to eq(0.7)
96
+ expect(params.top_p).to eq(0.95)
97
+ expect(params.top_k).to eq(50)
98
+ expect(params.stop_sequences).to eq(['END'])
99
+ expect(params.seed).to eq(42)
100
+ expect(params.frequency_penalty).to eq(0.5)
101
+ expect(params.presence_penalty).to eq(0.1)
102
+ expect(params.response_format).to eq({ type: 'json_object' })
103
+ end
104
+ end
105
+
106
+ describe '#to_h' do
107
+ it 'serializes to compact hash' do
108
+ params = described_class.new(
109
+ max_tokens: 4096, temperature: 0.7,
110
+ max_thinking_tokens: nil, top_p: nil, top_k: nil,
111
+ stop_sequences: nil, seed: nil,
112
+ frequency_penalty: nil, presence_penalty: nil,
113
+ response_format: nil
114
+ )
115
+ hash = params.to_h
116
+
117
+ expect(hash).to eq(max_tokens: 4096, temperature: 0.7)
118
+ end
119
+
120
+ it 'includes all non-nil fields' do
121
+ params = described_class.new(
122
+ max_tokens: 4096, max_thinking_tokens: 10_000,
123
+ temperature: 0.7, top_p: 0.95, top_k: 50,
124
+ stop_sequences: ['END'], seed: 42,
125
+ frequency_penalty: 0.5, presence_penalty: 0.1,
126
+ response_format: { type: 'json_object' }
127
+ )
128
+ hash = params.to_h
129
+
130
+ expect(hash).to include(
131
+ max_tokens: 4096, max_thinking_tokens: 10_000,
132
+ temperature: 0.7, top_p: 0.95, top_k: 50,
133
+ stop_sequences: ['END'], seed: 42,
134
+ frequency_penalty: 0.5, presence_penalty: 0.1,
135
+ response_format: { type: 'json_object' }
136
+ )
137
+ end
138
+ end
139
+
140
+ describe 'round-trip' do
141
+ it 'preserves values through from_hash/to_h' do
142
+ original = { max_tokens: 4096, temperature: 0.7, top_p: 0.95 }
143
+ params = described_class.from_hash(original)
144
+ serialized = params.to_h
145
+
146
+ expect(serialized).to eq(original)
147
+ end
148
+
149
+ it 'normalizes provider keys through round-trip' do
150
+ original = { max_output_tokens: 2048, budget_tokens: 5000, stop: ['END'] }
151
+ params = described_class.from_hash(original)
152
+ serialized = params.to_h
153
+
154
+ expect(serialized[:max_tokens]).to eq(2048)
155
+ expect(serialized[:max_thinking_tokens]).to eq(5000)
156
+ expect(serialized[:stop_sequences]).to eq(['END'])
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ 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
44
+
45
+ it 'normalizes tool_choice string to symbol' do
46
+ req = described_class.build(tool_choice: 'auto')
47
+
48
+ expect(req.tool_choice).to eq(:auto)
49
+ end
50
+
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)
56
+ end
57
+
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')
63
+ end
64
+
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')
72
+ end
73
+
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)
81
+ end
82
+ end
83
+
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)
95
+ end
96
+
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')
117
+ end
118
+
119
+ it 'returns nil for nil source' do
120
+ expect(described_class.from_hash(nil)).to be_nil
121
+ end
122
+ end
123
+
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') }
151
+ )
152
+ hash = req.to_h
153
+
154
+ expect(hash[:tools]).to be_a(Hash)
155
+ end
156
+ end
157
+
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
173
+ end
174
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ 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
90
+
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
96
+
97
+ it 'returns nil for nil source' do
98
+ expect(described_class.from_hash(nil)).to be_nil
99
+ end
100
+
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)
110
+ end
111
+ end
112
+
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({})
121
+ end
122
+
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/)
150
+ end
151
+ end
152
+
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)
177
+ end
178
+ end
179
+
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')]
185
+ )
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
200
+ end
201
+
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
206
+ end
207
+ end
208
+
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
215
+ end
216
+
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
233
+ end
234
+ end