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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
- data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
- data/lib/legion/extensions/llm/canonical/message.rb +53 -69
- data/lib/legion/extensions/llm/canonical/params.rb +58 -34
- data/lib/legion/extensions/llm/canonical/request.rb +68 -56
- data/lib/legion/extensions/llm/canonical/response.rb +62 -78
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +7 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +36 -100
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +14 -26
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +8 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +97 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- metadata +13 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- 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
|
-
|
|
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
|
-
#
|
|
35
|
+
# Canonical keys only (O03a): edges pass `stop_reason`, not `finish_reason`.
|
|
20
36
|
def self.from_hash(source)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
49
|
-
|
|
54
|
+
Strict.expect_type!(thinking, [::Hash], site, :thinking)
|
|
55
|
+
Thinking.from_hash(thinking)
|
|
56
|
+
end
|
|
50
57
|
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
120
|
-
Thinking.
|
|
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
|