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
@@ -4,21 +4,20 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Canonical
7
- TOOL_NAME_MAX_LENGTH = 64
8
7
  OBJECT_SCHEMA_KEYWORDS = %i[properties required additionalProperties].freeze
9
8
  COMPOSITE_SCHEMA_KEYWORDS = %i[oneOf anyOf allOf enum $ref $defs definitions].freeze
10
9
 
11
10
  # Canonical tool definition.
12
- # Ports field vocabulary from Legion::LLM::Types::ToolDefinition.
13
- ToolDefinition = ::Data.define(:name, :description, :parameters, :source) do
11
+ # Ports field vocabulary from Legion::LLM::Types::ToolDefinition. # -- required for Data.define block scope
12
+ ToolDefinition = ::Data.define(:name, :description, :parameters, :source, :metadata) do
14
13
  def self.normalize_parameters(parameters)
15
14
  empty = { type: 'object', properties: {} }
16
15
  return empty if parameters.nil?
17
16
 
18
- schema = if parameters.respond_to?(:transform_keys)
19
- parameters.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
20
- end
21
- return empty if schema.nil? || schema.empty?
17
+ Strict.expect_type!(parameters, [::Hash], self::NORMALIZE_PARAMETERS_SITE, :parameters) unless parameters.is_a?(::Hash)
18
+
19
+ schema = parameters.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
20
+ return empty if schema.empty?
22
21
  return schema if schema.key?(:type)
23
22
  return schema.merge(type: 'object') if OBJECT_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
24
23
  return schema if COMPOSITE_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
@@ -26,53 +25,50 @@ module Legion
26
25
  { type: 'object', properties: schema }
27
26
  end
28
27
 
29
- # Build from keyword args (primary constructor).
30
- def self.build(name:, description: '', parameters: nil, source: nil)
28
+ # Build from keyword args (primary constructor). M5: the name is the
29
+ # authoritative client/registry fact — never rewritten, stripped,
30
+ # truncated, or fabricated here; per-dialect name constraints belong
31
+ # to the provider translator edge. source is explicit or absent —
32
+ # never fabricated ({ type: :builtin } is deleted).
33
+ def self.build(name:, description: '', parameters: nil, source: nil, metadata: {})
31
34
  new(
32
- sanitize_tool_name(name),
33
- description.to_s,
35
+ name,
36
+ description,
34
37
  normalize_parameters(parameters),
35
- source || { type: :builtin }
38
+ source,
39
+ Strict.metadata!(metadata, self::BUILD_SITE)
36
40
  )
37
41
  end
38
42
 
39
43
  # Build from a Hash (raw provider response or deserialized wire payload).
40
- def self.from_hash(hash, source: nil)
41
- return nil if hash.nil?
42
-
43
- normalized = hash.respond_to?(:transform_keys) ? hash.transform_keys(&:to_sym) : {}
44
+ # Canonical keys only (O03a): edges pass `parameters`, not `input_schema`.
45
+ def self.from_hash(source)
46
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
47
+ hash = Strict.symbolize_keys(source)
48
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
44
49
  build(
45
- name: normalized[:name],
46
- description: normalized[:description],
47
- parameters: normalized[:parameters] || normalized[:input_schema],
48
- source: source || normalized[:source]
50
+ name: hash[:name],
51
+ description: hash[:description],
52
+ parameters: hash[:parameters],
53
+ source: hash[:source],
54
+ metadata:
49
55
  )
50
56
  end
51
57
 
52
- # Build from a registry entry (extension/registry tool metadata).
53
- def self.from_registry_entry(entry)
54
- source = {
55
- type: entry[:tool_class] ? :registry : :extension,
56
- tool_class: entry[:tool_class],
57
- extension: entry[:extension],
58
- runner: entry[:runner],
59
- function: entry[:function]
60
- }.compact
58
+ # M5: a tool name is authoritative — missing or empty is a contract
59
+ # error, never a fabricated label.
60
+ def self.require_name!(name, site)
61
+ raise ArgumentError, "#{site}: name must be a non-empty String, got #{name.class}: #{name.inspect}" unless name.is_a?(::String) && !name.empty?
61
62
 
