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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +255 -1
  4. data/docs/migration_guide_0.7.0.md +193 -0
  5. data/lib/llm_gateway/adapters/adapter.rb +1 -1
  6. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
  7. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -8
  8. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
  9. data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
  10. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
  11. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +48 -16
  12. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
  13. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
  14. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
  15. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +131 -3
  16. data/lib/llm_gateway/adapters/structs.rb +45 -10
  17. data/lib/llm_gateway/agents/event.rb +105 -0
  18. data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
  19. data/lib/llm_gateway/agents/harness.rb +176 -0
  20. data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
  21. data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
  22. data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
  23. data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
  24. data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
  25. data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
  26. data/lib/llm_gateway/base_client.rb +3 -3
  27. data/lib/llm_gateway/clients/anthropic.rb +5 -5
  28. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
  29. data/lib/llm_gateway/clients/openai.rb +2 -2
  30. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
  31. data/lib/llm_gateway/prompt.rb +105 -68
  32. data/lib/llm_gateway/utils.rb +116 -13
  33. data/lib/llm_gateway/version.rb +1 -1
  34. data/lib/llm_gateway.rb +4 -0
  35. metadata +12 -2
@@ -68,7 +68,7 @@ module LlmGateway
68
68
  module_function
69
69
 
70
70
  def map(options)
71
- mapped_options = options.reject { |key, _| MANAGED_OPTIONS.include?(key) }
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(block, same_model_replay: same_model_replay)
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.nil? || text.strip.empty?
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 unless text.empty?
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
- !provider.nil? && !api.nil? && !model.nil?
175
+ provider.present? && api.present? && model.present?
88
176
  end
89
177
 
90
- private_class_method :sanitize_message, :sanitize_content_block, :extract_reasoning_text, :same_model_replay?, :message_metadata_present?
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(event_patch)
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(event_patch)
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 = symbolize_keys(event_patch[: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: "tool_use",
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(symbolize_keys(usage).slice(*DEFAULT_USAGE.keys))
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
- 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])))
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.nil? || input.empty?
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.reject { |key, _| MANAGED_OPTIONS.include?(key) }
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
- mapped_tool = {
41
- type: "function",
42
- name: tool[:name],
43
- description: tool[:description],
44
- parameters: tool[:input_schema]
45
- }
46
-
47
- [ :contents, :content ].each do |key|
48
- next unless tool[key].is_a?(Array)
49
-
50
- mapped_tool[key] = tool[key].map do |entry|
51
- entry.is_a?(Hash) ? map_content(entry.transform_keys(&:to_sym)) : entry
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
- mapped_tool
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
- if text_blocks.any?
94
- result << {
95
- role: "assistant",
96
- content: text_blocks.map { |b| { type: "output_text", text: b[:text] } }
97
- }
98
- end
99
-
100
- tool_use_blocks.each do |b|
101
- result << {
102
- type: "function_call",
103
- call_id: b[:id],
104
- name: b[:name],
105
- arguments: b[:input].is_a?(Hash) ? b[:input].to_json : (b[:input] || {}).to_json
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.reject { |key, _| MANAGED_OPTIONS.include?(key) }
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
- [ { type: :text_end, delta: "" } ]
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] == "tool_use" }
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