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.
Files changed (117) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -2
  3. data/B1b-conformance-kit.md +79 -0
  4. data/CHANGELOG.md +33 -0
  5. data/README.md +349 -153
  6. data/lex-llm.gemspec +3 -3
  7. data/lib/legion/extensions/llm/attachment.rb +1 -1
  8. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  9. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  10. data/lib/legion/extensions/llm/canonical/message.rb +125 -0
  11. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  12. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  13. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  14. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  15. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  16. data/lib/legion/extensions/llm/canonical/tool_definition.rb +73 -0
  17. data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
  18. data/lib/legion/extensions/llm/canonical.rb +49 -0
  19. data/lib/legion/extensions/llm/chat.rb +3 -5
  20. data/lib/legion/extensions/llm/connection.rb +14 -2
  21. data/lib/legion/extensions/llm/error.rb +3 -7
  22. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  24. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  25. data/lib/legion/extensions/llm/model/info.rb +4 -6
  26. data/lib/legion/extensions/llm/models.rb +3 -3
  27. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +12 -4
  28. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  29. data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
  30. data/lib/legion/extensions/llm/streaming.rb +6 -4
  31. data/lib/legion/extensions/llm/tool.rb +1 -3
  32. data/lib/legion/extensions/llm/version.rb +1 -1
  33. data/lib/legion/extensions/llm.rb +118 -35
  34. data/spec/fixtures/ruby.mp3 +0 -0
  35. data/spec/fixtures/ruby.mp4 +0 -0
  36. data/spec/fixtures/ruby.png +0 -0
  37. data/spec/fixtures/ruby.txt +1 -0
  38. data/spec/fixtures/ruby.wav +0 -0
  39. data/spec/fixtures/ruby.xml +1 -0
  40. data/spec/fixtures/sample.pdf +0 -0
  41. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  42. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  43. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  44. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  45. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  46. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  47. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  48. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  49. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  50. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  51. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  52. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +174 -0
  53. data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
  54. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  55. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -0
  56. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  57. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  58. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  59. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  60. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  61. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  78. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  79. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  80. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  81. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  82. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  83. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  84. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  85. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  86. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  87. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  88. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  89. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  90. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  91. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  92. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  93. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  94. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  95. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  96. data/spec/legion/extensions/llm/provider_spec.rb +592 -0
  97. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  98. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  99. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  100. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  101. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  102. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  103. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  104. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +103 -0
  105. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  106. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  107. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  108. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  109. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  110. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  111. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  112. data/spec/spec_helper.rb +24 -0
  113. data/spec/support/fake_llm_provider.rb +148 -0
  114. data/spec/support/llm_configuration.rb +21 -0
  115. data/spec/support/rspec_configuration.rb +19 -0
  116. data/spec/support/simplecov_configuration.rb +20 -0
  117. 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