lex-llm 0.4.16 → 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 +33 -0
- data/README.md +349 -153
- data/lex-llm.gemspec +3 -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 +14 -2
- 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 +12 -4
- 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 +6 -4
- 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 +110 -15
|
@@ -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|
|
|
@@ -42,6 +42,7 @@ module Legion
|
|
|
42
42
|
|
|
43
43
|
ensure_configured!
|
|
44
44
|
@connection ||= Faraday.new(provider.api_base) do |faraday|
|
|
45
|
+
faraday.ssl.verify = false
|
|
45
46
|
setup_timeout(faraday)
|
|
46
47
|
setup_logging(faraday)
|
|
47
48
|
setup_retry(faraday)
|
|
@@ -76,9 +77,13 @@ module Legion
|
|
|
76
77
|
|
|
77
78
|
def setup_logging(faraday)
|
|
78
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)
|
|
79
84
|
faraday.response :logger,
|
|
80
85
|
logger,
|
|
81
|
-
bodies:
|
|
86
|
+
bodies: bodies_enabled,
|
|
82
87
|
errors: false,
|
|
83
88
|
headers: false,
|
|
84
89
|
log_level: :debug do |logger|
|
|
@@ -122,7 +127,13 @@ module Legion
|
|
|
122
127
|
faraday.request :multipart
|
|
123
128
|
faraday.request :json
|
|
124
129
|
faraday.response :json
|
|
125
|
-
|
|
130
|
+
# Prefer typhoeus (libcurl) over net_http to avoid Ruby 4.0 + net-http-0.9.1 SSL issues
|
|
131
|
+
begin
|
|
132
|
+
require 'faraday/typhoeus'
|
|
133
|
+
faraday.adapter :typhoeus
|
|
134
|
+
rescue LoadError
|
|
135
|
+
faraday.adapter :net_http
|
|
136
|
+
end
|
|
126
137
|
faraday.use :llm_errors, provider: @provider
|
|
127
138
|
end
|
|
128
139
|
|
|
@@ -138,6 +149,7 @@ module Legion
|
|
|
138
149
|
Timeout::Error,
|
|
139
150
|
Faraday::TimeoutError,
|
|
140
151
|
Faraday::ConnectionFailed,
|
|
152
|
+
Faraday::SSLError,
|
|
141
153
|
Faraday::RetriableResponse,
|
|
142
154
|
Legion::Extensions::Llm::RateLimitError,
|
|
143
155
|
Legion::Extensions::Llm::ServerError,
|
|
@@ -80,7 +80,7 @@ module Legion
|
|
|
80
80
|
/reduce the length of messages/i
|
|
81
81
|
].freeze
|
|
82
82
|
|
|
83
|
-
def parse_error(provider:, response:)
|
|
83
|
+
def parse_error(provider:, response:)
|
|
84
84
|
response = response_with_stream_error_body(response)
|
|
85
85
|
message = provider&.parse_error(response)
|
|
86
86
|
|
|
@@ -88,9 +88,7 @@ module Legion
|
|
|
88
88
|
when 200..399
|
|
89
89
|
message
|
|
90
90
|
when 400
|
|
91
|
-
if context_length_exceeded?(message)
|
|
92
|
-
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
93
|
-
end
|
|
91
|
+
raise ContextLengthExceededError.new(response, message || 'Context length exceeded') if context_length_exceeded?(message)
|
|
94
92
|
|
|
95
93
|
raise BadRequestError.new(response, message || 'Invalid request - please check your input')
|
|
96
94
|
when 401
|
|
@@ -101,9 +99,7 @@ module Legion
|
|
|
101
99
|
raise ForbiddenError.new(response,
|
|
102
100
|
message || 'Forbidden - you do not have permission to access this resource')
|
|
103
101
|
when 429
|
|
104
|
-
if context_length_exceeded?(message)
|
|
105
|
-
raise ContextLengthExceededError.new(response, message || 'Context length exceeded')
|
|
106
|
-
end
|
|
102
|
+
raise ContextLengthExceededError.new(response, message || 'Context length exceeded') if context_length_exceeded?(message)
|
|
107
103
|
|
|
108
104
|
raise RateLimitError.new(response, message || 'Rate limit exceeded - please wait a moment')
|
|
109
105
|
when 500
|
|
@@ -14,9 +14,7 @@ module Legion
|
|
|
14
14
|
|
|
15
15
|
def reject_legacy_options!
|
|
16
16
|
LEGACY_OPTIONS.each do |key|
|
|
17
|
-
if @options.key?(key) || @options.key?(key.to_s)
|
|
18
|
-
raise ArgumentError, "#{key} is not supported by fleet protocol v2"
|
|
19
|
-
end
|
|
17
|
+
raise ArgumentError, "#{key} is not supported by fleet protocol v2" if @options.key?(key) || @options.key?(key.to_s)
|
|
20
18
|
end
|
|
21
19
|
end
|
|
22
20
|
|
|
@@ -119,9 +119,7 @@ module Legion
|
|
|
119
119
|
raise ConfigurationError,
|
|
120
120
|
"fleet provider instance is not configured: #{instance_id}"
|
|
121
121
|
end
|
|
122
|
-
unless truthy?(dig(instance_settings, :fleet, :respond_to_requests))
|
|
123
|
-
raise ConfigurationError, "fleet responses are disabled for provider instance: #{instance_id}"
|
|
124
|
-
end
|
|
122
|
+
raise ConfigurationError, "fleet responses are disabled for provider instance: #{instance_id}" unless truthy?(dig(instance_settings, :fleet, :respond_to_requests))
|
|
125
123
|
|
|
126
124
|
provider_class.new(deep_symbolize(instance_settings))
|
|
127
125
|
end
|
|
@@ -186,9 +186,7 @@ module Legion
|
|
|
186
186
|
end
|
|
187
187
|
|
|
188
188
|
def signing_key
|
|
189
|
-
if defined?(::Legion::Crypt) && ::Legion::Crypt.respond_to?(:cluster_secret)
|
|
190
|
-
return ::Legion::Crypt.cluster_secret
|
|
191
|
-
end
|
|
189
|
+
return ::Legion::Crypt.cluster_secret if defined?(::Legion::Crypt) && ::Legion::Crypt.respond_to?(:cluster_secret)
|
|
192
190
|
|
|
193
191
|
raise TokenError, 'no signing key available - Legion::Crypt not initialized'
|
|
194
192
|
rescue TokenError
|