llm.rb 4.14.0 → 4.15.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.
@@ -4,6 +4,8 @@ class LLM::OpenAI
4
4
  ##
5
5
  # @private
6
6
  class Responses::StreamParser
7
+ EMPTY_HASH = {}.freeze
8
+
7
9
  ##
8
10
  # Returns the fully constructed response body
9
11
  # @return [Hash]
@@ -16,7 +18,15 @@ class LLM::OpenAI
16
18
  def initialize(stream)
17
19
  @body = {"output" => []}
18
20
  @stream = stream
19
- @emits = {tools: []}
21
+ @emits = {tools: {}}
22
+ @can_emit_content = stream.respond_to?(:on_content)
23
+ @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
24
+ @can_emit_tool_call = stream.respond_to?(:on_tool_call)
25
+ @can_push_content = stream.respond_to?(:<<)
26
+ @cached_output_index = nil
27
+ @cached_output_item = nil
28
+ @cached_content_index = nil
29
+ @cached_content_part = nil
20
30
  end
21
31
 
22
32
  ##
@@ -31,126 +41,238 @@ class LLM::OpenAI
31
41
  # @return [void]
32
42
  def free
33
43
  @emits.clear
44
+ clear_cache!
34
45
  end
35
46
 
36
47
  private
37
48
 
49
+ ##
50
+ # @group Dispatchers
51
+
38
52
  def handle_event(chunk)
39
- case chunk["type"]
40
- when "response.created"
41
- chunk.each do |k, v|
42
- next if k == "type"
43
- @body[k] = v
44
- end
45
- @body["output"] ||= []
46
- when "response.in_progress", "response.completed"
47
- response = chunk["response"] || {}
48
- response.each do |k, v|
49
- next if k == "output" && @body["output"].is_a?(Array) && @body["output"].any?
50
- @body[k] = v
53
+ output = @body["output"]
54
+ type = chunk["type"]
55
+ if type == "response.output_text.delta"
56
+ merge_output_text_delta!(output, chunk)
57
+ elsif type == "response.content_part.added"
58
+ merge_content_part!(output, chunk)
59
+ elsif type == "response.output_item.added"
60
+ merge_output_item!(output, chunk)
61
+ elsif type == "response.function_call_arguments.delta"
62
+ merge_function_call_arguments_delta!(output, chunk)
63
+ elsif type == "response.function_call_arguments.done"
64
+ merge_function_call_arguments_done!(output, chunk)
65
+ elsif type == "response.output_item.done"
66
+ merge_output_item!(output, chunk)
67
+ elsif type == "response.content_part.done"
68
+ merge_content_part!(output, chunk, part_key: "part")
69
+ else
70
+ case type
71
+ when "response.created"
72
+ merge_response_created!(chunk)
73
+ when "response.in_progress", "response.completed"
74
+ merge_response_state!(output, chunk)
75
+ when "response.reasoning_summary_text.delta"
76
+ merge_reasoning_summary_text_delta!(output, chunk)
77
+ when "response.reasoning_summary_text.done"
78
+ merge_reasoning_summary_text_done!(output, chunk)
51
79
  end
