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.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +565 -129
  4. data/Rakefile +8 -3
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +111 -0
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +12 -10
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +50 -0
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +110 -0
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +105 -0
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +40 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/openai/file_output_mapper.rb +25 -0
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +120 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +47 -0
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/{open_ai/output_mapper.rb → option_mapper.rb} +5 -2
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +97 -1
  41. data/lib/llm_gateway/client.rb +66 -54
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +23 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +169 -10
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -21
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -56
  60. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -30
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/input_mapper.rb +0 -105
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -62
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -59
  65. data/lib/llm_gateway/adapters/open_ai/input_mapper.rb +0 -63
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. 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
@@ -2,8 +2,11 @@
2
2
 
3
3
  module LlmGateway
4
4
  module Adapters
5
- module OpenAi
6
- class OutputMapper < LlmGateway::Adapters::Groq::OutputMapper
5
+ module OptionMapper
6
+ module_function
7
+
8
+ def map(options)
9
+ options
7
10
  end
8
11
  end
9
12
  end