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,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,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenAI
|
|
6
|
+
module ChatCompletions
|
|
7
|
+
class OutputMapper
|
|
8
|
+
def self.map(data)
|
|
9
|
+
{
|
|
10
|
+
id: data[:id],
|
|
11
|
+
model: data[:model],
|
|
12
|
+
usage: data[:usage],
|
|
13
|
+
choices: map_choices(data[:choices])
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def self.map_choices(choices)
|
|
20
|
+
return [] unless choices
|
|
21
|
+
message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_OUT)
|
|
22
|
+
|
|
23
|
+
choices.map do |choice|
|
|
24
|
+
message = choice[:message] || {}
|
|
25
|
+
content_item = message_mapper.map_content(message[:content])
|
|
26
|
+
tool_calls = message[:tool_calls] ? message[:tool_calls].map { |tool_call| message_mapper.map_content(tool_call) } : []
|
|
27
|
+
|
|
28
|
+
# Only include content_item if it has actual text content
|
|
29
|
+
content_array = []
|
|
30
|
+
content_array << content_item if LlmGateway::Utils.present?(content_item[:text])
|
|
31
|
+
content_array += tool_calls
|
|
32
|
+
|
|
33
|
+
{ role: message[:role], content: content_array }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
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,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenAI
|
|
6
|
+
class FileOutputMapper
|
|
7
|
+
def self.map(data)
|
|
8
|
+
bytes = data.delete(:bytes)
|
|
9
|
+
data.delete(:object) # Didnt see much value in this only option is "file"
|
|
10
|
+
data.delete(:status) # Deprecated so no need to pull through
|
|
11
|
+
data.delete(:status_details) # Deprecated so no need to pull through
|
|
12
|
+
created_at = data.delete(:created_at)
|
|
13
|
+
time = Time.at(created_at, in: "UTC")
|
|
14
|
+
iso_format = time.iso8601(6)
|
|
15
|
+
data.merge(
|
|
16
|
+
size_bytes: bytes,
|
|
17
|
+
downloadable: data[:purpose] != "user_data",
|
|
18
|
+
mime_type: nil,
|
|
19
|
+
created_at: iso_format # Claude api format, easier for human reading so kept it this way
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
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
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Adapters
|
|
7
|
+
module OpenAI
|
|
8
|
+
module Responses
|
|
9
|
+
class BidirectionalMessageMapper < OpenAI::ChatCompletions::BidirectionalMessageMapper
|
|
10
|
+
def map_content(content)
|
|
11
|
+
# Convert string content to text format
|
|
12
|
+
#
|
|
13
|
+
|
|
14
|
+
content = { type: "text", text: content } unless content.is_a?(Hash)
|
|
15
|
+
case content[:type]
|
|
16
|
+
when "text"
|
|
17
|
+
map_text_content(content)
|
|
18
|
+
when "image"
|
|
19
|
+
map_image_content(content)
|
|
20
|
+
when "message"
|
|
21
|
+
map_messages(content)
|
|
22
|
+
when "output_text"
|
|
23
|
+
map_output_text_content(content)
|
|
24
|
+
when "tool_use"
|
|
25
|
+
map_tool_use_content(content)
|
|
26
|
+
when "function_call"
|
|
27
|
+
map_tool_use_content(content)
|
|
28
|
+
when "tool_result"
|
|
29
|
+
map_tool_result_content(content)
|
|
30
|
+
when "reasoning"
|
|
31
|
+
map_reasoning_content(content)
|
|
32
|
+
else
|
|
33
|
+
content
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def map_messages(message)
|
|
40
|
+
message[:content].map { |content| map_content(content) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def map_tool_result_content(content)
|
|
44
|
+
output = content[:content]
|
|
45
|
+
if output.is_a?(Array)
|
|
46
|
+
output = output.map do |item|
|
|
47
|
+
if item.is_a?(Hash)
|
|
48
|
+
map_content(item.transform_keys(&:to_sym))
|
|
49
|
+
else
|
|
50
|
+
item
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
"type": "function_call_output",
|
|
57
|
+
"call_id": content[:tool_use_id],
|
|
58
|
+
"output": output
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def map_tool_use_content(content)
|
|
63
|
+
if direction == LlmGateway::DIRECTION_OUT
|
|
64
|
+
{ id: content[:call_id], type: "tool_use", name: content[:name], input: parse_tool_arguments(content[:arguments]) }
|
|
65
|
+
else
|
|
66
|
+
{ id: content[:id] }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def map_output_text_content(content)
|
|
71
|
+
{
|
|
72
|
+
type: direction == LlmGateway::DIRECTION_IN ? "input_text" : "text",
|
|
73
|
+
text: content[:text]
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def map_reasoning_content(content)
|
|
78
|
+
if direction == LlmGateway::DIRECTION_IN
|
|
79
|
+
return { id: content[:id] } if content[:id]
|
|
80
|
+
|
|
81
|
+
content
|
|
82
|
+
else
|
|
83
|
+
{
|
|
84
|
+
type: "reasoning",
|
|
85
|
+
reasoning: normalize_reasoning_text(content[:summary]),
|
|
86
|
+
signature: content[:signature]
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def map_image_content(content)
|
|
92
|
+
{
|
|
93
|
+
type: "input_image",
|
|
94
|
+
image_url: "data:#{content[:media_type]};base64,#{content[:data]}"
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def map_text_content(content)
|
|
99
|
+
{
|
|
100
|
+
type: "input_text",
|
|
101
|
+
text: content[:text]
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize_reasoning_text(summary)
|
|
106
|
+
return summary if summary.is_a?(String)
|
|
107
|
+
return nil unless summary.is_a?(Array)
|
|
108
|
+
return nil if summary.empty?
|
|
109
|
+
|
|
110
|
+
summary.filter_map do |item|
|
|
111
|
+
next item if item.is_a?(String)
|
|
112
|
+
|
|
113
|
+
item[:text] || item[:summary_text] || item[:reasoning]
|
|
114
|
+
end.join("\n")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
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 Responses
|
|
10
|
+
class InputMapper < OpenAI::ChatCompletions::InputMapper
|
|
11
|
+
def self.message_mapper
|
|
12
|
+
BidirectionalMessageMapper.new(LlmGateway::DIRECTION_IN)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.map_tools(tools)
|
|
16
|
+
return tools unless tools
|
|
17
|
+
mapper = message_mapper
|
|
18
|
+
|
|
19
|
+
tools.map do |tool|
|
|
20
|
+
mapped_tool = {
|
|
21
|
+
type: "function",
|
|
22
|
+
name: tool[:name],
|
|
23
|
+
description: tool[:description],
|
|
24
|
+
parameters: tool[:input_schema]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[ :contents, :content ].each do |key|
|
|
28
|
+
next unless tool[key].is_a?(Array)
|
|
29
|
+
|
|
30
|
+
mapped_tool[key] = tool[key].map do |entry|
|
|
31
|
+
entry.is_a?(Hash) ? mapper.map_content(entry.transform_keys(&:to_sym)) : entry
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
mapped_tool
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.map_messages(messages)
|
|
40
|
+
return messages unless messages
|
|
41
|
+
mapper = message_mapper
|
|
42
|
+
|
|
43
|
+
messages.flat_map do |msg|
|
|
44
|
+
if msg[:id] && msg[:content].is_a?(Array)
|
|
45
|
+
# Full AssistantMessage#to_h — expand content for stateless multi-turn
|
|
46
|
+
map_assistant_history_message(msg)
|
|
47
|
+
elsif msg[:id]
|
|
48
|
+
# Bare item-reference (e.g. manually constructed { id: "item_xxx" })
|
|
49
|
+
msg.slice(:id)
|
|
50
|
+
else
|
|
51
|
+
content = if msg[:content].is_a?(Array)
|
|
52
|
+
msg[:content].map do |content|
|
|
53
|
+
mapper.map_content(content)
|
|
54
|
+
end
|
|
55
|
+
else
|
|
56
|
+
[ mapper.map_content(msg[:content]) ]
|
|
57
|
+
end
|
|
58
|
+
if msg.dig(:content).is_a?(Array) && msg.dig(:content, 0, :type) == "tool_result"
|
|
59
|
+
content
|
|
60
|
+
else
|
|
61
|
+
{
|
|
62
|
+
role: msg[:role],
|
|
63
|
+
content: content
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Map a full AssistantMessage#to_h into Responses API input items for
|
|
71
|
+
# stateless multi-turn conversations.
|
|
72
|
+
#
|
|
73
|
+
# text blocks → { role: "assistant", content: [{ type: "output_text", ... }] }
|
|
74
|
+
# tool_use blocks → top-level function_call items
|
|
75
|
+
# thinking blocks → omitted (model handles reasoning internally)
|
|
76
|
+
def self.map_assistant_history_message(msg)
|
|
77
|
+
blocks = (msg[:content] || []).map { |b| b.transform_keys(&:to_sym) }
|
|
78
|
+
|
|
79
|
+
text_blocks = blocks.select { |b| b[:type] == "text" }
|
|
80
|
+
tool_use_blocks = blocks.select { |b| b[:type] == "tool_use" }
|
|
81
|
+
|
|
82
|
+
result = []
|
|
83
|
+
|
|
84
|
+
if text_blocks.any?
|
|
85
|
+
result << {
|
|
86
|
+
role: "assistant",
|
|
87
|
+
content: text_blocks.map { |b| { type: "output_text", text: b[:text] } }
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
tool_use_blocks.each do |b|
|
|
92
|
+
result << {
|
|
93
|
+
type: "function_call",
|
|
94
|
+
call_id: b[:id],
|
|
95
|
+
name: b[:name],
|
|
96
|
+
arguments: b[:input].is_a?(Hash) ? b[:input].to_json : (b[:input] || {}).to_json
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LlmGateway
|
|
4
|
+
module Adapters
|
|
5
|
+
module OpenAI
|
|
6
|
+
module Responses
|
|
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
|
+
|
|
17
|
+
max_completion_tokens = mapped_options.delete(:max_completion_tokens)
|
|
18
|
+
mapped_options[:max_output_tokens] = max_completion_tokens || mapped_options[:max_output_tokens] || 20_480
|
|
19
|
+
|
|
20
|
+
map_cache_key!(mapped_options)
|
|
21
|
+
map_prompt_cache_retention!(mapped_options)
|
|
22
|
+
|
|
23
|
+
return mapped_options unless mapped_options.key?(:reasoning)
|
|
24
|
+
|
|
25
|
+
reasoning = mapped_options.delete(:reasoning)
|
|
26
|
+
return mapped_options if reasoning.nil? || reasoning.to_s == "none"
|
|
27
|
+
|
|
28
|
+
mapped_options.merge(reasoning: normalize_reasoning(reasoning))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def normalize_reasoning(reasoning)
|
|
32
|
+
effort = reasoning.to_s
|
|
33
|
+
return { effort: effort, summary: "detailed" } if VALID_REASONING_LEVELS.include?(effort)
|
|
34
|
+
|
|
35
|
+
raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|