52
- @body["output"] ||= response["output"] || []
53
- when "response.output_item.added"
54
- output_index = chunk["output_index"]
55
- item = chunk["item"]
56
- @body["output"][output_index] = item
57
- @body["output"][output_index]["content"] ||= []
58
- @body["output"][output_index]["summary"] ||= [] if item["type"] == "reasoning"
59
- when "response.content_part.added"
60
- output_index = chunk["output_index"]
61
- content_index = chunk["content_index"]
62
- part = chunk["part"]
63
- @body["output"][output_index] ||= {"content" => []}
64
- @body["output"][output_index]["content"] ||= []
65
- @body["output"][output_index]["content"][content_index] = part
66
- when "response.reasoning_summary_text.delta"
67
- output_item = @body["output"][chunk["output_index"]]
68
- if output_item && output_item["type"] == "reasoning"
69
- summary_index = chunk["summary_index"] || 0
70
- output_item["summary"] ||= []
71
- output_item["summary"][summary_index] ||= {"type" => "summary_text", "text" => +""}
72
- output_item["summary"][summary_index]["text"] << chunk["delta"]
73
- emit_reasoning_content(chunk["delta"])
74
- end
75
- when "response.reasoning_summary_text.done"
76
- output_item = @body["output"][chunk["output_index"]]
77
- if output_item && output_item["type"] == "reasoning"
78
- summary_index = chunk["summary_index"] || 0
79
- output_item["summary"] ||= []
80
- output_item["summary"][summary_index] = {
81
- "type" => "summary_text",
82
- "text" => chunk["text"]
83
- }
84
- end
85
- when "response.output_text.delta"
86
- output_index = chunk["output_index"]
87
- content_index = chunk["content_index"]
80
+ end
81
+ end
82
+
83
+ ##
84
+ # @endgroup
85
+
86
+ ##
87
+ # @group Mergers
88
+
89
+ def merge_response_created!(chunk)
90
+ clear_cache!
91
+ chunk.each do |k, v|
92
+ next if k == "type"
93
+ @body[k] = v
94
+ end
95
+ @body["output"] ||= []
96
+ end
97
+
98
+ def merge_response_state!(output, chunk)
99
+ clear_cache!
100
+ response = chunk["response"] || EMPTY_HASH
101
+ response.each do |k, v|
102
+ next if k == "output" && Array === output && output.any?
103
+ @body[k] = v
104
+ end
105
+ @body["output"] ||= response["output"] || []
106
+ end
107
+
108
+ def merge_output_item!(output, chunk)
109
+ output_index = chunk["output_index"]
110
+ item = chunk["item"]
111
+ output[output_index] = item
112
+ item["content"] ||= [] if item["type"] == "message" || item.key?("content")
113
+ item["summary"] ||= [] if item["type"] == "reasoning"
114
+ cache_output_item!(output_index, item)
115
+ end
116
+
117
+ def merge_content_part!(output, chunk, part_key: "part")
118
+ output_index = chunk["output_index"]
119
+ content_index = chunk["content_index"]
120
+ part = chunk[part_key]
121
+ output_item = output_item_at(output, output_index)
122
+ unless output_item
123
+ output_item = {"content" => []}
124
+ output[output_index] = output_item
125
+ cache_output_item!(output_index, output_item)
126
+ end
127
+ content = output_item["content"] ||= []
128
+ content[content_index] = part
129
+ cache_content_part!(content_index, part)
130
+ end
131
+
132
+ def merge_output_text_delta!(output, chunk)
133
+ content_part = content_part_at(output, chunk["output_index"], chunk["content_index"])
134
+ if content_part && content_part["type"] == "output_text"
88
135
  delta_text = chunk["delta"]
89
- output_item = @body["output"][output_index]
90
- if output_item && output_item["content"]
91
- content_part = output_item["content"][content_index]
92
- if content_part && content_part["type"] == "output_text"
93
- content_part["text"] ||= ""
94
- content_part["text"] << delta_text
95
- emit_content(delta_text)
96
- end
136
+ if text = content_part["text"]
137
+ text << delta_text
138
+ else
139
+ content_part["text"] = delta_text
97
140
  end
98
- when "response.function_call_arguments.delta"
99
- output_item = @body["output"][chunk["output_index"]]
100
- if output_item && output_item["type"] == "function_call"
101
- output_item["arguments"] ||= +""
102
- output_item["arguments"] << chunk["delta"]
141
+ emit_content(delta_text)
142
+ end
143
+ end
144
+
145
+ def merge_reasoning_summary_text_delta!(output, chunk)
146
+ output_item = output_item_at(output, chunk["output_index"])
147
+ if output_item && output_item["type"] == "reasoning"
148
+ summary_index = chunk["summary_index"] || 0
149
+ delta = chunk["delta"]
150
+ summary = output_item["summary"] ||= []
151
+ if summary_item = summary[summary_index]
152
+ summary_item["text"] << delta
153
+ else
154
+ summary[summary_index] = {"type" => "summary_text", "text" => delta}
103
155
  end
