llm_gateway 0.3.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 +26 -0
- data/README.md +544 -186
- data/Rakefile +1 -2
- 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/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
- data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
- 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/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
- data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
- 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/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
- 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/{open_ai → openai}/file_output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
- 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/{open_ai → openai}/responses/output_mapper.rb +1 -1
- 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/option_mapper.rb +13 -0
- 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 +62 -1
- data/lib/llm_gateway/client.rb +45 -129
- 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 +21 -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 +165 -14
- 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 -28
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- 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,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
|
data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb
RENAMED
|
@@ -4,7 +4,7 @@ require "base64"
|
|
|
4
4
|
|
|
5
5
|
module LlmGateway
|
|
6
6
|
module Adapters
|
|
7
|
-
module
|
|
7
|
+
module OpenAI
|
|
8
8
|
module ChatCompletions
|
|
9
9
|
class BidirectionalMessageMapper
|
|
10
10
|
attr_reader :direction
|
|
@@ -90,10 +90,17 @@ module LlmGateway
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
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
|
+
|
|
93
100
|
{
|
|
94
101
|
role: "tool",
|
|
95
102
|
tool_call_id: content[:tool_use_id],
|
|
96
|
-
content:
|
|
103
|
+
content: mapped_content
|
|
97
104
|
}
|
|
98
105
|
end
|
|
99
106
|
end
|
|
@@ -5,13 +5,12 @@ require_relative "bidirectional_message_mapper"
|
|
|
5
5
|
|
|
6
6
|
module LlmGateway
|
|
7
7
|
module Adapters
|
|
8
|
-
module
|
|
8
|
+
module OpenAI
|
|
9
9
|
module ChatCompletions
|
|
10
10
|
class InputMapper
|
|
11
11
|
def self.map(data)
|
|
12
12
|
{
|
|
13
13
|
messages: map_messages(data[:messages]),
|
|
14
|
-
response_format: map_response_format(data[:response_format]),
|
|
15
14
|
tools: map_tools(data[:tools]),
|
|
16
15
|
system: map_system(data[:system])
|
|
17
16
|
}
|
|
@@ -19,10 +18,6 @@ module LlmGateway
|
|
|
19
18
|
|
|
20
19
|
private
|
|
21
20
|
|
|
22
|
-
def self.map_response_format(response_format)
|
|
23
|
-
response_format
|
|
24
|
-
end
|
|
25
|
-
|
|
26
21
|
def self.map_messages(messages)
|
|
27
22
|
return messages unless messages
|
|
28
23
|
|
|
@@ -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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenAI
|
|
6
|
+
module ChatCompletions
|
|
7
|
+
module OptionMapper
|
|
8
|
+
include LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
|
|
9
|
+
|
|
10
|
+
VALID_REASONING_LEVELS = %w[low medium high xhigh].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def map(options)
|
|
15
|
+
mapped_options = options.dup
|
|
16
|
+
mapped_options[:max_completion_tokens] ||= 20_480
|
|
17
|
+
|
|
18
|
+
map_cache_key!(mapped_options)
|
|
19
|
+
map_prompt_cache_retention!(mapped_options)
|
|
20
|
+
|
|
21
|
+
return mapped_options unless mapped_options.key?(:reasoning)
|
|
22
|
+
|
|
23
|
+
reasoning = mapped_options.delete(:reasoning)
|
|
24
|
+
return mapped_options if reasoning.nil? || reasoning.to_s == "none"
|
|
25
|
+
|
|
26
|
+
mapped_options.merge(reasoning_effort: normalize_reasoning_effort(reasoning))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def normalize_reasoning_effort(reasoning)
|
|
30
|
+
effort = reasoning.to_s
|
|
31
|
+
return effort if VALID_REASONING_LEVELS.include?(effort)
|
|
32
|
+
|
|
33
|
+
raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../structs"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Adapters
|
|
7
|
+
module OpenAI
|
|
8
|
+
module ChatCompletions
|
|
9
|
+
class StreamMapper
|
|
10
|
+
def map(chunk)
|
|
11
|
+
queued_event = shift_queued_event
|
|
12
|
+
return queued_event if queued_event
|
|
13
|
+
|
|
14
|
+
data = chunk[:data] || {}
|
|
15
|
+
raise_stream_error!(data) if chunk[:event] == "error" || data[:error] || data[:type] == "error"
|
|
16
|
+
|
|
17
|
+
choices = data[:choices] || []
|
|
18
|
+
|
|
19
|
+
if choices.empty?
|
|
20
|
+
return message_event(
|
|
21
|
+
delta: pending_finish_delta,
|
|
22
|
+
usage_increment: usage_increment(data)
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
choice = choices.first || {}
|
|
27
|
+
delta = choice[:delta] || {}
|
|
28
|
+
finish_reason = choice[:finish_reason]
|
|
29
|
+
|
|
30
|
+
event = map_choice_delta(data, choice, delta)
|
|
31
|
+
return event if event
|
|
32
|
+
|
|
33
|
+
return finish_event_for(finish_reason) if finish_reason
|
|
34
|
+
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def map_choice_delta(data, choice, delta)
|
|
41
|
+
if !message_started? && delta[:tool_calls]&.any?
|
|
42
|
+
@message_started = true
|
|
43
|
+
stash_message_attributes(data, delta)
|
|
44
|
+
return tool_event(delta[:tool_calls].first)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if !message_started? && (delta.key?(:role) || data[:id] || data[:model])
|
|
48
|
+
@message_started = true
|
|
49
|
+
return AssistantStreamMessageEvent.new(
|
|
50
|
+
type: :message_start,
|
|
51
|
+
delta: {
|
|
52
|
+
id: data[:id],
|
|
53
|
+
model: data[:model],
|
|
54
|
+
role: delta[:role]
|
|
55
|
+
}.compact,
|
|
56
|
+
usage_increment: {}
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if (content = delta[:content]) && !content.empty?
|
|
61
|
+
return text_event(content, choice[:index] || 0)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
return tool_event(delta[:tool_calls].first) if delta[:tool_calls]&.any?
|
|
65
|
+
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def finish_event_for(finish_reason)
|
|
70
|
+
normalized = normalize_stop_reason(finish_reason)
|
|
71
|
+
stash_pending_finish_delta(stop_reason: normalized)
|
|
72
|
+
|
|
73
|
+
case normalized
|
|
74
|
+
when "tool_use"
|
|
75
|
+
AssistantStreamEvent.new(type: :tool_end, content_index: last_started_tool_index || 0, delta: "")
|
|
76
|
+
else
|
|
77
|
+
AssistantStreamEvent.new(type: :text_end, content_index: last_started_text_index || 0, delta: "")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def message_event(delta:, usage_increment: {})
|
|
82
|
+
AssistantStreamMessageEvent.new(
|
|
83
|
+
type: pending_message_attributes.empty? ? :message_delta : :message_start,
|
|
84
|
+
delta: pending_message_attributes.merge(delta),
|
|
85
|
+
usage_increment:
|
|
86
|
+
).tap do
|
|
87
|
+
clear_pending_message_attributes
|
|
88
|
+
clear_pending_finish_delta
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def usage_increment(data)
|
|
93
|
+
usage = data[:usage] || {}
|
|
94
|
+
|
|
95
|
+
{
|
|
96
|
+
input_tokens: usage[:prompt_tokens] || 0,
|
|
97
|
+
cache_creation_input_tokens: 0,
|
|
98
|
+
cache_read_input_tokens: usage.dig(:prompt_tokens_details, :cached_tokens) || 0,
|
|
99
|
+
output_tokens: usage[:completion_tokens] || 0,
|
|
100
|
+
reasoning_tokens: usage.dig(:completion_tokens_details, :reasoning_tokens) || 0
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def text_event(content, content_index)
|
|
105
|
+
@last_started_text_index = content_index
|
|
106
|
+
|
|
107
|
+
if started_text_blocks.include?(content_index)
|
|
108
|
+
AssistantStreamEvent.new(type: :text_delta, content_index:, delta: content)
|
|
109
|
+
else
|
|
110
|
+
started_text_blocks << content_index
|
|
111
|
+
AssistantStreamEvent.new(type: :text_start, content_index:, delta: content)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def tool_event(tool_call)
|
|
116
|
+
tool_index = tool_call[:index] || 0
|
|
117
|
+
@last_started_tool_index = tool_index
|
|
118
|
+
function = tool_call[:function] || {}
|
|
119
|
+
arguments = function[:arguments] || ""
|
|
120
|
+
|
|
121
|
+
unless started_tool_blocks.include?(tool_index)
|
|
122
|
+
pending_tool_calls[tool_index] = merge_tool_call(pending_tool_calls[tool_index], tool_call)
|
|
123
|
+
pending = pending_tool_calls[tool_index]
|
|
124
|
+
|
|
125
|
+
return nil unless pending[:id] && pending.dig(:function, :name)
|
|
126
|
+
|
|
127
|
+
started_tool_blocks << tool_index
|
|
128
|
+
return AssistantToolStartEvent.new(
|
|
129
|
+
type: :tool_start,
|
|
130
|
+
content_index: tool_index,
|
|
131
|
+
delta: "",
|
|
132
|
+
id: pending[:id],
|
|
133
|
+
name: pending.dig(:function, :name)
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
AssistantStreamEvent.new(type: :tool_delta, content_index: tool_index, delta: arguments)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stash_message_attributes(data, delta)
|
|
141
|
+
@pending_message_attributes = {
|
|
142
|
+
id: data[:id],
|
|
143
|
+
model: data[:model],
|
|
144
|
+
role: delta[:role]
|
|
145
|
+
}.compact
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def pending_message_attributes
|
|
149
|
+
@pending_message_attributes ||= {}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def clear_pending_message_attributes
|
|
153
|
+
@pending_message_attributes = {}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def stash_pending_finish_delta(delta)
|
|
157
|
+
@pending_finish_delta = pending_finish_delta.merge(delta)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def pending_finish_delta
|
|
161
|
+
@pending_finish_delta ||= {}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def clear_pending_finish_delta
|
|
165
|
+
@pending_finish_delta = {}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def merge_tool_call(existing, incoming)
|
|
169
|
+
existing ||= {}
|
|
170
|
+
incoming ||= {}
|
|
171
|
+
|
|
172
|
+
existing_function = existing[:function] || {}
|
|
173
|
+
incoming_function = incoming[:function] || {}
|
|
174
|
+
|
|
175
|
+
{
|
|
176
|
+
index: incoming[:index] || existing[:index],
|
|
177
|
+
id: incoming[:id] || existing[:id],
|
|
178
|
+
type: incoming[:type] || existing[:type],
|
|
179
|
+
function: {
|
|
180
|
+
name: incoming_function[:name] || existing_function[:name],
|
|
181
|
+
arguments: "#{existing_function[:arguments]}#{incoming_function[:arguments]}"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def normalize_stop_reason(finish_reason)
|
|
187
|
+
case finish_reason
|
|
188
|
+
when "tool_calls"
|
|
189
|
+
"tool_use"
|
|
190
|
+
else
|
|
191
|
+
finish_reason
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def message_started?
|
|
196
|
+
@message_started ||= false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def started_text_blocks
|
|
200
|
+
@started_text_blocks ||= []
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def started_tool_blocks
|
|
204
|
+
@started_tool_blocks ||= []
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def pending_tool_calls
|
|
208
|
+
@pending_tool_calls ||= {}
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def last_started_text_index
|
|
212
|
+
@last_started_text_index
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def last_started_tool_index
|
|
216
|
+
@last_started_tool_index
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def shift_queued_event
|
|
220
|
+
queued_events.shift
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def queued_events
|
|
224
|
+
@queued_events ||= []
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def raise_stream_error!(data)
|
|
228
|
+
error = data[:error].is_a?(Hash) ? data[:error] : data
|
|
229
|
+
message = error[:message] || "Stream error"
|
|
230
|
+
code = error[:code] || error[:type]
|
|
231
|
+
|
|
232
|
+
if LlmGateway::Errors.context_overflow_message?(message)
|
|
233
|
+
raise LlmGateway::Errors::PromptTooLong.new(message, code)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
raise LlmGateway::Errors::APIStatusError.new(message, code)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../adapter"
|
|
4
|
+
require_relative "acts_like_chat_completions"
|
|
5
|
+
require_relative "chat_completions/input_mapper"
|
|
6
|
+
require_relative "chat_completions/input_message_sanitizer"
|
|
7
|
+
require_relative "chat_completions/output_mapper"
|
|
8
|
+
require_relative "chat_completions/option_mapper"
|
|
9
|
+
require_relative "file_output_mapper"
|
|
10
|
+
require_relative "chat_completions/stream_mapper"
|
|
11
|
+
|
|
12
|
+
module LlmGateway
|
|
13
|
+
module Adapters
|
|
14
|
+
module OpenAI
|
|
15
|
+
class ChatCompletionsAdapter < Adapter
|
|
16
|
+
include ActsLikeOpenAIChatCompletions
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenAI
|
|
6
|
+
module PromptCacheOptionMapper
|
|
7
|
+
def self.included(base)
|
|
8
|
+
base.extend(self)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def map_cache_key!(mapped_options)
|
|
12
|
+
cache_key = mapped_options.delete(:cache_key)
|
|
13
|
+
mapped_options.delete(:prompt_cache_key)
|
|
14
|
+
mapped_options[:prompt_cache_key] = cache_key unless cache_key.nil?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def map_prompt_cache_retention!(mapped_options)
|
|
18
|
+
retention = mapped_options.delete(:cache_retention)
|
|
19
|
+
mapped_options.delete(:prompt_cache_retention)
|
|
20
|
+
retention ||= "short" if mapped_options.key?(:prompt_cache_key)
|
|
21
|
+
|
|
22
|
+
case retention&.to_s
|
|
23
|
+
when nil
|
|
24
|
+
nil
|
|
25
|
+
when "short"
|
|
26
|
+
mapped_options[:prompt_cache_retention] = "in_memory"
|
|
27
|
+
when "long"
|
|
28
|
+
mapped_options[:prompt_cache_retention] = "24h"
|
|
29
|
+
when "none"
|
|
30
|
+
mapped_options.delete(:prompt_cache_key)
|
|
31
|
+
else
|
|
32
|
+
raise ArgumentError,
|
|
33
|
+
"Invalid cache_retention '#{retention}'. Use 'short', 'long', or 'none'."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|