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,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../adapter"
4
+ require_relative "../openai/acts_like_chat_completions"
5
+ require_relative "../input_message_sanitizer"
6
+ require_relative "../openai/chat_completions/stream_mapper"
7
+ require_relative "input_mapper"
8
+ require_relative "option_mapper"
9
+
10
+ module LlmGateway
11
+ module Adapters
12
+ module Groq
13
+ class ChatCompletionsAdapter < Adapter
14
+ include ActsLikeOpenAIChatCompletions
15
+
16
+ private
17
+
18
+ def file_output_mapper = nil
19
+ def input_mapper = Groq::InputMapper
20
+ def option_mapper = Groq::OptionMapper
21
+
22
+ def map_input(input)
23
+ groq_safe_input = input.dup
24
+ groq_safe_input[:messages] = Array(input[:messages]).map do |msg|
25
+ next msg unless msg.is_a?(Hash) && msg[:content].is_a?(Array)
26
+
27
+ rewritten_content = msg[:content].map do |block|
28
+ next block unless block.is_a?(Hash) && block[:type] == "file"
29
+
30
+ {
31
+ type: "text",
32
+ text: block[:text] || "[File: #{block[:name]}]"
33
+ }
34
+ end
35
+
36
+ msg.merge(content: rewritten_content)
37
+ end
38
+
39
+ mapped = super(groq_safe_input)
40
+ mapped[:system] = Array(mapped[:system]).map do |msg|
41
+ msg[:role] == "developer" ? msg.merge(role: "system") : msg
42
+ end
43
+ mapped
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,17 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "bidirectional_message_mapper"
4
- require_relative "../open_ai/chat_completions/input_mapper"
3
+ require_relative "../openai/chat_completions/input_mapper"
5
4
 
6
5
  module LlmGateway
7
6
  module Adapters
8
7
  module Groq
9
- class InputMapper < OpenAi::ChatCompletions::InputMapper
10
- private
8
+ class InputMapper < OpenAI::ChatCompletions::InputMapper
9
+ def self.map(data)
10
+ mapped = super
11
+ mapped.merge(messages: map_groq_messages(mapped[:messages]))
12
+ end
13
+
14
+ def self.map_groq_messages(messages)
15
+ return messages unless messages.is_a?(Array)
16
+
17
+ messages.map { |message| map_groq_message(message) }
18
+ end
19
+
20
+ def self.map_groq_message(message)
21
+ return message unless message.is_a?(Hash) && message[:role] == "assistant"
22
+ return message unless message[:content].is_a?(Array)
11
23
 
12
- def self.map_system(system)
13
- system
24
+ reasoning_blocks, content_blocks = message[:content].partition do |block|
25
+ block.is_a?(Hash) && %w[reasoning thinking].include?(block[:type] || block["type"])
26
+ end
27
+
28
+ return message if reasoning_blocks.empty?
29
+
30
+ mapped = message.merge(content: content_blocks.empty? ? nil : content_blocks)
31
+ reasoning = reasoning_blocks.filter_map { |block| reasoning_text(block) }.join("\n")
32
+ mapped[:reasoning] = reasoning unless reasoning.empty?
33
+ mapped
14
34
  end
35
+
36
+ def self.reasoning_text(block)
37
+ block[:reasoning] || block["reasoning"] || block[:thinking] || block["thinking"]
38
+ end
39
+
40
+ private_class_method :map_groq_messages, :map_groq_message, :reasoning_text
15
41
  end
16
42
  end