62
- build(
63
- name: entry[:name],
64
- description: entry[:description],
65
- parameters: entry[:input_schema] || entry[:parameters],
66
- source: source.compact
67
- )
63
+ name
68
64
  end
69
65
 
70
- # Sanitize a tool name to be safe for all wire formats.
71
- def self.sanitize_tool_name(raw)
72
- name = raw.to_s.tr('.', '_')
73
- name = name.gsub(/[^a-zA-Z0-9_-]/, '')
74
- name = name[0, TOOL_NAME_MAX_LENGTH] if name.length > TOOL_NAME_MAX_LENGTH
75
- name.empty? ? 'tool' : name
66
+ # M5: description is a String; absence is the empty string (the
67
+ # documented no-description value), wrong class raises.
68
+ def self.description_value!(description, site)
69
+ return '' if description.nil?
70
+
71
+ Strict.expect_type!(description, [::String], site, :description)
76
72
  end
77
73
 
78
74
  def params_schema
@@ -85,11 +81,7 @@ module Legion
85
81
 
86
82
  # Serialize to a Hash for AMQP/fleet/wire transport.
87
83
  def to_h
88
- {
89
- name: name,
90
- description: description,
91
- parameters: parameters
92
- }.compact.reject { |k, v| k == :description && v == '' }
84
+ super.compact
93
85
  end
94
86
 
95
87
  # MultiJson/Oj/::JSON callback — prevents Data.define #inspect leak into JSON.
@@ -100,7 +92,25 @@ module Legion
100
92
  def to_json(*)
101
93
  to_h.to_json(*)
102
94
  end
95
+
96
+ # H1/M5: the single strict constructor — .new runs the same member
97
+ # contract as the factories (authoritative name, String
98
+ # description, normalized parameters, explicit-or-absent source);
99
+ # the factories fill their defaults and delegate here.
100
+ Strict.install_strict_new!(self) do |values, site|
101
+ values[:name] = require_name!(values[:name], site)
102
+ values[:description] = description_value!(values[:description], site)
103
+ values[:parameters] = normalize_parameters(values[:parameters])
104
+ values[:source] = Strict.expect_type!(values[:source], [::Hash], site, :source)
105
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
106
+ values
107
+ end
103
108
  end
109
+
110
+ ToolDefinition::BUILD_SITE = 'Canonical::ToolDefinition.build'
111
+ ToolDefinition::FROM_HASH_SITE = 'Canonical::ToolDefinition.from_hash'
112
+ ToolDefinition::NEW_SITE = 'Canonical::ToolDefinition.new'
113
+ ToolDefinition::NORMALIZE_PARAMETERS_SITE = 'Canonical::ToolDefinition.normalize_parameters'
104
114
  end
105
115
  end
106
116
  end
@@ -4,40 +4,33 @@ module Legion
4
4
  module Extensions
5
5
  module Llm
6
6
  module Canonical
7
- # Extracts and normalizes tool schemas from heterogeneous sources.
7
+ # Extracts and normalizes tool schemas from ToolDefinition ONLY (04 §6).
8
+ # A schema extractor that tolerates raw hashes is a hidden path over the
9
+ # canonical type — wrong-class input raises (L3).
8
10
  module ToolSchema
9
11
  EMPTY_OBJECT = { type: 'object', properties: {} }.freeze
10
12
 
11
13
  module_function
12
14
 
13
- def extract(tool)
14
- raw = raw_schema(tool)
15
- ToolDefinition.normalize_parameters(raw)
15
+ def extract(tool_definition)
16
+ require_tool_definition!(tool_definition, 'ToolSchema.extract')
17
+ ToolDefinition.normalize_parameters(tool_definition.parameters)
16
18
  end
17
19
 
18
- def raw_schema(tool)
19
- return nil if tool.nil?
20
- return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
21
- return tool.parameters if tool.respond_to?(:parameters) && tool.parameters
22
-
23
- return unless tool.respond_to?(:[])
24
-
25
- tool[:parameters] || tool['parameters'] || tool[:input_schema] || tool['input_schema'] ||
26
- tool[:params_schema] || tool['params_schema']
20
+ def tool_name(tool_definition)
21
+ require_tool_definition!(tool_definition, 'ToolSchema.tool_name')
22
+ tool_definition.name
27
23
  end
