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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/.pi/skills/live-provider-testing/SKILL.md +183 -0
  3. data/.pi/skills/options-development/SKILL.md +131 -0
  4. data/CHANGELOG.md +43 -0
  5. data/README.md +559 -185
  6. data/Rakefile +2 -2
  7. data/docs/migration-guide.md +135 -0
  8. data/lib/llm_gateway/adapters/adapter.rb +140 -0
  9. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
  10. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
  11. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  12. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
  13. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
  14. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
  15. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
  16. data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
  17. data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
  18. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  19. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  20. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
  21. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
  26. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
  27. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  28. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
  31. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
  32. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
  33. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  34. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  35. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
  36. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  37. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  38. data/lib/llm_gateway/adapters/structs.rb +145 -0
  39. data/lib/llm_gateway/base_client.rb +62 -1
  40. data/lib/llm_gateway/client.rb +18 -158
  41. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  42. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  43. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  44. data/lib/llm_gateway/clients/groq.rb +66 -0
  45. data/lib/llm_gateway/clients/openai.rb +208 -0
  46. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  47. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  48. data/lib/llm_gateway/errors.rb +21 -0
  49. data/lib/llm_gateway/prompt.rb +12 -1
  50. data/lib/llm_gateway/provider_registry.rb +37 -0
  51. data/lib/llm_gateway/version.rb +1 -1
  52. data/lib/llm_gateway.rb +162 -17
  53. data/scripts/create_anthropic_credentials.rb +106 -0
  54. data/scripts/create_openai_codex_credentials.rb +116 -0
  55. metadata +60 -27
  56. data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
  57. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  58. data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
  59. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
  60. data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  63. data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
  64. data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
  65. data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
  66. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  67. data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
  68. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  69. data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
  70. data/sample/claude_code_clone/agent.rb +0 -65
  71. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  72. data/sample/claude_code_clone/prompt.rb +0 -79
  73. data/sample/claude_code_clone/run.rb +0 -47
  74. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  75. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  76. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  77. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  78. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module Responses
