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,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