28
24
 
29
- def tool_name(tool)
30
- return tool.name if tool.respond_to?(:name) && !tool.is_a?(Hash)
31
- return tool[:name] || tool['name'] if tool.respond_to?(:[])
32
-
33
- 'unknown'
25
+ def tool_description(tool_definition)
26
+ require_tool_definition!(tool_definition, 'ToolSchema.tool_description')
27
+ tool_definition.description
34
28
  end
35
29
 
36
- def tool_description(tool)
37
- return tool.description if tool.respond_to?(:description) && !tool.is_a?(Hash)
38
- return (tool[:description] || tool['description'] || '').to_s if tool.respond_to?(:[])
30
+ def require_tool_definition!(tool_definition, site)
31
+ return tool_definition if tool_definition.is_a?(ToolDefinition)
39
32
 
40
- ''
33
+ raise ArgumentError, "#{site}: expected Canonical::ToolDefinition, got #{tool_definition.class}"
41
34
  end
42
35
  end
43
36
  end
@@ -4,56 +4,44 @@
4
4
  module Legion
5
5
  module Extensions
6
6
  module Llm
7
+ # -- required for Data.define block scope
7
8
  module Canonical
8
- # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
9
9
  # Canonical usage/metering data for a response.
10
10
  # Ports field vocabulary from lex-llm Tokens and legion-llm Types.
11
11
  # Includes non-token units extension point per G20b.
12
+ # Canonical keys only (O03a): provider spellings are translated at the edges.
12
13
  Usage = ::Data.define(
13
14
  :input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens,
14
- :thinking_tokens, :units
15
+ :thinking_tokens, :units, :metadata
15
16
  ) do
16
- USAGE_KNOWN_KEYS = %i[input_tokens output_tokens cache_read_tokens cache_write_tokens
17
- thinking_tokens units].freeze
18
-
19
- # Build from a Hash (raw provider response or deserialized wire payload).
20
- # Accepts both canonical key names and legacy provider spellings.
21
- def self.from_hash(source)
22
- return nil if source.nil? || source.empty?
23
-
24
- h = source.transform_keys(&:to_sym)
25
-
26
- # Normalize legacy key names
27
- h[:input_tokens] ||= h.delete(:input) || h.delete(:prompt_tokens)
28
- h[:output_tokens] ||= h.delete(:output) || h.delete(:completion_tokens)
29
- h[:cache_read_tokens] ||= h.delete(:cached) || h.delete(:cache_read)
30
- h[:cache_write_tokens] ||= h.delete(:cache_creation) || h.delete(:cache_write)
31
- h[:thinking_tokens] ||= h.delete(:thinking) || h.delete(:reasoning)
32
-
33
- # Extract nested details (OpenAI prompt_tokens_details / input_tokens_details)
34
- h[:cache_read_tokens] ||= dig_nested(h, :prompt_tokens_details, :cached_tokens) ||
35
- dig_nested(h, :input_tokens_details, :cached_tokens)
36
- h[:thinking_tokens] ||= dig_nested(h, :completion_tokens_details, :reasoning_tokens) ||
37
- dig_nested(h, :output_tokens_details, :reasoning_tokens)
38
-
39
- # Extract units (non-token extension point — G20b)
40
- units = h.delete(:units) || {}
41
-
17
+ # rubocop:disable Metrics/ParameterLists -- factory methods have many params
18
+ # Build from keyword args (primary constructor).
19
+ def self.build(
20
+ input_tokens: nil, output_tokens: nil, cache_read_tokens: nil,
21
+ cache_write_tokens: nil, thinking_tokens: nil, units: nil, metadata: {}
22
+ )
42
23
  new(
43
- input_tokens: h[:input_tokens],
44
- output_tokens: h[:output_tokens],
45
- cache_read_tokens: h[:cache_read_tokens],
46
- cache_write_tokens: h[:cache_write_tokens],
47
- thinking_tokens: h[:thinking_tokens],
48
- units: units
24
+ input_tokens:, output_tokens:, cache_read_tokens:, cache_write_tokens:,
25
+ thinking_tokens:, units: units || {}, metadata: Strict.metadata!(metadata, self::BUILD_SITE)
49
26
  )
50
27
  end
