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.
Files changed (48) 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 +43 -0
  5. data/README.md +110 -41
  6. data/Rakefile +1 -0
  7. data/docs/migration_guide_0.6.0.md +386 -0
  8. data/lib/llm_gateway/adapters/adapter.rb +8 -44
  9. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +0 -2
  10. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +106 -27
  11. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +0 -33
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +59 -47
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +48 -6
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +3 -2
  15. data/lib/llm_gateway/adapters/groq/input_mapper.rb +44 -0
  16. data/lib/llm_gateway/adapters/groq/option_mapper.rb +89 -4
  17. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +336 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +0 -2
  19. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +0 -6
  20. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +135 -72
  21. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +100 -10
  22. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +193 -170
  23. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +0 -1
  24. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +128 -68
  25. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +99 -10
  26. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +106 -275
  27. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +0 -1
  28. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +3 -3
  29. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +0 -5
  30. data/lib/llm_gateway/adapters/stream_mapper.rb +57 -0
  31. data/lib/llm_gateway/adapters/structs.rb +102 -52
  32. data/lib/llm_gateway/base_client.rb +2 -4
  33. data/lib/llm_gateway/client.rb +10 -66
  34. data/lib/llm_gateway/clients/anthropic.rb +5 -4
  35. data/lib/llm_gateway/clients/groq.rb +18 -4
  36. data/lib/llm_gateway/clients/openai.rb +20 -18
  37. data/lib/llm_gateway/prompt.rb +35 -17
  38. data/lib/llm_gateway/version.rb +1 -1
  39. data/lib/llm_gateway.rb +5 -29
  40. metadata +8 -10
  41. data/lib/llm_gateway/adapters/anthropic/bidirectional_message_mapper.rb +0 -111
  42. data/lib/llm_gateway/adapters/openai/chat_completions/bidirectional_message_mapper.rb +0 -110
  43. data/lib/llm_gateway/adapters/openai/chat_completions/output_mapper.rb +0 -40
  44. data/lib/llm_gateway/adapters/openai/responses/bidirectional_message_mapper.rb +0 -120
  45. data/lib/llm_gateway/adapters/openai/responses/output_mapper.rb +0 -47
  46. data/lib/llm_gateway/adapters/stream_accumulator.rb +0 -91
  47. data/scripts/generate_handoff_live_fixture.rb +0 -169
  48. 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.message_mapper
12
- BidirectionalMessageMapper.new(LlmGateway::DIRECTION_IN)
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
- def self.map_tools(tools)
16
- return tools unless tools
17
- mapper = message_mapper
33
+ class << self
34
+ private
18
35
 
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
- }
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
- [ :contents, :content ].each do |key|
28
- next unless tool[key].is_a?(Array)
47
+ [ :contents, :content ].each do |key|
48
+ next unless tool[key].is_a?(Array)
29
49
 
30
- mapped_tool[key] = tool[key].map do |entry|
31
- entry.is_a?(Hash) ? mapper.map_content(entry.transform_keys(&:to_sym)) : entry
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
- mapped_tool
55
+ mapped_tool
56
+ end
36
57
  end
37
- end
38
58
 
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
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
- role: msg[:role],
63
- content: content
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
- # 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) }
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
- text_blocks = blocks.select { |b| b[:type] == "text" }
80
- tool_use_blocks = blocks.select { |b| b[:type] == "tool_use" }
124
+ {
125
+ type: "function_call_output",
126
+ call_id: content[:tool_use_id],
127
+ output: output
128
+ }
129
+ end
81
130
 
82
- result = []
131
+ def map_tool_use_content(content)
132
+ { id: content[:id] }
133
+ end
83
134
 
84
- if text_blocks.any?
85
- result << {
86
- role: "assistant",
87
- content: text_blocks.map { |b| { type: "output_text", text: b[:text] } }
135
+ def map_output_text_content(content)
136
+ {
137
+ type: "input_text",
138
+ text: content[:text]
88
139
  }
89
140
  end
90
141
 
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
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
- result
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
- include LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
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.dup
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
- 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
64
+ cache_key = options[:cache_key]
65
+ mapped_options[:prompt_cache_key] = cache_key unless cache_key.nil?
19
66
 
20
- map_cache_key!(mapped_options)
21
- map_prompt_cache_retention!(mapped_options)
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
- return mapped_options unless mapped_options.key?(:reasoning)
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
- return mapped_options if reasoning.nil? || reasoning.to_s == "none"
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
- mapped_options.merge(reasoning: normalize_reasoning(reasoning))
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