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,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/llm/fleet/provider_responder'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Fleet::ProviderResponder do
|
|
6
|
+
let(:protocol) { Legion::Extensions::Llm::Fleet::Protocol }
|
|
7
|
+
let(:payload) do
|
|
8
|
+
{
|
|
9
|
+
request_id: 'req-1',
|
|
10
|
+
correlation_id: 'corr-1',
|
|
11
|
+
idempotency_key: 'idem-1',
|
|
12
|
+
operation: :chat,
|
|
13
|
+
provider: :ollama,
|
|
14
|
+
provider_instance: :default,
|
|
15
|
+
model: 'llama3',
|
|
16
|
+
params: { messages: [{ role: 'user', content: 'hello' }], temperature: 0.1 },
|
|
17
|
+
reply_to: 'reply.queue',
|
|
18
|
+
message_context: { conversation_id: 'conv-1' },
|
|
19
|
+
caller: { identity: 'user:matt' },
|
|
20
|
+
trace_context: { trace_id: 'trace-1' },
|
|
21
|
+
signed_token: 'unsigned',
|
|
22
|
+
timeout_seconds: 30,
|
|
23
|
+
expires_at: (Time.now.utc + 30).iso8601,
|
|
24
|
+
protocol_version: protocol::VERSION
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
let(:provider_instances) do
|
|
28
|
+
{
|
|
29
|
+
default: {
|
|
30
|
+
base_url: 'http://localhost:11434',
|
|
31
|
+
fleet: { respond_to_requests: true }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
let(:provider_class) do
|
|
36
|
+
Class.new do
|
|
37
|
+
attr_reader :settings
|
|
38
|
+
|
|
39
|
+
def initialize(settings)
|
|
40
|
+
@settings = settings
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def chat(messages:, model:, **params)
|
|
44
|
+
{
|
|
45
|
+
content: "chat #{model} #{messages.first[:content]}",
|
|
46
|
+
usage: { input_tokens: 1, output_tokens: 2 },
|
|
47
|
+
metadata: { temperature: params[:temperature], base_url: settings[:base_url] }
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
before do
|
|
54
|
+
Legion::Extensions::Llm::Fleet::WorkerExecution.reset_idempotency_cache!
|
|
55
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
|
|
56
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
57
|
+
.with(:fleet, :auth, :require_signed_token, default: true).and_return(false)
|
|
58
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
59
|
+
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'does not require the legion-llm namespace on responder nodes' do
|
|
63
|
+
hide_const('Legion::LLM') if defined?(Legion::LLM)
|
|
64
|
+
|
|
65
|
+
expect(defined?(Legion::LLM)).to be_nil
|
|
66
|
+
expect(described_class).to respond_to(:call)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'builds a provider from the requested instance, dispatches the operation, and publishes a fleet response' do
|
|
70
|
+
response_message = instance_double(Legion::Extensions::Llm::Transport::Messages::FleetResponse, publish: true)
|
|
71
|
+
allow(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to receive(:new).and_return(response_message)
|
|
72
|
+
|
|
73
|
+
response = described_class.call(
|
|
74
|
+
payload: payload,
|
|
75
|
+
provider_family: :ollama,
|
|
76
|
+
provider_class: provider_class,
|
|
77
|
+
provider_instances: provider_instances
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
expect(response).to include(content: 'chat llama3 hello')
|
|
81
|
+
expect(Legion::Extensions::Llm::Transport::Messages::FleetResponse).to have_received(:new).with(
|
|
82
|
+
hash_including(
|
|
83
|
+
request_id: 'req-1',
|
|
84
|
+
correlation_id: 'corr-1',
|
|
85
|
+
provider: :ollama,
|
|
86
|
+
provider_instance: :default,
|
|
87
|
+
model: 'llama3',
|
|
88
|
+
content: 'chat llama3 hello',
|
|
89
|
+
usage: { input_tokens: 1, output_tokens: 2 },
|
|
90
|
+
metadata: { temperature: 0.1, base_url: 'http://localhost:11434' }
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
expect(response_message).to have_received(:publish)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'rejects legacy fleet protocol fields before provider execution' do
|
|
97
|
+
expect do
|
|
98
|
+
described_class.call(
|
|
99
|
+
payload: payload.merge(request_type: 'chat'),
|
|
100
|
+
provider_family: :ollama,
|
|
101
|
+
provider_class: provider_class,
|
|
102
|
+
provider_instances: provider_instances
|
|
103
|
+
)
|
|
104
|
+
end.to raise_error(ArgumentError, /request_type/)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'reports whether a provider instance is enabled for fleet responses' do
|
|
108
|
+
expect(described_class.enabled_for?(provider_instances)).to be(true)
|
|
109
|
+
expect(described_class.enabled_for?(default: { fleet: { respond_to_requests: false } })).to be(false)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'raises an actionable configuration error when fleet transport messages cannot load' do
|
|
113
|
+
allow(Legion::Extensions::Llm::Transport::Messages).to receive(:const_get)
|
|
114
|
+
.with(:FleetResponse).and_raise(NameError, 'missing transport')
|
|
115
|
+
|
|
116
|
+
expect do
|
|
117
|
+
described_class.transport_message_class(:FleetResponse)
|
|
118
|
+
end.to raise_error(described_class::ConfigurationError, /fleet responder transport unavailable/)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/crypt'
|
|
4
|
+
require 'legion/extensions/llm/fleet/token_validator'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Llm::Fleet::TokenValidator do
|
|
7
|
+
let(:expires_at) { (Time.now.utc + 60).iso8601 }
|
|
8
|
+
let(:envelope) do
|
|
9
|
+
{
|
|
10
|
+
request_id: 'req-1',
|
|
11
|
+
correlation_id: 'corr-1',
|
|
12
|
+
idempotency_key: 'idem-1',
|
|
13
|
+
operation: :chat,
|
|
14
|
+
provider: :ollama,
|
|
15
|
+
provider_instance: :default,
|
|
16
|
+
model: 'llama3',
|
|
17
|
+
reply_to: 'reply.queue',
|
|
18
|
+
message_context: { conversation_id: 'conv-1' },
|
|
19
|
+
params: { messages: [{ role: 'user', content: 'hello' }] },
|
|
20
|
+
caller: { identity: 'user:matt' },
|
|
21
|
+
trace_context: { trace_id: 'trace-1' },
|
|
22
|
+
timeout_seconds: 30,
|
|
23
|
+
expires_at: expires_at
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
let(:claims) do
|
|
27
|
+
envelope.merge(
|
|
28
|
+
iss: 'legion-llm',
|
|
29
|
+
aud: 'lex-llm-fleet-worker',
|
|
30
|
+
exp: Time.now.to_i + 60,
|
|
31
|
+
nbf: Time.now.to_i - 1,
|
|
32
|
+
jti: 'jti-1'
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
before do
|
|
37
|
+
described_class.reset_replay_cache!
|
|
38
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
|
|
39
|
+
allow(Legion::Crypt).to receive(:cluster_secret).and_return('secret')
|
|
40
|
+
allow(Legion::Crypt::JWT).to receive(:verify).and_return(claims)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'verifies the signed token and validates matching envelope claims' do
|
|
44
|
+
expect(validate_token).to include(jti: 'jti-1')
|
|
45
|
+
expect(Legion::Crypt::JWT).to have_received(:verify).with(
|
|
46
|
+
'signed.jwt',
|
|
47
|
+
verification_key: 'secret',
|
|
48
|
+
issuer: 'legion-llm',
|
|
49
|
+
algorithm: 'HS256',
|
|
50
|
+
verify_issuer: true
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'rejects missing tokens' do
|
|
55
|
+
expect do
|
|
56
|
+
described_class.validate!(token: nil, envelope: envelope)
|
|
57
|
+
end.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /token is required/)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
iss: ['other-issuer', /issuer mismatch/],
|
|
62
|
+
aud: ['wrong-audience', /audience mismatch/],
|
|
63
|
+
exp: [Time.now.to_i - 300, /token expired/],
|
|
64
|
+
nbf: [Time.now.to_i + 300, /not yet valid/],
|
|
65
|
+
jti: ['', /missing jti/]
|
|
66
|
+
}.each do |claim, (value, message)|
|
|
67
|
+
it "rejects invalid registered claim #{claim}" do
|
|
68
|
+
allow(Legion::Crypt::JWT).to receive(:verify).and_return(claims.merge(claim => value))
|
|
69
|
+
|
|
70
|
+
expect { validate_token }.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, message)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'rejects expired request envelopes' do
|
|
75
|
+
allow(Legion::Crypt::JWT).to receive(:verify)
|
|
76
|
+
.and_return(claims.merge(expires_at: (Time.now.utc - 31).iso8601))
|
|
77
|
+
|
|
78
|
+
expect { validate_token }.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /request expired/)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'rejects invalid request expiry timestamps' do
|
|
82
|
+
allow(Legion::Crypt::JWT).to receive(:verify).and_return(claims.merge(expires_at: 'not-time'))
|
|
83
|
+
|
|
84
|
+
expect { validate_token }.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /expires_at is invalid/)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
%i[
|
|
88
|
+
request_id correlation_id idempotency_key operation provider provider_instance model reply_to
|
|
89
|
+
timeout_seconds expires_at
|
|
90
|
+
].each do |claim|
|
|
91
|
+
it "rejects envelope claim mismatch for #{claim}" do
|
|
92
|
+
value = claim == :expires_at ? (Time.now.utc + 120).iso8601 : 'different'
|
|
93
|
+
allow(Legion::Crypt::JWT).to receive(:verify).and_return(claims.merge(claim => value))
|
|
94
|
+
|
|
95
|
+
expect do
|
|
96
|
+
validate_token
|
|
97
|
+
end.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /#{claim} claim mismatch/)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
%i[message_context params caller trace_context].each do |claim|
|
|
102
|
+
it "rejects envelope hash mismatch for #{claim}" do
|
|
103
|
+
allow(Legion::Crypt::JWT).to receive(:verify).and_return(claims.merge(claim => 'different'))
|
|
104
|
+
|
|
105
|
+
expect do
|
|
106
|
+
validate_token
|
|
107
|
+
end.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /#{claim} hash mismatch/)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'wraps JWT verification failures with a token validation error' do
|
|
112
|
+
allow(Legion::Crypt::JWT).to receive(:verify).and_raise(StandardError, 'bad signature')
|
|
113
|
+
|
|
114
|
+
expect do
|
|
115
|
+
validate_token
|
|
116
|
+
end.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /verification failed: bad signature/)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it 'reserves replay tokens immediately and rejects duplicate validation' do
|
|
120
|
+
validate_token
|
|
121
|
+
|
|
122
|
+
expect { validate_token }.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /replay detected/)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'supports non-recording validation while still rejecting already recorded tokens' do
|
|
126
|
+
expect(validate_token(record_replay: false)).to include(jti: 'jti-1')
|
|
127
|
+
expect(validate_token(record_replay: false)).to include(jti: 'jti-1')
|
|
128
|
+
|
|
129
|
+
validate_token
|
|
130
|
+
|
|
131
|
+
expect do
|
|
132
|
+
validate_token(record_replay: false)
|
|
133
|
+
end.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /replay detected/)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'can release inflight replay reservations after failed dispatch' do
|
|
137
|
+
validate_token
|
|
138
|
+
described_class.release_replay!('jti-1')
|
|
139
|
+
|
|
140
|
+
expect(validate_token).to include(jti: 'jti-1')
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'keeps completed replay reservations after release is requested' do
|
|
144
|
+
validate_token
|
|
145
|
+
described_class.mark_replay!('jti-1')
|
|
146
|
+
described_class.release_replay!('jti-1')
|
|
147
|
+
|
|
148
|
+
expect do
|
|
149
|
+
validate_token(record_replay: false)
|
|
150
|
+
end.to raise_error(Legion::Extensions::Llm::Fleet::TokenError, /replay detected/)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'uses a dedicated auth replay TTL setting' do
|
|
154
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
155
|
+
.with(:fleet, :auth, :replay_ttl_seconds, default: 600).and_return(42)
|
|
156
|
+
|
|
157
|
+
expect(described_class.replay_ttl_seconds).to eq(42)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def validate_token(record_replay: true)
|
|
161
|
+
described_class.validate!(token: 'signed.jwt', envelope: envelope, record_replay: record_replay)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/llm/fleet/worker_execution'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Llm::Fleet::WorkerExecution do
|
|
6
|
+
let(:envelope) do
|
|
7
|
+
{
|
|
8
|
+
operation: :chat,
|
|
9
|
+
model: 'llama3',
|
|
10
|
+
params: { messages: [{ role: 'user', content: 'hello' }] },
|
|
11
|
+
signed_token: 'unsigned',
|
|
12
|
+
idempotency_key: 'idem-1'
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
let(:provider) do
|
|
16
|
+
Class.new do
|
|
17
|
+
def chat(messages:, model:, **)
|
|
18
|
+
{ content: "#{model}:#{messages.first[:content]}" }
|
|
19
|
+
end
|
|
20
|
+
end.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
before do
|
|
24
|
+
described_class.reset_idempotency_cache!
|
|
25
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
|
|
26
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
27
|
+
.with(:fleet, :auth, :require_signed_token, default: true).and_return(false)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'dispatches canonical chat operations directly to the local provider' do
|
|
31
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
32
|
+
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
|
|
33
|
+
|
|
34
|
+
expect(described_class.call(envelope: envelope, provider: provider)).to eq(content: 'llama3:hello')
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'unpacks legacy nested options before dispatching to the local provider' do
|
|
38
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
39
|
+
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
|
|
40
|
+
captured_options = nil
|
|
41
|
+
capture_provider = Class.new do
|
|
42
|
+
define_method(:chat) do |messages:, model:, **options|
|
|
43
|
+
captured_options = options
|
|
44
|
+
{ content: "#{model}:#{messages.first[:content]}" }
|
|
45
|
+
end
|
|
46
|
+
end.new
|
|
47
|
+
legacy_envelope = envelope.merge(
|
|
48
|
+
params: {
|
|
49
|
+
messages: [{ role: 'user', content: 'hello' }],
|
|
50
|
+
stream: false,
|
|
51
|
+
tools: { current: { name: 'current' } },
|
|
52
|
+
options: {
|
|
53
|
+
'stream' => true,
|
|
54
|
+
'system' => 'Use available tools.',
|
|
55
|
+
'tools' => { legacy: { name: 'legacy' } }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
described_class.call(envelope: legacy_envelope, provider: capture_provider)
|
|
61
|
+
|
|
62
|
+
expect(captured_options).to include(
|
|
63
|
+
stream: false,
|
|
64
|
+
system: 'Use available tools.',
|
|
65
|
+
tools: { current: { name: 'current' } }
|
|
66
|
+
)
|
|
67
|
+
expect(captured_options).not_to have_key(:options)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'rejects duplicate idempotency keys before executing the provider again' do
|
|
71
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
72
|
+
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(true)
|
|
73
|
+
|
|
74
|
+
described_class.call(envelope: envelope, provider: provider)
|
|
75
|
+
|
|
76
|
+
expect do
|
|
77
|
+
described_class.call(envelope: envelope, provider: provider)
|
|
78
|
+
end.to raise_error(described_class::PolicyError, /duplicate fleet idempotency key/)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'replaces expired idempotency entries while reserving the new attempt' do
|
|
82
|
+
described_class.instance_variable_get(:@idempotency_keys)['idem-expired'] = {
|
|
83
|
+
state: :complete,
|
|
84
|
+
expires_at: Time.now.to_i - 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
expect { described_class.reserve_idempotency_key!('idem-expired') }.not_to raise_error
|
|
88
|
+
|
|
89
|
+
expect do
|
|
90
|
+
described_class.reserve_idempotency_key!('idem-expired')
|
|
91
|
+
end.to raise_error(described_class::PolicyError, /duplicate fleet idempotency key/)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'reserves token replay protection before provider dispatch and marks success after completion' do
|
|
95
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
96
|
+
.with(:fleet, :auth, :require_signed_token, default: true).and_return(true)
|
|
97
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
98
|
+
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
|
|
99
|
+
allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:validate!).and_return({ jti: 'jti-1' })
|
|
100
|
+
allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:mark_replay!)
|
|
101
|
+
|
|
102
|
+
described_class.call(envelope: envelope, provider: provider)
|
|
103
|
+
|
|
104
|
+
expect(Legion::Extensions::Llm::Fleet::TokenValidator).to have_received(:validate!)
|
|
105
|
+
.with(token: 'unsigned', envelope: envelope)
|
|
106
|
+
expect(Legion::Extensions::Llm::Fleet::TokenValidator).to have_received(:mark_replay!).with('jti-1')
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'releases reserved token replay state when provider dispatch fails' do
|
|
110
|
+
failing_provider = Class.new do
|
|
111
|
+
def chat(**)
|
|
112
|
+
raise 'provider unavailable'
|
|
113
|
+
end
|
|
114
|
+
end.new
|
|
115
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
116
|
+
.with(:fleet, :auth, :require_signed_token, default: true).and_return(true)
|
|
117
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
118
|
+
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
|
|
119
|
+
allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:validate!).and_return({ jti: 'jti-2' })
|
|
120
|
+
allow(Legion::Extensions::Llm::Fleet::TokenValidator).to receive(:release_replay!)
|
|
121
|
+
|
|
122
|
+
expect do
|
|
123
|
+
described_class.call(envelope: envelope, provider: failing_provider)
|
|
124
|
+
end.to raise_error(RuntimeError, /provider unavailable/)
|
|
125
|
+
|
|
126
|
+
expect(Legion::Extensions::Llm::Fleet::TokenValidator).to have_received(:release_replay!).with('jti-2')
|
|
127
|
+
end
|
|
128
|
+
end
|