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,104 +4,75 @@
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 response shape — the provider-boundary contract.
10
10
  # Per R2: does NOT replace Inference::Response (the pipeline envelope).
11
11
  # Per Amendment A: immutable Data.define with strict factory.
12
+ # Unknown keys fold into metadata — never silently dropped.
12
13
  Response = ::Data.define(
13
14
  :text, :thinking, :tool_calls, :usage,
14
15
  :stop_reason, :model, :routing, :metadata
15
16
  ) do
16
- STOP_REASONS = %i[end_turn tool_use max_tokens stop_sequence content_filter error].freeze
17
+ # Build from keyword args (primary constructor).
18
+ def self.build(
19
+ text: '', thinking: nil, tool_calls: nil, usage: nil,
20
+ stop_reason: nil, model: nil, routing: nil, metadata: {}
21
+ )
22
+ new(
23
+ text: text.to_s,
24
+ thinking: normalize_thinking!(thinking, self::BUILD_SITE),
25
+ tool_calls: normalize_tool_calls!(tool_calls, self::BUILD_SITE),
26
+ usage: normalize_usage!(usage, self::BUILD_SITE),
27
+ stop_reason: normalize_stop_reason!(stop_reason, self::BUILD_SITE),
28
+ model: model,
29
+ routing: routing || {},
30
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE)
31
+ )
32
+ end
17
33
 
18
34
  # Build from a Hash (raw provider response or deserialized wire payload).
19
- # Unknown keys go to metadata, never silently dropped.
35
+ # Canonical keys only (O03a): edges pass `stop_reason`, not `finish_reason`.
20
36
  def self.from_hash(source)
21
- return nil if source.nil?
22
-
23
- h = source.transform_keys(&:to_sym)
24
-
25
- # Extract known fields
26
- text = h.delete(:text) || h.delete(:content) || ''
27
- text = text.to_s if text
28
-
29
- thinking_raw = h.delete(:thinking)
30
- thinking = thinking_raw.is_a?(Thinking) ? thinking_raw : Thinking.from_hash(thinking_raw)
31
-
32
- tool_calls_raw = h.delete(:tool_calls)
33
- tool_calls = Array(tool_calls_raw).filter_map do |tc|
34
- tc.is_a?(ToolCall) ? tc : ToolCall.from_hash(tc)
35
- end
37
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
38
+ hash = Strict.symbolize_keys(source)
39
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
40
+ build(**hash, metadata:)
41
+ end
36
42
 
37
- usage_raw = h.delete(:usage)
38
- usage = usage_raw.is_a?(Usage) ? usage_raw : Usage.from_hash(usage_raw)
43
+ # L6: stop_reason validated at construction, in both factories.
44
+ def self.normalize_stop_reason!(stop_reason, site)
45
+ stop_reason_sym = stop_reason&.to_sym
46
+ Strict.enum!(stop_reason_sym, self::STOP_REASONS, site, :stop_reason)
47
+ end
39
48
 
40
- # Normalize stop_reason
41
- stop_reason_raw = h.delete(:stop_reason) || h.delete(:finish_reason)
42
- stop_reason = stop_reason_raw&.to_sym if stop_reason_raw
43
- unless stop_reason.nil? || STOP_REASONS.include?(stop_reason)
44
- raise ArgumentError,
45
- "Invalid stop_reason: #{stop_reason.inspect}. Must be one of: #{STOP_REASONS.join(', ')}"
46
- end
49
+ # L2: one normalizer per member, shared by build and from_hash.
50
+ def self.normalize_thinking!(thinking, site)
51
+ return nil if thinking.nil?
52
+ return thinking if thinking.is_a?(Thinking)
47
53
 
48
- model = h.delete(:model)
49
- routing = h.delete(:routing) || {}
54
+ Strict.expect_type!(thinking, [::Hash], site, :thinking)
55
+ Thinking.from_hash(thinking)
56
+ end
50
57
 
51
- # Remaining keys become metadata
52
- existing_metadata = h.delete(:metadata) || {}
53
- metadata = existing_metadata.merge(h).compact
58
+ def self.normalize_tool_calls!(tool_calls, site)
59
+ return [] if tool_calls.nil?
54
60
 