104
- when "response.function_call_arguments.done"
105
- output_item = @body["output"][chunk["output_index"]]
106
- if output_item && output_item["type"] == "function_call"
107
- output_item["arguments"] = chunk["arguments"]
108
- emit_tool(chunk["output_index"], output_item)
156
+ emit_reasoning_content(delta)
157
+ end
158
+ end
159
+
160
+ def merge_reasoning_summary_text_done!(output, chunk)
161
+ output_item = output_item_at(output, chunk["output_index"])
162
+ if output_item && output_item["type"] == "reasoning"
163
+ summary_index = chunk["summary_index"] || 0
164
+ output_item["summary"] ||= []
165
+ output_item["summary"][summary_index] = {
166
+ "type" => "summary_text",
167
+ "text" => chunk["text"]
168
+ }
169
+ end
170
+ end
171
+
172
+ def merge_function_call_arguments_delta!(output, chunk)
173
+ output_item = output_item_at(output, chunk["output_index"])
174
+ if output_item && output_item["type"] == "function_call"
175
+ if arguments = output_item["arguments"]
176
+ arguments << chunk["delta"]
177
+ else
178
+ output_item["arguments"] = chunk["delta"]
109
179
  end
110
- when "response.output_item.done"
111
- output_index = chunk["output_index"]
112
- item = chunk["item"]
113
- @body["output"][output_index] = item
114
- when "response.content_part.done"
115
- output_index = chunk["output_index"]
116
- content_index = chunk["content_index"]
117
- part = chunk["part"]
118
- @body["output"][output_index] ||= {"content" => []}
119
- @body["output"][output_index]["content"] ||= []
120
- @body["output"][output_index]["content"][content_index] = part
121
180
  end
122
181
  end
123
182
 
183
+ def merge_function_call_arguments_done!(output, chunk)
184
+ output_item = output_item_at(output, chunk["output_index"])
185
+ if output_item && output_item["type"] == "function_call"
186
+ output_item["arguments"] = chunk["arguments"]
187
+ emit_tool(chunk["output_index"], output_item)
188
+ end
189
+ end
190
+
191
+ ##
192
+ # @endgroup
193
+
194
+ ##
195
+ # @group Cache
196
+
197
+ def output_item_at(output, output_index)
198
+ if @cached_output_index == output_index
199
+ @cached_output_item
200
+ else
201
+ cache_output_item!(output_index, output[output_index])
202
+ end
203
+ end
204
+
205
+ def content_part_at(output, output_index, content_index)
206
+ if @cached_output_index == output_index && @cached_content_index == content_index
207
+ @cached_content_part
208
+ else
209
+ output_item = output_item_at(output, output_index)
210
+ content = output_item && output_item["content"]
211
+ cache_content_part!(content_index, content && content[content_index])
212
+ end
213
+ end
214
+
215
+ def cache_output_item!(output_index, output_item)
216
+ @cached_output_index = output_index
217
+ @cached_output_item = output_item
218
+ @cached_content_index = nil
219
+ @cached_content_part = nil
220
+ output_item
221
+ end
222
+
223
+ def cache_content_part!(content_index, content_part)
224
+ @cached_content_index = content_index
225
+ @cached_content_part = content_part
226
+ content_part
227
+ end
228
+
229
+ def clear_cache!
230
+ @cached_output_index = nil
231
+ @cached_output_item = nil
232
+ @cached_content_index = nil
233
+ @cached_content_part = nil
234
+ end
235
+
236
+ ##
237
+ # @endgroup
238
+
239
+ ##
240
+ # @group Emitters
241
+
124
242
  def emit_content(value)
