lex-llm 0.4.18 → 0.5.1
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/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +27 -0
- data/lex-llm.gemspec +2 -3
- data/lib/legion/extensions/llm/attachment.rb +1 -1
- data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
- data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
- data/lib/legion/extensions/llm/canonical/message.rb +138 -0
- data/lib/legion/extensions/llm/canonical/params.rb +61 -0
- data/lib/legion/extensions/llm/canonical/request.rb +117 -0
- data/lib/legion/extensions/llm/canonical/response.rb +124 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +98 -0
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
- data/lib/legion/extensions/llm/canonical.rb +50 -0
- data/lib/legion/extensions/llm/chat.rb +3 -5
- data/lib/legion/extensions/llm/connection.rb +5 -1
- data/lib/legion/extensions/llm/error.rb +5 -7
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
- data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
- data/lib/legion/extensions/llm/model/info.rb +4 -6
- data/lib/legion/extensions/llm/models.rb +3 -3
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
- data/lib/legion/extensions/llm/provider.rb +21 -4
- data/lib/legion/extensions/llm/provider_contract.rb +10 -1
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
- data/lib/legion/extensions/llm/streaming.rb +13 -5
- data/lib/legion/extensions/llm/tool.rb +1 -3
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +118 -35
- data/spec/fixtures/ruby.mp3 +0 -0
- data/spec/fixtures/ruby.mp4 +0 -0
- data/spec/fixtures/ruby.png +0 -0
- data/spec/fixtures/ruby.txt +1 -0
- data/spec/fixtures/ruby.wav +0 -0
- data/spec/fixtures/ruby.xml +1 -0
- data/spec/fixtures/sample.pdf +0 -0
- data/spec/legion/extensions/llm/agent_spec.rb +179 -0
- data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
- data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
- data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
- data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
- data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
- data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
- data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
- data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
- data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
- data/spec/legion/extensions/llm/context_spec.rb +127 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
- data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
- data/spec/legion/extensions/llm/error_spec.rb +87 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
- data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
- data/spec/legion/extensions/llm/message_spec.rb +64 -0
- data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
- data/spec/legion/extensions/llm/models_spec.rb +104 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
- data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
- data/spec/legion/extensions/llm/provider_spec.rb +613 -0
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
- data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
- data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
- data/spec/legion/extensions/llm/tool_spec.rb +94 -0
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
- data/spec/legion/extensions/llm/utils_spec.rb +113 -0
- data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
- data/spec/legion/extensions/llm_extension_spec.rb +78 -0
- data/spec/legion/extensions/llm_root_spec.rb +51 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/support/fake_llm_provider.rb +148 -0
- data/spec/support/llm_configuration.rb +21 -0
- data/spec/support/rspec_configuration.rb +19 -0
- data/spec/support/simplecov_configuration.rb +20 -0
- metadata +103 -15
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# -- from_hash normalization is intentional
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Llm
|
|
7
|
+
module Canonical
|
|
8
|
+
# rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
|
|
9
|
+
# Canonical sampling and limit parameters for a request.
|
|
10
|
+
# Per G18: all standard/useful params are first-class, mapped per provider by translators.
|
|
11
|
+
Params = ::Data.define(
|
|
12
|
+
:max_tokens, :max_thinking_tokens, :temperature, :top_p, :top_k,
|
|
13
|
+
:stop_sequences, :seed, :frequency_penalty, :presence_penalty,
|
|
14
|
+
:response_format
|
|
15
|
+
) do
|
|
16
|
+
PARAMS_KNOWN_KEYS = %i[max_tokens max_thinking_tokens temperature top_p top_k
|
|
17
|
+
stop_sequences seed frequency_penalty presence_penalty
|
|
18
|
+
response_format].freeze
|
|
19
|
+
|
|
20
|
+
# Build from a Hash (raw client request or deserialized wire payload).
|
|
21
|
+
# Accepts both canonical key names and common provider spellings.
|
|
22
|
+
def self.from_hash(source)
|
|
23
|
+
return nil if source.nil? || source.empty?
|
|
24
|
+
|
|
25
|
+
h = source.transform_keys(&:to_sym)
|
|
26
|
+
|
|
27
|
+
# Normalize common provider key variations
|
|
28
|
+
h[:max_tokens] ||= h.delete(:max_output_tokens) || h.delete(:num_predict)
|
|
29
|
+
h[:max_thinking_tokens] ||= h.delete(:budget_tokens) || h.delete(:thinking_budget)
|
|
30
|
+
h[:stop_sequences] ||= h.delete(:stop)
|
|
31
|
+
|
|
32
|
+
# Filter to known keys only
|
|
33
|
+
filtered = h.slice(*PARAMS_KNOWN_KEYS)
|
|
34
|
+
|
|
35
|
+
# Return nil if all known values are nil
|
|
36
|
+
return nil if filtered.all? { |_, v| v.nil? }
|
|
37
|
+
|
|
38
|
+
new(
|
|
39
|
+
max_tokens: filtered[:max_tokens],
|
|
40
|
+
max_thinking_tokens: filtered[:max_thinking_tokens],
|
|
41
|
+
temperature: filtered[:temperature],
|
|
42
|
+
top_p: filtered[:top_p],
|
|
43
|
+
top_k: filtered[:top_k],
|
|
44
|
+
stop_sequences: filtered[:stop_sequences],
|
|
45
|
+
seed: filtered[:seed],
|
|
46
|
+
frequency_penalty: filtered[:frequency_penalty],
|
|
47
|
+
presence_penalty: filtered[:presence_penalty],
|
|
48
|
+
response_format: filtered[:response_format]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
53
|
+
def to_h
|
|
54
|
+
super.compact
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
# rubocop:disable Metrics/ParameterLists -- factory methods have many params
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
module Canonical
|
|
10
|
+
# Canonical request shape — the single contract between client translators
|
|
11
|
+
# and the inference executor. Per R3 and G18.
|
|
12
|
+
Request = ::Data.define(
|
|
13
|
+
:id, :messages, :system, :tools, :tool_choice,
|
|
14
|
+
:params, :thinking, :stream,
|
|
15
|
+
:conversation_id, :caller, :routing, :metadata
|
|
16
|
+
) do
|
|
17
|
+
# Build from keyword args (primary constructor).
|
|
18
|
+
def self.build(
|
|
19
|
+
id: nil, messages: nil, system: nil, tools: nil, tool_choice: nil,
|
|
20
|
+
params: nil, thinking: nil, stream: false,
|
|
21
|
+
conversation_id: nil, caller: nil, routing: nil, metadata: nil
|
|
22
|
+
)
|
|
23
|
+
# Normalize messages to Canonical::Message array
|
|
24
|
+
msg_array = Array(messages).filter_map do |msg|
|
|
25
|
+
msg.is_a?(Message) ? msg : Message.from_hash(msg)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Normalize tools to Hash<name, ToolDefinition>
|
|
29
|
+
tool_hash = normalize_tools(tools)
|
|
30
|
+
|
|
31
|
+
# Normalize params
|
|
32
|
+
params_obj = case params
|
|
33
|
+
when Params then params
|
|
34
|
+
when Hash then Params.from_hash(params)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Normalize thinking config
|
|
38
|
+
thinking_obj = case thinking
|
|
39
|
+
when Thinking::Config then thinking
|
|
40
|
+
when Hash then Thinking::Config.new(**thinking.transform_keys(&:to_sym))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
new(
|
|
44
|
+
id: id || "req_#{SecureRandom.hex(12)}",
|
|
45
|
+
messages: msg_array,
|
|
46
|
+
system: system,
|
|
47
|
+
tools: tool_hash,
|
|
48
|
+
tool_choice: tool_choice.is_a?(String) ? tool_choice.to_sym : tool_choice,
|
|
49
|
+
params: params_obj,
|
|
50
|
+
thinking: thinking_obj,
|
|
51
|
+
stream: stream,
|
|
52
|
+
conversation_id: conversation_id,
|
|
53
|
+
caller: caller,
|
|
54
|
+
routing: routing || {},
|
|
55
|
+
metadata: metadata || {}
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build from a Hash (raw client request or deserialized wire payload).
|
|
60
|
+
def self.from_hash(source)
|
|
61
|
+
return nil if source.nil?
|
|
62
|
+
|
|
63
|
+
h = source.transform_keys(&:to_sym)
|
|
64
|
+
|
|
65
|
+
# Extract metadata from unknown keys
|
|
66
|
+
metadata = h[:metadata] || {}
|
|
67
|
+
known_keys = %i[id messages system tools tool_choice params thinking
|
|
68
|
+
stream conversation_id caller routing metadata]
|
|
69
|
+
(h.keys - known_keys).each do |key|
|
|
70
|
+
metadata[key] = h.delete(key)
|
|
71
|
+
end
|
|
72
|
+
h[:metadata] = metadata
|
|
73
|
+
|
|
74
|
+
build(**h)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
78
|
+
def to_h
|
|
79
|
+
{
|
|
80
|
+
id: id,
|
|
81
|
+
messages: messages&.map { |m| m.is_a?(Message) ? m.to_h : m },
|
|
82
|
+
system: system,
|
|
83
|
+
tools: tools&.transform_values { |t| t.is_a?(ToolDefinition) ? t.to_h : t },
|
|
84
|
+
tool_choice: tool_choice,
|
|
85
|
+
params: params&.to_h,
|
|
86
|
+
thinking: thinking&.to_h,
|
|
87
|
+
stream: stream,
|
|
88
|
+
conversation_id: conversation_id,
|
|
89
|
+
caller: caller,
|
|
90
|
+
routing: routing,
|
|
91
|
+
metadata: metadata
|
|
92
|
+
}.compact
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.normalize_tools(tools)
|
|
96
|
+
return {} if tools.nil? || tools.empty?
|
|
97
|
+
|
|
98
|
+
case tools
|
|
99
|
+
when Hash
|
|
100
|
+
tools.transform_values do |tool|
|
|
101
|
+
tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool)
|
|
102
|
+
end
|
|
103
|
+
when Array
|
|
104
|
+
tools.each_with_object({}) do |tool, hash|
|
|
105
|
+
td = tool.is_a?(ToolDefinition) ? tool : ToolDefinition.from_hash(tool)
|
|
106
|
+
hash[td.name] = td
|
|
107
|
+
end
|
|
108
|
+
else
|
|
109
|
+
{}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
# rubocop:enable Metrics/ParameterLists
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Metrics/ParameterLists -- factory methods have many params
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Llm
|
|
7
|
+
module Canonical
|
|
8
|
+
# rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
|
|
9
|
+
# Canonical response shape — the provider-boundary contract.
|
|
10
|
+
# Per R2: does NOT replace Inference::Response (the pipeline envelope).
|
|
11
|
+
# Per Amendment A: immutable Data.define with strict factory.
|
|
12
|
+
Response = ::Data.define(
|
|
13
|
+
:text, :thinking, :tool_calls, :usage,
|
|
14
|
+
:stop_reason, :model, :routing, :metadata
|
|
15
|
+
) do
|
|
16
|
+
STOP_REASONS = %i[end_turn tool_use max_tokens stop_sequence content_filter error].freeze
|
|
17
|
+
|
|
18
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
19
|
+
# Unknown keys go to metadata, never silently dropped.
|
|
20
|
+
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
|
|
36
|
+
|
|
37
|
+
usage_raw = h.delete(:usage)
|
|
38
|
+
usage = usage_raw.is_a?(Usage) ? usage_raw : Usage.from_hash(usage_raw)
|
|
39
|
+
|
|
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
|
|
47
|
+
|
|
48
|
+
model = h.delete(:model)
|
|
49
|
+
routing = h.delete(:routing) || {}
|
|
50
|
+
|
|
51
|
+
# Remaining keys become metadata
|
|
52
|
+
existing_metadata = h.delete(:metadata) || {}
|
|
53
|
+
metadata = existing_metadata.merge(h).compact
|
|
54
|
+
|
|
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
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
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
|
|
77
|
+
|
|
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
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
91
|
+
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
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Whether the response includes tool calls.
|
|
108
|
+
def tool_call?
|
|
109
|
+
!tool_calls.nil? && !tool_calls.empty?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Whether the response ended due to an error.
|
|
113
|
+
def error?
|
|
114
|
+
stop_reason == :error
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
Response::STOP_REASONS = %i[end_turn tool_use max_tokens stop_sequence content_filter error].freeze
|
|
119
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
# rubocop:enable Metrics/ParameterLists
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# -- from_hash normalization is intentional
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Llm
|
|
7
|
+
# rubocop:disable Style/Documentation -- module doc is in canonical.rb entry point
|
|
8
|
+
module Canonical
|
|
9
|
+
# Canonical thinking/reasoning block.
|
|
10
|
+
# Ports field vocabulary from Legion::LLM::Types and lex-llm Thinking.
|
|
11
|
+
Thinking = ::Data.define(:content, :signature) do
|
|
12
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
13
|
+
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?
|
|
23
|
+
|
|
24
|
+
return nil if content.nil? && signature.nil?
|
|
25
|
+
|
|
26
|
+
new(content: content, signature: signature)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
30
|
+
def to_h
|
|
31
|
+
super.compact
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether this thinking block has any content.
|
|
35
|
+
def empty?
|
|
36
|
+
content.nil? && signature.nil?
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Normalized config for thinking across providers.
|
|
41
|
+
# Mirrors lex-llm Thinking::Config.
|
|
42
|
+
class ThinkingConfig
|
|
43
|
+
INCLUDES = Thinking
|
|
44
|
+
attr_reader :effort, :budget
|
|
45
|
+
|
|
46
|
+
def initialize(effort: nil, budget: nil)
|
|
47
|
+
@effort = effort.is_a?(Symbol) ? effort.to_s : effort
|
|
48
|
+
@budget = budget
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Build from keyword args.
|
|
52
|
+
def self.build(effort: nil, budget: nil)
|
|
53
|
+
new(effort: effort, budget: budget)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build from a Hash.
|
|
57
|
+
def self.from_hash(source)
|
|
58
|
+
return nil if source.nil? || source.empty?
|
|
59
|
+
|
|
60
|
+
h = source.transform_keys(&:to_sym)
|
|
61
|
+
build(effort: h[:effort], budget: h[:budget])
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
65
|
+
def to_h
|
|
66
|
+
{ effort: effort, budget: budget }.compact
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Whether thinking is configured.
|
|
70
|
+
def enabled?
|
|
71
|
+
!effort.nil? || !budget.nil?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Alias for convenience: Canonical::Thinking::Config
|
|
76
|
+
Thinking.const_set(:Config, ThinkingConfig)
|
|
77
|
+
end
|
|
78
|
+
# rubocop:enable Style/Documentation
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
# rubocop:disable Metrics/ParameterLists -- factory methods have many params
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module Llm
|
|
9
|
+
module Canonical
|
|
10
|
+
# rubocop:disable Lint/ConstantDefinitionInBlock -- required for Data.define block scope
|
|
11
|
+
# Canonical tool call with source enum and compliance fields.
|
|
12
|
+
# Ports field vocabulary from Legion::LLM::Types::ToolCall.
|
|
13
|
+
# Source enum per R7: :client | :registry | :special | :extension | :mcp
|
|
14
|
+
# Compliance fields per R8: data_handling_classification, policy_decision
|
|
15
|
+
ToolCall = ::Data.define(
|
|
16
|
+
:id, :exchange_id, :name, :arguments, :source,
|
|
17
|
+
:status, :duration_ms, :result, :error,
|
|
18
|
+
:started_at, :finished_at, :category,
|
|
19
|
+
:data_handling_classification, :policy_decision
|
|
20
|
+
) do
|
|
21
|
+
SOURCE_VALUES = %i[client registry special extension mcp].freeze
|
|
22
|
+
STATUS_VALUES = %i[pending running success error].freeze
|
|
23
|
+
|
|
24
|
+
# Build from keyword args (primary constructor).
|
|
25
|
+
def self.build(
|
|
26
|
+
name:, id: nil, exchange_id: nil, arguments: nil, source: nil,
|
|
27
|
+
status: nil, duration_ms: nil, result: nil, error: nil,
|
|
28
|
+
started_at: nil, finished_at: nil, category: nil,
|
|
29
|
+
data_handling_classification: nil, policy_decision: nil
|
|
30
|
+
)
|
|
31
|
+
new(
|
|
32
|
+
id: id || "call_#{SecureRandom.hex(12)}",
|
|
33
|
+
exchange_id: exchange_id,
|
|
34
|
+
name: name,
|
|
35
|
+
arguments: arguments || {},
|
|
36
|
+
source: source,
|
|
37
|
+
status: status,
|
|
38
|
+
duration_ms: duration_ms,
|
|
39
|
+
result: result,
|
|
40
|
+
error: error,
|
|
41
|
+
started_at: started_at,
|
|
42
|
+
finished_at: finished_at,
|
|
43
|
+
category: category,
|
|
44
|
+
data_handling_classification: data_handling_classification,
|
|
45
|
+
policy_decision: policy_decision
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
50
|
+
def self.from_hash(hash)
|
|
51
|
+
return nil if hash.nil?
|
|
52
|
+
|
|
53
|
+
h = hash.transform_keys(&:to_sym)
|
|
54
|
+
|
|
55
|
+
# Normalize source to symbol
|
|
56
|
+
source_raw = h[:source]
|
|
57
|
+
h[:source] = source_raw&.to_sym if source_raw.is_a?(String)
|
|
58
|
+
|
|
59
|
+
# Normalize status to symbol
|
|
60
|
+
status_raw = h[:status]
|
|
61
|
+
h[:status] = status_raw&.to_sym if status_raw.is_a?(String)
|
|
62
|
+
|
|
63
|
+
# Parse arguments if they're a JSON string
|
|
64
|
+
args = h[:arguments]
|
|
65
|
+
if args.is_a?(String) && !args.empty?
|
|
66
|
+
begin
|
|
67
|
+
h[:arguments] = Legion::JSON.load(args)
|
|
68
|
+
rescue Legion::JSON::ParseError => e
|
|
69
|
+
Legion::Logging.debug("[lex-llm][canonical][tool_call] arguments not parseable as JSON, leaving as string: #{e.message}")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
build(**h)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Return a new ToolCall with execution result attached.
|
|
77
|
+
def with_result(result:, status:, duration_ms: nil, finished_at: nil)
|
|
78
|
+
self.class.new(
|
|
79
|
+
id: id,
|
|
80
|
+
exchange_id: exchange_id,
|
|
81
|
+
name: name,
|
|
82
|
+
arguments: arguments,
|
|
83
|
+
source: source,
|
|
84
|
+
status: status,
|
|
85
|
+
duration_ms: duration_ms,
|
|
86
|
+
result: result,
|
|
87
|
+
error: status == :error ? result : error,
|
|
88
|
+
started_at: started_at,
|
|
89
|
+
finished_at: finished_at || ::Time.now,
|
|
90
|
+
category: category,
|
|
91
|
+
data_handling_classification: data_handling_classification,
|
|
92
|
+
policy_decision: policy_decision
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def success?
|
|
97
|
+
status == :success
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def error?
|
|
101
|
+
status == :error
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
105
|
+
def to_h
|
|
106
|
+
super.compact
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Subset for audit/ledger emission.
|
|
110
|
+
def to_audit_hash
|
|
111
|
+
{
|
|
112
|
+
id: id,
|
|
113
|
+
name: name,
|
|
114
|
+
arguments: arguments,
|
|
115
|
+
status: status,
|
|
116
|
+
duration_ms: duration_ms,
|
|
117
|
+
error: error,
|
|
118
|
+
exchange_id: exchange_id,
|
|
119
|
+
source: source,
|
|
120
|
+
category: category,
|
|
121
|
+
data_handling_classification: data_handling_classification,
|
|
122
|
+
policy_decision: policy_decision
|
|
123
|
+
}.compact
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
ToolCall::SOURCE_VALUES = %i[client registry special extension mcp].freeze
|
|
128
|
+
ToolCall::STATUS_VALUES = %i[pending running success error].freeze
|
|
129
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
# rubocop:enable Metrics/ParameterLists
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Canonical
|
|
7
|
+
TOOL_NAME_MAX_LENGTH = 64
|
|
8
|
+
OBJECT_SCHEMA_KEYWORDS = %i[properties required additionalProperties].freeze
|
|
9
|
+
COMPOSITE_SCHEMA_KEYWORDS = %i[oneOf anyOf allOf enum $ref $defs definitions].freeze
|
|
10
|
+
|
|
11
|
+
# Canonical tool definition.
|
|
12
|
+
# Ports field vocabulary from Legion::LLM::Types::ToolDefinition.
|
|
13
|
+
ToolDefinition = ::Data.define(:name, :description, :parameters, :source) do
|
|
14
|
+
def self.normalize_parameters(parameters)
|
|
15
|
+
empty = { type: 'object', properties: {} }
|
|
16
|
+
return empty if parameters.nil?
|
|
17
|
+
|
|
18
|
+
schema = if parameters.respond_to?(:transform_keys)
|
|
19
|
+
parameters.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
|
|
20
|
+
end
|
|
21
|
+
return empty if schema.nil? || schema.empty?
|
|
22
|
+
return schema if schema.key?(:type)
|
|
23
|
+
return schema.merge(type: 'object') if OBJECT_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
|
|
24
|
+
return schema if COMPOSITE_SCHEMA_KEYWORDS.any? { |k| schema.key?(k) }
|
|
25
|
+
|
|
26
|
+
{ type: 'object', properties: schema }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Build from keyword args (primary constructor).
|
|
30
|
+
def self.build(name:, description: '', parameters: nil, source: nil)
|
|
31
|
+
new(
|
|
32
|
+
sanitize_tool_name(name),
|
|
33
|
+
description.to_s,
|
|
34
|
+
normalize_parameters(parameters),
|
|
35
|
+
source || { type: :builtin }
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
40
|
+
def self.from_hash(hash, source: nil)
|
|
41
|
+
return nil if hash.nil?
|
|
42
|
+
|
|
43
|
+
normalized = hash.respond_to?(:transform_keys) ? hash.transform_keys(&:to_sym) : {}
|
|
44
|
+
build(
|
|
45
|
+
name: normalized[:name],
|
|
46
|
+
description: normalized[:description],
|
|
47
|
+
parameters: normalized[:parameters] || normalized[:input_schema],
|
|
48
|
+
source: source || normalized[:source]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Build from a registry entry (extension/registry tool metadata).
|
|
53
|
+
def self.from_registry_entry(entry)
|
|
54
|
+
source = {
|
|
55
|
+
type: entry[:tool_class] ? :registry : :extension,
|
|
56
|
+
tool_class: entry[:tool_class],
|
|
57
|
+
extension: entry[:extension],
|
|
58
|
+
runner: entry[:runner],
|
|
59
|
+
function: entry[:function]
|
|
60
|
+
}.compact
|
|
61
|
+
|
|
62
|
+
build(
|
|
63
|
+
name: entry[:name],
|
|
64
|
+
description: entry[:description],
|
|
65
|
+
parameters: entry[:input_schema] || entry[:parameters],
|
|
66
|
+
source: source.compact
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Sanitize a tool name to be safe for all wire formats.
|
|
71
|
+
def self.sanitize_tool_name(raw)
|
|
72
|
+
name = raw.to_s.tr('.', '_')
|
|
73
|
+
name = name.gsub(/[^a-zA-Z0-9_-]/, '')
|
|
74
|
+
name = name[0, TOOL_NAME_MAX_LENGTH] if name.length > TOOL_NAME_MAX_LENGTH
|
|
75
|
+
name.empty? ? 'tool' : name
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def params_schema
|
|
79
|
+
parameters
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def input_schema
|
|
83
|
+
parameters
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
87
|
+
def to_h
|
|
88
|
+
{
|
|
89
|
+
name: name,
|
|
90
|
+
description: description,
|
|
91
|
+
parameters: parameters
|
|
92
|
+
}.compact.reject { |k, v| k == :description && v == '' }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Canonical
|
|
7
|
+
# Extracts and normalizes tool schemas from heterogeneous sources.
|
|
8
|
+
module ToolSchema
|
|
9
|
+
EMPTY_OBJECT = { type: 'object', properties: {} }.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def extract(tool)
|
|
14
|
+
raw = raw_schema(tool)
|
|
15
|
+
ToolDefinition.normalize_parameters(raw)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def raw_schema(tool)
|
|
19
|
+
return nil if tool.nil?
|
|
20
|
+
return tool.params_schema if tool.respond_to?(:params_schema) && tool.params_schema
|
|
21
|
+
return tool.parameters if tool.respond_to?(:parameters) && tool.parameters
|
|
22
|
+
|
|
23
|
+
return unless tool.respond_to?(:[])
|
|
24
|
+
|
|
25
|
+
tool[:parameters] || tool['parameters'] || tool[:input_schema] || tool['input_schema'] ||
|
|
26
|
+
tool[:params_schema] || tool['params_schema']
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tool_name(tool)
|
|
30
|
+
return tool.name if tool.respond_to?(:name) && !tool.is_a?(Hash)
|
|
31
|
+
return tool[:name] || tool['name'] if tool.respond_to?(:[])
|
|
32
|
+
|
|
33
|
+
'unknown'
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tool_description(tool)
|
|
37
|
+
return tool.description if tool.respond_to?(:description) && !tool.is_a?(Hash)
|
|
38
|
+
return (tool[:description] || tool['description'] || '').to_s if tool.respond_to?(:[])
|
|
39
|
+
|
|
40
|
+
''
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|