llm_gateway 0.4.0 → 0.6.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/.pi/skills/live-provider-testing/SKILL.md +183 -0
- data/.pi/skills/options-development/SKILL.md +131 -0
- data/CHANGELOG.md +43 -0
- data/README.md +110 -41
- data/Rakefile +1 -0
- data/docs/migration_guide_0.6.0.md +386 -0
- data/lib/llm_gateway/adapters/adapter.rb +8 -44
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +0 -2
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +106 -27
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +0 -33
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +59 -47
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +48 -6
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +3 -2
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +44 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +89 -4
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +336 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +0 -2
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +0 -6
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +135 -72
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +100 -10
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +193 -170
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +0 -1
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +128 -68
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +99 -10
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +106 -275
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +0 -1
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +3 -3
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +0 -5
- data/lib/llm_gateway/adapters/stream_mapper.rb +57 -0
- data/lib/llm_gateway/adapters/structs.rb +102 -52
- data/lib/llm_gateway/base_client.rb +2 -4
- data/lib/llm_gateway/client.rb +10 -66
- data/lib/llm_gateway/clients/anthropic.rb +5 -4
- data/lib/llm_gateway/clients/groq.rb +18 -4
- data/lib/llm_gateway/clients/openai.rb +20 -18
- data/lib/llm_gateway/prompt.rb +35 -17
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +5 -29
- metadata +8 -10
- data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +0 -111
- data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +0 -110
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +0 -40
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +0 -120
- data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +0 -47
- data/lib/llm_gateway/adapters/stream_accumulator.rb +0 -91
- data/scripts/generate_handoff_live_fixture.rb +0 -169
- data/scripts/generate_handoff_media_fixture.rb +0 -167
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "../utils"
|
|
6
|
+
require_relative "structs"
|
|
7
|
+
|
|
8
|
+
module LlmGateway
|
|
9
|
+
module Adapters
|
|
10
|
+
class NormalizedStreamAccumulator
|
|
11
|
+
# Contract:
|
|
12
|
+
#
|
|
13
|
+
# `push` accepts a single provider-independent, normalized stream event
|
|
14
|
+
# patch hash. Event patches are never arrays; mappers call `push` once per
|
|
15
|
+
# patch.
|
|
16
|
+
#
|
|
17
|
+
# Provider wire events such as Anthropic `message_start` /
|
|
18
|
+
# `content_block_start`, OpenAI `response.output_text.delta`, etc. must be
|
|
19
|
+
# translated by the mapper before calling this accumulator. The normalized
|
|
20
|
+
# symbol `:message_start` below is allowed; the raw provider event string is
|
|
21
|
+
# not.
|
|
22
|
+
#
|
|
23
|
+
# Accepted event shapes:
|
|
24
|
+
#
|
|
25
|
+
# { type: :message_start, delta: { id: "...", model: "...", role: "assistant", timestamp: 1716650000000 } }
|
|
26
|
+
# { type: :message_delta, delta: { stop_reason: "stop" }, usage: { output: 2 } }
|
|
27
|
+
# { type: :message_end }
|
|
28
|
+
#
|
|
29
|
+
# { type: :text_start, delta: "hi" }
|
|
30
|
+
# { type: :text_delta, delta: " there" }
|
|
31
|
+
# { type: :text_end, delta: "" }
|
|
32
|
+
#
|
|
33
|
+
# { type: :reasoning_start, delta: "thinking", signature: "" }
|
|
34
|
+
# { type: :reasoning_delta, delta: "...", signature: "" }
|
|
35
|
+
# { type: :reasoning_end, delta: "", signature: "" }
|
|
36
|
+
#
|
|
37
|
+
# { type: :tool_start, id: "...", name: "tool_name", delta: "" }
|
|
38
|
+
# { type: :tool_delta, delta: "{\"a\":" }
|
|
39
|
+
# { type: :tool_end, delta: "" }
|
|
40
|
+
#
|
|
41
|
+
# Mappers do not provide `content_index`. The accumulator assigns the next
|
|
42
|
+
# public content index when a block starts and reuses the active content
|
|
43
|
+
# index for that block's deltas and end event.
|
|
44
|
+
#
|
|
45
|
+
# Without source indexes, the accumulator cannot detect two interleaved
|
|
46
|
+
# blocks of the same type. Providers that can interleave same-type blocks
|
|
47
|
+
# must buffer or serialize them in the mapper before pushing normalized
|
|
48
|
+
# events.
|
|
49
|
+
#
|
|
50
|
+
# The accumulator creates the public Assistant* event structs, updates its
|
|
51
|
+
# accumulated message state, then yields the created event to the callback.
|
|
52
|
+
attr_accessor :blocks, :message_hash, :usage_hash
|
|
53
|
+
attr_reader :active_block_type, :final_message
|
|
54
|
+
|
|
55
|
+
DEFAULT_USAGE = {
|
|
56
|
+
input: 0,
|
|
57
|
+
cache_write: 0,
|
|
58
|
+
cache_read: 0,
|
|
59
|
+
output: 0,
|
|
60
|
+
total: 0,
|
|
61
|
+
raw: {}
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
BLOCK_EVENT_TRANSITIONS = {
|
|
65
|
+
text_start: { block_type: :text, phase: :start },
|
|
66
|
+
text_delta: { block_type: :text, phase: :delta },
|
|
67
|
+
text_end: { block_type: :text, phase: :end },
|
|
68
|
+
tool_start: { block_type: :tool, phase: :start },
|
|
69
|
+
tool_delta: { block_type: :tool, phase: :delta },
|
|
70
|
+
tool_end: { block_type: :tool, phase: :end },
|
|
71
|
+
reasoning_start: { block_type: :reasoning, phase: :start },
|
|
72
|
+
reasoning_delta: { block_type: :reasoning, phase: :delta },
|
|
73
|
+
reasoning_end: { block_type: :reasoning, phase: :end }
|
|
74
|
+
}.freeze
|
|
75
|
+
|
|
76
|
+
def initialize(provider: nil, api: nil)
|
|
77
|
+
@provider = provider
|
|
78
|
+
@api = api
|
|
79
|
+
@message_hash = {}
|
|
80
|
+
@usage_hash = default_usage
|
|
81
|
+
@blocks = []
|
|
82
|
+
@next_content_index = 0
|
|
83
|
+
@active_block_type = nil
|
|
84
|
+
@active_content_index = nil
|
|
85
|
+
@timestamp = nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def result
|
|
89
|
+
ensure_timestamp!
|
|
90
|
+
|
|
91
|
+
message_hash.merge(
|
|
92
|
+
timestamp: @timestamp,
|
|
93
|
+
usage: usage_hash,
|
|
94
|
+
content: serialized_blocks
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def final_result
|
|
99
|
+
result.merge(provider: @provider, api: @api)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def active_tool?
|
|
103
|
+
active_block_type == :tool
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def push(event_patch, &block)
|
|
107
|
+
raise ArgumentError, "Normalized stream event patch must be a Hash" unless event_patch.is_a?(Hash)
|
|
108
|
+
|
|
109
|
+
event_patch = symbolize_keys(event_patch)
|
|
110
|
+
type = event_patch.fetch(:type).to_sym
|
|
111
|
+
event_patch = prepare_event_patch(event_patch.merge(type:), type)
|
|
112
|
+
ensure_timestamp!
|
|
113
|
+
|
|
114
|
+
if type == :message_end
|
|
115
|
+
@final_message = AssistantMessage.new(final_result)
|
|
116
|
+
block.call(AssistantStreamMessageEndEvent.new(type:, message: final_message)) if block
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
event = build_event(event_patch, partial: empty_partial)
|
|
121
|
+
accumulate(event)
|
|
122
|
+
content_index = event.content_index if event.respond_to?(:content_index)
|
|
123
|
+
commit_block_transition(type, content_index)
|
|
124
|
+
event = build_event(event_patch, partial: partial_message)
|
|
125
|
+
block.call(event) if block
|
|
126
|
+
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def prepare_event_patch(event_patch, type)
|
|
133
|
+
transition = BLOCK_EVENT_TRANSITIONS[type]
|
|
134
|
+
return event_patch unless transition
|
|
135
|
+
|
|
136
|
+
block_type = transition[:block_type]
|
|
137
|
+
|
|
138
|
+
case transition[:phase]
|
|
139
|
+
when :start
|
|
140
|
+
validate_start!(block_type)
|
|
141
|
+
event_patch.merge(content_index: @next_content_index)
|
|
142
|
+
when :delta
|
|
143
|
+
validate_delta!(type, block_type)
|
|
144
|
+
event_patch.merge(content_index: @active_content_index)
|
|
145
|
+
when :end
|
|
146
|
+
validate_end!(block_type)
|
|
147
|
+
event_patch.merge(content_index: @active_content_index)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def validate_start!(block_type)
|
|
152
|
+
return unless @active_block_type
|
|
153
|
+
|
|
154
|
+
raise ArgumentError, "Cannot start #{block_type} block while #{@active_block_type} block is active"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate_delta!(type, block_type)
|
|
158
|
+
unless @active_block_type
|
|
159
|
+
raise ArgumentError, "Cannot apply #{type} without an active #{block_type} block"
|
|
160
|
+
end
|
|
161
|
+
return if @active_block_type == block_type
|
|
162
|
+
|
|
163
|
+
raise ArgumentError, "Cannot apply #{type} while #{@active_block_type} block is active"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def validate_end!(block_type)
|
|
167
|
+
unless @active_block_type
|
|
168
|
+
raise ArgumentError, "Cannot end #{block_type} block without an active #{block_type} block"
|
|
169
|
+
end
|
|
170
|
+
return if @active_block_type == block_type
|
|
171
|
+
|
|
172
|
+
raise ArgumentError, "Cannot end #{block_type} block while #{@active_block_type} block is active"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def commit_block_transition(type, content_index)
|
|
176
|
+
transition = BLOCK_EVENT_TRANSITIONS[type]
|
|
177
|
+
return unless transition
|
|
178
|
+
|
|
179
|
+
case transition[:phase]
|
|
180
|
+
when :start
|
|
181
|
+
@active_block_type = transition[:block_type]
|
|
182
|
+
@active_content_index = content_index
|
|
183
|
+
@next_content_index += 1
|
|
184
|
+
when :end
|
|
185
|
+
@active_block_type = nil
|
|
186
|
+
@active_content_index = nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_event(event_patch, partial:)
|
|
191
|
+
event_patch = symbolize_keys(event_patch)
|
|
192
|
+
type = event_patch.fetch(:type).to_sym
|
|
193
|
+
|
|
194
|
+
case type
|
|
195
|
+
when :message_start, :message_delta
|
|
196
|
+
delta = symbolize_keys(event_patch[:delta] || {})
|
|
197
|
+
raw_usage = event_patch[:usage] || delta.delete(:usage) || {}
|
|
198
|
+
usage = raw_usage.empty? ? {} : normalized_usage(raw_usage)
|
|
199
|
+
|
|
200
|
+
AssistantStreamMessageEvent.new(
|
|
201
|
+
type:,
|
|
202
|
+
delta:,
|
|
203
|
+
usage:,
|
|
204
|
+
partial:
|
|
205
|
+
)
|
|
206
|
+
when :tool_start
|
|
207
|
+
AssistantToolStartEvent.new(
|
|
208
|
+
type:,
|
|
209
|
+
content_index: event_patch.fetch(:content_index),
|
|
210
|
+
delta: string_value(event_patch[:delta]),
|
|
211
|
+
id: event_patch[:id],
|
|
212
|
+
name: event_patch[:name],
|
|
213
|
+
partial:
|
|
214
|
+
)
|
|
215
|
+
when :reasoning_start, :reasoning_delta, :reasoning_end
|
|
216
|
+
AssistantStreamReasoningEvent.new(
|
|
217
|
+
type:,
|
|
218
|
+
content_index: event_patch.fetch(:content_index),
|
|
219
|
+
delta: string_value(event_patch[:delta]),
|
|
220
|
+
signature: string_value(event_patch[:signature]),
|
|
221
|
+
partial:
|
|
222
|
+
)
|
|
223
|
+
when :text_start, :text_delta, :text_end, :tool_delta, :tool_end
|
|
224
|
+
AssistantStreamEvent.new(
|
|
225
|
+
type:,
|
|
226
|
+
content_index: event_patch.fetch(:content_index),
|
|
227
|
+
delta: string_value(event_patch[:delta]),
|
|
228
|
+
partial:
|
|
229
|
+
)
|
|
230
|
+
else
|
|
231
|
+
raise ArgumentError, "Unsupported normalized stream event type: #{type.inspect}"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def accumulate(event)
|
|
236
|
+
@timestamp = event.delta[:timestamp] if event.respond_to?(:delta) && event.delta.is_a?(Hash) && event.delta[:timestamp]
|
|
237
|
+
|
|
238
|
+
case event.type
|
|
239
|
+
when :text_start
|
|
240
|
+
blocks[event.content_index] = {
|
|
241
|
+
type: "text",
|
|
242
|
+
text: ""
|
|
243
|
+
}
|
|
244
|
+
blocks[event.content_index][:text] += event.delta
|
|
245
|
+
when :text_delta, :text_end
|
|
246
|
+
blocks[event.content_index][:text] += event.delta
|
|
247
|
+
when :tool_start
|
|
248
|
+
blocks[event.content_index] = {
|
|
249
|
+
type: "tool_use",
|
|
250
|
+
id: event.id,
|
|
251
|
+
name: event.name,
|
|
252
|
+
input: event.delta.to_s
|
|
253
|
+
}
|
|
254
|
+
when :tool_delta, :tool_end
|
|
255
|
+
blocks[event.content_index][:input] += event.delta
|
|
256
|
+
when :message_start
|
|
257
|
+
message_hash.merge!(event.delta)
|
|
258
|
+
when :reasoning_start
|
|
259
|
+
blocks[event.content_index] = {
|
|
260
|
+
type: "reasoning",
|
|
261
|
+
reasoning: "",
|
|
262
|
+
signature: ""
|
|
263
|
+
}
|
|
264
|
+
blocks[event.content_index][:reasoning] += event.delta
|
|
265
|
+
blocks[event.content_index][:signature] += event.signature
|
|
266
|
+
when :reasoning_delta, :reasoning_end
|
|
267
|
+
blocks[event.content_index][:reasoning] += event.delta
|
|
268
|
+
blocks[event.content_index][:signature] += event.signature
|
|
269
|
+
when :message_delta
|
|
270
|
+
message_hash.merge!(event.delta)
|
|
271
|
+
assign_usage(event.usage) unless event.usage.empty?
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def empty_partial
|
|
276
|
+
PartialAssistantMessage.new(timestamp: @timestamp)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def partial_message
|
|
280
|
+
PartialAssistantMessage.new(partial_result)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def partial_result
|
|
284
|
+
ensure_timestamp!
|
|
285
|
+
|
|
286
|
+
message_hash.merge(
|
|
287
|
+
timestamp: @timestamp,
|
|
288
|
+
content: serialized_blocks
|
|
289
|
+
)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def assign_usage(usage)
|
|
293
|
+
@usage_hash = normalized_usage(usage)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def normalized_usage(usage)
|
|
297
|
+
usage = default_usage.merge(symbolize_keys(usage).slice(*DEFAULT_USAGE.keys))
|
|
298
|
+
usage[:total] = usage[:input] + usage[:cache_write] + usage[:cache_read] + usage[:output]
|
|
299
|
+
usage[:raw] ||= {}
|
|
300
|
+
usage
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def default_usage
|
|
304
|
+
DEFAULT_USAGE.merge(raw: {})
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def serialized_blocks
|
|
308
|
+
blocks.map do |content_block|
|
|
309
|
+
next content_block unless content_block[:type] == "tool_use"
|
|
310
|
+
|
|
311
|
+
content_block.merge(input: LlmGateway::Utils.deep_symbolize_keys(parse_tool_input(content_block[:input])))
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def parse_tool_input(input)
|
|
316
|
+
return {} if input.nil? || input.empty?
|
|
317
|
+
|
|
318
|
+
JSON.parse(input)
|
|
319
|
+
rescue JSON::ParserError
|
|
320
|
+
{}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def symbolize_keys(hash)
|
|
324
|
+
hash.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def string_value(value)
|
|
328
|
+
value.nil? ? "" : value.to_s
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def ensure_timestamp!
|
|
332
|
+
@timestamp ||= (Time.now.to_f * 1000).to_i
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -10,8 +10,6 @@ module LlmGateway
|
|
|
10
10
|
|
|
11
11
|
def input_sanitizer = OpenAI::ChatCompletions::InputMessageSanitizer
|
|
12
12
|
|
|
13
|
-
def output_mapper = OpenAI::ChatCompletions::OutputMapper
|
|
14
|
-
|
|
15
13
|
def file_output_mapper = OpenAI::FileOutputMapper
|
|
16
14
|
|
|
17
15
|
def option_mapper = OpenAI::ChatCompletions::OptionMapper
|
|
@@ -11,18 +11,12 @@ module LlmGateway
|
|
|
11
11
|
|
|
12
12
|
def input_sanitizer = InputMessageSanitizer
|
|
13
13
|
|
|
14
|
-
def output_mapper = OpenAI::Responses::OutputMapper
|
|
15
|
-
|
|
16
14
|
def file_output_mapper = OpenAI::FileOutputMapper
|
|
17
15
|
|
|
18
16
|
def option_mapper = OpenAI::Responses::OptionMapper
|
|
19
17
|
|
|
20
18
|
def stream_mapper = OpenAI::Responses::StreamMapper
|
|
21
19
|
|
|
22
|
-
def perform_chat(messages, tools:, system:, **options)
|
|
23
|
-
client.responses(messages, tools: tools, system: system, **options)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
20
|
def perform_stream(messages, tools:, system:, **options, &block)
|
|
27
21
|
client.stream_responses(messages, tools: tools, system: system, **options, &block)
|
|
28
22
|
end
|
|
@@ -1,104 +1,167 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
|
-
require_relative "bidirectional_message_mapper"
|
|
5
4
|
|
|
6
5
|
module LlmGateway
|
|
7
6
|
module Adapters
|
|
8
7
|
module OpenAI
|
|
9
8
|
module ChatCompletions
|
|
10
9
|
class InputMapper
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
def self.map(data)
|
|
11
|
+
{
|
|
12
|
+
messages: map_messages(data[:messages]),
|
|
13
|
+
tools: map_tools(data[:tools]),
|
|
14
|
+
system: map_system(data[:system])
|
|
15
|
+
}
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
def self.map_content(content)
|
|
19
|
+
content = { type: "text", text: content } unless content.is_a?(Hash)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
case content[:type]
|
|
22
|
+
when "text"
|
|
23
|
+
map_text_content(content)
|
|
24
|
+
when "file"
|
|
25
|
+
map_file_content(content)
|
|
26
|
+
when "image"
|
|
27
|
+
map_image_content(content)
|
|
28
|
+
when "tool_use", "function"
|
|
29
|
+
map_tool_use_content(content)
|
|
30
|
+
when "tool_result"
|
|
31
|
+
map_tool_result_content(content)
|
|
32
|
+
else
|
|
33
|
+
content
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
private
|
|
23
39
|
|
|
24
|
-
|
|
40
|
+
def map_messages(messages)
|
|
41
|
+
return messages unless messages
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
msg = msg.merge(role: "user") if msg[:role] == "developer"
|
|
43
|
+
mapped_messages = messages.map do |msg|
|
|
44
|
+
msg = msg.merge(role: "user") if msg[:role] == "developer"
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
content = if msg[:content].is_a?(Array)
|
|
47
|
+
msg[:content].map { |content| map_content(content) }
|
|
48
|
+
else
|
|
49
|
+
[ map_content(msg[:content]) ]
|
|
33
50
|
end
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
role: msg[:role],
|
|
54
|
+
content: content
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
mapped_messages.flat_map do |msg|
|
|
59
|
+
tool_calls = []
|
|
60
|
+
regular_content = []
|
|
61
|
+
tool_messages = []
|
|
62
|
+
msg[:content].each do |content|
|
|
63
|
+
case content[:type] || content[:role]
|
|
64
|
+
when "tool"
|
|
65
|
+
tool_messages << content
|
|
66
|
+
when "function"
|
|
67
|
+
tool_calls << content
|
|
68
|
+
else
|
|
69
|
+
regular_content << content
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
result = []
|
|
73
|
+
|
|
74
|
+
if tool_calls.any? || regular_content.any?
|
|
75
|
+
main_msg = msg.dup
|
|
76
|
+
main_msg[:role] = "assistant" if !main_msg[:role]
|
|
77
|
+
main_msg[:tool_calls] = tool_calls if tool_calls.any?
|
|
78
|
+
main_msg[:content] = regular_content.any? ? regular_content : nil
|
|
79
|
+
result << main_msg
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
result + tool_messages
|
|
83
|
+
end
|
|
36
84
|
end
|
|
37
85
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case content[:type] || content[:role]
|
|
51
|
-
when "tool"
|
|
52
|
-
tool_messages << content
|
|
53
|
-
when "function"
|
|
54
|
-
tool_calls << content
|
|
55
|
-
else
|
|
56
|
-
regular_content << content
|
|
86
|
+
def map_tools(tools)
|
|
87
|
+
return tools unless tools
|
|
88
|
+
|
|
89
|
+
tools.map do |tool|
|
|
90
|
+
{
|
|
91
|
+
type: "function",
|
|
92
|
+
function: {
|
|
93
|
+
name: tool[:name],
|
|
94
|
+
description: tool[:description],
|
|
95
|
+
parameters: tool[:input_schema]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
57
98
|
end
|
|
58
99
|
end
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
|
|
101
|
+
def map_system(system)
|
|
102
|
+
if !system || system.empty?
|
|
103
|
+
[]
|
|
104
|
+
else
|
|
105
|
+
system.map do |msg|
|
|
106
|
+
msg[:role] == "system" ? msg.merge(role: "developer") : msg
|
|
107
|
+
end
|
|
108
|
+
end
|
|
68
109
|
end
|
|
69
110
|
|
|
70
|
-
|
|
71
|
-
|
|
111
|
+
def map_text_content(content)
|
|
112
|
+
{
|
|
113
|
+
type: "text",
|
|
114
|
+
text: content[:text]
|
|
115
|
+
}
|
|
116
|
+
end
|
|
72
117
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
def map_file_content(content)
|
|
119
|
+
media_type = content[:media_type] == "text/plain" ? "application/pdf" : content[:media_type]
|
|
120
|
+
{
|
|
121
|
+
type: "file",
|
|
122
|
+
file: {
|
|
123
|
+
filename: content[:name],
|
|
124
|
+
file_data: "data:#{media_type};base64,#{Base64.encode64(content[:data])}"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
end
|
|
76
128
|
|
|
77
|
-
|
|
78
|
-
|
|
129
|
+
def map_image_content(content)
|
|
130
|
+
{
|
|
131
|
+
type: "image_url",
|
|
132
|
+
image_url: {
|
|
133
|
+
url: "data:#{content[:media_type]};base64,#{content[:data]}"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
end
|
|
79
137
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
138
|
+
def map_tool_use_content(content)
|
|
139
|
+
{
|
|
140
|
+
id: content[:id],
|
|
141
|
+
type: "function",
|
|
142
|
+
function: {
|
|
143
|
+
name: content[:name],
|
|
144
|
+
arguments: content[:input].to_json
|
|
145
|
+
}
|
|
87
146
|
}
|
|
88
|
-
|
|
89
|
-
end
|
|
90
|
-
end
|
|
147
|
+
end
|
|
91
148
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
149
|
+
def map_tool_result_content(content)
|
|
150
|
+
mapped_content = content[:content]
|
|
151
|
+
if mapped_content.is_a?(Array)
|
|
152
|
+
mapped_content = mapped_content.map do |item|
|
|
153
|
+
item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
{
|
|
158
|
+
role: "tool",
|
|
159
|
+
tool_call_id: content[:tool_use_id],
|
|
160
|
+
content: mapped_content
|
|
161
|
+
}
|
|
98
162
|
end
|
|
99
163
|
end
|
|
100
164
|
end
|
|
101
|
-
end
|
|
102
165
|
end
|
|
103
166
|
end
|
|
104
167
|
end
|