125
- if @stream.respond_to?(:on_content)
243
+ if @can_emit_content
126
244
  @stream.on_content(value)
127
- elsif @stream.respond_to?(:<<)
245
+ elsif @can_push_content
128
246
  @stream << value
129
247
  end
130
248
  end
131
249
 
132
250
  def emit_reasoning_content(value)
133
- @stream.on_reasoning_content(value) if @stream.respond_to?(:on_reasoning_content)
251
+ @stream.on_reasoning_content(value) if @can_emit_reasoning_content
134
252
  end
135
253
 
136
254
  def emit_tool(index, tool)
137
- return unless @stream.respond_to?(:on_tool_call)
138
- return unless complete_tool?(tool)
139
- return if @emits[:tools].include?(index)
140
- function, error = resolve_tool(tool)
141
- @emits[:tools] << index
255
+ return unless @can_emit_tool_call
256
+ return if @emits[:tools][index]
257
+ return unless tool["call_id"] && tool["name"]
258
+ arguments = parse_arguments(tool["arguments"])
259
+ return unless arguments
260
+ function, error = resolve_tool(tool, arguments)
261
+ @emits[:tools][index] = true
142
262
  @stream.on_tool_call(function, error)
143
263
  end
144
264
 
145
- def complete_tool?(tool)
146
- tool["call_id"] && tool["name"] && parse_arguments(tool["arguments"])
147
- end
265
+ ##
266
+ # @endgroup
148
267
 
149
- def resolve_tool(tool)
268
+ ##
269
+ # @group Resolvers
270
+
271
+ def resolve_tool(tool, arguments)
150
272
  registered = LLM::Function.find_by_name(tool["name"])
151
273
  fn = (registered || LLM::Function.new(tool["name"])).dup.tap do |fn|
152
274
  fn.id = tool["call_id"]
153
- fn.arguments = parse_arguments(tool["arguments"])
275
+ fn.arguments = arguments
154
276
  end
155
277
  [fn, (registered ? nil : @stream.tool_not_found(fn))]
156
278
  end
@@ -162,5 +284,8 @@ class LLM::OpenAI
162
284
  rescue *LLM.json.parser_error
163
285
  nil
164
286
  end
287
+
288
+ ##
289
+ # @endgroup
165
290
  end
166
291
  end
@@ -4,6 +4,8 @@ class LLM::OpenAI
4
4
  ##
5
5
  # @private
6
6
  class StreamParser
7
+ EMPTY_HASH = {}.freeze
8
+
7
9
  ##
8
10
  # Returns the fully constructed response body
9
11
  # @return [Hash]
@@ -14,7 +16,11 @@ class LLM::OpenAI
14
16
  def initialize(stream)
15
17
  @body = {}
16
18
  @stream = stream
17
- @emits = {tools: []}
19
+ @emits = {tools: {}}
20
+ @can_emit_content = stream.respond_to?(:on_content)
21
+ @can_emit_reasoning_content = stream.respond_to?(:on_reasoning_content)
22
+ @can_emit_tool_call = stream.respond_to?(:on_tool_call)
23
+ @can_push_content = stream.respond_to?(:<<)
18
24
  end
19
25
 
20
26
  ##
@@ -45,45 +51,68 @@ class LLM::OpenAI
45
51
  end
46
52
 
47
53
  def merge_choices!(choices)
54
+ body_choices = @body["choices"]
48
55
  choices.each do |choice|
49
56
  index = choice["index"]
