llm_gateway 0.3.0 → 0.4.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +544 -186
  4. data/Rakefile +1 -2
  5. data/docs/migration-guide.md +135 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +173 -0
  7. data/lib/llm_gateway/adapters/anthropic/acts_like_messages.rb +23 -0
  8. data/lib/llm_gateway/adapters/{claude → anthropic}/bidirectional_message_mapper.rb +31 -3
  9. data/lib/llm_gateway/adapters/{claude → anthropic}/input_mapper.rb +4 -3
  10. data/lib/llm_gateway/adapters/anthropic/messages_adapter.rb +19 -0
  11. data/lib/llm_gateway/adapters/{claude → anthropic}/output_mapper.rb +1 -1
  12. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +110 -0
  13. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +53 -0
  14. data/lib/llm_gateway/adapters/groq/chat_completions_adapter.rb +47 -0
  15. data/lib/llm_gateway/adapters/groq/option_mapper.rb +27 -0
  16. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +93 -0
  17. data/lib/llm_gateway/adapters/openai/acts_like_chat_completions.rb +22 -0
  18. data/lib/llm_gateway/adapters/openai/acts_like_responses.rb +31 -0
  19. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/bidirectional_message_mapper.rb +9 -2
  20. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/input_mapper.rb +1 -6
  21. data/lib/llm_gateway/adapters/openai/chat_completions/input_message_sanitizer.rb +65 -0
  22. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +39 -0
  23. data/lib/llm_gateway/adapters/{open_ai → openai}/chat_completions/output_mapper.rb +1 -1
  24. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +242 -0
  25. data/lib/llm_gateway/adapters/openai/chat_completions_adapter.rb +20 -0
  26. data/lib/llm_gateway/adapters/{open_ai → openai}/file_output_mapper.rb +1 -1
  27. data/lib/llm_gateway/adapters/openai/prompt_cache_option_mapper.rb +39 -0
  28. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/bidirectional_message_mapper.rb +52 -4
  29. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +106 -0
  30. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +41 -0
  31. data/lib/llm_gateway/adapters/{open_ai → openai}/responses/output_mapper.rb +1 -1
  32. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +340 -0
  33. data/lib/llm_gateway/adapters/openai/responses_adapter.rb +20 -0
  34. data/lib/llm_gateway/adapters/openai_codex/input_mapper.rb +206 -0
  35. data/lib/llm_gateway/adapters/openai_codex/option_mapper.rb +28 -0
  36. data/lib/llm_gateway/adapters/openai_codex/responses_adapter.rb +38 -0
  37. data/lib/llm_gateway/adapters/option_mapper.rb +13 -0
  38. data/lib/llm_gateway/adapters/stream_accumulator.rb +91 -0
  39. data/lib/llm_gateway/adapters/structs.rb +145 -0
  40. data/lib/llm_gateway/base_client.rb +62 -1
  41. data/lib/llm_gateway/client.rb +45 -129
  42. data/lib/llm_gateway/clients/anthropic.rb +167 -0
  43. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +162 -0
  44. data/lib/llm_gateway/clients/claude_code/token_manager.rb +112 -0
  45. data/lib/llm_gateway/clients/groq.rb +54 -0
  46. data/lib/llm_gateway/clients/openai.rb +208 -0
  47. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +258 -0
  48. data/lib/llm_gateway/clients/openai_codex/token_manager.rb +71 -0
  49. data/lib/llm_gateway/errors.rb +21 -0
  50. data/lib/llm_gateway/prompt.rb +12 -1
  51. data/lib/llm_gateway/provider_registry.rb +37 -0
  52. data/lib/llm_gateway/version.rb +1 -1
  53. data/lib/llm_gateway.rb +165 -14
  54. data/scripts/create_anthropic_credentials.rb +106 -0
  55. data/scripts/create_openai_codex_credentials.rb +116 -0
  56. data/scripts/generate_handoff_live_fixture.rb +169 -0
  57. data/scripts/generate_handoff_media_fixture.rb +167 -0
  58. metadata +64 -28
  59. data/lib/llm_gateway/adapters/claude/client.rb +0 -60
  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/input_mapper.rb +0 -18
  63. data/lib/llm_gateway/adapters/groq/output_mapper.rb +0 -10
  64. data/lib/llm_gateway/adapters/open_ai/client.rb +0 -80
  65. data/lib/llm_gateway/adapters/open_ai/responses/input_mapper.rb +0 -62
  66. data/sample/claude_code_clone/agent.rb +0 -65
  67. data/sample/claude_code_clone/claude_code_clone.rb +0 -40
  68. data/sample/claude_code_clone/prompt.rb +0 -79
  69. data/sample/claude_code_clone/run.rb +0 -47
  70. data/sample/claude_code_clone/tools/bash_tool.rb +0 -54
  71. data/sample/claude_code_clone/tools/edit_tool.rb +0 -61
  72. data/sample/claude_code_clone/tools/grep_tool.rb +0 -113
  73. data/sample/claude_code_clone/tools/read_tool.rb +0 -61
  74. data/sample/claude_code_clone/tools/todowrite_tool.rb +0 -98
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module Groq
6
+ module OptionMapper
7
+ module_function
8
+
9
+ def map(options)
10
+ mapped_options = options.dup
11
+ mapped_options[:temperature] ||= 0
12
+ mapped_options[:max_completion_tokens] ||= 20480
13
+ mapped_options[:response_format] = normalize_response_format(mapped_options[:response_format] || "text")
14
+ mapped_options
15
+ end
16
+
17
+ def normalize_response_format(response_format)
18
+ if response_format.is_a?(String)
19
+ { type: response_format }
20
+ else
21
+ response_format
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ class InputMessageSanitizer
6
+ def self.sanitize(messages, target_provider:, target_api:, target_model:)
7
+ return messages unless messages.is_a?(Array)
8
+
9
+ messages.map do |message|
10
+ sanitize_message(
11
+ message,
12
+ target_provider: target_provider,
13
+ target_api: target_api,
14
+ target_model: target_model
15
+ )
16
+ end
17
+ end
18
+
19
+ def self.sanitize_message(message, target_provider:, target_api:, target_model:)
20
+ return message unless message.is_a?(Hash)
21
+
22
+ role = message[:role] || message["role"]
23
+ content = message[:content] || message["content"]
24
+ return message unless role == "assistant" && content.is_a?(Array)
25
+ return message unless message_metadata_present?(message)
26
+
27
+ same_model_replay = same_model_replay?(message, target_provider:, target_api:, target_model:)
28
+
29
+ sanitized_content = content.each_with_object([]) do |block, acc|
30
+ sanitized = sanitize_content_block(block, same_model_replay: same_model_replay)
31
+ next if sanitized.nil?
32
+
33
+ if sanitized.is_a?(Array)
34
+ acc.concat(sanitized)
35
+ else
36
+ acc << sanitized
37
+ end
38
+ end
39
+
40
+ message.merge(content: sanitized_content)
41
+ end
42
+
43
+ def self.sanitize_content_block(block, same_model_replay:)
44
+ return block unless block.is_a?(Hash)
45
+
46
+ type = block[:type] || block["type"]
47
+ return block unless %w[thinking reasoning].include?(type)
48
+ return block if same_model_replay
49
+
50
+ text = extract_reasoning_text(block)
51
+ return nil if text.nil? || text.strip.empty?
52
+
53
+ { type: "text", text: text }
54
+ end
55
+
56
+ def self.extract_reasoning_text(block)
57
+ return block[:thinking] if block[:thinking].is_a?(String)
58
+ return block[:reasoning] if block[:reasoning].is_a?(String)
59
+
60
+ summary = block[:summary]
61
+ if summary.is_a?(Array)
62
+ text = summary.filter_map do |item|
63
+ next item if item.is_a?(String)
64
+ next unless item.is_a?(Hash)
65
+
66
+ item[:text] || item[:summary_text] || item[:reasoning]
67
+ end.join("\n")
68
+ return text unless text.empty?
69
+ end
70
+
71
+ nil
72
+ end
73
+
74
+ def self.same_model_replay?(message, target_provider:, target_api:, target_model:)
75
+ provider = message[:provider] || message["provider"]
76
+ api = message[:api] || message["api"]
77
+ model = message[:model] || message["model"]
78
+
79
+ provider == target_provider && api == target_api && model == target_model
80
+ end
81
+
82
+ def self.message_metadata_present?(message)
83
+ provider = message[:provider] || message["provider"]
84
+ api = message[:api] || message["api"]
85
+ model = message[:model] || message["model"]
86
+
87
+ !provider.nil? && !api.nil? && !model.nil?
88
+ end
89
+
90
+ private_class_method :sanitize_message, :sanitize_content_block, :extract_reasoning_text, :same_model_replay?, :message_metadata_present?
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module ActsLikeOpenAIChatCompletions
6
+ private
7
+ def api_name = "completions"
8
+
9
+ def input_mapper = OpenAI::ChatCompletions::InputMapper
10
+
11
+ def input_sanitizer = OpenAI::ChatCompletions::InputMessageSanitizer
12
+
13
+ def output_mapper = OpenAI::ChatCompletions::OutputMapper
14
+
15
+ def file_output_mapper = OpenAI::FileOutputMapper
16
+
17
+ def option_mapper = OpenAI::ChatCompletions::OptionMapper
18
+
19
+ def stream_mapper = OpenAI::ChatCompletions::StreamMapper
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module ActsLikeOpenAIResponses
6
+ private
7
+
8
+ def api_name = "responses"
9
+
10
+ def input_mapper = OpenAI::Responses::InputMapper
11
+
12
+ def input_sanitizer = InputMessageSanitizer
13
+
14
+ def output_mapper = OpenAI::Responses::OutputMapper
15
+
16
+ def file_output_mapper = OpenAI::FileOutputMapper
17
+
18
+ def option_mapper = OpenAI::Responses::OptionMapper
19
+
20
+ def stream_mapper = OpenAI::Responses::StreamMapper
21
+
22
+ def perform_chat(messages, tools:, system:, **options)
23
+ client.responses(messages, tools: tools, system: system, **options)
24
+ end
25
+
26
+ def perform_stream(messages, tools:, system:, **options, &block)
27
+ client.stream_responses(messages, tools: tools, system: system, **options, &block)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -4,7 +4,7 @@ require "base64"
4
4
 