55
- new(
56
- text: text,
57
- thinking: thinking,
58
- tool_calls: tool_calls,
59
- usage: usage,
60
- stop_reason: stop_reason,
61
- model: model,
62
- routing: routing,
63
- metadata: metadata
64
- )
61
+ Strict.expect_type!(tool_calls, [::Array], site, :tool_calls)
62
+ tool_calls.map { |tc| tc.is_a?(ToolCall) ? tc : ToolCall.from_hash(tc) }
65
63
  end
66
64
 
67
- # Build from keyword args (primary constructor).
68
- def self.build(
69
- text: '', thinking: nil, tool_calls: nil, usage: nil,
70
- stop_reason: nil, model: nil, routing: nil, metadata: nil
71
- )
72
- stop_reason_sym = stop_reason&.to_sym
73
- unless stop_reason_sym.nil? || STOP_REASONS.include?(stop_reason_sym)
74
- raise ArgumentError,
75
- "Invalid stop_reason: #{stop_reason_sym.inspect}. Must be one of: #{STOP_REASONS.join(', ')}"
76
- end
65
+ def self.normalize_usage!(usage, site)
66
+ return nil if usage.nil?
67
+ return usage if usage.is_a?(Usage)
77
68
 
78
- new(
79
- text: text.to_s,
80
- thinking: thinking,
81
- tool_calls: tool_calls || [],
82
- usage: usage,
83
- stop_reason: stop_reason_sym,
84
- model: model,
85
- routing: routing || {},
86
- metadata: metadata || {}
87
- )
69
+ Strict.expect_type!(usage, [::Hash], site, :usage)
70
+ Usage.from_hash(usage)
88
71
  end
89
72
 
90
73
  # Serialize to a Hash for AMQP/fleet/wire transport.
91
74
  def to_h
92
- {
93
- text: text,
94
- thinking: thinking&.to_h,
95
- tool_calls: tool_calls&.map { |tc| tc.is_a?(ToolCall) ? tc.to_h : tc },
96
- usage: usage&.to_h,
97
- stop_reason: stop_reason,
98
- model: model,
99
- routing: routing,
100
- metadata: metadata
101
- }.compact.reject do |k, v|
102
- %i[tool_calls routing
103
- metadata].include?(k) && v.is_a?(Enumerable) && v.empty?
104
- end
75
+ super.compact
105
76
  end
106
77
 
107
78
  # MultiJson/Oj/::JSON callback — prevents Data.define #inspect leak into JSON.
@@ -122,10 +93,24 @@ module Legion
122
93
  def error?
123
94
  stop_reason == :error
124
95
  end
96
+
97
+ # H1: the single strict constructor — .new runs the same member
98
+ # contract as the factories; the factories fill their defaults and
99
+ # delegate here.
100
+ Strict.install_strict_new!(self) do |values, site|
101
+ values[:thinking] = normalize_thinking!(values[:thinking], site)
102
+ values[:tool_calls] = normalize_tool_calls!(values[:tool_calls], site)
103
+ values[:usage] = normalize_usage!(values[:usage], site)
104
+ values[:stop_reason] = normalize_stop_reason!(values[:stop_reason], site)
105
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
106
+ values
107
+ end
125
108
  end
126
109
 
127
110
  Response::STOP_REASONS = %i[end_turn tool_use max_tokens stop_sequence content_filter error].freeze
128
- # rubocop:enable Lint/ConstantDefinitionInBlock
111
+ Response::BUILD_SITE = 'Canonical::Response.build'
112
+ Response::FROM_HASH_SITE = 'Canonical::Response.from_hash'
113
+ Response::NEW_SITE = 'Canonical::Response.new'
129
114
  end
130
115
  end
131
116
  end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ # -- module doc is in canonical.rb entry point