50
- if @body["choices"][index]
51
- target_message = @body["choices"][index]["message"]
52
- delta = choice["delta"] || {}
53
- delta.each do |key, value|
54
- next if value.nil?
55
- if key == "content"
56
- target_message[key] ||= +""
57
- target_message[key] << value
58
- emit_content(value)
59
- elsif key == "reasoning_content"
60
- target_message[key] ||= +""
61
- target_message[key] << value
62
- emit_reasoning_content(value)
63
- elsif key == "tool_calls"
64
- merge_tools!(target_message, value)
65
- else
66
- target_message[key] = value
67
- end
68
- end
57
+ delta = choice["delta"] || EMPTY_HASH
58
+ target_message = if body_choice = body_choices[index]
59
+ body_choice["message"]
60
+ else
61
+ body_choices[index] = {"message" => {"role" => "assistant"}}
62
+ body_choices[index]["message"]
63
+ end
64
+ merge_delta!(target_message, delta)
65
+ end
66
+ end
67
+
68
+ def merge_delta!(target_message, delta)
69
+ if delta.length == 1
70
+ merge_single_delta!(target_message, delta)
71
+ elsif content = delta["content"]
72
+ if target_content = target_message["content"]
73
+ target_content << content
74
+ else
75
+ target_message["content"] = content
76
+ end
77
+ emit_content(content)
78
+ elsif reasoning = delta["reasoning_content"]
79
+ if target_reasoning = target_message["reasoning_content"]
80
+ target_reasoning << reasoning
81
+ else
82
+ target_message["reasoning_content"] = reasoning
83
+ end
84
+ emit_reasoning_content(reasoning)
85
+ elsif tool_calls = delta["tool_calls"]
86
+ merge_tools!(target_message, tool_calls)
87
+ end
88
+ return if delta.length <= 1
89
+ delta.each do |key, value|
90
+ next if value.nil? || key == "content" || key == "reasoning_content" || key == "tool_calls"
91
+ target_message[key] = value
92
+ end
93
+ end
94
+
95
+ def merge_single_delta!(target_message, delta)
96
+ if content = delta["content"]
97
+ if target_content = target_message["content"]
98
+ target_content << content
99
+ else
100
+ target_message["content"] = content
101
+ end
102
+ emit_content(content)
103
+ return
104
+ end
105
+ if reasoning = delta["reasoning_content"]
106
+ if target_reasoning = target_message["reasoning_content"]
107
+ target_reasoning << reasoning
69
108
  else
70
- message_hash = {"role" => "assistant"}
71
- @body["choices"][index] = {"message" => message_hash}
72
- (choice["delta"] || {}).each do |key, value|
73
- next if value.nil?
74
- if key == "content"
75
- emit_content(value)
76
- message_hash[key] = value
77
- elsif key == "reasoning_content"
78
- emit_reasoning_content(value)
79
- message_hash[key] = value
80
- elsif key == "tool_calls"
81
- merge_tools!(message_hash, value)
82
- else
83
- message_hash[key] = value
84
- end
85
- end
109
+ target_message["reasoning_content"] = reasoning
86
110
  end
111
+ emit_reasoning_content(reasoning)
112
+ return
113
+ end
114
+ if tool_calls = delta["tool_calls"]
115
+ merge_tools!(target_message, tool_calls)
87
116
  end
88
117
  end
89
118
 
@@ -93,12 +122,11 @@ class LLM::OpenAI
93
122
  tindex = toola["index"]
94
123
  tindex = index unless Integer === tindex && tindex >= 0
95
124
  toolb = target["tool_calls"][tindex]
96
- if toolb && toola["function"] && toolb["function"]
125
+ functiona = toola["function"]
126
+ functionb = toolb && toolb["function"]
127
+ if functiona && functionb
97
128
  # Append to existing function arguments
98
- toola["function"].each do |func_key, func_value|
99
- toolb["function"][func_key] ||= +""
100
- toolb["function"][func_key] << func_value
101
- end
129
+ merge_function!(functionb, functiona)
102
130
  else
103
131
  target["tool_calls"][tindex] = toola
104
132
  end
@@ -106,40 +134,61 @@ class LLM::OpenAI
106
134
  end
107
135
  end
108
136
 
