lex-llm 0.8.0 → 0.8.5
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +34 -0
- data/lib/legion/extensions/llm/canonical/chunk.rb +1 -2
- data/lib/legion/extensions/llm/canonical/content_block.rb +1 -2
- data/lib/legion/extensions/llm/canonical/message.rb +1 -2
- data/lib/legion/extensions/llm/canonical/params.rb +3 -5
- data/lib/legion/extensions/llm/canonical/request.rb +1 -2
- data/lib/legion/extensions/llm/canonical/response.rb +1 -2
- data/lib/legion/extensions/llm/canonical/thinking.rb +2 -98
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +1 -2
- data/lib/legion/extensions/llm/canonical.rb +1 -0
- data/lib/legion/extensions/llm/connection.rb +1 -1
- data/lib/legion/extensions/llm/credential_sources.rb +1 -5
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +19 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +3 -0
- data/spec/legion/extensions/llm/canonical/params_spec.rb +0 -2
- data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +0 -68
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +2 -4
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +2 -4
- data/spec/legion/extensions/llm/connection_tls_spec.rb +103 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +16 -7
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +1 -2
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +23 -0
- data/spec/legion/extensions/llm/provider_spec.rb +1 -2
- data/spec/support/tls_endpoint.rb +92 -0
- metadata +5 -1
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require_relative '../../conformance/conformance'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Canonical::Thinking::Config do
|
|
7
|
+
subject(:config_class) { described_class }
|
|
8
|
+
|
|
9
|
+
let(:type_class) { described_class }
|
|
10
|
+
let(:auto_generated_members) { [] }
|
|
11
|
+
let(:type_source) do
|
|
12
|
+
{ enabled: true, effort: 'high', budget: 4096, summary: :concise, metadata: { source: 'client' } }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it_behaves_like 'a canonical type'
|
|
16
|
+
|
|
17
|
+
it 'converts from a plain class to a Data with one name (04 §8)' do
|
|
18
|
+
expect(config_class).to be_a(Class)
|
|
19
|
+
expect(config_class.new(enabled: true, effort: 'low', budget: nil, summary: nil, metadata: {})).to be_a(config_class)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'normalizes symbol effort to String' do
|
|
23
|
+
config = config_class.build(effort: :low)
|
|
24
|
+
expect(config.effort).to eq('low')
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'raises on a wrong-class effort' do
|
|
28
|
+
expect { config_class.build(effort: 3) }
|
|
29
|
+
.to raise_error(ArgumentError, /effort expected String, got Integer/)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe 'enabled member' do
|
|
33
|
+
it 'defaults to true in .build' do
|
|
34
|
+
config = config_class.build
|
|
35
|
+
expect(config.enabled).to be(true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'defaults to true in .from_hash when key absent' do
|
|
39
|
+
config = config_class.from_hash({})
|
|
40
|
+
expect(config.enabled).to be(true)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'accepts explicit true' do
|
|
44
|
+
config = config_class.build(enabled: true)
|
|
45
|
+
expect(config.enabled).to be(true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'accepts explicit false' do
|
|
49
|
+
config = config_class.build(enabled: false)
|
|
50
|
+
expect(config.enabled).to be(false)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'rejects non-boolean in .new' do
|
|
54
|
+
expect { config_class.new(enabled: 'yes', effort: nil, budget: nil, summary: nil, metadata: {}) }
|
|
55
|
+
.to raise_error(ArgumentError, /enabled must be true or false/)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'rejects nil in .new (must be an explicit boolean)' do
|
|
59
|
+
expect { config_class.new(enabled: nil, effort: nil, budget: nil, summary: nil, metadata: {}) }
|
|
60
|
+
.to raise_error(ArgumentError, /enabled must be true or false/)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'enabled? returns the enabled member' do
|
|
64
|
+
expect(config_class.build(enabled: true).enabled?).to be(true)
|
|
65
|
+
expect(config_class.build(enabled: false).enabled?).to be(false)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe 'H1/M4 — effort is a closed enum, validated in every constructor' do
|
|
70
|
+
it 'rejects an unrecognized effort in build, from_hash, and .new' do
|
|
71
|
+
expect { config_class.build(effort: 'banana') }
|
|
72
|
+
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
73
|
+
expect { config_class.from_hash(effort: 'banana') }
|
|
74
|
+
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
75
|
+
expect { config_class.new(enabled: true, effort: 'banana', budget: nil, summary: nil, metadata: {}) }
|
|
76
|
+
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'accepts the enum case-insensitively (symbol or string)' do
|
|
80
|
+
expect(config_class.build(effort: :HIGH).effort).to eq('high')
|
|
81
|
+
expect(config_class.new(enabled: true, effort: 'Medium', budget: nil, summary: nil, metadata: {}).effort).to eq('medium')
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'accepts all six effort levels' do
|
|
85
|
+
%w[none low medium high xhigh max].each do |level|
|
|
86
|
+
expect(config_class.build(effort: level).effort).to eq(level)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'resolved_budget derives from the validated effort (no silent medium fallback)' do
|
|
91
|
+
expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
|
|
92
|
+
expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
|
|
93
|
+
expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
|
|
94
|
+
expect(config_class.build(effort: 'xhigh').resolved_budget).to eq(24_576)
|
|
95
|
+
expect(config_class.build(effort: 'max').resolved_budget).to eq(32_768)
|
|
96
|
+
expect(config_class.build(effort: 'none').resolved_budget).to be_nil
|
|
97
|
+
expect(config_class.build(budget: 123).resolved_budget).to eq(123)
|
|
98
|
+
expect(config_class.build(enabled: false).resolved_budget).to be_nil
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe 'budget validation' do
|
|
103
|
+
it 'accepts a positive integer' do
|
|
104
|
+
expect(config_class.build(budget: 1).budget).to eq(1)
|
|
105
|
+
expect(config_class.build(budget: 50_000).budget).to eq(50_000)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'accepts nil' do
|
|
109
|
+
expect(config_class.build(budget: nil).budget).to be_nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'rejects zero' do
|
|
113
|
+
expect { config_class.build(budget: 0) }
|
|
114
|
+
.to raise_error(ArgumentError, /budget must be positive/)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'rejects negative integers' do
|
|
118
|
+
expect { config_class.build(budget: -1) }
|
|
119
|
+
.to raise_error(ArgumentError, /budget must be positive/)
|
|
120
|
+
expect { config_class.build(budget: -100) }
|
|
121
|
+
.to raise_error(ArgumentError, /budget must be positive/)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'rejects non-Integer types' do
|
|
125
|
+
expect { config_class.build(budget: 1.5) }
|
|
126
|
+
.to raise_error(ArgumentError, /budget expected Integer/)
|
|
127
|
+
expect { config_class.build(budget: '1024') }
|
|
128
|
+
.to raise_error(ArgumentError, /budget expected Integer/)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe 'summary enum' do
|
|
133
|
+
it 'accepts nil (default)' do
|
|
134
|
+
expect(config_class.build(summary: nil).summary).to be_nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'accepts :auto, :none, :concise, :detailed' do
|
|
138
|
+
%i[auto none concise detailed].each do |level|
|
|
139
|
+
expect(config_class.build(summary: level).summary).to eq(level)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'coerces string to symbol' do
|
|
144
|
+
expect(config_class.from_hash(summary: 'concise').summary).to eq(:concise)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'rejects invalid summary values' do
|
|
148
|
+
expect { config_class.build(summary: :verbose) }
|
|
149
|
+
.to raise_error(ArgumentError, /Invalid summary/)
|
|
150
|
+
expect { config_class.build(summary: :full) }
|
|
151
|
+
.to raise_error(ArgumentError, /Invalid summary/)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'rejects non-symbol/string types' do
|
|
155
|
+
expect { config_class.build(summary: 42) }
|
|
156
|
+
.to raise_error(ArgumentError, /summary expected Symbol/)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'keeps the effort<->budget SSOT conversions' do
|
|
161
|
+
expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
|
|
162
|
+
expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
|
|
163
|
+
expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
|
|
164
|
+
expect(config_class.build(effort: 'xhigh').resolved_budget).to eq(24_576)
|
|
165
|
+
expect(config_class.build(effort: 'max').resolved_budget).to eq(32_768)
|
|
166
|
+
expect(config_class.build(budget: 512).resolved_effort).to eq('low')
|
|
167
|
+
expect(config_class.build(budget: 1024).resolved_effort).to eq('low')
|
|
168
|
+
expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
|
|
169
|
+
expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
|
|
170
|
+
expect(config_class.build(budget: 24_576).resolved_effort).to eq('xhigh')
|
|
171
|
+
expect(config_class.build(budget: 32_768).resolved_effort).to eq('max')
|
|
172
|
+
expect(config_class.build(budget: 50_000).resolved_effort).to eq('max')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
describe 'resolved_effort band boundaries' do
|
|
176
|
+
it 'maps budget at boundary to the correct effort level' do
|
|
177
|
+
# At the low boundary
|
|
178
|
+
expect(config_class.build(budget: 1024).resolved_effort).to eq('low')
|
|
179
|
+
# Just above low
|
|
180
|
+
expect(config_class.build(budget: 1025).resolved_effort).to eq('medium')
|
|
181
|
+
# At medium boundary
|
|
182
|
+
expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
|
|
183
|
+
# Just above medium
|
|
184
|
+
expect(config_class.build(budget: 8193).resolved_effort).to eq('high')
|
|
185
|
+
# At high boundary
|
|
186
|
+
expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
|
|
187
|
+
# Just above high
|
|
188
|
+
expect(config_class.build(budget: 16_385).resolved_effort).to eq('xhigh')
|
|
189
|
+
# At xhigh boundary
|
|
190
|
+
expect(config_class.build(budget: 24_576).resolved_effort).to eq('xhigh')
|
|
191
|
+
# Just above xhigh
|
|
192
|
+
expect(config_class.build(budget: 24_577).resolved_effort).to eq('max')
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'returns nil when budget is nil (no fabrication)' do
|
|
196
|
+
expect(config_class.build.resolved_effort).to be_nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'returns the supplied effort when both are set (no overwrite)' do
|
|
200
|
+
config = config_class.build(effort: 'low', budget: 30_000)
|
|
201
|
+
expect(config.resolved_effort).to eq('low')
|
|
202
|
+
expect(config.resolved_budget).to eq(30_000)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
it 'to_h is faithful to what was set (no fabricated axis)' do
|
|
207
|
+
expect(config_class.build(effort: 'low').to_h).to eq(enabled: true, effort: 'low', metadata: {})
|
|
208
|
+
# enabled:false is explicit OFF signal — it IS set, so it appears in to_h
|
|
209
|
+
expect(config_class.build(enabled: false).to_h).to eq(enabled: false, metadata: {})
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it 'to_h omits nil members (compact)' do
|
|
213
|
+
config = config_class.build(enabled: true, effort: 'high', budget: 4096, summary: :detailed)
|
|
214
|
+
expect(config.to_h).to eq(enabled: true, effort: 'high', budget: 4096, summary: :detailed, metadata: {})
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'from_hash round-trips through to_h' do
|
|
218
|
+
original = config_class.build(enabled: true, effort: 'xhigh', budget: 24_576, summary: :auto)
|
|
219
|
+
rebuilt = config_class.from_hash(original.to_h)
|
|
220
|
+
expect(rebuilt).to eq(original)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -38,72 +38,4 @@ RSpec.describe Legion::Extensions::Llm::Canonical::Thinking do
|
|
|
38
38
|
expect(round_tripped.signature).to eq('sig-1')
|
|
39
39
|
end
|
|
40
40
|
end
|
|
41
|
-
|
|
42
|
-
describe Legion::Extensions::Llm::Canonical::Thinking::Config do
|
|
43
|
-
subject(:config_class) { described_class }
|
|
44
|
-
|
|
45
|
-
let(:type_class) { described_class }
|
|
46
|
-
let(:auto_generated_members) { [] }
|
|
47
|
-
let(:type_source) do
|
|
48
|
-
{ effort: 'high', budget: 4096, metadata: { source: 'client' } }
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
it_behaves_like 'a canonical type'
|
|
52
|
-
|
|
53
|
-
it 'converts from a plain class to a Data with one name (04 §8)' do
|
|
54
|
-
expect(config_class).to be_a(Class)
|
|
55
|
-
expect(config_class.new(effort: 'low', budget: nil, metadata: {})).to be_a(config_class)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
it 'normalizes symbol effort to String' do
|
|
59
|
-
config = config_class.build(effort: :low)
|
|
60
|
-
expect(config.effort).to eq('low')
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
it 'raises on a wrong-class effort' do
|
|
64
|
-
expect { config_class.build(effort: 3) }
|
|
65
|
-
.to raise_error(ArgumentError, /effort expected String, got Integer/)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
describe 'H1/M4 — effort is a closed enum, validated in every constructor' do
|
|
69
|
-
it 'rejects an unrecognized effort in build, from_hash, and .new' do
|
|
70
|
-
expect { config_class.build(effort: 'banana') }
|
|
71
|
-
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
72
|
-
expect { config_class.from_hash(effort: 'banana') }
|
|
73
|
-
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
74
|
-
expect { config_class.new(effort: 'banana') }
|
|
75
|
-
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
it 'accepts the enum case-insensitively (symbol or string)' do
|
|
79
|
-
expect(config_class.build(effort: :HIGH).effort).to eq('high')
|
|
80
|
-
expect(config_class.new(effort: 'Medium').effort).to eq('medium')
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
it 'resolved_budget derives from the validated effort (no silent medium fallback)' do
|
|
84
|
-
expect(config_class.new(effort: 'low').resolved_budget).to eq(1024)
|
|
85
|
-
expect(config_class.build(budget: 123).resolved_budget).to eq(123)
|
|
86
|
-
expect(config_class.build.resolved_budget).to be_nil
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
it 'keeps the effort<->budget SSOT conversions' do
|
|
91
|
-
expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
|
|
92
|
-
expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
|
|
93
|
-
expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
|
|
94
|
-
expect(config_class.build(budget: 512).resolved_effort).to eq('low')
|
|
95
|
-
expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
|
|
96
|
-
expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
it 'is enabled only when an axis is set' do
|
|
100
|
-
expect(config_class.build(effort: 'low').enabled?).to be(true)
|
|
101
|
-
expect(config_class.build(budget: 1).enabled?).to be(true)
|
|
102
|
-
expect(config_class.build.enabled?).to be(false)
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
it 'to_h is faithful to what was set (no fabricated axis)' do
|
|
106
|
-
expect(config_class.build(effort: 'low').to_h).to eq(effort: 'low', metadata: {})
|
|
107
|
-
end
|
|
108
|
-
end
|
|
109
41
|
end
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
#
|
|
11
11
|
# Usage:
|
|
12
12
|
# it_behaves_like 'a canonical client translator', MyClientTranslatorClass
|
|
13
|
-
# rubocop:disable Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
|
|
13
|
+
# rubocop:disable-next Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
|
|
14
14
|
RSpec.shared_examples 'a canonical client translator' do |translator_class|
|
|
15
15
|
let(:translator) { translator_class.new }
|
|
16
16
|
let(:canonical) { Legion::Extensions::Llm::Canonical }
|
|
@@ -429,4 +429,3 @@ RSpec.shared_examples 'a canonical client translator' do |translator_class|
|
|
|
429
429
|
end
|
|
430
430
|
end
|
|
431
431
|
end
|
|
432
|
-
# rubocop:enable Lint/NonLocalExitFromIterator
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
"params": { "max_tokens": 1024, "temperature": 0.5 },
|
|
24
|
-
"thinking": { "effort": "low", "budget": 1024 },
|
|
24
|
+
"thinking": { "enabled": true, "effort": "low", "budget": 1024 },
|
|
25
25
|
"stream": false,
|
|
26
26
|
"conversation_id": "conv_fleet_rt_001",
|
|
27
27
|
"caller": { "type": "anthropic_messages", "client_id": "test_client" },
|
|
@@ -9,7 +9,7 @@ require_relative 'ssot_contract_examples'
|
|
|
9
9
|
# Kit self-test: the B/F/R shared examples run against a real Provider-derived
|
|
10
10
|
# callable, the real Registry, and the real fleet responder/worker — proving
|
|
11
11
|
# the examples provider gems will load are executable and meaningful.
|
|
12
|
-
# rubocop:disable RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers -- kit host
|
|
12
|
+
# rubocop:disable-next RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers -- kit host
|
|
13
13
|
RSpec.describe 'SSOT v4 contract conformance kit' do
|
|
14
14
|
include SsotRegistryHelpers
|
|
15
15
|
|
|
@@ -34,7 +34,7 @@ RSpec.describe 'SSOT v4 contract conformance kit' do
|
|
|
34
34
|
Legion::Extensions::Llm::Canonical::Response.build(text: 'ok', model: model, stop_reason: :end_turn)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
# rubocop:disable Lint/UnusedMethodArgument -- model: mirrors the 0.8.0 callable contract
|
|
37
|
+
# rubocop:disable-next Lint/UnusedMethodArgument -- model: mirrors the 0.8.0 callable contract
|
|
38
38
|
def stream_chat(messages, model: nil, tools: nil, **_contract, &block)
|
|
39
39
|
enforce_canonical_messages!(messages)
|
|
40
40
|
enforce_canonical_tools!(tools)
|
|
@@ -43,7 +43,6 @@ RSpec.describe 'SSOT v4 contract conformance kit' do
|
|
|
43
43
|
usage: Legion::Extensions::Llm::Canonical::Usage.build(input_tokens: 1)))
|
|
44
44
|
nil
|
|
45
45
|
end
|
|
46
|
-
# rubocop:enable Lint/UnusedMethodArgument
|
|
47
46
|
|
|
48
47
|
def count_tokens(messages:, **_contract)
|
|
49
48
|
enforce_canonical_messages!(messages)
|
|
@@ -127,4 +126,3 @@ RSpec.describe 'SSOT v4 contract conformance kit' do
|
|
|
127
126
|
it_behaves_like 'R4 — weight law (07 §4)'
|
|
128
127
|
it_behaves_like 'R5 — callable lifecycle (07 §6)'
|
|
129
128
|
end
|
|
130
|
-
# rubocop:enable RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers
|
|
@@ -44,10 +44,9 @@ RSpec.shared_examples 'B1 — central canonical enforcement (08 F2)' do
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
it "stream_chat rejects #{label} with a typed ArgumentError" do
|
|
47
|
-
# rubocop:disable Lint/EmptyBlock -- the block never runs: enforcement raises first
|
|
47
|
+
# rubocop:disable-next Lint/EmptyBlock -- the block never runs: enforcement raises first
|
|
48
48
|
expect { callable.stream_chat(bad_messages, model: 'm') { |_c| } }
|
|
49
49
|
.to raise_error(ArgumentError, /Canonical::Message/)
|
|
50
|
-
# rubocop:enable Lint/EmptyBlock
|
|
51
50
|
end
|
|
52
51
|
|
|
53
52
|
it "count_tokens rejects #{label} with a typed ArgumentError" do
|
|
@@ -121,9 +120,8 @@ RSpec.shared_examples 'B3 — operation preservation (PR #189 defect class)' do
|
|
|
121
120
|
block&.call(CAN::Chunk.done(request_id: nil))
|
|
122
121
|
end
|
|
123
122
|
|
|
124
|
-
# rubocop:disable Lint/EmptyBlock -- the block is the assertion target
|
|
123
|
+
# rubocop:disable-next Lint/EmptyBlock -- the block is the assertion target
|
|
125
124
|
callable.stream_chat([CAN::Message.build(role: :user, content: 'hi')], model: 'm') { |_c| }
|
|
126
|
-
# rubocop:enable Lint/EmptyBlock
|
|
127
125
|
|
|
128
126
|
expect(stream_invocations).to eq(1)
|
|
129
127
|
expect(chat_invocations).to eq(0)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'faraday/net_http'
|
|
5
|
+
require_relative '../../../support/tls_endpoint'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Llm::Connection do
|
|
8
|
+
around do |example|
|
|
9
|
+
saved = Legion::Settings.loader.settings[:extensions]
|
|
10
|
+
Legion::Settings.loader.settings[:extensions] = { llm: Legion::Extensions::Llm.default_settings }
|
|
11
|
+
example.run
|
|
12
|
+
ensure
|
|
13
|
+
if saved.nil?
|
|
14
|
+
Legion::Settings.loader.settings.delete(:extensions)
|
|
15
|
+
else
|
|
16
|
+
Legion::Settings.loader.settings[:extensions] = saved
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
{ typhoeus: Faraday::ConnectionFailed, net_http: Faraday::SSLError }.each do |adapter, error_class|
|
|
21
|
+
context "with the #{adapter} adapter" do
|
|
22
|
+
let(:endpoint) { SpecSupport::TlsEndpoint.new }
|
|
23
|
+
let(:config) do
|
|
24
|
+
Legion::Extensions::Llm::Configuration.new.tap do |configuration|
|
|
25
|
+
configuration.logger = Logger.new(File::NULL)
|
|
26
|
+
configuration.max_retries = 0
|
|
27
|
+
configuration.request_timeout = 3
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
let(:provider) do
|
|
31
|
+
instance_double(Legion::Extensions::Llm::Provider,
|
|
32
|
+
api_base: endpoint.url,
|
|
33
|
+
headers: { 'Authorization' => 'Bearer tls-fixture' },
|
|
34
|
+
parse_error: nil)
|
|
35
|
+
end
|
|
36
|
+
let(:connection) do
|
|
37
|
+
described_class.new(provider, config).tap do |client|
|
|
38
|
+
client.connection.builder.adapter(adapter)
|
|
39
|
+
client.connection.proxy = nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
after do
|
|
44
|
+
connection.close
|
|
45
|
+
endpoint.close
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
context 'with the default disabled verification' do
|
|
49
|
+
let(:endpoint) { SpecSupport::TlsEndpoint.new(subject_alt_name: 'DNS:wrong.example.test') }
|
|
50
|
+
|
|
51
|
+
it 'allows an authenticated request despite an untrusted and mismatched certificate' do
|
|
52
|
+
response = connection.get('/tls-check')
|
|
53
|
+
endpoint.finish
|
|
54
|
+
|
|
55
|
+
expect(response.status).to eq(200)
|
|
56
|
+
expect(response.body).to eq('status' => 'ok')
|
|
57
|
+
expect(endpoint.requests.one? { |request| request.match?(/^authorization: Bearer tls-fixture\r?$/i) }).to be(true)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
context 'when TLS verification is enabled in Legion settings' do
|
|
62
|
+
before do
|
|
63
|
+
Legion::Settings.loader.settings[:extensions][:llm][:ssl][:verify] = true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'rejects an untrusted certificate before sending an authenticated HTTP request' do
|
|
67
|
+
expect { connection.get('/tls-check') }.to raise_error(error_class)
|
|
68
|
+
endpoint.finish
|
|
69
|
+
|
|
70
|
+
expect(endpoint.requests).to be_empty
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'accepts a matching certificate through an explicitly trusted custom CA' do
|
|
74
|
+
connection.connection.ssl.ca_file = endpoint.ca_file.path
|
|
75
|
+
|
|
76
|
+
response = connection.get('/tls-check')
|
|
77
|
+
endpoint.finish
|
|
78
|
+
|
|
79
|
+
expect(response.status).to eq(200)
|
|
80
|
+
expect(response.body).to eq('status' => 'ok')
|
|
81
|
+
expect(endpoint.requests.one? { |request| request.match?(/^authorization: Bearer tls-fixture\r?$/i) }).to be(true)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
context 'when TLS verification is enabled for a trusted wrong-host certificate' do
|
|
86
|
+
let(:endpoint) { SpecSupport::TlsEndpoint.new(subject_alt_name: 'DNS:wrong.example.test') }
|
|
87
|
+
|
|
88
|
+
before do
|
|
89
|
+
Legion::Settings.loader.settings[:extensions][:llm][:ssl][:verify] = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'rejects the peer before sending an authenticated HTTP request' do
|
|
93
|
+
connection.connection.ssl.ca_file = endpoint.ca_file.path
|
|
94
|
+
|
|
95
|
+
expect { connection.get('/tls-check') }.to raise_error(error_class)
|
|
96
|
+
endpoint.finish
|
|
97
|
+
|
|
98
|
+
expect(endpoint.requests).to be_empty
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -247,7 +247,12 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
|
|
|
247
247
|
end
|
|
248
248
|
|
|
249
249
|
it 'returns false when port is closed' do
|
|
250
|
-
|
|
250
|
+
socket = Socket.new(:INET, :STREAM)
|
|
251
|
+
socket.bind(Socket.sockaddr_in(0, '127.0.0.1'))
|
|
252
|
+
|
|
253
|
+
expect(mod.socket_open?('127.0.0.1', socket.local_address.ip_port, timeout: 0.05)).to be false
|
|
254
|
+
ensure
|
|
255
|
+
socket&.close
|
|
251
256
|
end
|
|
252
257
|
|
|
253
258
|
it 'returns false when connection is refused' do
|
|
@@ -260,12 +265,16 @@ RSpec.describe Legion::Extensions::Llm::CredentialSources do
|
|
|
260
265
|
expect(mod.socket_open?('192.0.2.1', 80, timeout: 0.05)).to be false
|
|
261
266
|
end
|
|
262
267
|
|
|
263
|
-
it '
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
it 'returns false when an asynchronous connection completes with an error' do
|
|
269
|
+
socket_error = Socket::Option.int(Socket::AF_INET, Socket::SOL_SOCKET, Socket::SO_ERROR, Errno::ECONNREFUSED::Errno)
|
|
270
|
+
fake_sock = instance_double(Socket, setsockopt: nil, wait_writable: true, getsockopt: socket_error, close: nil)
|
|
271
|
+
allow(Socket).to receive(:new).and_return(fake_sock)
|
|
272
|
+
allow(fake_sock).to receive(:connect_nonblock).and_invoke(
|
|
273
|
+
->(*) { raise IO::EINPROGRESSWaitWritable },
|
|
274
|
+
->(*) { 0 }
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
expect(mod.socket_open?('192.0.2.1', 80, timeout: 0.05)).to be false
|
|
269
278
|
end
|
|
270
279
|
end
|
|
271
280
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
|
-
# rubocop:disable RSpec/DescribeClass
|
|
5
|
+
# rubocop:disable-next RSpec/DescribeClass
|
|
6
6
|
RSpec.describe 'LLM fleet message envelopes' do
|
|
7
7
|
let(:protocol) { Legion::Extensions::Llm::Fleet::Protocol }
|
|
8
8
|
let(:channel_class) do
|
|
@@ -408,4 +408,3 @@ RSpec.describe 'LLM fleet message envelopes' do
|
|
|
408
408
|
end
|
|
409
409
|
end
|
|
410
410
|
end
|
|
411
|
-
# rubocop:enable RSpec/DescribeClass
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
|
-
# rubocop:disable RSpec/DescribeClass
|
|
5
|
+
# rubocop:disable-next RSpec/DescribeClass
|
|
6
6
|
RSpec.describe 'lex-llm.gemspec' do
|
|
7
7
|
subject(:gemspec) { Gem::Specification.load(File.expand_path('../../../../lex-llm.gemspec', __dir__)) }
|
|
8
8
|
|
|
@@ -22,4 +22,3 @@ RSpec.describe 'lex-llm.gemspec' do
|
|
|
22
22
|
expect(gemspec.name).to eq('lex-llm')
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
-
# rubocop:enable RSpec/DescribeClass
|
|
@@ -49,6 +49,29 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
49
49
|
expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</thinking>\n\nHello" }])
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
describe 'reasoning_effort transposition (canonical Thinking::Config -> OpenAI wire)' do
|
|
53
|
+
def payload_for(thinking)
|
|
54
|
+
provider.send(:render_payload, [], tools: {}, model: model_id, stream: false,
|
|
55
|
+
schema: nil, thinking: thinking, tool_prefs: nil, params: nil)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'passes an explicit effort through' do
|
|
59
|
+
expect(payload_for(canonical::Thinking::Config.build(effort: 'high'))[:reasoning_effort]).to eq('high')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'derives effort from a budget-only config so it is never dropped' do
|
|
63
|
+
expect(payload_for(canonical::Thinking::Config.build(budget: 8192))[:reasoning_effort]).to eq('medium')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'omits reasoning_effort when thinking is explicitly disabled' do
|
|
67
|
+
expect(payload_for(canonical::Thinking::Config.build(enabled: false))).not_to have_key(:reasoning_effort)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'omits reasoning_effort when there is no thinking' do
|
|
71
|
+
expect(payload_for(nil)).not_to have_key(:reasoning_effort)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
52
75
|
it 'renders content block arrays in the OpenAI wire shape' do
|
|
53
76
|
message = canonical::Message.build(
|
|
54
77
|
role: :user,
|
|
@@ -201,9 +201,8 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
201
201
|
it 'accepts Hash<name, Canonical::ToolDefinition> through chat and stream_chat' do
|
|
202
202
|
expect(provider.chat([], model: 'test-model', tools: { get_weather: tool })).to eq('ok')
|
|
203
203
|
expect do
|
|
204
|
-
# rubocop:disable Lint/EmptyBlock -- the streamed chunk is consumed by the stub
|
|
204
|
+
# rubocop:disable-next Lint/EmptyBlock -- the streamed chunk is consumed by the stub
|
|
205
205
|
provider.stream_chat([], model: 'test-model', tools: { get_weather: tool }) { |_c| }
|
|
206
|
-
# rubocop:enable Lint/EmptyBlock
|
|
207
206
|
end.not_to raise_error
|
|
208
207
|
end
|
|
209
208
|
|