lex-llm 0.7.3 → 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 +106 -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 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- 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 +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- 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 +29 -17
- 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 +10 -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 +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- 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 +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- 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 +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- 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 -16
- 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
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -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 -311
- 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 -337
- 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,250 +1,135 @@
|
|
|
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::Message do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
msg = described_class.build(
|
|
19
|
-
id: 'msg-1',
|
|
20
|
-
parent_id: 'msg-0',
|
|
21
|
-
role: :assistant,
|
|
22
|
-
content: 'response',
|
|
23
|
-
tool_calls: [],
|
|
24
|
-
conversation_id: 'conv-1'
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
expect(msg.id).to eq('msg-1')
|
|
28
|
-
expect(msg.parent_id).to eq('msg-0')
|
|
29
|
-
expect(msg.role).to eq(:assistant)
|
|
30
|
-
expect(msg.conversation_id).to eq('conv-1')
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
it 'normalizes string role to symbol' do
|
|
34
|
-
msg = described_class.build(role: 'user', content: 'hello')
|
|
35
|
-
|
|
36
|
-
expect(msg.role).to eq(:user)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
it 'rejects invalid roles' do
|
|
40
|
-
expect do
|
|
41
|
-
described_class.build(role: :invalid, content: 'hello')
|
|
42
|
-
end.to raise_error(ArgumentError, /Invalid role/)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
it 'defaults to user role' do
|
|
46
|
-
msg = described_class.build(content: 'hello')
|
|
47
|
-
|
|
48
|
-
expect(msg.role).to eq(:user)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
it 'sets timestamp automatically' do
|
|
52
|
-
msg = described_class.build(content: 'hello')
|
|
53
|
-
|
|
54
|
-
expect(msg.timestamp).to be_a(Time)
|
|
55
|
-
end
|
|
7
|
+
let(:type_class) { described_class }
|
|
8
|
+
let(:auto_generated_members) { %i[id timestamp] }
|
|
9
|
+
let(:type_source) do
|
|
10
|
+
{
|
|
11
|
+
id: 'msg_1',
|
|
12
|
+
role: 'user',
|
|
13
|
+
content: 'hello there',
|
|
14
|
+
tool_call_id: 'call_1',
|
|
15
|
+
conversation_id: 'conv_1',
|
|
16
|
+
cache_control: { type: 'ephemeral' },
|
|
17
|
+
metadata: { origin: 'client' }
|
|
18
|
+
}
|
|
56
19
|
end
|
|
57
20
|
|
|
58
|
-
|
|
59
|
-
it 'parses from hash with symbol keys' do
|
|
60
|
-
msg = described_class.from_hash(role: :user, content: 'hello')
|
|
21
|
+
it_behaves_like 'a canonical type'
|
|
61
22
|
|
|
62
|
-
|
|
63
|
-
|
|
23
|
+
describe 'H1 — .new is as strict as the factories (the bypass is closed)' do
|
|
24
|
+
it 'rejects a poison role that would pass every is_a? check' do
|
|
25
|
+
expect { described_class.new(role: :banana, content: 'x') }
|
|
26
|
+
.to raise_error(ArgumentError, /Invalid role: :banana/)
|
|
64
27
|
end
|
|
65
28
|
|
|
66
|
-
it '
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
expect(msg.role).to eq(:assistant)
|
|
29
|
+
it 'rejects a poison content member' do
|
|
30
|
+
expect { described_class.new(role: :user, content: :symbol) }
|
|
31
|
+
.to raise_error(ArgumentError, /content expected String \| ContentBlock \| Array, got Symbol/)
|
|
70
32
|
end
|
|
71
33
|
|
|
72
|
-
it '
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
content: [{ type: 'text', text: 'hello' }]
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
expect(msg.content).to be_an(Array)
|
|
79
|
-
expect(msg.content.first).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
|
|
80
|
-
expect(msg.content.first.type).to eq(:text)
|
|
34
|
+
it 'rejects the legacy Hash tool_calls shape' do
|
|
35
|
+
expect { described_class.new(role: :assistant, tool_calls: { a: { name: 'x' } }) }
|
|
36
|
+
.to raise_error(ArgumentError, /tool_calls expected Array, got Hash/)
|
|
81
37
|
end
|
|
82
38
|
|
|
83
|
-
it '
|
|
84
|
-
|
|
85
|
-
role: :user,
|
|
86
|
-
content: { type: 'text', text: 'hello' }
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
expect(msg.content).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
it 'parses tool calls from array of hashes' do
|
|
93
|
-
msg = described_class.from_hash(
|
|
94
|
-
role: :assistant,
|
|
95
|
-
tool_calls: [{ name: 'search', arguments: { query: 'test' } }]
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
expect(msg.tool_calls).to be_an(Array)
|
|
99
|
-
expect(msg.tool_calls.first).to be_a(Legion::Extensions::Llm::Canonical::ToolCall)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
it 'handles string keys' do
|
|
103
|
-
msg = described_class.from_hash('role' => 'user', 'content' => 'hello')
|
|
104
|
-
|
|
105
|
-
expect(msg.role).to eq(:user)
|
|
106
|
-
expect(msg.content).to eq('hello')
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
it 'drops the transport-only :cache_control key instead of raising ArgumentError' do
|
|
110
|
-
# Regression guard (root cause B): the prompt-cache step injects :cache_control
|
|
111
|
-
# onto every >=2-message request. from_hash previously did build(**h) with the
|
|
112
|
-
# whole hash, raising ArgumentError 'unknown keyword: :cache_control' before HTTP.
|
|
113
|
-
# It now projects onto the known member keys, so the transport-only key is dropped.
|
|
114
|
-
expect do
|
|
115
|
-
described_class.from_hash(
|
|
116
|
-
role: 'user',
|
|
117
|
-
content: 'hi',
|
|
118
|
-
cache_control: { type: 'ephemeral' }
|
|
119
|
-
)
|
|
120
|
-
end.not_to raise_error
|
|
121
|
-
|
|
122
|
-
msg = described_class.from_hash(
|
|
123
|
-
role: 'user',
|
|
124
|
-
content: 'hi',
|
|
125
|
-
cache_control: { type: 'ephemeral' }
|
|
126
|
-
)
|
|
127
|
-
|
|
128
|
-
expect(msg).to be_a(described_class)
|
|
129
|
-
expect(msg.role).to eq(:user)
|
|
130
|
-
expect(msg.text).to eq('hi')
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
it 'returns nil for nil source' do
|
|
134
|
-
expect(described_class.from_hash(nil)).to be_nil
|
|
39
|
+
it 'still constructs valid data (consumer .new calls keep working)' do
|
|
40
|
+
expect(described_class.new(role: :assistant, content: 'ok')).to be_a(described_class)
|
|
135
41
|
end
|
|
136
42
|
end
|
|
137
43
|
|
|
138
|
-
describe '
|
|
139
|
-
it '
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
44
|
+
describe 'T5 — role enum (validated in both factories)' do
|
|
45
|
+
it 'accepts the four roles as symbols or strings' do
|
|
46
|
+
described_class::ROLES.each do |role|
|
|
47
|
+
expect(described_class.build(role:).role).to eq(role)
|
|
48
|
+
expect(described_class.from_hash(role: role.to_s).role).to eq(role)
|
|
49
|
+
end
|
|
144
50
|
end
|
|
145
51
|
|
|
146
|
-
it '
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
expect(
|
|
150
|
-
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
it 'returns nil for unsupported input' do
|
|
154
|
-
expect(described_class.wrap('hello')).to be_nil
|
|
52
|
+
it 'raises on an unknown role in both factories' do
|
|
53
|
+
expect { described_class.build(role: :bogus) }
|
|
54
|
+
.to raise_error(ArgumentError, /Invalid role: :bogus/)
|
|
55
|
+
expect { described_class.from_hash(role: 'bogus') }
|
|
56
|
+
.to raise_error(ArgumentError, /Invalid role: :bogus/)
|
|
155
57
|
end
|
|
156
58
|
end
|
|
157
59
|
|
|
158
|
-
describe '
|
|
159
|
-
it '
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
60
|
+
describe 'L2 — one content/tool_calls normalizer shared by build and from_hash' do
|
|
61
|
+
it 'normalizes raw-Hash tool_calls to Array<ToolCall> in both factories (T7)' do
|
|
62
|
+
raw = [{ id: 'tc_1', name: 'get_weather', arguments: { location: 'sf' } }]
|
|
63
|
+
via_build = described_class.build(role: :assistant, tool_calls: raw)
|
|
64
|
+
via_hash = described_class.from_hash(role: 'assistant', tool_calls: raw)
|
|
65
|
+
expect(via_build.tool_calls).to eq(via_hash.tool_calls)
|
|
66
|
+
expect(via_build.tool_calls).to all(be_a(Legion::Extensions::Llm::Canonical::ToolCall))
|
|
67
|
+
expect(via_build.tool_calls.first.name).to eq('get_weather')
|
|
163
68
|
end
|
|
164
69
|
|
|
165
|
-
it '
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
msg = described_class.build(role: :user, content: blocks)
|
|
171
|
-
|
|
172
|
-
expect(msg.text).to eq('hello world')
|
|
70
|
+
it 'normalizes content block hashes in both factories' do
|
|
71
|
+
via_build = described_class.build(role: :user, content: [{ type: 'text', text: 'hi' }])
|
|
72
|
+
via_hash = described_class.from_hash(role: 'user', content: [{ type: 'text', text: 'hi' }])
|
|
73
|
+
expect(via_build.content).to eq(via_hash.content)
|
|
74
|
+
expect(via_build.content.first).to be_a(Legion::Extensions::Llm::Canonical::ContentBlock)
|
|
173
75
|
end
|
|
174
76
|
|
|
175
|
-
it '
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
msg = described_class.build(role: :user, content: blocks)
|
|
181
|
-
|
|
182
|
-
expect(msg.text).to eq('hello')
|
|
77
|
+
it 'raises on wrong-class content (the build-accepts-anything gap is closed)' do
|
|
78
|
+
expect { described_class.build(content: 42) }
|
|
79
|
+
.to raise_error(ArgumentError, /content expected String \| ContentBlock \| Array, got Integer/)
|
|
80
|
+
expect { described_class.from_hash(content: 42) }
|
|
81
|
+
.to raise_error(ArgumentError, /content expected String \| ContentBlock \| Array, got Integer/)
|
|
183
82
|
end
|
|
184
83
|
|
|
185
|
-
it '
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
expect(msg.text).to eq('')
|
|
84
|
+
it 'rejects the legacy Hash tool_calls shape (Array is canonical)' do
|
|
85
|
+
expect { described_class.build(tool_calls: { a: { name: 'x' } }) }
|
|
86
|
+
.to raise_error(ArgumentError, /tool_calls expected Array, got Hash/)
|
|
189
87
|
end
|
|
88
|
+
end
|
|
190
89
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
expect(msg.text).to eq("The seat templates don't")
|
|
198
|
-
expect(msg.text).not_to include('#<data')
|
|
199
|
-
expect(msg.text).not_to include('ContentBlock')
|
|
90
|
+
describe 'E01 — cache_control survives the full round-trip' do
|
|
91
|
+
it 'survives build → to_h → JSON → from_hash (the fleet-wire variant)' do
|
|
92
|
+
message = described_class.build(role: :user, content: 'hi', cache_control: { type: 'ephemeral' })
|
|
93
|
+
wire = Legion::JSON.load(Legion::JSON.dump(message.to_h))
|
|
94
|
+
expect(wire).to include(cache_control: { type: 'ephemeral' })
|
|
95
|
+
expect(described_class.from_hash(wire).cache_control).to eq(type: 'ephemeral')
|
|
200
96
|
end
|
|
201
97
|
|
|
202
|
-
it '
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
content: [
|
|
206
|
-
{ type: 'output_text', text: 'first ' },
|
|
207
|
-
{ type: 'text', text: 'second' }
|
|
208
|
-
]
|
|
209
|
-
)
|
|
210
|
-
|
|
211
|
-
expect(msg.text).to eq('first second')
|
|
98
|
+
it 'is carried by to_h (no projection drop)' do
|
|
99
|
+
message = described_class.build(role: :user, content: 'hi', cache_control: { type: 'ephemeral' })
|
|
100
|
+
expect(message.to_h).to include(cache_control: { type: 'ephemeral' })
|
|
212
101
|
end
|
|
213
102
|
end
|
|
214
103
|
|
|
215
|
-
describe '
|
|
216
|
-
it '
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
expect(hash).to include(id: msg.id, role: :user, content: 'hello')
|
|
104
|
+
describe 'F1 fix — unknown keys fold into metadata (never dropped)' do
|
|
105
|
+
it 'folds unknowns on from_hash' do
|
|
106
|
+
message = described_class.from_hash(role: 'user', content: 'hi', fleet_wire_key: 'kept')
|
|
107
|
+
expect(message.metadata).to eq(fleet_wire_key: 'kept')
|
|
221
108
|
end
|
|
222
109
|
end
|
|
223
110
|
|
|
224
|
-
describe '
|
|
225
|
-
it '
|
|
226
|
-
|
|
227
|
-
hash = msg.to_provider_hash
|
|
228
|
-
|
|
229
|
-
expect(hash).to eq(role: 'user', content: 'hello')
|
|
111
|
+
describe 'F2 fix — wrap and to_provider_hash are deleted' do
|
|
112
|
+
it 'exposes no wrap factory' do
|
|
113
|
+
expect(described_class.respond_to?(:wrap)).to be(false)
|
|
230
114
|
end
|
|
231
|
-
end
|
|
232
115
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
expect(described_class::ROLES).to eq(%i[system user assistant tool])
|
|
116
|
+
it 'exposes no to_provider_hash projection (L9)' do
|
|
117
|
+
expect(described_class.build(role: :user, content: 'x')).not_to respond_to(:to_provider_hash)
|
|
236
118
|
end
|
|
237
119
|
end
|
|
238
120
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
121
|
+
it 'auto-generates msg_<24-hex> ids and timestamps when absent' do
|
|
122
|
+
message = described_class.build(role: :user, content: 'x')
|
|
123
|
+
expect(message.id).to match(/\Amsg_[0-9a-f]{24}\z/)
|
|
124
|
+
expect(message.timestamp).to be_a(Time)
|
|
125
|
+
end
|
|
244
126
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
127
|
+
it 'extracts plain text from String and ContentBlock content' do
|
|
128
|
+
expect(described_class.build(role: :user, content: 'plain').text).to eq('plain')
|
|
129
|
+
blocks = [
|
|
130
|
+
Legion::Extensions::Llm::Canonical::ContentBlock.text('a '),
|
|
131
|
+
Legion::Extensions::Llm::Canonical::ContentBlock.thinking('hidden')
|
|
132
|
+
]
|
|
133
|
+
expect(described_class.build(role: :user, content: blocks).text).to eq('a ')
|
|
249
134
|
end
|
|
250
135
|
end
|
|
@@ -1,159 +1,80 @@
|
|
|
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::Params do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
params = described_class.from_hash(num_predict: 1024)
|
|
24
|
-
|
|
25
|
-
expect(params.max_tokens).to eq(1024)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
it 'normalizes budget_tokens to max_thinking_tokens' do
|
|
29
|
-
params = described_class.from_hash(budget_tokens: 5000)
|
|
30
|
-
|
|
31
|
-
expect(params.max_thinking_tokens).to eq(5000)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
it 'normalizes thinking_budget to max_thinking_tokens' do
|
|
35
|
-
params = described_class.from_hash(thinking_budget: 8000)
|
|
36
|
-
|
|
37
|
-
expect(params.max_thinking_tokens).to eq(8000)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
it 'normalizes stop to stop_sequences' do
|
|
41
|
-
params = described_class.from_hash(stop: ['\n', 'END'])
|
|
42
|
-
|
|
43
|
-
expect(params.stop_sequences).to eq(['\n', 'END'])
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
it 'handles string keys' do
|
|
47
|
-
params = described_class.from_hash('max_tokens' => '4096', 'temperature' => '0.5')
|
|
48
|
-
|
|
49
|
-
expect(params.max_tokens).to eq('4096')
|
|
50
|
-
expect(params.temperature).to eq('0.5')
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
it 'returns nil for nil source' do
|
|
54
|
-
expect(described_class.from_hash(nil)).to be_nil
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
it 'returns nil for empty hash' do
|
|
58
|
-
expect(described_class.from_hash({})).to be_nil
|
|
59
|
-
end
|
|
7
|
+
let(:type_class) { described_class }
|
|
8
|
+
let(:auto_generated_members) { [] }
|
|
9
|
+
let(:type_source) do
|
|
10
|
+
{
|
|
11
|
+
max_tokens: 1024,
|
|
12
|
+
max_thinking_tokens: 512,
|
|
13
|
+
temperature: 0.7,
|
|
14
|
+
top_p: 0.9,
|
|
15
|
+
top_k: 40,
|
|
16
|
+
stop_sequences: ['\n\n'],
|
|
17
|
+
seed: 42,
|
|
18
|
+
frequency_penalty: 0.1,
|
|
19
|
+
presence_penalty: 0.2,
|
|
20
|
+
response_format: { type: 'json_object' },
|
|
21
|
+
metadata: { source: 'client' }
|
|
22
|
+
}
|
|
23
|
+
end
|
|
60
24
|
|
|
61
|
-
|
|
62
|
-
result = described_class.from_hash(unknown_key: 'value')
|
|
25
|
+
it_behaves_like 'a canonical type'
|
|
63
26
|
|
|
64
|
-
|
|
27
|
+
describe 'O03a — canonical keys only' do
|
|
28
|
+
it 'does not translate provider spellings (they fold into metadata)' do
|
|
29
|
+
params = described_class.from_hash(max_output_tokens: 999, num_predict: 888, stop: ['x'])
|
|
30
|
+
expect(params.max_tokens).to be_nil
|
|
31
|
+
expect(params.max_thinking_tokens).to be_nil
|
|
32
|
+
expect(params.stop_sequences).to be_nil
|
|
33
|
+
expect(params.metadata).to eq(max_output_tokens: 999, num_predict: 888, stop: ['x'])
|
|
65
34
|
end
|
|
35
|
+
end
|
|
66
36
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
)
|
|
74
|
-
|
|
75
|
-
expect(params.max_tokens).to eq(4096)
|
|
76
|
-
expect(params.temperature).to eq(0.7)
|
|
37
|
+
describe 'H1/M3 — wire-type validation in every constructor' do
|
|
38
|
+
it 'rejects garbage numeric members in build, from_hash, and .new' do
|
|
39
|
+
expect { described_class.build(max_tokens: 'abc') }
|
|
40
|
+
.to raise_error(ArgumentError, /max_tokens expected Integer, got String/)
|
|
41
|
+
expect { described_class.from_hash(temperature: :hot) }
|
|
42
|
+
.to raise_error(ArgumentError, /temperature expected Numeric, got Symbol/)
|
|
43
|
+
expect { described_class.new(seed: -3.5) }
|
|
44
|
+
.to raise_error(ArgumentError, /seed expected Integer, got Float/)
|
|
77
45
|
end
|
|
78
46
|
|
|
79
|
-
it '
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
top_p: 0.95,
|
|
85
|
-
top_k: 50,
|
|
86
|
-
stop_sequences: ['END'],
|
|
87
|
-
seed: 42,
|
|
88
|
-
frequency_penalty: 0.5,
|
|
89
|
-
presence_penalty: 0.1,
|
|
90
|
-
response_format: { type: 'json_object' }
|
|
91
|
-
)
|
|
92
|
-
|
|
93
|
-
expect(params.max_tokens).to eq(4096)
|
|
94
|
-
expect(params.max_thinking_tokens).to eq(10_000)
|
|
95
|
-
expect(params.temperature).to eq(0.7)
|
|
96
|
-
expect(params.top_p).to eq(0.95)
|
|
97
|
-
expect(params.top_k).to eq(50)
|
|
98
|
-
expect(params.stop_sequences).to eq(['END'])
|
|
99
|
-
expect(params.seed).to eq(42)
|
|
100
|
-
expect(params.frequency_penalty).to eq(0.5)
|
|
101
|
-
expect(params.presence_penalty).to eq(0.1)
|
|
102
|
-
expect(params.response_format).to eq({ type: 'json_object' })
|
|
47
|
+
it 'rejects wrong-class stop_sequences and response_format' do
|
|
48
|
+
expect { described_class.new(stop_sequences: 42) }
|
|
49
|
+
.to raise_error(ArgumentError, /stop_sequences expected String \| Array, got Integer/)
|
|
50
|
+
expect { described_class.build(response_format: 42) }
|
|
51
|
+
.to raise_error(ArgumentError, /response_format expected String \| Hash, got Integer/)
|
|
103
52
|
end
|
|
104
53
|
end
|
|
105
54
|
|
|
106
|
-
describe '
|
|
107
|
-
it '
|
|
108
|
-
params = described_class.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
frequency_penalty: nil, presence_penalty: nil,
|
|
113
|
-
response_format: nil
|
|
114
|
-
)
|
|
115
|
-
hash = params.to_h
|
|
116
|
-
|
|
117
|
-
expect(hash).to eq(max_tokens: 4096, temperature: 0.7)
|
|
55
|
+
describe 'construction law' do
|
|
56
|
+
it 'from_hash({}) is a valid all-nil Params (never nil)' do
|
|
57
|
+
params = described_class.from_hash({})
|
|
58
|
+
expect(params).to be_a(described_class)
|
|
59
|
+
expect(params.max_tokens).to be_nil
|
|
60
|
+
expect(params.temperature).to be_nil
|
|
118
61
|
end
|
|
119
62
|
|
|
120
|
-
it '
|
|
121
|
-
params = described_class.
|
|
122
|
-
|
|
123
|
-
temperature: 0.7, top_p: 0.95, top_k: 50,
|
|
124
|
-
stop_sequences: ['END'], seed: 42,
|
|
125
|
-
frequency_penalty: 0.5, presence_penalty: 0.1,
|
|
126
|
-
response_format: { type: 'json_object' }
|
|
127
|
-
)
|
|
128
|
-
hash = params.to_h
|
|
129
|
-
|
|
130
|
-
expect(hash).to include(
|
|
131
|
-
max_tokens: 4096, max_thinking_tokens: 10_000,
|
|
132
|
-
temperature: 0.7, top_p: 0.95, top_k: 50,
|
|
133
|
-
stop_sequences: ['END'], seed: 42,
|
|
134
|
-
frequency_penalty: 0.5, presence_penalty: 0.1,
|
|
135
|
-
response_format: { type: 'json_object' }
|
|
136
|
-
)
|
|
63
|
+
it 'builds from keyword args' do
|
|
64
|
+
params = described_class.build(temperature: 1.0)
|
|
65
|
+
expect(params.temperature).to eq(1.0)
|
|
137
66
|
end
|
|
138
|
-
end
|
|
139
67
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
serialized = params.to_h
|
|
145
|
-
|
|
146
|
-
expect(serialized).to eq(original)
|
|
68
|
+
it 'build and from_hash share one normalization path (T7)' do
|
|
69
|
+
via_build = described_class.build(temperature: 0.5, metadata: { a: 1 })
|
|
70
|
+
via_hash = described_class.from_hash(temperature: 0.5, metadata: { a: 1 })
|
|
71
|
+
expect(via_build).to eq(via_hash)
|
|
147
72
|
end
|
|
148
73
|
|
|
149
|
-
it '
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
expect(serialized[:max_tokens]).to eq(2048)
|
|
155
|
-
expect(serialized[:max_thinking_tokens]).to eq(5000)
|
|
156
|
-
expect(serialized[:stop_sequences]).to eq(['END'])
|
|
74
|
+
it 'survives build → to_h → JSON → from_hash (T4)' do
|
|
75
|
+
params = described_class.build(temperature: 0.5, stop_sequences: ['stop'])
|
|
76
|
+
round_tripped = described_class.from_hash(Legion::JSON.load(Legion::JSON.dump(params.to_h)))
|
|
77
|
+
expect(round_tripped).to eq(params)
|
|
157
78
|
end
|
|
158
79
|
end
|
|
159
80
|
end
|