137
+ def merge_function!(target, source)
138
+ if arguments = source["arguments"]
139
+ if target_arguments = target["arguments"]
140
+ target_arguments << arguments
141
+ else
142
+ target["arguments"] = arguments
143
+ end
144
+ end
145
+ if name = source["name"]
146
+ if target_name = target["name"]
147
+ target_name << name
148
+ else
149
+ target["name"] = name
150
+ end
151
+ end
152
+ return if source.length <= 2
153
+ source.each do |func_key, func_value|
154
+ next if func_key == "arguments" || func_key == "name"
155
+ target[func_key] ||= +""
156
+ target[func_key] << func_value
157
+ end
158
+ end
159
+
109
160
  def emit_content(value)
110
- if @stream.respond_to?(:on_content)
161
+ if @can_emit_content
111
162
  @stream.on_content(value)
112
- elsif @stream.respond_to?(:<<)
163
+ elsif @can_push_content
113
164
  @stream << value
114
165
  end
115
166
  end
116
167
 
117
168
  def emit_reasoning_content(value)
118
- if @stream.respond_to?(:on_reasoning_content)
169
+ if @can_emit_reasoning_content
119
170
  @stream.on_reasoning_content(value)
120
171
  end
121
172
  end
122
173
 
123
174
  def emit_tool(tool, tindex)
124
- return unless @stream.respond_to?(:on_tool_call)
125
- return unless complete_tool?(tool)
126
- return if @emits[:tools].include?(tindex)
127
- function, error = resolve_tool(tool)
128
- @emits[:tools] << tindex
129
- @stream.on_tool_call(function, error)
130
- end
131
-
132
- def complete_tool?(tool)
175
+ return unless @can_emit_tool_call
176
+ return if @emits[:tools][tindex]
133
177
  function = tool["function"]
134
- function && tool["id"] && function["name"] && parse_arguments(function["arguments"])
178
+ return unless function && tool["id"] && function["name"]
179
+ return unless arguments_complete?(function["arguments"])
180
+ arguments = parse_arguments(function["arguments"])
181
+ return unless arguments
182
+ function, error = resolve_tool(tool, function, arguments)
183
+ @emits[:tools][tindex] = true
184
+ @stream.on_tool_call(function, error)
135
185
  end
136
186
 
137
- def resolve_tool(tool)
138
- function = tool["function"]
187
+ def resolve_tool(tool, function, arguments)
139
188
  registered = LLM::Function.find_by_name(function["name"])
140
189
  fn = (registered || LLM::Function.new(function["name"])).dup.tap do |fn|
141
190
  fn.id = tool["id"]
142
- fn.arguments = parse_arguments(function["arguments"])
191
+ fn.arguments = arguments
143
192
  end
144
193
  [fn, (registered ? nil : @stream.tool_not_found(fn))]
145
194
  end
@@ -151,5 +200,10 @@ class LLM::OpenAI
151
200
  rescue *LLM.json.parser_error
152
201
  nil
153
202
  end
203
+
204
+ def arguments_complete?(arguments)
205
+ value = arguments.to_s.rstrip
206
+ !value.empty? && value.end_with?("}")
207
+ end
154
208
  end
155
209
  end
data/lib/llm/response.rb CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  module LLM
4
4
  ##
5
- # {LLM::Response LLM::Response} encapsulates a response
6
- # from an LLM provider. It is returned by all methods
7
- # that make requests to a provider, and sometimes extended
8
- # with provider-specific functionality.
5
+ # {LLM::Response LLM::Response} is the normalized base shape for
6
+ # provider and endpoint responses in llm.rb.
7
+ #
8
+ # Provider calls return an instance of this class, then extend it
9
+ # with provider-, endpoint-, or context-specific modules so response
10
+ # handling can share one common surface without flattening away
11
+ # specialized behavior.
12
+ #
13
+ # The normalized response still keeps the original
14
+ # {Net::HTTPResponse Net::HTTPResponse} available through {#res}
15
+ # when callers need direct access to raw HTTP details such as
16
+ # headers, status codes, or unadapted bodies.
9
17
  class Response
10
18
  require "json"
11
19