llm_gateway 0.2.0 → 0.4.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +565 -129
  4. data/Rakefile +8 -3
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +97 -1
  41. data/lib/llm_gateway/client.rb +66 -54
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +23 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +169 -10
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -21
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -56
  60. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
  65. data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../structs.rb"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module Anthropic
8
+ class StreamMapper
9
+ def map(chunk)
10
+ case chunk[:event]
11
+ when "message_start"
12
+ delta = {
13
+ id: chunk.dig(:data, :message, :id),
14
+ model: chunk.dig(:data, :message, :model),
15
+ role: chunk.dig(:data, :message, :role)
16
+ }
17
+ usage_increment = chunk.dig(:data, :message, :usage) || {}
18
+
19
+ AssistantStreamMessageEvent.new(type: :message_start, usage_increment:, delta:)
20
+ when "content_block_start"
21
+ content_index = chunk.dig(:data, :index)
22
+ delta = chunk.dig(:data, :content_block, :text)
23
+ current_type = chunk.dig(:data, :content_block, :type)
24
+ content_block_types[content_index] = current_type
25
+
26
+ case current_type
27
+ when "thinking"
28
+ AssistantStreamEvent.new(type: :reasoning_start, content_index:, delta:)
29
+ when "text"
30
+ AssistantStreamEvent.new(type: :text_start, content_index:, delta:)
31
+ when "tool_use"
32
+ id = chunk.dig(:data, :content_block, :id)
33
+ name = chunk.dig(:data, :content_block, :name)
34
+ AssistantToolStartEvent.new(type: :tool_start, content_index:, delta:, id:, name:)
35
+ end
36
+ when "content_block_delta"
37
+ content_index = chunk.dig(:data, :index)
38
+
39
+ case content_block_types[content_index]
40
+ when "thinking"
41
+ delta = chunk.dig(:data, :delta, :thinking)
42
+ signature = chunk.dig(:data, :delta, :signature)
43
+ AssistantStreamReasoningEvent.new(type: :reasoning_delta, signature:, delta:, content_index:)
44
+ when "text"
45
+ delta = chunk.dig(:data, :delta, :text)
46
+ AssistantStreamEvent.new(type: :text_delta, content_index:, delta:)
47
+ when "tool_use"
48
+ delta = chunk.dig(:data, :delta, :partial_json)
49
+ AssistantStreamEvent.new(type: :tool_delta, content_index:, delta:)
50
+ end
51
+ when "content_block_stop"
52
+ content_index = chunk.dig(:data, :index)
53
+ type = case content_block_types[content_index]
54
+ when "thinking"
55
+ :reasoning_end
56
+ when "text"
57
+ :text_end
58
+ when "tool_use"
59
+ :tool_end
60
+ end
61
+ AssistantStreamEvent.new(type: type, content_index:, delta: "")
62
+ when "message_delta"
63
+ delta = normalize_message_delta(chunk.dig(:data, :delta) || {})
64
+ usage_increment = chunk.dig(:data, :usage) || {}
65
+
66
+ AssistantStreamMessageEvent.new(type: :message_delta, usage_increment:, delta:)
67
+ when "message_stop"
68
+ AssistantStreamMessageEvent.new(type: :message_end, usage_increment: {}, delta: {})
69
+ when "ping"
70
+ nil
71
+ when "error"
72
+ error = chunk.dig(:data, :error) || {}
73
+ message = error[:message] || "Stream error"
74
+ code = error[:type]
75
+
76
+ if LlmGateway::Errors.context_overflow_message?(message)
77
+ raise LlmGateway::Errors::PromptTooLong.new(message, code)
78
+ end
79
+
80
+ if code == "overloaded_error"
81
+ raise LlmGateway::Errors::OverloadError.new(message, code)
82
+ end
83
+
84
+ raise LlmGateway::Errors::APIStatusError.new(message, code)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def content_block_types
91
+ @content_block_types ||= {}
92
+ end
93
+
94
+ def normalize_message_delta(delta)
95
+ return delta unless delta[:stop_reason] || delta["stop_reason"]
96
+
97
+ stop_reason = delta[:stop_reason] || delta["stop_reason"]
98
+ normalized_stop_reason = case stop_reason
99
+ when "end_turn"
100
+ "stop"
101
+ else
102
+ stop_reason
103
+ end
104
+
105
+ delta.merge(stop_reason: normalized_stop_reason)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module AnthropicOptionMapper
6
+ DEFAULT_MAX_TOKENS = 20_480
7
+ REASONING_EFFORT_BUDGET_TOKENS = {
8
+ "low" => 1024,
9
+ "medium" => 5 * 1024,
10
+ "high" => 10 * 1024,
11
+ "xhigh" => 20 * 1024
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def map(options)
17
+ mapped_options = options.reject { |key, _| %i[reasoning max_completion_tokens response_format prompt_cache_retention cache_key prompt_cache_key].include?(key) }
18
+ mapped_options[:max_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_TOKENS
19
+
20
+ retention = options[:cache_retention]
21
+ mapped_options[:cache_retention] = retention unless retention.nil?
22
+
23
+ response_format = options[:response_format]
24
+ mapped_options[:output_config] = normalize_output_config(response_format) unless response_format.nil?
25
+
26
+ reasoning = options[:reasoning]
27
+ return mapped_options if reasoning.nil? || reasoning.to_s == "none"
28
+
29
+ mapped_options[:thinking] = normalize_reasoning(reasoning)
30
+ mapped_options
31
+ end
32
+
33
+ def normalize_output_config(response_format)
34
+ format_type = response_format.is_a?(Hash) ? response_format[:type] || response_format["type"] : response_format
35
+
36
+ case format_type.to_s
37
+ when "json_object", "json_schema"
38
+ { format: "json_schema" }
39
+ else
40
+ { format: "text" }
41
+ end
42
+ end
43
+
44
+ def normalize_reasoning(reasoning)
45
+ budget_tokens = REASONING_EFFORT_BUDGET_TOKENS[reasoning.to_s] ||
46
+ raise(ArgumentError,
47
+ "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'.")
48
+
49
+ { type: "enabled", budget_tokens: budget_tokens }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../adapter"
4
+ require_relative "../openai/acts_like_chat_completions"
5
+ require_relative "../input_message_sanitizer"
6
+ require_relative "../openai/chat_completions/input_mapper"
7
+ require_relative "option_mapper"
8
+
9
+ module LlmGateway
10
+ module Adapters
11
+ module Groq
12
+ class ChatCompletionsAdapter < Adapter
13
+ include ActsLikeOpenAIChatCompletions
14
+
15
+ private
16
+
17
+ def file_output_mapper = nil
18
+ def stream_mapper = nil
19
+ def option_mapper = Groq::OptionMapper
20
+
21
+ def map_input(input)
22
+ groq_safe_input = input.dup
23
+ groq_safe_input[:messages] = Array(input[:messages]).map do |msg|
24
+ next msg unless msg.is_a?(Hash) && msg[:content].is_a?(Array)
25
+
26
+ rewritten_content = msg[:content].map do |block|
27
+ next block unless block.is_a?(Hash) && block[:type] == "file"
28
+
29
+ {
30
+ type: "text",
31
+ text: block[:text] || "[File: #{block[:name]}]"
32
+ }
33
+ end
34
+
35
+ msg.merge(content: rewritten_content)
36
+ end
37
+
38
+ mapped = super(groq_safe_input)
39
+ mapped[:system] = Array(mapped[:system]).map do |msg|
40
+ msg[:role] == "developer" ? msg.merge(role: "system") : msg
41
+ end
42
+ mapped
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module Groq
6
+ module OptionMapper
7
+ module_function
8
+
9
+ def map(options)
10
+ mapped_options = options.dup
11
+ mapped_options[:temperature] ||= 0
12
+ mapped_options[:max_completion_tokens] ||= 20480
13
+ mapped_options[:response_format] = normalize_response_format(mapped_options[:response_format] || "text")
14
+ mapped_options
15
+ end
16
+
17
+ def normalize_response_format(response_format)
18
+ if response_format.is_a?(String)
19
+ { type: response_format }
20
+ else
21
+ response_format
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ class InputMessageSanitizer
6
+ def self.sanitize(messages, target_provider:, target_api:, target_model:)
7
+ return messages unless messages.is_a?(Array)
8
+
9
+ messages.map do |message|
10
+ sanitize_message(
11
+ message,
12
+ target_provider: target_provider,
13
+ target_api: target_api,
14
+ target_model: target_model
15
+ )
16
+ end
17
+ end
18
+
19
+ def self.sanitize_message(message, target_provider:, target_api:, target_model:)
20
+ return message unless message.is_a?(Hash)
21
+
22
+ role = message[:role] || message["role"]
23
+ content = message[:content] || message["content"]
24
+ return message unless role == "assistant" && content.is_a?(Array)
25
+ return message unless message_metadata_present?(message)
26
+
27
+ same_model_replay = same_model_replay?(message, target_provider:, target_api:, target_model:)
28
+
29
+ sanitized_content = content.each_with_object([]) do |block, acc|
30
+ sanitized = sanitize_content_block(block, same_model_replay: same_model_replay)
31
+ next if sanitized.nil?
32
+
33
+ if sanitized.is_a?(Array)
34
+ acc.concat(sanitized)
35
+ else
36
+ acc << sanitized
37
+ end
38
+ end
39
+
40
+ message.merge(content: sanitized_content)
41
+ end
42
+
43
+ def self.sanitize_content_block(block, same_model_replay:)
44
+ return block unless block.is_a?(Hash)
45
+
46
+ type = block[:type] || block["type"]
47
+ return block unless %w[thinking reasoning].include?(type)
48
+ return block if same_model_replay
49
+
50
+ text = extract_reasoning_text(block)
51
+ return nil if text.nil? || text.strip.empty?
52
+
53
+ { type: "text", text: text }
54
+ end
55
+
56
+ def self.extract_reasoning_text(block)
57
+ return block[:thinking] if block[:thinking].is_a?(String)
58
+ return block[:reasoning] if block[:reasoning].is_a?(String)
59
+
60
+ summary = block[:summary]
61
+ if summary.is_a?(Array)
62
+ text = summary.filter_map do |item|
63
+ next item if item.is_a?(String)
64
+ next unless item.is_a?(Hash)
65
+
66
+ item[:text] || item[:summary_text] || item[:reasoning]
67
+ end.join("\n")
68
+ return text unless text.empty?
69
+ end
70
+
71
+ nil
72
+ end
73
+
74
+ def self.same_model_replay?(message, target_provider:, target_api:, target_model:)
75
+ provider = message[:provider] || message["provider"]
76
+ api = message[:api] || message["api"]
77
+ model = message[:model] || message["model"]
78
+
79
+ provider == target_provider && api == target_api && model == target_model
80
+ end
81
+
82
+ def self.message_metadata_present?(message)
83
+ provider = message[:provider] || message["provider"]
84
+ api = message[:api] || message["api"]
85
+ model = message[:model] || message["model"]
86
+
87
+ !provider.nil? && !api.nil? && !model.nil?
88
+ end
89
+
90
+ private_class_method :sanitize_message, :sanitize_content_block, :extract_reasoning_text, :same_model_replay?, :message_metadata_present?
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module ActsLikeOpenAIChatCompletions
6
+ private
7
+ def api_name = "completions"
8
+
9
+ def input_mapper = OpenAI::ChatCompletions::InputMapper
10
+
11
+ def input_sanitizer = OpenAI::ChatCompletions::InputMessageSanitizer
12
+
13
+ def output_mapper = OpenAI::ChatCompletions::OutputMapper
14
+
15
+ def file_output_mapper = OpenAI::FileOutputMapper
16
+
17
+ def option_mapper = OpenAI::ChatCompletions::OptionMapper
18
+
19
+ def stream_mapper = OpenAI::ChatCompletions::StreamMapper
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module ActsLikeOpenAIResponses
6
+ private
7
+
8
+ def api_name = "responses"
9
+
10
+ def input_mapper = OpenAI::Responses::InputMapper
11
+
12
+ def input_sanitizer = InputMessageSanitizer
13
+
14
+ def output_mapper = OpenAI::Responses::OutputMapper
15
+
16
+ def file_output_mapper = OpenAI::FileOutputMapper
17
+
18
+ def option_mapper = OpenAI::Responses::OptionMapper
19
+
20
+ def stream_mapper = OpenAI::Responses::StreamMapper
21
+
22
+ def perform_chat(messages, tools:, system:, **options)
23
+ client.responses(messages, tools: tools, system: system, **options)
24
+ end
25
+
26
+ def perform_stream(messages, tools:, system:, **options, &block)
27
+ client.stream_responses(messages, tools: tools, system: system, **options, &block)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module ChatCompletions
9
+ class BidirectionalMessageMapper
10
+ attr_reader :direction
11
+
12
+ def initialize(direction)
13
+ @direction = direction
14
+ end
15
+
16
+ def map_content(content)
17
+ # Convert string content to text format
18
+ content = { type: "text", text: content } unless content.is_a?(Hash)
19
+ case content[:type]
20
+ when "text"
21
+ map_text_content(content)
22
+ when "file"
23
+ map_file_content(content)
24
+ when "image"
25
+ map_image_content(content)
26
+ when "tool_use"
27
+ map_tool_use_content(content)
28
+ when "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
+ private
38
+
39
+ def parse_tool_arguments(arguments)
40
+ return arguments unless arguments.is_a?(String)
41
+ JSON.parse(arguments, symbolize_names: true)
42
+ end
43
+
44
+ def map_text_content(content)
45
+ {
46
+ type: "text",
47
+ text: content[:text]
48
+ }
49
+ end
50
+
51
+ def map_file_content(content)
52
+ # Map text/plain to application/pdf for OpenAI
53
+ media_type = content[:media_type] == "text/plain" ? "application/pdf" : content[:media_type]
54
+ {
55
+ type: "file",
56
+ file: {
57
+ filename: content[:name],
58
+ file_data: "data:#{media_type};base64,#{Base64.encode64(content[:data])}"
59
+ }
60
+ }
61
+ end
62
+
63
+ def map_image_content(content)
64
+ {
65
+ type: "image_url",
66
+ image_url: {
67
+ url: "data:#{content[:media_type]};base64,#{content[:data]}"
68
+ }
69
+ }
70
+ end
71
+
72
+ def map_tool_use_content(content)
73
+ if direction == LlmGateway::DIRECTION_IN
74
+ {
75
+ id: content[:id],
76
+ type: "function",
77
+ function: {
78
+ name: content[:name],
79
+ arguments: content[:input].to_json
80
+ }
81
+ }
82
+ else
83
+ {
84
+ id: content[:id],
85
+ type: "tool_use",
86
+ name: content[:function][:name],
87
+ input: parse_tool_arguments(content[:function][:arguments])
88
+ }
89
+ end
90
+ end
91
+
92
+ def map_tool_result_content(content)
93
+ mapped_content = content[:content]
94
+ if mapped_content.is_a?(Array)
95
+ mapped_content = mapped_content.map do |item|
96
+ item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
97
+ end
98
+ end
99
+
100
+ {
101
+ role: "tool",
102
+ tool_call_id: content[:tool_use_id],
103
+ content: mapped_content
104
+ }
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require_relative "bidirectional_message_mapper"
5
+
6
+ module LlmGateway
7
+ module Adapters
8
+ module OpenAI
9
+ module ChatCompletions
10
+ class InputMapper
11
+ def self.map(data)
12
+ {
13
+ messages: map_messages(data[:messages]),
14
+ tools: map_tools(data[:tools]),
15
+ system: map_system(data[:system])
16
+ }
17
+ end
18
+
19
+ private
20
+
21
+ def self.map_messages(messages)
22
+ return messages unless messages
23
+
24
+ message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_IN)
25
+
26
+ # First map messages like Claude
27
+ mapped_messages = messages.map do |msg|
28
+ msg = msg.merge(role: "user") if msg[:role] == "developer"
29
+
30
+ content = if msg[:content].is_a?(Array)
31
+ msg[:content].map do |content|
32
+ message_mapper.map_content(content)
33
+ end
34
+ else
35
+ [ message_mapper.map_content(msg[:content]) ]
36
+ end
37
+
38
+ {
39
+ role: msg[:role],
40
+ content: content
41
+ }
42
+ end
43
+ # Then transform to OpenAI format
44
+ mapped_messages.flat_map do |msg|
45
+ # Handle array content with tool calls and tool results
46
+ tool_calls = []
47
+ regular_content = []
48
+ tool_messages = []
49
+ msg[:content].each do |content|
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
57
+ end
58
+ end
59
+ result = []
60
+
61
+ # Add the main message with tool calls if any
62
+ if tool_calls.any? || regular_content.any?
63
+ main_msg = msg.dup
64
+ main_msg[:role] = "assistant" if !main_msg[:role]
65
+ main_msg[:tool_calls] = tool_calls if tool_calls.any?
66
+ main_msg[:content] = regular_content.any? ? regular_content : nil
67
+ result << main_msg
68
+ end
69
+
70
+ # Add separate tool result messages
71
+ result += tool_messages
72
+
73
+ result
74
+ end
75
+ end
76
+
77
+ def self.map_tools(tools)
78
+ return tools unless tools
79
+
80
+ tools.map do |tool|
81
+ {
82
+ type: "function",
83
+ function: {
84
+ name: tool[:name],
85
+ description: tool[:description],
86
+ parameters: tool[:input_schema]
87
+ }
88
+ }
89
+ end
90
+ end
91
+
92
+ def self.map_system(system)
93
+ if !system || system.empty?
94
+ []
95
+ else
96
+ system.map do |msg|
97
+ msg[:role] == "system" ? msg.merge(role: "developer") : msg
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../input_message_sanitizer"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module ChatCompletions
9
+ class InputMessageSanitizer < LlmGateway::Adapters::InputMessageSanitizer
10
+ def self.sanitize(messages, target_provider:, target_api:, target_model:)
11
+ sanitized = super
12
+ normalize_tool_call_ids(sanitized, target_provider: target_provider)
13
+ end
14
+
15
+ def self.normalize_tool_call_ids(messages, target_provider:)
16
+ return messages unless messages.is_a?(Array)
17
+
18
+ id_map = {}
19
+
20
+ messages.map do |message|
21
+ next message unless message.is_a?(Hash) && message[:content].is_a?(Array)
22
+
23
+ content = message[:content].map do |block|
24
+ next block unless block.is_a?(Hash)
25
+
26
+ type = block[:type] || block["type"]
27
+
28
+ case type
29
+ when "tool_use", "function"
30
+ original_id = block[:id] || block["id"]
31
+ normalized_id = normalize_tool_call_id(original_id, target_provider: target_provider)
32
+ id_map[original_id] = normalized_id if original_id && normalized_id
33
+ block.merge(id: normalized_id)
34
+ when "tool_result"
35
+ original_tool_use_id = block[:tool_use_id] || block["tool_use_id"]
36
+ normalized_tool_use_id = id_map[original_tool_use_id] || normalize_tool_call_id(original_tool_use_id, target_provider: target_provider)
37
+ block.merge(tool_use_id: normalized_tool_use_id)
38
+ else
39
+ block
40
+ end
41
+ end
42
+
43
+ message.merge(content: content)
44
+ end
45
+ end
46
+
47
+ def self.normalize_tool_call_id(id, target_provider:)
48
+ return id unless id.is_a?(String)
49
+
50
+ if id.include?("|")
51
+ call_id = id.split("|", 2).first
52
+ call_id.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 40]
53
+ elsif target_provider == "openai"
54
+ id[0, 40]
55
+ else
56
+ id
57
+ end
58
+ end
59
+
60
+ private_class_method :normalize_tool_call_ids, :normalize_tool_call_id
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end