lex-llm 0.7.6 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -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 +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,178 +1,69 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::Usage do
6
- 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
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # 09 §1 — canonical type conformance: the T-groups as shared examples.
4
+ #
5
+ # The including spec supplies:
6
+ # let(:type_class) — the Canonical type
7
+ # let(:type_source) — a Hash exercising the members (from_hash input)
8
+ # optionally:
9
+ # let(:auto_generated_members) — member names filled by the factory when
10
+ # absent from the source (id, timestamp), excluded from member-equality
11
+ # checks.
12
+ #
13
+ # Type-specific laws (T4 member survival, T5 enum law, T7 member normalization
14
+ # for the type's normalizable members) are asserted in each type spec.
15
+
16
+ RSpec.shared_examples 'a canonical type' do
17
+ let(:type) { type_class }
18
+ let(:built) { type.from_hash(type_source) }
19
+
20
+ describe 'T1 — strict input' do
21
+ it 'raises a typed ArgumentError on nil (no nil factory)' do
22
+ expect { type.from_hash(nil) }
23
+ .to raise_error(ArgumentError, /expected Hash, got NilClass/)
24
+ end
25
+
26
+ it 'raises a typed ArgumentError naming the offending class on wrong-class input' do
27
+ expect { type.from_hash('not a hash') }
28
+ .to raise_error(ArgumentError, /expected Hash, got String/)
29
+ expect { type.from_hash(42) }
30
+ .to raise_error(ArgumentError, /expected Hash, got Integer/)
31
+ end
32
+
33
+ it 'never returns nil from from_hash (valid instances or typed raises only)' do
34
+ expect(type.from_hash(type_source)).not_to be_nil
35
+ result =
36
+ begin
37
+ type.from_hash({})
38
+ rescue ArgumentError
39
+ :typed_raise
40
+ end
41
+ expect(result).not_to be_nil
42
+ end
43
+ end
44
+
45
+ describe 'T2 — unknown-key policy (04 L5)' do
46
+ it 'folds unknown keys into metadata verbatim — no drops, no raises' do
47
+ source = type_source.merge(fleet_unknown_key: 'kept', other_unknown: [1, 2])
48
+ instance = type.from_hash(source)
49
+
50
+ expect(instance.metadata).to include(fleet_unknown_key: 'kept', other_unknown: [1, 2])
51
+ end
52
+
53
+ it 'round-trips the folded unknowns' do
54
+ source = type_source.merge(fleet_unknown_key: 'kept')
55
+ instance = type.from_hash(source)
56
+ expect(type.from_hash(instance.to_h).metadata).to include(fleet_unknown_key: 'kept')
57
+ end
58
+ end
59
+
60
+ describe 'T3 — round-trip identity (04 L7)' do
61
+ it 'build → to_h → from_hash → Data-equal' do
62
+ expect(type.from_hash(built.to_h)).to eq(built)
63
+ end
64
+
65
+ it 'to_h is a plain Hash with symbol keys (no member lost, no key invented)' do
66
+ hash = built.to_h
67
+ expect(hash).to be_a(Hash)
68
+ expect(hash.keys).to all(be_a(Symbol))
69
+ checkable = (type.members.map(&:to_sym) - auto_generated_members)
70
+ checkable.each do |member|
71
+ value = built.public_send(member)
72
+ next if value.nil?
73
+
74
+ expect(hash.key?(member)).to be(true)
75
+ end
76
+ end
77
+ end
78
+
79
+ describe 'T6 — serialization uniformity (04 L8)' do
80
+ it 'as_json == to_h and to_json == to_h.to_json' do
81
+ expect(built.as_json).to eq(built.to_h)
82
+ expect(built.to_json).to eq(built.to_h.to_json)
83
+ end
84
+ end
85
+
86
+ # H1: .new is the single strict constructor — it runs the same member
87
+ # contract as the factories. Poison that passes an is_a? check is not
88
+ # constructible through ANY public path.
89
+ describe 'T8 — strict .new (H1: one validated constructor)' do
90
+ let(:member_kwargs) { type_source.transform_keys(&:to_sym) }
91
+
92
+ it 'rejects unknown members with a typed ArgumentError (no silent drops)' do
93
+ expect { type_class.new(**member_kwargs, not_a_member: true) }
94
+ .to raise_error(ArgumentError, /not_a_member/)
95
+ end
96
+
97
+ it 'rejects a non-Hash metadata member' do
98
+ expect { type_class.new(**member_kwargs, metadata: 'not-a-hash') }
99
+ .to raise_error(ArgumentError, /metadata expected Hash, got String/)
100
+ end
101
+
102
+ it 'constructs valid data (the factories and .new share one contract)' do
103
+ expect { type_class.new(**member_kwargs) }.not_to raise_error
104
+ end
105
+ end
106
+ end
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # Usage:
12
12
  # it_behaves_like 'a canonical client translator', MyClientTranslatorClass
