llm_gateway 0.4.0 → 0.6.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 +110 -41
- data/Rakefile +1 -0
- data/docs/migration_guide_0.6.0.md +386 -0
- data/lib/llm_gateway/adapters/adapter.rb +8 -44
- data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +0 -2
- data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +106 -27
- data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +0 -33
- data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +59 -47
- data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +48 -6
- data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +3 -2
- data/lib/llm_gateway/adapters/groq/input_mapper.rb +44 -0
- data/lib/llm_gateway/adapters/groq/option_mapper.rb +89 -4
- data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +336 -0
- data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +0 -2
- data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +0 -6
- data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +135 -72
- data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +100 -10
- data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +193 -170
- data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +0 -1
- data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +128 -68
- data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +99 -10
- data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +106 -275
- data/lib/llm_gateway/adapters/openai/responses_adapter.rb +0 -1
- data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +3 -3
- data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +0 -5
- data/lib/llm_gateway/adapters/stream_mapper.rb +57 -0
- data/lib/llm_gateway/adapters/structs.rb +102 -52
- data/lib/llm_gateway/base_client.rb +2 -4
- data/lib/llm_gateway/client.rb +10 -66
- data/lib/llm_gateway/clients/anthropic.rb +5 -4
- data/lib/llm_gateway/clients/groq.rb +18 -4
- data/lib/llm_gateway/clients/openai.rb +20 -18
- data/lib/llm_gateway/prompt.rb +35 -17
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +5 -29
- metadata +8 -10
- data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +0 -111
- data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +0 -110
- data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +0 -40
- data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +0 -120
- data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +0 -47
- data/lib/llm_gateway/adapters/stream_accumulator.rb +0 -91
- data/scripts/generate_handoff_live_fixture.rb +0 -169
- data/scripts/generate_handoff_media_fixture.rb +0 -167
|
@@ -1,103 +1,163 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
|
-
require_relative "bidirectional_message_mapper"
|
|
5
4
|
|
|
6
5
|
module LlmGateway
|
|
7
6
|
module Adapters
|
|
8
7
|
module OpenAI
|
|
9
8
|
module Responses
|
|
10
9
|
class InputMapper < OpenAI::ChatCompletions::InputMapper
|
|
11
|
-
def self.
|
|
12
|
-
|
|
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
|
|
13
31
|
end
|
|
14
32
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
mapper = message_mapper
|
|
33
|
+
class << self
|
|
34
|
+
private
|
|
18
35
|
|
|
19
|
-
tools
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
+
}
|
|
26
46
|
|
|
27
|
-
|
|
28
|
-
|
|
47
|
+
[ :contents, :content ].each do |key|
|
|
48
|
+
next unless tool[key].is_a?(Array)
|
|
29
49
|
|
|
30
|
-
|
|
31
|
-
|
|
50
|
+
mapped_tool[key] = tool[key].map do |entry|
|
|
51
|
+
entry.is_a?(Hash) ? map_content(entry.transform_keys(&:to_sym)) : entry
|
|
52
|
+
end
|
|
32
53
|
end
|
|
33
|
-
end
|
|
34
54
|
|
|
35
|
-
|
|
55
|
+
mapped_tool
|
|
56
|
+
end
|
|
36
57
|
end
|
|
37
|
-
end
|
|
38
58
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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)
|
|
60
67
|
else
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
65
81
|
end
|
|
66
82
|
end
|
|
67
83
|
end
|
|
68
|
-
end
|
|
69
84
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
123
|
|
|
79
|
-
|
|
80
|
-
|
|
124
|
+
{
|
|
125
|
+
type: "function_call_output",
|
|
126
|
+
call_id: content[:tool_use_id],
|
|
127
|
+
output: output
|
|
128
|
+
}
|
|
129
|
+
end
|
|
81
130
|
|
|
82
|
-
|
|
131
|
+
def map_tool_use_content(content)
|
|
132
|
+
{ id: content[:id] }
|
|
133
|
+
end
|
|
83
134
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
135
|
+
def map_output_text_content(content)
|
|
136
|
+
{
|
|
137
|
+
type: "input_text",
|
|
138
|
+
text: content[:text]
|
|
88
139
|
}
|
|
89
140
|
end
|
|
90
141
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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]}"
|
|
97
152
|
}
|
|
98
153
|
end
|
|
99
154
|
|
|
100
|
-
|
|
155
|
+
def map_text_content(content)
|
|
156
|
+
{
|
|
157
|
+
type: "input_text",
|
|
158
|
+
text: content[:text]
|
|
159
|
+
}
|
|
160
|
+
end
|
|
101
161
|
end
|
|
102
162
|
end
|
|
103
163
|
end
|
|
@@ -5,27 +5,110 @@ module LlmGateway
|
|
|
5
5
|
module OpenAI
|
|
6
6
|
module Responses
|
|
7
7
|
module OptionMapper
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
DEFAULT_MAX_OUTPUT_TOKENS = 20_480
|
|
10
9
|
VALID_REASONING_LEVELS = %w[low medium high xhigh].freeze
|
|
11
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
|
+
|
|
12
58
|
module_function
|
|
13
59
|
|
|
14
60
|
def map(options)
|
|
15
|
-
mapped_options = 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
|
|
16
63
|
|
|
17
|
-
|
|
18
|
-
mapped_options[:
|
|
64
|
+
cache_key = options[:cache_key]
|
|
65
|
+
mapped_options[:prompt_cache_key] = cache_key unless cache_key.nil?
|
|
19
66
|
|
|
20
|
-
|
|
21
|
-
|
|
67
|
+
cache_retention = options[:cache_retention]
|
|
68
|
+
mapped_options[:prompt_cache_retention] = normalize_cache_retention(cache_retention) \
|
|
69
|
+
unless cache_retention.nil?
|
|
22
70
|
|
|
23
|
-
|
|
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?
|
|
24
82
|
|
|
25
83
|
reasoning = mapped_options.delete(:reasoning)
|
|
26
|
-
|
|
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
|
|
27
99
|
|
|
28
|
-
|
|
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
|
|
29
112
|
end
|
|
30
113
|
|
|
31
114
|
def normalize_reasoning(reasoning)
|
|
@@ -34,6 +117,12 @@ module LlmGateway
|
|
|
34
117
|
|
|
35
118
|
raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
|
|
36
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
|
|
37
126
|
end
|
|
38
127
|
end
|
|
39
128
|
end
|