4
+ module Legion
5
+ module Extensions
6
+ module Llm
7
+ # -- required for Data.define block scope
8
+ module Canonical
9
+ # Shared strict-factory guards (04 L1/L3/L5/L6) — one implementation for
10
+ # every type. Nil or wrong-class input raises ArgumentError naming the
11
+ # site, member, and offending class. No factory returns nil; unknown
12
+ # keys fold into the metadata member (no drops, no raises).
13
+ #
14
+ # H1: every type installs a validated `.new` (a single strict
15
+ # constructor). `.new`, `.build`, and `.from_hash` all run the same
16
+ # member contract — a `.new`-minted object cannot carry poison past
17
+ # the class-membership boundaries (enforce_canonical_messages!,
18
+ # fleet W4 rehydration, the conformance kit).
19
+ module Strict
20
+ module_function
21
+
22
+ # H1: install the strict `.new` on a Data type. The C-level
23
+ # constructor is preserved as the PRIVATE `data_define_new` (used
24
+ # only by the strict `.new` itself); the public `.new` maps the
25
+ # call shape (member_values!), runs the type's member contract
26
+ # (validate, a ->(values, site) block returning the normalized
27
+ # values), and delegates. Every construction path — .new, .build,
28
+ # .from_hash — funnels through the same contract.
29
+ def install_strict_new!(type_class, &validate)
30
+ singleton = type_class.singleton_class
31
+ singleton.alias_method(:data_define_new, :new)
32
+ singleton.send(:private, :data_define_new)
33
+
34
+ singleton.define_method(:new) do |*args, **kwargs|
35
+ values = Strict.member_values!(self, self::NEW_SITE, args, kwargs)
36
+ values = validate.call(values, self::NEW_SITE)
37
+ data_define_new(**values)
38
+ end
39
+ end
40
+
41
+ # Map raw `.new` arguments (positional or keyword form) onto a
42
+ # member => value Hash. Wrong call shapes raise a typed
43
+ # ArgumentError naming the site: mixing both forms, a positional
44
+ # count mismatch, or an unknown member. Members absent from the
45
+ # call map to nil and follow each member's own contract.
46
+ def member_values!(type_class, site, args, kwargs)
47
+ members = type_class.members
48
+ raise ArgumentError, "#{site}: pass either positional or keyword members, not both" if args.any? && kwargs.any?
49
+
50
+ if args.any?
51
+ raise ArgumentError, "#{site}: expected #{members.size} positional members, got #{args.size}" unless args.size == members.size
52
+
53
+ return members.zip(args).to_h
54
+ end
55
+
56
+ unknown = kwargs.keys - members.map(&:to_sym)
57
+ raise ArgumentError, "#{site}: unknown member(s) #{unknown.sort.join(', ')}" unless unknown.empty?
58
+
59
+ members.to_h { |member| [member, kwargs[member]] }
60
+ end
61
+
62
+ def require_hash!(source, site)
63
+ return source if source.is_a?(::Hash)
64
+
65
+ raise ArgumentError, "#{site}: expected Hash, got #{source.class}"
66
+ end
67
+
68
+ def symbolize_keys(hash)
69
+ hash.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
70
+ end
71
+
72
+ # 04 L5: unknown keys fold into the metadata member.
73
+ def fold_unknowns!(type_class, site, hash)
74
+ metadata = metadata!(hash.delete(:metadata), site)
75
+ known = type_class.members.map(&:to_sym)
76
+ (hash.keys - known).each { |key| metadata[key] = hash.delete(key) }
77
+ metadata
78
+ end
79
+
80
+ def metadata!(value, site, member: :metadata)
81
+ return {} if value.nil?
82
+
83
+ raise ArgumentError, "#{site}: #{member} expected Hash, got #{value.class}" unless value.is_a?(::Hash)
84
+
85
+ value
86
+ end
87
+
88
+ def enum!(value, allowed, site, member)
89
+ return value if value.nil? || allowed.include?(value)
90
+
91
+ raise ArgumentError,
92
+ "#{site}: Invalid #{member}: #{value.inspect}. Must be one of: #{allowed.join(', ')}"
93
+ end
94
+
95
+ def expect_type!(value, allowed, site, member)
96
+ return value if value.nil? || allowed.any? { |klass| value.is_a?(klass) }
97
+
98
+ raise ArgumentError,
99
+ "#{site}: #{member} expected #{allowed.map(&:name).join(' | ')}, got #{value.class}"
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -3,27 +3,37 @@
3
3
  # -- from_hash normalization is intentional
4
4
  module Legion
5
5
  module Extensions
6
+ # -- module doc is in canonical.rb entry point
6
7
  module Llm
7
- # rubocop:disable Style/Documentation -- module doc is in canonical.rb entry point
8
+ # -- required for Data.define block scope
8
9
  module Canonical
9
10
  # Canonical thinking/reasoning block.
10
11
  # Ports field vocabulary from Legion::LLM::Types and lex-llm Thinking.
11
- Thinking = ::Data.define(:content, :signature) do
12
+ # Empty-string values normalize to nil (absence, not data — 04 §8).
13
+ Thinking = ::Data.define(:content, :signature, :metadata) do
14
+ # Build from keyword args (primary constructor).
15
+ def self.build(content: nil, signature: nil, metadata: {})
16
+ new(
17
+ content: absence!(content, self::BUILD_SITE, :content),
18
+ signature: absence!(signature, self::BUILD_SITE, :signature),
19
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE)
20
+ )
21
+ end
22
+
12
23
  # Build from a Hash (raw provider response or deserialized wire payload).
