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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +34 -0
  4. data/lib/legion/extensions/llm/canonical/chunk.rb +1 -2
  5. data/lib/legion/extensions/llm/canonical/content_block.rb +1 -2
  6. data/lib/legion/extensions/llm/canonical/message.rb +1 -2
  7. data/lib/legion/extensions/llm/canonical/params.rb +3 -5
  8. data/lib/legion/extensions/llm/canonical/request.rb +1 -2
  9. data/lib/legion/extensions/llm/canonical/response.rb +1 -2
  10. data/lib/legion/extensions/llm/canonical/thinking.rb +2 -98
  11. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  12. data/lib/legion/extensions/llm/canonical/tool_call.rb +1 -2
  13. data/lib/legion/extensions/llm/canonical.rb +1 -0
  14. data/lib/legion/extensions/llm/connection.rb +1 -1
  15. data/lib/legion/extensions/llm/credential_sources.rb +1 -5
  16. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +7 -1
  17. data/lib/legion/extensions/llm/responses/tool_arguments.rb +19 -5
  18. data/lib/legion/extensions/llm/version.rb +1 -1
  19. data/lib/legion/extensions/llm.rb +3 -0
  20. data/spec/legion/extensions/llm/canonical/params_spec.rb +0 -2
  21. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  22. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +0 -68
  23. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  24. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  25. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  26. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +2 -4
  27. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +2 -4
  28. data/spec/legion/extensions/llm/connection_tls_spec.rb +103 -0
  29. data/spec/legion/extensions/llm/credential_sources_spec.rb +16 -7
  30. data/spec/legion/extensions/llm/fleet_messages_spec.rb +1 -2
  31. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  32. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +23 -0
  33. data/spec/legion/extensions/llm/provider_spec.rb +1 -2
  34. data/spec/support/tls_endpoint.rb +92 -0
  35. 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" },
@@ -8,12 +8,12 @@
8
8
  }
9
9
  ],
10
10
  "thinking": {
11
+ "enabled": true,
11
12
  "effort": "high",
12
13
  "budget": 4096
13
14
  },
14
15
  "params": {
15
- "max_tokens": 2048,
16
- "max_thinking_tokens": 4096
16
+ "max_tokens": 2048
17
17
  },
18
18
  "stream": false
19
19
  }
@@ -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
- expect(mod.socket_open?('127.0.0.1', 39_999, timeout: 0.05)).to be false
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 'uses 0.1 second default timeout' do
264
- server = TCPServer.new('127.0.0.1', 0)
265
- port = server.addr[1]
266
- expect(mod.socket_open?('127.0.0.1', port, timeout: 0.1)).to be true
267
- ensure
268
- server&.close
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