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,184 @@
|
|
|
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
|
+
# Canonical streaming chunk with full lifecycle support.
|
|
9
|
+
# Per R4: block_index/item_id/signature lifecycle, multi-tool-call deltas.
|
|
10
|
+
# Per G20d: strict on produce, ignore-unknown on consume.
|
|
11
|
+
Chunk = ::Data.define(
|
|
12
|
+
:request_id, :conversation_id, :exchange_id,
|
|
13
|
+
:index, :type, :block_index,
|
|
14
|
+
:item_id, :delta, :tool_call, :signature,
|
|
15
|
+
:usage, :stop_reason, :metadata, :timestamp
|
|
16
|
+
) do
|
|
17
|
+
# Build a text delta chunk.
|
|
18
|
+
def self.text_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
|
|
19
|
+
index: 0, block_index: nil, item_id: nil)
|
|
20
|
+
new(
|
|
21
|
+
type: :text_delta, delta: delta, index: index,
|
|
22
|
+
request_id: request_id, conversation_id: conversation_id,
|
|
23
|
+
exchange_id: exchange_id, block_index: block_index,
|
|
24
|
+
item_id: item_id, tool_call: nil, signature: nil,
|
|
25
|
+
usage: nil, stop_reason: nil, metadata: {},
|
|
26
|
+
timestamp: ::Time.now
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Build a thinking delta chunk.
|
|
31
|
+
def self.thinking_delta(delta:, request_id:, conversation_id: nil, exchange_id: nil,
|
|
32
|
+
index: 0, block_index: nil, item_id: nil, signature: nil)
|
|
33
|
+
new(
|
|
34
|
+
type: :thinking_delta, delta: delta, index: index,
|
|
35
|
+
request_id: request_id, conversation_id: conversation_id,
|
|
36
|
+
exchange_id: exchange_id, block_index: block_index,
|
|
37
|
+
item_id: item_id, tool_call: nil, signature: signature,
|
|
38
|
+
usage: nil, stop_reason: nil, metadata: {},
|
|
39
|
+
timestamp: ::Time.now
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Build a tool_call_delta chunk (supports multiple in-flight tool calls via tool_call.id).
|
|
44
|
+
def self.tool_call_delta(tool_call:, request_id:, conversation_id: nil, exchange_id: nil,
|
|
45
|
+
index: 0, block_index: nil, item_id: nil)
|
|
46
|
+
new(
|
|
47
|
+
type: :tool_call_delta, index: index,
|
|
48
|
+
request_id: request_id, conversation_id: conversation_id,
|
|
49
|
+
exchange_id: exchange_id, block_index: block_index,
|
|
50
|
+
item_id: item_id, delta: nil, tool_call: tool_call, signature: nil,
|
|
51
|
+
usage: nil, stop_reason: nil, metadata: {},
|
|
52
|
+
timestamp: ::Time.now
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build a usage chunk.
|
|
57
|
+
def self.usage_chunk(usage:, request_id:, conversation_id: nil, exchange_id: nil)
|
|
58
|
+
new(
|
|
59
|
+
type: :usage, request_id: request_id,
|
|
60
|
+
conversation_id: conversation_id, exchange_id: exchange_id,
|
|
61
|
+
index: nil, block_index: nil, item_id: nil,
|
|
62
|
+
delta: nil, tool_call: nil, signature: nil,
|
|
63
|
+
usage: usage, stop_reason: nil, metadata: {},
|
|
64
|
+
timestamp: ::Time.now
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Build a done chunk.
|
|
69
|
+
def self.done(request_id:, usage: nil, stop_reason: nil, conversation_id: nil, exchange_id: nil)
|
|
70
|
+
new(
|
|
71
|
+
type: :done, request_id: request_id,
|
|
72
|
+
conversation_id: conversation_id, exchange_id: exchange_id,
|
|
73
|
+
index: nil, block_index: nil, item_id: nil,
|
|
74
|
+
delta: nil, tool_call: nil, signature: nil,
|
|
75
|
+
usage: usage, stop_reason: stop_reason, metadata: {},
|
|
76
|
+
timestamp: ::Time.now
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Build an error chunk.
|
|
81
|
+
def self.error_chunk(error:, request_id:, conversation_id: nil, exchange_id: nil, metadata: nil)
|
|
82
|
+
new(
|
|
83
|
+
type: :error, request_id: request_id,
|
|
84
|
+
conversation_id: conversation_id, exchange_id: exchange_id,
|
|
85
|
+
index: nil, block_index: nil, item_id: nil,
|
|
86
|
+
delta: nil, tool_call: nil, signature: nil,
|
|
87
|
+
usage: nil, stop_reason: :error,
|
|
88
|
+
metadata: (metadata || {}).merge(error: error),
|
|
89
|
+
timestamp: ::Time.now
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
94
|
+
# Per G20d: ignore-unknown on consume — unknown chunk types are passed through.
|
|
95
|
+
def self.from_hash(source)
|
|
96
|
+
return nil if source.nil?
|
|
97
|
+
|
|
98
|
+
h = source.transform_keys(&:to_sym)
|
|
99
|
+
|
|
100
|
+
# Normalize type
|
|
101
|
+
type_raw = h.delete(:type)
|
|
102
|
+
type_sym = type_raw&.to_sym if type_raw
|
|
103
|
+
|
|
104
|
+
# Normalize nested objects
|
|
105
|
+
tool_call_raw = h.delete(:tool_call)
|
|
106
|
+
h[:tool_call] = if tool_call_raw.is_a?(ToolCall)
|
|
107
|
+
tool_call_raw
|
|
108
|
+
elsif tool_call_raw.is_a?(Hash)
|
|
109
|
+
ToolCall.from_hash(tool_call_raw)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
usage_raw = h.delete(:usage)
|
|
113
|
+
h[:usage] = if usage_raw.is_a?(Usage)
|
|
114
|
+
usage_raw
|
|
115
|
+
elsif usage_raw.is_a?(Hash)
|
|
116
|
+
Usage.from_hash(usage_raw)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Normalize stop_reason
|
|
120
|
+
stop_reason_raw = h.delete(:stop_reason) || h.delete(:finish_reason)
|
|
121
|
+
h[:stop_reason] = stop_reason_raw&.to_sym if stop_reason_raw
|
|
122
|
+
|
|
123
|
+
# Ensure metadata is a Hash
|
|
124
|
+
h[:metadata] = h[:metadata] || {}
|
|
125
|
+
|
|
126
|
+
# Provide defaults for missing fields
|
|
127
|
+
new(
|
|
128
|
+
request_id: h[:request_id],
|
|
129
|
+
conversation_id: h[:conversation_id],
|
|
130
|
+
exchange_id: h[:exchange_id],
|
|
131
|
+
index: h[:index],
|
|
132
|
+
type: type_sym,
|
|
133
|
+
block_index: h[:block_index],
|
|
134
|
+
item_id: h[:item_id],
|
|
135
|
+
delta: h[:delta],
|
|
136
|
+
tool_call: h[:tool_call],
|
|
137
|
+
signature: h[:signature],
|
|
138
|
+
usage: h[:usage],
|
|
139
|
+
stop_reason: h[:stop_reason],
|
|
140
|
+
metadata: h[:metadata],
|
|
141
|
+
timestamp: h[:timestamp] || ::Time.now
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
146
|
+
def to_h
|
|
147
|
+
{
|
|
148
|
+
request_id: request_id,
|
|
149
|
+
conversation_id: conversation_id,
|
|
150
|
+
exchange_id: exchange_id,
|
|
151
|
+
index: index,
|
|
152
|
+
type: type,
|
|
153
|
+
block_index: block_index,
|
|
154
|
+
item_id: item_id,
|
|
155
|
+
delta: delta,
|
|
156
|
+
tool_call: tool_call&.to_h,
|
|
157
|
+
signature: signature,
|
|
158
|
+
usage: usage&.to_h,
|
|
159
|
+
stop_reason: stop_reason,
|
|
160
|
+
metadata: metadata,
|
|
161
|
+
timestamp: timestamp
|
|
162
|
+
}.compact
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Type predicate helpers.
|
|
166
|
+
def text_delta? = type == :text_delta
|
|
167
|
+
def thinking_delta? = type == :thinking_delta
|
|
168
|
+
def tool_call_delta? = type == :tool_call_delta
|
|
169
|
+
def usage? = type == :usage
|
|
170
|
+
def done? = type == :done
|
|
171
|
+
def error? = type == :error
|
|
172
|
+
|
|
173
|
+
# Whether this chunk carries content (text or thinking).
|
|
174
|
+
def content?
|
|
175
|
+
%i[text_delta thinking_delta].include?(type)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
Chunk::CHUNK_TYPES = %i[text_delta thinking_delta tool_call_delta usage done error].freeze
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
# rubocop:enable Metrics/ParameterLists
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Llm
|
|
6
|
+
module Canonical
|
|
7
|
+
# Typed content block with media_type support per G20a.
|
|
8
|
+
# Ports field vocabulary from Legion::LLM::Types::ContentBlock.
|
|
9
|
+
ContentBlock = ::Data.define(
|
|
10
|
+
:type, :text, :data, :source_type, :media_type,
|
|
11
|
+
:detail, :name, :file_id,
|
|
12
|
+
:id, :input, :tool_use_id, :is_error,
|
|
13
|
+
:source, :start_index, :end_index,
|
|
14
|
+
:code, :message, :cache_control
|
|
15
|
+
) do
|
|
16
|
+
# Build a text content block.
|
|
17
|
+
def self.text(content, cache_control: nil)
|
|
18
|
+
new(
|
|
19
|
+
type: :text, text: content, data: nil, source_type: nil, media_type: nil,
|
|
20
|
+
detail: nil, name: nil, file_id: nil, id: nil, input: nil,
|
|
21
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
22
|
+
end_index: nil, code: nil, message: nil, cache_control: cache_control
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build a thinking content block.
|
|
27
|
+
def self.thinking(content)
|
|
28
|
+
new(
|
|
29
|
+
type: :thinking, text: content, data: nil, source_type: nil, media_type: nil,
|
|
30
|
+
detail: nil, name: nil, file_id: nil, id: nil, input: nil,
|
|
31
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
32
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build a tool_use content block.
|
|
37
|
+
def self.tool_use(id:, name:, input:)
|
|
38
|
+
new(
|
|
39
|
+
type: :tool_use, text: nil, data: nil, source_type: nil, media_type: nil,
|
|
40
|
+
detail: nil, name: name, file_id: nil, id: id, input: input,
|
|
41
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
42
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build a tool_result content block.
|
|
47
|
+
def self.tool_result(tool_use_id:, content:, is_error: false)
|
|
48
|
+
new(
|
|
49
|
+
type: :tool_result, text: content, data: nil, source_type: nil, media_type: nil,
|
|
50
|
+
detail: nil, name: nil, file_id: nil, id: nil, input: nil,
|
|
51
|
+
tool_use_id: tool_use_id, is_error: is_error, source: nil, start_index: nil,
|
|
52
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build an image content block with media_type (G20a).
|
|
57
|
+
def self.image(data:, media_type:, source_type: :base64, detail: nil)
|
|
58
|
+
new(
|
|
59
|
+
type: :image, text: nil, data: data, source_type: source_type, media_type: media_type,
|
|
60
|
+
detail: detail, name: nil, file_id: nil, id: nil, input: nil,
|
|
61
|
+
tool_use_id: nil, is_error: nil, source: nil, start_index: nil,
|
|
62
|
+
end_index: nil, code: nil, message: nil, cache_control: nil
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
67
|
+
def self.from_hash(source)
|
|
68
|
+
return nil if source.nil?
|
|
69
|
+
|
|
70
|
+
h = source.transform_keys(&:to_sym)
|
|
71
|
+
type_raw = h.delete(:type)
|
|
72
|
+
h[:type] = type_raw&.to_sym if type_raw
|
|
73
|
+
|
|
74
|
+
new(
|
|
75
|
+
type: h[:type],
|
|
76
|
+
text: h[:text],
|
|
77
|
+
data: h[:data],
|
|
78
|
+
source_type: h[:source_type],
|
|
79
|
+
media_type: h[:media_type],
|
|
80
|
+
detail: h[:detail],
|
|
81
|
+
name: h[:name],
|
|
82
|
+
file_id: h[:file_id],
|
|
83
|
+
id: h[:id],
|
|
84
|
+
input: h[:input],
|
|
85
|
+
tool_use_id: h[:tool_use_id],
|
|
86
|
+
is_error: h[:is_error],
|
|
87
|
+
source: h[:source],
|
|
88
|
+
start_index: h[:start_index],
|
|
89
|
+
end_index: h[:end_index],
|
|
90
|
+
code: h[:code],
|
|
91
|
+
message: h[:message],
|
|
92
|
+
cache_control: h[:cache_control]
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
97
|
+
def to_h
|
|
98
|
+
super.compact
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Whether this block carries textual content.
|
|
102
|
+
def text?
|
|
103
|
+
type == :text
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Whether this block carries thinking/reasoning content.
|
|
107
|
+
def thinking?
|
|
108
|
+
type == :thinking
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Whether this block represents a tool use request.
|
|
112
|
+
def tool_use?
|
|
113
|
+
type == :tool_use
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Whether this block represents a tool result.
|
|
117
|
+
def tool_result?
|
|
118
|
+
type == :tool_result
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
ContentBlock::CONTENT_BLOCK_TYPES = %i[text thinking tool_use tool_result image audio video].freeze
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
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 message in a conversation.
|
|
12
|
+
# Ports field vocabulary from Legion::LLM::Types::Message and lex-llm Message.
|
|
13
|
+
Message = ::Data.define(
|
|
14
|
+
:id, :parent_id, :role, :content, :tool_calls, :tool_call_id,
|
|
15
|
+
:name, :status, :version, :timestamp, :seq,
|
|
16
|
+
:provider, :model, :input_tokens, :output_tokens,
|
|
17
|
+
:conversation_id, :task_id
|
|
18
|
+
) do
|
|
19
|
+
ROLES = %i[system user assistant tool].freeze
|
|
20
|
+
|
|
21
|
+
# Build from keyword args (primary constructor).
|
|
22
|
+
def self.build(
|
|
23
|
+
id: nil, parent_id: nil, role: :user, content: nil, tool_calls: nil,
|
|
24
|
+
tool_call_id: nil, name: nil, status: :created, version: 1,
|
|
25
|
+
timestamp: nil, seq: nil, provider: nil, model: nil,
|
|
26
|
+
input_tokens: nil, output_tokens: nil, conversation_id: nil, task_id: nil
|
|
27
|
+
)
|
|
28
|
+
role_sym = role.is_a?(String) ? role.to_sym : role
|
|
29
|
+
unless ROLES.include?(role_sym)
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"Invalid role: #{role_sym}. Must be one of: #{ROLES.join(', ')}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
new(
|
|
35
|
+
id: id || "msg_#{SecureRandom.hex(12)}",
|
|
36
|
+
parent_id: parent_id,
|
|
37
|
+
role: role_sym,
|
|
38
|
+
content: content,
|
|
39
|
+
tool_calls: tool_calls,
|
|
40
|
+
tool_call_id: tool_call_id,
|
|
41
|
+
name: name,
|
|
42
|
+
status: status,
|
|
43
|
+
version: version,
|
|
44
|
+
timestamp: timestamp || ::Time.now,
|
|
45
|
+
seq: seq,
|
|
46
|
+
provider: provider,
|
|
47
|
+
model: model,
|
|
48
|
+
input_tokens: input_tokens,
|
|
49
|
+
output_tokens: output_tokens,
|
|
50
|
+
conversation_id: conversation_id,
|
|
51
|
+
task_id: task_id
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Build from a Hash (raw provider response or deserialized wire payload).
|
|
56
|
+
def self.from_hash(hash)
|
|
57
|
+
return nil if hash.nil?
|
|
58
|
+
|
|
59
|
+
h = hash.transform_keys(&:to_sym)
|
|
60
|
+
|
|
61
|
+
# Normalize role to symbol
|
|
62
|
+
role_raw = h[:role]
|
|
63
|
+
h[:role] = role_raw&.to_sym if role_raw
|
|
64
|
+
|
|
65
|
+
# Parse content blocks if they're an array of hashes
|
|
66
|
+
content = h[:content]
|
|
67
|
+
if content.is_a?(Array)
|
|
68
|
+
h[:content] = content.map do |block|
|
|
69
|
+
block.is_a?(ContentBlock) ? block : ContentBlock.from_hash(block)
|
|
70
|
+
end
|
|
71
|
+
elsif content.is_a?(Hash)
|
|
72
|
+
h[:content] = ContentBlock.from_hash(content)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Parse tool calls if they're an array of hashes
|
|
76
|
+
tool_calls = h[:tool_calls]
|
|
77
|
+
if tool_calls.is_a?(Array)
|
|
78
|
+
h[:tool_calls] = tool_calls.map do |tc|
|
|
79
|
+
tc.is_a?(ToolCall) ? tc : ToolCall.from_hash(tc)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
build(**h)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Wrap input: pass through if already a Message, parse if Hash.
|
|
87
|
+
def self.wrap(input)
|
|
88
|
+
return input if input.is_a?(Message)
|
|
89
|
+
return from_hash(input) if input.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extract plain text from content (String or ContentBlock array).
|
|
95
|
+
def text
|
|
96
|
+
case content
|
|
97
|
+
when String then content
|
|
98
|
+
when Array
|
|
99
|
+
content.filter_map do |block|
|
|
100
|
+
block.is_a?(ContentBlock) && block.text? ? block.text : nil
|
|
101
|
+
end.join
|
|
102
|
+
when ContentBlock then content.text if content.text?
|
|
103
|
+
else
|
|
104
|
+
content.to_s
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Serialize to a Hash for AMQP/fleet/wire transport.
|
|
109
|
+
def to_h
|
|
110
|
+
super.compact
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Minimal provider-facing hash (role + text content).
|
|
114
|
+
def to_provider_hash
|
|
115
|
+
{ role: role.to_s, content: text }.compact
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
Message::ROLES = %i[system user assistant tool].freeze
|
|
120
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
# rubocop:enable Metrics/ParameterLists
|
|
@@ -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
|