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
@@ -0,0 +1,336 @@
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", timestamp: 1716650000000 } }
26
+ # { type: :message_delta, delta: { stop_reason: "stop" }, usage: { output: 2 } }
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, :final_message
54
+
55
+ DEFAULT_USAGE = {
56
+ input: 0,
57
+ cache_write: 0,
58
+ cache_read: 0,
59
+ output: 0,
60
+ total: 0,
61
+ raw: {}
62
+ }.freeze
63
+
64
+ BLOCK_EVENT_TRANSITIONS = {
65
+ text_start: { block_type: :text, phase: :start },
66
+ text_delta: { block_type: :text, phase: :delta },
67
+ text_end: { block_type: :text, phase: :end },
68
+ tool_start: { block_type: :tool, phase: :start },
69
+ tool_delta: { block_type: :tool, phase: :delta },
70
+ tool_end: { block_type: :tool, phase: :end },
71
+ reasoning_start: { block_type: :reasoning, phase: :start },
72
+ reasoning_delta: { block_type: :reasoning, phase: :delta },
73
+ reasoning_end: { block_type: :reasoning, phase: :end }
74
+ }.freeze
75
+
76
+ def initialize(provider: nil, api: nil)
77
+ @provider = provider
78
+ @api = api
79
+ @message_hash = {}
80
+ @usage_hash = default_usage
81
+ @blocks = []
82
+ @next_content_index = 0
83
+ @active_block_type = nil
84
+ @active_content_index = nil
85
+ @timestamp = nil
86
+ end
87
+
88
+ def result
89
+ ensure_timestamp!
90
+
91
+ message_hash.merge(
92
+ timestamp: @timestamp,
93
+ usage: usage_hash,
94
+ content: serialized_blocks
95
+ )
96
+ end
97
+
98
+ def final_result
99
+ result.merge(provider: @provider, api: @api)
100
+ end
101
+
102
+ def active_tool?
103
+ active_block_type == :tool
104
+ end
105
+
106
+ def push(event_patch, &block)
107
+ raise ArgumentError, "Normalized stream event patch must be a Hash" unless event_patch.is_a?(Hash)
108
+
109
+ event_patch = symbolize_keys(event_patch)
110
+ type = event_patch.fetch(:type).to_sym
111
+ event_patch = prepare_event_patch(event_patch.merge(type:), type)
112
+ ensure_timestamp!
113
+
114
+ if type == :message_end
115
+ @final_message = AssistantMessage.new(final_result)
116
+ block.call(AssistantStreamMessageEndEvent.new(type:, message: final_message)) if block
117
+ return nil
118
+ end
119
+
120
+ event = build_event(event_patch, partial: empty_partial)
121
+ accumulate(event)
122
+ content_index = event.content_index if event.respond_to?(:content_index)
123
+ commit_block_transition(type, content_index)
124
+ event = build_event(event_patch, partial: partial_message)
125
+ block.call(event) if block
126
+
127
+ nil
128
+ end
129
+
130
+ private
131
+
132
+ def prepare_event_patch(event_patch, type)
133
+ transition = BLOCK_EVENT_TRANSITIONS[type]
134
+ return event_patch unless transition
135
+
136
+ block_type = transition[:block_type]
137
+
138
+ case transition[:phase]
139
+ when :start
140
+ validate_start!(block_type)
141
+ event_patch.merge(content_index: @next_content_index)
142
+ when :delta
143
+ validate_delta!(type, block_type)
144
+ event_patch.merge(content_index: @active_content_index)
145
+ when :end
146
+ validate_end!(block_type)
147
+ event_patch.merge(content_index: @active_content_index)
148
+ end
149
+ end
150
+
151
+ def validate_start!(block_type)
152
+ return unless @active_block_type
153
+
154
+ raise ArgumentError, "Cannot start #{block_type} block while #{@active_block_type} block is active"
155
+ end
156
+
157
+ def validate_delta!(type, block_type)
158
+ unless @active_block_type
159
+ raise ArgumentError, "Cannot apply #{type} without an active #{block_type} block"
160
+ end
161
+ return if @active_block_type == block_type
162
+
163
+ raise ArgumentError, "Cannot apply #{type} while #{@active_block_type} block is active"
164
+ end
165
+
166
+ def validate_end!(block_type)
167
+ unless @active_block_type
168
+ raise ArgumentError, "Cannot end #{block_type} block without an active #{block_type} block"
169
+ end
170
+ return if @active_block_type == block_type
171
+
172
+ raise ArgumentError, "Cannot end #{block_type} block while #{@active_block_type} block is active"
173
+ end
174
+
175
+ def commit_block_transition(type, content_index)
176
+ transition = BLOCK_EVENT_TRANSITIONS[type]
177
+ return unless transition
178
+
179
+ case transition[:phase]
180
+ when :start
181
+ @active_block_type = transition[:block_type]
182
+ @active_content_index = content_index
183
+ @next_content_index += 1
184
+ when :end
185
+ @active_block_type = nil
186
+ @active_content_index = nil
187
+ end
188
+ end
189
+
190
+ def build_event(event_patch, partial:)
191
+ event_patch = symbolize_keys(event_patch)
192
+ type = event_patch.fetch(:type).to_sym
193
+
194
+ case type
195
+ when :message_start, :message_delta
196
+ delta = symbolize_keys(event_patch[:delta] || {})
197
+ raw_usage = event_patch[:usage] || delta.delete(:usage) || {}
198
+ usage = raw_usage.empty? ? {} : normalized_usage(raw_usage)
199
+
200
+ AssistantStreamMessageEvent.new(
201
+ type:,
202
+ delta:,
203
+ usage:,
204
+ partial:
205
+ )
206
+ when :tool_start
207
+ AssistantToolStartEvent.new(
208
+ type:,
209
+ content_index: event_patch.fetch(:content_index),
210
+ delta: string_value(event_patch[:delta]),
211
+ id: event_patch[:id],
212
+ name: event_patch[:name],
213
+ partial:
214
+ )
215
+ when :reasoning_start, :reasoning_delta, :reasoning_end
216
+ AssistantStreamReasoningEvent.new(
217
+ type:,
218
+ content_index: event_patch.fetch(:content_index),
219
+ delta: string_value(event_patch[:delta]),
220
+ signature: string_value(event_patch[:signature]),
221
+ partial:
222
+ )
223
+ when :text_start, :text_delta, :text_end, :tool_delta, :tool_end
224
+ AssistantStreamEvent.new(
225
+ type:,
226
+ content_index: event_patch.fetch(:content_index),
227
+ delta: string_value(event_patch[:delta]),
228
+ partial:
229
+ )
230
+ else
231
+ raise ArgumentError, "Unsupported normalized stream event type: #{type.inspect}"
232
+ end
233
+ end
234
+
235
+ def accumulate(event)
236
+ @timestamp = event.delta[:timestamp] if event.respond_to?(:delta) && event.delta.is_a?(Hash) && event.delta[:timestamp]
237
+
238
+ case event.type
239
+ when :text_start
240
+ blocks[event.content_index] = {
241
+ type: "text",
242
+ text: ""
243
+ }
244
+ blocks[event.content_index][:text] += event.delta
245
+ when :text_delta, :text_end
246
+ blocks[event.content_index][:text] += event.delta
247
+ when :tool_start
248
+ blocks[event.content_index] = {
249
+ type: "tool_use",
250
+ id: event.id,
251
+ name: event.name,
252
+ input: event.delta.to_s
253
+ }
254
+ when :tool_delta, :tool_end
255
+ blocks[event.content_index][:input] += event.delta
256
+ when :message_start
257
+ message_hash.merge!(event.delta)
258
+ when :reasoning_start
259
+ blocks[event.content_index] = {
260
+ type: "reasoning",
261
+ reasoning: "",
262
+ signature: ""
263
+ }
264
+ blocks[event.content_index][:reasoning] += event.delta
265
+ blocks[event.content_index][:signature] += event.signature
266
+ when :reasoning_delta, :reasoning_end
267
+ blocks[event.content_index][:reasoning] += event.delta
268
+ blocks[event.content_index][:signature] += event.signature
269
+ when :message_delta
270
+ message_hash.merge!(event.delta)
271
+ assign_usage(event.usage) unless event.usage.empty?
272
+ end
273
+ end
274
+
275
+ def empty_partial
276
+ PartialAssistantMessage.new(timestamp: @timestamp)
277
+ end
278
+
279
+ def partial_message
280
+ PartialAssistantMessage.new(partial_result)
281
+ end
282
+
283
+ def partial_result
284
+ ensure_timestamp!
285
+
286
+ message_hash.merge(
287
+ timestamp: @timestamp,
288
+ content: serialized_blocks
289
+ )
290
+ end
291
+
292
+ def assign_usage(usage)
293
+ @usage_hash = normalized_usage(usage)
294
+ end
295
+
296
+ def normalized_usage(usage)
297
+ usage = default_usage.merge(symbolize_keys(usage).slice(*DEFAULT_USAGE.keys))
298
+ usage[:total] = usage[:input] + usage[:cache_write] + usage[:cache_read] + usage[:output]
299
+ usage[:raw] ||= {}
300
+ usage
301
+ end
302
+
303
+ def default_usage
304
+ DEFAULT_USAGE.merge(raw: {})
305
+ end
306
+
307
+ def serialized_blocks
308
+ blocks.map do |content_block|
309
+ next content_block unless content_block[:type] == "tool_use"
310
+
311
+ content_block.merge(input: LlmGateway::Utils.deep_symbolize_keys(parse_tool_input(content_block[:input])))
312
+ end
313
+ end
314
+
315
+ def parse_tool_input(input)
316
+ return {} if input.nil? || input.empty?
317
+
318
+ JSON.parse(input)
319
+ rescue JSON::ParserError
320
+ {}
321
+ end
322
+
323
+ def symbolize_keys(hash)
324
+ hash.to_h.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
325
+ end
326
+
327
+ def string_value(value)
328
+ value.nil? ? "" : value.to_s
329
+ end
330
+
331
+ def ensure_timestamp!
332
+ @timestamp ||= (Time.now.to_f * 1000).to_i
333
+ end
334
+ end
335
+ end
336
+ end
@@ -10,8 +10,6 @@ module LlmGateway
10
10
 
11
11
  def input_sanitizer = OpenAI::ChatCompletions::InputMessageSanitizer
12
12
 
13
- def output_mapper = OpenAI::ChatCompletions::OutputMapper
14
-
15
13
  def file_output_mapper = OpenAI::FileOutputMapper
16
14
 
17
15
  def option_mapper = OpenAI::ChatCompletions::OptionMapper
@@ -11,18 +11,12 @@ module LlmGateway
11
11
 
12
12
  def input_sanitizer = InputMessageSanitizer
13
13
 
14
- def output_mapper = OpenAI::Responses::OutputMapper
15
-
16
14
  def file_output_mapper = OpenAI::FileOutputMapper
17
15
 
18
16
  def option_mapper = OpenAI::Responses::OptionMapper
19
17
 
20
18
  def stream_mapper = OpenAI::Responses::StreamMapper
21
19
 
22
- def perform_chat(messages, tools:, system:, **options)
23
- client.responses(messages, tools: tools, system: system, **options)
24
- end
25
-
26
20
  def perform_stream(messages, tools:, system:, **options, &block)
27
21
  client.stream_responses(messages, tools: tools, system: system, **options, &block)
28
22
  end
@@ -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