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
|
@@ -1,178 +1,69 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'spec_helper'
|
|
4
|
+
require_relative '../conformance/conformance'
|
|
4
5
|
|
|
5
6
|
RSpec.describe Legion::Extensions::Llm::Canonical::Usage do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
it 'normalizes legacy key names' do
|
|
20
|
-
usage = described_class.from_hash(
|
|
21
|
-
input: 200, output: 100, cached: 50, cache_creation: 25, thinking: 10
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
expect(usage.input_tokens).to eq(200)
|
|
25
|
-
expect(usage.output_tokens).to eq(100)
|
|
26
|
-
expect(usage.cache_read_tokens).to eq(50)
|
|
27
|
-
expect(usage.cache_write_tokens).to eq(25)
|
|
28
|
-
expect(usage.thinking_tokens).to eq(10)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
it 'normalizes prompt_tokens/completion_tokens aliases' do
|
|
32
|
-
usage = described_class.from_hash(prompt_tokens: 300, completion_tokens: 150)
|
|
33
|
-
|
|
34
|
-
expect(usage.input_tokens).to eq(300)
|
|
35
|
-
expect(usage.output_tokens).to eq(150)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
it 'normalizes reasoning alias for thinking_tokens' do
|
|
39
|
-
usage = described_class.from_hash(reasoning: 75)
|
|
40
|
-
|
|
41
|
-
expect(usage.thinking_tokens).to eq(75)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
it 'handles string keys' do
|
|
45
|
-
usage = described_class.from_hash('input_tokens' => '100', 'output_tokens' => '50')
|
|
46
|
-
|
|
47
|
-
expect(usage.input_tokens).to eq('100')
|
|
48
|
-
expect(usage.output_tokens).to eq('50')
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
it 'returns nil for nil source' do
|
|
52
|
-
expect(described_class.from_hash(nil)).to be_nil
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
it 'returns nil for empty hash' do
|
|
56
|
-
expect(described_class.from_hash({})).to be_nil
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
it 'preserves units extension point' do
|
|
60
|
-
units = { images: 3, characters: 1500 }
|
|
61
|
-
usage = described_class.from_hash(input_tokens: 10, units: units)
|
|
62
|
-
|
|
63
|
-
expect(usage.units).to eq(units)
|
|
64
|
-
end
|
|
7
|
+
let(:type_class) { described_class }
|
|
8
|
+
let(:auto_generated_members) { [] }
|
|
9
|
+
let(:type_source) do
|
|
10
|
+
{
|
|
11
|
+
input_tokens: 100,
|
|
12
|
+
output_tokens: 50,
|
|
13
|
+
cache_read_tokens: 10,
|
|
14
|
+
cache_write_tokens: 5,
|
|
15
|
+
thinking_tokens: 25,
|
|
16
|
+
units: { images: 1 },
|
|
17
|
+
metadata: { source: 'provider' }
|
|
18
|
+
}
|
|
65
19
|
end
|
|
66
20
|
|
|
67
|
-
|
|
68
|
-
it 'serializes to compact hash' do
|
|
69
|
-
usage = described_class.new(
|
|
70
|
-
input_tokens: 100, output_tokens: 50,
|
|
71
|
-
cache_read_tokens: nil, cache_write_tokens: nil,
|
|
72
|
-
thinking_tokens: nil, units: {}
|
|
73
|
-
)
|
|
74
|
-
hash = usage.to_h
|
|
75
|
-
|
|
76
|
-
expect(hash).to eq(input_tokens: 100, output_tokens: 50, units: {})
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
it 'includes all non-nil fields' do
|
|
80
|
-
usage = described_class.new(
|
|
81
|
-
input_tokens: 200, output_tokens: 100,
|
|
82
|
-
cache_read_tokens: 50, cache_write_tokens: 25,
|
|
83
|
-
thinking_tokens: 10, units: { images: 2 }
|
|
84
|
-
)
|
|
85
|
-
hash = usage.to_h
|
|
21
|
+
it_behaves_like 'a canonical type'
|
|
86
22
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
23
|
+
describe 'H1/M3 — Integer token validation in every constructor' do
|
|
24
|
+
it 'rejects a string token count in build, from_hash, and .new' do
|
|
25
|
+
expect { described_class.build(input_tokens: 'many') }
|
|
26
|
+
.to raise_error(ArgumentError, /input_tokens expected Integer, got String/)
|
|
27
|
+
expect { described_class.from_hash(output_tokens: 3.5) }
|
|
28
|
+
.to raise_error(ArgumentError, /output_tokens expected Integer, got Float/)
|
|
29
|
+
expect { described_class.new(thinking_tokens: 'x', units: 42) }
|
|
30
|
+
.to raise_error(ArgumentError)
|
|
92
31
|
end
|
|
93
32
|
end
|
|
94
33
|
|
|
95
|
-
describe '
|
|
96
|
-
it '
|
|
97
|
-
usage = described_class.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
expect(usage.total_tokens).to eq(185)
|
|
34
|
+
describe 'O03a — canonical keys only' do
|
|
35
|
+
it 'does not translate provider spellings (they fold into metadata)' do
|
|
36
|
+
usage = described_class.from_hash(prompt_tokens: 7, completion_tokens: 3)
|
|
37
|
+
expect(usage.input_tokens).to be_nil
|
|
38
|
+
expect(usage.output_tokens).to be_nil
|
|
39
|
+
expect(usage.metadata).to eq(prompt_tokens: 7, completion_tokens: 3)
|
|
104
40
|
end
|
|
105
41
|
|
|
106
|
-
it '
|
|
107
|
-
usage = described_class.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
thinking_tokens: nil, units: {}
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
expect(usage.total_tokens).to eq(150)
|
|
42
|
+
it 'does not dig nested *_tokens_details (edge concern)' do
|
|
43
|
+
usage = described_class.from_hash(prompt_tokens_details: { cached_tokens: 9 })
|
|
44
|
+
expect(usage.cache_read_tokens).to be_nil
|
|
45
|
+
expect(usage.metadata).to eq(prompt_tokens_details: { cached_tokens: 9 })
|
|
114
46
|
end
|
|
115
47
|
end
|
|
116
48
|
|
|
117
|
-
describe '
|
|
118
|
-
it '
|
|
119
|
-
usage = described_class.from_hash(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
completion_tokens_details: { reasoning_tokens: 50 }
|
|
124
|
-
)
|
|
125
|
-
|
|
126
|
-
expect(usage.input_tokens).to eq(1000)
|
|
127
|
-
expect(usage.output_tokens).to eq(200)
|
|
128
|
-
expect(usage.cache_read_tokens).to eq(800)
|
|
129
|
-
expect(usage.thinking_tokens).to eq(50)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
it 'extracts cached_tokens from input_tokens_details (Responses API)' do
|
|
133
|
-
usage = described_class.from_hash(
|
|
134
|
-
input_tokens: 500,
|
|
135
|
-
output_tokens: 100,
|
|
136
|
-
input_tokens_details: { cached_tokens: 400 },
|
|
137
|
-
output_tokens_details: { reasoning_tokens: 30 }
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
expect(usage.input_tokens).to eq(500)
|
|
141
|
-
expect(usage.output_tokens).to eq(100)
|
|
142
|
-
expect(usage.cache_read_tokens).to eq(400)
|
|
143
|
-
expect(usage.thinking_tokens).to eq(30)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
it 'prefers top-level cache_read_tokens over nested details' do
|
|
147
|
-
usage = described_class.from_hash(
|
|
148
|
-
input_tokens: 500,
|
|
149
|
-
cache_read_tokens: 300,
|
|
150
|
-
prompt_tokens_details: { cached_tokens: 400 }
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
expect(usage.cache_read_tokens).to eq(300)
|
|
49
|
+
describe 'construction law' do
|
|
50
|
+
it 'from_hash({}) is a valid all-nil Usage with empty units (never nil)' do
|
|
51
|
+
usage = described_class.from_hash({})
|
|
52
|
+
expect(usage).to be_a(described_class)
|
|
53
|
+
expect(usage.input_tokens).to be_nil
|
|
54
|
+
expect(usage.units).to eq({})
|
|
154
55
|
end
|
|
155
|
-
end
|
|
156
56
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
usage = described_class.from_hash(original)
|
|
161
|
-
serialized = usage.to_h
|
|
162
|
-
|
|
163
|
-
expect(serialized[:input_tokens]).to eq(100)
|
|
164
|
-
expect(serialized[:output_tokens]).to eq(50)
|
|
165
|
-
expect(serialized[:cache_read_tokens]).to eq(10)
|
|
57
|
+
it 'builds from keyword args' do
|
|
58
|
+
usage = described_class.build(input_tokens: 1, output_tokens: 2)
|
|
59
|
+
expect(usage.total_tokens).to eq(3)
|
|
166
60
|
end
|
|
167
61
|
|
|
168
|
-
it '
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
expect(serialized[:input_tokens]).to eq(200)
|
|
174
|
-
expect(serialized[:output_tokens]).to eq(100)
|
|
175
|
-
expect(serialized[:cache_read_tokens]).to eq(50)
|
|
62
|
+
it 'survives build → to_h → JSON → from_hash (T4 units)' do
|
|
63
|
+
usage = described_class.build(input_tokens: 1, units: { images: 2 })
|
|
64
|
+
round_tripped = described_class.from_hash(Legion::JSON.load(Legion::JSON.dump(usage.to_h)))
|
|
65
|
+
expect(round_tripped.units).to eq(images: 2)
|
|
66
|
+
expect(round_tripped.input_tokens).to eq(1)
|
|
176
67
|
end
|
|
177
68
|
end
|
|
178
69
|
end
|
|
@@ -15,11 +15,13 @@ RSpec.describe Legion::Extensions::Llm::Configuration do
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
it 'exposes a discoverable options API' do
|
|
18
|
-
expect(described_class.options).to include(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
expect(described_class.options).to include(:request_timeout)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'declares no model-defaulting options (H4: model choice belongs to the router)' do
|
|
22
|
+
expect(described_class.options).not_to include(
|
|
23
|
+
:default_model, :default_embedding_model, :default_moderation_model,
|
|
24
|
+
:default_image_model, :default_transcription_model
|
|
23
25
|
)
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -36,6 +38,30 @@ RSpec.describe Legion::Extensions::Llm::Configuration do
|
|
|
36
38
|
end
|
|
37
39
|
end
|
|
38
40
|
|
|
41
|
+
describe 'L5 — log defaults come from the settings system (no ENV reads)' do
|
|
42
|
+
around do |example|
|
|
43
|
+
original_extensions = Legion::Settings.loader.settings[:extensions]
|
|
44
|
+
example.run
|
|
45
|
+
ensure
|
|
46
|
+
Legion::Settings.loader.settings[:extensions] = original_extensions
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'defaults log_level to INFO and log_stream_debug to false when settings are unset' do
|
|
50
|
+
Legion::Settings.loader.settings[:extensions] = {}
|
|
51
|
+
expect(described_class.log_settings_defaults).to eq(level: Logger::INFO, stream_debug: false)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'reads log_level / log_stream_debug from extensions.llm settings' do
|
|
55
|
+
Legion::Settings.loader.settings[:extensions] = { llm: { log_level: :debug, log_stream_debug: true } }
|
|
56
|
+
expect(described_class.log_settings_defaults).to eq(level: Logger::DEBUG, stream_debug: true)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'raises on an unknown log_level name (configuration error, no fallback)' do
|
|
60
|
+
Legion::Settings.loader.settings[:extensions] = { llm: { log_level: :bogus } }
|
|
61
|
+
expect { described_class.log_settings_defaults }.to raise_error(NameError)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
39
65
|
describe '.register_provider_options' do
|
|
40
66
|
after do
|
|
41
67
|
# Clean up test options to avoid polluting other specs
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# 09 §1 — canonical type conformance: the T-groups as shared examples.
|
|
4
|
+
#
|
|
5
|
+
# The including spec supplies:
|
|
6
|
+
# let(:type_class) — the Canonical type
|
|
7
|
+
# let(:type_source) — a Hash exercising the members (from_hash input)
|
|
8
|
+
# optionally:
|
|
9
|
+
# let(:auto_generated_members) — member names filled by the factory when
|
|
10
|
+
# absent from the source (id, timestamp), excluded from member-equality
|
|
11
|
+
# checks.
|
|
12
|
+
#
|
|
13
|
+
# Type-specific laws (T4 member survival, T5 enum law, T7 member normalization
|
|
14
|
+
# for the type's normalizable members) are asserted in each type spec.
|
|
15
|
+
|
|
16
|
+
RSpec.shared_examples 'a canonical type' do
|
|
17
|
+
let(:type) { type_class }
|
|
18
|
+
let(:built) { type.from_hash(type_source) }
|
|
19
|
+
|
|
20
|
+
describe 'T1 — strict input' do
|
|
21
|
+
it 'raises a typed ArgumentError on nil (no nil factory)' do
|
|
22
|
+
expect { type.from_hash(nil) }
|
|
23
|
+
.to raise_error(ArgumentError, /expected Hash, got NilClass/)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'raises a typed ArgumentError naming the offending class on wrong-class input' do
|
|
27
|
+
expect { type.from_hash('not a hash') }
|
|
28
|
+
.to raise_error(ArgumentError, /expected Hash, got String/)
|
|
29
|
+
expect { type.from_hash(42) }
|
|
30
|
+
.to raise_error(ArgumentError, /expected Hash, got Integer/)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'never returns nil from from_hash (valid instances or typed raises only)' do
|
|
34
|
+
expect(type.from_hash(type_source)).not_to be_nil
|
|
35
|
+
result =
|
|
36
|
+
begin
|
|
37
|
+
type.from_hash({})
|
|
38
|
+
rescue ArgumentError
|
|
39
|
+
:typed_raise
|
|
40
|
+
end
|
|
41
|
+
expect(result).not_to be_nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe 'T2 — unknown-key policy (04 L5)' do
|
|
46
|
+
it 'folds unknown keys into metadata verbatim — no drops, no raises' do
|
|
47
|
+
source = type_source.merge(fleet_unknown_key: 'kept', other_unknown: [1, 2])
|
|
48
|
+
instance = type.from_hash(source)
|
|
49
|
+
|
|
50
|
+
expect(instance.metadata).to include(fleet_unknown_key: 'kept', other_unknown: [1, 2])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'round-trips the folded unknowns' do
|
|
54
|
+
source = type_source.merge(fleet_unknown_key: 'kept')
|
|
55
|
+
instance = type.from_hash(source)
|
|
56
|
+
expect(type.from_hash(instance.to_h).metadata).to include(fleet_unknown_key: 'kept')
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe 'T3 — round-trip identity (04 L7)' do
|
|
61
|
+
it 'build → to_h → from_hash → Data-equal' do
|
|
62
|
+
expect(type.from_hash(built.to_h)).to eq(built)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'to_h is a plain Hash with symbol keys (no member lost, no key invented)' do
|
|
66
|
+
hash = built.to_h
|
|
67
|
+
expect(hash).to be_a(Hash)
|
|
68
|
+
expect(hash.keys).to all(be_a(Symbol))
|
|
69
|
+
checkable = (type.members.map(&:to_sym) - auto_generated_members)
|
|
70
|
+
checkable.each do |member|
|
|
71
|
+
value = built.public_send(member)
|
|
72
|
+
next if value.nil?
|
|
73
|
+
|
|
74
|
+
expect(hash.key?(member)).to be(true)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe 'T6 — serialization uniformity (04 L8)' do
|
|
80
|
+
it 'as_json == to_h and to_json == to_h.to_json' do
|
|
81
|
+
expect(built.as_json).to eq(built.to_h)
|
|
82
|
+
expect(built.to_json).to eq(built.to_h.to_json)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# H1: .new is the single strict constructor — it runs the same member
|
|
87
|
+
# contract as the factories. Poison that passes an is_a? check is not
|
|
88
|
+
# constructible through ANY public path.
|
|
89
|
+
describe 'T8 — strict .new (H1: one validated constructor)' do
|
|
90
|
+
let(:member_kwargs) { type_source.transform_keys(&:to_sym) }
|
|
91
|
+
|
|
92
|
+
it 'rejects unknown members with a typed ArgumentError (no silent drops)' do
|
|
93
|
+
expect { type_class.new(**member_kwargs, not_a_member: true) }
|
|
94
|
+
.to raise_error(ArgumentError, /not_a_member/)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'rejects a non-Hash metadata member' do
|
|
98
|
+
expect { type_class.new(**member_kwargs, metadata: 'not-a-hash') }
|
|
99
|
+
.to raise_error(ArgumentError, /metadata expected Hash, got String/)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'constructs valid data (the factories and .new share one contract)' do
|
|
103
|
+
expect { type_class.new(**member_kwargs) }.not_to raise_error
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
#
|
|
11
11
|
# Usage:
|
|
12
12
|
# it_behaves_like 'a canonical client translator', MyClientTranslatorClass
|
|
13
|
-
# rubocop:disable Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
|
|
13
|
+
# rubocop:disable-next Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
|
|
14
14
|
RSpec.shared_examples 'a canonical client translator' do |translator_class|
|
|
15
15
|
let(:translator) { translator_class.new }
|
|
16
16
|
let(:canonical) { Legion::Extensions::Llm::Canonical }
|
|
@@ -429,4 +429,3 @@ RSpec.shared_examples 'a canonical client translator' do |translator_class|
|
|
|
429
429
|
end
|
|
430
430
|
end
|
|
431
431
|
end
|
|
432
|
-
# rubocop:enable Lint/NonLocalExitFromIterator
|
|
@@ -5,10 +5,17 @@
|
|
|
5
5
|
# Ship location: spec/legion/extensions/llm/conformance/
|
|
6
6
|
# Module: Canonical::Conformance
|
|
7
7
|
#
|
|
8
|
-
# Consumer pattern (in provider gem spec_helper)
|
|
8
|
+
# Consumer pattern (in provider gem spec_helper) — load the kit's example
|
|
9
|
+
# modules BY EXPLICIT NAME. Do NOT glob the kit dir: it also ships lex-llm's
|
|
10
|
+
# own self-test specs (echo_translator_spec, ssot_*_conformance_spec) plus
|
|
11
|
+
# their support files, which LoadError outside this repo.
|
|
9
12
|
# kit = File.join(Gem.loaded_specs['lex-llm'].full_gem_path,
|
|
10
13
|
# 'spec/legion/extensions/llm/conformance')
|
|
11
|
-
#
|
|
14
|
+
# %w[conformance.rb canonical_type_examples.rb client_translator_examples.rb
|
|
15
|
+
# provider_translator_examples.rb provider_tool_rendering_examples.rb
|
|
16
|
+
# ssot_contract_examples.rb ssot_provider_examples.rb].each do |f|
|
|
17
|
+
# require File.join(kit, f)
|
|
18
|
+
# end
|
|
12
19
|
#
|
|
13
20
|
# Then in specs:
|
|
14
21
|
# it_behaves_like 'a canonical provider translator', described_class
|
|
@@ -47,5 +54,6 @@ module Canonical
|
|
|
47
54
|
end
|
|
48
55
|
end
|
|
49
56
|
|
|
57
|
+
require_relative 'canonical_type_examples'
|
|
50
58
|
require_relative 'provider_translator_examples'
|
|
51
59
|
require_relative 'client_translator_examples'
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
23
|
"params": { "max_tokens": 1024, "temperature": 0.5 },
|
|
24
|
-
"thinking": { "effort": "low", "budget": 1024 },
|
|
24
|
+
"thinking": { "enabled": true, "effort": "low", "budget": 1024 },
|
|
25
25
|
"stream": false,
|
|
26
26
|
"conversation_id": "conv_fleet_rt_001",
|
|
27
27
|
"caller": { "type": "anthropic_messages", "client_id": "test_client" },
|
|
@@ -298,7 +298,7 @@ RSpec.shared_examples 'a canonical provider translator' do |translator_class|
|
|
|
298
298
|
it 'preserves tool call identity across chunks' do
|
|
299
299
|
tool_chunks = chunks.select { |c| c['type'] == 'tool_call_delta' }
|
|
300
300
|
parsed_chunks = tool_chunks.map { |c| translator.parse_chunk(c) }
|
|
301
|
-
ids = parsed_chunks.map { |c| c.tool_call&.id }
|
|
301
|
+
ids = parsed_chunks.map { |c| c.tool_call.is_a?(Hash) ? c.tool_call[:id] : c.tool_call&.id }
|
|
302
302
|
expect(ids.uniq.length).to eq(1)
|
|
303
303
|
end
|
|
304
304
|
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'legion/extensions/llm/inventory/registry'
|
|
5
|
+
require 'support/ssot_registry_helpers'
|
|
6
|
+
require_relative 'conformance'
|
|
7
|
+
require_relative 'ssot_contract_examples'
|
|
8
|
+
|
|
9
|
+
# Kit self-test: the B/F/R shared examples run against a real Provider-derived
|
|
10
|
+
# callable, the real Registry, and the real fleet responder/worker — proving
|
|
11
|
+
# the examples provider gems will load are executable and meaningful.
|
|
12
|
+
# rubocop:disable-next RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers -- kit host
|
|
13
|
+
RSpec.describe 'SSOT v4 contract conformance kit' do
|
|
14
|
+
include SsotRegistryHelpers
|
|
15
|
+
|
|
16
|
+
let(:registry) { Legion::Extensions::Llm::Inventory::Registry }
|
|
17
|
+
let(:family) { :fake_llm }
|
|
18
|
+
let(:key) do
|
|
19
|
+
Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(provider_family: family, instance_id: 'primary')
|
|
20
|
+
end
|
|
21
|
+
let(:coordinator) { probe_coordinator(key) }
|
|
22
|
+
let(:activated) { true }
|
|
23
|
+
|
|
24
|
+
# A real 0.8.0 callable: Provider-derived, canonical in and out, no HTTP.
|
|
25
|
+
let(:callable) do
|
|
26
|
+
Class.new(Legion::Extensions::Llm::Provider) do
|
|
27
|
+
def self.slug = 'kit'
|
|
28
|
+
def self.configuration_requirements = []
|
|
29
|
+
def api_base = 'https://kit.invalid'
|
|
30
|
+
|
|
31
|
+
def chat(messages, model:, tools: nil, **_rest)
|
|
32
|
+
enforce_canonical_messages!(messages)
|
|
33
|
+
enforce_canonical_tools!(tools)
|
|
34
|
+
Legion::Extensions::Llm::Canonical::Response.build(text: 'ok', model: model, stop_reason: :end_turn)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# rubocop:disable-next Lint/UnusedMethodArgument -- model: mirrors the 0.8.0 callable contract
|
|
38
|
+
def stream_chat(messages, model: nil, tools: nil, **_contract, &block)
|
|
39
|
+
enforce_canonical_messages!(messages)
|
|
40
|
+
enforce_canonical_tools!(tools)
|
|
41
|
+
block&.call(Legion::Extensions::Llm::Canonical::Chunk.text_delta(delta: 'ok', request_id: nil))
|
|
42
|
+
block&.call(Legion::Extensions::Llm::Canonical::Chunk.done(request_id: nil, stop_reason: :end_turn,
|
|
43
|
+
usage: Legion::Extensions::Llm::Canonical::Usage.build(input_tokens: 1)))
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def count_tokens(messages:, **_contract)
|
|
48
|
+
enforce_canonical_messages!(messages)
|
|
49
|
+
1
|
|
50
|
+
end
|
|
51
|
+
end.new(Legion::Extensions::Llm.config)
|
|
52
|
+
end
|
|
53
|
+
let(:instance_key) { key }
|
|
54
|
+
let(:lane_id) { registry.snapshot.lanes_for(instance_key: key).first.lane_id }
|
|
55
|
+
# The fleet claim field keeps the name offering_id (D4 protocol continuity);
|
|
56
|
+
# its value is the lane's 5 tuple.
|
|
57
|
+
let(:offering_id) { lane_id }
|
|
58
|
+
let(:callable_handle) { registry.snapshot.instance(instance_key: key).callable_handle }
|
|
59
|
+
let(:operation_evidence_map) { operation_evidence }
|
|
60
|
+
let(:unknown_value) do
|
|
61
|
+
Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
|
|
62
|
+
end
|
|
63
|
+
let(:exact_envelope_data) do
|
|
64
|
+
{
|
|
65
|
+
request_id: 'req-1', correlation_id: 'corr-1', idempotency_key: 'idem-1',
|
|
66
|
+
operation: 'chat', provider: family.to_s, provider_instance: 'primary', model: 'gemma4',
|
|
67
|
+
params: { messages: [{ role: 'user', content: 'hello' }] },
|
|
68
|
+
reply_to: 'reply.1', message_context: {}, caller: 'e2e', trace_context: {},
|
|
69
|
+
signed_token: 'unsigned', timeout_seconds: 30,
|
|
70
|
+
expires_at: (Time.now.utc + 120).iso8601,
|
|
71
|
+
protocol_version: Legion::Extensions::Llm::Fleet::Protocol::VERSION,
|
|
72
|
+
execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
|
|
73
|
+
offering_id: offering_id
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
let(:valid_request_options) do
|
|
77
|
+
{
|
|
78
|
+
routing_key: 'llm.fleet.inference.gemma4.ctx8192',
|
|
79
|
+
request_id: 'req-1', correlation_id: 'corr-1', idempotency_key: 'idem-1',
|
|
80
|
+
operation: :chat, provider: family.to_s, provider_instance: 'primary', model: 'gemma4',
|
|
81
|
+
params: { messages: [{ role: 'user', content: 'hello' }] },
|
|
82
|
+
reply_to: 'reply.1', message_context: {}, caller: 'e2e', trace_context: {},
|
|
83
|
+
signed_token: 'signed.jwt', timeout_seconds: 30, expires_at: '2026-08-20T12:00:00Z',
|
|
84
|
+
protocol_version: Legion::Extensions::Llm::Fleet::Protocol::VERSION,
|
|
85
|
+
execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
|
|
86
|
+
offering_id: offering_id
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
before do
|
|
91
|
+
registry.reset!
|
|
92
|
+
claim_and_activate(
|
|
93
|
+
key: key, callable: callable, coordinator: coordinator, model: 'gemma4',
|
|
94
|
+
weight_inputs: { tier: 100, provider: 50, instance: 2, model_or_offering: 1 },
|
|
95
|
+
base_weight: 10_000
|
|
96
|
+
)
|
|
97
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
|
|
98
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
99
|
+
.with(:fleet, :auth, :require_signed_token, default: true).and_return(false)
|
|
100
|
+
allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
|
|
101
|
+
.with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
|
|
102
|
+
Legion::Extensions::Llm::Fleet::WorkerExecution.reset_idempotency_cache!
|
|
103
|
+
Legion::Extensions::Llm::Fleet::TokenValidator.reset_replay_cache!
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it_behaves_like 'B1 — central canonical enforcement (08 F2)'
|
|
107
|
+
it_behaves_like 'B1b — central canonical tool enforcement (H3)'
|
|
108
|
+
it_behaves_like 'B2 — canonical outputs (05 O5, 08 R2)'
|
|
109
|
+
it_behaves_like 'B3 — operation preservation (PR #189 defect class)'
|
|
110
|
+
it_behaves_like 'B4 — no model re-derivation (PR #45 law)'
|
|
111
|
+
it_behaves_like 'B5 — no weight recomputation (PR #47 defect class)'
|
|
112
|
+
it_behaves_like 'B6 — zero-weight disable (U4)'
|
|
113
|
+
it_behaves_like 'B7 — health authority (RULES 9)'
|
|
114
|
+
it_behaves_like 'B8 — exact execution never downgrades (06 P2/W1)'
|
|
115
|
+
it_behaves_like 'B9 — no silent defaults (06 W5)'
|
|
116
|
+
it_behaves_like 'F1 — envelope round-trip (06 E2)'
|
|
117
|
+
it_behaves_like 'F2 — rehydration identity (06 W4, E01)'
|
|
118
|
+
it_behaves_like 'F3 — signing law (06 S3)'
|
|
119
|
+
it_behaves_like 'F4 — fencing (07 §3)'
|
|
120
|
+
it_behaves_like 'F5 — response envelope (06 E3/E4)'
|
|
121
|
+
it_behaves_like 'F6 — contract errors (06 §5)'
|
|
122
|
+
it_behaves_like 'F7 — retryability (06 F6)'
|
|
123
|
+
it_behaves_like 'R1 — state machine (07 §3)'
|
|
124
|
+
it_behaves_like 'R2 — identity (07 §1, 5-tuple law)'
|
|
125
|
+
it_behaves_like 'R3 — snapshot law (07 §5)'
|
|
126
|
+
it_behaves_like 'R4 — weight law (07 §4)'
|
|
127
|
+
it_behaves_like 'R5 — callable lifecycle (07 §6)'
|
|
128
|
+
end
|