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.
Files changed (78) 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 +559 -185
  6. data/Rakefile +2 -2
  7. data/docs/migration-guide.md +135 -0
  8. data/lib/llm_gateway/adapters/adapter.rb +140 -0
  9. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +21 -0
  10. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +137 -0
  11. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  12. data/lib/llm_gateway/adapters/anthropic/output_mapper.rb +17 -0
  13. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +95 -0
  14. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +95 -0
  15. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +48 -0
  16. data/lib/llm_gateway/adapters/groq/input_mapper.rb +32 -6
  17. data/lib/llm_gateway/adapters/groq/option_mapper.rb +112 -0
  18. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  19. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +275 -0
  20. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +20 -0
  21. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +25 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/input_mapper.rb +168 -0
  23. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  24. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +129 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +241 -0
  26. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +19 -0
  27. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  28. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +166 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +130 -0
  31. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +150 -0
  32. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +19 -0
  33. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  34. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  35. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +33 -0
  36. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  37. data/lib/llm_gateway/adapters/stream_mapper.rb +50 -0
  38. data/lib/llm_gateway/adapters/structs.rb +145 -0
  39. data/lib/llm_gateway/base_client.rb +62 -1
  40. data/lib/llm_gateway/client.rb +18 -158
  41. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  42. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  43. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  44. data/lib/llm_gateway/clients/groq.rb +66 -0
  45. data/lib/llm_gateway/clients/openai.rb +208 -0
  46. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  47. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  48. data/lib/llm_gateway/errors.rb +21 -0
  49. data/lib/llm_gateway/prompt.rb +12 -1
  50. data/lib/llm_gateway/provider_registry.rb +37 -0
  51. data/lib/llm_gateway/version.rb +1 -1
  52. data/lib/llm_gateway.rb +162 -17
  53. data/scripts/create_anthropic_credentials.rb +106 -0
  54. data/scripts/create_openai_codex_credentials.rb +116 -0
  55. metadata +60 -27
  56. data/lib/llm_gateway/adapters/claude/bidirectional_message_mapper.rb +0 -83
  57. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  58. data/lib/llm_gateway/adapters/claude/input_mapper.rb +0 -57
  59. data/lib/llm_gateway/adapters/claude/output_mapper.rb +0 -50
  60. data/lib/llm_gateway/adapters/groq/bidirectional_message_mapper.rb +0 -18
  61. data/lib/llm_gateway/adapters/groq/client.rb +0 -58
  62. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  63. data/lib/llm_gateway/adapters/open_ai/chat_completions/bidirectional_message_mapper.rb +0 -103
  64. data/lib/llm_gateway/adapters/open_ai/chat_completions/input_mapper.rb +0 -110
  65. data/lib/llm_gateway/adapters/open_ai/chat_completions/output_mapper.rb +0 -40
  66. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  67. data/lib/llm_gateway/adapters/open_ai/responses/bidirectional_message_mapper.rb +0 -72
  68. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  69. data/lib/llm_gateway/adapters/open_ai/responses/output_mapper.rb +0 -47
  70. data/sample/claude_code_clone/agent.rb +0 -65
  71. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  72. data/sample/claude_code_clone/prompt.rb +0 -79
  73. data/sample/claude_code_clone/run.rb +0 -47
  74. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  75. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  76. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  77. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  78. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module ChatCompletions
