lex-llm 0.4.18 → 0.5.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/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +19 -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 +125 -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 +73 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +61 -0
- data/lib/legion/extensions/llm/canonical.rb +49 -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 +3 -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 +7 -3
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +1 -1
- data/lib/legion/extensions/llm/streaming.rb +1 -3
- 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 +174 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +138 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +269 -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_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_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_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_contract_spec.rb +60 -0
- data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
- data/spec/legion/extensions/llm/provider_spec.rb +592 -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 +103 -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 +96 -15
|
@@ -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,73 @@
|
|
|
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
|
+
|
|
9
|
+
# Canonical tool definition.
|
|
10
|
+
# Ports field vocabulary from Legion::LLM::Types::ToolDefinition.
|
|
11
|
+
ToolDefinition = ::Data.define(:name, :description, :parameters, :source) do
|
|
12
|
+
# Build from keyword args (primary constructor).
|
|
13
|
+
def self.build(name:, description: '', parameters: nil, source: nil)
|
|
14
|
+
new(
|
|
15
|
+
sanitize_tool_name(name),
|
|
16
|
+
description.to_s,
|
|
17
|
+
parameters || {},
|
|
18
|
+
source || { type: :builtin }
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
23
|
+
def self.from_hash(hash, source: nil)
|
|
24
|
+
return nil if hash.nil?
|
|
25
|
+
|
|
26
|
+
normalized = hash.respond_to?(:transform_keys) ? hash.transform_keys(&:to_sym) : {}
|
|
27
|
+
build(
|
|
28
|
+
name: normalized[:name],
|
|
29
|
+
description: normalized[:description],
|
|
30
|
+
parameters: normalized[:parameters] || normalized[:input_schema],
|
|
31
|
+
source: source || normalized[:source]
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Build from a registry entry (extension/registry tool metadata).
|
|
36
|
+
def self.from_registry_entry(entry)
|
|
37
|
+
source = {
|
|
38
|
+
type: entry[:tool_class] ? :registry : :extension,
|
|
39
|
+
tool_class: entry[:tool_class],
|
|
40
|
+
extension: entry[:extension],
|
|
41
|
+
runner: entry[:runner],
|
|
42
|
+
function: entry[:function]
|
|
43
|
+
}.compact
|
|
44
|
+
|
|
45
|
+
build(
|
|
46
|
+
name: entry[:name],
|
|
47
|
+
description: entry[:description],
|
|
48
|
+
parameters: entry[:input_schema] || entry[:parameters],
|
|
49
|
+
source: source.compact
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sanitize a tool name to be safe for all wire formats.
|
|
54
|
+
def self.sanitize_tool_name(raw)
|
|
55
|
+
name = raw.to_s.tr('.', '_')
|
|
56
|
+
name = name.gsub(/[^a-zA-Z0-9_-]/, '')
|
|
57
|
+
name = name[0, TOOL_NAME_MAX_LENGTH] if name.length > TOOL_NAME_MAX_LENGTH
|
|
58
|
+
name.empty? ? 'tool' : name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
62
|
+
def to_h
|
|
63
|
+
{
|
|
64
|
+
name: name,
|
|
65
|
+
description: description,
|
|
66
|
+
parameters: parameters
|
|
67
|
+
}.compact.reject { |k, v| k == :description && v == '' }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -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 usage/metering data for a response.
|
|
10
|
+
# Ports field vocabulary from lex-llm Tokens and legion-llm Types.
|
|
11
|
+
# Includes non-token units extension point per G20b.
|
|
12
|
+
Usage = ::Data.define(
|
|
13
|
+
:input_tokens, :output_tokens, :cache_read_tokens, :cache_write_tokens,
|
|
14
|
+
:thinking_tokens, :units
|
|
15
|
+
) do
|
|
16
|
+
USAGE_KNOWN_KEYS = %i[input_tokens output_tokens cache_read_tokens cache_write_tokens
|
|
17
|
+
thinking_tokens units].freeze
|
|
18
|
+
|
|
19
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
20
|
+
# Accepts both canonical key names and legacy provider spellings.
|
|
21
|
+
def self.from_hash(source)
|
|
22
|
+
return nil if source.nil? || source.empty?
|
|
23
|
+
|
|
24
|
+
h = source.transform_keys(&:to_sym)
|
|
25
|
+
|
|
26
|
+
# Normalize legacy key names
|
|
27
|
+
h[:input_tokens] ||= h.delete(:input) || h.delete(:prompt_tokens)
|
|
28
|
+
h[:output_tokens] ||= h.delete(:output) || h.delete(:completion_tokens)
|
|
29
|
+
h[:cache_read_tokens] ||= h.delete(:cached) || h.delete(:cache_read)
|
|
30
|
+
h[:cache_write_tokens] ||= h.delete(:cache_creation) || h.delete(:cache_write)
|
|
31
|
+
h[:thinking_tokens] ||= h.delete(:thinking) || h.delete(:reasoning)
|
|
32
|
+
|
|
33
|
+
# Extract units (non-token extension point — G20b)
|
|
34
|
+
units = h.delete(:units) || {}
|
|
35
|
+
|
|
36
|
+
new(
|
|
37
|
+
input_tokens: h[:input_tokens],
|
|
38
|
+
output_tokens: h[:output_tokens],
|
|
39
|
+
cache_read_tokens: h[:cache_read_tokens],
|
|
40
|
+
cache_write_tokens: h[:cache_write_tokens],
|
|
41
|
+
thinking_tokens: h[:thinking_tokens],
|
|
42
|
+
units: units
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
47
|
+
def to_h
|
|
48
|
+
super.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Total tokens across all categories.
|
|
52
|
+
def total_tokens
|
|
53
|
+
[input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
|
54
|
+
thinking_tokens].compact.sum
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'canonical/thinking'
|
|
4
|
+
require_relative 'canonical/usage'
|
|
5
|
+
require_relative 'canonical/params'
|
|
6
|
+
require_relative 'canonical/content_block'
|
|
7
|
+
require_relative 'canonical/tool_definition'
|
|
8
|
+
require_relative 'canonical/tool_call'
|
|
9
|
+
require_relative 'canonical/message'
|
|
10
|
+
require_relative 'canonical/request'
|
|
11
|
+
require_relative 'canonical/response'
|
|
12
|
+
require_relative 'canonical/chunk'
|
|
13
|
+
|
|
14
|
+
module Legion
|
|
15
|
+
module Extensions
|
|
16
|
+
module Llm
|
|
17
|
+
# Canonical types for the N×N client→provider routing architecture.
|
|
18
|
+
#
|
|
19
|
+
# These Data.define structs form the single contract between client translators
|
|
20
|
+
# and provider translators. Per Amendment A: immutable, strict factories,
|
|
21
|
+
# enum validation, unknown keys → metadata.
|
|
22
|
+
#
|
|
23
|
+
# Contract version: incremented on any breaking change to the canonical shape.
|
|
24
|
+
# Provider registration refuses gems built against a mismatched version (G7).
|
|
25
|
+
module Canonical
|
|
26
|
+
CONTRACT_VERSION = '1.0.0'
|
|
27
|
+
|
|
28
|
+
# Available canonical types.
|
|
29
|
+
TYPES = %i[
|
|
30
|
+
Thinking Usage Params ContentBlock
|
|
31
|
+
ToolDefinition ToolCall Message
|
|
32
|
+
Request Response Chunk
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
class << self
|
|
36
|
+
# List all canonical type classes.
|
|
37
|
+
def types
|
|
38
|
+
TYPES.map { |name| const_get(name) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if a given constant name is a registered canonical type.
|
|
42
|
+
def type?(name)
|
|
43
|
+
TYPES.include?(name.to_sym)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -11,9 +11,7 @@ module Legion
|
|
|
11
11
|
attr_reader :model, :messages, :tools, :tool_prefs, :params, :headers, :schema
|
|
12
12
|
|
|
13
13
|
def initialize(model: nil, provider: nil, assume_model_exists: false, context: nil)
|
|
14
|
-
if assume_model_exists && !provider
|
|
15
|
-
raise ArgumentError, 'Provider must be specified if assume_model_exists is true'
|
|
16
|
-
end
|
|
14
|
+
raise ArgumentError, 'Provider must be specified if assume_model_exists is true' if assume_model_exists && !provider
|
|
17
15
|
|
|
18
16
|
@context = context
|
|
19
17
|
@config = context&.config || Legion::Extensions::Llm.config
|
|
@@ -139,7 +137,7 @@ module Legion
|
|
|
139
137
|
messages.each(&)
|
|
140
138
|
end
|
|
141
139
|
|
|
142
|
-
def complete(&)
|
|
140
|
+
def complete(&)
|
|
143
141
|
response = @provider.complete(
|
|
144
142
|
messages,
|
|
145
143
|
tools: @tools,
|
|
@@ -234,7 +232,7 @@ module Legion
|
|
|
234
232
|
end
|
|
235
233
|
end
|
|
236
234
|
|
|
237
|
-
def handle_tool_calls(response, &)
|
|
235
|
+
def handle_tool_calls(response, &)
|
|
238
236
|
halt_result = nil
|
|
239
237
|
|
|
240
238
|
response.tool_calls.each_value do |tool_call|
|
|
@@ -77,9 +77,13 @@ module Legion
|
|
|
77
77
|
|
|
78
78
|
def setup_logging(faraday)
|
|
79
79
|
logger = faraday_logger
|
|
80
|
+
# Enable request body logging when the logger is at DEBUG level,
|
|
81
|
+
# or when explicitly enabled via fleet request_payload setting.
|
|
82
|
+
request_payload = Legion::Extensions::Llm.default_settings.dig(:fleet, :request, :logger, :request_payload)
|
|
83
|
+
bodies_enabled = request_payload == true || debug_logger?(logger)
|
|
80
84
|
faraday.response :logger,
|
|
81
85
|
logger,
|
|
82
|
-
bodies:
|
|
86
|
+
bodies: bodies_enabled,
|
|
83
87
|
errors: false,
|
|
84
88
|
headers: false,
|
|
85
89
|
log_level: :debug do |logger|
|