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,221 +1,85 @@
|
|
|
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::ToolDefinition do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
it 'accepts parameters and source' do
|
|
17
|
-
tool = described_class.build(
|
|
18
|
-
name: 'search',
|
|
19
|
-
description: 'Search',
|
|
20
|
-
parameters: { type: 'object', properties: { query: { type: 'string' } } },
|
|
21
|
-
source: { type: :registry }
|
|
22
|
-
)
|
|
23
|
-
|
|
24
|
-
expect(tool.parameters).to eq({ type: 'object', properties: { query: { type: 'string' } } })
|
|
25
|
-
expect(tool.source).to eq({ type: :registry })
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
it 'sanitizes tool names' do
|
|
29
|
-
tool = described_class.build(name: 'My.Tool.Name!', description: 'test')
|
|
30
|
-
|
|
31
|
-
expect(tool.name).to eq('My_Tool_Name')
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
it 'truncates long tool names' do
|
|
35
|
-
long_name = 'a' * 100
|
|
36
|
-
tool = described_class.build(name: long_name)
|
|
37
|
-
|
|
38
|
-
expect(tool.name.length).to eq(64)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
it 'provides fallback name for empty input' do
|
|
42
|
-
tool = described_class.build(name: '')
|
|
43
|
-
|
|
44
|
-
expect(tool.name).to eq('tool')
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
it 'converts nil description to empty string' do
|
|
48
|
-
tool = described_class.build(name: 'search')
|
|
49
|
-
|
|
50
|
-
expect(tool.description).to eq('')
|
|
51
|
-
end
|
|
7
|
+
let(:type_class) { described_class }
|
|
8
|
+
let(:auto_generated_members) { [] }
|
|
9
|
+
let(:type_source) do
|
|
10
|
+
{
|
|
11
|
+
name: 'get_weather',
|
|
12
|
+
description: 'Get the weather',
|
|
13
|
+
parameters: { type: 'object', properties: { location: { type: 'string' } } },
|
|
14
|
+
source: { type: :registry, tool_class: 'WeatherTool' },
|
|
15
|
+
metadata: { origin: 'client' }
|
|
16
|
+
}
|
|
52
17
|
end
|
|
53
18
|
|
|
54
|
-
|
|
55
|
-
it 'parses from hash with symbol keys' do
|
|
56
|
-
tool = described_class.from_hash({ name: 'search', description: 'Search', parameters: { type: 'object' } })
|
|
57
|
-
|
|
58
|
-
expect(tool.name).to eq('search')
|
|
59
|
-
expect(tool.description).to eq('Search')
|
|
60
|
-
expect(tool.parameters).to eq({ type: 'object' })
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
it 'parses from hash with string keys' do
|
|
64
|
-
tool = described_class.from_hash({ 'name' => 'search', 'description' => 'Search' })
|
|
65
|
-
|
|
66
|
-
expect(tool.name).to eq('search')
|
|
67
|
-
expect(tool.description).to eq('Search')
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
it 'accepts input_schema as alias for parameters' do
|
|
71
|
-
tool = described_class.from_hash({ name: 'search', input_schema: { type: 'object' } })
|
|
72
|
-
|
|
73
|
-
expect(tool.parameters).to eq({ type: 'object' })
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
it 'accepts source parameter' do
|
|
77
|
-
tool = described_class.from_hash({ name: 'search', source: { type: :extension } })
|
|
78
|
-
|
|
79
|
-
expect(tool.source).to eq({ type: :extension })
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
it 'overrides source with keyword arg' do
|
|
83
|
-
tool = described_class.from_hash(
|
|
84
|
-
{ name: 'search', source: { type: :builtin } },
|
|
85
|
-
source: { type: :override }
|
|
86
|
-
)
|
|
19
|
+
it_behaves_like 'a canonical type'
|
|
87
20
|
|
|
88
|
-
|
|
21
|
+
describe 'O03a — no input_schema alias' do
|
|
22
|
+
it 'does not translate input_schema (it folds into metadata)' do
|
|
23
|
+
td = described_class.from_hash(name: 'x', input_schema: { type: 'object', properties: {} })
|
|
24
|
+
expect(td.parameters).to eq(type: 'object', properties: {})
|
|
25
|
+
expect(td.metadata).to eq(input_schema: { type: 'object', properties: {} })
|
|
89
26
|
end
|
|
90
27
|
end
|
|
91
28
|
|
|
92
|
-
describe '
|
|
93
|
-
it '
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
tool_class: 'RubyTool',
|
|
99
|
-
extension: 'legion-code',
|
|
100
|
-
runner: 'RubyRunner',
|
|
101
|
-
function: :execute
|
|
102
|
-
}
|
|
103
|
-
tool = described_class.from_registry_entry(entry)
|
|
104
|
-
|
|
105
|
-
expect(tool.name).to eq('ruby')
|
|
106
|
-
expect(tool.description).to eq('Run Ruby code')
|
|
107
|
-
expect(tool.parameters).to eq({ type: 'object' })
|
|
108
|
-
expect(tool.source[:type]).to eq(:registry)
|
|
109
|
-
expect(tool.source[:extension]).to eq('legion-code')
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
it 'creates from registry entry without tool_class' do
|
|
113
|
-
entry = {
|
|
114
|
-
name: 'custom',
|
|
115
|
-
description: 'Custom tool',
|
|
116
|
-
parameters: { type: 'object', properties: { name: { type: 'string' } } },
|
|
117
|
-
extension: 'custom-ext'
|
|
118
|
-
}
|
|
119
|
-
tool = described_class.from_registry_entry(entry)
|
|
120
|
-
|
|
121
|
-
expect(tool.source[:type]).to eq(:extension)
|
|
29
|
+
describe 'L3 — no coercion of non-Hash input' do
|
|
30
|
+
it 'raises on non-Hash from_hash input (the {} fabrication is deleted)' do
|
|
31
|
+
expect { described_class.from_hash(nil) }
|
|
32
|
+
.to raise_error(ArgumentError, /expected Hash, got NilClass/)
|
|
33
|
+
expect { described_class.from_hash('raw') }
|
|
34
|
+
.to raise_error(ArgumentError, /expected Hash, got String/)
|
|
122
35
|
end
|
|
123
36
|
end
|
|
124
37
|
|
|
125
|
-
describe '
|
|
126
|
-
it '
|
|
127
|
-
expect(described_class.
|
|
38
|
+
describe 'schema normalization (frozen inference rules)' do
|
|
39
|
+
it 'defaults missing/empty parameters to an empty object schema' do
|
|
40
|
+
expect(described_class.build(name: 'x').parameters).to eq(type: 'object', properties: {})
|
|
128
41
|
end
|
|
129
42
|
|
|
130
|
-
it '
|
|
131
|
-
|
|
43
|
+
it 'wraps bare property maps in an object schema' do
|
|
44
|
+
td = described_class.build(name: 'x', parameters: { a: { type: 'string' } })
|
|
45
|
+
expect(td.parameters).to eq(type: 'object', properties: { a: { type: 'string' } })
|
|
132
46
|
end
|
|
133
47
|
|
|
134
|
-
it '
|
|
135
|
-
expect(described_class.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
describe '#to_h' do
|
|
140
|
-
it 'serializes to hash with name, description, parameters' do
|
|
141
|
-
tool = described_class.build(
|
|
142
|
-
name: 'search',
|
|
143
|
-
description: 'Search the web',
|
|
144
|
-
parameters: { type: 'object' }
|
|
145
|
-
)
|
|
146
|
-
hash = tool.to_h
|
|
147
|
-
|
|
148
|
-
expect(hash).to eq(
|
|
149
|
-
name: 'search',
|
|
150
|
-
description: 'Search the web',
|
|
151
|
-
parameters: { type: 'object' }
|
|
152
|
-
)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
it 'omits nil values' do
|
|
156
|
-
tool = described_class.new('search', '', nil, nil)
|
|
157
|
-
hash = tool.to_h
|
|
158
|
-
|
|
159
|
-
expect(hash).to eq(name: 'search')
|
|
48
|
+
it 'keeps explicit type and composite schemas as-is' do
|
|
49
|
+
expect(described_class.build(name: 'x', parameters: { type: 'string' }).parameters)
|
|
50
|
+
.to eq(type: 'string')
|
|
51
|
+
expect(described_class.build(name: 'x', parameters: { oneOf: [] }).parameters)
|
|
52
|
+
.to eq(oneOf: [])
|
|
160
53
|
end
|
|
161
54
|
end
|
|
162
55
|
|
|
163
|
-
describe '
|
|
164
|
-
it '
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
expect(
|
|
168
|
-
expect(result[:properties]).to eq(task: { type: 'string' })
|
|
169
|
-
expect(result[:required]).to eq(['task'])
|
|
56
|
+
describe 'M5 — the name is authoritative (no rewrite, no fabrication)' do
|
|
57
|
+
it 'preserves the client/registry name verbatim (dialect rules live at the provider edge)' do
|
|
58
|
+
expect(described_class.build(name: 'get.weather').name).to eq('get.weather')
|
|
59
|
+
expect(described_class.build(name: 'a' * 80).name).to eq('a' * 80)
|
|
60
|
+
expect(described_class).not_to respond_to(:sanitize_tool_name)
|
|
170
61
|
end
|
|
171
62
|
|
|
172
|
-
it '
|
|
173
|
-
|
|
174
|
-
|
|
63
|
+
it 'raises on a missing or empty name (the "tool" fabrication is deleted)' do
|
|
64
|
+
expect { described_class.build(name: nil) }
|
|
65
|
+
.to raise_error(ArgumentError, /name must be a non-empty String/)
|
|
66
|
+
expect { described_class.build(name: '') }
|
|
67
|
+
.to raise_error(ArgumentError, /name must be a non-empty String/)
|
|
68
|
+
expect { described_class.from_hash({}) }
|
|
69
|
+
.to raise_error(ArgumentError, /name must be a non-empty String/)
|
|
175
70
|
end
|
|
176
71
|
|
|
177
|
-
it '
|
|
178
|
-
expect(described_class.
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
it 'returns an empty object schema for nil/empty' do
|
|
183
|
-
expect(described_class.normalize_parameters(nil)).to eq(type: 'object', properties: {})
|
|
184
|
-
expect(described_class.normalize_parameters({})).to eq(type: 'object', properties: {})
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
it 'symbolizes top-level string keys' do
|
|
188
|
-
result = described_class.normalize_parameters('properties' => { 'a' => { 'type' => 'string' } })
|
|
189
|
-
expect(result[:type]).to eq('object')
|
|
190
|
-
expect(result).to have_key(:properties)
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
it 'leaves composite schemas (oneOf etc.) without forcing type' do
|
|
194
|
-
schema = { oneOf: [{ type: 'string' }, { type: 'integer' }] }
|
|
195
|
-
expect(described_class.normalize_parameters(schema)).to eq(schema)
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
it 'normalizes parameters at construction via .build' do
|
|
199
|
-
td = described_class.build(name: 'multi_agent_v1',
|
|
200
|
-
parameters: { properties: { task: { type: 'string' } } })
|
|
201
|
-
expect(td.parameters[:type]).to eq('object')
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
it 'normalizes nil parameters to empty object schema via .build' do
|
|
205
|
-
td = described_class.build(name: 'bare_tool')
|
|
206
|
-
expect(td.parameters).to eq(type: 'object', properties: {})
|
|
72
|
+
it 'does not fabricate a source (explicit or absent, never laundered to builtin)' do
|
|
73
|
+
expect(described_class.build(name: 'x').source).to be_nil
|
|
74
|
+
expect(described_class.build(name: 'x', source: { type: :client }).source).to eq(type: :client)
|
|
75
|
+
expect { described_class.build(name: 'x', source: 'client') }
|
|
76
|
+
.to raise_error(ArgumentError, /source expected Hash/)
|
|
207
77
|
end
|
|
208
78
|
end
|
|
209
79
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
serialized = tool.to_h
|
|
215
|
-
|
|
216
|
-
expect(serialized[:name]).to eq('search')
|
|
217
|
-
expect(serialized[:description]).to eq('Search')
|
|
218
|
-
expect(serialized[:parameters]).to eq({ type: 'object' })
|
|
219
|
-
end
|
|
80
|
+
it 'builds from keyword args with defaults (empty description, absent source)' do
|
|
81
|
+
td = described_class.build(name: 'x')
|
|
82
|
+
expect(td.description).to eq('')
|
|
83
|
+
expect(td.source).to be_nil
|
|
220
84
|
end
|
|
221
85
|
end
|
|
@@ -3,81 +3,40 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
RSpec.describe Legion::Extensions::Llm::Canonical::ToolSchema do
|
|
6
|
-
let(:
|
|
7
|
-
let(:canonical_tool) do
|
|
6
|
+
let(:tool_definition) do
|
|
8
7
|
Legion::Extensions::Llm::Canonical::ToolDefinition.build(
|
|
9
|
-
name: 'get_weather',
|
|
8
|
+
name: 'get_weather',
|
|
9
|
+
description: 'Get the weather',
|
|
10
|
+
parameters: { type: 'object', properties: { location: { type: 'string' } } }
|
|
10
11
|
)
|
|
11
12
|
end
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
expect(result[:properties]).to eq(city: { type: 'string' })
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
it 'extracts from a Hash with :parameters' do
|
|
21
|
-
result = described_class.extract({ parameters: full_schema })
|
|
22
|
-
expect(result[:type]).to eq('object')
|
|
23
|
-
expect(result[:properties]).to eq(city: { type: 'string' })
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
it 'extracts from a Hash with :input_schema' do
|
|
27
|
-
result = described_class.extract({ input_schema: full_schema })
|
|
28
|
-
expect(result[:type]).to eq('object')
|
|
29
|
-
expect(result[:properties]).to eq(city: { type: 'string' })
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
it 'extracts from a Hash with :params_schema' do
|
|
33
|
-
result = described_class.extract({ params_schema: full_schema })
|
|
34
|
-
expect(result[:type]).to eq('object')
|
|
35
|
-
expect(result[:properties]).to eq(city: { type: 'string' })
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
it 'extracts from an object responding to params_schema' do
|
|
39
|
-
tool = Struct.new(:params_schema).new(full_schema)
|
|
40
|
-
result = described_class.extract(tool)
|
|
41
|
-
expect(result[:type]).to eq('object')
|
|
42
|
-
expect(result[:properties]).to eq(city: { type: 'string' })
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
it 'returns empty object schema for nil' do
|
|
46
|
-
expect(described_class.extract(nil)).to eq(type: 'object', properties: {})
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
it 'returns empty object schema for empty hash' do
|
|
50
|
-
expect(described_class.extract({})).to eq(type: 'object', properties: {})
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
describe '.tool_name' do
|
|
55
|
-
it 'gets name from Canonical::ToolDefinition' do
|
|
56
|
-
expect(described_class.tool_name(canonical_tool)).to eq('get_weather')
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
it 'gets name from Hash' do
|
|
60
|
-
expect(described_class.tool_name({ name: 'foo' })).to eq('foo')
|
|
61
|
-
end
|
|
14
|
+
it 'extracts the normalized schema from a ToolDefinition' do
|
|
15
|
+
expect(described_class.extract(tool_definition)).to eq(
|
|
16
|
+
type: 'object', properties: { location: { type: 'string' } }
|
|
17
|
+
)
|
|
62
18
|
end
|
|
63
19
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
it 'gets description from Hash' do
|
|
70
|
-
expect(described_class.tool_description({ description: 'bar' })).to eq('bar')
|
|
71
|
-
end
|
|
20
|
+
it 'returns the tool name and description' do
|
|
21
|
+
expect(described_class.tool_name(tool_definition)).to eq('get_weather')
|
|
22
|
+
expect(described_class.tool_description(tool_definition)).to eq('Get the weather')
|
|
72
23
|
end
|
|
73
24
|
|
|
74
|
-
describe 'ToolDefinition
|
|
75
|
-
it '
|
|
76
|
-
expect(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(
|
|
25
|
+
describe '04 §6 — ToolDefinition ONLY (dual-shape accessors deleted)' do
|
|
26
|
+
it 'raises on a raw Hash' do
|
|
27
|
+
expect { described_class.extract({ name: 'x' }) }
|
|
28
|
+
.to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got Hash/)
|
|
29
|
+
expect { described_class.tool_name({ name: 'x' }) }
|
|
30
|
+
.to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got Hash/)
|
|
31
|
+
expect { described_class.tool_description({ name: 'x' }) }
|
|
32
|
+
.to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got Hash/)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'raises on nil and other wrong classes' do
|
|
36
|
+
expect { described_class.extract(nil) }
|
|
37
|
+
.to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got NilClass/)
|
|
38
|
+
expect { described_class.tool_name('str') }
|
|
39
|
+
.to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got String/)
|
|
81
40
|
end
|
|
82
41
|
end
|
|
83
42
|
end
|
|
@@ -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
|