llm_gateway 0.3.0 → 0.5.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/.pi/skills/live-provider-testing/SKILL.md +183 -0
- data/.pi/skills/options-development/SKILL.md +131 -0
- data/CHANGELOG.md +43 -0
- data/README.md +559 -185
- data/Rakefile +2 -2
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +140 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -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 +129 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -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/openai/responses/input_mapper.rb +166 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -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 +33 -0
- data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
- data/lib/llm_gateway/adapters/stream_mapper.rb +50 -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 +18 -158
- 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 +66 -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 +162 -17
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- metadata +60 -27
- data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
- 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/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
- data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
- data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
- 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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Adapters
|
|
7
|
+
module OpenAI
|
|
8
|
+
module ChatCompletions
|
|
9
|
+
class InputMapper
|
|
10
|
+
def self.map(data)
|
|
11
|
+
{
|
|
12
|
+
messages: map_messages(data[:messages]),
|
|
13
|
+
tools: map_tools(data[:tools]),
|
|
14
|
+
system: map_system(data[:system])
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.map_content(content)
|
|
19
|
+
content = { type: "text", text: content } unless content.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
case content[:type]
|
|
22
|
+
when "text"
|
|
23
|
+
map_text_content(content)
|
|
24
|
+
when "file"
|
|
25
|
+
map_file_content(content)
|
|
26
|
+
when "image"
|
|
27
|
+
map_image_content(content)
|
|
28
|
+
when "tool_use", "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
|
+
class << self
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def map_messages(messages)
|
|
41
|
+
return messages unless messages
|
|
42
|
+
|
|
43
|
+
mapped_messages = messages.map do |msg|
|
|
44
|
+
msg = msg.merge(role: "user") if msg[:role] == "developer"
|
|
45
|
+
|
|
46
|
+
content = if msg[:content].is_a?(Array)
|
|
47
|
+
msg[:content].map { |content| map_content(content) }
|
|
48
|
+
else
|
|
49
|
+
[ map_content(msg[:content]) ]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
role: msg[:role],
|
|
54
|
+
content: content
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
mapped_messages.flat_map do |msg|
|
|
59
|
+
tool_calls = []
|
|
60
|
+
regular_content = []
|
|
61
|
+
tool_messages = []
|
|
62
|
+
msg[:content].each do |content|
|
|
63
|
+
case content[:type] || content[:role]
|
|
64
|
+
when "tool"
|
|
65
|
+
tool_messages << content
|
|
66
|
+
when "function"
|
|
67
|
+
tool_calls << content
|
|
68
|
+
else
|
|
69
|
+
regular_content << content
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
result = []
|
|
73
|
+
|
|
74
|
+
if tool_calls.any? || regular_content.any?
|
|
75
|
+
main_msg = msg.dup
|
|
76
|
+
main_msg[:role] = "assistant" if !main_msg[:role]
|
|
77
|
+
main_msg[:tool_calls] = tool_calls if tool_calls.any?
|
|
78
|
+
main_msg[:content] = regular_content.any? ? regular_content : nil
|
|
79
|
+
result << main_msg
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
result + tool_messages
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def map_tools(tools)
|
|
87
|
+
return tools unless tools
|
|
88
|
+
|
|
89
|
+
tools.map do |tool|
|
|
90
|
+
{
|
|
91
|
+
type: "function",
|
|
92
|
+
function: {
|
|
93
|
+
name: tool[:name],
|
|
94
|
+
description: tool[:description],
|
|
95
|
+
parameters: tool[:input_schema]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def map_system(system)
|
|
102
|
+
if !system || system.empty?
|
|
103
|
+
[]
|
|
104
|
+
else
|
|
105
|
+
system.map do |msg|
|
|
106
|
+
msg[:role] == "system" ? msg.merge(role: "developer") : msg
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def map_text_content(content)
|
|
112
|
+
{
|
|
113
|
+
type: "text",
|
|
114
|
+
text: content[:text]
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def map_file_content(content)
|
|
119
|
+
media_type = content[:media_type] == "text/plain" ? "application/pdf" : content[:media_type]
|
|
120
|
+
{
|
|
121
|
+
type: "file",
|
|
122
|
+
file: {
|
|
123
|
+
filename: content[:name],
|
|
124
|
+
file_data: "data:#{media_type};base64,#{Base64.encode64(content[:data])}"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def map_image_content(content)
|
|
130
|
+
{
|
|
131
|
+
type: "image_url",
|
|
132
|
+
image_url: {
|
|
133
|
+
url: "data:#{content[:media_type]};base64,#{content[:data]}"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def map_tool_use_content(content)
|
|
139
|
+
{
|
|
140
|
+
id: content[:id],
|
|
141
|
+
type: "function",
|
|
142
|
+
function: {
|
|
143
|
+
name: content[:name],
|
|
144
|
+
arguments: content[:input].to_json
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def map_tool_result_content(content)
|
|
150
|
+
mapped_content = content[:content]
|
|
151
|
+
if mapped_content.is_a?(Array)
|
|
152
|
+
mapped_content = mapped_content.map do |item|
|
|
153
|
+
item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
{
|
|
158
|
+
role: "tool",
|
|
159
|
+
tool_call_id: content[:tool_use_id],
|
|
160
|
+
content: mapped_content
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
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
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenAI
|
|
6
|
+
module ChatCompletions
|
|
7
|
+
module OptionMapper
|
|
8
|
+
DEFAULT_MAX_COMPLETION_TOKENS = 20_480
|
|
9
|
+
VALID_REASONING_LEVELS = %w[low medium high xhigh].freeze
|
|
10
|
+
|
|
11
|
+
# Source: https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create/index.md
|
|
12
|
+
# API: OpenAI Chat Completions Create; accessed 2026-05-18.
|
|
13
|
+
# Body parameters listed by the API reference: messages, model, audio,
|
|
14
|
+
# frequency_penalty, function_call, functions, logit_bias, logprobs,
|
|
15
|
+
# max_completion_tokens, max_tokens, metadata, modalities, n,
|
|
16
|
+
# parallel_tool_calls, prediction, presence_penalty, prompt_cache_key,
|
|
17
|
+
# prompt_cache_retention, reasoning_effort, response_format,
|
|
18
|
+
# safety_identifier, seed, service_tier, stop, store, stream,
|
|
19
|
+
# stream_options, temperature, tool_choice, tools, top_logprobs, top_p,
|
|
20
|
+
# user, verbosity, web_search_options.
|
|
21
|
+
# This mapper intentionally excludes transcript/tool structural fields
|
|
22
|
+
# (messages, tools) from option handling.
|
|
23
|
+
|
|
24
|
+
VALID_OPTIONS = %i[
|
|
25
|
+
model
|
|
26
|
+
audio
|
|
27
|
+
frequency_penalty
|
|
28
|
+
function_call
|
|
29
|
+
functions
|
|
30
|
+
logit_bias
|
|
31
|
+
logprobs
|
|
32
|
+
max_completion_tokens
|
|
33
|
+
max_tokens
|
|
34
|
+
metadata
|
|
35
|
+
modalities
|
|
36
|
+
n
|
|
37
|
+
parallel_tool_calls
|
|
38
|
+
prediction
|
|
39
|
+
presence_penalty
|
|
40
|
+
prompt_cache_key
|
|
41
|
+
prompt_cache_retention
|
|
42
|
+
reasoning_effort
|
|
43
|
+
response_format
|
|
44
|
+
safety_identifier
|
|
45
|
+
seed
|
|
46
|
+
service_tier
|
|
47
|
+
stop
|
|
48
|
+
store
|
|
49
|
+
stream
|
|
50
|
+
stream_options
|
|
51
|
+
temperature
|
|
52
|
+
tool_choice
|
|
53
|
+
top_logprobs
|
|
54
|
+
top_p
|
|
55
|
+
user
|
|
56
|
+
verbosity
|
|
57
|
+
web_search_options
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
MANAGED_OPTIONS = %i[
|
|
61
|
+
reasoning
|
|
62
|
+
cache_key
|
|
63
|
+
cache_retention
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
def map(options)
|
|
69
|
+
mapped_options = options.reject { |key, _| MANAGED_OPTIONS.include?(key) }
|
|
70
|
+
mapped_options[:max_completion_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_COMPLETION_TOKENS
|
|
71
|
+
|
|
72
|
+
cache_key = options[:cache_key]
|
|
73
|
+
mapped_options[:prompt_cache_key] = cache_key unless cache_key.nil?
|
|
74
|
+
|
|
75
|
+
cache_retention = options[:cache_retention]
|
|
76
|
+
mapped_options[:prompt_cache_retention] = normalize_cache_retention(cache_retention) \
|
|
77
|
+
unless cache_retention.nil?
|
|
78
|
+
|
|
79
|
+
if mapped_options[:prompt_cache_key] && !mapped_options[:prompt_cache_retention]
|
|
80
|
+
mapped_options[:prompt_cache_retention] = normalize_cache_retention("short")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
if cache_retention.to_s == "none"
|
|
84
|
+
mapped_options.delete(:prompt_cache_key)
|
|
85
|
+
mapped_options.delete(:prompt_cache_retention)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
reasoning = options[:reasoning]
|
|
89
|
+
mapped_options[:reasoning_effort] = normalize_reasoning_effort(reasoning) \
|
|
90
|
+
unless reasoning.nil? || reasoning.to_s == "none"
|
|
91
|
+
|
|
92
|
+
validate_options!(mapped_options)
|
|
93
|
+
mapped_options
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_options!(mapped_options)
|
|
97
|
+
unknown_options = mapped_options.keys - VALID_OPTIONS
|
|
98
|
+
return if unknown_options.empty?
|
|
99
|
+
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"Unknown OpenAI Chat Completions options: #{unknown_options.join(', ')}. " \
|
|
102
|
+
"Valid options: #{VALID_OPTIONS.join(', ')}."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize_cache_retention(cache_retention)
|
|
106
|
+
case cache_retention.to_s
|
|
107
|
+
when "short"
|
|
108
|
+
"in_memory"
|
|
109
|
+
when "long"
|
|
110
|
+
"24h"
|
|
111
|
+
when "none"
|
|
112
|
+
nil
|
|
113
|
+
else
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"Invalid cache_retention '#{cache_retention}'. Use 'short', 'long', or 'none'."
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def normalize_reasoning_effort(reasoning)
|
|
120
|
+
effort = reasoning.to_s
|
|
121
|
+
return effort if VALID_REASONING_LEVELS.include?(effort)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../stream_mapper"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Adapters
|
|
7
|
+
module OpenAI
|
|
8
|
+
module ChatCompletions
|
|
9
|
+
class StreamMapper < LlmGateway::Adapters::StreamMapper
|
|
10
|
+
def map(chunk, &block)
|
|
11
|
+
data = chunk[:data] || {}
|
|
12
|
+
raise_stream_error!(data) if chunk[:event] == "error" || data[:error] || data[:type] == "error"
|
|
13
|
+
|
|
14
|
+
push_patches(patches_for(data), &block)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def patches_for(data)
|
|
20
|
+
choices = data[:choices] || []
|
|
21
|
+
return final_usage_patches(data) if choices.empty?
|
|
22
|
+
|
|
23
|
+
choice = choices.first || {}
|
|
24
|
+
delta = choice[:delta] || {}
|
|
25
|
+
patches = []
|
|
26
|
+
active_block_type = accumulator.active_block_type
|
|
27
|
+
active_tool = active_tool_block
|
|
28
|
+
|
|
29
|
+
append_patches(patches, message_start_patches(data, delta))
|
|
30
|
+
|
|
31
|
+
active_block_type, active_tool = append_patches(
|
|
32
|
+
patches,
|
|
33
|
+
reasoning_patches(delta[:reasoning], active_block_type:),
|
|
34
|
+
active_block_type,
|
|
35
|
+
active_tool
|
|
36
|
+
)
|
|
37
|
+
active_block_type, active_tool = append_patches(
|
|
38
|
+
patches,
|
|
39
|
+
text_patches(delta[:content], active_block_type:),
|
|
40
|
+
active_block_type,
|
|
41
|
+
active_tool
|
|
42
|
+
)
|
|
43
|
+
delta.fetch(:tool_calls, []).each do |tool_call|
|
|
44
|
+
active_block_type, active_tool = append_patches(
|
|
45
|
+
patches,
|
|
46
|
+
patches_for_tool_call(tool_call, active_block_type:, active_tool:),
|
|
47
|
+
active_block_type,
|
|
48
|
+
active_tool
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
append_patches(patches, finish_patches(choice[:finish_reason], active_block_type:))
|
|
52
|
+
|
|
53
|
+
patches
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def append_patches(patches, new_patches, active_block_type = nil, active_tool = nil)
|
|
57
|
+
patches.concat(new_patches)
|
|
58
|
+
|
|
59
|
+
new_patches.each do |patch|
|
|
60
|
+
case patch[:type]
|
|
61
|
+
when :text_start
|
|
62
|
+
active_block_type = :text
|
|
63
|
+
active_tool = nil
|
|
64
|
+
when :reasoning_start
|
|
65
|
+
active_block_type = :reasoning
|
|
66
|
+
active_tool = nil
|
|
67
|
+
when :tool_start
|
|
68
|
+
active_block_type = :tool
|
|
69
|
+
active_tool = { id: patch[:id], name: patch[:name] }
|
|
70
|
+
when :text_end, :reasoning_end, :tool_end
|
|
71
|
+
active_block_type = nil
|
|
72
|
+
active_tool = nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
[ active_block_type, active_tool ]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def message_start_patches(data, delta)
|
|
80
|
+
return [] unless accumulator.message_hash.empty?
|
|
81
|
+
|
|
82
|
+
return [] unless delta.key?(:role) ||
|
|
83
|
+
data[:id] ||
|
|
84
|
+
data[:model] ||
|
|
85
|
+
delta[:content] ||
|
|
86
|
+
delta[:reasoning] ||
|
|
87
|
+
delta[:tool_calls]&.any?
|
|
88
|
+
|
|
89
|
+
[
|
|
90
|
+
{
|
|
91
|
+
type: :message_start,
|
|
92
|
+
delta: {
|
|
93
|
+
id: data[:id],
|
|
94
|
+
model: data[:model],
|
|
95
|
+
role: delta[:role] || "assistant"
|
|
96
|
+
}.compact,
|
|
97
|
+
usage_increment: {}
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Groq exposes OpenAI-compatible chat completion chunks, but may include
|
|
103
|
+
# `delta.reasoning` before normal `delta.content`.
|
|
104
|
+
def reasoning_patches(reasoning, active_block_type: accumulator.active_block_type)
|
|
105
|
+
return [] if reasoning.to_s.empty?
|
|
106
|
+
|
|
107
|
+
[
|
|
108
|
+
*close_active_non_reasoning_patches(active_block_type:),
|
|
109
|
+
{
|
|
110
|
+
type: active_block_type == :reasoning ? :reasoning_delta : :reasoning_start,
|
|
111
|
+
delta: reasoning,
|
|
112
|
+
signature: ""
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def text_patches(content, active_block_type: accumulator.active_block_type)
|
|
118
|
+
return [] if content.to_s.empty?
|
|
119
|
+
|
|
120
|
+
[
|
|
121
|
+
*close_active_non_text_patches(active_block_type:),
|
|
122
|
+
{
|
|
123
|
+
type: active_block_type == :text ? :text_delta : :text_start,
|
|
124
|
+
delta: content
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def patches_for_tool_call(tool_call, active_block_type: accumulator.active_block_type, active_tool: active_tool_block)
|
|
130
|
+
id = tool_call[:id]
|
|
131
|
+
name = tool_call.dig(:function, :name)
|
|
132
|
+
arguments = tool_call.dig(:function, :arguments).to_s
|
|
133
|
+
|
|
134
|
+
patches = []
|
|
135
|
+
|
|
136
|
+
if id || name
|
|
137
|
+
if active_block_type == :tool
|
|
138
|
+
patches.concat(close_active_block_patches(active_block_type:)) if new_active_tool?(id, name, active_tool:)
|
|
139
|
+
else
|
|
140
|
+
patches.concat(close_active_non_tool_patches(active_block_type:))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
unless active_block_type == :tool && patches.empty?
|
|
144
|
+
patches << {
|
|
145
|
+
type: :tool_start,
|
|
146
|
+
delta: "",
|
|
147
|
+
id: id,
|
|
148
|
+
name: name
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
patches << { type: :tool_delta, delta: arguments } unless arguments.empty?
|
|
154
|
+
patches
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def new_active_tool?(id, name, active_tool: active_tool_block)
|
|
158
|
+
return true unless active_tool
|
|
159
|
+
|
|
160
|
+
(id && active_tool[:id] != id) || (name && active_tool[:name] != name)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def active_tool_block
|
|
164
|
+
return nil unless accumulator.active_tool?
|
|
165
|
+
|
|
166
|
+
accumulator.blocks.reverse.find { |block| block&.fetch(:type, nil) == "tool_use" }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def close_active_block_patches(active_block_type: accumulator.active_block_type)
|
|
170
|
+
case active_block_type
|
|
171
|
+
when :text
|
|
172
|
+
[ { type: :text_end, delta: "" } ]
|
|
173
|
+
when :reasoning
|
|
174
|
+
[ { type: :reasoning_end, delta: "", signature: "" } ]
|
|
175
|
+
when :tool
|
|
176
|
+
[ { type: :tool_end, delta: "" } ]
|
|
177
|
+
else
|
|
178
|
+
[]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def close_active_non_text_patches(active_block_type: accumulator.active_block_type)
|
|
183
|
+
active_block_type == :text ? [] : close_active_block_patches(active_block_type:)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def close_active_non_reasoning_patches(active_block_type: accumulator.active_block_type)
|
|
187
|
+
active_block_type == :reasoning ? [] : close_active_block_patches(active_block_type:)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def close_active_non_tool_patches(active_block_type: accumulator.active_block_type)
|
|
191
|
+
active_block_type == :tool ? [] : close_active_block_patches(active_block_type:)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def finish_patches(finish_reason, active_block_type: accumulator.active_block_type)
|
|
195
|
+
return [] unless finish_reason
|
|
196
|
+
|
|
197
|
+
[
|
|
198
|
+
*close_active_block_patches(active_block_type:),
|
|
199
|
+
{
|
|
200
|
+
type: :message_delta,
|
|
201
|
+
delta: { stop_reason: normalize_stop_reason(finish_reason) },
|
|
202
|
+
usage_increment: {}
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def final_usage_patches(data)
|
|
208
|
+
[
|
|
209
|
+
{
|
|
210
|
+
type: accumulator.message_hash.empty? ? :message_start : :message_delta,
|
|
211
|
+
delta: {},
|
|
212
|
+
usage_increment: usage_increment(data)
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def usage_increment(data)
|
|
218
|
+
usage = data[:usage] || {}
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
input_tokens: usage[:prompt_tokens] || 0,
|
|
222
|
+
cache_creation_input_tokens: 0,
|
|
223
|
+
cache_read_input_tokens: usage.dig(:prompt_tokens_details, :cached_tokens) || 0,
|
|
224
|
+
output_tokens: usage[:completion_tokens] || 0,
|
|
225
|
+
reasoning_tokens: usage.dig(:completion_tokens_details, :reasoning_tokens) || 0
|
|
226
|
+
}
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def normalize_stop_reason(finish_reason)
|
|
230
|
+
case finish_reason
|
|
231
|
+
when "tool_calls"
|
|
232
|
+
"tool_use"
|
|
233
|
+
else
|
|
234
|
+
finish_reason
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
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/option_mapper"
|
|
8
|
+
require_relative "file_output_mapper"
|
|
9
|
+
require_relative "chat_completions/stream_mapper"
|
|
10
|
+
|
|
11
|
+
module LlmGateway
|
|
12
|
+
module Adapters
|
|
13
|
+
module OpenAI
|
|
14
|
+
class ChatCompletionsAdapter < Adapter
|
|
15
|
+
include ActsLikeOpenAIChatCompletions
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
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
|