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
@@ -2,39 +2,40 @@
2
2
 
3
3
  require 'securerandom'
4
4
 
5
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
5
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
6
6
  module Legion
7
7
  module Extensions
8
8
  module Llm
9
+ # -- required for Data.define block scope
9
10
  module Canonical
10
- # rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
11
11
  # Canonical tool call with source enum and compliance fields.
12
12
  # Ports field vocabulary from Legion::LLM::Types::ToolCall.
13
13
  # Source enum per R7: :client | :registry | :special | :extension | :mcp
14
14
  # Compliance fields per R8: data_handling_classification, policy_decision
15
+ # arguments is a Hash only (O03a): JSON-string arguments are a provider
16
+ # wire spelling parsed at the provider translator edge (10 U2).
15
17
  ToolCall = ::Data.define(
16
18
  :id, :exchange_id, :name, :arguments, :source,
17
19
  :status, :duration_ms, :result, :error,
18
20
  :started_at, :finished_at, :category,
19
- :data_handling_classification, :policy_decision
21
+ :data_handling_classification, :policy_decision, :metadata
20
22
  ) do
21
- SOURCE_VALUES = %i[client registry special extension mcp].freeze
22
- STATUS_VALUES = %i[pending running success error].freeze
23
-
24
23
  # Build from keyword args (primary constructor).
24
+ # arguments: nil means "no arguments" and normalizes to {} (documented
25
+ # default, not tolerance).
25
26
  def self.build(
26
27
  name:, id: nil, exchange_id: nil, arguments: nil, source: nil,
27
28
  status: nil, duration_ms: nil, result: nil, error: nil,
28
29
  started_at: nil, finished_at: nil, category: nil,
29
- data_handling_classification: nil, policy_decision: nil
30
+ data_handling_classification: nil, policy_decision: nil, metadata: {}
30
31
  )
31
32
  new(
32
33
  id: id || "call_#{SecureRandom.hex(12)}",
33
34
  exchange_id: exchange_id,
34
- name: name,
35
- arguments: arguments || {},
36
- source: source,
37
- status: status,
35
+ name: Strict.expect_type!(name, [::String], self::BUILD_SITE, :name),
36
+ arguments: arguments.nil? ? {} : Strict.expect_type!(arguments, [::Hash], self::BUILD_SITE, :arguments),
37
+ source: normalize_enum!(source, self::SOURCE_VALUES, self::BUILD_SITE, :source),
38
+ status: normalize_enum!(status, self::STATUS_VALUES, self::BUILD_SITE, :status),
38
39
  duration_ms: duration_ms,
39
40
  result: result,
40
41
  error: error,
@@ -42,38 +43,30 @@ module Legion
42
43
  finished_at: finished_at,
43
44
  category: category,
44
45
  data_handling_classification: data_handling_classification,
45
- policy_decision: policy_decision
46
+ policy_decision: policy_decision,
47
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE)
46
48
  )
47
49
  end
48
50
 
49
51
  # Build from a Hash (raw provider response or deserialized wire payload).
50
- def self.from_hash(hash)
51
- return nil if hash.nil?
52
-
53
- h = hash.transform_keys(&:to_sym)
54
-
55
- # Normalize source to symbol
56
- source_raw = h[:source]
57
- h[:source] = source_raw&.to_sym if source_raw.is_a?(String)
58
-
59
- # Normalize status to symbol
60
- status_raw = h[:status]
61
- h[:status] = status_raw&.to_sym if status_raw.is_a?(String)
52
+ def self.from_hash(source)
53
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
54
+ hash = Strict.symbolize_keys(source)
55
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
56
+ build(**hash, metadata:)
57
+ end
62
58
 
63
- # Parse arguments if they're a JSON string
64
- args = h[:arguments]
65
- if args.is_a?(String) && !args.empty?
66
- begin
67
- h[:arguments] = Legion::JSON.load(args)
68
- rescue Legion::JSON::ParseError => e
69
- Legion::Logging.debug("[lex-llm][canonical][tool_call] arguments not parseable as JSON, leaving as string: #{e.message}")
70
- end
71
- end
59
+ # L6: declared enums validated at construction, in both factories.
60
+ def self.normalize_enum!(value, allowed, site, member)
61
+ return nil if value.nil?
72
62
 
