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,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Streaming do
6
+ let(:test_obj) do
7
+ Object.new.tap do |obj|
8
+ obj.extend(described_class)
9
+ obj.define_singleton_method(:build_chunk) { |data| "chunk:#{data['x']}" }
10
+ end
11
+ end
12
+
13
+ let(:env) { Struct.new(:status).new(200) }
14
+
15
+ before do
16
+ stub_const('Faraday::VERSION', '2.0.0')
17
+ end
18
+
19
+ it 'skips non-hash SSE payloads' do
20
+ yielded_chunks = []
21
+ handler = test_obj.send(:handle_stream) { |chunk| yielded_chunks << chunk }
22
+
23
+ expect { handler.call("data: true\n\n", 0, env) }.not_to raise_error
24
+ expect(yielded_chunks).to eq([])
25
+ end
26
+
27
+ it 'processes hash SSE payloads' do
28
+ yielded_chunks = []
29
+ handler = test_obj.send(:handle_stream) { |chunk| yielded_chunks << chunk }
30
+
31
+ handler.call("data: {\"x\":\"ok\"}\n\n", 0, env)
32
+
33
+ expect(yielded_chunks).to eq(['chunk:ok'])
34
+ end
35
+
36
+ describe '#handle_failed_response (private)' do
37
+ let(:error_env) { Struct.new(:status, :body).new(500, nil) }
38
+ let(:faraday_env) do
39
+ Struct.new(:status, :body) do
40
+ def [](key)
41
+ custom[key]
42
+ end
43
+
44
+ def []=(key, value)
45
+ custom[key] = value
46
+ end
47
+
48
+ private
49
+
50
+ def custom
51
+ @custom ||= {}
52
+ end
53
+ end.new(500, nil)
54
+ end
55
+ let(:non_mutable_env) { Struct.new(:status).new(500) }
56
+
57
+ it 'raises ServerError with extracted message when JSON is complete' do
58
+ buffer = +''
59
+ error_chunk = '{"error":{"message":"Model overloaded","code":500}}'
60
+ allow(test_obj).to receive(:handle_parsed_error) do
61
+ raise Legion::Extensions::Llm::ServerError, 'Model overloaded'
62
+ end
63
+ expect { test_obj.send(:handle_failed_response, error_chunk, buffer, error_env) }
64
+ .to raise_error(Legion::Extensions::Llm::ServerError)
65
+ end
66
+
67
+ it 'buffers partial JSON on mutable Faraday envs until the error body is complete' do
68
+ buffer = +''
69
+ first_chunk = '{"error":{"message":"The model is currently'
70
+ second_chunk = ' overloaded","code":500}}'
71
+ parsed_error = nil
72
+ allow(test_obj).to receive(:handle_parsed_error) do |data, _env|
73
+ parsed_error = data
74
+ raise Legion::Extensions::Llm::ServerError, 'The model is currently overloaded'
75
+ end
76
+
77
+ expect { test_obj.send(:handle_failed_response, first_chunk, buffer, error_env) }.not_to raise_error
78
+ expect(error_env.body).to eq(first_chunk)
79
+
80
+ expect { test_obj.send(:handle_failed_response, second_chunk, buffer, error_env) }
81
+ .to raise_error(Legion::Extensions::Llm::ServerError, /overloaded/)
82
+ expect(parsed_error.dig('error', 'message')).to eq('The model is currently overloaded')
83
+ expect(error_env.body).to eq("#{first_chunk}#{second_chunk}")
84
+ end
85
+
86
+ it 'stores partial JSON in a custom Faraday env key that final response handling will not overwrite' do
87
+ buffer = +''
88
+ first_chunk = '{"error":{"message":"The model is currently'
89
+
90
+ expect { test_obj.send(:handle_failed_response, first_chunk, buffer, faraday_env) }.not_to raise_error
91
+ expect(faraday_env[Legion::Extensions::Llm::ErrorMiddleware::STREAM_ERROR_BODY_KEY]).to eq(first_chunk)
92
+ end
93
+
94
+ it 'raises ServerError with partial message when the env cannot carry the buffered body' do
95
+ buffer = +''
96
+ truncated_chunk = '{"error":{"message":"The model is currently overloaded'
97
+ expect { test_obj.send(:handle_failed_response, truncated_chunk, buffer, non_mutable_env) }
98
+ .to raise_error(Legion::Extensions::Llm::ServerError, /Provider error.*The model is currently overloaded/)
99
+ end
100
+
101
+ it 'raises ServerError with generic message when no partial message is extractable and env cannot buffer' do
102
+ buffer = +''
103
+ partial_chunk = '{"error":{'
104
+ expect { test_obj.send(:handle_failed_response, partial_chunk, buffer, non_mutable_env) }
105
+ .to raise_error(Legion::Extensions::Llm::ServerError, /Provider error.*incomplete/)
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Tool do
6
+ describe '#name' do
7
+ it 'converts class name to snake_case and removes _tool suffix' do
8
+ stub_const('SampleTool', Class.new(described_class))
9
+ expect(SampleTool.new.name).to eq('sample')
10
+ end
11
+
12
+ # rubocop:disable Naming/AsciiIdentifiers
13
+
14
+ it 'normalizes class name Unicode characters to ASCII' do
15
+ stub_const('SàmpleTòol', Class.new(described_class))
16
+ expect(SàmpleTòol.new.name).to eq('sample')
17
+ end
18
+
19
+ it 'handles class names with unsupported characters' do
20
+ stub_const('SampleΨTool', Class.new(described_class))
21
+ expect(SampleΨTool.new.name).to eq('sample')
22
+ end
23
+
24
+ # rubocop:enable Naming/AsciiIdentifiers
25
+
26
+ it 'handles class names without Tool suffix' do
27
+ stub_const('AnotherSample', Class.new(described_class))
28
+ expect(AnotherSample.new.name).to eq('another_sample')
29
+ end
30
+
31
+ it 'strips :: for class in module namespace' do
32
+ stub_const('TestModule::SampleTool', Class.new(described_class))
33
+ expect(TestModule::SampleTool.new.name).to eq('test_module--sample')
34
+ end
35
+
36
+ it 'handles ASCII-8BIT encoded class names without raising Encoding::CompatibilityError' do
37
+ # This simulates a class name that is ASCII-8BIT encoded
38
+ tool_class = Class.new(described_class)
39
+ ascii_8bit_name = 'SampleTool'.dup.force_encoding('ASCII-8BIT')
40
+ allow(tool_class).to receive(:name).and_return(ascii_8bit_name)
41
+ expect(tool_class.new.name).to eq('sample')
42
+ end
43
+ end
44
+
45
+ describe '#call' do
46
+ it 'returns an error hash for unknown keyword arguments' do
47
+ stub_const('SignatureTool', Class.new(described_class) do
48
+ def execute(questions:)
49
+ questions
50
+ end
51
+ end)
52
+
53
+ result = SignatureTool.new.call({ 'questions' => [], 'isOther' => true })
54
+
55
+ expect(result).to eq({ error: 'Invalid tool arguments: unknown keyword: isOther' })
56
+ end
57
+
58
+ it 'returns an error hash for missing required keyword arguments' do
59
+ stub_const('RequiredTool', Class.new(described_class) do
60
+ def execute(questions:)
61
+ questions
62
+ end
63
+ end)
64
+
65
+ result = RequiredTool.new.call({})
66
+
67
+ expect(result).to eq({ error: 'Invalid tool arguments: missing keyword: questions' })
68
+ end
69
+
70
+ it 'allows extra keyword arguments when execute accepts keyrest' do
71
+ stub_const('FlexibleTool', Class.new(described_class) do
72
+ def execute(questions:, **extra)
73
+ { questions:, extra: }
74
+ end
75
+ end)
76
+
77
+ result = FlexibleTool.new.call({ 'questions' => [1], 'isOther' => true })
78
+
79
+ expect(result).to eq({ questions: [1], extra: { isOther: true } })
80
+ end
81
+
82
+ it 're-raises unrelated ArgumentError from inside execute' do
83
+ stub_const('ManualArgumentErrorTool', Class.new(described_class) do
84
+ def execute(questions:)
85
+ _ = questions
86
+ raise ArgumentError, 'bad value provided'
87
+ end
88
+ end)
89
+
90
+ expect { ManualArgumentErrorTool.new.call({ 'questions' => [] }) }
91
+ .to raise_error(ArgumentError, 'bad value provided')
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Transport::FleetLane do
6
+ describe '.queue_options' do
7
+ it 'returns durable quorum live-work queue defaults' do
8
+ options = described_class.queue_options
9
+
10
+ expect(options[:durable]).to be(true)
11
+ expect(options[:auto_delete]).to be(false)
12
+ expect(options[:arguments]).to include(
13
+ 'x-queue-type' => 'quorum',
14
+ 'x-queue-leader-locator' => 'balanced',
15
+ 'x-overflow' => 'reject-publish',
16
+ 'x-expires' => 60_000,
17
+ 'x-message-ttl' => 120_000,
18
+ 'x-max-length' => 100,
19
+ 'x-delivery-limit' => 3,
20
+ 'x-consumer-timeout' => 300_000
21
+ )
22
+ end
23
+
24
+ it 'allows provider gems to override lane limits' do
25
+ options = described_class.queue_options(queue_max_length: 25, delivery_limit: 7)
26
+
27
+ expect(options[:arguments]['x-max-length']).to eq(25)
28
+ expect(options[:arguments]['x-delivery-limit']).to eq(7)
29
+ end
30
+ end
31
+
32
+ describe '.build_queue_class' do
33
+ it 'builds a queue class without requiring legion-transport at gem load time' do
34
+ exchange_class = Class.new
35
+ base_queue = Class.new do
36
+ attr_reader :bindings
37
+
38
+ def initialize
39
+ @bindings = []
40
+ end
41
+
42
+ def bind(exchange, routing_key:)
43
+ @bindings << [exchange, routing_key]
44
+ end
45
+ end
46
+
47
+ queue_class = described_class.build_queue_class(
48
+ queue_name: 'llm.fleet.embed.nomic-embed-text',
49
+ exchange_class: exchange_class,
50
+ base_queue_class: base_queue
51
+ )
52
+ queue = queue_class.new
53
+
54
+ expect(queue.queue_name).to eq('llm.fleet.embed.nomic-embed-text')
55
+ expect(queue.queue_options[:arguments]['x-queue-type']).to eq('quorum')
56
+ expect(queue.dlx_enabled).to be(false)
57
+ expect(queue.bindings.first.last).to eq('llm.fleet.embed.nomic-embed-text')
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Utils do
6
+ describe '.hash_get' do
7
+ it 'fetches a value using a symbol when the hash key is stored as a string' do
8
+ hash = { 'name' => 'Legion::Extensions::Llm' }
9
+
10
+ expect(described_class.hash_get(hash, :name)).to eq('Legion::Extensions::Llm')
11
+ end
12
+
13
+ it 'fetches a value using a string when the hash key is stored as a symbol' do
14
+ hash = { name: 'Legion::Extensions::Llm' }
15
+
16
+ expect(described_class.hash_get(hash, 'name')).to eq('Legion::Extensions::Llm')
17
+ end
18
+ end
19
+
20
+ describe '.to_safe_array' do
21
+ it 'returns the same array instance when the input is already an array' do
22
+ items = [1, 2, 3]
23
+
24
+ expect(described_class.to_safe_array(items)).to equal(items)
25
+ end
26
+
27
+ it 'wraps hashes in an array' do
28
+ hash = { key: 'value' }
29
+
30
+ expect(described_class.to_safe_array(hash)).to eq([hash])
31
+ end
32
+
33
+ it 'wraps non-collection values in an array' do
34
+ expect(described_class.to_safe_array('value')).to eq(['value'])
35
+ end
36
+ end
37
+
38
+ describe '.deep_merge' do
39
+ it 'merges nested hashes without mutating the originals' do
40
+ original = { config: { retries: 3, timeout: 5 }, mode: :safe }
41
+ overrides = { config: { timeout: 10 }, verbose: true }
42
+
43
+ result = described_class.deep_merge(original, overrides)
44
+
45
+ expect(result).to eq(
46
+ config: { retries: 3, timeout: 10 },
47
+ mode: :safe,
48
+ verbose: true
49
+ )
50
+ expect(original).to eq(config: { retries: 3, timeout: 5 }, mode: :safe)
51
+ expect(overrides).to eq(config: { timeout: 10 }, verbose: true)
52
+ end
53
+ end
54
+
55
+ describe '.deep_dup' do
56
+ it 'duplicates nested arrays and hashes' do
57
+ original = {
58
+ metadata: {
59
+ tags: %w[ruby llm],
60
+ info: { version: '1.0.0' }
61
+ }
62
+ }
63
+
64
+ duplicate = described_class.deep_dup(original)
65
+
66
+ expect(duplicate).to eq(original)
67
+ expect(duplicate).not_to equal(original)
68
+ expect(duplicate[:metadata]).not_to equal(original[:metadata])
69
+ expect(duplicate[:metadata][:tags]).not_to equal(original[:metadata][:tags])
70
+ expect(duplicate[:metadata][:info]).not_to equal(original[:metadata][:info])
71
+ end
72
+ end
73
+
74
+ describe '.deep_stringify_keys' do
75
+ it 'converts nested keys and symbol values to strings' do
76
+ data = {
77
+ config: {
78
+ retries: 3,
79
+ mode: :safe
80
+ },
81
+ 'files' => [{ path: '/tmp/file.txt' }]
82
+ }
83
+
84
+ expect(described_class.deep_stringify_keys(data)).to eq(
85
+ 'config' => {
86
+ 'retries' => 3,
87
+ 'mode' => 'safe'
88
+ },
89
+ 'files' => [{ 'path' => '/tmp/file.txt' }]
90
+ )
91
+ end
92
+ end
93
+
94
+ describe '.deep_symbolize_keys' do
95
+ it 'converts nested string keys to symbols and preserves non-convertible keys' do
96
+ data = {
97
+ 'config' => {
98
+ 'retries' => 3,
99
+ 'mode' => 'safe',
100
+ 'options' => [{ 'path' => '/tmp/file.txt' }]
101
+ },
102
+ 42 => 'answer'
103
+ }
104
+
105
+ result = described_class.deep_symbolize_keys(data)
106
+
107
+ expect(result[:config][:retries]).to eq(3)
108
+ expect(result[:config][:mode]).to eq('safe')
109
+ expect(result[:config][:options].first[:path]).to eq('/tmp/file.txt')
110
+ expect(result[42]).to eq('answer')
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm do
6
+ include_context 'with fake llm provider'
7
+
8
+ before do
9
+ stub_const(
10
+ 'SpecSupport::EchoTool',
11
+ Class.new(Legion::Extensions::Llm::Tool) do
12
+ description 'Echo a numeric value'
13
+ param :value, type: :integer, desc: 'Value to echo'
14
+
15
+ def execute(value:)
16
+ "echo #{value}"
17
+ end
18
+ end
19
+ )
20
+ end
21
+
22
+ it 'loads and discovers provider classes from the namespace' do
23
+ provider_classes = Legion::Extensions::Llm::Models.scan_provider_classes
24
+ expect(provider_classes).to include(fake_llm: SpecSupport::FakeLLMProvider)
25
+ expect(Legion::Extensions::Llm::Routing::ModelOffering).to be_a(Class)
26
+ end
27
+
28
+ it 'lets a provider gem register options and satisfy chat through the shared API' do
29
+ chat = described_class.chat(model: 'fake-chat-model', provider: :fake_llm, assume_model_exists: true)
30
+ response = chat.ask('hello')
31
+
32
+ expect(response).to have_attributes(
33
+ role: :assistant,
34
+ content: 'fake response to hello',
35
+ model_id: 'fake-chat-model',
36
+ input_tokens: 10,
37
+ output_tokens: 5
38
+ )
39
+ end
40
+
41
+ it 'runs shared tool orchestration without provider-specific payload code' do
42
+ response = described_class.chat(model: 'fake-chat-model', provider: :fake_llm, assume_model_exists: true)
43
+ .with_tool(SpecSupport::EchoTool)
44
+ .ask('use the tool')
45
+
46
+ expect(response.content).to eq('tool result: echo 21')
47
+ end
48
+
49
+ it 'normalizes schema responses through the base chat layer' do
50
+ schema = {
51
+ name: 'answer',
52
+ schema: {
53
+ type: 'object',
54
+ properties: { answer: { type: 'integer' } },
55
+ required: ['answer']
56
+ }
57
+ }
58
+
59
+ response = described_class.chat(model: 'fake-chat-model', provider: :fake_llm, assume_model_exists: true)
60
+ .with_schema(schema)
61
+ .ask('structured')
62
+
63
+ expect(response.content).to eq({ 'answer' => 42 })
64
+ end
65
+
66
+ it 'delegates embedding, moderation, image, and transcription calls through registered providers' do
67
+ expect(described_class.embed('hello', model: 'fake-embed', provider: :fake_llm, assume_model_exists: true).vectors)
68
+ .to eq([0.5, 0.5, 0.5])
69
+
70
+ expect(described_class.moderate('safe', model: 'fake-moderation', provider: :fake_llm, assume_model_exists: true))
71
+ .not_to be_flagged
72
+
73
+ expect(described_class.paint('draw', model: 'fake-image', provider: :fake_llm, assume_model_exists: true).to_blob)
74
+ .to eq('fake-image')
75
+
76
+ expect(described_class.transcribe('audio.wav', model: 'fake-audio', provider: :fake_llm,
77
+ assume_model_exists: true).text)
78
+ .to eq('fake transcript')
79
+ end
80
+
81
+ it 'connects model offerings to Legion fleet queue construction end to end' do
82
+ offering = Legion::Extensions::Llm::Routing::ModelOffering.new(
83
+ provider_family: :fake_llm,
84
+ instance_id: :worker_one,
85
+ transport: :rabbitmq,
86
+ model: 'fake-chat-model',
87
+ limits: { context_window: 16_384 }
88
+ )
89
+ exchange_class = Class.new
90
+ base_queue = Class.new do
91
+ attr_reader :bindings
92
+
93
+ def initialize
94
+ @bindings = []
95
+ end
96
+
97
+ def bind(exchange, routing_key:)
98
+ @bindings << [exchange, routing_key]
99
+ end
100
+ end
101
+
102
+ queue_class = Legion::Extensions::Llm::Transport::FleetLane.build_queue_class(
103
+ queue_name: offering.lane_key,
104
+ exchange_class: exchange_class,
105
+ base_queue_class: base_queue
106
+ )
107
+
108
+ expect(queue_class.new.queue_name).to eq('llm.fleet.inference.fake-chat-model.ctx16384')
109
+ end
110
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/llm'
4
+
5
+ RSpec.describe Legion::Extensions::Llm do
6
+ it 'exposes the Legion-native extension namespace for autoloading' do
7
+ expect(described_class::Types::ModelOffering).to equal(Legion::Extensions::Llm::Routing::ModelOffering)
8
+ expect(described_class::Types::OfferingRegistry).to equal(Legion::Extensions::Llm::Routing::OfferingRegistry)
9
+ expect(described_class::Routing::LaneKey).to equal(Legion::Extensions::Llm::Routing::LaneKey)
10
+ expect(described_class::Routing::OfferingRegistry).to equal(Legion::Extensions::Llm::Routing::OfferingRegistry)
11
+ end
12
+
13
+ it 'provides complete default fleet settings' do
14
+ defaults = described_class.default_settings
15
+
16
+ expect(defaults.dig(:fleet, :consumer, :scheduler)).to eq(:basic_get)
17
+ expect(defaults.dig(:fleet, :consumer, :queue_expires_ms)).to eq(60_000)
18
+ expect(defaults.dig(:fleet, :consumer, :consumer_ack_timeout_ms)).to eq(90_000)
19
+ expect(defaults.dig(:fleet, :auth, :accepted_issuers)).to eq(['legion-llm'])
20
+ expect(defaults.dig(:fleet, :auth, :audience)).to eq('lex-llm-fleet-worker')
21
+ expect(defaults.dig(:fleet, :auth, :algorithm)).to eq('HS256')
22
+ expect(defaults.dig(:fleet, :auth, :replay_ttl_seconds)).to eq(600)
23
+ expect(defaults.dig(:fleet, :responder, :require_idempotency)).to be(true)
24
+ expect(defaults.dig(:fleet, :responder, :idempotency_ttl_seconds)).to eq(600)
25
+ end
26
+
27
+ it 'reads fleet settings from extension settings before falling back to core llm settings' do
28
+ data = {
29
+ extensions: { llm: { fleet: { auth: { accepted_issuers: ['extension'] } } } },
30
+ llm: {
31
+ fleet: {
32
+ auth: { audience: 'core-audience', accepted_issuers: ['core'] },
33
+ responder: { require_auth: false }
34
+ }
35
+ }
36
+ }
37
+ settings = Module.new
38
+ settings.define_singleton_method(:[]) { |key| data[key] }
39
+
40
+ stub_const('Legion::Settings', settings)
41
+
42
+ expect(Legion::Extensions::Llm::Fleet::Settings.value(:fleet, :auth, :accepted_issuers, default: []))
43
+ .to eq(['extension'])
44
+ expect(Legion::Extensions::Llm::Fleet::Settings.value(:fleet, :auth, :audience, default: nil))
45
+ .to eq('core-audience')
46
+ expect(Legion::Extensions::Llm::Fleet::Settings.value(:fleet, :responder, :require_auth, default: true))
47
+ .to be(false)
48
+ end
49
+
50
+ it 'builds provider defaults with shared fleet settings' do
51
+ settings = described_class.provider_settings(
52
+ family: :ollama,
53
+ instance: {
54
+ base_url: 'http://localhost:11434',
55
+ fleet: { enabled: true, consumer_priority: 10 }
56
+ }
57
+ )
58
+
59
+ expect(settings[:provider_family]).to eq(:ollama)
60
+ expect(settings.dig(:discovery, :interval_seconds)).to eq(300)
61
+ expect(settings.dig(:fleet, :consumer, :scheduler)).to eq(:basic_get)
62
+ expect(settings.dig(:instances, :default, :base_url)).to eq('http://localhost:11434')
63
+ expect(settings.dig(:instances, :default, :fleet)).to include(
64
+ enabled: true,
65
+ consumer_priority: 10,
66
+ prefetch: 1
67
+ )
68
+ end
69
+
70
+ it 'deep duplicates provider defaults between calls' do
71
+ first = described_class.provider_settings(family: :vllm)
72
+ second = described_class.provider_settings(family: :vllm)
73
+
74
+ first.dig(:instances, :default, :fleet)[:prefetch] = 99
75
+
76
+ expect(second.dig(:instances, :default, :fleet, :prefetch)).to eq(1)
77
+ end
78
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm do
6
+ describe '.logger' do
7
+ let(:logger) { instance_double(Logger) }
8
+ let(:log_file) { double }
9
+ let(:log_level) { double }
10
+
11
+ before do
12
+ described_class.instance_variable_set(:@config, nil)
13
+ described_class.instance_variable_set(:@logger, nil)
14
+ end
15
+
16
+ after do
17
+ described_class.instance_variable_set(:@config, nil)
18
+ described_class.instance_variable_set(:@logger, nil)
19
+ end
20
+
21
+ context 'with configuration options' do
22
+ before do
23
+ described_class.configure do |config|
24
+ config.log_file = log_file
25
+ config.log_level = log_level
26
+ end
27
+ end
28
+
29
+ it 'returns a default Logger' do
30
+ allow(Logger).to receive(:new).with(log_file, progname: 'Legion::Extensions::Llm',
31
+ level: log_level).and_return(logger)
32
+
33
+ expect(described_class.logger).to eq(logger)
34
+ end
35
+ end
36
+
37
+ context 'with a custom logger' do
38
+ before do
39
+ described_class.configure do |config|
40
+ config.logger = logger
41
+ config.log_file = log_file
42
+ config.log_level = log_level
43
+ end
44
+ end
45
+
46
+ it 'returns a the custom Logger' do
47
+ expect(described_class.logger).to eq(logger)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require 'dotenv/load'
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ begin
10
+ require 'simplecov'
11
+ require 'simplecov-cobertura'
12
+ require_relative 'support/simplecov_configuration'
13
+ rescue LoadError
14
+ nil
15
+ end
16
+
17
+ require 'bundler/setup'
18
+ require 'fileutils'
19
+ require 'tempfile'
20
+ require 'legion/extensions/llm'
21
+ require 'ruby_llm/schema'
22
+ require_relative 'support/rspec_configuration'
23
+ require_relative 'support/llm_configuration'
24
+ require_relative 'support/fake_llm_provider'