lex-llm 0.4.16 → 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.
- checksums.yaml +4 -4
- data/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +33 -0
- data/README.md +349 -153
- data/lex-llm.gemspec +3 -3
- data/lib/legion/extensions/llm/attachment.rb +1 -1
- data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
- data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
- data/lib/legion/extensions/llm/canonical/message.rb +125 -0
- data/lib/legion/extensions/llm/canonical/params.rb +61 -0
- data/lib/legion/extensions/llm/canonical/request.rb +117 -0
- data/lib/legion/extensions/llm/canonical/response.rb +124 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
- data/lib/legion/extensions/llm/canonical.rb +49 -0
- data/lib/legion/extensions/llm/chat.rb +3 -5
- data/lib/legion/extensions/llm/connection.rb +14 -2
- data/lib/legion/extensions/llm/error.rb +3 -7
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
- data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
- data/lib/legion/extensions/llm/model/info.rb +4 -6
- data/lib/legion/extensions/llm/models.rb +3 -3
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +12 -4
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
- data/lib/legion/extensions/llm/streaming.rb +6 -4
- data/lib/legion/extensions/llm/tool.rb +1 -3
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +118 -35
- data/spec/fixtures/ruby.mp3 +0 -0
- data/spec/fixtures/ruby.mp4 +0 -0
- data/spec/fixtures/ruby.png +0 -0
- data/spec/fixtures/ruby.txt +1 -0
- data/spec/fixtures/ruby.wav +0 -0
- data/spec/fixtures/ruby.xml +1 -0
- data/spec/fixtures/sample.pdf +0 -0
- data/spec/legion/extensions/llm/agent_spec.rb +179 -0
- data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
- data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
- data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
- data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
- data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
- data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
- data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
- data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
- data/spec/legion/extensions/llm/context_spec.rb +127 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
- data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
- data/spec/legion/extensions/llm/error_spec.rb +87 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
- data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
- data/spec/legion/extensions/llm/message_spec.rb +64 -0
- data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
- data/spec/legion/extensions/llm/models_spec.rb +104 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
- data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
- data/spec/legion/extensions/llm/provider_spec.rb +592 -0
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
- data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
- data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
- data/spec/legion/extensions/llm/tool_spec.rb +94 -0
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
- data/spec/legion/extensions/llm/utils_spec.rb +113 -0
- data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
- data/spec/legion/extensions/llm_extension_spec.rb +78 -0
- data/spec/legion/extensions/llm_root_spec.rb +51 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/support/fake_llm_provider.rb +148 -0
- data/spec/support/llm_configuration.rb +21 -0
- data/spec/support/rspec_configuration.rb +19 -0
- data/spec/support/simplecov_configuration.rb +20 -0
- metadata +110 -15
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'tmpdir'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'base64'
|
|
7
|
+
|
|
8
|
+
RSpec.describe Legion::Extensions::Llm::CredentialSources do
|
|
9
|
+
let(:mod) { described_class }
|
|
10
|
+
|
|
11
|
+
describe '.env' do
|
|
12
|
+
it 'returns stripped value for existing env var' do
|
|
13
|
+
allow(ENV).to receive(:fetch).with('TEST_KEY_123', nil).and_return(" my_value \n")
|
|
14
|
+
expect(mod.env('TEST_KEY_123')).to eq('my_value')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'returns nil for missing env var' do
|
|
18
|
+
allow(ENV).to receive(:fetch).with('MISSING_KEY_XYZ', nil).and_return(nil)
|
|
19
|
+
expect(mod.env('MISSING_KEY_XYZ')).to be_nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'returns nil for blank env var' do
|
|
23
|
+
allow(ENV).to receive(:fetch).with('BLANK_KEY', nil).and_return(' ')
|
|
24
|
+
expect(mod.env('BLANK_KEY')).to be_nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'returns nil for empty string env var' do
|
|
28
|
+
allow(ENV).to receive(:fetch).with('EMPTY_KEY', nil).and_return('')
|
|
29
|
+
expect(mod.env('EMPTY_KEY')).to be_nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '.claude_config' do
|
|
34
|
+
around do |example|
|
|
35
|
+
described_class.instance_variable_set(:@claude_config, nil)
|
|
36
|
+
example.run
|
|
37
|
+
described_class.instance_variable_set(:@claude_config, nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'returns a hash from merged Claude settings files' do
|
|
41
|
+
Dir.mktmpdir do |dir|
|
|
42
|
+
user_path = File.join(dir, 'user_settings.json')
|
|
43
|
+
project_path = File.join(dir, 'project_settings.json')
|
|
44
|
+
|
|
45
|
+
File.write(user_path, '{"model":"claude-sonnet","env":{"ANTHROPIC_API_KEY":"sk-user"}}')
|
|
46
|
+
File.write(project_path, '{"model":"claude-opus"}')
|
|
47
|
+
|
|
48
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CLAUDE_SETTINGS', user_path)
|
|
49
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CLAUDE_PROJECT', project_path)
|
|
50
|
+
|
|
51
|
+
result = mod.claude_config
|
|
52
|
+
expect(result[:model]).to eq('claude-opus')
|
|
53
|
+
expect(result.dig(:env, :ANTHROPIC_API_KEY)).to eq('sk-user')
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'is memoized' do
|
|
58
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CLAUDE_SETTINGS', '/nonexistent/a.json')
|
|
59
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CLAUDE_PROJECT', '/nonexistent/b.json')
|
|
60
|
+
|
|
61
|
+
first = mod.claude_config
|
|
62
|
+
second = mod.claude_config
|
|
63
|
+
expect(first).to equal(second)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'returns empty hash when both files are missing' do
|
|
67
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CLAUDE_SETTINGS', '/nonexistent/a.json')
|
|
68
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CLAUDE_PROJECT', '/nonexistent/b.json')
|
|
69
|
+
|
|
70
|
+
expect(mod.claude_config).to eq({})
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe '.claude_config_value' do
|
|
75
|
+
around do |example|
|
|
76
|
+
described_class.instance_variable_set(:@claude_config, nil)
|
|
77
|
+
example.run
|
|
78
|
+
described_class.instance_variable_set(:@claude_config, nil)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'reads value by symbol key' do
|
|
82
|
+
described_class.instance_variable_set(:@claude_config, { model: 'claude-opus' })
|
|
83
|
+
expect(mod.claude_config_value(:model)).to eq('claude-opus')
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'falls back to string key' do
|
|
87
|
+
described_class.instance_variable_set(:@claude_config, { 'model' => 'claude-opus' })
|
|
88
|
+
expect(mod.claude_config_value(:model)).to eq('claude-opus')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'returns nil for missing key' do
|
|
92
|
+
described_class.instance_variable_set(:@claude_config, {})
|
|
93
|
+
expect(mod.claude_config_value(:missing)).to be_nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe '.claude_env_value' do
|
|
98
|
+
around do |example|
|
|
99
|
+
described_class.instance_variable_set(:@claude_config, nil)
|
|
100
|
+
example.run
|
|
101
|
+
described_class.instance_variable_set(:@claude_config, nil)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'reads from env hash with symbol key' do
|
|
105
|
+
described_class.instance_variable_set(:@claude_config, { env: { ANTHROPIC_API_KEY: 'sk-123' } })
|
|
106
|
+
expect(mod.claude_env_value(:ANTHROPIC_API_KEY)).to eq('sk-123')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'falls back to string key in env hash' do
|
|
110
|
+
described_class.instance_variable_set(:@claude_config, { env: { 'ANTHROPIC_API_KEY' => 'sk-456' } })
|
|
111
|
+
expect(mod.claude_env_value(:ANTHROPIC_API_KEY)).to eq('sk-456')
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'returns nil when env hash is missing' do
|
|
115
|
+
described_class.instance_variable_set(:@claude_config, {})
|
|
116
|
+
expect(mod.claude_env_value(:ANTHROPIC_API_KEY)).to be_nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'returns nil when key is not in env hash' do
|
|
120
|
+
described_class.instance_variable_set(:@claude_config, { env: { OTHER: 'val' } })
|
|
121
|
+
expect(mod.claude_env_value(:ANTHROPIC_API_KEY)).to be_nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
describe '.codex_token' do
|
|
126
|
+
let(:future_exp) { Time.now.to_i + 3600 }
|
|
127
|
+
let(:past_exp) { Time.now.to_i - 3600 }
|
|
128
|
+
|
|
129
|
+
def make_jwt(payload)
|
|
130
|
+
header = Base64.urlsafe_encode64('{"alg":"HS256"}', padding: false)
|
|
131
|
+
body = Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
|
|
132
|
+
sig = Base64.urlsafe_encode64('fakesig', padding: false)
|
|
133
|
+
"#{header}.#{body}.#{sig}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'returns bearer token when auth_mode is chatgpt and token is valid' do
|
|
137
|
+
token = make_jwt(exp: future_exp)
|
|
138
|
+
auth_json = JSON.generate(auth_mode: 'chatgpt', bearer_token: token)
|
|
139
|
+
|
|
140
|
+
Dir.mktmpdir do |dir|
|
|
141
|
+
path = File.join(dir, 'auth.json')
|
|
142
|
+
File.write(path, auth_json)
|
|
143
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', path)
|
|
144
|
+
|
|
145
|
+
expect(mod.codex_token).to eq(token)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'returns nil when auth_mode is not chatgpt' do
|
|
150
|
+
Dir.mktmpdir do |dir|
|
|
151
|
+
path = File.join(dir, 'auth.json')
|
|
152
|
+
File.write(path, JSON.generate(auth_mode: 'api_key', bearer_token: 'some-token'))
|
|
153
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', path)
|
|
154
|
+
|
|
155
|
+
expect(mod.codex_token).to be_nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
it 'returns nil when bearer_token is missing' do
|
|
160
|
+
Dir.mktmpdir do |dir|
|
|
161
|
+
path = File.join(dir, 'auth.json')
|
|
162
|
+
File.write(path, JSON.generate(auth_mode: 'chatgpt'))
|
|
163
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', path)
|
|
164
|
+
|
|
165
|
+
expect(mod.codex_token).to be_nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'returns nil when token is expired' do
|
|
170
|
+
token = make_jwt(exp: past_exp)
|
|
171
|
+
|
|
172
|
+
Dir.mktmpdir do |dir|
|
|
173
|
+
path = File.join(dir, 'auth.json')
|
|
174
|
+
File.write(path, JSON.generate(auth_mode: 'chatgpt', bearer_token: token))
|
|
175
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', path)
|
|
176
|
+
|
|
177
|
+
expect(mod.codex_token).to be_nil
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'returns nil when codex auth file is missing' do
|
|
182
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', '/nonexistent/auth.json')
|
|
183
|
+
expect(mod.codex_token).to be_nil
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe '.codex_openai_key' do
|
|
188
|
+
it 'returns the OPENAI_API_KEY from codex auth file' do
|
|
189
|
+
Dir.mktmpdir do |dir|
|
|
190
|
+
path = File.join(dir, 'auth.json')
|
|
191
|
+
File.write(path, JSON.generate(OPENAI_API_KEY: " sk-proj-abc123 \n"))
|
|
192
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', path)
|
|
193
|
+
|
|
194
|
+
expect(mod.codex_openai_key).to eq('sk-proj-abc123')
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it 'returns nil when key is missing' do
|
|
199
|
+
Dir.mktmpdir do |dir|
|
|
200
|
+
path = File.join(dir, 'auth.json')
|
|
201
|
+
File.write(path, '{}')
|
|
202
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', path)
|
|
203
|
+
|
|
204
|
+
expect(mod.codex_openai_key).to be_nil
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
it 'returns nil when value is blank' do
|
|
209
|
+
Dir.mktmpdir do |dir|
|
|
210
|
+
path = File.join(dir, 'auth.json')
|
|
211
|
+
File.write(path, JSON.generate(OPENAI_API_KEY: ' '))
|
|
212
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', path)
|
|
213
|
+
|
|
214
|
+
expect(mod.codex_openai_key).to be_nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'returns nil when file is missing' do
|
|
219
|
+
stub_const('Legion::Extensions::Llm::CredentialSources::CODEX_AUTH', '/nonexistent/auth.json')
|
|
220
|
+
expect(mod.codex_openai_key).to be_nil
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
describe '.setting' do
|
|
225
|
+
it 'digs into Legion::Settings when defined' do
|
|
226
|
+
Legion::Settings.merge_settings(:llm, { provider: 'anthropic' })
|
|
227
|
+
expect(mod.setting(:llm, :provider)).to eq('anthropic')
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
it 'returns nil for missing paths' do
|
|
231
|
+
expect(mod.setting(:nonexistent, :deep, :path)).to be_nil
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'returns nil when dig raises an error' do
|
|
235
|
+
allow(Legion::Settings).to receive(:dig).and_raise(NoMethodError, 'undefined method')
|
|
236
|
+
expect(mod.setting(:llm, :provider)).to be_nil
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
describe '.socket_open?' do
|
|
241
|
+
it 'returns true when port is open' do
|
|
242
|
+
server = TCPServer.new('127.0.0.1', 0)
|
|
243
|
+
port = server.addr[1]
|
|
244
|
+
expect(mod.socket_open?('127.0.0.1', port)).to be true
|
|
245
|
+
ensure
|
|
246
|
+
server&.close
|
|
247
|
+
end
|
|
248
|
+
|
|
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
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it 'returns false when connection is refused' do
|
|
254
|
+
fake_sock = instance_double(Socket)
|
|
255
|
+
allow(Socket).to receive(:new).and_return(fake_sock)
|
|
256
|
+
allow(fake_sock).to receive(:setsockopt)
|
|
257
|
+
allow(fake_sock).to receive(:connect_nonblock).and_raise(Errno::ECONNREFUSED)
|
|
258
|
+
allow(fake_sock).to receive(:close)
|
|
259
|
+
|
|
260
|
+
expect(mod.socket_open?('192.0.2.1', 80, timeout: 0.05)).to be false
|
|
261
|
+
end
|
|
262
|
+
|
|
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
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
describe '.http_ok?' do
|
|
273
|
+
it 'returns true for a successful HTTP response' do
|
|
274
|
+
fake_response = instance_double(Faraday::Response, status: 200)
|
|
275
|
+
fake_conn = instance_double(Faraday::Connection, get: fake_response, close: nil)
|
|
276
|
+
allow(Faraday).to receive(:new).and_return(fake_conn)
|
|
277
|
+
|
|
278
|
+
expect(mod.http_ok?('http://localhost:8080', path: '/health')).to be true
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
it 'returns false for a non-200 HTTP response' do
|
|
282
|
+
fake_response = instance_double(Faraday::Response, status: 503)
|
|
283
|
+
fake_conn = instance_double(Faraday::Connection, get: fake_response, close: nil)
|
|
284
|
+
allow(Faraday).to receive(:new).and_return(fake_conn)
|
|
285
|
+
|
|
286
|
+
expect(mod.http_ok?('http://localhost:8080', path: '/health')).to be false
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
it 'returns false on connection error' do
|
|
290
|
+
allow(Faraday).to receive(:new).and_raise(Faraday::ConnectionFailed.new('refused'))
|
|
291
|
+
|
|
292
|
+
expect(mod.http_ok?('http://localhost:9999', path: '/')).to be false
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
it 'returns false on timeout' do
|
|
296
|
+
allow(Faraday).to receive(:new).and_raise(Faraday::TimeoutError)
|
|
297
|
+
|
|
298
|
+
expect(mod.http_ok?('http://localhost:9999', path: '/')).to be false
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
describe '.dedup_credentials' do
|
|
303
|
+
it 'deduplicates by api_key' do
|
|
304
|
+
candidates = {
|
|
305
|
+
source_a: { api_key: 'sk-same', base_url: 'http://a' },
|
|
306
|
+
source_b: { api_key: 'sk-same', base_url: 'http://b' },
|
|
307
|
+
source_c: { api_key: 'sk-diff', base_url: 'http://c' }
|
|
308
|
+
}
|
|
309
|
+
result = mod.dedup_credentials(candidates)
|
|
310
|
+
expect(result.keys).to contain_exactly(:source_a, :source_c)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it 'deduplicates by bearer_token' do
|
|
314
|
+
candidates = {
|
|
315
|
+
source_a: { bearer_token: 'tok-1' },
|
|
316
|
+
source_b: { bearer_token: 'tok-1' }
|
|
317
|
+
}
|
|
318
|
+
result = mod.dedup_credentials(candidates)
|
|
319
|
+
expect(result.keys).to contain_exactly(:source_a)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it 'keeps entries without credential values' do
|
|
323
|
+
candidates = {
|
|
324
|
+
source_a: { base_url: 'http://a' },
|
|
325
|
+
source_b: { base_url: 'http://b' }
|
|
326
|
+
}
|
|
327
|
+
result = mod.dedup_credentials(candidates)
|
|
328
|
+
expect(result.keys).to contain_exactly(:source_a, :source_b)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'first source wins on duplicates' do
|
|
332
|
+
candidates = {
|
|
333
|
+
first: { api_key: 'sk-same', note: 'winner' },
|
|
334
|
+
second: { api_key: 'sk-same', note: 'loser' }
|
|
335
|
+
}
|
|
336
|
+
result = mod.dedup_credentials(candidates)
|
|
337
|
+
expect(result[:first][:note]).to eq('winner')
|
|
338
|
+
expect(result).not_to have_key(:second)
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
describe '.credential_hash' do
|
|
343
|
+
it 'returns SHA-256 hex of api_key' do
|
|
344
|
+
config = { api_key: 'sk-test123' }
|
|
345
|
+
expected = Digest::SHA256.hexdigest('sk-test123')
|
|
346
|
+
expect(mod.credential_hash(config)).to eq(expected)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
it 'returns SHA-256 hex of bearer_token when no api_key' do
|
|
350
|
+
config = { bearer_token: 'tok-abc' }
|
|
351
|
+
expected = Digest::SHA256.hexdigest('tok-abc')
|
|
352
|
+
expect(mod.credential_hash(config)).to eq(expected)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
it 'returns SHA-256 hex of access_token as last resort' do
|
|
356
|
+
config = { access_token: 'at-xyz' }
|
|
357
|
+
expected = Digest::SHA256.hexdigest('at-xyz')
|
|
358
|
+
expect(mod.credential_hash(config)).to eq(expected)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
it 'prefers api_key over bearer_token' do
|
|
362
|
+
config = { api_key: 'sk-key', bearer_token: 'tok-bear' }
|
|
363
|
+
expected = Digest::SHA256.hexdigest('sk-key')
|
|
364
|
+
expect(mod.credential_hash(config)).to eq(expected)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
it 'returns nil when no credential fields present' do
|
|
368
|
+
config = { base_url: 'http://example.com' }
|
|
369
|
+
expect(mod.credential_hash(config)).to be_nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it 'returns nil for empty config' do
|
|
373
|
+
expect(mod.credential_hash({})).to be_nil
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
describe '.localhost?' do
|
|
378
|
+
it 'returns true for localhost' do
|
|
379
|
+
expect(mod.localhost?('http://localhost:8080')).to be true
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
it 'returns true for 127.0.0.1' do
|
|
383
|
+
expect(mod.localhost?('http://127.0.0.1:3000/api')).to be true
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it 'returns true for ::1' do
|
|
387
|
+
expect(mod.localhost?('http://[::1]:8080')).to be true
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
it 'returns false for remote host' do
|
|
391
|
+
expect(mod.localhost?('https://api.example.com')).to be false
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
it 'returns false for nil' do
|
|
395
|
+
expect(mod.localhost?(nil)).to be false
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
it 'returns false for malformed URL' do
|
|
399
|
+
expect(mod.localhost?('not a url at all')).to be false
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
describe '.read_json (private)' do
|
|
404
|
+
it 'parses a valid JSON file' do
|
|
405
|
+
Dir.mktmpdir do |dir|
|
|
406
|
+
path = File.join(dir, 'test.json')
|
|
407
|
+
File.write(path, '{"key": "value"}')
|
|
408
|
+
result = mod.send(:read_json, path)
|
|
409
|
+
expect(result[:key]).to eq('value')
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
it 'returns empty hash for missing file' do
|
|
414
|
+
result = mod.send(:read_json, '/nonexistent/file.json')
|
|
415
|
+
expect(result).to eq({})
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
it 'returns empty hash for invalid JSON' do
|
|
419
|
+
Dir.mktmpdir do |dir|
|
|
420
|
+
path = File.join(dir, 'bad.json')
|
|
421
|
+
File.write(path, 'not json{{{')
|
|
422
|
+
result = mod.send(:read_json, path)
|
|
423
|
+
expect(result).to eq({})
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
it 'returns empty hash for empty file' do
|
|
428
|
+
Dir.mktmpdir do |dir|
|
|
429
|
+
path = File.join(dir, 'empty.json')
|
|
430
|
+
File.write(path, '')
|
|
431
|
+
result = mod.send(:read_json, path)
|
|
432
|
+
expect(result).to eq({})
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
describe '.token_valid? (private)' do
|
|
438
|
+
def make_jwt(payload)
|
|
439
|
+
header = Base64.urlsafe_encode64('{"alg":"HS256"}', padding: false)
|
|
440
|
+
body = Base64.urlsafe_encode64(JSON.generate(payload), padding: false)
|
|
441
|
+
sig = Base64.urlsafe_encode64('fakesig', padding: false)
|
|
442
|
+
"#{header}.#{body}.#{sig}"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
it 'returns true for non-expired token' do
|
|
446
|
+
token = make_jwt(exp: Time.now.to_i + 3600)
|
|
447
|
+
expect(mod.send(:token_valid?, token)).to be true
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
it 'returns false for expired token' do
|
|
451
|
+
token = make_jwt(exp: Time.now.to_i - 3600)
|
|
452
|
+
expect(mod.send(:token_valid?, token)).to be false
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
it 'returns true when token has no exp claim' do
|
|
456
|
+
token = make_jwt(sub: 'user')
|
|
457
|
+
expect(mod.send(:token_valid?, token)).to be true
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
it 'returns true on parse error (malformed token)' do
|
|
461
|
+
expect(mod.send(:token_valid?, 'not.a.jwt')).to be true
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
it 'returns true for nil token' do
|
|
465
|
+
expect(mod.send(:token_valid?, nil)).to be true
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::ErrorMiddleware do
|
|
6
|
+
describe '.parse_error' do
|
|
7
|
+
let(:provider) { instance_double(Legion::Extensions::Llm::Provider, parse_error: 'provider error') }
|
|
8
|
+
|
|
9
|
+
let(:stream_response) do
|
|
10
|
+
Struct.new(:status, :body) do
|
|
11
|
+
def [](key)
|
|
12
|
+
custom[key]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def []=(key, value)
|
|
16
|
+
custom[key] = value
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def custom
|
|
22
|
+
@custom ||= {}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'maps 502 to ServiceUnavailableError' do
|
|
28
|
+
response = Struct.new(:status, :body).new(502, '{"error":{"message":"down"}}')
|
|
29
|
+
|
|
30
|
+
expect do
|
|
31
|
+
described_class.parse_error(provider: provider, response: response)
|
|
32
|
+
end.to raise_error(Legion::Extensions::Llm::ServiceUnavailableError)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'maps 503 to ServiceUnavailableError' do
|
|
36
|
+
response = Struct.new(:status, :body).new(503, '{"error":{"message":"down"}}')
|
|
37
|
+
|
|
38
|
+
expect do
|
|
39
|
+
described_class.parse_error(provider: provider, response: response)
|
|
40
|
+
end.to raise_error(Legion::Extensions::Llm::ServiceUnavailableError)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'maps 504 to ServiceUnavailableError' do
|
|
44
|
+
response = Struct.new(:status, :body).new(504, '{"error":{"message":"timeout"}}')
|
|
45
|
+
|
|
46
|
+
expect do
|
|
47
|
+
described_class.parse_error(provider: provider, response: response)
|
|
48
|
+
end.to raise_error(Legion::Extensions::Llm::ServiceUnavailableError)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'maps context-length-like 429 errors to ContextLengthExceededError' do
|
|
52
|
+
response = Struct.new(:status, :body).new(429, '{"error":{"message":"Request too large for model"}}')
|
|
53
|
+
provider = instance_double(Legion::Extensions::Llm::Provider, parse_error: 'Request too large for model')
|
|
54
|
+
|
|
55
|
+
expect do
|
|
56
|
+
described_class.parse_error(provider: provider, response: response)
|
|
57
|
+
end.to raise_error(Legion::Extensions::Llm::ContextLengthExceededError)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'keeps regular 429 errors as RateLimitError' do
|
|
61
|
+
response = Struct.new(:status, :body).new(429, '{"error":{"message":"Rate limit exceeded"}}')
|
|
62
|
+
provider = instance_double(Legion::Extensions::Llm::Provider, parse_error: 'Rate limit exceeded')
|
|
63
|
+
|
|
64
|
+
expect do
|
|
65
|
+
described_class.parse_error(provider: provider, response: response)
|
|
66
|
+
end.to raise_error(Legion::Extensions::Llm::RateLimitError)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'maps context-length-like 400 errors to ContextLengthExceededError' do
|
|
70
|
+
msg = "This model's maximum context length is 8192 tokens."
|
|
71
|
+
response = Struct.new(:status, :body).new(400, %({"error":{"message":"#{msg}"}}))
|
|
72
|
+
provider = instance_double(Legion::Extensions::Llm::Provider, parse_error: msg)
|
|
73
|
+
|
|
74
|
+
expect do
|
|
75
|
+
described_class.parse_error(provider: provider, response: response)
|
|
76
|
+
end.to raise_error(Legion::Extensions::Llm::ContextLengthExceededError)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'keeps regular 400 errors as BadRequestError' do
|
|
80
|
+
response = Struct.new(:status, :body).new(400, '{"error":{"message":"Invalid model specified"}}')
|
|
81
|
+
provider = instance_double(Legion::Extensions::Llm::Provider, parse_error: 'Invalid model specified')
|
|
82
|
+
|
|
83
|
+
expect do
|
|
84
|
+
described_class.parse_error(provider: provider, response: response)
|
|
85
|
+
end.to raise_error(Legion::Extensions::Llm::BadRequestError)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'uses preserved streaming error body when Faraday finalizes an empty response body' do
|
|
89
|
+
response = stream_response.new(500, '')
|
|
90
|
+
response[described_class::STREAM_ERROR_BODY_KEY] =
|
|
91
|
+
'{"error":{"message":"The model rejected chat_template_kwargs"}}'
|
|
92
|
+
provider = instance_double(Legion::Extensions::Llm::Provider)
|
|
93
|
+
allow(provider).to receive(:parse_error) do |error_response|
|
|
94
|
+
error_response.body[/"message"\s*:\s*"([^"]+)"/, 1]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
expect do
|
|
98
|
+
described_class.parse_error(provider: provider, response: response)
|
|
99
|
+
end.to raise_error(Legion::Extensions::Llm::ServerError, /chat_template_kwargs/)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Error do
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
context 'with a string argument' do
|
|
8
|
+
it 'treats the string as the message' do
|
|
9
|
+
error = described_class.new('something went wrong')
|
|
10
|
+
expect(error.message).to eq('something went wrong')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'sets response to nil' do
|
|
14
|
+
error = described_class.new('something went wrong')
|
|
15
|
+
expect(error.response).to be_nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'does not raise NoMethodError' do
|
|
19
|
+
expect { described_class.new('something went wrong') }.not_to raise_error
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
context 'with a response object and message' do
|
|
24
|
+
let(:response) { Struct.new(:status, :body).new(500, '{"error":"server error"}') }
|
|
25
|
+
|
|
26
|
+
it 'stores the response' do
|
|
27
|
+
error = described_class.new(response, 'server error')
|
|
28
|
+
expect(error.response).to eq(response)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'uses the provided message' do
|
|
32
|
+
error = described_class.new(response, 'server error')
|
|
33
|
+
expect(error.message).to eq('server error')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
context 'with a response object only' do
|
|
38
|
+
let(:response) { Struct.new(:status, :body).new(500, 'raw body') }
|
|
39
|
+
|
|
40
|
+
it 'falls back to response body for the message' do
|
|
41
|
+
error = described_class.new(response)
|
|
42
|
+
expect(error.message).to eq('raw body')
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
context 'with no arguments' do
|
|
47
|
+
it 'works without raising' do
|
|
48
|
+
error = described_class.new
|
|
49
|
+
expect(error.response).to be_nil
|
|
50
|
+
expect(error.message).to eq('Legion::Extensions::Llm::Error')
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe 'subclasses' do
|
|
56
|
+
it 'inherits the string argument fix' do
|
|
57
|
+
error = Legion::Extensions::Llm::BadRequestError.new('bad request')
|
|
58
|
+
expect(error.message).to eq('bad request')
|
|
59
|
+
expect(error.response).to be_nil
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe Legion::Extensions::Llm::Errors::UnsupportedCapability do
|
|
64
|
+
it 'formats provider capability failures' do
|
|
65
|
+
error = described_class.new(provider: :ollama, capability: :image, model: 'llama3')
|
|
66
|
+
|
|
67
|
+
expect(error.message).to eq('Provider ollama does not support image for llama3')
|
|
68
|
+
expect(error.provider).to eq(:ollama)
|
|
69
|
+
expect(error.capability).to eq(:image)
|
|
70
|
+
expect(error.model).to eq('llama3')
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
describe Legion::Extensions::Llm::UnsupportedCapabilityError do
|
|
75
|
+
it 'keeps string message compatibility for existing callers' do
|
|
76
|
+
error = described_class.new('not supported')
|
|
77
|
+
|
|
78
|
+
expect(error.message).to eq('not supported')
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'accepts the shared keyword initializer' do
|
|
82
|
+
error = described_class.new(provider: :openai, capability: :embed)
|
|
83
|
+
|
|
84
|
+
expect(error.message).to eq('Provider openai does not support embed')
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|