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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +106 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +106 -92
- data/lib/legion/extensions/llm/canonical/content_block.rb +66 -75
- data/lib/legion/extensions/llm/canonical/message.rb +52 -67
- data/lib/legion/extensions/llm/canonical/params.rb +59 -33
- data/lib/legion/extensions/llm/canonical/request.rb +67 -54
- data/lib/legion/extensions/llm/canonical/response.rb +61 -76
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +99 -51
- data/lib/legion/extensions/llm/canonical/tool_call.rb +42 -51
- 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 +6 -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 -153
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -150
- 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 +82 -99
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -46
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +249 -0
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +146 -0
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +172 -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 +34 -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 +29 -17
- 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 +10 -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 +57 -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_spec.rb +68 -148
- 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/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +130 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +507 -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 +178 -67
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +101 -62
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +118 -123
- 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 +129 -41
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +55 -4
- 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 +312 -0
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +141 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +105 -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 +98 -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 -16
- 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
- data/spec/support/ssot_registry_helpers.rb +3 -2
- metadata +15 -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 -311
- 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 -337
- 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
|
@@ -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
|
-
|
|
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,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
|
-
|
|
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
|
-
#
|
|
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,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
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
112
|
-
if
|
|
113
|
-
elsif
|
|
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
|
-
#
|
|
120
|
-
|
|
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(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|