lex-llm 0.7.6 → 0.8.4
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/CHANGELOG.md +139 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
- data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
- data/lib/legion/extensions/llm/canonical/message.rb +53 -69
- data/lib/legion/extensions/llm/canonical/params.rb +58 -34
- data/lib/legion/extensions/llm/canonical/request.rb +68 -56
- data/lib/legion/extensions/llm/canonical/response.rb +62 -78
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +7 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +36 -100
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +14 -26
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +8 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +97 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- metadata +13 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
|
@@ -2,27 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
require 'legion/extensions/llm/provider/open_ai_compatible'
|
|
5
|
-
require 'legion/extensions/llm/message'
|
|
6
|
-
require 'legion/extensions/llm/tool_call'
|
|
7
5
|
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# assistant message — which is the historical canonical shape per
|
|
12
|
-
# canonical/message.rb:75 ("Array is canonical; Hash is legacy lex-llm
|
|
13
|
-
# format (name → ToolCall)"). The OpenAI-compatible HTTP payload renderer
|
|
14
|
-
# missed that update and still calls `.values` on tool_calls, so live
|
|
15
|
-
# requests now blow up with:
|
|
16
|
-
#
|
|
17
|
-
# undefined method 'values' for an instance of Array
|
|
18
|
-
#
|
|
19
|
-
# (captured at
|
|
20
|
-
# legionio-e2e/results/claude/openai_legionio_tool_injection_returns_response_containing_legionio_tool_references_response.json)
|
|
21
|
-
#
|
|
22
|
-
# The bedrock provider (format_invoke_model_assistant) and the
|
|
23
|
-
# lex-llm-openai canonical translator (translator.rb:290) already handle
|
|
24
|
-
# both shapes via `tool_calls.is_a?(Hash) ? .values : Array(...)`. This
|
|
25
|
-
# spec pins the same shape-tolerance for OpenAICompatible.
|
|
6
|
+
# Regression pin for the legionio-e2e claude/openai legionio_tool_injection
|
|
7
|
+
# defect: the renderer must handle Array<Canonical::ToolCall> (the canonical
|
|
8
|
+
# shape). 0.8.0: the legacy Hash shape is deleted (O03a/L4 — Array only).
|
|
26
9
|
RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
27
10
|
let(:host_class) do
|
|
28
11
|
Class.new do
|
|
@@ -37,32 +20,25 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
37
20
|
let(:host) { host_class.new }
|
|
38
21
|
|
|
39
22
|
let(:tool_call) do
|
|
40
|
-
Legion::Extensions::Llm::ToolCall.
|
|
41
|
-
id: 'call_001',
|
|
23
|
+
Legion::Extensions::Llm::Canonical::ToolCall.build(
|
|
42
24
|
name: 'legion_list_all_tools',
|
|
25
|
+
id: 'call_001',
|
|
43
26
|
arguments: { 'filter' => 'all' }
|
|
44
27
|
)
|
|
45
28
|
end
|
|
46
29
|
|
|
47
|
-
it 'renders an Array<ToolCall>
|
|
30
|
+
it 'renders an Array<Canonical::ToolCall>' do
|
|
48
31
|
rendered = host.format_openai_tool_calls([tool_call])
|
|
49
32
|
expect(rendered).to be_an(Array)
|
|
50
33
|
expect(rendered.size).to eq(1)
|
|
51
34
|
expect(rendered.first[:id]).to eq('call_001')
|
|
52
35
|
expect(rendered.first[:type]).to eq('function')
|
|
53
36
|
expect(rendered.first[:function][:name]).to eq('legion_list_all_tools')
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
it 'renders a Hash<id, ToolCall> (legacy lex-llm shape) without regression' do
|
|
57
|
-
rendered = host.format_openai_tool_calls({ call_id: tool_call })
|
|
58
|
-
expect(rendered).to be_an(Array)
|
|
59
|
-
expect(rendered.size).to eq(1)
|
|
60
|
-
expect(rendered.first[:function][:name]).to eq('legion_list_all_tools')
|
|
37
|
+
expect(Legion::JSON.parse(rendered.first[:function][:arguments])).to eq(filter: 'all')
|
|
61
38
|
end
|
|
62
39
|
|
|
63
40
|
it 'returns nil when tool_calls is nil or empty' do
|
|
64
41
|
expect(host.format_openai_tool_calls(nil)).to be_nil
|
|
65
42
|
expect(host.format_openai_tool_calls([])).to be_nil
|
|
66
|
-
expect(host.format_openai_tool_calls({})).to be_nil
|
|
67
43
|
end
|
|
68
44
|
end
|
|
@@ -10,22 +10,19 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
|
|
|
10
10
|
def self.configuration_requirements = []
|
|
11
11
|
def self.local? = false
|
|
12
12
|
def self.remote? = true
|
|
13
|
-
def self.assume_models_exist? = false
|
|
14
13
|
|
|
15
14
|
def ensure_configured! = nil
|
|
16
15
|
def api_base = 'http://example.invalid'
|
|
17
16
|
def models_url = '/v1/models'
|
|
18
|
-
def parse_list_models_response(*) = []
|
|
19
17
|
end
|
|
20
18
|
end
|
|
21
19
|
|
|
22
|
-
it 'requires
|
|
20
|
+
it 'requires the 0.8.0 provider method signatures' do
|
|
23
21
|
expected = {
|
|
24
|
-
chat: [%i[
|
|
25
|
-
stream_chat: [%i[
|
|
22
|
+
chat: [%i[req messages], %i[keyreq model]],
|
|
23
|
+
stream_chat: [%i[req messages], %i[keyreq model]],
|
|
26
24
|
embed: [%i[keyreq text], %i[keyreq model]],
|
|
27
25
|
image: [%i[keyreq prompt], %i[keyreq model]],
|
|
28
|
-
list_models: [%i[key live], %i[keyrest filters]],
|
|
29
26
|
discover_offerings: [%i[key live], %i[keyrest filters]],
|
|
30
27
|
health: [%i[key live]],
|
|
31
28
|
count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
|
|
@@ -36,13 +33,12 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
|
|
|
36
33
|
required_parameters.each do |required_parameter|
|
|
37
34
|
expect(parameters).to include(required_parameter), "#{method_name} missing #{required_parameter.inspect}"
|
|
38
35
|
end
|
|
39
|
-
expect(parameters).not_to include(%i[req messages])
|
|
40
36
|
expect(parameters).not_to include(%i[req text])
|
|
41
37
|
expect(parameters).not_to include(%i[req prompt])
|
|
42
38
|
end
|
|
43
39
|
end
|
|
44
40
|
|
|
45
|
-
it 'rejects positional
|
|
41
|
+
it 'rejects non-canonical message input and positional artifact arguments' do
|
|
46
42
|
provider = provider_class.new(
|
|
47
43
|
request_timeout: 30,
|
|
48
44
|
max_retries: 0,
|
|
@@ -51,20 +47,19 @@ RSpec.describe Legion::Extensions::Llm::ProviderContract do
|
|
|
51
47
|
retry_interval_randomness: 0
|
|
52
48
|
)
|
|
53
49
|
|
|
54
|
-
expect { provider.chat([], model: 'model') }.to raise_error(ArgumentError)
|
|
55
|
-
expect { provider.stream_chat([], model: 'model') }.to raise_error(ArgumentError)
|
|
50
|
+
expect { provider.chat(['not canonical'], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
|
|
51
|
+
expect { provider.stream_chat([42], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
|
|
52
|
+
expect { provider.count_tokens(messages: ['nope'], model: 'model') }.to raise_error(ArgumentError, /Canonical::Message/)
|
|
56
53
|
expect { provider.embed('text', model: 'model') }.to raise_error(ArgumentError)
|
|
57
54
|
expect { provider.image('prompt', model: 'model') }.to raise_error(ArgumentError)
|
|
58
|
-
expect { provider.count_tokens([], model: 'model') }.to raise_error(ArgumentError)
|
|
59
55
|
end
|
|
60
56
|
|
|
61
|
-
it 'keeps REQUIRED_SIGNATURES as exactly the
|
|
57
|
+
it 'keeps REQUIRED_SIGNATURES as exactly the seven-entry reflection baseline' do
|
|
62
58
|
expect(described_class::REQUIRED_SIGNATURES).to eq(
|
|
63
|
-
chat: [%i[
|
|
64
|
-
stream_chat: [%i[
|
|
59
|
+
chat: [%i[req messages], %i[keyreq model]],
|
|
60
|
+
stream_chat: [%i[req messages], %i[keyreq model]],
|
|
65
61
|
embed: [%i[keyreq text], %i[keyreq model]],
|
|
66
62
|
image: [%i[keyreq prompt], %i[keyreq model]],
|
|
67
|
-
list_models: [%i[key live], %i[keyrest filters]],
|
|
68
63
|
discover_offerings: [%i[key live], %i[key raise_on_unreachable], %i[keyrest filters]],
|
|
69
64
|
health: [%i[key live]],
|
|
70
65
|
count_tokens: [%i[keyreq messages], %i[keyreq model], %i[key params]]
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require 'support/ssot_registry_helpers'
|
|
4
5
|
|
|
5
6
|
RSpec.describe Legion::Extensions::Llm::Provider do
|
|
7
|
+
include SsotRegistryHelpers
|
|
8
|
+
|
|
6
9
|
describe 'Hash config support' do
|
|
7
10
|
let(:provider_class) do
|
|
8
11
|
Class.new(described_class) do
|
|
@@ -100,38 +103,19 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
100
103
|
end
|
|
101
104
|
|
|
102
105
|
describe 'canonical provider contract' do
|
|
103
|
-
let(:model) do
|
|
104
|
-
Legion::Extensions::Llm::Model::Info.new(
|
|
105
|
-
id: 'test-model',
|
|
106
|
-
provider: :contract,
|
|
107
|
-
instance: :primary,
|
|
108
|
-
capabilities: %i[completion streaming tools],
|
|
109
|
-
context_length: 8192,
|
|
110
|
-
metadata: { max_output_tokens: 2048 }
|
|
111
|
-
)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
106
|
let(:provider_class) do
|
|
115
|
-
model_info = model
|
|
116
107
|
Class.new(described_class) do
|
|
117
108
|
def self.name = 'Provider'
|
|
118
109
|
|
|
119
110
|
define_method(:api_base) { 'https://contract.invalid' }
|
|
120
111
|
define_method(:models_url) { '/v1/models' }
|
|
121
|
-
attr_reader :list_model_calls
|
|
122
|
-
|
|
123
|
-
define_method(:list_models) do |live: false, **filters|
|
|
124
|
-
@list_model_calls ||= []
|
|
125
|
-
@list_model_calls << { live: live, filters: filters }
|
|
126
|
-
[model_info]
|
|
127
|
-
end
|
|
128
112
|
|
|
129
113
|
def render_payload(_messages, **)
|
|
130
114
|
{}
|
|
131
115
|
end
|
|
132
116
|
|
|
133
117
|
def parse_completion_response(_response)
|
|
134
|
-
Legion::Extensions::Llm::
|
|
118
|
+
Legion::Extensions::Llm::Canonical::Response.build(text: 'ok')
|
|
135
119
|
end
|
|
136
120
|
end
|
|
137
121
|
end
|
|
@@ -143,12 +127,12 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
143
127
|
instance_id: :primary })
|
|
144
128
|
end
|
|
145
129
|
|
|
146
|
-
it 'exposes a canonical chat alias over complete' do
|
|
130
|
+
it 'exposes a canonical chat alias over complete (0.8.0 signature — O4 temperature in Params)' do
|
|
147
131
|
allow(provider).to receive(:complete).and_return('ok')
|
|
148
132
|
|
|
149
|
-
expect(provider.chat(
|
|
133
|
+
expect(provider.chat([], model: 'test-model')).to eq('ok')
|
|
150
134
|
expect(provider).to have_received(:complete).with(
|
|
151
|
-
[], tools: [],
|
|
135
|
+
[], tools: [], model: 'test-model', params: nil, headers: {},
|
|
152
136
|
schema: nil, thinking: nil, tool_prefs: nil
|
|
153
137
|
)
|
|
154
138
|
end
|
|
@@ -157,73 +141,85 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
157
141
|
seen = []
|
|
158
142
|
allow(provider).to receive(:complete) { |_messages, **_opts, &block| block.call('chunk') }
|
|
159
143
|
|
|
160
|
-
provider.stream_chat(
|
|
144
|
+
provider.stream_chat([], model: 'test-model') { |chunk| seen << chunk }
|
|
161
145
|
|
|
162
146
|
expect(seen).to eq(['chunk'])
|
|
163
147
|
end
|
|
164
148
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
expect(offering.provider_instance).to eq(:primary)
|
|
172
|
-
expect(offering.model).to eq('test-model')
|
|
173
|
-
expect(offering.usage_type).to eq(:inference)
|
|
174
|
-
expect(offering.capabilities).to include(:completion, :streaming, :tools)
|
|
175
|
-
expect(offering.context_window).to eq(8192)
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
it 'publishes every discovered model before policy filtering removes blocked models' do
|
|
179
|
-
blocked_model = Legion::Extensions::Llm::Model::Info.new(
|
|
180
|
-
id: 'blocked-model',
|
|
181
|
-
provider: :contract,
|
|
182
|
-
instance: :primary,
|
|
183
|
-
capabilities: %i[completion],
|
|
184
|
-
context_length: 4096
|
|
149
|
+
# 0.8.0 (07 C5): the legacy ModelOffering production path is deleted — the
|
|
150
|
+
# per-gem writer publishes; the base read path serves the activated
|
|
151
|
+
# inventory offerings for this instance from the registry snapshot.
|
|
152
|
+
def activate_contract_instance
|
|
153
|
+
key = Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(
|
|
154
|
+
provider_family: 'provider', instance_id: 'primary'
|
|
185
155
|
)
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
allow(provider_class).to receive(:registry_publisher).and_return(registry_publisher)
|
|
189
|
-
allow(provider).to receive_messages(list_models: [model, blocked_model], settings: { model_blacklist: ['blocked'] })
|
|
190
|
-
|
|
191
|
-
offerings = provider.discover_offerings(live: true)
|
|
192
|
-
|
|
193
|
-
expect(offerings.map(&:model)).to eq(['test-model'])
|
|
194
|
-
expect(registry_publisher).to have_received(:publish_models_async).with([model], anything)
|
|
195
|
-
expect(registry_publisher).to have_received(:publish_models_async).with([blocked_model], anything)
|
|
156
|
+
Legion::Extensions::Llm::Inventory::Registry.reset!
|
|
157
|
+
claim_and_activate(key: key, callable: Object.new, coordinator: probe_coordinator(key), model: 'test-model')
|
|
196
158
|
end
|
|
197
159
|
|
|
198
|
-
it '
|
|
199
|
-
|
|
160
|
+
it 'serves the activated inventory offerings for the instance from the registry snapshot (07 C5)' do
|
|
161
|
+
activate_contract_instance
|
|
162
|
+
offerings = provider.discover_offerings
|
|
200
163
|
|
|
201
|
-
expect(
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
)
|
|
164
|
+
expect(offerings.size).to eq(1)
|
|
165
|
+
expect(offerings.first.model).to eq('test-model')
|
|
166
|
+
expect(offerings.first.instance_key.instance_id).to eq('primary')
|
|
167
|
+
expect(offerings.first.instance_key.provider_family).to eq(:provider)
|
|
205
168
|
end
|
|
206
169
|
|
|
207
|
-
it 'filters
|
|
208
|
-
|
|
170
|
+
it 'filters snapshot offerings by model and instance keys' do
|
|
171
|
+
activate_contract_instance
|
|
209
172
|
|
|
210
|
-
expect(provider.discover_offerings(
|
|
211
|
-
expect(provider.discover_offerings(
|
|
212
|
-
expect(provider.discover_offerings(
|
|
173
|
+
expect(provider.discover_offerings(model: 'test-model')).not_to be_empty
|
|
174
|
+
expect(provider.discover_offerings(id: 'test-model')).not_to be_empty
|
|
175
|
+
expect(provider.discover_offerings(model: 'other-model')).to be_empty
|
|
176
|
+
expect(provider.discover_offerings(instance: 'other')).to be_empty
|
|
213
177
|
end
|
|
214
178
|
|
|
215
|
-
it '
|
|
179
|
+
it 'never calls list_models — the production path moved to the per-gem writer' do
|
|
216
180
|
allow(provider).to receive(:list_models).and_raise('unexpected live discovery')
|
|
217
181
|
|
|
218
|
-
expect
|
|
182
|
+
expect { provider.discover_offerings(live: true) }.not_to raise_error
|
|
219
183
|
expect(provider).not_to have_received(:list_models)
|
|
220
184
|
end
|
|
221
185
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
186
|
+
describe 'H5 — honest read-path contract (accepted-for-compat params are documented, not silently discarded)' do
|
|
187
|
+
it 'accepts the full discover_offerings signature against the snapshot' do
|
|
188
|
+
expect { provider.discover_offerings(live: true, raise_on_unreachable: true) }.not_to raise_error
|
|
189
|
+
expect(provider.discover_offerings(raise_on_unreachable: true, model: 'test-model').size).to eq(1)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
describe 'H3 — the funnel enforces the tool contract (one contract, one oracle)' do
|
|
194
|
+
let(:tool) { Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'get_weather') }
|
|
195
|
+
|
|
196
|
+
before do
|
|
197
|
+
allow(provider).to receive(:sync_response).and_return('ok') # -- stream_response's model: kwarg is not exercised here
|
|
198
|
+
allow(provider).to receive(:stream_response) { |_connection, _payload, _headers, **_opts, &stream_block| stream_block&.call('c') }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'accepts Hash<name, Canonical::ToolDefinition> through chat and stream_chat' do
|
|
202
|
+
expect(provider.chat([], model: 'test-model', tools: { get_weather: tool })).to eq('ok')
|
|
203
|
+
expect do
|
|
204
|
+
# rubocop:disable-next Lint/EmptyBlock -- the streamed chunk is consumed by the stub
|
|
205
|
+
provider.stream_chat([], model: 'test-model', tools: { get_weather: tool }) { |_c| }
|
|
206
|
+
end.not_to raise_error
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
it 'rejects Hash tool values with a typed ArgumentError' do
|
|
210
|
+
expect { provider.chat([], model: 'test-model', tools: { get_weather: { name: 'get_weather' } }) }
|
|
211
|
+
.to raise_error(ArgumentError, /Canonical::ToolDefinition/)
|
|
212
|
+
end
|
|
225
213
|
|
|
226
|
-
|
|
214
|
+
it 'rejects a non-Hash tools container with a typed ArgumentError' do
|
|
215
|
+
expect { provider.chat([], model: 'test-model', tools: [tool]) }
|
|
216
|
+
.to raise_error(ArgumentError, /Hash<name, Canonical::ToolDefinition>/)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
it 'rejects legacy tool objects with a typed ArgumentError' do
|
|
220
|
+
expect { provider.chat([], model: 'test-model', tools: { get_weather: Legion::Extensions::Llm::Tool.new }) }
|
|
221
|
+
.to raise_error(ArgumentError, /Canonical::ToolDefinition/)
|
|
222
|
+
end
|
|
227
223
|
end
|
|
228
224
|
|
|
229
225
|
it 'returns normalized health metadata' do
|
|
@@ -237,16 +233,17 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
237
233
|
end
|
|
238
234
|
|
|
239
235
|
it 'provides a deterministic token estimate fallback' do
|
|
240
|
-
|
|
236
|
+
messages = [Legion::Extensions::Llm::Canonical::Message.build(role: :user, content: 'hello world')]
|
|
237
|
+
expect(provider.count_tokens(messages: messages, model: 'test-model')).to be >= 1
|
|
241
238
|
end
|
|
242
239
|
|
|
243
|
-
it 'summarizes
|
|
240
|
+
it 'summarizes canonical tools for debug logging' do
|
|
244
241
|
tools = {
|
|
245
|
-
current:
|
|
246
|
-
|
|
242
|
+
current: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'current'),
|
|
243
|
+
other: Legion::Extensions::Llm::Canonical::ToolDefinition.build(name: 'other')
|
|
247
244
|
}
|
|
248
245
|
|
|
249
|
-
expect(provider.send(:debug_tool_names, tools)).to eq(%w[current
|
|
246
|
+
expect(provider.send(:debug_tool_names, tools)).to eq(%w[current other])
|
|
250
247
|
end
|
|
251
248
|
|
|
252
249
|
it 'deep merges embedding params into the provider payload' do
|
|
@@ -375,6 +372,28 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
375
372
|
expect(provider.model_allowed?('gpt-5')).to be true
|
|
376
373
|
end
|
|
377
374
|
end
|
|
375
|
+
|
|
376
|
+
context 'when the model is an object exposing #id' do
|
|
377
|
+
let(:model_info) do
|
|
378
|
+
Struct.new(:id).new('claude-sonnet-4-6')
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
before { provider.settings = { model_whitelist: %w[sonnet] } }
|
|
382
|
+
|
|
383
|
+
it 'matches policy against the model id, not the inspect string' do
|
|
384
|
+
expect(provider.model_allowed?(model_info)).to be true
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
it 'blocks object models the whitelist does not cover' do
|
|
388
|
+
other = Struct.new(:id).new('llama-3')
|
|
389
|
+
expect(provider.model_allowed?(other)).to be false
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
it 'never matches inspect-string artifacts (e.g. a data/name pattern)' do
|
|
393
|
+
provider.settings = { model_whitelist: %w[data] }
|
|
394
|
+
expect(provider.model_allowed?(model_info)).to be false
|
|
395
|
+
end
|
|
396
|
+
end
|
|
378
397
|
end
|
|
379
398
|
|
|
380
399
|
describe '#enforce_model_allowed! (dispatch compliance guard)' do
|
|
@@ -407,7 +426,7 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
407
426
|
end
|
|
408
427
|
|
|
409
428
|
it 'fails closed in #complete before any provider call' do
|
|
410
|
-
expect { provider.complete([], tools: [],
|
|
429
|
+
expect { provider.complete([], tools: [], model: 'gpt-5') }
|
|
411
430
|
.to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
|
|
412
431
|
end
|
|
413
432
|
|
|
@@ -421,7 +440,7 @@ RSpec.describe Legion::Extensions::Llm::Provider do
|
|
|
421
440
|
before { provider.settings = { model_blacklist: %w[sonnet] } }
|
|
422
441
|
|
|
423
442
|
it 'fails closed in #complete for a blacklisted model' do
|
|
424
|
-
expect { provider.complete([], tools: [],
|
|
443
|
+
expect { provider.complete([], tools: [], model: 'claude-sonnet-4-6') }
|
|
425
444
|
.to raise_error(Legion::Extensions::Llm::ModelNotAllowedError)
|
|
426
445
|
end
|
|
427
446
|
end
|
|
@@ -16,9 +16,11 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
|
|
|
16
16
|
end
|
|
17
17
|
let(:callable_handle) { inventory::CallableHandle.new(handle_id: 'call:v1:x', callable: callable_class.new) }
|
|
18
18
|
let(:instance_key) { identity::InstanceKey.new(provider_family: 'vllm', instance_id: 'h200') }
|
|
19
|
-
let(:offering_id) { identity.offering_id(instance_key: instance_key, provider_native_key: 'gemma4') }
|
|
20
19
|
let(:lane_id) do
|
|
21
|
-
identity.
|
|
20
|
+
identity.compose_lane_id(
|
|
21
|
+
tier: :local, provider_family: 'vllm', instance_id: 'h200',
|
|
22
|
+
type: Legion::Extensions::Llm::Taxonomies.lane_type_for(operation: :chat), model: 'gemma4'
|
|
23
|
+
)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
describe routing::AttemptTargetKey do
|
|
@@ -76,7 +78,7 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
|
|
|
76
78
|
describe routing::Selection do
|
|
77
79
|
def build_selection(**overrides)
|
|
78
80
|
Legion::Extensions::Llm::Routing::Selection.new(
|
|
79
|
-
inventory_generation: 7, lane_id: lane_id, instance_key: instance_key,
|
|
81
|
+
inventory_generation: 7, lane_id: lane_id, instance_key: instance_key,
|
|
80
82
|
provider_family: :vllm, instance_id: 'h200', model: 'gemma4', operation: :chat,
|
|
81
83
|
callable_handle: callable_handle, publisher_token_id: 'ptok:v1:abc', capability_evidence: {},
|
|
82
84
|
context_evidence: Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent),
|
|
@@ -88,6 +90,8 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
|
|
|
88
90
|
|
|
89
91
|
it 'builds a valid selection and derives the attempt target key' do
|
|
90
92
|
selection = build_selection
|
|
93
|
+
expect(selection.lane_id).to eq('local:vllm:h200:inference:gemma4')
|
|
94
|
+
expect(selection.members).not_to include(:offering_id)
|
|
91
95
|
key = selection.attempt_target_key
|
|
92
96
|
expect(key).to eq(routing::AttemptTargetKey.new(provider_family: :vllm, instance_id: 'h200', model: 'gemma4'))
|
|
93
97
|
end
|
|
@@ -105,8 +109,37 @@ RSpec.describe Legion::Extensions::Llm::Routing::Records do
|
|
|
105
109
|
.to raise_error(errors::ValidationError)
|
|
106
110
|
end
|
|
107
111
|
|
|
108
|
-
it 'rejects a lane_id that
|
|
109
|
-
expect { build_selection(lane_id:
|
|
112
|
+
it 'rejects a lane_id that is not a 5-tuple' do
|
|
113
|
+
expect { build_selection(lane_id: 'not-a-five-tuple') }
|
|
114
|
+
.to raise_error(errors::ValidationError)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'rejects a lane_id whose family part disagrees with the selection' do
|
|
118
|
+
expect { build_selection(lane_id: 'local:openai:h200:inference:gemma4') }
|
|
119
|
+
.to raise_error(errors::ValidationError, /provider_family/)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'rejects a lane_id whose instance part disagrees with the selection' do
|
|
123
|
+
expect { build_selection(lane_id: 'local:vllm:helios1:inference:gemma4') }
|
|
124
|
+
.to raise_error(errors::ValidationError, /instance_id/)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'rejects a lane_id whose type part disagrees with the operation' do
|
|
128
|
+
expect { build_selection(lane_id: 'local:vllm:h200:embedding:gemma4') }
|
|
129
|
+
.to raise_error(errors::ValidationError, /type/)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'rejects a lane_id whose model part disagrees with the selection' do
|
|
133
|
+
expect { build_selection(lane_id: 'local:vllm:h200:inference:other-model') }
|
|
134
|
+
.to raise_error(errors::ValidationError, /model/)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'keeps colons inside the lane id model part' do
|
|
138
|
+
colon_model = 'llama3.2:8b-instruct'
|
|
139
|
+
id = identity.compose_lane_id(
|
|
140
|
+
tier: :local, provider_family: 'vllm', instance_id: 'h200', type: :inference, model: colon_model
|
|
141
|
+
)
|
|
142
|
+
expect(build_selection(lane_id: id, model: colon_model).lane_id).to eq(id)
|
|
110
143
|
end
|
|
111
144
|
|
|
112
145
|
it 'rejects a publisher_token_id without the ptok:v1: prefix' do
|