17
43
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LlmGateway
4
+ module Adapters
5
+ module Groq
6
+ module OptionMapper
7
+ DEFAULT_TEMPERATURE = 0
8
+ DEFAULT_MAX_COMPLETION_TOKENS = 20_480
9
+ VALID_REASONING_LEVELS = %w[default low medium high].freeze
10
+
11
+ # Source: https://console.groq.com/docs/text-chat.md and
12
+ # https://console.groq.com/docs/api-reference.md#chat-create
13
+ # API: Groq Chat Completions Create; accessed 2026-05-19.
14
+ # Body parameters listed by the API reference: messages, model,
15
+ # citation_options, compound_custom, disable_tool_validation, documents,
16
+ # exclude_domains, frequency_penalty, function_call, functions,
17
+ # include_domains, include_reasoning, logit_bias, logprobs,
18
+ # max_completion_tokens, max_tokens, metadata, n, parallel_tool_calls,
19
+ # presence_penalty, reasoning_effort, reasoning_format, response_format,
20
+ # search_settings, seed, service_tier, stop, store, stream,
21
+ # stream_options, temperature, tool_choice, tools, top_logprobs, top_p,
22
+ # user.
23
+ # This mapper intentionally excludes transcript/tool structural fields
24
+ # (messages, tools) from option handling.
25
+ VALID_OPTIONS = %i[
26
+ model
27
+ citation_options
28
+ compound_custom
29
+ disable_tool_validation
30
+ documents
31
+ exclude_domains
32
+ frequency_penalty
33
+ function_call
34
+ functions
35
+ include_domains
36
+ include_reasoning
37
+ logit_bias
38
+ logprobs
39
+ max_completion_tokens
40
+ max_tokens
41
+ metadata
42
+ n
43
+ parallel_tool_calls
44
+ presence_penalty
45
+ reasoning_effort
46
+ reasoning_format
47
+ response_format
48
+ search_settings
49
+ seed
50
+ service_tier
51
+ stop
52
+ store
53
+ stream
54
+ stream_options
55
+ temperature
56
+ tool_choice
57
+ top_logprobs
58
+ top_p
59
+ user
60
+ ].freeze
61
+
62
+ MANAGED_OPTIONS = %i[
63
+ reasoning
64
+ cache_key
65
+ cache_retention
66
+ ].freeze
67
+
68
+ module_function
69
+
70
+ def map(options)
71
+ mapped_options = options.reject { |key, _| MANAGED_OPTIONS.include?(key) }
72
+ mapped_options[:temperature] = options.key?(:temperature) ? options[:temperature] : DEFAULT_TEMPERATURE
73
+ mapped_options[:max_completion_tokens] = options[:max_completion_tokens] || DEFAULT_MAX_COMPLETION_TOKENS
74
+ mapped_options[:response_format] = normalize_response_format(options[:response_format] || "text")
75
+
76
+ reasoning = options[:reasoning]
77
+ unless reasoning.nil? || reasoning.to_s == "none"
78
+ mapped_options[:reasoning_effort] = normalize_reasoning_effort(reasoning)
79
+ mapped_options[:reasoning_format] = "parsed"
80
+ end
81
+
82
+ validate_options!(mapped_options)
83
+ mapped_options
84
+ end
85
+
86
+ def validate_options!(mapped_options)
87
+ unknown_options = mapped_options.keys - VALID_OPTIONS
88
+ return if unknown_options.empty?
89
+
90
+ raise ArgumentError,
91
+ "Unknown Groq Chat Completions options: #{unknown_options.join(', ')}. " \
92
+ "Valid options: #{VALID_OPTIONS.join(', ')}."
93
+ end
94
+
95
+ def normalize_response_format(response_format)
96
+ if response_format.is_a?(String)
97
+ { type: response_format }
98
+ else
99
+ response_format
100
+ end
101
+ end
102
+
103
+ def normalize_reasoning_effort(reasoning)
104
+ effort = reasoning.to_s
105
+ return effort if VALID_REASONING_LEVELS.include?(effort)
106
+
107
+ raise ArgumentError, "Invalid reasoning '#{reasoning}'. Use 'none', 'default', 'low', 'medium', or 'high'."
108
+ end
109
+ end
110
+ end
111
+ end
112
+ 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,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../utils"
6
+ require_relative "structs"
7
+
8
+ module LlmGateway
9
+ module Adapters
10
+ class NormalizedStreamAccumulator
11
+ # Contract:
12
+ #
13
+ # `push` accepts a single provider-independent, normalized stream event
14
+ # patch hash. Event patches are never arrays; mappers call `push` once per
15
+ # patch.
16
+ #
17
+ # Provider wire events such as Anthropic `message_start` /
18
+ # `content_block_start`, OpenAI `response.output_text.delta`, etc. must be
19
+ # translated by the mapper before calling this accumulator. The normalized
20
+ # symbol `:message_start` below is allowed; the raw provider event string is
21
+ # not.
22
+ #
23
+ # Accepted event shapes:
24
+ #
25
+ # { type: :message_start, delta: { id: "...", model: "...", role: "assistant" }, usage_increment: { ... } }
26
+ # { type: :message_delta, delta: { stop_reason: "stop" }, usage_increment: { ... } }
27
+ # { type: :message_end }
28
+ #
29
+ # { type: :text_start, delta: "hi" }
30
+ # { type: :text_delta, delta: " there" }
31
+ # { type: :text_end, delta: "" }
32
+ #
33
+ # { type: :reasoning_start, delta: "thinking", signature: "" }
34
+ # { type: :reasoning_delta, delta: "...", signature: "" }
35
+ # { type: :reasoning_end, delta: "", signature: "" }
36
+ #
37
+ # { type: :tool_start, id: "...", name: "tool_name", delta: "" }
38
+ # { type: :tool_delta, delta: "{\"a\":" }
39
+ # { type: :tool_end, delta: "" }
40
+ #
41
+ # Mappers do not provide `content_index`. The accumulator assigns the next
42
+ # public content index when a block starts and reuses the active content
43
+ # index for that block's deltas and end event.
44
+ #
45
+ # Without source indexes, the accumulator cannot detect two interleaved
46
+ # blocks of the same type. Providers that can interleave same-type blocks
47
+ # must buffer or serialize them in the mapper before pushing normalized
48
+ # events.
49
+ #
50
+ # The accumulator creates the public Assistant* event structs, updates its
51
+ # accumulated message state, then yields the created event to the callback.
52
+ attr_accessor :blocks, :message_hash, :usage_hash
53
+ attr_reader :active_block_type
54
+
55
+ BLOCK_EVENT_TRANSITIONS = {
56
+ text_start: { block_type: :text, phase: :start },
57
+ text_delta: { block_type: :text, phase: :delta },
58
+ text_end: { block_type: :text, phase: :end },
59
+ tool_start: { block_type: :tool, phase: :start },
60
+ tool_delta: { block_type: :tool, phase: :delta },
61
+ tool_end: { block_type: :tool, phase: :end },
62
+ reasoning_start: { block_type: :reasoning, phase: :start },
63
+ reasoning_delta: { block_type: :reasoning, phase: :delta },
64
+ reasoning_end: { block_type: :reasoning, phase: :end }
65
+ }.freeze
66
+
67
+ def initialize
68
+ @message_hash = {}
69
+ @usage_hash = {
70
+ input_tokens: 0,
71
+ cache_creation_input_tokens: 0,
72
+ cache_read_input_tokens: 0,
73
+ output_tokens: 0,
74
+ reasoning_tokens: 0
75
+ }
76
+ @blocks = []
77
+ @next_content_index = 0
78
+ @active_block_type = nil
79
+ @active_content_index = nil
80
+ end
81
+
82
+ def result
83
+ message_hash.merge(
84
+ usage: usage_hash,
85
+ content: serialized_blocks
86
+ )
87
+ end
88
+
89
+ def active_tool?
90
+ active_block_type == :tool
91
+ end
92
+
93
+ def push(event_patch, &block)
94
+ raise ArgumentError, "Normalized stream event patch must be a Hash" unless event_patch.is_a?(Hash)
95
+
96
+ event_patch = symbolize_keys(event_patch)
97
+ type = event_patch.fetch(:type).to_sym
98
+ event_patch = prepare_event_patch(event_patch.merge(type:), type)
99
+
100
+ event = build_event(event_patch)
101
+ accumulate(event)
102
+ content_index = event.content_index if event.respond_to?(:content_index)
103
+ commit_block_transition(type, content_index)
104
+ block.call(event) if block
105
+
106
+ nil
107
+ end
108
+
109
+ private
110
+
111
+ def prepare_event_patch(event_patch, type)
112
+ transition = BLOCK_EVENT_TRANSITIONS[type]
113
+ return event_patch unless transition
114
+
115
+ block_type = transition[:block_type]
116
+
117
+ case transition[:phase]
118
+ when :start
119
+ validate_start!(block_type)
120
+ event_patch.merge(content_index: @next_content_index)
121
+ when :delta
122
+ validate_delta!(type, block_type)
123
+ event_patch.merge(content_index: @active_content_index)
124
+ when :end
125
+ validate_end!(block_type)
126
+ event_patch.merge(content_index: @active_content_index)
127
+ end
128
+ end
129
+
130
+ def validate_start!(block_type)
131
+ return unless @active_block_type
132
+
133
+ raise ArgumentError, "Cannot start #{block_type} block while #{@active_block_type} block is active"
134
+ end
135
+
136
+ def validate_delta!(type, block_type)
137
+ unless @active_block_type
138
+ raise ArgumentError, "Cannot apply #{type} without an active #{block_type} block"
139
+ end
140
+ return if @active_block_type == block_type
141
+
142
+ raise ArgumentError, "Cannot apply #{type} while #{@active_block_type} block is active"
143
+ end
144
+
145
+ def validate_end!(block_type)
146
+ unless @active_block_type
147
+ raise ArgumentError, "Cannot end #{block_type} block without an active #{block_type} block"
148
+ end
149
+ return if @active_block_type == block_type
150
+
151
+ raise ArgumentError, "Cannot end #{block_type} block while #{@active_block_type} block is active"
152
+ end
153
+
154
+ def commit_block_transition(type, content_index)
155
+ transition = BLOCK_EVENT_TRANSITIONS[type]
156
+ return unless transition
157
+
158
+ case transition[:phase]
159
+ when :start
160
+ @active_block_type = transition[:block_type]
161
+ @active_content_index = content_index
162
+ @next_content_index += 1
163
+ when :end
164
+ @active_block_type = nil
165
+ @active_content_index = nil
166
+ end
167
+ end
168
+
169
+ def build_event(event_patch)
170
+ event_patch = symbolize_keys(event_patch)
171
+ type = event_patch.fetch(:type).to_sym
172
+
173
+ case type
174
+ when :message_start, :message_delta, :message_end
175
+ AssistantStreamMessageEvent.new(
176
+ type:,
177
+ delta: symbolize_keys(event_patch[:delta] || {}),
178
+ usage_increment: symbolize_keys(event_patch[:usage_increment] || {})
179
+ )
180
+ when :tool_start
181
+ AssistantToolStartEvent.new(
182
+ type:,
183
+ content_index: event_patch.fetch(:content_index),
184
+ delta: string_value(event_patch[:delta]),
185
+ id: event_patch[:id],
186
+ name: event_patch[:name]
187
+ )
188
+ when :reasoning_start, :reasoning_delta, :reasoning_end
189
+ AssistantStreamReasoningEvent.new(
190
+ type:,
191
+ content_index: event_patch.fetch(:content_index),
192
+ delta: string_value(event_patch[:delta]),
193
+ signature: string_value(event_patch[:signature])
194
+ )
195
+ when :text_start, :text_delta, :text_end, :tool_delta, :tool_end
196
+ AssistantStreamEvent.new(
197
+ type:,
198
+ content_index: event_patch.fetch(:content_index),
199
+ delta: string_value(event_patch[:delta])
200
+ )
201
+ else
202
+ raise ArgumentError, "Unsupported normalized stream event type: #{type.inspect}"
203
+ end
204
+ end
205
+
206
+ def accumulate(event)
207
+ case event.type
208
+ when :text_start
209
+ blocks[event.content_index] = {
210
+ type: "text",
211
+ text: ""
212
+ }
213
+ blocks[event.content_index][:text] += event.delta
214
+ when :text_delta, :text_end
215
+ blocks[event.content_index][:text] += event.delta
216
+ when :tool_start
217
+ blocks[event.content_index] = {
218
+ type: "tool_use",
219
+ id: event.id,
220
+ name: event.name,
221
+ input: event.delta.to_s
222
+ }
223
+ when :tool_delta, :tool_end
224
+ blocks[event.content_index][:input] += event.delta
225
+ when :message_start
226
+ message_hash.merge!(event.delta)
227
+ usage_hash.each_key do |key|
228
+ usage_hash[key] += event.usage_increment.fetch(key, 0)
229
+ end
230
+ when :reasoning_start
231
+ blocks[event.content_index] = {
232
+ type: "reasoning",
233
+ reasoning: "",
234
+ signature: ""
235
+ }
236
+ blocks[event.content_index][:reasoning] += event.delta
237
+ blocks[event.content_index][:signature] += event.signature
238
+ when :reasoning_delta, :reasoning_end
239
+ blocks[event.content_index][:reasoning] += event.delta
240
+ blocks[event.content_index][:signature] += event.signature
241
+ when :message_delta
242
+ message_hash.merge!(event.delta)
243
+ usage_hash.each_key do |key|
244
+ usage_hash[key] += event.usage_increment.fetch(key, 0)
245
+ end
246
+ when :message_end
247
+ end
248
+ end
249
+
250
+ def serialized_blocks
251
+ blocks.map do |content_block|
252
+ next content_block unless content_block[:type] == "tool_use"
253
+
254
+ content_block.merge(input: LlmGateway::Utils.deep_symbolize_keys(parse_tool_input(content_block[:input])))
255
+ end
256
+ end
257
+
258
+ def parse_tool_input(input)
259
+ return {} if input.nil? || input.empty?
260
+
261
+ JSON.parse(input)
262
+ rescue JSON::ParserError
263
+ {}
264
+ end
265
+
266
+ def symbolize_keys(hash)
267
+ hash.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
268
+ end
269
+
270
+ def string_value(value)
271
+ value.nil? ? "" : value.to_s
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,20 @@
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 file_output_mapper = OpenAI::FileOutputMapper
14
+
15
+ def option_mapper = OpenAI::ChatCompletions::OptionMapper
16
+
17
+ def stream_mapper = OpenAI::ChatCompletions::StreamMapper
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
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 file_output_mapper = OpenAI::FileOutputMapper
15
+
16
+ def option_mapper = OpenAI::Responses::OptionMapper
17
+
18
+ def stream_mapper = OpenAI::Responses::StreamMapper
19
+
20
+ def perform_stream(messages, tools:, system:, **options, &block)
21
+ client.stream_responses(messages, tools: tools, system: system, **options, &block)
22
+ end
23
+ end
24
+ end
25
+ end