lex-llm 0.7.6 → 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,107 +1,78 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # rubocop:disable Metrics/ParameterLists -- factory methods have many params
3
+ # rubocop:disable-next Metrics/ParameterLists -- factory methods have many params
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,12 +93,25 @@ 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
132
117
  end
133
- # rubocop:enable Metrics/ParameterLists
@@ -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,82 +54,24 @@ 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
-
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
68
- end
69
57
 
70
- # Build from keyword args.
71
- def self.build(effort: nil, budget: nil)
72
- new(effort: effort, budget: budget)
73
- end
74
-
75
- # Build from a Hash.
76
- def self.from_hash(source)
77
- return nil if source.nil? || source.empty?
78
-
79
- h = source.transform_keys(&:to_sym)
80
- build(effort: h[:effort], budget: h[:budget])
81
- end
82
-
83
- # Serialize to a Hash for AMQP/fleet/wire transport. Faithful to what was
84
- # SET — never fabricates the missing axis (use resolved_* for that).
85
- def to_h
86
- { effort: effort, budget: budget }.compact
87
- end
88
-
89
- # Whether thinking is configured.
90
- def enabled?
91
- !effort.nil? || !budget.nil?
92
- end
93
-
94
- # 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.
97
- def resolved_budget
98
- return budget unless budget.nil?
99
- return nil if effort.nil?
100
-
101
- EFFORT_BUDGET[effort.to_s.downcase] || EFFORT_BUDGET['medium']
102
- end
103
-
104
- # 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.
107
- def resolved_effort
108
- return effort unless effort.nil?
109
- return nil if budget.nil?
110
-
111
- b = budget.to_i
112
- if b < EFFORT_BUDGET['medium'] then 'low'
113
- elsif b < EFFORT_BUDGET['high'] then 'medium'
114
- else 'high'
115
- end
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
116
66
  end
117
67
  end
118
68
 
119
- # Alias for convenience: Canonical::Thinking::Config
120
- Thinking.const_set(:Config, ThinkingConfig)
69
+ Thinking::BUILD_SITE = 'Canonical::Thinking.build'
70
+ Thinking::FROM_HASH_SITE = 'Canonical::Thinking.from_hash'
71
+ Thinking::NEW_SITE = 'Canonical::Thinking.new'
121
72
  end
122
- # rubocop:enable Style/Documentation
123
73
  end
124
74
  end
125
75
  end
