lex-llm 0.7.6 → 0.8.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/CHANGELOG.md +87 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- 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 +6 -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 +172 -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 +34 -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 +57 -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_spec.rb +68 -148
- 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/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -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 +118 -123
- 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 +105 -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 +98 -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 +11 -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
|
@@ -21,7 +21,6 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
|
21
21
|
snapshot = described_class.snapshot
|
|
22
22
|
expect(snapshot.generation).to eq(0)
|
|
23
23
|
expect(snapshot.each_lane.to_a).to be_empty
|
|
24
|
-
expect(snapshot.each_offering.to_a).to be_empty
|
|
25
24
|
expect(snapshot.instance(instance_key: key)).to be_nil
|
|
26
25
|
end
|
|
27
26
|
end
|
|
@@ -63,33 +62,67 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
|
63
62
|
.to raise_error(errors::InvalidTransitionError)
|
|
64
63
|
end
|
|
65
64
|
|
|
66
|
-
it 'atomically publishes callable,
|
|
65
|
+
it 'atomically publishes callable, lanes, and available on activation' do
|
|
67
66
|
claim_and_activate(key: key, callable: callable, coordinator: coordinator)
|
|
68
67
|
snapshot = described_class.snapshot
|
|
69
68
|
record = snapshot.instance(instance_key: key)
|
|
70
69
|
expect(record).not_to be_nil
|
|
71
70
|
expect(record.availability.state).to eq(:available)
|
|
72
71
|
expect(snapshot.lanes_for(instance_key: key).size).to eq(1)
|
|
73
|
-
expect(snapshot.offerings_for(instance_key: key).size).to eq(1)
|
|
74
72
|
expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
|
|
75
73
|
end
|
|
76
74
|
|
|
77
|
-
it '
|
|
78
|
-
|
|
75
|
+
it 'stores the lane keyed by the 5 tuple and exposes it through the record' do
|
|
76
|
+
claim_and_activate(key: key, callable: callable, coordinator: coordinator)
|
|
77
|
+
snapshot = described_class.snapshot
|
|
78
|
+
lane = snapshot.lanes_for(instance_key: key).first
|
|
79
|
+
expect(lane.lane_id).to eq('local:vllm:h200:inference:gemma4')
|
|
80
|
+
expect(snapshot.lane(lane_id: lane.lane_id)).to be(lane)
|
|
81
|
+
expect(snapshot.instance(instance_key: key).lanes_by_id.keys).to eq([lane.lane_id])
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'derives one lane per supported operation of each draft' do
|
|
79
85
|
claim_and_activate(
|
|
80
|
-
key: key, callable: callable, coordinator: coordinator,
|
|
81
|
-
weight_inputs: weights, base_weight: 172_500_000
|
|
86
|
+
key: key, callable: callable, coordinator: coordinator, supported: %i[chat embed]
|
|
82
87
|
)
|
|
83
88
|
snapshot = described_class.snapshot
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
expect(snapshot.lanes_for(instance_key: key).map(&:lane_id).sort).to eq(
|
|
90
|
+
%w[local:vllm:h200:embedding:gemma4 local:vllm:h200:inference:gemma4]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'raises on a duplicate (operation, model) published under a conflicting tier (D1)' do
|
|
95
|
+
key2 = instance_key(instance: 'tier-collision')
|
|
96
|
+
coordinator2 = probe_coordinator(key2)
|
|
97
|
+
token = described_class.claim_instance(instance_key: key2, callable: callable, probe_request_handle: coordinator2)
|
|
98
|
+
probe = described_class.readiness_probe_started(instance_key: key2, publisher_token: token)
|
|
99
|
+
expect do
|
|
100
|
+
described_class.activate_instance_snapshot(
|
|
101
|
+
publisher_token: token, instance_key: key2,
|
|
102
|
+
offerings: [
|
|
103
|
+
*drafts(model: 'm1', native: 'm1-a', supported: %i[chat], tier: :local),
|
|
104
|
+
*drafts(model: 'm1', native: 'm1-b', supported: %i[chat], tier: :frontier)
|
|
105
|
+
],
|
|
106
|
+
sequence: 0, probe_token: probe
|
|
107
|
+
)
|
|
108
|
+
end.to raise_error(inventory::Errors::ValidationError, /conflicting tier/)
|
|
109
|
+
end
|
|
86
110
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect
|
|
111
|
+
it 'raises on a duplicate 5 tuple from two native keys of the same model' do
|
|
112
|
+
key3 = instance_key(instance: 'dup-tuple')
|
|
113
|
+
coordinator3 = probe_coordinator(key3)
|
|
114
|
+
token = described_class.claim_instance(instance_key: key3, callable: callable, probe_request_handle: coordinator3)
|
|
115
|
+
probe = described_class.readiness_probe_started(instance_key: key3, publisher_token: token)
|
|
116
|
+
expect do
|
|
117
|
+
described_class.activate_instance_snapshot(
|
|
118
|
+
publisher_token: token, instance_key: key3,
|
|
119
|
+
offerings: [
|
|
120
|
+
*drafts(model: 'm1', native: 'm1-a', supported: %i[chat], tier: :local),
|
|
121
|
+
*drafts(model: 'm1', native: 'm1-b', supported: %i[chat], tier: :local)
|
|
122
|
+
],
|
|
123
|
+
sequence: 0, probe_token: probe
|
|
124
|
+
)
|
|
125
|
+
end.to raise_error(inventory::Errors::ValidationError, /duplicate lane_id/)
|
|
93
126
|
end
|
|
94
127
|
|
|
95
128
|
it 'accepts an explicit complete empty activation as complete, not initializing' do
|
|
@@ -101,7 +134,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
|
101
134
|
expect(result.applied).to be(true)
|
|
102
135
|
snapshot = described_class.snapshot
|
|
103
136
|
expect(snapshot.publication_status(instance_key: key).state).to eq(:complete)
|
|
104
|
-
expect(snapshot.instance(instance_key: key).
|
|
137
|
+
expect(snapshot.instance(instance_key: key).lanes_by_id).to eq({})
|
|
105
138
|
end
|
|
106
139
|
|
|
107
140
|
it 'consumes the probe token so it cannot be reused' do
|
|
@@ -23,8 +23,9 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
|
23
23
|
)
|
|
24
24
|
expect(result.applied).to be(true)
|
|
25
25
|
expect(result.reason).to eq(:snapshot_replaced)
|
|
26
|
-
|
|
27
|
-
expect(
|
|
26
|
+
lanes = described_class.snapshot.lanes_for(instance_key: key)
|
|
27
|
+
expect(lanes.map(&:model)).to eq(['gemma5'])
|
|
28
|
+
expect(lanes.map(&:lane_id)).to eq(%w[local:vllm:h200:inference:gemma5])
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
it 'preserves the exact availability fact across replacement' do
|
|
@@ -45,7 +46,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
|
45
46
|
it 'treats an empty array as an authoritative complete empty snapshot' do
|
|
46
47
|
token
|
|
47
48
|
described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: [], sequence: 1)
|
|
48
|
-
expect(described_class.snapshot.
|
|
49
|
+
expect(described_class.snapshot.lanes_for(instance_key: key)).to be_empty
|
|
49
50
|
expect(described_class.snapshot.instance(instance_key: key).availability.state).to eq(:available)
|
|
50
51
|
end
|
|
51
52
|
|
|
@@ -56,7 +57,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Registry do
|
|
|
56
57
|
expect { described_class.replace_instance_snapshot(publisher_token: token, instance_key: key, offerings: duplicate, sequence: 1) }
|
|
57
58
|
.to raise_error(errors::ValidationError)
|
|
58
59
|
expect(described_class.snapshot.generation).to eq(generation)
|
|
59
|
-
expect(described_class.snapshot.
|
|
60
|
+
expect(described_class.snapshot.lanes_for(instance_key: key).size).to eq(1)
|
|
60
61
|
end
|
|
61
62
|
|
|
62
63
|
it 'returns a stale publisher result for a superseded token' do
|
|
@@ -26,8 +26,7 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
|
|
|
26
26
|
record = snapshot.instance(instance_key: key_a)
|
|
27
27
|
expect(record).to be_frozen
|
|
28
28
|
expect(snapshot.instance(instance_key: instance_key(instance: 'absent'))).to be_nil
|
|
29
|
-
expect(snapshot.lane(lane_id: '
|
|
30
|
-
expect(snapshot.offering(offering_id: 'off:v1:missing')).to be_nil
|
|
29
|
+
expect(snapshot.lane(lane_id: 'local:absent:absent:inference:missing')).to be_nil
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
it 'only lists activated instances but keeps initializing claims in publication status' do
|
|
@@ -41,17 +40,24 @@ RSpec.describe Legion::Extensions::Llm::Inventory::Snapshot do
|
|
|
41
40
|
end
|
|
42
41
|
|
|
43
42
|
describe 'ordering and iteration' do
|
|
44
|
-
it 'returns frozen lanes_for
|
|
43
|
+
it 'returns a frozen lanes_for array ordered by 5-tuple id' do
|
|
45
44
|
snapshot = activate_both
|
|
46
45
|
lanes = snapshot.lanes_for(instance_key: key_a)
|
|
47
46
|
expect(lanes).to be_frozen
|
|
48
47
|
expect(lanes.map(&:lane_id)).to eq(lanes.map(&:lane_id).sort)
|
|
48
|
+
expect(lanes.map(&:lane_id)).to all(match(/\A[a-z_]+:[a-z_0-9]+:[^:]+:[a-z_]+:.+\z/))
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
it '
|
|
51
|
+
it 'exposes lanes only — no offering surface' do
|
|
52
|
+
snapshot = activate_both
|
|
53
|
+
expect(snapshot).not_to respond_to(:offering)
|
|
54
|
+
expect(snapshot).not_to respond_to(:offerings_for)
|
|
55
|
+
expect(snapshot).not_to respond_to(:each_offering)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'each_lane yields in id order and returns an Enumerator without a block' do
|
|
52
59
|
snapshot = activate_both
|
|
53
60
|
expect(snapshot.each_lane).to be_a(Enumerator)
|
|
54
|
-
expect(snapshot.each_offering).to be_a(Enumerator)
|
|
55
61
|
expect(snapshot.each_lane.map(&:lane_id)).to eq(snapshot.each_lane.map(&:lane_id).sort)
|
|
56
62
|
end
|
|
57
63
|
end
|
|
@@ -244,13 +244,17 @@ RSpec.describe 'Inventory::WeightReconciler' do
|
|
|
244
244
|
end
|
|
245
245
|
|
|
246
246
|
it 'uses the exact provider, instance, model, instance-model, and offering dormant keys' do
|
|
247
|
-
|
|
247
|
+
# The offering-scope key is the lane's 5 tuple — operator-readable.
|
|
248
|
+
offering_lane_id = inventory::Identity.compose_lane_id(
|
|
249
|
+
tier: :local, provider_family: 'vllm', instance_id: 'helios', type: :inference, model: 'gemma4'
|
|
250
|
+
)
|
|
251
|
+
expect(offering_lane_id).to eq('local:vllm:helios:inference:gemma4')
|
|
248
252
|
settings = settings_for(
|
|
249
253
|
provider: {
|
|
250
254
|
weight: false,
|
|
251
255
|
models: { gemma4: { weight: 101 } },
|
|
252
256
|
instances: { helios: { weight: 0, models: { gemma4: { weight: 102 } } } },
|
|
253
|
-
offerings: {
|
|
257
|
+
offerings: { offering_lane_id => { weight: 103 } }
|
|
254
258
|
},
|
|
255
259
|
tier: 777
|
|
256
260
|
)
|
|
@@ -266,7 +270,7 @@ RSpec.describe 'Inventory::WeightReconciler' do
|
|
|
266
270
|
[:vllm, :instance, 'helios'],
|
|
267
271
|
[:vllm, :model, 'gemma4'],
|
|
268
272
|
[:vllm, :instance, 'helios', :model, 'gemma4'],
|
|
269
|
-
[:vllm, :offering,
|
|
273
|
+
[:vllm, :offering, offering_lane_id]
|
|
270
274
|
)
|
|
271
275
|
expect(logged.flatten).not_to include(:tier)
|
|
272
276
|
|
|
@@ -12,25 +12,38 @@ RSpec.describe 'Inventory::WeightSchema' do
|
|
|
12
12
|
inventory::Identity::InstanceKey.new(provider_family: :vllm, instance_id: 'helios')
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
+
def operation_evidence(supported: %i[chat], **)
|
|
16
|
+
Legion::Extensions::Llm::Taxonomies::OPERATIONS.to_h do |op|
|
|
17
|
+
status, source = supported.include?(op) ? %i[supported provider_implementation] : %i[unknown absent]
|
|
18
|
+
[op, Legion::Extensions::Llm::Inventory::OperationEvidence.new(operation: op, status: status, source: source)]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
15
22
|
def settings_for(provider: {})
|
|
16
23
|
{ extensions: { llm: { vllm: provider } }, llm: { routing: { tier_weights: { direct: 100 } } } }
|
|
17
24
|
end
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
# The offering-scope settings key is the lane's 5 tuple (operator-readable).
|
|
27
|
+
def lane_id(tier: :direct, model: 'model-y', type: :inference)
|
|
28
|
+
Legion::Extensions::Llm::Inventory::Identity.compose_lane_id(
|
|
29
|
+
tier: tier, provider_family: 'vllm', instance_id: 'helios', type: type, model: model
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inputs(settings, operation: :chat, model: 'model-y')
|
|
20
34
|
schema.weight_inputs(
|
|
21
|
-
settings: settings, instance_key: instance_key,
|
|
22
|
-
|
|
35
|
+
settings: settings, instance_key: instance_key, model: model, tier: :direct,
|
|
36
|
+
operation_evidence: operation_evidence(supported: [operation])
|
|
23
37
|
)
|
|
24
38
|
end
|
|
25
39
|
|
|
26
40
|
it 'multiplies independent scopes without double-counting and lets offering override model' do
|
|
27
|
-
offering_id = inventory::Identity.offering_id(instance_key: instance_key, provider_native_key: 'deployment-x')
|
|
28
41
|
settings = settings_for(
|
|
29
42
|
provider: {
|
|
30
43
|
weight: 100,
|
|
31
44
|
models: { 'model-y' => { weight: 200 } },
|
|
32
45
|
instances: { helios: { weight: 115 } },
|
|
33
|
-
offerings: {
|
|
46
|
+
offerings: { lane_id => { weight: 300 } }
|
|
34
47
|
}
|
|
35
48
|
)
|
|
36
49
|
|
|
@@ -60,16 +73,53 @@ RSpec.describe 'Inventory::WeightSchema' do
|
|
|
60
73
|
expect(schema.base_weight(identity)).to eq(100_000_000)
|
|
61
74
|
end
|
|
62
75
|
|
|
63
|
-
it '
|
|
64
|
-
|
|
65
|
-
|
|
76
|
+
it 'treats the offering scope as the lane 5 tuple: a different model key does not apply' do
|
|
77
|
+
other_model_key = lane_id(model: 'other-model')
|
|
78
|
+
settings = settings_for(
|
|
79
|
+
provider: { offerings: { other_model_key => { weight: 999 }, lane_id => { weight: 321 } } }
|
|
66
80
|
)
|
|
67
|
-
model_id = inventory::Identity.offering_id(instance_key: instance_key, provider_native_key: 'model-y')
|
|
68
|
-
settings = settings_for(provider: { offerings: { deployment_id => { weight: 321 }, model_id => { weight: 999 } } })
|
|
69
81
|
|
|
70
82
|
expect(inputs(settings)[:model_or_offering]).to eq(321)
|
|
71
83
|
end
|
|
72
84
|
|
|
85
|
+
it 'derives the offering scope type from the operation: an embed key does not apply to a chat lane' do
|
|
86
|
+
embed_key = lane_id(type: :embedding)
|
|
87
|
+
settings = settings_for(provider: { offerings: { embed_key => { weight: 999 } } })
|
|
88
|
+
|
|
89
|
+
expect(inputs(settings, operation: :embed)[:model_or_offering]).to eq(999)
|
|
90
|
+
expect(inputs(settings)[:model_or_offering]).to eq(100) # chat draft: no inference key configured
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'falls through to the model scope when no offering-scope key is configured' do
|
|
94
|
+
settings = settings_for(provider: { models: { 'model-y' => { weight: 200 } } })
|
|
95
|
+
expect(inputs(settings)[:model_or_offering]).to eq(200)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'raises when a multi-type draft has differing configured weights across its lanes' do
|
|
99
|
+
inference_key = lane_id(type: :inference)
|
|
100
|
+
embedding_key = lane_id(type: :embedding)
|
|
101
|
+
settings = settings_for(
|
|
102
|
+
provider: { offerings: { inference_key => { weight: 100 }, embedding_key => { weight: 200 } } }
|
|
103
|
+
)
|
|
104
|
+
expect do
|
|
105
|
+
schema.weight_inputs(
|
|
106
|
+
settings: settings, instance_key: instance_key, model: 'model-y', tier: :direct,
|
|
107
|
+
operation_evidence: operation_evidence(supported: %i[chat embed])
|
|
108
|
+
)
|
|
109
|
+
end.to raise_error(ArgumentError, /ambiguous/)
|
|
110
|
+
|
|
111
|
+
# Equal weights across the draft's lanes resolve to that weight.
|
|
112
|
+
settings = settings_for(
|
|
113
|
+
provider: { offerings: { inference_key => { weight: 100 }, embedding_key => { weight: 100 } } }
|
|
114
|
+
)
|
|
115
|
+
expect(
|
|
116
|
+
schema.weight_inputs(
|
|
117
|
+
settings: settings, instance_key: instance_key, model: 'model-y', tier: :direct,
|
|
118
|
+
operation_evidence: operation_evidence(supported: %i[chat embed])
|
|
119
|
+
)[:model_or_offering]
|
|
120
|
+
).to eq(100)
|
|
121
|
+
end
|
|
122
|
+
|
|
73
123
|
it 'rejects every present malformed configuration scope instead of defaulting it' do
|
|
74
124
|
cases = {
|
|
75
125
|
'extensions.llm' => { extensions: { llm: false } },
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
6
|
+
let(:canonical) { Legion::Extensions::Llm::Canonical }
|
|
6
7
|
let(:provider_class) do
|
|
7
8
|
capability_object = Object.new
|
|
8
9
|
def capability_object.chat?(model) = !embeddings?(model)
|
|
@@ -18,89 +19,135 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
let(:provider) { provider_class.new(Legion::Extensions::Llm.config) }
|
|
21
|
-
let(:
|
|
22
|
+
let(:model_id) { 'model-a' }
|
|
22
23
|
|
|
23
|
-
it 'renders chat payloads for OpenAI-compatible servers' do
|
|
24
|
+
it 'renders chat payloads for OpenAI-compatible servers (canonical in, O4 temperature from Params)' do
|
|
24
25
|
payload = chat_payload
|
|
25
26
|
|
|
26
|
-
expect(payload.values_at(:model, :stream, :temperature)).to eq([
|
|
27
|
+
expect(payload.values_at(:model, :stream, :temperature)).to eq([model_id, false, 0.2])
|
|
27
28
|
expect(payload[:messages]).to eq([{ role: 'user', content: 'hello' }])
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
it 'preserves assistant think tags in outbound OpenAI-compatible history' do
|
|
31
|
-
message =
|
|
32
|
+
message = canonical::Message.build(
|
|
32
33
|
role: :assistant,
|
|
33
|
-
content: "internal\n</
|
|
34
|
+
content: "internal\n</thinking>\n\nHello"
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
payload = provider.send(
|
|
37
38
|
:render_payload,
|
|
38
39
|
[message],
|
|
39
40
|
tools: {},
|
|
40
|
-
|
|
41
|
-
model: model,
|
|
41
|
+
model: model_id,
|
|
42
42
|
stream: false,
|
|
43
43
|
schema: nil,
|
|
44
44
|
thinking: nil,
|
|
45
|
-
tool_prefs: nil
|
|
45
|
+
tool_prefs: nil,
|
|
46
|
+
params: canonical::Params.build(temperature: 0.2)
|
|
46
47
|
)
|
|
47
48
|
|
|
48
|
-
expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</
|
|
49
|
+
expect(payload[:messages]).to eq([{ role: 'assistant', content: "internal\n</thinking>\n\nHello" }])
|
|
49
50
|
end
|
|
50
51
|
|
|
51
|
-
it '
|
|
52
|
-
message =
|
|
52
|
+
it 'renders content block arrays in the OpenAI wire shape' do
|
|
53
|
+
message = canonical::Message.build(
|
|
54
|
+
role: :user,
|
|
55
|
+
content: [canonical::ContentBlock.text('look at this'),
|
|
56
|
+
canonical::ContentBlock.image(data: 'AAA=', media_type: 'image/png')]
|
|
57
|
+
)
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
payload = provider.send(
|
|
60
|
+
:render_payload,
|
|
61
|
+
[message],
|
|
62
|
+
tools: {},
|
|
63
|
+
model: model_id,
|
|
64
|
+
stream: false,
|
|
65
|
+
schema: nil,
|
|
66
|
+
thinking: nil,
|
|
67
|
+
tool_prefs: nil,
|
|
68
|
+
params: nil
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Text blocks render as plain strings; non-text blocks as OpenAI content parts.
|
|
72
|
+
expect(payload[:messages].first[:content]).to eq(
|
|
73
|
+
['look at this', { type: 'image', data: 'AAA=', media_type: 'image/png', source_type: :base64 }]
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'parses chat completion responses to a Canonical::Response with usage and tool calls (08 R2)' do
|
|
78
|
+
response = provider.send(:parse_completion_response, fake_response(completion_body))
|
|
79
|
+
|
|
80
|
+
expect(response).to be_a(canonical::Response)
|
|
81
|
+
expect([response.text, response.usage.input_tokens, response.usage.output_tokens]).to eq(['hi', 3, 5])
|
|
82
|
+
expect(response.tool_calls.first).to be_a(canonical::ToolCall)
|
|
83
|
+
expect(response.tool_calls.first.arguments).to eq('id' => 1)
|
|
84
|
+
expect(response.stop_reason).to be_nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'maps finish_reason through the StopReasonMapping vocabulary' do
|
|
88
|
+
response = provider.send(
|
|
89
|
+
:parse_completion_response,
|
|
90
|
+
fake_response({
|
|
91
|
+
'model' => 'model-a',
|
|
92
|
+
'choices' => [{ 'message' => { 'content' => 'hi' }, 'finish_reason' => 'tool_calls' }]
|
|
93
|
+
})
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
expect(response.stop_reason).to eq(:tool_use)
|
|
56
97
|
end
|
|
57
98
|
|
|
58
99
|
it 'strips malformed trailing think close tags from chat completion responses' do
|
|
59
|
-
|
|
100
|
+
response = provider.send(
|
|
60
101
|
:parse_completion_response,
|
|
61
|
-
fake_response(completion_body(content: "hidden only\n</
|
|
102
|
+
fake_response(completion_body(content: "hidden only\n</thinking>\n\nvisible"))
|
|
62
103
|
)
|
|
63
104
|
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
105
|
+
expect(response.text).to eq('visible')
|
|
106
|
+
expect(response.thinking.content).to eq('hidden only')
|
|
66
107
|
end
|
|
67
108
|
|
|
68
109
|
it 'strips truncated think tags from chat completion responses' do
|
|
69
|
-
|
|
110
|
+
response = provider.send(
|
|
70
111
|
:parse_completion_response,
|
|
71
|
-
fake_response(completion_body(content: "visible\n<
|
|
112
|
+
fake_response(completion_body(content: "visible\n<thinking>hidden only"))
|
|
72
113
|
)
|
|
73
114
|
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
115
|
+
expect(response.text).to eq('visible')
|
|
116
|
+
expect(response.thinking.content).to eq('hidden only')
|
|
76
117
|
end
|
|
77
118
|
|
|
78
119
|
it 'does not leak streamed think-tag content split across chunks' do
|
|
79
120
|
accumulator = Legion::Extensions::Llm::StreamAccumulator.new
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
accumulator.
|
|
121
|
+
emitted = []
|
|
122
|
+
|
|
123
|
+
think_tag_stream.each do |content|
|
|
124
|
+
emitted.concat(accumulator.add(provider.send(:build_chunk, stream_delta(content: content))))
|
|
84
125
|
end
|
|
85
126
|
|
|
86
|
-
message = accumulator.
|
|
127
|
+
message = accumulator.to_response(model: model_id)
|
|
87
128
|
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
90
|
-
expect(message.
|
|
91
|
-
expect(message.thinking.
|
|
129
|
+
expect(emitted.select(&:text_delta?).map(&:delta)).to eq(['visible'])
|
|
130
|
+
expect(emitted.select(&:thinking_delta?).map(&:delta).join).to eq('internal')
|
|
131
|
+
expect(message.text).to eq('visible')
|
|
132
|
+
expect(message.thinking.content).to eq('internal')
|
|
92
133
|
end
|
|
93
134
|
|
|
94
|
-
it '
|
|
95
|
-
chunk =
|
|
135
|
+
it 'builds canonical chunks from streamed data (O5 — canonical out)' do
|
|
136
|
+
chunk = provider.send(:build_chunk, stream_delta(content: 'hello'))
|
|
137
|
+
expect(chunk).to be_a(canonical::Chunk)
|
|
138
|
+
expect(chunk.type).to eq(:text_delta)
|
|
139
|
+
expect(chunk.delta).to eq('hello')
|
|
96
140
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
141
|
+
usage_chunk = provider.send(:build_chunk, { 'choices' => [], 'usage' => { 'prompt_tokens' => 2 } })
|
|
142
|
+
expect(usage_chunk.type).to eq(:usage)
|
|
143
|
+
expect(usage_chunk.usage.input_tokens).to eq(2)
|
|
144
|
+
end
|
|
100
145
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
expect(chunk
|
|
146
|
+
it 'emits tool_call_delta chunks carrying the wire fragment (U1/U2 shapes)' do
|
|
147
|
+
chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
|
|
148
|
+
expect(chunk).to be_a(canonical::Chunk)
|
|
149
|
+
expect(chunk.type).to eq(:tool_call_delta)
|
|
150
|
+
expect(chunk.tool_call).to eq(id: nil, name: nil, arguments: '{}', index: 0)
|
|
104
151
|
end
|
|
105
152
|
|
|
106
153
|
it 'accumulates streamed OpenAI-compatible tool call argument fragments' do
|
|
@@ -110,52 +157,51 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
110
157
|
accumulator.add(provider.send(:build_chunk, stream_delta(tool_calls: [delta])))
|
|
111
158
|
end
|
|
112
159
|
|
|
113
|
-
message = accumulator.
|
|
114
|
-
expect(message.tool_calls
|
|
160
|
+
message = accumulator.to_response(model: model_id)
|
|
161
|
+
expect(message.tool_calls.first.name).to eq('lookup')
|
|
162
|
+
expect(message.tool_calls.first.arguments).to eq('city' => 'Chicago')
|
|
115
163
|
end
|
|
116
164
|
|
|
117
165
|
it 'renders embedding payloads with model ids' do
|
|
118
|
-
payload = provider.send(:render_embedding_payload, 'hello', model:
|
|
166
|
+
payload = provider.send(:render_embedding_payload, 'hello', model: model_id, dimensions: 768)
|
|
119
167
|
|
|
120
|
-
expect(payload).to eq(model:
|
|
168
|
+
expect(payload).to eq(model: model_id, input: 'hello', dimensions: 768)
|
|
121
169
|
end
|
|
122
170
|
|
|
123
|
-
it 'parses embedding responses
|
|
171
|
+
it 'parses embedding responses to the documented artifact Hash (05 §3)' do
|
|
124
172
|
single = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: 'a')
|
|
125
173
|
batch = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: %w[a b])
|
|
126
174
|
|
|
127
|
-
expect([
|
|
175
|
+
expect(single[:embedding]).to eq([0.1])
|
|
176
|
+
expect(batch[:embedding]).to eq([[0.1], [0.2]])
|
|
177
|
+
expect(single[:usage]).to be_a(canonical::Usage)
|
|
128
178
|
end
|
|
129
179
|
|
|
130
|
-
it '
|
|
131
|
-
|
|
132
|
-
|
|
180
|
+
it 'parses moderation responses to the documented artifact Hash (05 §3)' do
|
|
181
|
+
body = { 'model' => 'mod-1', 'results' => [{ 'flagged' => true, 'categories' => { 'hate' => true } }] }
|
|
182
|
+
result = provider.send(:parse_moderation_response, fake_response(body), model: 'mod-1')
|
|
133
183
|
|
|
134
|
-
expect(
|
|
135
|
-
expect(models.map { |model| model.modalities.to_h }).to eq([
|
|
136
|
-
{ input: %w[text image], output: %w[text] },
|
|
137
|
-
{ input: %w[text], output: %w[embeddings] }
|
|
138
|
-
])
|
|
184
|
+
expect(result).to eq(model: 'mod-1', result: { flagged: true, categories: { 'hate' => true } })
|
|
139
185
|
end
|
|
140
186
|
|
|
141
187
|
def chat_payload
|
|
142
|
-
message =
|
|
188
|
+
message = canonical::Message.build(role: :user, content: 'hello')
|
|
143
189
|
provider.send(
|
|
144
190
|
:render_payload,
|
|
145
191
|
[message],
|
|
146
192
|
tools: {},
|
|
147
|
-
|
|
148
|
-
model: model,
|
|
193
|
+
model: model_id,
|
|
149
194
|
stream: false,
|
|
150
195
|
schema: nil,
|
|
151
196
|
thinking: nil,
|
|
152
|
-
tool_prefs: nil
|
|
197
|
+
tool_prefs: nil,
|
|
198
|
+
params: canonical::Params.build(temperature: 0.2)
|
|
153
199
|
)
|
|
154
200
|
end
|
|
155
201
|
|
|
156
202
|
def completion_body(content: 'hi')
|
|
157
203
|
{
|
|
158
|
-
'model' =>
|
|
204
|
+
'model' => model_id,
|
|
159
205
|
'choices' => [{ 'message' => { 'content' => content, 'tool_calls' => [tool_call] } }],
|
|
160
206
|
'usage' => { 'prompt_tokens' => 3, 'completion_tokens' => 5 }
|
|
161
207
|
}
|
|
@@ -177,26 +223,17 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
177
223
|
end
|
|
178
224
|
|
|
179
225
|
def think_tag_stream
|
|
180
|
-
['<
|
|
226
|
+
['<thinking>', 'internal', '</thinking>visible']
|
|
181
227
|
end
|
|
182
228
|
|
|
183
229
|
def stream_delta(content: nil, tool_calls: nil)
|
|
184
|
-
{ 'model' =>
|
|
230
|
+
{ 'model' => model_id, 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
|
|
185
231
|
end
|
|
186
232
|
|
|
187
233
|
def embedding_body
|
|
188
234
|
{ 'data' => [{ 'embedding' => [0.1] }, { 'embedding' => [0.2] }], 'usage' => { 'prompt_tokens' => 2 } }
|
|
189
235
|
end
|
|
190
236
|
|
|
191
|
-
def models_body
|
|
192
|
-
{
|
|
193
|
-
'data' => [
|
|
194
|
-
{ 'id' => 'chat-model', 'created' => 1 },
|
|
195
|
-
{ 'id' => 'embed-model', 'created' => 2 }
|
|
196
|
-
]
|
|
197
|
-
}
|
|
198
|
-
end
|
|
199
|
-
|
|
200
237
|
def fake_response(body)
|
|
201
238
|
Struct.new(:body).new(body)
|
|
202
239
|
end
|
|
@@ -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
|