9
+ class InputMapper < OpenAI::ChatCompletions::InputMapper
10
+ def self.map_content(content)
11
+ content = { type: "text", text: content } unless content.is_a?(Hash)
12
+
13
+ case content[:type]
14
+ when "text"
15
+ map_text_content(content)
16
+ when "image"
17
+ map_image_content(content)
18
+ when "message"
19
+ map_messages_content(content)
20
+ when "output_text"
21
+ map_output_text_content(content)
22
+ when "tool_use", "function_call"
23
+ map_tool_use_content(content)
24
+ when "tool_result"
25
+ map_tool_result_content(content)
26
+ when "reasoning"
27
+ map_reasoning_content(content)
28
+ else
29
+ content
30
+ end
31
+ end
32
+
33
+ class << self
34
+ private
35
+
36
+ def map_tools(tools)
37
+ return tools unless tools
38
+
39
+ tools.map do |tool|
40
+ mapped_tool = {
41
+ type: "function",
42
+ name: tool[:name],
43
+ description: tool[:description],
44
+ parameters: tool[:input_schema]
45
+ }
46
+
47
+ [ :contents, :content ].each do |key|
48
+ next unless tool[key].is_a?(Array)
49
+
50
+ mapped_tool[key] = tool[key].map do |entry|
51
+ entry.is_a?(Hash) ? map_content(entry.transform_keys(&:to_sym)) : entry
52
+ end
53
+ end
54
+
55
+ mapped_tool
56
+ end
57
+ end
58
+
59
+ def map_messages(messages)
60
+ return messages unless messages
61
+
62
+ messages.flat_map do |msg|
63
+ if msg[:id] && msg[:content].is_a?(Array)
64
+ map_assistant_history_message(msg)
65
+ elsif msg[:id]
66
+ msg.slice(:id)
67
+ else
68
+ content = if msg[:content].is_a?(Array)
69
+ msg[:content].map { |content| map_content(content) }
70
+ else
71
+ [ map_content(msg[:content]) ]
72
+ end
73
+ if msg.dig(:content).is_a?(Array) && msg.dig(:content, 0, :type) == "tool_result"
74
+ content
75
+ else
76
+ {
77
+ role: msg[:role],
78
+ content: content
79
+ }
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def map_assistant_history_message(msg)
86
+ blocks = (msg[:content] || []).map { |b| b.transform_keys(&:to_sym) }
87
+
88
+ text_blocks = blocks.select { |b| b[:type] == "text" }
89
+ tool_use_blocks = blocks.select { |b| b[:type] == "tool_use" }
90
+
91
+ result = []
92
+
93
+ if text_blocks.any?
94
+ result << {
95
+ role: "assistant",
96
+ content: text_blocks.map { |b| { type: "output_text", text: b[:text] } }
97
+ }
98
+ end
99
+
100
+ tool_use_blocks.each do |b|
101
+ result << {
102
+ type: "function_call",
103
+ call_id: b[:id],
104
+ name: b[:name],
105
+ arguments: b[:input].is_a?(Hash) ? b[:input].to_json : (b[:input] || {}).to_json
106
+ }
107
+ end
108
+
109
+ result
110
+ end
111
+
112
+ def map_messages_content(message)
113
+ message[:content].map { |content| map_content(content) }
114
+ end
115
+
116
+ def map_tool_result_content(content)
117
+ output = content[:content]
118
+ if output.is_a?(Array)
119
+ output = output.map do |item|
120
+ item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
121
+ end
122
+ end
123
+
124
+ {
125
+ type: "function_call_output",
126
+ call_id: content[:tool_use_id],
127
+ output: output
128
+ }
129
+ end
130
+
131
+ def map_tool_use_content(content)
132
+ { id: content[:id] }
133
+ end
134
+
135
+ def map_output_text_content(content)
136
+ {
137
+ type: "input_text",
138
+ text: content[:text]
139
+ }
140
+ end
141
+
142
+ def map_reasoning_content(content)
143
+ return { id: content[:id] } if content[:id]
144
+
145
+ content
146
+ end
147
+
148
+ def map_image_content(content)
149
+ {
150
+ type: "input_image",
151
+ image_url: "data:#{content[:media_type]};base64,#{content[:data]}"
152
+ }
153
+ end
154
+
155
+ def map_text_content(content)
156
+ {
157
+ type: "input_text",
158
+ text: content[:text]
159
+ }
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module OpenAI
6
+ module Responses
7
+ module OptionMapper
8
+ DEFAULT_MAX_OUTPUT_TOKENS = 20_480
9
+ VALID_REASONING_LEVELS = %w[low medium high xhigh].freeze
10
+
11
+ # Source: https://developers.openai.com/api/reference/resources/responses/methods/create/index.md
12
+ # API: OpenAI Responses Create; accessed 2026-05-18.
13
+ # Body parameters listed by the API reference: background,
14
+ # context_management, conversation, include, input, instructions,
15
+ # max_output_tokens, max_tool_calls, metadata, model,
16
+ # parallel_tool_calls, previous_response_id, prompt, prompt_cache_key,
17
+ # prompt_cache_retention, reasoning, safety_identifier, service_tier,
18
+ # store, stream, stream_options, temperature, text, tool_choice, tools,
19
+ # top_logprobs, top_p, truncation, user.
20
+ # This mapper intentionally excludes transcript/tool/system structural
21
+ # fields (input, instructions, tools) from option handling.
22
+ VALID_OPTIONS = %i[
23
+ background
24
+ context_management
25
+ conversation
26
+ include
27
+ max_output_tokens
28
+ max_tool_calls
29
+ metadata
30
+ model
31
+ parallel_tool_calls
32
+ previous_response_id
33
+ prompt
34
+ prompt_cache_key
35
+ prompt_cache_retention
36
+ reasoning
37
+ safety_identifier
38
+ service_tier
39
+ store
40
+ stream
41
+ stream_options
42
+ temperature
43
+ text
44
+ tool_choice
45
+ top_logprobs
46
+ top_p
47
+ truncation
48
+ user
49
+ ].freeze
50
+
51
+ MANAGED_OPTIONS = %i[
52
+ max_completion_tokens
53
+ response_format
54
+ cache_key
55
+ cache_retention
56
+ ].freeze
57
+
58
+ module_function
59
+
60
+ def map(options)
61
+ mapped_options = options.reject { |key, _| MANAGED_OPTIONS.include?(key) }
62
+ mapped_options[:max_output_tokens] = options[:max_completion_tokens] || options[:max_output_tokens] || DEFAULT_MAX_OUTPUT_TOKENS
63
+
64
+ cache_key = options[:cache_key]
65
+ mapped_options[:prompt_cache_key] = cache_key unless cache_key.nil?
66
+
67
+ cache_retention = options[:cache_retention]
68
+ mapped_options[:prompt_cache_retention] = normalize_cache_retention(cache_retention) \
69
+ unless cache_retention.nil?
70
+
71
+ if mapped_options[:prompt_cache_key] && !mapped_options[:prompt_cache_retention]
72
+ mapped_options[:prompt_cache_retention] = normalize_cache_retention("short")
73
+ end
74
+
75
+ if cache_retention.to_s == "none"
76
+ mapped_options.delete(:prompt_cache_key)
77
+ mapped_options.delete(:prompt_cache_retention)
78
+ end
79
+
80
+ response_format = options[:response_format]
81
+ mapped_options[:text] = text_with_response_format(mapped_options[:text], response_format) unless response_format.nil?
82
+
83
+ reasoning = mapped_options.delete(:reasoning)
84
+ mapped_options[:reasoning] = normalize_reasoning(reasoning) \
85
+ unless reasoning.nil? || reasoning.to_s == "none"
86
+
87
+ validate_options!(mapped_options)
88
+ mapped_options
89
+ end
90
+
91
+ def validate_options!(mapped_options)
92
+ unknown_options = mapped_options.keys - VALID_OPTIONS
93
+ return if unknown_options.empty?
94
+
95
+ raise ArgumentError,
96
+ "Unknown OpenAI Responses options: #{unknown_options.join(', ')}. " \
97
+ "Valid options: #{VALID_OPTIONS.join(', ')}."
98
+ end
99
+
100
+ def normalize_cache_retention(cache_retention)
101
+ case cache_retention.to_s
102
+ when "short"
103
+ "in_memory"
104
+ when "long"
105
+ "24h"
106
+ when "none"
107
+ nil
108
+ else
109
+ raise ArgumentError,
110
+ "Invalid cache_retention '#{cache_retention}'. Use 'short', 'long', or 'none'."
111
+ end
112
+ end
113
+
114
+ def normalize_reasoning(reasoning)
115
+ effort = reasoning.to_s
116
+ return { effort: effort, summary: "detailed" } if VALID_REASONING_LEVELS.include?(effort)
117
+
118
+ raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
119
+ end
120
+
121
+ def text_with_response_format(text, response_format)
122
+ text_options = text ? text.dup : {}
123
+ text_options[:format] = response_format.is_a?(String) ? { type: response_format } : response_format
124
+ text_options
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../stream_mapper"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module Responses
9
+ class StreamMapper < LlmGateway::Adapters::StreamMapper
10
+ def map(chunk, &block)
11
+ event_type = chunk[:event]
12
+ data = chunk[:data] || {}
13
+ raise_stream_error!(data) if event_type == "error" || data[:error] || data[:type] == "error"
14
+
15
+ push_patches(patches_for(event_type, data), &block)
16
+ end
17
+
18
+ private
19
+
20
+ def patches_for(event_type, data)
21
+ case event_type
22
+ when "response.created"
23
+ response_created_patches(data[:response])
24
+ when "response.output_item.added"
25
+ output_item_added_patches(data)
26
+ when "response.content_part.added"
27
+ content_part_added_patches(data)
28
+ when "response.content_part.done"
29
+ content_part_done_patches(data)
30
+ when "response.output_text.delta"
31
+ [ { type: :text_delta, delta: data[:delta] || "" } ]
32
+ when "response.function_call_arguments.delta"
33
+ [ { type: :tool_delta, delta: data[:delta] || "" } ]
34
+ when "response.function_call_arguments.done"
35
+ [ { type: :tool_end, delta: "" } ]
36
+ when "response.reasoning_summary_part.added"
37
+ [ { type: :reasoning_start, delta: "", signature: "" } ]
38
+ when "response.reasoning_summary_text.delta"
39
+ [ { type: :reasoning_delta, delta: data[:delta] || "", signature: "" } ]
40
+ when "response.reasoning_summary_part.done"
41
+ [ { type: :reasoning_end, delta: "", signature: "" } ]
42
+ when "response.completed"
43
+ response_completed_patches(data[:response])
44
+ else
45
+ []
46
+ end
47
+ end
48
+
49
+ def response_created_patches(response)
50
+ response ||= {}
51
+
52
+ [
53
+ {
54
+ type: :message_start,
55
+ delta: {
56
+ id: response[:id],
57
+ model: response[:model],
58
+ role: "assistant"
59
+ }.compact,
60
+ usage_increment: {}
61
+ }
62
+ ]
63
+ end
64
+
65
+ def output_item_added_patches(data)
66
+ item = data[:item] || {}
67
+
68
+ case item[:type]
69
+ when "message"
70
+ return [] unless accumulator.message_hash.empty?
71
+
72
+ [
73
+ {
74
+ type: :message_start,
75
+ delta: { role: item[:role] || "assistant" },
76
+ usage_increment: {}
77
+ }
78
+ ]
79
+ when "function_call"
80
+ [
81
+ {
82
+ type: :tool_start,
83
+ delta: "",
84
+ id: item[:call_id] || item[:id],
85
+ name: item[:name]
86
+ }
87
+ ]
88
+ else
89
+ []
90
+ end
91
+ end
92
+
93
+ def content_part_added_patches(data)
94
+ part = data[:part] || {}
95
+ return [] unless part[:type] == "output_text"
96
+
97
+ [ { type: :text_start, delta: "" } ]
98
+ end
99
+
100
+ def content_part_done_patches(data)
101
+ part = data[:part] || {}
102
+ return [] unless part.empty? || part[:type] == "output_text"
103
+
104
+ [ { type: :text_end, delta: "" } ]
105
+ end
106
+
107
+ def response_completed_patches(response)
108
+ response ||= {}
109
+
110
+ [
111
+ {
112
+ type: accumulator.message_hash.empty? ? :message_start : :message_delta,
113
+ delta: {
114
+ id: response[:id],
115
+ model: response[:model],
116
+ role: "assistant",
117
+ stop_reason: stop_reason_for(response)
118
+ }.compact,
119
+ usage_increment: usage_increment(response)
120
+ }
121
+ ]
122
+ end
123
+
124
+ def usage_increment(response)
125
+ usage = response[:usage] || {}
126
+
127
+ {
128
+ input_tokens: usage[:input_tokens] || 0,
129
+ cache_creation_input_tokens: 0,
130
+ cache_read_input_tokens: usage.dig(:input_tokens_details, :cached_tokens) || 0,
131
+ output_tokens: usage[:output_tokens] || 0,
132
+ reasoning_tokens: usage.dig(:output_tokens_details, :reasoning_tokens) || 0
133
+ }
134
+ end
135
+
136
+ def stop_reason_for(response)
137
+ output = response[:output] || []
138
+ last_item = output.last || {}
139
+
140
+ tool_seen? || last_item[:type] == "function_call" ? "tool_use" : "stop"
141
+ end
142
+
143
+ def tool_seen?
144
+ accumulator.blocks.any? { |content_block| content_block && content_block[:type] == "tool_use" }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,19 @@
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/option_mapper"
8
+ require_relative "file_output_mapper"
9
+ require_relative "responses/stream_mapper"
10
+
11
+ module LlmGateway
12
+ module Adapters
13
+ module OpenAI
14
+ class ResponsesAdapter < Adapter
15
+ include ActsLikeOpenAIResponses
16
+ end
17
+ end
18
+ end
19
+ 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 = self
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 base Responses input mapper 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 Responses input mapper
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