76
+
77
+ require_relative 'thinking_config'
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ # -- extracted from thinking.rb; depends on Thinking being defined first
4
+ module Legion
5
+ module Extensions
6
+ # -- module doc is in canonical.rb entry point
7
+ module Llm
8
+ # -- required for Data.define block scope
9
+ module Canonical
10
+ # Normalized config for thinking across providers — one name, one shape
11
+ # (04 §8): Canonical::Thinking::Config.
12
+ # Members: enabled, effort, budget, summary, metadata
13
+ Thinking::Config = ::Data.define(:enabled, :effort, :budget, :summary, :metadata) do
14
+ def self.build(enabled: true, effort: nil, budget: nil, summary: nil, metadata: {})
15
+ new(enabled: enabled, effort: effort_string!(effort, self::BUILD_SITE), budget: budget,
16
+ summary: summary, metadata: Strict.metadata!(metadata, self::BUILD_SITE))
17
+ end
18
+
19
+ # Build from a Hash.
20
+ def self.from_hash(source)
21
+ Strict.require_hash!(source, self::FROM_HASH_SITE)
22
+ hash = Strict.symbolize_keys(source)
23
+ metadata = Strict.fold_unknowns!(self, self::FROM_HASH_SITE, hash)
24
+ build(enabled: hash.key?(:enabled) ? hash[:enabled] : true,
25
+ effort: hash[:effort], budget: hash[:budget],
26
+ summary: hash[:summary], metadata: metadata)
27
+ end
28
+
29
+ # M4: effort is a closed enum (the EFFORT_BUDGET keys), not an
30
+ # unbounded string — an unrecognized effort is a contract error at
31
+ # construction, never a silently-derived budget.
32
+ def self.effort_string!(effort, site)
33
+ return nil if effort.nil?
34
+
35
+ value = effort.is_a?(::Symbol) ? effort.to_s : Strict.expect_type!(effort, [::String], site, :effort)
36
+ normalized = value.downcase
37
+ allowed = self::EFFORT_LEVELS
38
+ raise ArgumentError, "#{site}: Invalid effort: #{value.inspect}. Must be one of: #{allowed.join(', ')}" unless allowed.include?(normalized)
39
+
40
+ normalized
41
+ end
42
+
43
+ # Validate summary is a closed enum.
44
+ def self.summary_enum!(value, site)
45
+ return nil if value.nil?
46
+
47
+ sym = value.is_a?(::String) ? value.to_sym : value
48
+ Strict.expect_type!(sym, [::Symbol], site, :summary)
49
+ allowed = self::SUMMARY_LEVELS
50
+ unless allowed.include?(sym)
51
+ raise ArgumentError,
52
+ "#{site}: Invalid summary: #{value.inspect}. Must be one of: #{allowed.map(&:inspect).join(', ')}"
53
+ end
54
+
55
+ sym
56
+ end
57
+
58
+ # Serialize to a Hash for AMQP/fleet/wire transport. Faithful to what was
59
+ # SET — never fabricates the missing axis (use resolved_* for that).
60
+ def to_h
61
+ super.compact
62
+ end
63
+
64
+ def as_json(*)
65
+ to_h
66
+ end
67
+
68
+ def to_json(*)
69
+ to_h.to_json(*)
70
+ end
71
+
72
+ # Whether thinking is enabled (the enabled member).
73
+ def enabled?
74
+ enabled
75
+ end
76
+
77
+ # Budget for a provider that needs a token budget (e.g. Anthropic),
78
+ # derived from effort when budget was not explicitly set. nil when
79
+ # effort is 'none' or neither axis is configured. Only FILLS — never
80
+ # overwrites a supplied budget. Both axes may be carried together.
81
+ def resolved_budget
82
+ return budget unless budget.nil?
83
+ return nil if effort.nil?
84
+ return nil if effort == 'none'
85
+
86
+ self.class::EFFORT_BUDGET[effort]
87
+ end
88
+
89
+ # Effort for a provider that needs an effort level (e.g. OpenAI),
90
+ # derived from budget when effort was not explicitly set. nil only
91
+ # when neither axis is configured. Only FILLS — never overwrites a
92
+ # supplied effort.
93
+ def resolved_effort
94
+ return effort unless effort.nil?
95
+ return nil if budget.nil?
96
+
97
+ bands = self.class::EFFORT_BUDGET
98
+ if budget <= bands['low'] then 'low'
99
+ elsif budget <= bands['medium'] then 'medium'
100
+ elsif budget <= bands['high'] then 'high'
101
+ elsif budget <= bands['xhigh'] then 'xhigh'
102
+ else 'max'
103
+ end
104
+ end
105
+
106
+ # H1/M4: the single strict constructor — .new runs the same member
107
+ # contract as the factories (enabled bool, effort enum, Integer budget,
108
+ # summary enum); the factories fill their defaults and delegate here.
109
+ Strict.install_strict_new!(self) do |values, site|
110
+ # enabled: must be true or false
111
+ raise ArgumentError, "#{site}: enabled must be true or false, got #{values[:enabled].inspect}" unless [true, false].include?(values[:enabled])
112
+
113
+ values[:effort] = effort_string!(values[:effort], site)
114
+
115
+ # budget: must be a positive Integer or nil
116
+ values[:budget] = Strict.expect_type!(values[:budget], [::Integer], site, :budget)
117
+ raise ArgumentError, "#{site}: budget must be positive, got #{values[:budget]}" if values[:budget] && !values[:budget].positive?
118
+
119
+ values[:summary] = summary_enum!(values[:summary], site)
120
+ values[:metadata] = Strict.metadata!(values[:metadata], site)
121
+ values
122
+ end
123
+ end
124
+
125
+ # Closed set of valid effort levels.
126
+ Thinking::Config::EFFORT_LEVELS = %w[none low medium high xhigh max].freeze
127
+
128
+ # SSOT for the effort<->budget conversion. A client dialect supplies only
129
+ # ONE axis (Anthropic = budget_tokens only; OpenAI = effort only), but a
130
+ # provider translator may need the OTHER. This single map lets every
131
+ # provider ask for whichever axis it needs and always get a usable value,
132
+ # so thinking survives any client x provider pair (best-effort, never
133
+ # silently dropped). effort -> budget is exact; budget -> effort uses the
134
+ # band boundaries above. 'none' has no budget — resolves to nil.
135
+ Thinking::Config::EFFORT_BUDGET = {
136
+ 'low' => 1024, 'medium' => 8192, 'high' => 16_384,
137
+ 'xhigh' => 24_576, 'max' => 32_768
138
+ }.freeze
139
+
140
+ # Closed set of valid summary levels.
141
+ Thinking::Config::SUMMARY_LEVELS = %i[auto none concise detailed].freeze
142
+
143
+ Thinking::Config::BUILD_SITE = 'Canonical::Thinking::Config.build'
144
+ Thinking::Config::FROM_HASH_SITE = 'Canonical::Thinking::Config.from_hash'
145
+ Thinking::Config::NEW_SITE = 'Canonical::Thinking::Config.new'
146
+ end
147
+ end
148
+ end
149
+ end