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.
- checksums.yaml +4 -4
- data/.pi/skills/live-provider-testing/SKILL.md +183 -0
- data/.pi/skills/options-development/SKILL.md +131 -0
- data/CHANGELOG.md +43 -0
- data/README.md +559 -185
- data/Rakefile +2 -2
- data/docs/migration-guide.md +135 -0
- data/lib/llm_gateway/adapters/adapter.rb +140 -0
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
- data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
- data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -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 +129 -0
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
- data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
- data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -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 +33 -0
- data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
- data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
- data/lib/llm_gateway/adapters/structs.rb +145 -0
- data/lib/llm_gateway/base_client.rb +62 -1
- data/lib/llm_gateway/client.rb +18 -158
- 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 +66 -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 +21 -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 +162 -17
- data/scripts/create_anthropic_credentials.rb +106 -0
- data/scripts/create_openai_codex_credentials.rb +116 -0
- metadata +60 -27
- data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
- data/lib/llm_gateway/adapters/claude/client.rb +0 -60
- data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
- data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
- data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
- data/lib/llm_gateway/adapters/groq/client.rb +0 -58
- data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
- data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
- data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
- data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
- data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
- data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
- data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
- data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
- 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,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
|