13
- # rubocop:disable Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
13
+ # rubocop:disable-next Lint/NonLocalExitFromIterator -- return guard is idiomatic in shared_example blocks
14
14
  RSpec.shared_examples 'a canonical client translator' do |translator_class|
15
15
  let(:translator) { translator_class.new }
16
16
  let(:canonical) { Legion::Extensions::Llm::Canonical }
@@ -429,4 +429,3 @@ RSpec.shared_examples 'a canonical client translator' do |translator_class|
429
429
  end
430
430
  end
431
431
  end
432
- # rubocop:enable Lint/NonLocalExitFromIterator
@@ -5,10 +5,17 @@
5
5
  # Ship location: spec/legion/extensions/llm/conformance/
6
6
  # Module: Canonical::Conformance
7
7
  #
8
- # Consumer pattern (in provider gem spec_helper):
8
+ # Consumer pattern (in provider gem spec_helper) — load the kit's example
9
+ # modules BY EXPLICIT NAME. Do NOT glob the kit dir: it also ships lex-llm's
10
+ # own self-test specs (echo_translator_spec, ssot_*_conformance_spec) plus
11
+ # their support files, which LoadError outside this repo.
9
12
  # kit = File.join(Gem.loaded_specs['lex-llm'].full_gem_path,
10
13
  # 'spec/legion/extensions/llm/conformance')
11
- # Dir[File.join(kit, '**', '*.rb')].sort.each { |f| require f }
14
+ # %w[conformance.rb canonical_type_examples.rb client_translator_examples.rb
15
+ # provider_translator_examples.rb provider_tool_rendering_examples.rb
16
+ # ssot_contract_examples.rb ssot_provider_examples.rb].each do |f|
17
+ # require File.join(kit, f)
18
+ # end
12
19
  #
13
20
  # Then in specs:
14
21
  # it_behaves_like 'a canonical provider translator', described_class
@@ -47,5 +54,6 @@ module Canonical
47
54
  end
48
55
  end
49
56
 
57
+ require_relative 'canonical_type_examples'
50
58
  require_relative 'provider_translator_examples'
51
59
  require_relative 'client_translator_examples'
@@ -21,7 +21,7 @@
21
21
  }
22
22
  },
23
23
  "params": { "max_tokens": 1024, "temperature": 0.5 },
24
- "thinking": { "effort": "low", "budget": 1024 },
24
+ "thinking": { "enabled": true, "effort": "low", "budget": 1024 },
25
25
  "stream": false,
26
26
  "conversation_id": "conv_fleet_rt_001",
27
27
  "caller": { "type": "anthropic_messages", "client_id": "test_client" },
@@ -8,12 +8,12 @@
8
8
  }
9
9
  ],
10
10
  "thinking": {
11
+ "enabled": true,
11
12
  "effort": "high",
12
13
  "budget": 4096
13
14
  },
14
15
  "params": {
15
- "max_tokens": 2048,
16
- "max_thinking_tokens": 4096
16
+ "max_tokens": 2048
17
17
  },
18
18
  "stream": false
19
19
  }
@@ -298,7 +298,7 @@ RSpec.shared_examples 'a canonical provider translator' do |translator_class|
298
298
  it 'preserves tool call identity across chunks' do