13
24
  def self.from_hash(source)
14
- return nil if source.nil?
15
-
16
- h = source.transform_keys(&:to_sym)
17
-
18
- # Treat empty strings as nil
19
- content = h[:content]
20
- content = nil if content.is_a?(String) && content.empty?
21
- signature = h[:signature]
22
- signature = nil if signature.is_a?(String) && signature.empty?
25
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
26
+ hash = Strict.symbolize_keys(source)
27
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
28
+ build(content: hash[:content], signature: hash[:signature], metadata:)
29
+ end
23
30
 
24
- return nil if content.nil? && signature.nil?
31
+ # Empty-string is absence, not data (04 §8).
32
+ def self.absence!(value, site, member)
33
+ return nil if value.nil?
25
34
 
26
- new(content: content, signature: signature)
35
+ Strict.expect_type!(value, [::String], site, member)
36
+ value.empty? ? nil : value
27
37
  end
28
38
 
29
39
  # Serialize to a Hash for AMQP/fleet/wire transport.
@@ -44,46 +54,64 @@ module Legion
44
54
  def empty?
45
55
  content.nil? && signature.nil?
46
56
  end
47
- end
48
-
49
- # Normalized config for thinking across providers.
50
- # Mirrors lex-llm Thinking::Config.
51
- class ThinkingConfig
52
- INCLUDES = Thinking
53
57
 
54
- # SSOT for the effort<->budget conversion. A client dialect supplies only
55
- # ONE axis (Anthropic = budget_tokens only; OpenAI = effort only), but a
56
- # provider translator may need the OTHER. This single map lets every
57
- # provider ask for whichever axis it needs and always get a usable value,
58
- # so thinking survives any client x provider pair (best-effort, never
59
- # silently dropped). effort -> budget is exact; budget -> effort uses the
60
- # band boundaries below.
61
- EFFORT_BUDGET = { 'low' => 1024, 'medium' => 8192, 'high' => 16_384 }.freeze
62
-
63
- attr_reader :effort, :budget
64
-
65
- def initialize(effort: nil, budget: nil)
66
- @effort = effort.is_a?(Symbol) ? effort.to_s : effort
67
- @budget = budget
58
+ # H1: the single strict constructor — .new runs the same member
59
+ # contract as the factories; the factories fill their defaults and
60
+ # delegate here.
61
+ Strict.install_strict_new!(self) do |values, site|
62
+ values[:content] = absence!(values[:content], site, :content)
63
+ values[:signature] = absence!(values[:signature], site, :signature)
64
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
65
+ values
68
66
  end
67
+ end
69
68
 
70
- # Build from keyword args.
71
- def self.build(effort: nil, budget: nil)
72
- new(effort: effort, budget: budget)
69
+ Thinking::BUILD_SITE = 'Canonical::Thinking.build'
70
+ Thinking::FROM_HASH_SITE = 'Canonical::Thinking.from_hash'
71
+ Thinking::NEW_SITE = 'Canonical::Thinking.new'
72
+
73
+ # Normalized config for thinking across providers — one name, one shape
74
+ # (04 §8): Canonical::Thinking::Config. # -- required for Data.define block scope
75
+ Thinking::Config = ::Data.define(:effort, :budget, :metadata) do
76
+ def self.build(effort: nil, budget: nil, metadata: {})
77
+ new(effort: effort_string!(effort, self::BUILD_SITE), budget:,
78
+ metadata: Strict.metadata!(metadata, self::BUILD_SITE))
73
79
  end
74
80
 
75
81
  # Build from a Hash.
76
82
  def self.from_hash(source)
77
- return nil if source.nil? || source.empty?
83
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
84
+ hash = Strict.symbolize_keys(source)
85
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
86
+ build(effort: hash[:effort], budget: hash[:budget], metadata:)
87
+ end
88
+
89
+ # M4: effort is a closed enum (the EFFORT_BUDGET keys), not an
90
+ # unbounded string — an unrecognized effort is a contract error at
91
+ # construction, never a silently-derived budget.
92
+ def self.effort_string!(effort, site)
93
+ return nil if effort.nil?
94
+
95
+ value = effort.is_a?(::Symbol) ? effort.to_s : Strict.expect_type!(effort, [::String], site, :effort)
96
+ normalized = value.downcase
97
+ allowed = self::EFFORT_BUDGET.keys
98
+ raise ArgumentError, "#{site}: Invalid effort: #{value.inspect}. Must be one of: #{allowed.join(', ')}" unless allowed.include?(normalized)
78
99
 
