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,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