llm_gateway 0.4.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 +17 -0
- data/README.md +16 -0
- data/Rakefile +1 -0
- data/lib/llm_gateway/adapters/adapter.rb +2 -35
- 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 +31 -46
- 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 +275 -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 +169 -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 +81 -271
- 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 +50 -0
- data/lib/llm_gateway/client.rb +10 -66
- data/lib/llm_gateway/clients/groq.rb +13 -1
- data/lib/llm_gateway/version.rb +1 -1
- data/lib/llm_gateway.rb +2 -8
- metadata +7 -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,104 +1,167 @@
|
|
|
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 ChatCompletions
|
|
10
9
|
class InputMapper
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
def self.map(data)
|
|
11
|
+
{
|
|
12
|
+
messages: map_messages(data[:messages]),
|
|
13
|
+
tools: map_tools(data[:tools]),
|
|
14
|
+
system: map_system(data[:system])
|
|
15
|
+
}
|
|
16
|
+
end
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
def self.map_content(content)
|
|
19
|
+
content = { type: "text", text: content } unless content.is_a?(Hash)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
case content[:type]
|
|
22
|
+
when "text"
|
|
23
|
+
map_text_content(content)
|
|
24
|
+
when "file"
|
|
25
|
+
map_file_content(content)
|
|
26
|
+
when "image"
|
|
27
|
+
map_image_content(content)
|
|
28
|
+
when "tool_use", "function"
|
|
29
|
+
map_tool_use_content(content)
|
|
30
|
+
when "tool_result"
|
|
31
|
+
map_tool_result_content(content)
|
|
32
|
+
else
|
|
33
|
+
content
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
private
|
|
23
39
|
|
|
24
|
-
|
|
40
|
+
def map_messages(messages)
|
|
41
|
+
return messages unless messages
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
msg = msg.merge(role: "user") if msg[:role] == "developer"
|
|
43
|
+
mapped_messages = messages.map do |msg|
|
|
44
|
+
msg = msg.merge(role: "user") if msg[:role] == "developer"
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
content = if msg[:content].is_a?(Array)
|
|
47
|
+
msg[:content].map { |content| map_content(content) }
|
|
48
|
+
else
|
|
49
|
+
[ map_content(msg[:content]) ]
|
|
33
50
|
end
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
role: msg[:role],
|
|
54
|
+
content: content
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
mapped_messages.flat_map do |msg|
|
|
59
|
+
tool_calls = []
|
|
60
|
+
regular_content = []
|
|
61
|
+
tool_messages = []
|
|
62
|
+
msg[:content].each do |content|
|
|
63
|
+
case content[:type] || content[:role]
|
|
64
|
+
when "tool"
|
|
65
|
+
tool_messages << content
|
|
66
|
+
when "function"
|
|
67
|
+
tool_calls << content
|
|
68
|
+
else
|
|
69
|
+
regular_content << content
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
result = []
|
|
73
|
+
|
|
74
|
+
if tool_calls.any? || regular_content.any?
|
|
75
|
+
main_msg = msg.dup
|
|
76
|
+
main_msg[:role] = "assistant" if !main_msg[:role]
|
|
77
|
+
main_msg[:tool_calls] = tool_calls if tool_calls.any?
|
|
78
|
+
main_msg[:content] = regular_content.any? ? regular_content : nil
|
|
79
|
+
result << main_msg
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
result + tool_messages
|
|
83
|
+
end
|
|
36
84
|
end
|
|
37
85
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
case content[:type] || content[:role]
|
|
51
|
-
when "tool"
|
|
52
|
-
tool_messages << content
|
|
53
|
-
when "function"
|
|
54
|
-
tool_calls << content
|
|
55
|
-
else
|
|
56
|
-
regular_content << content
|
|
86
|
+
def map_tools(tools)
|
|
87
|
+
return tools unless tools
|
|
88
|
+
|
|
89
|
+
tools.map do |tool|
|
|
90
|
+
{
|
|
91
|
+
type: "function",
|
|
92
|
+
function: {
|
|
93
|
+
name: tool[:name],
|
|
94
|
+
description: tool[:description],
|
|
95
|
+
parameters: tool[:input_schema]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
57
98
|
end
|
|
58
99
|
end
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
|
|
101
|
+
def map_system(system)
|
|
102
|
+
if !system || system.empty?
|
|
103
|
+
[]
|
|
104
|
+
else
|
|
105
|
+
system.map do |msg|
|
|
106
|
+
msg[:role] == "system" ? msg.merge(role: "developer") : msg
|
|
107
|
+
end
|
|
108
|
+
end
|
|
68
109
|
end
|
|
69
110
|
|
|
70
|
-
|
|
71
|
-
|
|
111
|
+
def map_text_content(content)
|
|
112
|
+
{
|
|
113
|
+
type: "text",
|
|
114
|
+
text: content[:text]
|
|
115
|
+
}
|
|
116
|
+
end
|
|
72
117
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
118
|
+
def map_file_content(content)
|
|
119
|
+
media_type = content[:media_type] == "text/plain" ? "application/pdf" : content[:media_type]
|
|
120
|
+
{
|
|
121
|
+
type: "file",
|
|
122
|
+
file: {
|
|
123
|
+
filename: content[:name],
|
|
124
|
+
file_data: "data:#{media_type};base64,#{Base64.encode64(content[:data])}"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
end
|
|
76
128
|
|
|
77
|
-
|
|
78
|
-
|
|
129
|
+
def map_image_content(content)
|
|
130
|
+
{
|
|
131
|
+
type: "image_url",
|
|
132
|
+
image_url: {
|
|
133
|
+
url: "data:#{content[:media_type]};base64,#{content[:data]}"
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
end
|
|
79
137
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
138
|
+
def map_tool_use_content(content)
|
|
139
|
+
{
|
|
140
|
+
id: content[:id],
|
|
141
|
+
type: "function",
|
|
142
|
+
function: {
|
|
143
|
+
name: content[:name],
|
|
144
|
+
arguments: content[:input].to_json
|
|
145
|
+
}
|
|
87
146
|
}
|
|
88
|
-
|
|
89
|
-
end
|
|
90
|
-
end
|
|
147
|
+
end
|
|
91
148
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
149
|
+
def map_tool_result_content(content)
|
|
150
|
+
mapped_content = content[:content]
|
|
151
|
+
if mapped_content.is_a?(Array)
|
|
152
|
+
mapped_content = mapped_content.map do |item|
|
|
153
|
+
item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
{
|
|
158
|
+
role: "tool",
|
|
159
|
+
tool_call_id: content[:tool_use_id],
|
|
160
|
+
content: mapped_content
|
|
161
|
+
}
|
|
98
162
|
end
|
|
99
163
|
end
|
|
100
164
|
end
|
|
101
|
-
end
|
|
102
165
|
end
|
|
103
166
|
end
|
|
104
167
|
end
|
|
@@ -5,25 +5,115 @@ module LlmGateway
|
|
|
5
5
|
module OpenAI
|
|
6
6
|
module ChatCompletions
|
|
7
7
|
module OptionMapper
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
DEFAULT_MAX_COMPLETION_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/chat/subresources/completions/methods/create/index.md
|
|
12
|
+
# API: OpenAI Chat Completions Create; accessed 2026-05-18.
|
|
13
|
+
# Body parameters listed by the API reference: messages, model, audio,
|
|
14
|
+
# frequency_penalty, function_call, functions, logit_bias, logprobs,
|
|
15
|
+
# max_completion_tokens, max_tokens, metadata, modalities, n,
|
|
16
|
+
# parallel_tool_calls, prediction, presence_penalty, prompt_cache_key,
|
|
17
|
+
# prompt_cache_retention, reasoning_effort, response_format,
|
|
18
|
+
# safety_identifier, seed, service_tier, stop, store, stream,
|
|
19
|
+
# stream_options, temperature, tool_choice, tools, top_logprobs, top_p,
|
|
20
|
+
# user, verbosity, web_search_options.
|
|
21
|
+
# This mapper intentionally excludes transcript/tool structural fields
|
|
22
|
+
# (messages, tools) from option handling.
|
|
23
|
+
|
|
24
|
+
VALID_OPTIONS = %i[
|
|
25
|
+
model
|
|
26
|
+
audio
|
|
27
|
+
frequency_penalty
|
|
28
|
+
function_call
|
|
29
|
+
functions
|
|
30
|
+
logit_bias
|
|
31
|
+
logprobs
|
|
32
|
+
max_completion_tokens
|
|
33
|
+
max_tokens
|
|
34
|
+
metadata
|
|
35
|
+
modalities
|
|
36
|
+
n
|
|
37
|
+
parallel_tool_calls
|
|
38
|
+
prediction
|
|
39
|
+
presence_penalty
|
|
40
|
+
prompt_cache_key
|
|
41
|
+
prompt_cache_retention
|
|
42
|
+
reasoning_effort
|
|
43
|
+
response_format
|
|
44
|
+
safety_identifier
|
|
45
|
+
seed
|
|
46
|
+
service_tier
|
|
47
|
+
stop
|
|
48
|
+
store
|
|
49
|
+
stream
|
|
50
|
+
stream_options
|
|
51
|
+
temperature
|
|
52
|
+
tool_choice
|
|
53
|
+
top_logprobs
|
|
54
|
+
top_p
|
|
55
|
+
user
|
|
56
|
+
verbosity
|
|
57
|
+
web_search_options
|
|
58
|
+
].freeze
|
|
59
|
+
|
|
60
|
+
MANAGED_OPTIONS = %i[
|
|
61
|
+
reasoning
|
|
62
|
+
cache_key
|
|
63
|
+
cache_retention
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
12
66
|
module_function
|
|
13
67
|
|
|
14
68
|
def map(options)
|
|
15
|
-
mapped_options = options.
|
|
16
|
-
mapped_options[:max_completion_tokens]
|
|
69
|
+
mapped_options = options.reject { |key, _| MANAGED_OPTIONS.include?(key) }
|
|
70
|
+
mapped_options[:max_completion_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_COMPLETION_TOKENS
|
|
71
|
+
|
|
72
|
+
cache_key = options[:cache_key]
|
|
73
|
+
mapped_options[:prompt_cache_key] = cache_key unless cache_key.nil?
|
|
74
|
+
|
|
75
|
+
cache_retention = options[:cache_retention]
|
|
76
|
+
mapped_options[:prompt_cache_retention] = normalize_cache_retention(cache_retention) \
|
|
77
|
+
unless cache_retention.nil?
|
|
17
78
|
|
|
18
|
-
|
|
19
|
-
|
|
79
|
+
if mapped_options[:prompt_cache_key] && !mapped_options[:prompt_cache_retention]
|
|
80
|
+
mapped_options[:prompt_cache_retention] = normalize_cache_retention("short")
|
|
81
|
+
end
|
|
20
82
|
|
|
21
|
-
|
|
83
|
+
if cache_retention.to_s == "none"
|
|
84
|
+
mapped_options.delete(:prompt_cache_key)
|
|
85
|
+
mapped_options.delete(:prompt_cache_retention)
|
|
86
|
+
end
|
|
22
87
|
|
|
23
|
-
reasoning =
|
|
24
|
-
|
|
88
|
+
reasoning = options[:reasoning]
|
|
89
|
+
mapped_options[:reasoning_effort] = normalize_reasoning_effort(reasoning) \
|
|
90
|
+
unless reasoning.nil? || reasoning.to_s == "none"
|
|
91
|
+
|
|
92
|
+
validate_options!(mapped_options)
|
|
93
|
+
mapped_options
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_options!(mapped_options)
|
|
97
|
+
unknown_options = mapped_options.keys - VALID_OPTIONS
|
|
98
|
+
return if unknown_options.empty?
|
|
99
|
+
|
|
100
|
+
raise ArgumentError,
|
|
101
|
+
"Unknown OpenAI Chat Completions options: #{unknown_options.join(', ')}. " \
|
|
102
|
+
"Valid options: #{VALID_OPTIONS.join(', ')}."
|
|
103
|
+
end
|
|
25
104
|
|
|
26
|
-
|
|
105
|
+
def normalize_cache_retention(cache_retention)
|
|
106
|
+
case cache_retention.to_s
|
|
107
|
+
when "short"
|
|
108
|
+
"in_memory"
|
|
109
|
+
when "long"
|
|
110
|
+
"24h"
|
|
111
|
+
when "none"
|
|
112
|
+
nil
|
|
113
|
+
else
|
|
114
|
+
raise ArgumentError,
|
|
115
|
+
"Invalid cache_retention '#{cache_retention}'. Use 'short', 'long', or 'none'."
|
|
116
|
+
end
|
|
27
117
|
end
|
|
28
118
|
|
|
29
119
|
def normalize_reasoning_effort(reasoning)
|