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,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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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'
|