9
+ class InputMapper
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
17
+
18
+ def self.map_content(content)
19
+ content = { type: "text", text: content } unless content.is_a?(Hash)
20
+
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
39
+
40
+ def map_messages(messages)
41
+ return messages unless messages
42
+
43
+ mapped_messages = messages.map do |msg|
44
+ msg = msg.merge(role: "user") if msg[:role] == "developer"
45
+
46
+ content = if msg[:content].is_a?(Array)
47
+ msg[:content].map { |content| map_content(content) }
48
+ else
49
+ [ map_content(msg[:content]) ]
50
+ end
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
84
+ end
85
+
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
+ }
98
+ end
99
+ end
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
109
+ end
110
+
111
+ def map_text_content(content)
112
+ {
113
+ type: "text",
114
+ text: content[:text]
115
+ }
116
+ end
117
+
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
128
+
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
137
+
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
+ }
146
+ }
147
+ end
148
+
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
+ }
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../input_message_sanitizer"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module ChatCompletions
9
+ class InputMessageSanitizer < LlmGateway::Adapters::InputMessageSanitizer
10
+ def self.sanitize(messages, target_provider:, target_api:, target_model:)
11
+ sanitized = super
12
+ normalize_tool_call_ids(sanitized, target_provider: target_provider)
13
+ end
14
+
15
+ def self.normalize_tool_call_ids(messages, target_provider:)
16
+ return messages unless messages.is_a?(Array)
17
+
18
+ id_map = {}
19
+
20
+ messages.map do |message|
21
+ next message unless message.is_a?(Hash) && message[:content].is_a?(Array)
22
+
23
+ content = message[:content].map do |block|
24
+ next block unless block.is_a?(Hash)
25
+
26
+ type = block[:type] || block["type"]
27
+
28
+ case type
29
+ when "tool_use", "function"
30
+ original_id = block[:id] || block["id"]
31
+ normalized_id = normalize_tool_call_id(original_id, target_provider: target_provider)
32
+ id_map[original_id] = normalized_id if original_id && normalized_id
33
+ block.merge(id: normalized_id)
34
+ when "tool_result"
35
+ original_tool_use_id = block[:tool_use_id] || block["tool_use_id"]
36
+ normalized_tool_use_id = id_map[original_tool_use_id] || normalize_tool_call_id(original_tool_use_id, target_provider: target_provider)
37
+ block.merge(tool_use_id: normalized_tool_use_id)
38
+ else
39
+ block
40
+ end
41
+ end
42
+
43
+ message.merge(content: content)
44
+ end
45
+ end
46
+
47
+ def self.normalize_tool_call_id(id, target_provider:)
48
+ return id unless id.is_a?(String)
49
+
50
+ if id.include?("|")
51
+ call_id = id.split("|", 2).first
52
+ call_id.gsub(/[^a-zA-Z0-9_-]/, "_")[0, 40]
53
+ elsif target_provider == "openai"
54
+ id[0, 40]
55
+ else
56
+ id
57
+ end
58
+ end
59
+
60
+ private_class_method :normalize_tool_call_ids, :normalize_tool_call_id
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module OpenAI
6
+ module ChatCompletions
7
+ module OptionMapper
8
+ DEFAULT_MAX_COMPLETION_TOKENS = 20_480
9
+ VALID_REASONING_LEVELS = %w[low medium high xhigh].freeze
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
+
66
+ module_function
67
+
68
+ def map(options)
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?
78
+
79
+ if mapped_options[:prompt_cache_key] && !mapped_options[:prompt_cache_retention]
80
+ mapped_options[:prompt_cache_retention] = normalize_cache_retention("short")
81
+ end
82
+
83
+ if cache_retention.to_s == "none"
84
+ mapped_options.delete(:prompt_cache_key)
85
+ mapped_options.delete(:prompt_cache_retention)
86
+ end
87
+
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
104
+
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
117
+ end
118
+
119
+ def normalize_reasoning_effort(reasoning)
120
+ effort = reasoning.to_s
121
+ return effort if VALID_REASONING_LEVELS.include?(effort)
122
+
123
+ raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../stream_mapper"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module ChatCompletions
9
+ class StreamMapper < LlmGateway::Adapters::StreamMapper
10
+ def map(chunk, &block)
11
+ data = chunk[:data] || {}
12
+ raise_stream_error!(data) if chunk[:event] == "error" || data[:error] || data[:type] == "error"
13
+
14
+ push_patches(patches_for(data), &block)
15
+ end
16
+
17
+ private
18
+
19
+ def patches_for(data)
20
+ choices = data[:choices] || []
21
+ return final_usage_patches(data) if choices.empty?
22
+
23
+ choice = choices.first || {}
24
+ delta = choice[:delta] || {}
25
+ patches = []
26
+ active_block_type = accumulator.active_block_type
27
+ active_tool = active_tool_block
28
+
29
+ append_patches(patches, message_start_patches(data, delta))
30
+
31
+ active_block_type, active_tool = append_patches(
32
+ patches,
33
+ reasoning_patches(delta[:reasoning], active_block_type:),
34
+ active_block_type,
35
+ active_tool
36
+ )
37
+ active_block_type, active_tool = append_patches(
38
+ patches,
39
+ text_patches(delta[:content], active_block_type:),
40
+ active_block_type,
41
+ active_tool
42
+ )
43
+ delta.fetch(:tool_calls, []).each do |tool_call|
44
+ active_block_type, active_tool = append_patches(
45
+ patches,
46
+ patches_for_tool_call(tool_call, active_block_type:, active_tool:),
47
+ active_block_type,
48
+ active_tool
49
+ )
50
+ end
51
+ append_patches(patches, finish_patches(choice[:finish_reason], active_block_type:))
52
+
53
+ patches
54
+ end
55
+
56
+ def append_patches(patches, new_patches, active_block_type = nil, active_tool = nil)
57
+ patches.concat(new_patches)
58
+
59
+ new_patches.each do |patch|
60
+ case patch[:type]
61
+ when :text_start
62
+ active_block_type = :text
63
+ active_tool = nil
64
+ when :reasoning_start
65
+ active_block_type = :reasoning
66
+ active_tool = nil
67
+ when :tool_start
68
+ active_block_type = :tool
69
+ active_tool = { id: patch[:id], name: patch[:name] }
70
+ when :text_end, :reasoning_end, :tool_end
71
+ active_block_type = nil
72
+ active_tool = nil
73
+ end
74
+ end
75
+
76
+ [ active_block_type, active_tool ]
77
+ end
78
+
79
+ def message_start_patches(data, delta)
80
+ return [] unless accumulator.message_hash.empty?
81
+
82
+ return [] unless delta.key?(:role) ||
83
+ data[:id] ||
84
+ data[:model] ||
85
+ delta[:content] ||
86
+ delta[:reasoning] ||
87
+ delta[:tool_calls]&.any?
88
+
89
+ [
90
+ {
91
+ type: :message_start,
92
+ delta: {
93
+ id: data[:id],
94
+ model: data[:model],
95
+ role: delta[:role] || "assistant"
96
+ }.compact,
97
+ usage_increment: {}
98
+ }
99
+ ]
100
+ end
101
+
102
+ # Groq exposes OpenAI-compatible chat completion chunks, but may include
103
+ # `delta.reasoning` before normal `delta.content`.
104
+ def reasoning_patches(reasoning, active_block_type: accumulator.active_block_type)
105
+ return [] if reasoning.to_s.empty?
106
+
107
+ [
108
+ *close_active_non_reasoning_patches(active_block_type:),
109
+ {
110
+ type: active_block_type == :reasoning ? :reasoning_delta : :reasoning_start,
111
+ delta: reasoning,
112
+ signature: ""
113
+ }
114
+ ]
115
+ end
116
+
117
+ def text_patches(content, active_block_type: accumulator.active_block_type)
118
+ return [] if content.to_s.empty?
119
+
120
+ [
121
+ *close_active_non_text_patches(active_block_type:),
122
+ {
123
+ type: active_block_type == :text ? :text_delta : :text_start,
124
+ delta: content
125
+ }
126
+ ]
127
+ end
128
+
129
+ def patches_for_tool_call(tool_call, active_block_type: accumulator.active_block_type, active_tool: active_tool_block)
130
+ id = tool_call[:id]
131
+ name = tool_call.dig(:function, :name)
132
+ arguments = tool_call.dig(:function, :arguments).to_s
133
+
134
+ patches = []
135
+
136
+ if id || name
137
+ if active_block_type == :tool
138
+ patches.concat(close_active_block_patches(active_block_type:)) if new_active_tool?(id, name, active_tool:)
139
+ else
140
+ patches.concat(close_active_non_tool_patches(active_block_type:))
141
+ end
142
+
143
+ unless active_block_type == :tool && patches.empty?
144
+ patches << {
145
+ type: :tool_start,
146
+ delta: "",
147
+ id: id,
148
+ name: name
149
+ }
150
+ end
151
+ end
152
+
153
+ patches << { type: :tool_delta, delta: arguments } unless arguments.empty?
154
+ patches
155
+ end
156
+
157
+ def new_active_tool?(id, name, active_tool: active_tool_block)
158
+ return true unless active_tool
159
+
160
+ (id && active_tool[:id] != id) || (name && active_tool[:name] != name)
161
+ end
162
+
163
+ def active_tool_block
164
+ return nil unless accumulator.active_tool?
165
+
166
+ accumulator.blocks.reverse.find { |block| block&.fetch(:type, nil) == "tool_use" }
167
+ end
168
+
169
+ def close_active_block_patches(active_block_type: accumulator.active_block_type)
170
+ case active_block_type
171
+ when :text
172
+ [ { type: :text_end, delta: "" } ]
173
+ when :reasoning
174
+ [ { type: :reasoning_end, delta: "", signature: "" } ]
175
+ when :tool
176
+ [ { type: :tool_end, delta: "" } ]
177
+ else
178
+ []
179
+ end
180
+ end
181
+
182
+ def close_active_non_text_patches(active_block_type: accumulator.active_block_type)
183
+ active_block_type == :text ? [] : close_active_block_patches(active_block_type:)
184
+ end
185
+
186
+ def close_active_non_reasoning_patches(active_block_type: accumulator.active_block_type)
187
+ active_block_type == :reasoning ? [] : close_active_block_patches(active_block_type:)
188
+ end
189
+
190
+ def close_active_non_tool_patches(active_block_type: accumulator.active_block_type)
191
+ active_block_type == :tool ? [] : close_active_block_patches(active_block_type:)
192
+ end
193
+
194
+ def finish_patches(finish_reason, active_block_type: accumulator.active_block_type)
195
+ return [] unless finish_reason
196
+
197
+ [
198
+ *close_active_block_patches(active_block_type:),
199
+ {
200
+ type: :message_delta,
201
+ delta: { stop_reason: normalize_stop_reason(finish_reason) },
202
+ usage_increment: {}
203
+ }
204
+ ]
205
+ end
206
+
207
+ def final_usage_patches(data)
208
+ [
209
+ {
210
+ type: accumulator.message_hash.empty? ? :message_start : :message_delta,
211
+ delta: {},
212
+ usage_increment: usage_increment(data)
213
+ }
214
+ ]
215
+ end
216
+
217
+ def usage_increment(data)
218
+ usage = data[:usage] || {}
219
+
220
+ {
221
+ input_tokens: usage[:prompt_tokens] || 0,
222
+ cache_creation_input_tokens: 0,
223
+ cache_read_input_tokens: usage.dig(:prompt_tokens_details, :cached_tokens) || 0,
224
+ output_tokens: usage[:completion_tokens] || 0,
225
+ reasoning_tokens: usage.dig(:completion_tokens_details, :reasoning_tokens) || 0
226
+ }
227
+ end
228
+
229
+ def normalize_stop_reason(finish_reason)
230
+ case finish_reason
231
+ when "tool_calls"
232
+ "tool_use"
233
+ else
234
+ finish_reason
235
+ end
236
+ end
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../adapter"
4
+ require_relative "acts_like_chat_completions"
5
+ require_relative "chat_completions/input_mapper"
6
+ require_relative "chat_completions/input_message_sanitizer"
7
+ require_relative "chat_completions/option_mapper"
8
+ require_relative "file_output_mapper"
9
+ require_relative "chat_completions/stream_mapper"
10
+
11
+ module LlmGateway
12
+ module Adapters
13
+ module OpenAI
14
+ class ChatCompletionsAdapter < Adapter
15
+ include ActsLikeOpenAIChatCompletions
16
+ end
17
+ end
18
+ end
19
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module LlmGateway
4
4
  module Adapters
