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
|
@@ -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,158 @@ 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
|
-
|
|
52
|
-
|
|
52
|
+
describe 'reasoning_effort transposition (canonical Thinking::Config -> OpenAI wire)' do
|
|
53
|
+
def payload_for(thinking)
|
|
54
|
+
provider.send(:render_payload, [], tools: {}, model: model_id, stream: false,
|
|
55
|
+
schema: nil, thinking: thinking, tool_prefs: nil, params: nil)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'passes an explicit effort through' do
|
|
59
|
+
expect(payload_for(canonical::Thinking::Config.build(effort: 'high'))[:reasoning_effort]).to eq('high')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'derives effort from a budget-only config so it is never dropped' do
|
|
63
|
+
expect(payload_for(canonical::Thinking::Config.build(budget: 8192))[:reasoning_effort]).to eq('medium')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'omits reasoning_effort when thinking is explicitly disabled' do
|
|
67
|
+
expect(payload_for(canonical::Thinking::Config.build(enabled: false))).not_to have_key(:reasoning_effort)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'omits reasoning_effort when there is no thinking' do
|
|
71
|
+
expect(payload_for(nil)).not_to have_key(:reasoning_effort)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'renders content block arrays in the OpenAI wire shape' do
|
|
76
|
+
message = canonical::Message.build(
|
|
77
|
+
role: :user,
|
|
78
|
+
content: [canonical::ContentBlock.text('look at this'),
|
|
79
|
+
canonical::ContentBlock.image(data: 'AAA=', media_type: 'image/png')]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
payload = provider.send(
|
|
83
|
+
:render_payload,
|
|
84
|
+
[message],
|
|
85
|
+
tools: {},
|
|
86
|
+
model: model_id,
|
|
87
|
+
stream: false,
|
|
88
|
+
schema: nil,
|
|
89
|
+
thinking: nil,
|
|
90
|
+
tool_prefs: nil,
|
|
91
|
+
params: nil
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Text blocks render as plain strings; non-text blocks as OpenAI content parts.
|
|
95
|
+
expect(payload[:messages].first[:content]).to eq(
|
|
96
|
+
['look at this', { type: 'image', data: 'AAA=', media_type: 'image/png', source_type: :base64 }]
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'parses chat completion responses to a Canonical::Response with usage and tool calls (08 R2)' do
|
|
101
|
+
response = provider.send(:parse_completion_response, fake_response(completion_body))
|
|
102
|
+
|
|
103
|
+
expect(response).to be_a(canonical::Response)
|
|
104
|
+
expect([response.text, response.usage.input_tokens, response.usage.output_tokens]).to eq(['hi', 3, 5])
|
|
105
|
+
expect(response.tool_calls.first).to be_a(canonical::ToolCall)
|
|
106
|
+
expect(response.tool_calls.first.arguments).to eq('id' => 1)
|
|
107
|
+
expect(response.stop_reason).to be_nil
|
|
108
|
+
end
|
|
53
109
|
|
|
54
|
-
|
|
55
|
-
|
|
110
|
+
it 'maps finish_reason through the StopReasonMapping vocabulary' do
|
|
111
|
+
response = provider.send(
|
|
112
|
+
:parse_completion_response,
|
|
113
|
+
fake_response({
|
|
114
|
+
'model' => 'model-a',
|
|
115
|
+
'choices' => [{ 'message' => { 'content' => 'hi' }, 'finish_reason' => 'tool_calls' }]
|
|
116
|
+
})
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
expect(response.stop_reason).to eq(:tool_use)
|
|
56
120
|
end
|
|
57
121
|
|
|
58
122
|
it 'strips malformed trailing think close tags from chat completion responses' do
|
|
59
|
-
|
|
123
|
+
response = provider.send(
|
|
60
124
|
:parse_completion_response,
|
|
61
|
-
fake_response(completion_body(content: "hidden only\n</
|
|
125
|
+
fake_response(completion_body(content: "hidden only\n</thinking>\n\nvisible"))
|
|
62
126
|
)
|
|
63
127
|
|
|
64
|
-
expect(
|
|
65
|
-
expect(
|
|
128
|
+
expect(response.text).to eq('visible')
|
|
129
|
+
expect(response.thinking.content).to eq('hidden only')
|
|
66
130
|
end
|
|
67
131
|
|
|
68
132
|
it 'strips truncated think tags from chat completion responses' do
|
|
69
|
-
|
|
133
|
+
response = provider.send(
|
|
70
134
|
:parse_completion_response,
|
|
71
|
-
fake_response(completion_body(content: "visible\n<
|
|
135
|
+
fake_response(completion_body(content: "visible\n<thinking>hidden only"))
|
|
72
136
|
)
|
|
73
137
|
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
138
|
+
expect(response.text).to eq('visible')
|
|
139
|
+
expect(response.thinking.content).to eq('hidden only')
|
|
76
140
|
end
|
|
77
141
|
|
|
78
142
|
it 'does not leak streamed think-tag content split across chunks' do
|
|
79
143
|
accumulator = Legion::Extensions::Llm::StreamAccumulator.new
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
accumulator.
|
|
144
|
+
emitted = []
|
|
145
|
+
|
|
146
|
+
think_tag_stream.each do |content|
|
|
147
|
+
emitted.concat(accumulator.add(provider.send(:build_chunk, stream_delta(content: content))))
|
|
84
148
|
end
|
|
85
149
|
|
|
86
|
-
message = accumulator.
|
|
150
|
+
message = accumulator.to_response(model: model_id)
|
|
87
151
|
|
|
88
|
-
expect(
|
|
89
|
-
expect(
|
|
90
|
-
expect(message.
|
|
91
|
-
expect(message.thinking.
|
|
152
|
+
expect(emitted.select(&:text_delta?).map(&:delta)).to eq(['visible'])
|
|
153
|
+
expect(emitted.select(&:thinking_delta?).map(&:delta).join).to eq('internal')
|
|
154
|
+
expect(message.text).to eq('visible')
|
|
155
|
+
expect(message.thinking.content).to eq('internal')
|
|
92
156
|
end
|
|
93
157
|
|
|
94
|
-
it '
|
|
95
|
-
chunk =
|
|
158
|
+
it 'builds canonical chunks from streamed data (O5 — canonical out)' do
|
|
159
|
+
chunk = provider.send(:build_chunk, stream_delta(content: 'hello'))
|
|
160
|
+
expect(chunk).to be_a(canonical::Chunk)
|
|
161
|
+
expect(chunk.type).to eq(:text_delta)
|
|
162
|
+
expect(chunk.delta).to eq('hello')
|
|
96
163
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
164
|
+
usage_chunk = provider.send(:build_chunk, { 'choices' => [], 'usage' => { 'prompt_tokens' => 2 } })
|
|
165
|
+
expect(usage_chunk.type).to eq(:usage)
|
|
166
|
+
expect(usage_chunk.usage.input_tokens).to eq(2)
|
|
167
|
+
end
|
|
100
168
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
expect(chunk
|
|
169
|
+
it 'emits tool_call_delta chunks carrying the wire fragment (U1/U2 shapes)' do
|
|
170
|
+
chunk = provider.send(:build_chunk, stream_delta(tool_calls: [tool_call_delta_without_name]))
|
|
171
|
+
expect(chunk).to be_a(canonical::Chunk)
|
|
172
|
+
expect(chunk.type).to eq(:tool_call_delta)
|
|
173
|
+
expect(chunk.tool_call).to eq(id: nil, name: nil, arguments: '{}', index: 0)
|
|
104
174
|
end
|
|
105
175
|
|
|
106
176
|
it 'accumulates streamed OpenAI-compatible tool call argument fragments' do
|
|
@@ -110,52 +180,51 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
110
180
|
accumulator.add(provider.send(:build_chunk, stream_delta(tool_calls: [delta])))
|
|
111
181
|
end
|
|
112
182
|
|
|
113
|
-
message = accumulator.
|
|
114
|
-
expect(message.tool_calls
|
|
183
|
+
message = accumulator.to_response(model: model_id)
|
|
184
|
+
expect(message.tool_calls.first.name).to eq('lookup')
|
|
185
|
+
expect(message.tool_calls.first.arguments).to eq('city' => 'Chicago')
|
|
115
186
|
end
|
|
116
187
|
|
|
117
188
|
it 'renders embedding payloads with model ids' do
|
|
118
|
-
payload = provider.send(:render_embedding_payload, 'hello', model:
|
|
189
|
+
payload = provider.send(:render_embedding_payload, 'hello', model: model_id, dimensions: 768)
|
|
119
190
|
|
|
120
|
-
expect(payload).to eq(model:
|
|
191
|
+
expect(payload).to eq(model: model_id, input: 'hello', dimensions: 768)
|
|
121
192
|
end
|
|
122
193
|
|
|
123
|
-
it 'parses embedding responses
|
|
194
|
+
it 'parses embedding responses to the documented artifact Hash (05 §3)' do
|
|
124
195
|
single = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: 'a')
|
|
125
196
|
batch = provider.send(:parse_embedding_response, fake_response(embedding_body), model: 'embed', text: %w[a b])
|
|
126
197
|
|
|
127
|
-
expect([
|
|
198
|
+
expect(single[:embedding]).to eq([0.1])
|
|
199
|
+
expect(batch[:embedding]).to eq([[0.1], [0.2]])
|
|
200
|
+
expect(single[:usage]).to be_a(canonical::Usage)
|
|
128
201
|
end
|
|
129
202
|
|
|
130
|
-
it '
|
|
131
|
-
|
|
132
|
-
|
|
203
|
+
it 'parses moderation responses to the documented artifact Hash (05 §3)' do
|
|
204
|
+
body = { 'model' => 'mod-1', 'results' => [{ 'flagged' => true, 'categories' => { 'hate' => true } }] }
|
|
205
|
+
result = provider.send(:parse_moderation_response, fake_response(body), model: 'mod-1')
|
|
133
206
|
|
|
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
|
-
])
|
|
207
|
+
expect(result).to eq(model: 'mod-1', result: { flagged: true, categories: { 'hate' => true } })
|
|
139
208
|
end
|
|
140
209
|
|
|
141
210
|
def chat_payload
|
|
142
|
-
message =
|
|
211
|
+
message = canonical::Message.build(role: :user, content: 'hello')
|
|
143
212
|
provider.send(
|
|
144
213
|
:render_payload,
|
|
145
214
|
[message],
|
|
146
215
|
tools: {},
|
|
147
|
-
|
|
148
|
-
model: model,
|
|
216
|
+
model: model_id,
|
|
149
217
|
stream: false,
|
|
150
218
|
schema: nil,
|
|
151
219
|
thinking: nil,
|
|
152
|
-
tool_prefs: nil
|
|
220
|
+
tool_prefs: nil,
|
|
221
|
+
params: canonical::Params.build(temperature: 0.2)
|
|
153
222
|
)
|
|
154
223
|
end
|
|
155
224
|
|
|
156
225
|
def completion_body(content: 'hi')
|
|
157
226
|
{
|
|
158
|
-
'model' =>
|
|
227
|
+
'model' => model_id,
|
|
159
228
|
'choices' => [{ 'message' => { 'content' => content, 'tool_calls' => [tool_call] } }],
|
|
160
229
|
'usage' => { 'prompt_tokens' => 3, 'completion_tokens' => 5 }
|
|
161
230
|
}
|
|
@@ -177,26 +246,17 @@ RSpec.describe Legion::Extensions::Llm::Provider::OpenAICompatible do
|
|
|
177
246
|
end
|
|
178
247
|
|
|
179
248
|
def think_tag_stream
|
|
180
|
-
['<
|
|
249
|
+
['<thinking>', 'internal', '</thinking>visible']
|
|
181
250
|
end
|
|
182
251
|
|
|
183
252
|
def stream_delta(content: nil, tool_calls: nil)
|
|
184
|
-
{ 'model' =>
|
|
253
|
+
{ 'model' => model_id, 'choices' => [{ 'delta' => { 'content' => content, 'tool_calls' => tool_calls }.compact }] }
|
|
185
254
|
end
|
|
186
255
|
|
|
187
256
|
def embedding_body
|
|
188
257
|
{ 'data' => [{ 'embedding' => [0.1] }, { 'embedding' => [0.2] }], 'usage' => { 'prompt_tokens' => 2 } }
|
|
189
258
|
end
|
|
190
259
|
|
|
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
260
|
def fake_response(body)
|
|
201
261
|
Struct.new(:body).new(body)
|
|
202
262
|
end
|