llm_gateway 0.6.0 → 0.7.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/CHANGELOG.md +12 -0
- data/README.md +255 -1
- data/docs/migration_guide_0.7.0.md +193 -0
- data/lib/llm_gateway/adapters/adapter.rb +1 -1
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -8
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +48 -16
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +131 -3
- data/lib/llm_gateway/adapters/structs.rb +45 -10
- data/lib/llm_gateway/agents/event.rb +105 -0
- data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
- data/lib/llm_gateway/agents/harness.rb +176 -0
- data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
- data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
- data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
- data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
- data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
- data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
- data/lib/llm_gateway/base_client.rb +3 -3
- data/lib/llm_gateway/clients/anthropic.rb +5 -5
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
- data/lib/llm_gateway/clients/openai.rb +2 -2
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
- data/lib/llm_gateway/prompt.rb +105 -68
- data/lib/llm_gateway/utils.rb +116 -13
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +4 -0
- metadata +12 -2
|
@@ -68,7 +68,7 @@ module LlmGateway
|
|
|
68
68
|
module_function
|
|
69
69
|
|
|
70
70
|
def map(options)
|
|
71
|
-
mapped_options = options.
|
|
71
|
+
mapped_options = options.except(*MANAGED_OPTIONS)
|
|
72
72
|
mapped_options[:temperature] = options.key?(:temperature) ? options[:temperature] : DEFAULT_TEMPERATURE
|
|
73
73
|
mapped_options[:max_completion_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_COMPLETION_TOKENS
|
|
74
74
|
mapped_options[:response_format] = normalize_response_format(options[:response_format] || "text")
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module LlmGateway
|
|
4
6
|
module Adapters
|
|
5
7
|
class InputMessageSanitizer
|
|
6
8
|
def self.sanitize(messages, target_provider:, target_api:, target_model:)
|
|
7
9
|
return messages unless messages.is_a?(Array)
|
|
8
10
|
|
|
9
|
-
messages.map do |message|
|
|
11
|
+
sanitized = messages.map do |message|
|
|
10
12
|
sanitize_message(
|
|
11
13
|
message,
|
|
12
14
|
target_provider: target_provider,
|
|
@@ -14,6 +16,8 @@ module LlmGateway
|
|
|
14
16
|
target_model: target_model
|
|
15
17
|
)
|
|
16
18
|
end
|
|
19
|
+
|
|
20
|
+
relocate_assistant_tool_results(sanitized)
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def self.sanitize_message(message, target_provider:, target_api:, target_model:)
|
|
@@ -25,9 +29,14 @@ module LlmGateway
|
|
|
25
29
|
return message unless message_metadata_present?(message)
|
|
26
30
|
|
|
27
31
|
same_model_replay = same_model_replay?(message, target_provider:, target_api:, target_model:)
|
|
32
|
+
same_provider_api_replay = same_provider_api_replay?(message, target_provider:, target_api:)
|
|
28
33
|
|
|
29
34
|
sanitized_content = content.each_with_object([]) do |block, acc|
|
|
30
|
-
sanitized = sanitize_content_block(
|
|
35
|
+
sanitized = sanitize_content_block(
|
|
36
|
+
block,
|
|
37
|
+
same_model_replay: same_model_replay,
|
|
38
|
+
same_provider_api_replay: same_provider_api_replay
|
|
39
|
+
)
|
|
31
40
|
next if sanitized.nil?
|
|
32
41
|
|
|
33
42
|
if sanitized.is_a?(Array)
|
|
@@ -40,19 +49,91 @@ module LlmGateway
|
|
|
40
49
|
message.merge(content: sanitized_content)
|
|
41
50
|
end
|
|
42
51
|
|
|
43
|
-
def self.sanitize_content_block(block, same_model_replay:)
|
|
52
|
+
def self.sanitize_content_block(block, same_model_replay:, same_provider_api_replay:)
|
|
44
53
|
return block unless block.is_a?(Hash)
|
|
45
54
|
|
|
46
55
|
type = block[:type] || block["type"]
|
|
56
|
+
|
|
57
|
+
if type == "server_tool_use"
|
|
58
|
+
return normalize_server_tool_use_for_replay(block) if same_provider_api_replay
|
|
59
|
+
|
|
60
|
+
return convert_server_tool_use_to_tool_use(block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if type == "server_tool_result"
|
|
64
|
+
return block if same_provider_api_replay
|
|
65
|
+
|
|
66
|
+
return convert_server_tool_result_to_tool_result(block)
|
|
67
|
+
end
|
|
68
|
+
|
|
47
69
|
return block unless %w[thinking reasoning].include?(type)
|
|
48
70
|
return block if same_model_replay
|
|
49
71
|
|
|
50
72
|
text = extract_reasoning_text(block)
|
|
51
|
-
return nil if text.
|
|
73
|
+
return nil if text.blank?
|
|
52
74
|
|
|
53
75
|
{ type: "text", text: text }
|
|
54
76
|
end
|
|
55
77
|
|
|
78
|
+
def self.normalize_server_tool_use_for_replay(block)
|
|
79
|
+
input = block[:input] || block["input"]
|
|
80
|
+
return block unless input.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
outputs = input[:outputs] || input["outputs"]
|
|
83
|
+
return block unless outputs.is_a?(Hash)
|
|
84
|
+
|
|
85
|
+
normalized_input = input.merge(outputs: outputs.values)
|
|
86
|
+
normalized_input.delete(:outputs) if input.key?("outputs") && !input.key?(:outputs)
|
|
87
|
+
normalized_input["outputs"] = outputs.values if input.key?("outputs")
|
|
88
|
+
|
|
89
|
+
normalized = block.merge(input: normalized_input)
|
|
90
|
+
normalized.delete(:input) if block.key?("input") && !block.key?(:input)
|
|
91
|
+
normalized["input"] = normalized_input if block.key?("input")
|
|
92
|
+
normalized
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.convert_server_tool_use_to_tool_use(block)
|
|
96
|
+
converted = block.merge(type: "tool_use")
|
|
97
|
+
converted.delete(:type) if block.key?("type") && !block.key?(:type)
|
|
98
|
+
converted["type"] = "tool_use" if block.key?("type")
|
|
99
|
+
converted
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.convert_server_tool_result_to_tool_result(block)
|
|
103
|
+
converted = block.merge(type: "tool_result")
|
|
104
|
+
converted.delete(:type) if block.key?("type") && !block.key?(:type)
|
|
105
|
+
converted["type"] = "tool_result" if block.key?("type")
|
|
106
|
+
|
|
107
|
+
content = converted[:content] || converted["content"]
|
|
108
|
+
if content.is_a?(Hash)
|
|
109
|
+
converted = converted.merge(content: JSON.generate(content))
|
|
110
|
+
converted.delete(:content) if block.key?("content") && !block.key?(:content)
|
|
111
|
+
converted["content"] = JSON.generate(content) if block.key?("content")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
converted
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.relocate_assistant_tool_results(messages)
|
|
118
|
+
messages.flat_map do |message|
|
|
119
|
+
next message unless message.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
role = message[:role] || message["role"]
|
|
122
|
+
content = message[:content] || message["content"]
|
|
123
|
+
next message unless role == "assistant" && content.is_a?(Array)
|
|
124
|
+
|
|
125
|
+
tool_results, assistant_content = content.partition do |block|
|
|
126
|
+
block.is_a?(Hash) && (block[:type] || block["type"]) == "tool_result"
|
|
127
|
+
end
|
|
128
|
+
next message if tool_results.empty?
|
|
129
|
+
|
|
130
|
+
relocated = []
|
|
131
|
+
relocated << message.merge(content: assistant_content) unless assistant_content.empty?
|
|
132
|
+
relocated << { role: "user", content: tool_results }
|
|
133
|
+
relocated
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
56
137
|
def self.extract_reasoning_text(block)
|
|
57
138
|
return block[:thinking] if block[:thinking].is_a?(String)
|
|
58
139
|
return block[:reasoning] if block[:reasoning].is_a?(String)
|
|
@@ -65,7 +146,7 @@ module LlmGateway
|
|
|
65
146
|
|
|
66
147
|
item[:text] || item[:summary_text] || item[:reasoning]
|
|
67
148
|
end.join("\n")
|
|
68
|
-
return text
|
|
149
|
+
return text if text.present?
|
|
69
150
|
end
|
|
70
151
|
|
|
71
152
|
nil
|
|
@@ -79,15 +160,25 @@ module LlmGateway
|
|
|
79
160
|
provider == target_provider && api == target_api && model == target_model
|
|
80
161
|
end
|
|
81
162
|
|
|
163
|
+
def self.same_provider_api_replay?(message, target_provider:, target_api:)
|
|
164
|
+
provider = message[:provider] || message["provider"]
|
|
165
|
+
api = message[:api] || message["api"]
|
|
166
|
+
|
|
167
|
+
provider == target_provider && api == target_api
|
|
168
|
+
end
|
|
169
|
+
|
|
82
170
|
def self.message_metadata_present?(message)
|
|
83
171
|
provider = message[:provider] || message["provider"]
|
|
84
172
|
api = message[:api] || message["api"]
|
|
85
173
|
model = message[:model] || message["model"]
|
|
86
174
|
|
|
87
|
-
|
|
175
|
+
provider.present? && api.present? && model.present?
|
|
88
176
|
end
|
|
89
177
|
|
|
90
|
-
private_class_method :sanitize_message, :sanitize_content_block, :
|
|
178
|
+
private_class_method :sanitize_message, :sanitize_content_block, :normalize_server_tool_use_for_replay,
|
|
179
|
+
:convert_server_tool_use_to_tool_use, :convert_server_tool_result_to_tool_result,
|
|
180
|
+
:relocate_assistant_tool_results, :extract_reasoning_text, :same_model_replay?,
|
|
181
|
+
:same_provider_api_replay?, :message_metadata_present?
|
|
91
182
|
end
|
|
92
183
|
end
|
|
93
184
|
end
|
|
@@ -34,10 +34,14 @@ module LlmGateway
|
|
|
34
34
|
# { type: :reasoning_delta, delta: "...", signature: "" }
|
|
35
35
|
# { type: :reasoning_end, delta: "", signature: "" }
|
|
36
36
|
#
|
|
37
|
-
# { type: :tool_start, id: "...", name: "tool_name", delta: "" }
|
|
37
|
+
# { type: :tool_start, id: "...", name: "tool_name", tool_type: "tool_use", delta: "" }
|
|
38
38
|
# { type: :tool_delta, delta: "{\"a\":" }
|
|
39
39
|
# { type: :tool_end, delta: "" }
|
|
40
40
|
#
|
|
41
|
+
# { type: :tool_result_start, tool_use_id: "...", name: "server_tool_result", delta: "..." }
|
|
42
|
+
# { type: :tool_result_delta, delta: "..." }
|
|
43
|
+
# { type: :tool_result_end, delta: "" }
|
|
44
|
+
#
|
|
41
45
|
# Mappers do not provide `content_index`. The accumulator assigns the next
|
|
42
46
|
# public content index when a block starts and reuses the active content
|
|
43
47
|
# index for that block's deltas and end event.
|
|
@@ -68,6 +72,9 @@ module LlmGateway
|
|
|
68
72
|
tool_start: { block_type: :tool, phase: :start },
|
|
69
73
|
tool_delta: { block_type: :tool, phase: :delta },
|
|
70
74
|
tool_end: { block_type: :tool, phase: :end },
|
|
75
|
+
tool_result_start: { block_type: :tool_result, phase: :start },
|
|
76
|
+
tool_result_delta: { block_type: :tool_result, phase: :delta },
|
|
77
|
+
tool_result_end: { block_type: :tool_result, phase: :end },
|
|
71
78
|
reasoning_start: { block_type: :reasoning, phase: :start },
|
|
72
79
|
reasoning_delta: { block_type: :reasoning, phase: :delta },
|
|
73
80
|
reasoning_end: { block_type: :reasoning, phase: :end }
|
|
@@ -106,7 +113,7 @@ module LlmGateway
|
|
|
106
113
|
def push(event_patch, &block)
|
|
107
114
|
raise ArgumentError, "Normalized stream event patch must be a Hash" unless event_patch.is_a?(Hash)
|
|
108
115
|
|
|
109
|
-
event_patch = symbolize_keys
|
|
116
|
+
event_patch = event_patch.symbolize_keys
|
|
110
117
|
type = event_patch.fetch(:type).to_sym
|
|
111
118
|
event_patch = prepare_event_patch(event_patch.merge(type:), type)
|
|
112
119
|
ensure_timestamp!
|
|
@@ -188,12 +195,12 @@ module LlmGateway
|
|
|
188
195
|
end
|
|
189
196
|
|
|
190
197
|
def build_event(event_patch, partial:)
|
|
191
|
-
event_patch = symbolize_keys
|
|
198
|
+
event_patch = event_patch.symbolize_keys
|
|
192
199
|
type = event_patch.fetch(:type).to_sym
|
|
193
200
|
|
|
194
201
|
case type
|
|
195
202
|
when :message_start, :message_delta
|
|
196
|
-
delta =
|
|
203
|
+
delta = (event_patch[:delta] || {}).symbolize_keys
|
|
197
204
|
raw_usage = event_patch[:usage] || delta.delete(:usage) || {}
|
|
198
205
|
usage = raw_usage.empty? ? {} : normalized_usage(raw_usage)
|
|
199
206
|
|
|
@@ -210,6 +217,16 @@ module LlmGateway
|
|
|
210
217
|
delta: string_value(event_patch[:delta]),
|
|
211
218
|
id: event_patch[:id],
|
|
212
219
|
name: event_patch[:name],
|
|
220
|
+
partial:,
|
|
221
|
+
tool_type: event_patch[:tool_type] || "tool_use"
|
|
222
|
+
)
|
|
223
|
+
when :tool_result_start
|
|
224
|
+
AssistantToolResultStartEvent.new(
|
|
225
|
+
type:,
|
|
226
|
+
content_index: event_patch.fetch(:content_index),
|
|
227
|
+
delta: string_value(event_patch[:delta]),
|
|
228
|
+
tool_use_id: event_patch[:tool_use_id],
|
|
229
|
+
name: event_patch[:name],
|
|
213
230
|
partial:
|
|
214
231
|
)
|
|
215
232
|
when :reasoning_start, :reasoning_delta, :reasoning_end
|
|
@@ -220,7 +237,7 @@ module LlmGateway
|
|
|
220
237
|
signature: string_value(event_patch[:signature]),
|
|
221
238
|
partial:
|
|
222
239
|
)
|
|
223
|
-
when :text_start, :text_delta, :text_end, :tool_delta, :tool_end
|
|
240
|
+
when :text_start, :text_delta, :text_end, :tool_delta, :tool_end, :tool_result_delta, :tool_result_end
|
|
224
241
|
AssistantStreamEvent.new(
|
|
225
242
|
type:,
|
|
226
243
|
content_index: event_patch.fetch(:content_index),
|
|
@@ -246,13 +263,21 @@ module LlmGateway
|
|
|
246
263
|
blocks[event.content_index][:text] += event.delta
|
|
247
264
|
when :tool_start
|
|
248
265
|
blocks[event.content_index] = {
|
|
249
|
-
type:
|
|
266
|
+
type: event.tool_type,
|
|
250
267
|
id: event.id,
|
|
251
268
|
name: event.name,
|
|
252
269
|
input: event.delta.to_s
|
|
253
270
|
}
|
|
254
271
|
when :tool_delta, :tool_end
|
|
255
272
|
blocks[event.content_index][:input] += event.delta
|
|
273
|
+
when :tool_result_start
|
|
274
|
+
blocks[event.content_index] = {
|
|
275
|
+
type: event.name,
|
|
276
|
+
tool_use_id: event.tool_use_id,
|
|
277
|
+
content: event.delta.to_s
|
|
278
|
+
}
|
|
279
|
+
when :tool_result_delta, :tool_result_end
|
|
280
|
+
blocks[event.content_index][:content] += event.delta
|
|
256
281
|
when :message_start
|
|
257
282
|
message_hash.merge!(event.delta)
|
|
258
283
|
when :reasoning_start
|
|
@@ -294,7 +319,7 @@ module LlmGateway
|
|
|
294
319
|
end
|
|
295
320
|
|
|
296
321
|
def normalized_usage(usage)
|
|
297
|
-
usage = default_usage.merge(
|
|
322
|
+
usage = default_usage.merge(usage.to_h.symbolize_keys.slice(*DEFAULT_USAGE.keys))
|
|
298
323
|
usage[:total] = usage[:input] + usage[:cache_write] + usage[:cache_read] + usage[:output]
|
|
299
324
|
usage[:raw] ||= {}
|
|
300
325
|
usage
|
|
@@ -305,25 +330,32 @@ module LlmGateway
|
|
|
305
330
|
end
|
|
306
331
|
|
|
307
332
|
def serialized_blocks
|
|
308
|
-
blocks.map do |content_block|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
333
|
+
blocks.compact.map do |content_block|
|
|
334
|
+
if [ "tool_use", "server_tool_use" ].include?(content_block[:type])
|
|
335
|
+
next content_block.merge(input: parse_tool_input(content_block[:input]).deep_symbolize_keys)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
if content_block[:type]&.end_with?("_tool_result")
|
|
339
|
+
next {
|
|
340
|
+
type: "server_tool_result",
|
|
341
|
+
tool_use_id: content_block[:tool_use_id],
|
|
342
|
+
name: content_block[:type],
|
|
343
|
+
content: parse_tool_input(content_block[:content]).deep_symbolize_keys
|
|
344
|
+
}
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
content_block
|
|
312
348
|
end
|
|
313
349
|
end
|
|
314
350
|
|
|
315
351
|
def parse_tool_input(input)
|
|
316
|
-
return {} if input.
|
|
352
|
+
return {} if input.blank?
|
|
317
353
|
|
|
318
354
|
JSON.parse(input)
|
|
319
355
|
rescue JSON::ParserError
|
|
320
356
|
{}
|
|
321
357
|
end
|
|
322
358
|
|
|
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
359
|
def string_value(value)
|
|
328
360
|
value.nil? ? "" : value.to_s
|
|
329
361
|
end
|
|
@@ -66,7 +66,7 @@ module LlmGateway
|
|
|
66
66
|
module_function
|
|
67
67
|
|
|
68
68
|
def map(options)
|
|
69
|
-
mapped_options = options.
|
|
69
|
+
mapped_options = options.except(*MANAGED_OPTIONS)
|
|
70
70
|
mapped_options[:max_completion_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_COMPLETION_TOKENS
|
|
71
71
|
|
|
72
72
|
cache_key = options[:cache_key]
|
|
@@ -37,22 +37,28 @@ module LlmGateway
|
|
|
37
37
|
return tools unless tools
|
|
38
38
|
|
|
39
39
|
tools.map do |tool|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
40
|
+
tool = tool.transform_keys(&:to_sym)
|
|
41
|
+
|
|
42
|
+
if tool[:name].nil?
|
|
43
|
+
tool
|
|
44
|
+
else
|
|
45
|
+
mapped_tool = {
|
|
46
|
+
type: "function",
|
|
47
|
+
name: tool[:name],
|
|
48
|
+
description: tool[:description],
|
|
49
|
+
parameters: tool[:input_schema]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
[ :contents, :content ].each do |key|
|
|
53
|
+
next unless tool[key].is_a?(Array)
|
|
54
|
+
|
|
55
|
+
mapped_tool[key] = tool[key].map do |entry|
|
|
56
|
+
entry.is_a?(Hash) ? map_content(entry.transform_keys(&:to_sym)) : entry
|
|
57
|
+
end
|
|
52
58
|
end
|
|
53
|
-
end
|
|
54
59
|
|
|
55
|
-
|
|
60
|
+
mapped_tool
|
|
61
|
+
end
|
|
56
62
|
end
|
|
57
63
|
end
|
|
58
64
|
|
|
@@ -85,30 +91,40 @@ module LlmGateway
|
|
|
85
91
|
def map_assistant_history_message(msg)
|
|
86
92
|
blocks = (msg[:content] || []).map { |b| b.transform_keys(&:to_sym) }
|
|
87
93
|
|
|
88
|
-
text_blocks = blocks.select { |b| b[:type] == "text" }
|
|
89
|
-
tool_use_blocks = blocks.select { |b| b[:type] == "tool_use" }
|
|
90
|
-
|
|
91
94
|
result = []
|
|
92
95
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
96
|
+
blocks.each do |block|
|
|
97
|
+
case block[:type]
|
|
98
|
+
when "text"
|
|
99
|
+
result << {
|
|
100
|
+
role: "assistant",
|
|
101
|
+
content: [ { type: "output_text", text: block[:text] } ]
|
|
102
|
+
}
|
|
103
|
+
when "tool_use"
|
|
104
|
+
result << {
|
|
105
|
+
type: "function_call",
|
|
106
|
+
call_id: block[:id],
|
|
107
|
+
name: block[:name],
|
|
108
|
+
arguments: block[:input].is_a?(Hash) ? block[:input].to_json : (block[:input] || {}).to_json
|
|
109
|
+
}
|
|
110
|
+
when "server_tool_use"
|
|
111
|
+
result << map_server_tool_use_history_item(block)
|
|
112
|
+
end
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
result
|
|
110
116
|
end
|
|
111
117
|
|
|
118
|
+
def map_server_tool_use_history_item(block)
|
|
119
|
+
input = block[:input].is_a?(Hash) ? block[:input] : {}
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
id: block[:id],
|
|
123
|
+
type: block[:name],
|
|
124
|
+
status: "completed"
|
|
125
|
+
}.merge(input)
|
|
126
|
+
end
|
|
127
|
+
|
|
112
128
|
def map_messages_content(message)
|
|
113
129
|
message[:content].map { |content| map_content(content) }
|
|
114
130
|
end
|
|
@@ -58,7 +58,7 @@ module LlmGateway
|
|
|
58
58
|
module_function
|
|
59
59
|
|
|
60
60
|
def map(options)
|
|
61
|
-
mapped_options = options.
|
|
61
|
+
mapped_options = options.except(*MANAGED_OPTIONS)
|
|
62
62
|
mapped_options[:max_output_tokens] = options[:max_completion_tokens] || options[:max_output_tokens] || DEFAULT_MAX_OUTPUT_TOKENS
|
|
63
63
|
|
|
64
64
|
cache_key = options[:cache_key]
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
require_relative "../../stream_mapper"
|
|
4
6
|
|
|
5
7
|
module LlmGateway
|
|
@@ -23,10 +25,16 @@ module LlmGateway
|
|
|
23
25
|
response_created_patches(data[:response])
|
|
24
26
|
when "response.output_item.added"
|
|
25
27
|
output_item_added_patches(data)
|
|
28
|
+
when "response.output_item.done"
|
|
29
|
+
output_item_done_patches(data)
|
|
26
30
|
when "response.content_part.added"
|
|
27
31
|
content_part_added_patches(data)
|
|
28
|
-
when "response.content_part.done"
|
|
32
|
+
when "response.content_part.done", "response.output_text.done"
|
|
29
33
|
content_part_done_patches(data)
|
|
34
|
+
when "response.code_interpreter_call_code.delta"
|
|
35
|
+
code_interpreter_code_delta_patches(data)
|
|
36
|
+
when "response.code_interpreter_call.in_progress", "response.code_interpreter_call.interpreting", "response.code_interpreter_call.completed", "response.code_interpreter_call_code.done"
|
|
37
|
+
[]
|
|
30
38
|
when "response.output_text.delta"
|
|
31
39
|
[ { type: :text_delta, delta: data[:delta] || "" } ]
|
|
32
40
|
when "response.function_call_arguments.delta"
|
|
@@ -84,6 +92,38 @@ module LlmGateway
|
|
|
84
92
|
name: item[:name]
|
|
85
93
|
}
|
|
86
94
|
]
|
|
95
|
+
when "code_interpreter_call"
|
|
96
|
+
state = code_interpreter_state[data[:output_index] || 0] = {
|
|
97
|
+
id: item[:id],
|
|
98
|
+
container_id: item[:container_id],
|
|
99
|
+
outputs: item[:outputs],
|
|
100
|
+
input_opened: false,
|
|
101
|
+
input_closed: false
|
|
102
|
+
}
|
|
103
|
+
container_id_to_tool_id[state[:container_id]] = state[:id] if state[:container_id]
|
|
104
|
+
|
|
105
|
+
[
|
|
106
|
+
{
|
|
107
|
+
type: :tool_start,
|
|
108
|
+
delta: "",
|
|
109
|
+
id: item[:id],
|
|
110
|
+
name: "code_interpreter_call",
|
|
111
|
+
tool_type: "server_tool_use"
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
else
|
|
115
|
+
[]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def output_item_done_patches(data)
|
|
120
|
+
item = data[:item] || {}
|
|
121
|
+
|
|
122
|
+
case item[:type]
|
|
123
|
+
when "code_interpreter_call"
|
|
124
|
+
code_interpreter_done_patches(data[:output_index] || 0, item)
|
|
125
|
+
when "message"
|
|
126
|
+
container_file_citation_patches(item)
|
|
87
127
|
else
|
|
88
128
|
[]
|
|
89
129
|
end
|
|
@@ -100,7 +140,83 @@ module LlmGateway
|
|
|
100
140
|
part = data[:part] || {}
|
|
101
141
|
return [] unless part.empty? || part[:type] == "output_text"
|
|
102
142
|
|
|
103
|
-
|
|
143
|
+
citations = container_file_citation_patches(data)
|
|
144
|
+
return citations unless accumulator.active_block_type == :text
|
|
145
|
+
|
|
146
|
+
[ { type: :text_end, delta: "" } ] + citations
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def code_interpreter_code_delta_patches(data)
|
|
150
|
+
output_index = data[:output_index] || 0
|
|
151
|
+
state = code_interpreter_state[output_index] ||= {
|
|
152
|
+
id: nil,
|
|
153
|
+
container_id: nil,
|
|
154
|
+
outputs: nil,
|
|
155
|
+
input_opened: false,
|
|
156
|
+
input_closed: false
|
|
157
|
+
}
|
|
158
|
+
delta = escape_json_string_fragment(data[:delta] || "")
|
|
159
|
+
delta = "{\"code\":\"#{delta}" unless state[:input_opened]
|
|
160
|
+
state[:input_opened] = true
|
|
161
|
+
|
|
162
|
+
[ { type: :tool_delta, delta: } ]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def code_interpreter_done_patches(output_index, item)
|
|
166
|
+
state = code_interpreter_state[output_index] ||= {}
|
|
167
|
+
state[:id] ||= item[:id]
|
|
168
|
+
state[:container_id] = item[:container_id] if item.key?(:container_id)
|
|
169
|
+
state[:outputs] = item[:outputs] if item.key?(:outputs)
|
|
170
|
+
container_id_to_tool_id[state[:container_id]] = state[:id] if state[:container_id] && state[:id]
|
|
171
|
+
return [] if state[:input_closed]
|
|
172
|
+
|
|
173
|
+
opening = state[:input_opened] ? "" : "{\"code\":\""
|
|
174
|
+
state[:input_opened] = true
|
|
175
|
+
closing = "\"," + JSON.generate(container_id: state[:container_id], outputs: state[:outputs])[1..]
|
|
176
|
+
state[:input_closed] = true
|
|
177
|
+
|
|
178
|
+
[
|
|
179
|
+
{ type: :tool_delta, delta: opening + closing },
|
|
180
|
+
{ type: :tool_end, delta: "" }
|
|
181
|
+
]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def container_file_citation_patches(data)
|
|
185
|
+
extract_annotations(data).filter_map do |annotation|
|
|
186
|
+
next unless annotation[:type] == "container_file_citation"
|
|
187
|
+
|
|
188
|
+
container_id = annotation[:container_id]
|
|
189
|
+
file_id = annotation[:file_id]
|
|
190
|
+
filename = annotation[:filename]
|
|
191
|
+
tool_id = container_id_to_tool_id[container_id]
|
|
192
|
+
next unless tool_id
|
|
193
|
+
|
|
194
|
+
key = [ tool_id, container_id, file_id, filename ]
|
|
195
|
+
next if emitted_citation_keys[key]
|
|
196
|
+
|
|
197
|
+
emitted_citation_keys[key] = true
|
|
198
|
+
{
|
|
199
|
+
type: :tool_result_start,
|
|
200
|
+
delta: JSON.generate(container_id:, file_id:, filename:),
|
|
201
|
+
tool_use_id: tool_id,
|
|
202
|
+
name: "container_file_citation_tool_result"
|
|
203
|
+
}
|
|
204
|
+
end.flat_map { |start| [ start, { type: :tool_result_end, delta: "" } ] }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def extract_annotations(data)
|
|
208
|
+
annotations = []
|
|
209
|
+
annotations.concat(Array(data[:annotations]))
|
|
210
|
+
annotations.concat(Array(data.dig(:part, :annotations)))
|
|
211
|
+
annotations.concat(Array(data.dig(:item, :annotations)))
|
|
212
|
+
Array(data.dig(:item, :content)).each do |content_part|
|
|
213
|
+
annotations.concat(Array(content_part[:annotations])) if content_part.is_a?(Hash)
|
|
214
|
+
end
|
|
215
|
+
annotations
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def escape_json_string_fragment(value)
|
|
219
|
+
JSON.generate(value)[1...-1]
|
|
104
220
|
end
|
|
105
221
|
|
|
106
222
|
def response_completed_patches(response)
|
|
@@ -162,7 +278,19 @@ module LlmGateway
|
|
|
162
278
|
end
|
|
163
279
|
|
|
164
280
|
def tool_seen?
|
|
165
|
-
accumulator.blocks.any? { |content_block| content_block && content_block[:type]
|
|
281
|
+
accumulator.blocks.any? { |content_block| content_block && [ "tool_use", "server_tool_use" ].include?(content_block[:type]) }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def code_interpreter_state
|
|
285
|
+
@code_interpreter_state ||= {}
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def container_id_to_tool_id
|
|
289
|
+
@container_id_to_tool_id ||= {}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def emitted_citation_keys
|
|
293
|
+
@emitted_citation_keys ||= {}
|
|
166
294
|
end
|
|
167
295
|
end
|
|
168
296
|
end
|