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,47 @@
|
|
|
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 OutputMapper
|
|
11
|
+
def self.map(data)
|
|
12
|
+
{
|
|
13
|
+
id: data[:id],
|
|
14
|
+
model: data[:model],
|
|
15
|
+
usage: data[:usage],
|
|
16
|
+
choices: map_choices(data[:output])
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def self.map_choices(choices)
|
|
23
|
+
return [] unless choices
|
|
24
|
+
message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_OUT)
|
|
25
|
+
choices.map do |choice|
|
|
26
|
+
content = if choice[:id].start_with?("fc_")
|
|
27
|
+
{
|
|
28
|
+
id: choice[:id],
|
|
29
|
+
role: choice[:role] || "assistant", # tool call doesnt have a role apparently
|
|
30
|
+
content: [ message_mapper.map_content(choice) ].flatten
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
content = message_mapper.map_content(choice)
|
|
34
|
+
id = content.delete(:id)
|
|
35
|
+
{
|
|
36
|
+
id: choice[:id] || id,
|
|
37
|
+
role: choice[:role],
|
|
38
|
+
content: [ content ].flatten
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../structs"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Adapters
|
|
7
|
+
module OpenAI
|
|
8
|
+
module Responses
|
|
9
|
+
class StreamMapper
|
|
10
|
+
def map(chunk)
|
|
11
|
+
queued_event = shift_queued_event
|
|
12
|
+
return queued_event if queued_event
|
|
13
|
+
|
|
14
|
+
event_type = chunk[:event]
|
|
15
|
+
data = chunk[:data] || {}
|
|
16
|
+
raise_stream_error!(data) if event_type == "error" || data[:error] || data[:type] == "error"
|
|
17
|
+
|
|
18
|
+
case event_type
|
|
19
|
+
when "response.created"
|
|
20
|
+
stash_response(data[:response])
|
|
21
|
+
nil
|
|
22
|
+
when "response.output_item.added"
|
|
23
|
+
map_output_item_added(data)
|
|
24
|
+
when "response.output_item.done"
|
|
25
|
+
map_output_item_done(data)
|
|
26
|
+
when "response.content_part.added"
|
|
27
|
+
map_content_part_added(data)
|
|
28
|
+
when "response.content_part.done", "response.output_text.done"
|
|
29
|
+
map_text_done(data)
|
|
30
|
+
when "response.output_text.delta"
|
|
31
|
+
AssistantStreamEvent.new(
|
|
32
|
+
type: :text_delta,
|
|
33
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
34
|
+
delta: data[:delta] || ""
|
|
35
|
+
)
|
|
36
|
+
when "response.function_call_arguments.delta"
|
|
37
|
+
AssistantStreamEvent.new(
|
|
38
|
+
type: :tool_delta,
|
|
39
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
40
|
+
delta: data[:delta] || ""
|
|
41
|
+
)
|
|
42
|
+
when "response.function_call_arguments.done"
|
|
43
|
+
map_tool_done(data)
|
|
44
|
+
when "response.reasoning_summary_text.delta"
|
|
45
|
+
output_index = data[:output_index] || 0
|
|
46
|
+
mark_reasoning_has_content(output_index)
|
|
47
|
+
AssistantStreamReasoningEvent.new(
|
|
48
|
+
type: :reasoning_delta,
|
|
49
|
+
content_index: content_index_for(output_index),
|
|
50
|
+
delta: data[:delta] || "",
|
|
51
|
+
signature: ""
|
|
52
|
+
)
|
|
53
|
+
when "response.completed"
|
|
54
|
+
map_response_completed(data[:response])
|
|
55
|
+
else
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def map_output_item_added(data)
|
|
63
|
+
item = data[:item] || {}
|
|
64
|
+
output_index = data[:output_index] || 0
|
|
65
|
+
|
|
66
|
+
case item[:type]
|
|
67
|
+
when "reasoning"
|
|
68
|
+
mark_reasoning_started(output_index)
|
|
69
|
+
AssistantStreamReasoningEvent.new(
|
|
70
|
+
type: :reasoning_start,
|
|
71
|
+
content_index: register_content_index(output_index),
|
|
72
|
+
delta: "",
|
|
73
|
+
signature: ""
|
|
74
|
+
)
|
|
75
|
+
when "message"
|
|
76
|
+
register_content_index(output_index)
|
|
77
|
+
ensure_message_started(role: item[:role] || "assistant")
|
|
78
|
+
when "function_call"
|
|
79
|
+
stash_role("assistant")
|
|
80
|
+
mark_tool_started(output_index)
|
|
81
|
+
AssistantToolStartEvent.new(
|
|
82
|
+
type: :tool_start,
|
|
83
|
+
content_index: register_content_index(output_index),
|
|
84
|
+
delta: "",
|
|
85
|
+
id: item[:call_id] || item[:id],
|
|
86
|
+
name: item[:name]
|
|
87
|
+
)
|
|
88
|
+
else
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def map_output_item_done(data)
|
|
94
|
+
item = data[:item] || {}
|
|
95
|
+
output_index = data[:output_index] || 0
|
|
96
|
+
|
|
97
|
+
case item[:type]
|
|
98
|
+
when "reasoning"
|
|
99
|
+
map_reasoning_done(output_index, item)
|
|
100
|
+
when "function_call"
|
|
101
|
+
map_function_call_done(output_index, item)
|
|
102
|
+
else
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def map_reasoning_done(output_index, item)
|
|
108
|
+
content_index = content_index_for(output_index)
|
|
109
|
+
summary_text = extract_reasoning_summary_text(item)
|
|
110
|
+
|
|
111
|
+
if reasoning_started_without_content?(output_index) && !summary_text.empty?
|
|
112
|
+
queue_event(
|
|
113
|
+
AssistantStreamReasoningEvent.new(
|
|
114
|
+
type: :reasoning_end,
|
|
115
|
+
content_index:,
|
|
116
|
+
delta: "",
|
|
117
|
+
signature: ""
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
mark_reasoning_completed(output_index)
|
|
121
|
+
return AssistantStreamReasoningEvent.new(
|
|
122
|
+
type: :reasoning_delta,
|
|
123
|
+
content_index:,
|
|
124
|
+
delta: summary_text,
|
|
125
|
+
signature: ""
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
mark_reasoning_completed(output_index)
|
|
130
|
+
AssistantStreamReasoningEvent.new(
|
|
131
|
+
type: :reasoning_end,
|
|
132
|
+
content_index:,
|
|
133
|
+
delta: "",
|
|
134
|
+
signature: ""
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def map_function_call_done(output_index, item)
|
|
139
|
+
return nil if tool_started?(output_index)
|
|
140
|
+
|
|
141
|
+
mark_tool_started(output_index)
|
|
142
|
+
queue_event(
|
|
143
|
+
AssistantStreamEvent.new(
|
|
144
|
+
type: :tool_end,
|
|
145
|
+
content_index: content_index_for(output_index),
|
|
146
|
+
delta: ""
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
AssistantToolStartEvent.new(
|
|
151
|
+
type: :tool_start,
|
|
152
|
+
content_index: register_content_index(output_index),
|
|
153
|
+
delta: "",
|
|
154
|
+
id: item[:call_id] || item[:id],
|
|
155
|
+
name: item[:name]
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def map_content_part_added(data)
|
|
160
|
+
part = data[:part] || {}
|
|
161
|
+
return nil unless part[:type] == "output_text"
|
|
162
|
+
|
|
163
|
+
AssistantStreamEvent.new(
|
|
164
|
+
type: :text_start,
|
|
165
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
166
|
+
delta: ""
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def map_text_done(data)
|
|
171
|
+
AssistantStreamEvent.new(
|
|
172
|
+
type: :text_end,
|
|
173
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
174
|
+
delta: ""
|
|
175
|
+
)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def map_tool_done(data)
|
|
179
|
+
AssistantStreamEvent.new(
|
|
180
|
+
type: :tool_end,
|
|
181
|
+
content_index: content_index_for(data[:output_index] || 0),
|
|
182
|
+
delta: ""
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def map_response_completed(response)
|
|
187
|
+
stash_response(response)
|
|
188
|
+
AssistantStreamMessageEvent.new(
|
|
189
|
+
type: message_started? ? :message_delta : :message_start,
|
|
190
|
+
delta: pending_message_attributes.merge(role: pending_message_attributes[:role] || "assistant", stop_reason: stop_reason_for(response)),
|
|
191
|
+
usage_increment: usage_increment(response)
|
|
192
|
+
).tap do
|
|
193
|
+
@message_started = true
|
|
194
|
+
clear_pending_message_attributes
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def usage_increment(response)
|
|
199
|
+
usage = response[:usage] || {}
|
|
200
|
+
|
|
201
|
+
{
|
|
202
|
+
input_tokens: usage[:input_tokens] || 0,
|
|
203
|
+
cache_creation_input_tokens: 0,
|
|
204
|
+
cache_read_input_tokens: usage.dig(:input_tokens_details, :cached_tokens) || 0,
|
|
205
|
+
output_tokens: usage[:output_tokens] || 0,
|
|
206
|
+
reasoning_tokens: usage.dig(:output_tokens_details, :reasoning_tokens) || 0
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def stop_reason_for(response)
|
|
211
|
+
output = response[:output] || []
|
|
212
|
+
last_item = output.last || {}
|
|
213
|
+
|
|
214
|
+
tool_state.any? || last_item[:type] == "function_call" ? "tool_use" : "stop"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def ensure_message_started(role: "assistant")
|
|
218
|
+
return nil if message_started?
|
|
219
|
+
|
|
220
|
+
@message_started = true
|
|
221
|
+
AssistantStreamMessageEvent.new(
|
|
222
|
+
type: :message_start,
|
|
223
|
+
delta: pending_message_attributes.merge(role: role).compact,
|
|
224
|
+
usage_increment: {}
|
|
225
|
+
).tap do
|
|
226
|
+
clear_pending_message_attributes
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def extract_reasoning_summary_text(item)
|
|
231
|
+
Array(item[:summary]).filter_map do |summary|
|
|
232
|
+
next summary[:text] if summary.is_a?(Hash) && summary[:text]
|
|
233
|
+
next summary[:summary] if summary.is_a?(Hash) && summary[:summary]
|
|
234
|
+
next summary if summary.is_a?(String)
|
|
235
|
+
end.join
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def mark_reasoning_started(output_index)
|
|
239
|
+
reasoning_state[output_index] = :started
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def mark_reasoning_has_content(output_index)
|
|
243
|
+
reasoning_state[output_index] = :has_content
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def mark_reasoning_completed(output_index)
|
|
247
|
+
reasoning_state[output_index] = :completed
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def reasoning_started_without_content?(output_index)
|
|
251
|
+
reasoning_state[output_index] == :started
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def reasoning_state
|
|
255
|
+
@reasoning_state ||= {}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def mark_tool_started(output_index)
|
|
259
|
+
tool_state[output_index] = :started
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def tool_started?(output_index)
|
|
263
|
+
tool_state[output_index] == :started
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def tool_state
|
|
267
|
+
@tool_state ||= {}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def stash_response(response)
|
|
271
|
+
response ||= {}
|
|
272
|
+
@pending_message_attributes = pending_message_attributes.merge(
|
|
273
|
+
id: response[:id],
|
|
274
|
+
model: response[:model]
|
|
275
|
+
).compact
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def stash_role(role)
|
|
279
|
+
@pending_message_attributes = pending_message_attributes.merge(role:)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def pending_message_attributes
|
|
283
|
+
@pending_message_attributes ||= {}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def clear_pending_message_attributes
|
|
287
|
+
@pending_message_attributes = {}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def register_content_index(output_index)
|
|
291
|
+
content_index_map[output_index] ||= next_content_index!
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def content_index_for(output_index)
|
|
295
|
+
content_index_map.fetch(output_index) { register_content_index(output_index) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def next_content_index!
|
|
299
|
+
@next_content_index ||= 0
|
|
300
|
+
current = @next_content_index
|
|
301
|
+
@next_content_index += 1
|
|
302
|
+
current
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def content_index_map
|
|
306
|
+
@content_index_map ||= {}
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def message_started?
|
|
310
|
+
@message_started ||= false
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def queue_event(event)
|
|
314
|
+
queued_events << event
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def shift_queued_event
|
|
318
|
+
queued_events.shift
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def queued_events
|
|
322
|
+
@queued_events ||= []
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def raise_stream_error!(data)
|
|
326
|
+
error = data[:error].is_a?(Hash) ? data[:error] : data
|
|
327
|
+
message = error[:message] || "Stream error"
|
|
328
|
+
code = error[:code] || error[:type]
|
|
329
|
+
|
|
330
|
+
if LlmGateway::Errors.context_overflow_message?(message)
|
|
331
|
+
raise LlmGateway::Errors::PromptTooLong.new(message, code)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
raise LlmGateway::Errors::APIStatusError.new(message, code)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../adapter"
|
|
4
|
+
require_relative "acts_like_responses"
|
|
5
|
+
require_relative "../input_message_sanitizer"
|
|
6
|
+
require_relative "responses/input_mapper"
|
|
7
|
+
require_relative "responses/output_mapper"
|
|
8
|
+
require_relative "responses/option_mapper"
|
|
9
|
+
require_relative "file_output_mapper"
|
|
10
|
+
require_relative "responses/stream_mapper"
|
|
11
|
+
|
|
12
|
+
module LlmGateway
|
|
13
|
+
module Adapters
|
|
14
|
+
module OpenAI
|
|
15
|
+
class ResponsesAdapter < Adapter
|
|
16
|
+
include ActsLikeOpenAIResponses
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../openai/responses/input_mapper"
|
|
5
|
+
|
|
6
|
+
module LlmGateway
|
|
7
|
+
module Adapters
|
|
8
|
+
module OpenAICodex
|
|
9
|
+
# Custom input mapper for the Codex backend.
|
|
10
|
+
#
|
|
11
|
+
# The Codex Responses endpoint rejects several content block types that
|
|
12
|
+
# the standard OpenAI Responses InputMapper passes through:
|
|
13
|
+
# - "reasoning" and "summary_text" blocks are never accepted as input.
|
|
14
|
+
# - "thinking" blocks are only valid when they carry an encrypted
|
|
15
|
+
# `signature`; unsigned thinking blocks must be dropped.
|
|
16
|
+
#
|
|
17
|
+
# Additional normalisation:
|
|
18
|
+
# - Tool-result output is coerced to recognised Responses input types
|
|
19
|
+
# (input_text / input_image).
|
|
20
|
+
# - Assistant text content is always sent as "output_text" (not
|
|
21
|
+
# "input_text") because Codex is strict about directionality.
|
|
22
|
+
# - function_call / tool_use blocks inside an assistant turn are
|
|
23
|
+
# promoted to top-level function_call items so that Codex can match
|
|
24
|
+
# them against the subsequent function_call_output items.
|
|
25
|
+
class InputMapper < OpenAI::Responses::InputMapper
|
|
26
|
+
def self.map_messages(messages)
|
|
27
|
+
return messages unless messages.is_a?(Array)
|
|
28
|
+
|
|
29
|
+
mapper = message_mapper
|
|
30
|
+
stripped = strip_reasoning_blocks(messages)
|
|
31
|
+
|
|
32
|
+
mapped = stripped.each_with_object([]) do |msg, acc|
|
|
33
|
+
next unless msg.is_a?(Hash)
|
|
34
|
+
|
|
35
|
+
role = msg[:role]
|
|
36
|
+
content = msg[:content]
|
|
37
|
+
|
|
38
|
+
if %w[user developer].include?(role) && tool_result_message?(content)
|
|
39
|
+
# Responses API expects tool results as top-level input items.
|
|
40
|
+
# Also normalise nested tool_result output blocks to Responses
|
|
41
|
+
# input types (text → input_text, image → input_image).
|
|
42
|
+
content.each { |part| acc << map_tool_result_for_responses(part, mapper) }
|
|
43
|
+
next
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
if role == "assistant" && content.is_a?(Array)
|
|
47
|
+
acc.concat(map_assistant_content(content, mapper))
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
mapped_content =
|
|
52
|
+
if content.is_a?(Array)
|
|
53
|
+
content.map { |part| mapper.map_content(part) }
|
|
54
|
+
else
|
|
55
|
+
[ mapper.map_content(content) ]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
acc << { role: role, content: mapped_content }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
normalize_assistant_content_types(mapped)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Recursively strip Codex-incompatible content blocks from a message tree.
|
|
65
|
+
#
|
|
66
|
+
# "reasoning" → always removed
|
|
67
|
+
# "summary_text" → always removed
|
|
68
|
+
# "thinking" → removed unless :signature is present
|
|
69
|
+
def self.strip_reasoning_blocks(obj)
|
|
70
|
+
case obj
|
|
71
|
+
when Array
|
|
72
|
+
obj.map { |item| strip_reasoning_blocks(item) }.compact
|
|
73
|
+
when Hash
|
|
74
|
+
type = obj[:type]
|
|
75
|
+
return nil if %w[reasoning summary_text].include?(type)
|
|
76
|
+
return nil if type == "thinking" && obj[:signature].nil?
|
|
77
|
+
|
|
78
|
+
obj.each_with_object({}) do |(k, v), acc|
|
|
79
|
+
result = strip_reasoning_blocks(v)
|
|
80
|
+
acc[k] = result unless result.nil?
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
obj
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Ensure assistant messages carry "output_text" rather than "input_text".
|
|
88
|
+
# The BidirectionalMessageMapper maps plain text blocks to "input_text";
|
|
89
|
+
# Codex is strict about directionality and rejects "input_text" on the
|
|
90
|
+
# assistant side.
|
|
91
|
+
def self.normalize_assistant_content_types(messages)
|
|
92
|
+
return messages unless messages.is_a?(Array)
|
|
93
|
+
|
|
94
|
+
messages.map do |msg|
|
|
95
|
+
next msg unless msg.is_a?(Hash) && msg[:role] == "assistant" && msg[:content].is_a?(Array)
|
|
96
|
+
|
|
97
|
+
msg.merge(
|
|
98
|
+
content: msg[:content].map do |part|
|
|
99
|
+
part.is_a?(Hash) && part[:type] == "input_text" ? part.merge(type: "output_text") : part
|
|
100
|
+
end
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def self.tool_result_message?(content)
|
|
106
|
+
content.is_a?(Array) &&
|
|
107
|
+
content.first.is_a?(Hash) &&
|
|
108
|
+
content.first[:type] == "tool_result"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Map assistant content blocks into Codex-compatible top-level items.
|
|
112
|
+
#
|
|
113
|
+
# - thinking with signature → parsed JSON reasoning item (the encrypted
|
|
114
|
+
# signature *is* the serialised item)
|
|
115
|
+
# - tool_use / function_call → top-level function_call item
|
|
116
|
+
# - text / *_text variants → output_text inside an assistant content block
|
|
117
|
+
# - anything else → delegated to the BidirectionalMessageMapper
|
|
118
|
+
def self.map_assistant_content(content, mapper)
|
|
119
|
+
text_parts = []
|
|
120
|
+
items = []
|
|
121
|
+
|
|
122
|
+
content.each do |part|
|
|
123
|
+
next unless part.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
case part[:type]
|
|
126
|
+
when "tool_use", "function_call"
|
|
127
|
+
call_id = part[:id] || part[:call_id]
|
|
128
|
+
arguments = part[:input] || part[:arguments] || {}
|
|
129
|
+
arguments = JSON.generate(arguments) unless arguments.is_a?(String)
|
|
130
|
+
|
|
131
|
+
items << {
|
|
132
|
+
type: "function_call",
|
|
133
|
+
call_id: call_id,
|
|
134
|
+
name: part[:name],
|
|
135
|
+
arguments: arguments
|
|
136
|
+
}.compact
|
|
137
|
+
|
|
138
|
+
when "thinking"
|
|
139
|
+
# Only signed thinking blocks survive strip_reasoning_blocks;
|
|
140
|
+
# the signature payload is the full reasoning item JSON.
|
|
141
|
+
signature = part[:signature]
|
|
142
|
+
if signature
|
|
143
|
+
begin
|
|
144
|
+
items << JSON.parse(signature, symbolize_names: true)
|
|
145
|
+
rescue JSON::ParserError
|
|
146
|
+
# Malformed signature — silently drop.
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
when "text", "input_text", "output_text"
|
|
151
|
+
text_parts << { type: "output_text", text: part[:text].to_s }
|
|
152
|
+
|
|
153
|
+
else
|
|
154
|
+
mapped = mapper.map_content(part)
|
|
155
|
+
text_parts << mapped if mapped
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Text parts form a single assistant message; tool/reasoning items follow.
|
|
160
|
+
items.unshift({ role: "assistant", content: text_parts }) if text_parts.any?
|
|
161
|
+
items
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Wrap a tool_result part in the Responses wire format, normalising the
|
|
165
|
+
# nested output content types along the way.
|
|
166
|
+
def self.map_tool_result_for_responses(part, mapper)
|
|
167
|
+
return mapper.map_content(part) unless part.is_a?(Hash) && part[:type] == "tool_result"
|
|
168
|
+
|
|
169
|
+
mapper.map_content(part.merge(content: normalize_tool_result_output(part[:content])))
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Coerce each element of a tool result's output array to a Responses
|
|
173
|
+
# input type (input_text or input_image).
|
|
174
|
+
def self.normalize_tool_result_output(output)
|
|
175
|
+
Array(output).map do |item|
|
|
176
|
+
case item
|
|
177
|
+
when String
|
|
178
|
+
{ type: "input_text", text: item }
|
|
179
|
+
when Hash
|
|
180
|
+
type = item[:type] || item["type"]
|
|
181
|
+
case type
|
|
182
|
+
when "text", "input_text", "output_text"
|
|
183
|
+
{ type: "input_text", text: (item[:text] || item["text"]).to_s }
|
|
184
|
+
when "image", "input_image"
|
|
185
|
+
data = item[:data] || item["data"]
|
|
186
|
+
mime = item[:mimeType] || item["mimeType"] ||
|
|
187
|
+
item[:media_type] || item["media_type"] || "image/png"
|
|
188
|
+
image_url = item[:image_url] || item["image_url"] ||
|
|
189
|
+
"data:#{mime};base64,#{data}"
|
|
190
|
+
{ type: "input_image", image_url: image_url }
|
|
191
|
+
else
|
|
192
|
+
item
|
|
193
|
+
end
|
|
194
|
+
else
|
|
195
|
+
{ type: "input_text", text: item.to_s }
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
private_class_method :strip_reasoning_blocks, :normalize_assistant_content_types,
|
|
201
|
+
:tool_result_message?, :map_assistant_content,
|
|
202
|
+
:map_tool_result_for_responses, :normalize_tool_result_output
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../openai/responses/option_mapper"
|
|
4
|
+
|
|
5
|
+
module LlmGateway
|
|
6
|
+
module Adapters
|
|
7
|
+
module OpenAICodex
|
|
8
|
+
module OptionMapper
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def map(options)
|
|
12
|
+
mapped_options = OpenAI::Responses::OptionMapper.map(options)
|
|
13
|
+
|
|
14
|
+
# Codex endpoint currently rejects token limit parameters.
|
|
15
|
+
mapped_options.delete(:max_output_tokens)
|
|
16
|
+
mapped_options.delete(:max_completion_tokens)
|
|
17
|
+
|
|
18
|
+
# Codex transport does not use retention flags in the request body.
|
|
19
|
+
mapped_options.delete(:prompt_cache_retention)
|
|
20
|
+
mapped_options.delete(:cacheRetention)
|
|
21
|
+
mapped_options.delete(:cache_retention)
|
|
22
|
+
|
|
23
|
+
mapped_options
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../adapter"
|
|
4
|
+
require_relative "../openai/acts_like_responses"
|
|
5
|
+
require_relative "../openai/responses/output_mapper"
|
|
6
|
+
require_relative "option_mapper"
|
|
7
|
+
require_relative "../openai/responses/stream_mapper"
|
|
8
|
+
require_relative "../openai/file_output_mapper"
|
|
9
|
+
require_relative "input_mapper"
|
|
10
|
+
require_relative "../input_message_sanitizer"
|
|
11
|
+
|
|
12
|
+
module LlmGateway
|
|
13
|
+
module Adapters
|
|
14
|
+
module OpenAICodex
|
|
15
|
+
class ResponsesAdapter < Adapter
|
|
16
|
+
include ActsLikeOpenAIResponses
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def input_mapper
|
|
21
|
+
OpenAICodex::InputMapper
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def option_mapper
|
|
25
|
+
OptionMapper
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def perform_chat(messages, tools:, system:, **options)
|
|
29
|
+
client.chat_codex(messages, tools: tools, system: system, **options)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def perform_stream(messages, tools:, system:, **options, &block)
|
|
33
|
+
client.stream_codex(messages, tools: tools, system: system, **options, &block)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|