28
+ # rubocop:enable Metrics/ParameterLists
51
29
 
52
- def self.dig_nested(hash, details_key, value_key)
53
- details = hash[details_key]
54
- return nil unless details.is_a?(Hash)
55
-
56
- details[value_key] || details[value_key.to_s]
30
+ # Build from a Hash (raw provider response or deserialized wire payload).
31
+ # from_hash({}) is a valid all-nil Usage (the no-usage object), never nil.
32
+ def self.from_hash(source)
33
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
34
+ hash = Strict.symbolize_keys(source)
35
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
36
+ build(
37
+ input_tokens: hash[:input_tokens],
38
+ output_tokens: hash[:output_tokens],
39
+ cache_read_tokens: hash[:cache_read_tokens],
40
+ cache_write_tokens: hash[:cache_write_tokens],
41
+ thinking_tokens: hash[:thinking_tokens],
42
+ units: hash[:units] || {},
43
+ metadata:
44
+ )
57
45
  end
58
46
 
59
47
  # Serialize to a Hash for AMQP/fleet/wire transport.
@@ -75,8 +63,27 @@ module Legion
75
63
  [input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
76
64
  thinking_tokens].compact.sum
77
65
  end
66
+
67
+ # H1/M3: the single strict constructor. .new validates the token
68
+ # members as Integers (a string count would raise deep in
69
+ # StreamAccumulator#total_tokens instead of at construction) and
70
+ # units as a Hash. The factories fill their defaults and delegate
71
+ # here.
72
+ Strict.install_strict_new!(self) do |values, site|
73
+ values[:input_tokens] = Strict.expect_type!(values[:input_tokens], [::Integer], site, :input_tokens)
74
+ values[:output_tokens] = Strict.expect_type!(values[:output_tokens], [::Integer], site, :output_tokens)
75
+ values[:cache_read_tokens] = Strict.expect_type!(values[:cache_read_tokens], [::Integer], site, :cache_read_tokens)
76
+ values[:cache_write_tokens] = Strict.expect_type!(values[:cache_write_tokens], [::Integer], site, :cache_write_tokens)
77
+ values[:thinking_tokens] = Strict.expect_type!(values[:thinking_tokens], [::Integer], site, :thinking_tokens)
78
+ values[:units] = values[:units].nil? ? {} : Strict.expect_type!(values[:units], [::Hash], site, :units)
79
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
80
+ values
81
+ end
78
82
  end
79
- # rubocop:enable Lint/ConstantDefinitionInBlock
83
+
84
+ Usage::BUILD_SITE = 'Canonical::Usage.build'
85
+ Usage::FROM_HASH_SITE = 'Canonical::Usage.from_hash'
86
+ Usage::NEW_SITE = 'Canonical::Usage.new'
80
87
  end
81
88
  end
82
89
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'canonical/strict'
3
4
  require_relative 'canonical/thinking'
4
5
  require_relative 'canonical/usage'
5
6
  require_relative 'canonical/params'
@@ -26,17 +27,17 @@ module Legion
26
27
  module Canonical
27
28
  CONTRACT_VERSION = '1.0.0'
28
29
 
29
- # Available canonical types.
30
+ # Available canonical types (the frozen 04 §1 inventory).
30
31
  TYPES = %i[
31
- Thinking Usage Params ContentBlock
32
- ToolDefinition ToolCall Message
33
- Request Response Chunk
32
+ Message ContentBlock ToolCall ToolDefinition ToolSchema
33
+ Params Thinking Thinking::Config
34
+ Request Response Chunk Usage
34
35
  ].freeze
35
36
 
36
37
  class << self
37
38
  # List all canonical type classes.
38
39
  def types
39
- TYPES.map { |name| const_get(name) }
40
+ TYPES.map { |name| name.to_s.split('::').reduce(self) { |mod, part| mod.const_get(part) } }
40
41
  end
41
42
 
42
43
  # Check if a given constant name is a registered canonical type.
@@ -26,23 +26,49 @@ module Legion
26
26
  Array(keys).each { |key| option(key.to_sym) }
27
27
  end
28
28
 