79
- h = source.transform_keys(&:to_sym)
80
- build(effort: h[:effort], budget: h[:budget])
100
+ normalized
81
101
  end
82
102
 
83
103
  # Serialize to a Hash for AMQP/fleet/wire transport. Faithful to what was
84
104
  # SET — never fabricates the missing axis (use resolved_* for that).
85
105
  def to_h
86
- { effort: effort, budget: budget }.compact
106
+ super.compact
107
+ end
108
+
109
+ def as_json(*)
110
+ to_h
111
+ end
112
+
113
+ def to_json(*)
114
+ to_h.to_json(*)
87
115
  end
88
116
 
89
117
  # Whether thinking is configured.
@@ -92,34 +120,54 @@ module Legion
92
120
  end
93
121
 
94
122
  # Budget for a provider that needs a token budget (e.g. Anthropic),
95
- # derived from effort when budget was not explicitly set. nil only when
96
- # neither axis is configured.
123
+ # derived from effort when budget was not explicitly set. nil only
124
+ # when neither axis is configured. M4: effort is enum-validated at
125
+ # construction, so the lookup cannot miss — the silent medium
126
+ # fallback is deleted.
97
127
  def resolved_budget
98
128
  return budget unless budget.nil?
99
129
  return nil if effort.nil?
100
130
 
101
- EFFORT_BUDGET[effort.to_s.downcase] || EFFORT_BUDGET['medium']
131
+ self.class::EFFORT_BUDGET[effort]
102
132
  end
103
133
 
104
134
  # Effort for a provider that needs an effort level (e.g. OpenAI),
105
- # derived from budget when effort was not explicitly set. nil only when
106
- # neither axis is configured.
135
+ # derived from budget when effort was not explicitly set. nil only
136
+ # when neither axis is configured.
107
137
  def resolved_effort
108
138
  return effort unless effort.nil?
109
139
  return nil if budget.nil?
110
140
 
111
- b = budget.to_i
112
- if b < EFFORT_BUDGET['medium'] then 'low'
113
- elsif b < EFFORT_BUDGET['high'] then 'medium'
141
+ bands = self.class::EFFORT_BUDGET
142
+ if budget < bands['medium'] then 'low'
143
+ elsif budget < bands['high'] then 'medium'
114
144
  else 'high'
115
145
  end
116
146
  end
147
+
148
+ # H1/M4: the single strict constructor — .new runs the same member
149
+ # contract as the factories (effort enum, Integer budget); the
150
+ # factories fill their defaults and delegate here.
151
+ Strict.install_strict_new!(self) do |values, site|
152
+ values[:effort] = effort_string!(values[:effort], site)
153
+ values[:budget] = Strict.expect_type!(values[:budget], [::Integer], site, :budget)
154
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
155
+ values
156
+ end
117
157
  end
118
158
 
119
- # Alias for convenience: Canonical::Thinking::Config
120
- Thinking.const_set(:Config, ThinkingConfig)
159
+ # SSOT for the effort<->budget conversion. A client dialect supplies only
160
+ # ONE axis (Anthropic = budget_tokens only; OpenAI = effort only), but a
161
+ # provider translator may need the OTHER. This single map lets every
162
+ # provider ask for whichever axis it needs and always get a usable value,
163
+ # so thinking survives any client x provider pair (best-effort, never
164
+ # silently dropped). effort -> budget is exact; budget -> effort uses the
165
+ # band boundaries above.
166
+ Thinking::Config::EFFORT_BUDGET = { 'low' => 1024, 'medium' => 8192, 'high' => 16_384 }.freeze
167
+ Thinking::Config::BUILD_SITE = 'Canonical::Thinking::Config.build'
168
+ Thinking::Config::FROM_HASH_SITE = 'Canonical::Thinking::Config.from_hash'
169
+ Thinking::Config::NEW_SITE = 'Canonical::Thinking::Config.new'
121
170
  end
122
- # rubocop:enable Style/Documentation
123
171
  end
124
172
  end
125
173
  end
@@ -6,35 +6,36 @@ require 'securerandom'
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,27 +110,24 @@ 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