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,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::Thinking do
6
+ describe '.from_hash' do
7
+ it 'returns a Thinking instance with content and signature' do
8
+ thinking = described_class.from_hash(content: 'reasoning here', signature: 'sig-abc')
9
+
10
+ expect(thinking).to be_a(described_class)
11
+ expect(thinking.content).to eq('reasoning here')
12
+ expect(thinking.signature).to eq('sig-abc')
13
+ end
14
+
15
+ it 'handles string keys' do
16
+ thinking = described_class.from_hash('content' => 'reasoning', 'signature' => 'sig-123')
17
+
18
+ expect(thinking.content).to eq('reasoning')
19
+ expect(thinking.signature).to eq('sig-123')
20
+ end
21
+
22
+ it 'returns nil for nil source' do
23
+ expect(described_class.from_hash(nil)).to be_nil
24
+ end
25
+
26
+ it 'returns nil for empty content and signature' do
27
+ result = described_class.from_hash(content: '', signature: '')
28
+
29
+ expect(result).to be_nil
30
+ end
31
+
32
+ it 'returns nil when both fields are nil' do
33
+ result = described_class.from_hash(content: nil, signature: nil)
34
+
35
+ expect(result).to be_nil
36
+ end
37
+
38
+ it 'returns instance with only content' do
39
+ thinking = described_class.from_hash(content: 'just reasoning')
40
+
41
+ expect(thinking.content).to eq('just reasoning')
42
+ expect(thinking.signature).to be_nil
43
+ end
44
+
45
+ it 'returns instance with only signature' do
46
+ thinking = described_class.from_hash(signature: 'sig-only')
47
+
48
+ expect(thinking.content).to be_nil
49
+ expect(thinking.signature).to eq('sig-only')
50
+ end
51
+ end
52
+
53
+ describe '#to_h' do
54
+ it 'serializes to compact hash' do
55
+ thinking = described_class.new(content: 'reasoning', signature: 'sig-1')
56
+ hash = thinking.to_h
57
+
58
+ expect(hash).to eq(content: 'reasoning', signature: 'sig-1')
59
+ end
60
+
61
+ it 'omits nil values' do
62
+ thinking = described_class.new(content: 'reasoning', signature: nil)
63
+ hash = thinking.to_h
64
+
65
+ expect(hash).to eq(content: 'reasoning')
66
+ end
67
+ end
68
+
69
+ describe '#empty?' do
70
+ it 'returns true when both fields are nil' do
71
+ thinking = described_class.new(content: nil, signature: nil)
72
+ expect(thinking.empty?).to be true
73
+ end
74
+
75
+ it 'returns false when content is present' do
76
+ thinking = described_class.new(content: 'reasoning', signature: nil)
77
+ expect(thinking.empty?).to be false
78
+ end
79
+ end
80
+
81
+ describe 'round-trip' do
82
+ it 'preserves content and signature through from_hash/to_h' do
83
+ original = { content: 'deep reasoning', signature: 'sig-xyz' }
84
+ thinking = described_class.from_hash(original)
85
+ serialized = thinking.to_h
86
+
87
+ expect(serialized).to eq(original)
88
+ end
89
+ end
90
+
91
+ describe '::Config' do
92
+ let(:config_class) { Legion::Extensions::Llm::Canonical::Thinking::Config }
93
+
94
+ describe '.build' do
95
+ it 'creates a config with effort and budget' do
96
+ config = config_class.build(effort: 'high', budget: 10_000)
97
+
98
+ expect(config.effort).to eq('high')
99
+ expect(config.budget).to eq(10_000)
100
+ expect(config.enabled?).to be true
101
+ end
102
+
103
+ it 'converts symbol effort to string' do
104
+ config = config_class.build(effort: :high)
105
+
106
+ expect(config.effort).to eq('high')
107
+ end
108
+
109
+ it 'creates disabled config when no values' do
110
+ config = config_class.build
111
+
112
+ expect(config.enabled?).to be false
113
+ end
114
+ end
115
+
116
+ describe '.from_hash' do
117
+ it 'parses config from hash' do
118
+ config = config_class.from_hash(effort: 'medium', budget: 5000)
119
+
120
+ expect(config.effort).to eq('medium')
121
+ expect(config.budget).to eq(5000)
122
+ end
123
+
124
+ it 'handles string keys' do
125
+ config = config_class.from_hash('effort' => 'low')
126
+
127
+ expect(config.effort).to eq('low')
128
+ end
129
+
130
+ it 'returns nil for nil source' do
131
+ expect(config_class.from_hash(nil)).to be_nil
132
+ end
133
+
134
+ it 'returns nil for empty hash' do
135
+ expect(config_class.from_hash({})).to be_nil
136
+ end
137
+ end
138
+
139
+ describe '#to_h' do
140
+ it 'serializes to compact hash' do
141
+ config = config_class.build(effort: 'high', budget: 10_000)
142
+ expect(config.to_h).to eq(effort: 'high', budget: 10_000)
143
+ end
144
+
145
+ it 'omits nil values' do
146
+ config = config_class.build(effort: 'low')
147
+ expect(config.to_h).to eq(effort: 'low')
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::ToolCall do
6
+ describe '.build' do
7
+ it 'creates a tool call with required fields' do
8
+ tc = described_class.build(name: 'search', arguments: { query: 'test' })
9
+
10
+ expect(tc.name).to eq('search')
11
+ expect(tc.arguments).to eq({ query: 'test' })
12
+ expect(tc.id).to start_with('call_')
13
+ expect(tc.arguments).to be_a(Hash)
14
+ end
15
+
16
+ it 'generates a random id' do
17
+ tc1 = described_class.build(name: 'search')
18
+ tc2 = described_class.build(name: 'search')
19
+
20
+ expect(tc1.id).not_to eq(tc2.id)
21
+ end
22
+
23
+ it 'accepts all fields' do
24
+ tc = described_class.build(
25
+ id: 'call-1',
26
+ exchange_id: 'ex-1',
27
+ name: 'search',
28
+ arguments: { query: 'test' },
29
+ source: :registry,
30
+ status: :pending,
31
+ data_handling_classification: :public,
32
+ policy_decision: :allowed
33
+ )
34
+
35
+ expect(tc.id).to eq('call-1')
36
+ expect(tc.exchange_id).to eq('ex-1')
37
+ expect(tc.source).to eq(:registry)
38
+ expect(tc.status).to eq(:pending)
39
+ expect(tc.data_handling_classification).to eq(:public)
40
+ expect(tc.policy_decision).to eq(:allowed)
41
+ end
42
+
43
+ it 'defaults arguments to empty hash' do
44
+ tc = described_class.build(name: 'search')
45
+
46
+ expect(tc.arguments).to eq({})
47
+ end
48
+ end
49
+
50
+ describe '.from_hash' do
51
+ it 'parses from hash with symbol keys' do
52
+ tc = described_class.from_hash(
53
+ id: 'call-1',
54
+ name: 'search',
55
+ arguments: { query: 'test' },
56
+ source: :registry
57
+ )
58
+
59
+ expect(tc.id).to eq('call-1')
60
+ expect(tc.name).to eq('search')
61
+ expect(tc.arguments).to eq({ query: 'test' })
62
+ expect(tc.source).to eq(:registry)
63
+ end
64
+
65
+ it 'normalizes string source to symbol' do
66
+ tc = described_class.from_hash(name: 'search', source: 'registry')
67
+
68
+ expect(tc.source).to eq(:registry)
69
+ end
70
+
71
+ it 'normalizes string status to symbol' do
72
+ tc = described_class.from_hash(name: 'search', status: 'success')
73
+
74
+ expect(tc.status).to eq(:success)
75
+ end
76
+
77
+ it 'parses JSON string arguments to symbol keys per Legion::JSON convention' do
78
+ tc = described_class.from_hash(
79
+ name: 'search',
80
+ arguments: '{"query":"test","limit":10}'
81
+ )
82
+
83
+ expect(tc.arguments).to eq({ query: 'test', limit: 10 })
84
+ end
85
+
86
+ it 'handles string keys' do
87
+ tc = described_class.from_hash('name' => 'search', 'arguments' => '{}')
88
+
89
+ expect(tc.name).to eq('search')
90
+ expect(tc.arguments).to eq({})
91
+ end
92
+
93
+ it 'returns nil for nil source' do
94
+ expect(described_class.from_hash(nil)).to be_nil
95
+ end
96
+ end
97
+
98
+ describe '#with_result' do
99
+ it 'returns a new tool call with result' do
100
+ tc = described_class.build(name: 'search', source: :registry)
101
+ result_tc = tc.with_result(result: { hits: 5 }, status: :success, duration_ms: 100)
102
+
103
+ expect(result_tc.result).to eq({ hits: 5 })
104
+ expect(result_tc.status).to eq(:success)
105
+ expect(result_tc.duration_ms).to eq(100)
106
+ expect(result_tc.finished_at).to be_a(Time)
107
+ end
108
+
109
+ it 'sets error on error status' do
110
+ tc = described_class.build(name: 'search')
111
+ result_tc = tc.with_result(result: 'not found', status: :error)
112
+
113
+ expect(result_tc.error).to eq('not found')
114
+ expect(result_tc.status).to eq(:error)
115
+ end
116
+ end
117
+
118
+ describe 'predicates' do
119
+ it 'identifies successful calls' do
120
+ tc = described_class.build(name: 'search', status: :success)
121
+ expect(tc.success?).to be true
122
+ expect(tc.error?).to be false
123
+ end
124
+
125
+ it 'identifies error calls' do
126
+ tc = described_class.build(name: 'search', status: :error)
127
+ expect(tc.error?).to be true
128
+ expect(tc.success?).to be false
129
+ end
130
+ end
131
+
132
+ describe '#to_h' do
133
+ it 'serializes to compact hash' do
134
+ tc = described_class.build(name: 'search', arguments: { query: 'test' })
135
+ hash = tc.to_h
136
+
137
+ expect(hash).to include(id: tc.id, name: 'search', arguments: { query: 'test' })
138
+ end
139
+ end
140
+
141
+ describe '#to_audit_hash' do
142
+ it 'includes compliance fields' do
143
+ tc = described_class.build(
144
+ name: 'search',
145
+ source: :registry,
146
+ data_handling_classification: :public,
147
+ policy_decision: :allowed
148
+ )
149
+ hash = tc.to_audit_hash
150
+
151
+ expect(hash).to include(
152
+ name: 'search',
153
+ source: :registry,
154
+ data_handling_classification: :public,
155
+ policy_decision: :allowed
156
+ )
157
+ end
158
+ end
159
+
160
+ describe 'SOURCE_VALUES' do
161
+ it 'includes all expected source types' do
162
+ expect(described_class::SOURCE_VALUES).to eq(%i[client registry special extension mcp])
163
+ end
164
+ end
165
+
166
+ describe 'STATUS_VALUES' do
167
+ it 'includes all expected status types' do
168
+ expect(described_class::STATUS_VALUES).to eq(%i[pending running success error])
169
+ end
170
+ end
171
+
172
+ describe 'round-trip' do
173
+ it 'preserves values through from_hash/to_h' do
174
+ original = {
175
+ id: 'call-1',
176
+ name: 'search',
177
+ arguments: { query: 'test' },
178
+ source: 'registry',
179
+ status: 'pending'
180
+ }
181
+ tc = described_class.from_hash(original)
182
+ serialized = tc.to_h
183
+
184
+ expect(serialized[:id]).to eq('call-1')
185
+ expect(serialized[:name]).to eq('search')
186
+ expect(serialized[:arguments]).to eq({ query: 'test' })
187
+ expect(serialized[:source]).to eq(:registry)
188
+ expect(serialized[:status]).to eq(:pending)
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::ToolDefinition do
6
+ describe '.build' do
7
+ it 'creates a tool definition with name and description' do
8
+ tool = described_class.build(name: 'search', description: 'Search the web')
9
+
10
+ expect(tool.name).to eq('search')
11
+ expect(tool.description).to eq('Search the web')
12
+ expect(tool.parameters).to eq({})
13
+ expect(tool.source).to eq({ type: :builtin })
14
+ end
15
+
16
+ it 'accepts parameters and source' do
17
+ tool = described_class.build(
18
+ name: 'search',
19
+ description: 'Search',
20
+ parameters: { type: 'object', properties: { query: { type: 'string' } } },
21
+ source: { type: :registry }
22
+ )
23
+
24
+ expect(tool.parameters).to eq({ type: 'object', properties: { query: { type: 'string' } } })
25
+ expect(tool.source).to eq({ type: :registry })
26
+ end
27
+
28
+ it 'sanitizes tool names' do
29
+ tool = described_class.build(name: 'My.Tool.Name!', description: 'test')
30
+
31
+ expect(tool.name).to eq('My_Tool_Name')
32
+ end
33
+
34
+ it 'truncates long tool names' do
35
+ long_name = 'a' * 100
36
+ tool = described_class.build(name: long_name)
37
+
38
+ expect(tool.name.length).to eq(64)
39
+ end
40
+
41
+ it 'provides fallback name for empty input' do
42
+ tool = described_class.build(name: '')
43
+
44
+ expect(tool.name).to eq('tool')
45
+ end
46
+
47
+ it 'converts nil description to empty string' do
48
+ tool = described_class.build(name: 'search')
49
+
50
+ expect(tool.description).to eq('')
51
+ end
52
+ end
53
+
54
+ describe '.from_hash' do
55
+ it 'parses from hash with symbol keys' do
56
+ tool = described_class.from_hash({ name: 'search', description: 'Search', parameters: { type: 'object' } })
57
+
58
+ expect(tool.name).to eq('search')
59
+ expect(tool.description).to eq('Search')
60
+ expect(tool.parameters).to eq({ type: 'object' })
61
+ end
62
+
63
+ it 'parses from hash with string keys' do
64
+ tool = described_class.from_hash({ 'name' => 'search', 'description' => 'Search' })
65
+
66
+ expect(tool.name).to eq('search')
67
+ expect(tool.description).to eq('Search')
68
+ end
69
+
70
+ it 'accepts input_schema as alias for parameters' do
71
+ tool = described_class.from_hash({ name: 'search', input_schema: { type: 'object' } })
72
+
73
+ expect(tool.parameters).to eq({ type: 'object' })
74
+ end
75
+
76
+ it 'accepts source parameter' do
77
+ tool = described_class.from_hash({ name: 'search', source: { type: :extension } })
78
+
79
+ expect(tool.source).to eq({ type: :extension })
80
+ end
81
+
82
+ it 'overrides source with keyword arg' do
83
+ tool = described_class.from_hash(
84
+ { name: 'search', source: { type: :builtin } },
85
+ source: { type: :override }
86
+ )
87
+
88
+ expect(tool.source).to eq({ type: :override })
89
+ end
90
+ end
91
+
92
+ describe '.from_registry_entry' do
93
+ it 'creates from registry entry with tool_class' do
94
+ entry = {
95
+ name: 'ruby',
96
+ description: 'Run Ruby code',
97
+ input_schema: { type: 'object' },
98
+ tool_class: 'RubyTool',
99
+ extension: 'legion-code',
100
+ runner: 'RubyRunner',
101
+ function: :execute
102
+ }
103
+ tool = described_class.from_registry_entry(entry)
104
+
105
+ expect(tool.name).to eq('ruby')
106
+ expect(tool.description).to eq('Run Ruby code')
107
+ expect(tool.parameters).to eq({ type: 'object' })
108
+ expect(tool.source[:type]).to eq(:registry)
109
+ expect(tool.source[:extension]).to eq('legion-code')
110
+ end
111
+
112
+ it 'creates from registry entry without tool_class' do
113
+ entry = {
114
+ name: 'custom',
115
+ description: 'Custom tool',
116
+ parameters: {},
117
+ extension: 'custom-ext'
118
+ }
119
+ tool = described_class.from_registry_entry(entry)
120
+
121
+ expect(tool.source[:type]).to eq(:extension)
122
+ end
123
+ end
124
+
125
+ describe '.sanitize_tool_name' do
126
+ it 'replaces dots with underscores' do
127
+ expect(described_class.sanitize_tool_name('my.tool')).to eq('my_tool')
128
+ end
129
+
130
+ it 'removes special characters' do
131
+ expect(described_class.sanitize_tool_name('my-tool!@#')).to eq('my-tool')
132
+ end
133
+
134
+ it 'preserves alphanumeric, underscores, and hyphens' do
135
+ expect(described_class.sanitize_tool_name('my-tool_123')).to eq('my-tool_123')
136
+ end
137
+ end
138
+
139
+ describe '#to_h' do
140
+ it 'serializes to hash with name, description, parameters' do
141
+ tool = described_class.build(
142
+ name: 'search',
143
+ description: 'Search the web',
144
+ parameters: { type: 'object' }
145
+ )
146
+ hash = tool.to_h
147
+
148
+ expect(hash).to eq(
149
+ name: 'search',
150
+ description: 'Search the web',
151
+ parameters: { type: 'object' }
152
+ )
153
+ end
154
+
155
+ it 'omits nil values' do
156
+ tool = described_class.new('search', '', nil, nil)
157
+ hash = tool.to_h
158
+
159
+ expect(hash).to eq(name: 'search')
160
+ end
161
+ end
162
+
163
+ describe 'round-trip' do
164
+ it 'preserves values through from_hash/to_h' do
165
+ original = { name: 'search', description: 'Search', parameters: { type: 'object' } }
166
+ tool = described_class.from_hash(original)
167
+ serialized = tool.to_h
168
+
169
+ expect(serialized[:name]).to eq('search')
170
+ expect(serialized[:description]).to eq('Search')
171
+ expect(serialized[:parameters]).to eq({ type: 'object' })
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::Usage do
6
+ describe '.from_hash' do
7
+ it 'returns a Usage instance with canonical fields' do
8
+ usage = described_class.from_hash(input_tokens: 100, output_tokens: 50)
9
+
10
+ expect(usage).to be_a(described_class)
11
+ expect(usage.input_tokens).to eq(100)
12
+ expect(usage.output_tokens).to eq(50)
13
+ expect(usage.cache_read_tokens).to be_nil
14
+ expect(usage.cache_write_tokens).to be_nil
15
+ expect(usage.thinking_tokens).to be_nil
16
+ expect(usage.units).to eq({})
17
+ end
18
+
19
+ it 'normalizes legacy key names' do
20
+ usage = described_class.from_hash(
21
+ input: 200, output: 100, cached: 50, cache_creation: 25, thinking: 10
22
+ )
23
+
24
+ expect(usage.input_tokens).to eq(200)
25
+ expect(usage.output_tokens).to eq(100)
26
+ expect(usage.cache_read_tokens).to eq(50)
27
+ expect(usage.cache_write_tokens).to eq(25)
28
+ expect(usage.thinking_tokens).to eq(10)
29
+ end
30
+
31
+ it 'normalizes prompt_tokens/completion_tokens aliases' do
32
+ usage = described_class.from_hash(prompt_tokens: 300, completion_tokens: 150)
33
+
34
+ expect(usage.input_tokens).to eq(300)
35
+ expect(usage.output_tokens).to eq(150)
36
+ end
37
+
38
+ it 'normalizes reasoning alias for thinking_tokens' do
39
+ usage = described_class.from_hash(reasoning: 75)
40
+
41
+ expect(usage.thinking_tokens).to eq(75)
42
+ end
43
+
44
+ it 'handles string keys' do
45
+ usage = described_class.from_hash('input_tokens' => '100', 'output_tokens' => '50')
46
+
47
+ expect(usage.input_tokens).to eq('100')
48
+ expect(usage.output_tokens).to eq('50')
49
+ end
50
+
51
+ it 'returns nil for nil source' do
52
+ expect(described_class.from_hash(nil)).to be_nil
53
+ end
54
+
55
+ it 'returns nil for empty hash' do
56
+ expect(described_class.from_hash({})).to be_nil
57
+ end
58
+
59
+ it 'preserves units extension point' do
60
+ units = { images: 3, characters: 1500 }
61
+ usage = described_class.from_hash(input_tokens: 10, units: units)
62
+
63
+ expect(usage.units).to eq(units)
64
+ end
65
+ end
66
+
67
+ describe '#to_h' do
68
+ it 'serializes to compact hash' do
69
+ usage = described_class.new(
70
+ input_tokens: 100, output_tokens: 50,
71
+ cache_read_tokens: nil, cache_write_tokens: nil,
72
+ thinking_tokens: nil, units: {}
73
+ )
74
+ hash = usage.to_h
75
+
76
+ expect(hash).to eq(input_tokens: 100, output_tokens: 50, units: {})
77
+ end
78
+
79
+ it 'includes all non-nil fields' do
80
+ usage = described_class.new(
81
+ input_tokens: 200, output_tokens: 100,
82
+ cache_read_tokens: 50, cache_write_tokens: 25,
83
+ thinking_tokens: 10, units: { images: 2 }
84
+ )
85
+ hash = usage.to_h
86
+
87
+ expect(hash).to include(
88
+ input_tokens: 200, output_tokens: 100,
89
+ cache_read_tokens: 50, cache_write_tokens: 25,
90
+ thinking_tokens: 10, units: { images: 2 }
91
+ )
92
+ end
93
+ end
94
+
95
+ describe '#total_tokens' do
96
+ it 'sums all token categories' do
97
+ usage = described_class.new(
98
+ input_tokens: 100, output_tokens: 50,
99
+ cache_read_tokens: 20, cache_write_tokens: 10,
100
+ thinking_tokens: 5, units: {}
101
+ )
102
+
103
+ expect(usage.total_tokens).to eq(185)
104
+ end
105
+
106
+ it 'ignores nil values' do
107
+ usage = described_class.new(
108
+ input_tokens: 100, output_tokens: 50,
109
+ cache_read_tokens: nil, cache_write_tokens: nil,
110
+ thinking_tokens: nil, units: {}
111
+ )
112
+
113
+ expect(usage.total_tokens).to eq(150)
114
+ end
115
+ end
116
+
117
+ describe 'round-trip' do
118
+ it 'preserves values through from_hash/to_h' do
119
+ original = { input_tokens: 100, output_tokens: 50, cache_read_tokens: 10 }
120
+ usage = described_class.from_hash(original)
121
+ serialized = usage.to_h
122
+
123
+ expect(serialized[:input_tokens]).to eq(100)
124
+ expect(serialized[:output_tokens]).to eq(50)
125
+ expect(serialized[:cache_read_tokens]).to eq(10)
126
+ end
127
+
128
+ it 'preserves legacy key normalization through round-trip' do
129
+ original = { input: 200, output: 100, cached: 50 }
130
+ usage = described_class.from_hash(original)
131
+ serialized = usage.to_h
132
+
133
+ expect(serialized[:input_tokens]).to eq(200)
134
+ expect(serialized[:output_tokens]).to eq(100)
135
+ expect(serialized[:cache_read_tokens]).to eq(50)
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Configuration do
6
+ describe 'DSL defaults' do
7
+ subject(:config) { described_class.new }
8
+
9
+ it 'applies core default values' do
10
+ expect(config.request_timeout).to eq(300)
11
+ expect(config.max_retries).to eq(3)
12
+ expect(config.retry_interval).to eq(0.1)
13
+ expect(config.retry_backoff_factor).to eq(2)
14
+ expect(config.retry_interval_randomness).to eq(0.5)
15
+ end
16
+
17
+ it 'exposes a discoverable options API' do
18
+ expect(described_class.options).to include(
19
+ :request_timeout,
20
+ :default_model,
21
+ :default_embedding_model,
22
+ :model_registry_file
23
+ )
24
+ end
25
+
26
+ it 'includes prompt caching configuration options' do
27
+ expect(described_class.options).to include(:llm_cache_enabled, :cache_control_prefix_tokens)
28
+ end
29
+
30
+ it 'defaults llm_cache_enabled to true' do
31
+ expect(config.llm_cache_enabled).to be true
32
+ end
33
+
34
+ it 'defaults cache_control_prefix_tokens to 4' do
35
+ expect(config.cache_control_prefix_tokens).to eq(4)
36
+ end
37
+ end
38
+ end