29
+ include Legion::Logging::Helper
30
+
31
+ # L5: the log defaults, read from the settings system (the ENV
32
+ # reads are deleted). A configured log_level Symbol/String names a
33
+ # Logger constant — an unknown name is a configuration error (it
34
+ # raises, it does not fall back).
35
+ def log_settings_defaults
36
+ {
37
+ level: log_level_value(llm_setting(:log_level)),
38
+ stream_debug: llm_setting(:log_stream_debug) == true
39
+ }
40
+ end
41
+
29
42
  private
30
43
 
31
44
  def option_keys = @option_keys ||= []
32
45
  def defaults = @defaults ||= {}
33
- private :option
46
+
47
+ def log_level_value(value)
48
+ return Logger::INFO if value.nil?
49
+
50
+ value.is_a?(::Integer) ? value : Logger.const_get(value.to_s.upcase)
51
+ end
52
+
53
+ def llm_setting(key)
54
+ return nil unless defined?(::Legion::Settings) && ::Legion::Settings.respond_to?(:dig)
55
+
56
+ ::Legion::Settings.dig(:extensions, :llm, key)
57
+ rescue StandardError => e
58
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.configuration.setting', key:)
59
+ nil
60
+ end
61
+ private :option, :log_level_value, :llm_setting
34
62
  end
35
63
 
36
64
  # System-level options are declared here.
37
65
  # Provider-specific options are declared in each provider extension via
38
66
  # `self.configuration_options`.
39
- option :default_model, nil
40
- option :default_embedding_model, nil
41
- option :default_moderation_model, nil
42
- option :default_image_model, nil
43
- option :default_transcription_model, nil
44
-
45
- option :model_registry_file, -> { File.expand_path('models.json', __dir__) }
67
+ # H4: the dormant default_model / default_*_model options are deleted —
68
+ # a model-defaulting authority with no consumer (verified in-repo and
69
+ # in consumer gems). Model selection belongs to the router.
70
+ # The model_registry_file option is deleted with the Models catalog
71
+ # (the second inventory) — the SSOT registry is the only inventory.
46
72
 
47
73
  option :request_timeout, 300
48
74
  option :max_retries, 3
@@ -53,8 +79,12 @@ module Legion
53
79
 
54
80
  option :logger, nil
55
81
  option :log_file, -> { $stdout }
56
- option :log_level, -> { ENV['LEGION_LLM_DEBUG'] ? Logger::DEBUG : Logger::INFO }
57
- option :log_stream_debug, -> { ENV['LEGION_LLM_STREAM_DEBUG'] == 'true' }
82
+ # L5: the ENV reads (LEGION_LLM_DEBUG / LEGION_LLM_STREAM_DEBUG) are
83
+ # deleted — every tunable lives in the settings system:
84
+ # extensions.llm.log_level (Symbol/Integer; Logger::INFO when unset)
85
+ # and extensions.llm.log_stream_debug (boolean; false when unset).
86
+ option :log_level, -> { self.class.log_settings_defaults[:level] }
87
+ option :log_stream_debug, -> { self.class.log_settings_defaults[:stream_debug] }
58
88
  option :log_regexp_timeout, -> { Regexp.respond_to?(:timeout) ? (Regexp.timeout || 1.0) : nil }
59
89
 
60
90
  # Prompt caching
@@ -13,7 +13,7 @@ module Legion
13
13
  include Legion::Logging::Helper
14
14
 
15
15
  def basic(&)
16
- logger = faraday_logger
16
+ logger = faraday_logger_for(Legion::Extensions::Llm.config, log)
17
17
  Faraday.new do |f|
18
18
  f.response :logger,
19
19
  logger,
@@ -25,22 +25,20 @@ module Legion
25
25
  yield f if block_given?
26
26
  end
27
27
  end
28
+ end
28
29
 
29
- private
30
-
31
- def faraday_logger
32
- config = Legion::Extensions::Llm.config
33
- return config.logger if config.respond_to?(:logger) && config.logger
30
+ # One Faraday logger resolution (10 U9): the configured logger when
31
+ # present, else the caller's fallback log.
32
+ def self.faraday_logger_for(config_source, fallback_log)
33
+ return config_source.logger if config_source.respond_to?(:logger) && config_source.logger
34
34
 
35
- log
36
- end
35
+ fallback_log
37
36
  end