5
5
  module LlmGateway
6
6
  module Adapters
7
- module OpenAi
7
+ module OpenAI
8
8
  module ChatCompletions
9
9
  class BidirectionalMessageMapper
10
10
  attr_reader :direction
@@ -90,10 +90,17 @@ module LlmGateway
90
90
  end
91
91
 
92
92
  def map_tool_result_content(content)
93
+ mapped_content = content[:content]
94
+ if mapped_content.is_a?(Array)
95
+ mapped_content = mapped_content.map do |item|
96
+ item.is_a?(Hash) ? map_content(item.transform_keys(&:to_sym)) : item
97
+ end
98
+ end
99
+
93
100
  {
94
101
  role: "tool",
95
102
  tool_call_id: content[:tool_use_id],
96
- content: content[:content]
103
+ content: mapped_content
97
104
  }
98
105
  end
99
106
  end
@@ -5,13 +5,12 @@ require_relative "bidirectional_message_mapper"
5
5
 
6
6
  module LlmGateway
7
7
  module Adapters
8
- module OpenAi
8
+ module OpenAI
9
9
  module ChatCompletions
10
10
  class InputMapper
11
11
  def self.map(data)
12
12
  {
13
13
  messages: map_messages(data[:messages]),
14
- response_format: map_response_format(data[:response_format]),
15
14
  tools: map_tools(data[:tools]),
16
15
  system: map_system(data[:system])
17
16
  }
