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.
Files changed (42) 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 +17 -0
  5. data/README.md +16 -0
  6. data/Rakefile +1 -0
  7. data/lib/llm_gateway/adapters/adapter.rb +2 -35
  8. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +0 -2
  9. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +106 -27
  10. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +0 -33
  11. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -46
  12. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +48 -6
  13. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +3 -2
  14. data/lib/llm_gateway/adapters/groq/input_mapper.rb +44 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +89 -4
  16. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +0 -2
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +0 -6
  19. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +135 -72
  20. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +100 -10
  21. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +169 -170
  22. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +0 -1
  23. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +128 -68
  24. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +99 -10
  25. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +81 -271
  26. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +0 -1
  27. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +3 -3
  28. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +0 -5
  29. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  30. data/lib/llm_gateway/client.rb +10 -66
  31. data/lib/llm_gateway/clients/groq.rb +13 -1
  32. data/lib/llm_gateway/version.rb +1 -1
  33. data/lib/llm_gateway.rb +2 -8
  34. metadata +7 -10
  35. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +0 -111
  36. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +0 -110
  37. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +0 -40
  38. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +0 -120
  39. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +0 -47
  40. data/lib/llm_gateway/adapters/stream_accumulator.rb +0 -91
  41. data/scripts/generate_handoff_live_fixture.rb +0 -169
  42. 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
- def self.map(data)
12
- {
13
- messages: map_messages(data[:messages]),
14
- tools: map_tools(data[:tools]),
15
- system: map_system(data[:system])
16
- }
17
- end
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
- private
18
+ def self.map_content(content)
19
+ content = { type: "text", text: content } unless content.is_a?(Hash)
20
20
 
21
- def self.map_messages(messages)
22
- return messages unless messages
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
- message_mapper = BidirectionalMessageMapper.new(LlmGateway::DIRECTION_IN)
40
+ def map_messages(messages)
41
+ return messages unless messages
25
42
 
26
- # First map messages like Claude
27
- mapped_messages = messages.map do |msg|
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
- content = if msg[:content].is_a?(Array)
31
- msg[:content].map do |content|
32
- message_mapper.map_content(content)
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
- else
35
- [ message_mapper.map_content(msg[:content]) ]
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
- role: msg[:role],
40
- content: content
41
- }
42
- end
43
- # Then transform to OpenAI format
44
- mapped_messages.flat_map do |msg|
45
- # Handle array content with tool calls and tool results
46
- tool_calls = []
47
- regular_content = []
48
- tool_messages = []
49
- msg[:content].each do |content|
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
- result = []
60
-
61
- # Add the main message with tool calls if any
62
- if tool_calls.any? || regular_content.any?
63
- main_msg = msg.dup
64
- main_msg[:role] = "assistant" if !main_msg[:role]
65
- main_msg[:tool_calls] = tool_calls if tool_calls.any?
66
- main_msg[:content] = regular_content.any? ? regular_content : nil
67
- result << main_msg
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
- # Add separate tool result messages
71
- result += tool_messages
111
+ def map_text_content(content)
112
+ {
113
+ type: "text",
114
+ text: content[:text]
115
+ }
116
+ end
72
117
 
73
- result
74
- end
75
- end
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
- def self.map_tools(tools)
78
- return tools unless tools
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
- tools.map do |tool|
81
- {
82
- type: "function",
83
- function: {
84
- name: tool[:name],
85
- description: tool[:description],
86
- parameters: tool[:input_schema]
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
- def self.map_system(system)
93
- if !system || system.empty?
94
- []
95
- else
96
- system.map do |msg|
97
- msg[:role] == "system" ? msg.merge(role: "developer") : msg
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
- include LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
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.dup
16
- mapped_options[:max_completion_tokens] ||= 20_480
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
- map_cache_key!(mapped_options)
19
- map_prompt_cache_retention!(mapped_options)
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
- return mapped_options unless mapped_options.key?(:reasoning)
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 = mapped_options.delete(:reasoning)
24
- return mapped_options if reasoning.nil? || reasoning.to_s == "none"
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
- mapped_options.merge(reasoning_effort: normalize_reasoning_effort(reasoning))
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)