38
37
 
39
38
  def initialize(provider, config)
40
39
  @provider = provider
41
40
  @config = config
42
41
 
43
- ensure_configured!
44
42
  @connection ||= Faraday.new(provider.api_base) do |faraday|
45
43
  faraday.ssl.verify = false
46
44
  setup_timeout(faraday)
@@ -83,7 +81,7 @@ module Legion
83
81
  end
84
82
 
85
83
  def setup_logging(faraday)
86
- logger = faraday_logger
84
+ logger = self.class.faraday_logger_for(config, log)
87
85
  # Enable request body logging when the logger is at DEBUG level,
88
86
  # or when explicitly enabled via fleet request_payload setting.
89
87
  request_payload = Legion::Extensions::Llm.default_settings.dig(:fleet, :request, :logger, :request_payload)
@@ -100,12 +98,6 @@ module Legion
100
98
  end
101
99
  end
102
100
 
103
- def faraday_logger
104
- return config.logger if config.respond_to?(:logger) && config.logger
105
-
106
- log
107
- end
108
-
109
101
  def debug_logger?(logger)
110
102
  return logger.debug? if logger.respond_to?(:debug?)
111
103
  return logger.level.to_i <= Logger::DEBUG if logger.respond_to?(:level)
@@ -164,20 +156,6 @@ module Legion
164
156
  Legion::Extensions::Llm::OverloadedError
165
157
  ]
166
158
  end
167
-
168
- def ensure_configured!
169
- return if @provider.configured?
170
-
171
- missing = @provider.configuration_requirements.reject { |req| @config.send(req) }
172
- config_block = <<~RUBY
173
- Legion::Extensions::Llm.configure do |config|
174
- #{missing.map { |key| "config.#{key} = ENV['#{key.to_s.upcase}']" }.join("\n ")}
175
- end
176
- RUBY
177
-
178
- raise ConfigurationError,
179
- "#{@provider.name} provider is not configured. Add this to your initialization:\n\n#{config_block}"
180
- end
181
159
  end
182
160
  end
183
161
  end
@@ -58,8 +58,12 @@ module Legion
58
58
  env_hash[key.to_sym] || env_hash[key.to_s]
59
59
  end
60
60
 
61
+ # The codex auth file is an optional source: absent means "no codex
62
+ # credential" (probe semantics); present-but-unreadable raises from
63
+ # read_json (O11 fail-closed).
61
64
  def codex_token
62
65
  return nil unless credential_source_probing_enabled?
66
+ return nil unless File.exist?(CODEX_AUTH)
63
67
 
64
68
  data = read_json(CODEX_AUTH)
65
69
  mode = data[:auth_mode] || data['auth_mode']
@@ -74,6 +78,7 @@ module Legion
74
78
 
75
79
  def codex_openai_key
76
80
  return nil unless credential_source_probing_enabled?
81
+ return nil unless File.exist?(CODEX_AUTH)
77
82
 
78
83
  data = read_json(CODEX_AUTH)
79
84
  val = data[:OPENAI_API_KEY] || data['OPENAI_API_KEY']
@@ -198,19 +203,12 @@ module Legion
198
203
  credential_fingerprint(val)
199
204
  end
200
205
 
201
- # Returns true when the URL points to localhost / 127.0.0.1 / ::1.
206
+ # Returns true when the URL points to localhost / 127.0.0.1 / ::1
207
+ # (the one shared host-locality detector — Utils.localhost_url?).
202
208
  def localhost?(url)
203
209
  return false if url.nil?
204
210
 
205
- uri = URI.parse(url.to_s)
206
- host = uri.host
207
- return false if host.nil?
208
-
209
- normalized = host.delete_prefix('[').delete_suffix(']')
210
- %w[localhost 127.0.0.1 ::1].include?(normalized)
211
- rescue URI::InvalidURIError => e
212
- handle_exception(e, level: :warn, handled: true, operation: 'llm.credential_sources.localhost')
213
- false
211
+ Utils.localhost_url?(url)
214
212
  end
215
213
 
216
214
  module_function :env, :credential_source_probing_enabled?,
@@ -224,69 +222,54 @@ module Legion
224
222
  # --- private helpers -----------------------------------------------
225
223
 
