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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +106 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +52 -67
  8. data/lib/legion/extensions/llm/canonical/params.rb +59 -33
  9. data/lib/legion/extensions/llm/canonical/request.rb +67 -54
  10. data/lib/legion/extensions/llm/canonical/response.rb +61 -76
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
  13. data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
  14. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  15. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  16. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  17. data/lib/legion/extensions/llm/canonical.rb +6 -5
  18. data/lib/legion/extensions/llm/configuration.rb +40 -10
  19. data/lib/legion/extensions/llm/connection.rb +8 -30
  20. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  21. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  22. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  23. data/lib/legion/extensions/llm/error.rb +0 -14
  24. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  25. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  26. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  27. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  28. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -153
  29. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  30. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
  31. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  32. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  33. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  34. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  35. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  36. data/lib/legion/extensions/llm/inventory/records.rb +82 -99
  37. data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
  38. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  39. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
  40. data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
  41. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -182
  42. data/lib/legion/extensions/llm/provider.rb +192 -315
  43. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  44. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  45. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  46. data/lib/legion/extensions/llm/responses/tool_arguments.rb +34 -0
  47. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  48. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  49. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  50. data/lib/legion/extensions/llm/streaming.rb +50 -35
  51. data/lib/legion/extensions/llm/taxonomies.rb +29 -17
  52. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  53. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  54. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  55. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  56. data/lib/legion/extensions/llm/utils.rb +23 -5
  57. data/lib/legion/extensions/llm/version.rb +1 -1
  58. data/lib/legion/extensions/llm.rb +10 -98
  59. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  60. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  61. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  62. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  63. data/spec/legion/extensions/llm/canonical/params_spec.rb +57 -136
  64. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  65. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  66. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +68 -148
  67. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  68. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  69. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  70. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  71. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  72. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  73. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  74. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  75. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
  76. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -0
  77. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  78. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  79. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  80. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  81. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +178 -67
  82. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  83. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
  84. data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
  85. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  86. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  87. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  88. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  89. data/spec/legion/extensions/llm/inventory/records_spec.rb +129 -41
  90. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
  91. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  92. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  93. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +312 -0
  94. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
  95. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -68
  96. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  97. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  98. data/spec/legion/extensions/llm/provider_spec.rb +98 -78
  99. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  100. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  101. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  102. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -16
  103. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  104. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  105. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  106. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  107. data/spec/support/fake_llm_provider.rb +45 -39
  108. data/spec/support/fake_ssot_harness.rb +7 -2
  109. data/spec/support/ssot_registry_helpers.rb +3 -2
  110. metadata +15 -54
  111. data/lib/legion/extensions/llm/agent.rb +0 -366
  112. data/lib/legion/extensions/llm/aliases.json +0 -436
  113. data/lib/legion/extensions/llm/aliases.rb +0 -67
  114. data/lib/legion/extensions/llm/attachment.rb +0 -229
  115. data/lib/legion/extensions/llm/chat.rb +0 -354
  116. data/lib/legion/extensions/llm/chunk.rb +0 -10
  117. data/lib/legion/extensions/llm/content.rb +0 -81
  118. data/lib/legion/extensions/llm/context.rb +0 -33
  119. data/lib/legion/extensions/llm/embedding.rb +0 -33
  120. data/lib/legion/extensions/llm/image.rb +0 -109
  121. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  122. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -311
  123. data/lib/legion/extensions/llm/message.rb +0 -118
  124. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  125. data/lib/legion/extensions/llm/model/info.rb +0 -286
  126. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  127. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  128. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  129. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  130. data/lib/legion/extensions/llm/model.rb +0 -11
  131. data/lib/legion/extensions/llm/models.json +0 -57313
  132. data/lib/legion/extensions/llm/models.rb +0 -530
  133. data/lib/legion/extensions/llm/models_schema.json +0 -168
  134. data/lib/legion/extensions/llm/moderation.rb +0 -60
  135. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  136. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  137. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  138. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  139. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  140. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  141. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  142. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  143. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  144. data/lib/legion/extensions/llm/thinking.rb +0 -53
  145. data/lib/legion/extensions/llm/tokens.rb +0 -51
  146. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  147. data/lib/legion/extensions/llm/transcription.rb +0 -39
  148. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  149. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  150. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  151. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  152. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  153. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  154. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -337
  155. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  156. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  157. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  158. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  159. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  160. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  161. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  162. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  163. 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