@@ -19,10 +18,6 @@ module LlmGateway
19
18
 
20
19
  private
21
20
 
22
- def self.map_response_format(response_format)
23
- response_format
24
- end
25
-
26
21
  def self.map_messages(messages)
27
22
  return messages unless messages
28
23
 
@@ -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,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module OpenAI
6
+ module ChatCompletions
7
+ module OptionMapper
8
+ include LlmGateway::Adapters::OpenAI::PromptCacheOptionMapper
9
+
10
+ VALID_REASONING_LEVELS = %w[low medium high xhigh].freeze
11
+
12
+ module_function
13
+
14
+ def map(options)
15
+ mapped_options = options.dup
16
+ mapped_options[:max_completion_tokens] ||= 20_480
17
+
18
+ map_cache_key!(mapped_options)
19
+ map_prompt_cache_retention!(mapped_options)
20
+
21
+ return mapped_options unless mapped_options.key?(:reasoning)
22
+
23
+ reasoning = mapped_options.delete(:reasoning)
24
+ return mapped_options if reasoning.nil? || reasoning.to_s == "none"
25
+
26
+ mapped_options.merge(reasoning_effort: normalize_reasoning_effort(reasoning))
27
+ end
28
+
29
+ def normalize_reasoning_effort(reasoning)
30
+ effort = reasoning.to_s
31
+ return effort if VALID_REASONING_LEVELS.include?(effort)
32
+
33
+ raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'low', 'medium', 'high', or 'xhigh'."
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module LlmGateway
4
4
  module Adapters