226
224
  # Merge user-level (~/.claude/settings.json) and project-level
227
- # (.claude/settings.json) Claude configs. Project overrides user.
225
+ # (.claude/settings.json) Claude configs. Both files are optional
226
+ # sources: absent means "no config from that level". Project overrides
227
+ # user.
228
228
  def merge_claude_configs
229
- user = read_json(CLAUDE_SETTINGS)
230
- project = read_json(CLAUDE_PROJECT)
231
- deep_merge(user, project)
229
+ user = File.exist?(CLAUDE_SETTINGS) ? read_json(CLAUDE_SETTINGS) : {}
230
+ project = File.exist?(CLAUDE_PROJECT) ? read_json(CLAUDE_PROJECT) : {}
231
+ Utils.deep_merge(user, project)
232
232
  end
233
233
 
234
- # Read and parse a JSON file. Returns an empty hash on any error.
234
+ # Read and parse a JSON file. O11 fail-closed: a missing, empty, or
235
+ # unreadable/unparseable credential file raises — it is a
236
+ # configuration error, never a fabricated empty credential.
235
237
  def read_json(path)
236
- return {} unless File.exist?(path)
238
+ raise ConfigurationError, "credential file is missing: #{path}" unless File.exist?(path)
237
239
 
238
240
  raw = File.read(path)
239
- return {} if raw.strip.empty?
241
+ raise ConfigurationError, "credential file is empty: #{path}" if raw.strip.empty?
240
242
 
241
- if defined?(::Legion::JSON)
242
- ::Legion::JSON.parse(raw, symbolize_names: true)
243
- else
244
- ::JSON.parse(raw, symbolize_names: true)
245
- end
246
- rescue StandardError => e
247
- handle_exception(e, level: :warn, handled: true, operation: 'llm.credential_sources.read_json',
248
- path:)
249
- {}
243
+ # L1: Legion::JSON only (house rule) — the bare ::JSON fallback is
244
+ # deleted; Legion::JSON is a hard dependency of this gem.
245
+ ::Legion::JSON.parse(raw, symbolize_names: true)
250
246
  end
251
247
 
252
248
  # JWT expiry check. Decodes the base64 payload segment and checks
253
- # that exp > now. Returns true on any parse error (benefit of the
254
- # doubt).
249
+ # that exp > now. O11 fail-closed: unreadable or invalid token data
250
+ # is INVALID (the old benefit-of-the-doubt true is deleted). A
251
+ # parseable token with no exp claim has nothing to violate.
255
252
  def token_valid?(token)
256
- return true if token.nil?
257
-
258
253
  require 'base64'
259
- require 'json'
260
254
 
261
255
  parts = token.to_s.split('.')
262
- return true unless parts.length >= 2
256
+ return false if parts.length < 2
263
257
 
264
- payload = ::JSON.parse(Base64.urlsafe_decode64(parts[1]))
258
+ # L1: Legion::JSON only (string keys — the JWT payload is read by
259
+ # string key).
260
+ payload = ::Legion::JSON.parse(Base64.urlsafe_decode64(parts[1]), symbolize_names: false)
265
261
  exp = payload['exp']
266
262
  return true if exp.nil?
267
263
 
268
264
  exp.to_i > Time.now.to_i
269
265
  rescue StandardError => e
270
266
  handle_exception(e, level: :warn, handled: true, operation: 'llm.credential_sources.token_valid')
271
- true
272
- end
273
-
274
- # Simple recursive hash merge (project values override user values).
275
- def deep_merge(base, override)
276
- base.merge(override) do |_key, old_val, new_val|
277
- if old_val.is_a?(Hash) && new_val.is_a?(Hash)
278
- deep_merge(old_val, new_val)
279
- else
280
- new_val
281
- end
282
- end
267
+ false
283
268
  end
284
269
 
285
- module_function :merge_claude_configs, :read_json,
286
- :token_valid?, :deep_merge
270
+ module_function :merge_claude_configs, :read_json, :token_valid?
287
271
 
288
- private_class_method :merge_claude_configs, :read_json,
289
- :token_valid?, :deep_merge
272
+ private_class_method :merge_claude_configs, :read_json, :token_valid?
290
273
  end
291
274
  end
292
275
  end