5
- module OpenAi
5
+ module OpenAI
6
6
  class FileOutputMapper
7
7
  def self.map(data)
8
8
  bytes = data.delete(:bytes)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module OpenAI
6
+ module PromptCacheOptionMapper
7
+ def self.included(base)
8
+ base.extend(self)
9
+ end
10
+
11
+ def map_cache_key!(mapped_options)
12
+ cache_key = mapped_options.delete(:cache_key)
13
+ mapped_options.delete(:prompt_cache_key)
14
+ mapped_options[:prompt_cache_key] = cache_key unless cache_key.nil?
15
+ end
16
+
17
+ def map_prompt_cache_retention!(mapped_options)
18
+ retention = mapped_options.delete(:cache_retention)
19
+ mapped_options.delete(:prompt_cache_retention)
20
+ retention ||= "short" if mapped_options.key?(:prompt_cache_key)
21
+
22
+ case retention&.to_s
23
+ when nil
24
+ nil
25
+ when "short"
26
+ mapped_options[:prompt_cache_retention] = "in_memory"
27
+ when "long"
28
+ mapped_options[:prompt_cache_retention] = "24h"
29
+ when "none"
30
+ mapped_options.delete(:prompt_cache_key)
31
+ else
32
+ raise ArgumentError,
33
+ "Invalid cache_retention '#{retention}'. Use 'short', 'long', or 'none'."
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end