5
- module OpenAi
5
+ module OpenAI
6
6
  module ChatCompletions
7
7
  class OutputMapper
8
8
  def self.map(data)
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../structs"
4
+
5
+ module LlmGateway
6
+ module Adapters
7
+ module OpenAI
8
+ module ChatCompletions
9
+ class StreamMapper
10
+ def map(chunk)
11
+ queued_event = shift_queued_event
12
+ return queued_event if queued_event
13
+
14
+ data = chunk[:data] || {}
15
+ raise_stream_error!(data) if chunk[:event] == "error" || data[:error] || data[:type] == "error"
16
+
17
+ choices = data[:choices] || []
18
+
19
+ if choices.empty?
20
+ return message_event(
21
+ delta: pending_finish_delta,
22
+ usage_increment: usage_increment(data)
23
+ )
24
+ end
25
+
26
+ choice = choices.first || {}
27
+ delta = choice[:delta] || {}
28
+ finish_reason = choice[:finish_reason]
29
+
30
+ event = map_choice_delta(data, choice, delta)
31
+ return event if event
32
+
33
+ return finish_event_for(finish_reason) if finish_reason
34
+
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ def map_choice_delta(data, choice, delta)
41
+ if !message_started? && delta[:tool_calls]&.any?
42
+ @message_started = true
43
+ stash_message_attributes(data, delta)
44
+ return tool_event(delta[:tool_calls].first)
45
+ end
46
+
47
+ if !message_started? && (delta.key?(:role) || data[:id] || data[:model])
48
+ @message_started = true
49
+ return AssistantStreamMessageEvent.new(
50
+ type: :message_start,
51
+ delta: {
52
+ id: data[:id],
53
+ model: data[:model],
54
+ role: delta[:role]
55
+ }.compact,
56
+ usage_increment: {}
57
+ )
58
+ end
59
+
60
+ if (content = delta[:content]) && !content.empty?
61
+ return text_event(content, choice[:index] || 0)
62
+ end
63
+
64
+ return tool_event(delta[:tool_calls].first) if delta[:tool_calls]&.any?
65
+
66
+ nil
67
+ end
68
+
69
+ def finish_event_for(finish_reason)
70
+ normalized = normalize_stop_reason(finish_reason)
71
+ stash_pending_finish_delta(stop_reason: normalized)
72
+
73
+ case normalized
74
+ when "tool_use"
75
+ AssistantStreamEvent.new(type: :tool_end, content_index: last_started_tool_index || 0, delta: "")
76
+ else
77
+ AssistantStreamEvent.new(type: :text_end, content_index: last_started_text_index || 0, delta: "")
78
+ end
79
+ end
80
+
81
+ def message_event(delta:, usage_increment: {})
82
+ AssistantStreamMessageEvent.new(
83
+ type: pending_message_attributes.empty? ? :message_delta : :message_start,
84
+ delta: pending_message_attributes.merge(delta),
85
+ usage_increment:
86
+ ).tap do
87
+ clear_pending_message_attributes
88
+ clear_pending_finish_delta
89
+ end
90
+ end
91
+
92
+ def usage_increment(data)
93
+ usage = data[:usage] || {}
94
+
95
+ {
96
+ input_tokens: usage[:prompt_tokens] || 0,
97
+ cache_creation_input_tokens: 0,
98
+ cache_read_input_tokens: usage.dig(:prompt_tokens_details, :cached_tokens) || 0,
99
+ output_tokens: usage[:completion_tokens] || 0,
100
+ reasoning_tokens: usage.dig(:completion_tokens_details, :reasoning_tokens) || 0
101
+ }
102
+ end
103
+
104
+ def text_event(content, content_index)
105
+ @last_started_text_index = content_index
106
+
107
+ if started_text_blocks.include?(content_index)
108
+ AssistantStreamEvent.new(type: :text_delta, content_index:, delta: content)
109
+ else
110
+ started_text_blocks << content_index
111
+ AssistantStreamEvent.new(type: :text_start, content_index:, delta: content)
112
+ end
113
+ end
114
+
115
+ def tool_event(tool_call)
116
+ tool_index = tool_call[:index] || 0
117
+ @last_started_tool_index = tool_index
118
+ function = tool_call[:function] || {}
119
+ arguments = function[:arguments] || ""
120
+
121
+ unless started_tool_blocks.include?(tool_index)
122
+ pending_tool_calls[tool_index] = merge_tool_call(pending_tool_calls[tool_index], tool_call)
123
+ pending = pending_tool_calls[tool_index]
124
+
125
+ return nil unless pending[:id] && pending.dig(:function, :name)
126
+
127
+ started_tool_blocks << tool_index
128
+ return AssistantToolStartEvent.new(
129
+ type: :tool_start,
130
+ content_index: tool_index,
131
+ delta: "",
132
+ id: pending[:id],
133
+ name: pending.dig(:function, :name)
134
+ )
135
+ end
136
+
137
+ AssistantStreamEvent.new(type: :tool_delta, content_index: tool_index, delta: arguments)
138
+ end
139
+
140
+ def stash_message_attributes(data, delta)
141
+ @pending_message_attributes = {
142
+ id: data[:id],
143
+ model: data[:model],
144
+ role: delta[:role]
145
+ }.compact
146
+ end
147
+
148
+ def pending_message_attributes
149
+ @pending_message_attributes ||= {}
150
+ end
151
+
152
+ def clear_pending_message_attributes
153
+ @pending_message_attributes = {}
154
+ end
155
+
156
+ def stash_pending_finish_delta(delta)
157
+ @pending_finish_delta = pending_finish_delta.merge(delta)
158
+ end
159
+
160
+ def pending_finish_delta
161
+ @pending_finish_delta ||= {}
162
+ end
163
+
164
+ def clear_pending_finish_delta
165
+ @pending_finish_delta = {}
166
+ end
167
+
168
+ def merge_tool_call(existing, incoming)
169
+ existing ||= {}
170
+ incoming ||= {}
171
+
172
+ existing_function = existing[:function] || {}
173
+ incoming_function = incoming[:function] || {}
174
+
175
+ {
176
+ index: incoming[:index] || existing[:index],
177
+ id: incoming[:id] || existing[:id],
178
+ type: incoming[:type] || existing[:type],
179
+ function: {
180
+ name: incoming_function[:name] || existing_function[:name],
181
+ arguments: "#{existing_function[:arguments]}#{incoming_function[:arguments]}"
182
+ }
183
+ }
184
+ end
185
+
186
+ def normalize_stop_reason(finish_reason)
187
+ case finish_reason
188
+ when "tool_calls"
189
+ "tool_use"
190
+ else
191
+ finish_reason
192
+ end
193
+ end
194
+
195
+ def message_started?
196
+ @message_started ||= false
197
+ end
198
+
199
+ def started_text_blocks
200
+ @started_text_blocks ||= []
201
+ end
202
+
203
+ def started_tool_blocks
204
+ @started_tool_blocks ||= []
205
+ end
206
+
207
+ def pending_tool_calls
208
+ @pending_tool_calls ||= {}
209
+ end
210
+
211
+ def last_started_text_index
212
+ @last_started_text_index
213
+ end
214
+
215
+ def last_started_tool_index
216
+ @last_started_tool_index
217
+ end
218
+
219
+ def shift_queued_event
220
+ queued_events.shift
221
+ end
222
+
223
+ def queued_events
224
+ @queued_events ||= []
225
+ end
226
+
227
+ def raise_stream_error!(data)
228
+ error = data[:error].is_a?(Hash) ? data[:error] : data
229
+ message = error[:message] || "Stream error"
230
+ code = error[:code] || error[:type]
231
+
232
+ if LlmGateway::Errors.context_overflow_message?(message)
233
+ raise LlmGateway::Errors::PromptTooLong.new(message, code)
234
+ end
235
+
236
+ raise LlmGateway::Errors::APIStatusError.new(message, code)
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -0,0 +1,20 @@
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/output_mapper"
8
+ require_relative "chat_completions/option_mapper"
9
+ require_relative "file_output_mapper"
10
+ require_relative "chat_completions/stream_mapper"
11
+
12
+ module LlmGateway
13
+ module Adapters
14
+ module OpenAI
15
+ class ChatCompletionsAdapter < Adapter
16
+ include ActsLikeOpenAIChatCompletions
17
+ end
18
+ end
19
+ end
20
+ 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