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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +565 -129
- data/Rakefile +8 -3
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +173 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
- data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
- data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
- data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
- data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
- data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +97 -1
- data/lib/llm_gateway/client.rb +66 -54
- data/lib/llm_gateway/clients/anthropic.rb +167 -0
- data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
- data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
- data/lib/llm_gateway/clients/groq.rb +54 -0
- data/lib/llm_gateway/clients/openai.rb +208 -0
- data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
- data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
- data/lib/llm_gateway/errors.rb +23 -0
- data/lib/llm_gateway/prompt.rb +12 -1
- data/lib/llm_gateway/provider_registry.rb +37 -0
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +169 -10
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- data/scripts/generate_handoff_live_fixture.rb +169 -0
- data/scripts/generate_handoff_media_fixture.rb +167 -0
- metadata +64 -21
- data/lib/llm_gateway/adapters/claude/client.rb +0 -56
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
- data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
- data/sample/claude_code_clone/agent.rb +0 -65
- data/sample/claude_code_clone/claude_code_clone.rb +0 -40
- data/sample/claude_code_clone/prompt.rb +0 -79
- data/sample/claude_code_clone/run.rb +0 -47
- data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
- data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
- data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
- data/sample/claude_code_clone/tools/read_tool.rb +0 -61
- 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
|