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,189 +1,109 @@
|
|
|
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::Thinking do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
expect(thinking.content).to eq('reasoning here')
|
|
12
|
-
expect(thinking.signature).to eq('sig-abc')
|
|
13
|
-
end
|
|
7
|
+
let(:type_class) { described_class }
|
|
8
|
+
let(:auto_generated_members) { [] }
|
|
9
|
+
let(:type_source) do
|
|
10
|
+
{ content: 'reasoning here', signature: 'sig-abc', metadata: { origin: 'provider' } }
|
|
11
|
+
end
|
|
14
12
|
|
|
15
|
-
|
|
16
|
-
thinking = described_class.from_hash('content' => 'reasoning', 'signature' => 'sig-123')
|
|
13
|
+
it_behaves_like 'a canonical type'
|
|
17
14
|
|
|
15
|
+
describe 'T5/T4 — construction law' do
|
|
16
|
+
it 'builds from keyword args' do
|
|
17
|
+
thinking = described_class.build(content: 'reasoning', signature: 'sig')
|
|
18
18
|
expect(thinking.content).to eq('reasoning')
|
|
19
|
-
expect(thinking.signature).to eq('sig
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
it 'returns nil for nil source' do
|
|
23
|
-
expect(described_class.from_hash(nil)).to be_nil
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
it 'returns nil for empty content and signature' do
|
|
27
|
-
result = described_class.from_hash(content: '', signature: '')
|
|
28
|
-
|
|
29
|
-
expect(result).to be_nil
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
it 'returns nil when both fields are nil' do
|
|
33
|
-
result = described_class.from_hash(content: nil, signature: nil)
|
|
34
|
-
|
|
35
|
-
expect(result).to be_nil
|
|
19
|
+
expect(thinking.signature).to eq('sig')
|
|
36
20
|
end
|
|
37
21
|
|
|
38
|
-
it '
|
|
39
|
-
thinking = described_class.
|
|
40
|
-
|
|
41
|
-
expect(thinking.content).to eq('just reasoning')
|
|
22
|
+
it 'normalizes empty strings to nil (absence, not data — 04 §8)' do
|
|
23
|
+
thinking = described_class.build(content: '', signature: '')
|
|
24
|
+
expect(thinking.content).to be_nil
|
|
42
25
|
expect(thinking.signature).to be_nil
|
|
26
|
+
expect(thinking.empty?).to be(true)
|
|
43
27
|
end
|
|
44
28
|
|
|
45
|
-
it '
|
|
46
|
-
|
|
29
|
+
it 'raises on a wrong-class member' do
|
|
30
|
+
expect { described_class.build(content: 42) }
|
|
31
|
+
.to raise_error(ArgumentError, /content expected String, got Integer/)
|
|
32
|
+
end
|
|
47
33
|
|
|
48
|
-
|
|
49
|
-
|
|
34
|
+
it 'survives build → to_h → JSON → from_hash (T4 signature)' do
|
|
35
|
+
thinking = described_class.build(content: 'x', signature: 'sig-1')
|
|
36
|
+
round_tripped = described_class.from_hash(Legion::JSON.load(Legion::JSON.dump(thinking.to_h)))
|
|
37
|
+
expect(round_tripped.content).to eq('x')
|
|
38
|
+
expect(round_tripped.signature).to eq('sig-1')
|
|
50
39
|
end
|
|
51
40
|
end
|
|
52
41
|
|
|
53
|
-
describe
|
|
54
|
-
|
|
55
|
-
thinking = described_class.new(content: 'reasoning', signature: 'sig-1')
|
|
56
|
-
hash = thinking.to_h
|
|
42
|
+
describe Legion::Extensions::Llm::Canonical::Thinking::Config do
|
|
43
|
+
subject(:config_class) { described_class }
|
|
57
44
|
|
|
58
|
-
|
|
45
|
+
let(:type_class) { described_class }
|
|
46
|
+
let(:auto_generated_members) { [] }
|
|
47
|
+
let(:type_source) do
|
|
48
|
+
{ effort: 'high', budget: 4096, metadata: { source: 'client' } }
|
|
59
49
|
end
|
|
60
50
|
|
|
61
|
-
|
|
62
|
-
thinking = described_class.new(content: 'reasoning', signature: nil)
|
|
63
|
-
hash = thinking.to_h
|
|
51
|
+
it_behaves_like 'a canonical type'
|
|
64
52
|
|
|
65
|
-
|
|
53
|
+
it 'converts from a plain class to a Data with one name (04 §8)' do
|
|
54
|
+
expect(config_class).to be_a(Class)
|
|
55
|
+
expect(config_class.new(effort: 'low', budget: nil, metadata: {})).to be_a(config_class)
|
|
66
56
|
end
|
|
67
|
-
end
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
expect(thinking.empty?).to be true
|
|
58
|
+
it 'normalizes symbol effort to String' do
|
|
59
|
+
config = config_class.build(effort: :low)
|
|
60
|
+
expect(config.effort).to eq('low')
|
|
73
61
|
end
|
|
74
62
|
|
|
75
|
-
it '
|
|
76
|
-
|
|
77
|
-
|
|
63
|
+
it 'raises on a wrong-class effort' do
|
|
64
|
+
expect { config_class.build(effort: 3) }
|
|
65
|
+
.to raise_error(ArgumentError, /effort expected String, got Integer/)
|
|
78
66
|
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
describe 'round-trip' do
|
|
82
|
-
it 'preserves content and signature through from_hash/to_h' do
|
|
83
|
-
original = { content: 'deep reasoning', signature: 'sig-xyz' }
|
|
84
|
-
thinking = described_class.from_hash(original)
|
|
85
|
-
serialized = thinking.to_h
|
|
86
|
-
|
|
87
|
-
expect(serialized).to eq(original)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
describe '::Config' do
|
|
92
|
-
let(:config_class) { Legion::Extensions::Llm::Canonical::Thinking::Config }
|
|
93
|
-
|
|
94
|
-
describe '.build' do
|
|
95
|
-
it 'creates a config with effort and budget' do
|
|
96
|
-
config = config_class.build(effort: 'high', budget: 10_000)
|
|
97
67
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
expect(
|
|
68
|
+
describe 'H1/M4 — effort is a closed enum, validated in every constructor' do
|
|
69
|
+
it 'rejects an unrecognized effort in build, from_hash, and .new' do
|
|
70
|
+
expect { config_class.build(effort: 'banana') }
|
|
71
|
+
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
72
|
+
expect { config_class.from_hash(effort: 'banana') }
|
|
73
|
+
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
74
|
+
expect { config_class.new(effort: 'banana') }
|
|
75
|
+
.to raise_error(ArgumentError, /Invalid effort: "banana"/)
|
|
101
76
|
end
|
|
102
77
|
|
|
103
|
-
it '
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
expect(config.effort).to eq('high')
|
|
78
|
+
it 'accepts the enum case-insensitively (symbol or string)' do
|
|
79
|
+
expect(config_class.build(effort: :HIGH).effort).to eq('high')
|
|
80
|
+
expect(config_class.new(effort: 'Medium').effort).to eq('medium')
|
|
107
81
|
end
|
|
108
82
|
|
|
109
|
-
it '
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
expect(
|
|
83
|
+
it 'resolved_budget derives from the validated effort (no silent medium fallback)' do
|
|
84
|
+
expect(config_class.new(effort: 'low').resolved_budget).to eq(1024)
|
|
85
|
+
expect(config_class.build(budget: 123).resolved_budget).to eq(123)
|
|
86
|
+
expect(config_class.build.resolved_budget).to be_nil
|
|
113
87
|
end
|
|
114
88
|
end
|
|
115
89
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
it 'handles string keys' do
|
|
125
|
-
config = config_class.from_hash('effort' => 'low')
|
|
126
|
-
|
|
127
|
-
expect(config.effort).to eq('low')
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
it 'returns nil for nil source' do
|
|
131
|
-
expect(config_class.from_hash(nil)).to be_nil
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
it 'returns nil for empty hash' do
|
|
135
|
-
expect(config_class.from_hash({})).to be_nil
|
|
136
|
-
end
|
|
90
|
+
it 'keeps the effort<->budget SSOT conversions' do
|
|
91
|
+
expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
|
|
92
|
+
expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
|
|
93
|
+
expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
|
|
94
|
+
expect(config_class.build(budget: 512).resolved_effort).to eq('low')
|
|
95
|
+
expect(config_class.build(budget: 8192).resolved_effort).to eq('medium')
|
|
96
|
+
expect(config_class.build(budget: 16_384).resolved_effort).to eq('high')
|
|
137
97
|
end
|
|
138
98
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
it 'omits nil values' do
|
|
146
|
-
config = config_class.build(effort: 'low')
|
|
147
|
-
expect(config.to_h).to eq(effort: 'low')
|
|
148
|
-
end
|
|
99
|
+
it 'is enabled only when an axis is set' do
|
|
100
|
+
expect(config_class.build(effort: 'low').enabled?).to be(true)
|
|
101
|
+
expect(config_class.build(budget: 1).enabled?).to be(true)
|
|
102
|
+
expect(config_class.build.enabled?).to be(false)
|
|
149
103
|
end
|
|
150
104
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
# effort only). Every provider translator must still get a usable value for
|
|
154
|
-
# whichever axis IT needs, so thinking survives ANY client×provider pair
|
|
155
|
-
# instead of being silently dropped. The conversion lives HERE (the canonical
|
|
156
|
-
# config), not scattered/one-directional in each translator. Derived accessors
|
|
157
|
-
# do NOT mutate stored state or #to_h — they only fill the gap on read.
|
|
158
|
-
describe 'derived cross-axis accessors' do
|
|
159
|
-
it 'derives budget from effort when only effort was set' do
|
|
160
|
-
expect(config_class.build(effort: 'low').resolved_budget).to eq(1024)
|
|
161
|
-
expect(config_class.build(effort: 'medium').resolved_budget).to eq(8192)
|
|
162
|
-
expect(config_class.build(effort: 'high').resolved_budget).to eq(16_384)
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
it 'derives effort from budget by band (< medium=low, < high=medium, else high)' do
|
|
166
|
-
expect(config_class.build(budget: 500).resolved_effort).to eq('low') # < 8192
|
|
167
|
-
expect(config_class.build(budget: 10_000).resolved_effort).to eq('medium') # 8192..16383
|
|
168
|
-
expect(config_class.build(budget: 20_000).resolved_effort).to eq('high') # >= 16384
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
it 'prefers the explicitly-set value over derivation' do
|
|
172
|
-
c = config_class.build(effort: 'high', budget: 2048)
|
|
173
|
-
expect(c.resolved_budget).to eq(2048)
|
|
174
|
-
expect(c.resolved_effort).to eq('high')
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
it 'returns nil derivations when neither axis is set' do
|
|
178
|
-
c = config_class.build
|
|
179
|
-
expect(c.resolved_budget).to be_nil
|
|
180
|
-
expect(c.resolved_effort).to be_nil
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
it 'does not fabricate the missing axis in to_h (stored state stays faithful)' do
|
|
184
|
-
expect(config_class.build(effort: 'high').to_h).to eq(effort: 'high')
|
|
185
|
-
expect(config_class.build(budget: 2048).to_h).to eq(budget: 2048)
|
|
186
|
-
end
|
|
105
|
+
it 'to_h is faithful to what was set (no fabricated axis)' do
|
|
106
|
+
expect(config_class.build(effort: 'low').to_h).to eq(effort: 'low', metadata: {})
|
|
187
107
|
end
|
|
188
108
|
end
|
|
189
109
|
end
|
|
@@ -1,191 +1,88 @@
|
|
|
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::ToolCall do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
tc2 = described_class.build(name: 'search')
|
|
19
|
-
|
|
20
|
-
expect(tc1.id).not_to eq(tc2.id)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
it 'accepts all fields' do
|
|
24
|
-
tc = described_class.build(
|
|
25
|
-
id: 'call-1',
|
|
26
|
-
exchange_id: 'ex-1',
|
|
27
|
-
name: 'search',
|
|
28
|
-
arguments: { query: 'test' },
|
|
29
|
-
source: :registry,
|
|
30
|
-
status: :pending,
|
|
31
|
-
data_handling_classification: :public,
|
|
32
|
-
policy_decision: :allowed
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
expect(tc.id).to eq('call-1')
|
|
36
|
-
expect(tc.exchange_id).to eq('ex-1')
|
|
37
|
-
expect(tc.source).to eq(:registry)
|
|
38
|
-
expect(tc.status).to eq(:pending)
|
|
39
|
-
expect(tc.data_handling_classification).to eq(:public)
|
|
40
|
-
expect(tc.policy_decision).to eq(:allowed)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
it 'defaults arguments to empty hash' do
|
|
44
|
-
tc = described_class.build(name: 'search')
|
|
45
|
-
|
|
46
|
-
expect(tc.arguments).to eq({})
|
|
47
|
-
end
|
|
7
|
+
let(:type_class) { described_class }
|
|
8
|
+
let(:auto_generated_members) { [:id] }
|
|
9
|
+
let(:type_source) do
|
|
10
|
+
{
|
|
11
|
+
id: 'call_1',
|
|
12
|
+
name: 'get_weather',
|
|
13
|
+
arguments: { location: 'San Francisco' },
|
|
14
|
+
source: 'client',
|
|
15
|
+
status: 'pending',
|
|
16
|
+
category: 'weather',
|
|
17
|
+
metadata: { origin: 'model' }
|
|
18
|
+
}
|
|
48
19
|
end
|
|
49
20
|
|
|
50
|
-
|
|
51
|
-
it 'parses from hash with symbol keys' do
|
|
52
|
-
tc = described_class.from_hash(
|
|
53
|
-
id: 'call-1',
|
|
54
|
-
name: 'search',
|
|
55
|
-
arguments: { query: 'test' },
|
|
56
|
-
source: :registry
|
|
57
|
-
)
|
|
21
|
+
it_behaves_like 'a canonical type'
|
|
58
22
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
expect
|
|
62
|
-
|
|
23
|
+
describe 'H1 — .new is as strict as the factories' do
|
|
24
|
+
it 'rejects a poison source enum' do
|
|
25
|
+
expect { described_class.new(name: 'x', source: :bogus) }
|
|
26
|
+
.to raise_error(ArgumentError, /Invalid source: :bogus/)
|
|
63
27
|
end
|
|
64
28
|
|
|
65
|
-
it '
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
expect(tc.source).to eq(:registry)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
it 'normalizes string status to symbol' do
|
|
72
|
-
tc = described_class.from_hash(name: 'search', status: 'success')
|
|
73
|
-
|
|
74
|
-
expect(tc.status).to eq(:success)
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
it 'parses JSON string arguments to symbol keys per Legion::JSON convention' do
|
|
78
|
-
tc = described_class.from_hash(
|
|
79
|
-
name: 'search',
|
|
80
|
-
arguments: '{"query":"test","limit":10}'
|
|
81
|
-
)
|
|
82
|
-
|
|
83
|
-
expect(tc.arguments).to eq({ query: 'test', limit: 10 })
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
it 'handles string keys' do
|
|
87
|
-
tc = described_class.from_hash('name' => 'search', 'arguments' => '{}')
|
|
88
|
-
|
|
89
|
-
expect(tc.name).to eq('search')
|
|
90
|
-
expect(tc.arguments).to eq({})
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
it 'returns nil for nil source' do
|
|
94
|
-
expect(described_class.from_hash(nil)).to be_nil
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
describe '#with_result' do
|
|
99
|
-
it 'returns a new tool call with result' do
|
|
100
|
-
tc = described_class.build(name: 'search', source: :registry)
|
|
101
|
-
result_tc = tc.with_result(result: { hits: 5 }, status: :success, duration_ms: 100)
|
|
102
|
-
|
|
103
|
-
expect(result_tc.result).to eq({ hits: 5 })
|
|
104
|
-
expect(result_tc.status).to eq(:success)
|
|
105
|
-
expect(result_tc.duration_ms).to eq(100)
|
|
106
|
-
expect(result_tc.finished_at).to be_a(Time)
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
it 'sets error on error status' do
|
|
110
|
-
tc = described_class.build(name: 'search')
|
|
111
|
-
result_tc = tc.with_result(result: 'not found', status: :error)
|
|
112
|
-
|
|
113
|
-
expect(result_tc.error).to eq('not found')
|
|
114
|
-
expect(result_tc.status).to eq(:error)
|
|
29
|
+
it 'rejects JSON-string arguments (Hash only, O03a)' do
|
|
30
|
+
expect { described_class.new(name: 'x', arguments: '{"a":1}') }
|
|
31
|
+
.to raise_error(ArgumentError, /arguments expected Hash, got String/)
|
|
115
32
|
end
|
|
116
33
|
end
|
|
117
34
|
|
|
118
|
-
describe '
|
|
119
|
-
it '
|
|
120
|
-
|
|
121
|
-
expect(
|
|
122
|
-
expect(
|
|
35
|
+
describe 'T5 — enum law (declared enums enforced in both factories)' do
|
|
36
|
+
it 'validates source against SOURCE_VALUES' do
|
|
37
|
+
expect { described_class.build(name: 'x', source: 'client') }.not_to raise_error
|
|
38
|
+
expect(described_class.build(name: 'x', source: 'client').source).to eq(:client)
|
|
39
|
+
expect { described_class.build(name: 'x', source: :bogus) }
|
|
40
|
+
.to raise_error(ArgumentError, /Invalid source: :bogus/)
|
|
41
|
+
expect { described_class.from_hash(name: 'x', source: 'nope') }
|
|
42
|
+
.to raise_error(ArgumentError, /Invalid source: :nope/)
|
|
123
43
|
end
|
|
124
44
|
|
|
125
|
-
it '
|
|
126
|
-
|
|
127
|
-
expect(
|
|
128
|
-
|
|
45
|
+
it 'validates status against STATUS_VALUES' do
|
|
46
|
+
expect(described_class.build(name: 'x', status: 'running').status).to eq(:running)
|
|
47
|
+
expect { described_class.build(name: 'x', status: :finished) }
|
|
48
|
+
.to raise_error(ArgumentError, /Invalid status: :finished/)
|
|
49
|
+
expect { described_class.from_hash(name: 'x', status: 'done') }
|
|
50
|
+
.to raise_error(ArgumentError, /Invalid status: :done/)
|
|
129
51
|
end
|
|
130
52
|
end
|
|
131
53
|
|
|
132
|
-
describe '
|
|
133
|
-
it '
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
54
|
+
describe 'O03a — arguments Hash only' do
|
|
55
|
+
it 'does not parse JSON-string arguments (the parse + rescue are deleted)' do
|
|
56
|
+
expect { described_class.build(name: 'x', arguments: '{"a":1}') }
|
|
57
|
+
.to raise_error(ArgumentError, /arguments expected Hash, got String/)
|
|
58
|
+
expect { described_class.from_hash(name: 'x', arguments: '{\"a\":1}') }
|
|
59
|
+
.to raise_error(ArgumentError, /arguments expected Hash, got String/)
|
|
138
60
|
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
describe '#to_audit_hash' do
|
|
142
|
-
it 'includes compliance fields' do
|
|
143
|
-
tc = described_class.build(
|
|
144
|
-
name: 'search',
|
|
145
|
-
source: :registry,
|
|
146
|
-
data_handling_classification: :public,
|
|
147
|
-
policy_decision: :allowed
|
|
148
|
-
)
|
|
149
|
-
hash = tc.to_audit_hash
|
|
150
61
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
data_handling_classification: :public,
|
|
155
|
-
policy_decision: :allowed
|
|
156
|
-
)
|
|
62
|
+
it 'defaults nil arguments to {} (documented no-arguments default)' do
|
|
63
|
+
expect(described_class.build(name: 'x').arguments).to eq({})
|
|
64
|
+
expect(described_class.from_hash(name: 'x').arguments).to eq({})
|
|
157
65
|
end
|
|
158
66
|
end
|
|
159
67
|
|
|
160
|
-
describe '
|
|
161
|
-
it '
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
68
|
+
describe 'with_result (canonical mutation-free update)' do
|
|
69
|
+
it 'attaches the result and normalizes error' do
|
|
70
|
+
tc = described_class.build(name: 'x', id: 'c1', status: 'running')
|
|
71
|
+
done = tc.with_result(result: 'ok', status: :success, duration_ms: 12)
|
|
72
|
+
expect(done.result).to eq('ok')
|
|
73
|
+
expect(done.status).to eq(:success)
|
|
74
|
+
expect(done.error).to be_nil
|
|
75
|
+
expect(done.success?).to be(true)
|
|
165
76
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
expect(
|
|
77
|
+
failed = tc.with_result(result: 'boom', status: :error)
|
|
78
|
+
expect(failed.error).to eq('boom')
|
|
79
|
+
expect(failed.error?).to be(true)
|
|
169
80
|
end
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
describe 'round-trip' do
|
|
173
|
-
it 'preserves values through from_hash/to_h' do
|
|
174
|
-
original = {
|
|
175
|
-
id: 'call-1',
|
|
176
|
-
name: 'search',
|
|
177
|
-
arguments: { query: 'test' },
|
|
178
|
-
source: 'registry',
|
|
179
|
-
status: 'pending'
|
|
180
|
-
}
|
|
181
|
-
tc = described_class.from_hash(original)
|
|
182
|
-
serialized = tc.to_h
|
|
183
81
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
expect
|
|
187
|
-
|
|
188
|
-
expect(serialized[:status]).to eq(:pending)
|
|
82
|
+
it 'validates the result status enum' do
|
|
83
|
+
tc = described_class.build(name: 'x', id: 'c1', status: 'running')
|
|
84
|
+
expect { tc.with_result(result: 'r', status: :bogus) }
|
|
85
|
+
.to raise_error(ArgumentError, /Invalid status: :bogus/)
|
|
189
86
|
end
|
|
190
87
|
end
|
|
191
88
|
end
|