- describe '.build' do
7
- it 'creates a tool definition with name and description' do
8
- tool = described_class.build(name: 'search', description: 'Search the web')
9
-
10
- expect(tool.name).to eq('search')
11
- expect(tool.description).to eq('Search the web')
12
- expect(tool.parameters).to eq(type: 'object', properties: {})
13
- expect(tool.source).to eq({ type: :builtin })
14
- end
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
- describe '.from_hash' do
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
- expect(tool.source).to eq({ type: :override })
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 '.from_registry_entry' do
93
- it 'creates from registry entry with tool_class' do
94
- entry = {
95
- name: 'ruby',
96
- description: 'Run Ruby code',
97
- input_schema: { type: 'object' },
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 '.sanitize_tool_name' do
126
- it 'replaces dots with underscores' do
127
- expect(described_class.sanitize_tool_name('my.tool')).to eq('my_tool')
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 'removes special characters' do
131
- expect(described_class.sanitize_tool_name('my-tool!@#')).to eq('my-tool')
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 'preserves alphanumeric, underscores, and hyphens' do
135
- expect(described_class.sanitize_tool_name('my-tool_123')).to eq('my-tool_123')
136
- end
137
- end
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 '.normalize_parameters' do
164
- it 'injects type: object when a schema has properties but no type' do
165
- schema = { properties: { task: { type: 'string' } }, required: ['task'] }
166
- result = described_class.normalize_parameters(schema)
167
- expect(result[:type]).to eq('object')
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 'passes schemas with an explicit type through unchanged' do
173
- schema = { type: 'object', properties: { a: { type: 'string' } } }
174
- expect(described_class.normalize_parameters(schema)).to eq(schema)
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 'wraps a bare property map under type:object/properties' do
178
- expect(described_class.normalize_parameters(location: { type: 'string' }))
179
- .to eq(type: 'object', properties: { location: { type: 'string' } })
180
- end
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
- describe 'round-trip' do
211
- it 'preserves values through from_hash/to_h' do
212
- original = { name: 'search', description: 'Search', parameters: { type: 'object' } }
213
- tool = described_class.from_hash(original)
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(:full_schema) { { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] } }
7
- let(:canonical_tool) do
6
+ let(:tool_definition) do
8
7
  Legion::Extensions::Llm::Canonical::ToolDefinition.build(
9
- name: 'get_weather', description: 'Weather lookup', parameters: full_schema
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
- describe '.extract' do
14
- it 'extracts from a Canonical::ToolDefinition' do
15
- result = described_class.extract(canonical_tool)
16
- expect(result[:type]).to eq('object')
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
- describe '.tool_description' do
65
- it 'gets description from Canonical::ToolDefinition' do
66
- expect(described_class.tool_description(canonical_tool)).to eq('Weather lookup')
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 compatibility readers' do
75
- it 'params_schema returns normalized parameters' do
76
- expect(canonical_tool.params_schema).to eq(full_schema)
77
- end
78
-
79
- it 'input_schema aliases params_schema' do
80
- expect(canonical_tool.input_schema).to eq(canonical_tool.params_schema)
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
- describe '.from_hash' do
7
- it 'returns a Usage instance with canonical fields' do
8
- usage = described_class.from_hash(input_tokens: 100, output_tokens: 50)
9
-
10
- expect(usage).to be_a(described_class)
11
- expect(usage.input_tokens).to eq(100)
12
- expect(usage.output_tokens).to eq(50)
13
- expect(usage.cache_read_tokens).to be_nil
14
- expect(usage.cache_write_tokens).to be_nil
15
- expect(usage.thinking_tokens).to be_nil
16
- expect(usage.units).to eq({})
17
- end
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
- describe '#to_h' do
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
- expect(hash).to include(
88
- input_tokens: 200, output_tokens: 100,
89
- cache_read_tokens: 50, cache_write_tokens: 25,
90
- thinking_tokens: 10, units: { images: 2 }
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 '#total_tokens' do
96
- it 'sums all token categories' do
97
- usage = described_class.new(
98
- input_tokens: 100, output_tokens: 50,
99
- cache_read_tokens: 20, cache_write_tokens: 10,
100
- thinking_tokens: 5, units: {}
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 'ignores nil values' do
107
- usage = described_class.new(
108
- input_tokens: 100, output_tokens: 50,
109
- cache_read_tokens: nil, cache_write_tokens: nil,
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 'OpenAI nested details extraction' do
118
- it 'extracts cached_tokens from prompt_tokens_details (Chat API)' do
119
- usage = described_class.from_hash(
120
- prompt_tokens: 1000,
121
- completion_tokens: 200,
122
- prompt_tokens_details: { cached_tokens: 800 },
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
- describe 'round-trip' do
158
- it 'preserves values through from_hash/to_h' do
159
- original = { input_tokens: 100, output_tokens: 50, cache_read_tokens: 10 }
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 'preserves legacy key normalization through round-trip' do
169
- original = { input: 200, output: 100, cached: 50 }
170
- usage = described_class.from_hash(original)
171
- serialized = usage.to_h
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
- :request_timeout,
20
- :default_model,
21
- :default_embedding_model,
22
- :model_registry_file
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