73
- build(**h)
63
+ value_sym = value.is_a?(::String) ? value.to_sym : value
64
+ Strict.enum!(value_sym, allowed, site, member)
74
65
  end
75
66
 
76
- # Return a new ToolCall with execution result attached.
67
+ # Return a new ToolCall with execution result attached. The strict
68
+ # constructor re-validates every carried member (status enum
69
+ # included) — the escape-hatch .new of the pre-H1 world is gone.
77
70
  def with_result(result:, status:, duration_ms: nil, finished_at: nil)
78
71
  self.class.new(
79
72
  id: id,
@@ -89,7 +82,8 @@ module Legion
89
82
  finished_at: finished_at || ::Time.now,
90
83
  category: category,
91
84
  data_handling_classification: data_handling_classification,
92
- policy_decision: policy_decision
85
+ policy_decision: policy_decision,
86
+ metadata: metadata
93
87
  )
94
88
  end
95
89
 
@@ -116,29 +110,25 @@ module Legion
116
110
  to_h.to_json(*)
117
111
  end
118
112
 
119
- # Subset for audit/ledger emission.
120
- def to_audit_hash
121
- {
122
- id: id,
123
- name: name,
124
- arguments: arguments,
125
- status: status,
126
- duration_ms: duration_ms,
127
- error: error,
128
- exchange_id: exchange_id,
129
- source: source,
130
- category: category,
131
- data_handling_classification: data_handling_classification,
132
- policy_decision: policy_decision
133
- }.compact
113
+ # H1: the single strict constructor — .new runs the same member
114
+ # contract as the factories; the factories fill their defaults and
115
+ # delegate here.
116
+ Strict.install_strict_new!(self) do |values, site|
117
+ values[:name] = Strict.expect_type!(values[:name], [::String], site, :name)
118
+ values[:arguments] = values[:arguments].nil? ? {} : Strict.expect_type!(values[:arguments], [::Hash], site, :arguments)
119
+ values[:source] = normalize_enum!(values[:source], self::SOURCE_VALUES, site, :source)
120
+ values[:status] = normalize_enum!(values[:status], self::STATUS_VALUES, site, :status)
121
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
122
+ values
134
123
  end
135
124
  end
136
125
 
137
126
  ToolCall::SOURCE_VALUES = %i[client registry special extension mcp].freeze
138
127
  ToolCall::STATUS_VALUES = %i[pending running success error].freeze
139
- # rubocop:enable Lint/ConstantDefinitionInBlock
128
+ ToolCall::BUILD_SITE = 'Canonical::ToolCall.build'
129
+ ToolCall::FROM_HASH_SITE = 'Canonical::ToolCall.from_hash'
130
+ ToolCall::NEW_SITE = 'Canonical::ToolCall.new'
140
131
  end
141
132
  end
142
133
  end
143
134
  end
144
- # rubocop:enable Metrics/ParameterLists
@@ -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,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'canonical/strict'
3
4
  require_relative 'canonical/thinking'
5
+ require_relative 'canonical/thinking_config'
4
6
  require_relative 'canonical/usage'
5
7
  require_relative 'canonical/params'
6
8
  require_relative 'canonical/content_block'
@@ -26,17 +28,17 @@ module Legion
26
28
  module Canonical
27
29
  CONTRACT_VERSION = '1.0.0'
28
30
 
29
- # Available canonical types.
31
+ # Available canonical types (the frozen 04 §1 inventory).
30
32
  TYPES = %i[
31
- Thinking Usage Params ContentBlock
32
- ToolDefinition ToolCall Message
33
- Request Response Chunk
33
+ Message ContentBlock ToolCall ToolDefinition ToolSchema
34
+ Params Thinking Thinking::Config
35
+ Request Response Chunk Usage
34
36
  ].freeze
35
37
 
36
38
  class << self
37
39
  # List all canonical type classes.
38
40
  def types
39
- TYPES.map { |name| const_get(name) }
41
+ TYPES.map { |name| name.to_s.split('::').reduce(self) { |mod, part| mod.const_get(part) } }
40
42
  end
41
43
 
42
44
  # 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