299
299
  tool_chunks = chunks.select { |c| c['type'] == 'tool_call_delta' }
300
300
  parsed_chunks = tool_chunks.map { |c| translator.parse_chunk(c) }
301
- ids = parsed_chunks.map { |c| c.tool_call&.id }
301
+ ids = parsed_chunks.map { |c| c.tool_call.is_a?(Hash) ? c.tool_call[:id] : c.tool_call&.id }
302
302
  expect(ids.uniq.length).to eq(1)
303
303
  end
304
304
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/llm/inventory/registry'
5
+ require 'support/ssot_registry_helpers'
6
+ require_relative 'conformance'
7
+ require_relative 'ssot_contract_examples'
8
+
9
+ # Kit self-test: the B/F/R shared examples run against a real Provider-derived
10
+ # callable, the real Registry, and the real fleet responder/worker — proving
11
+ # the examples provider gems will load are executable and meaningful.
12
+ # rubocop:disable-next RSpec/DescribeClass, RSpec/MultipleMemoizedHelpers -- kit host
13
+ RSpec.describe 'SSOT v4 contract conformance kit' do
14
+ include SsotRegistryHelpers
15
+
16
+ let(:registry) { Legion::Extensions::Llm::Inventory::Registry }
17
+ let(:family) { :fake_llm }
18
+ let(:key) do
19
+ Legion::Extensions::Llm::Inventory::Identity::InstanceKey.new(provider_family: family, instance_id: 'primary')
20
+ end
21
+ let(:coordinator) { probe_coordinator(key) }
22
+ let(:activated) { true }
23
+
24
+ # A real 0.8.0 callable: Provider-derived, canonical in and out, no HTTP.
25
+ let(:callable) do
26
+ Class.new(Legion::Extensions::Llm::Provider) do
27
+ def self.slug = 'kit'
28
+ def self.configuration_requirements = []
29
+ def api_base = 'https://kit.invalid'
30
+
31
+ def chat(messages, model:, tools: nil, **_rest)
32
+ enforce_canonical_messages!(messages)
33
+ enforce_canonical_tools!(tools)
34
+ Legion::Extensions::Llm::Canonical::Response.build(text: 'ok', model: model, stop_reason: :end_turn)
35
+ end
36
+
37
+ # rubocop:disable-next Lint/UnusedMethodArgument -- model: mirrors the 0.8.0 callable contract
38
+ def stream_chat(messages, model: nil, tools: nil, **_contract, &block)
39
+ enforce_canonical_messages!(messages)
40
+ enforce_canonical_tools!(tools)
41
+ block&.call(Legion::Extensions::Llm::Canonical::Chunk.text_delta(delta: 'ok', request_id: nil))
42
+ block&.call(Legion::Extensions::Llm::Canonical::Chunk.done(request_id: nil, stop_reason: :end_turn,
43
+ usage: Legion::Extensions::Llm::Canonical::Usage.build(input_tokens: 1)))
44
+ nil
45
+ end
46
+
47
+ def count_tokens(messages:, **_contract)
48
+ enforce_canonical_messages!(messages)
49
+ 1
50
+ end
51
+ end.new(Legion::Extensions::Llm.config)
52
+ end
53
+ let(:instance_key) { key }
54
+ let(:lane_id) { registry.snapshot.lanes_for(instance_key: key).first.lane_id }
55
+ # The fleet claim field keeps the name offering_id (D4 protocol continuity);
56
+ # its value is the lane's 5 tuple.
57
+ let(:offering_id) { lane_id }
58
+ let(:callable_handle) { registry.snapshot.instance(instance_key: key).callable_handle }
59
+ let(:operation_evidence_map) { operation_evidence }
60
+ let(:unknown_value) do
61
+ Legion::Extensions::Llm::Inventory::ValueEvidence.new(status: :unknown, source: :absent)
62
+ end
63
+ let(:exact_envelope_data) do
64
+ {
65
+ request_id: 'req-1', correlation_id: 'corr-1', idempotency_key: 'idem-1',
66
+ operation: 'chat', provider: family.to_s, provider_instance: 'primary', model: 'gemma4',
67
+ params: { messages: [{ role: 'user', content: 'hello' }] },
68
+ reply_to: 'reply.1', message_context: {}, caller: 'e2e', trace_context: {},
69
+ signed_token: 'unsigned', timeout_seconds: 30,
70
+ expires_at: (Time.now.utc + 120).iso8601,
71
+ protocol_version: Legion::Extensions::Llm::Fleet::Protocol::VERSION,
72
+ execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
73
+ offering_id: offering_id
74
+ }
75
+ end
76
+ let(:valid_request_options) do
77
+ {
78
+ routing_key: 'llm.fleet.inference.gemma4.ctx8192',
79
+ request_id: 'req-1', correlation_id: 'corr-1', idempotency_key: 'idem-1',
80
+ operation: :chat, provider: family.to_s, provider_instance: 'primary', model: 'gemma4',
81
+ params: { messages: [{ role: 'user', content: 'hello' }] },
82
+ reply_to: 'reply.1', message_context: {}, caller: 'e2e', trace_context: {},
83
+ signed_token: 'signed.jwt', timeout_seconds: 30, expires_at: '2026-08-20T12:00:00Z',
84
+ protocol_version: Legion::Extensions::Llm::Fleet::Protocol::VERSION,
85
+ execution_contract: Legion::Extensions::Llm::Fleet::Protocol::EXACT_EXECUTION_CONTRACT,
86
+ offering_id: offering_id
87
+ }
88
+ end
89
+
90
+ before do
91
+ registry.reset!
92
+ claim_and_activate(
93
+ key: key, callable: callable, coordinator: coordinator, model: 'gemma4',
94
+ weight_inputs: { tier: 100, provider: 50, instance: 2, model_or_offering: 1 },
95
+ base_weight: 10_000
96
+ )
97
+ allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value).and_call_original
98
+ allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
99
+ .with(:fleet, :auth, :require_signed_token, default: true).and_return(false)
100
+ allow(Legion::Extensions::Llm::Fleet::Settings).to receive(:value)
101
+ .with(:fleet, :responder, :require_idempotency, default: nil).and_return(false)
102
+ Legion::Extensions::Llm::Fleet::WorkerExecution.reset_idempotency_cache!
103
+ Legion::Extensions::Llm::Fleet::TokenValidator.reset_replay_cache!
104
+ end
105
+
106
+ it_behaves_like 'B1 — central canonical enforcement (08 F2)'
107
+ it_behaves_like 'B1b — central canonical tool enforcement (H3)'
108
+ it_behaves_like 'B2 — canonical outputs (05 O5, 08 R2)'
109
+ it_behaves_like 'B3 — operation preservation (PR #189 defect class)'
110
+ it_behaves_like 'B4 — no model re-derivation (PR #45 law)'
111
+ it_behaves_like 'B5 — no weight recomputation (PR #47 defect class)'
112
+ it_behaves_like 'B6 — zero-weight disable (U4)'
113
+ it_behaves_like 'B7 — health authority (RULES 9)'
114
+ it_behaves_like 'B8 — exact execution never downgrades (06 P2/W1)'
115
+ it_behaves_like 'B9 — no silent defaults (06 W5)'
116
+ it_behaves_like 'F1 — envelope round-trip (06 E2)'
117
+ it_behaves_like 'F2 — rehydration identity (06 W4, E01)'
118
+ it_behaves_like 'F3 — signing law (06 S3)'
119
+ it_behaves_like 'F4 — fencing (07 §3)'
120
+ it_behaves_like 'F5 — response envelope (06 E3/E4)'
121
+ it_behaves_like 'F6 — contract errors (06 §5)'
122
+ it_behaves_like 'F7 — retryability (06 F6)'
123
+ it_behaves_like 'R1 — state machine (07 §3)'
124
+ it_behaves_like 'R2 — identity (07 §1, 5-tuple law)'
125
+ it_behaves_like 'R3 — snapshot law (07 §5)'
126
+ it_behaves_like 'R4 — weight law (07 §4)'
127
+ it_behaves_like 'R5 — callable lifecycle (07 §6)'
128
+ end