llm_providers 0.1.0 → 0.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd68399074379aa3cca94d766217abe7270698fa764b1656d1fc585977c47e49
4
- data.tar.gz: 6b82fe10d4ae4bb414317c1fbbca2252976032032fcc846c2864154cb54aee62
3
+ metadata.gz: f2f9351f5b2fcd03887b26b61aad5a7efdd810207d9ae96577455b5450730c96
4
+ data.tar.gz: 17aa0c0c838045102727f5650ba5be88d232ac6e54b7bf283eb5d8bcc7b1b2d4
5
5
  SHA512:
6
- metadata.gz: 77759c7d037da031e31db5ce76456279d5c1ed104b7df60d19289ceb6cba0ad26b7de1aa80f1e78b1afcc18b41d780ffd34718cb318b6cf905a1947adde806e0
7
- data.tar.gz: 598b707ba1f7160b7b6f03b3d291a7d2bf0d726afe55d6fd86b60747c8ebc440438c5e5753597eb36016b0012552404aedc98f3cc3dc28e5ba93465c78a8b73a
6
+ metadata.gz: 6ec192e3c3bd4d66d29e7dd619f7821cd2a79d41bd0df3ee61376dee03bf5fa0fd78b1fb1fb9f0ee2a4481c0f419139decade2f70767bc7493d6c59f619bc158
7
+ data.tar.gz: 5482d3ed05b2dedc3285cfc6b337e08ed1334826eefa4549e79a64a1effabe4c97078fd550b0946db24b4b5b9f5f14885636a683352cd6f9559e9982808758e1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.1] - 2026-02-27
4
+
5
+ ### Fixed
6
+
7
+ - Fix SSE streaming parser losing data when HTTP chunk boundaries split SSE data lines mid-line
8
+ - Added line buffering to `stream_response` in Anthropic, OpenAI, and OpenRouter providers
9
+ - Fixes tool call `input_json_delta` events being dropped, which caused tool calls with empty `{}` input
10
+
3
11
  ## [0.1.0] - 2026-02-13
4
12
 
5
13
  - Initial release
@@ -131,64 +131,19 @@ module LlmProviders
131
131
  payload[:stream] = true
132
132
  started_at = Time.now
133
133
 
134
+ puts "[Anthropic] stream_response payload tools: #{payload[:tools]&.size || 0}"
135
+ puts "[Anthropic] payload[:tools] = #{payload[:tools].inspect}" if payload[:tools]
136
+
134
137
  full_content = ""
135
138
  tool_calls = []
136
139
  usage = {}
140
+ line_buffer = ""
137
141
 
138
- conn = Faraday.new do |f|
139
- f.options.open_timeout = 10
140
- f.options.read_timeout = 300
141
- f.options.write_timeout = 30
142
- f.adapter Faraday.default_adapter
143
- end
144
-
145
- response = conn.post(API_URL) do |req|
146
- req.headers["Content-Type"] = "application/json"
147
- req.headers["x-api-key"] = api_key
148
- req.headers["anthropic-version"] = API_VERSION
149
- req.body = payload.to_json
150
- req.options.on_data = proc do |chunk, _|
151
- process_stream_chunk(chunk, full_content, tool_calls) do |parsed|
152
- if parsed[:content]
153
- full_content += parsed[:content]
154
- block.call(content: parsed[:content])
155
- end
156
- usage = parsed[:usage] if parsed[:usage]
157
- end
158
- end
159
- end
160
-
161
- unless response.success?
162
- error_message = begin
163
- if response.body.is_a?(Hash)
164
- response.body["error"]&.dig("message")
165
- else
166
- response.body.to_s
167
- end
168
- rescue StandardError
169
- response.body.to_s
170
- end
171
- raise ProviderError.new(
172
- (error_message && !error_message.empty? ? error_message : nil) || "API error",
173
- code: "anthropic_error"
174
- )
175
- end
176
-
177
- {
178
- content: full_content,
179
- tool_calls: tool_calls,
180
- usage: usage,
181
- latency_ms: ((Time.now - started_at) * 1000).to_i,
182
- raw_response: { content: full_content, tool_calls: tool_calls }
183
- }
184
- end
185
-
186
- def process_stream_chunk(chunk, _full_content, tool_calls)
187
- chunk.each_line do |line|
142
+ process_sse_line = proc do |line|
188
143
  next unless line.start_with?("data: ")
189
144
 
190
145
  data = line.sub("data: ", "").strip
191
- next if data == "[DONE]"
146
+ next if data == "[DONE]" || data.empty?
192
147
 
193
148
  begin
194
149
  event = JSON.parse(data)
@@ -196,7 +151,11 @@ module LlmProviders
196
151
  case event["type"]
197
152
  when "content_block_delta"
198
153
  if event.dig("delta", "type") == "text_delta"
199
- yield(content: event.dig("delta", "text"))
154
+ text = event.dig("delta", "text")
155
+ if text
156
+ full_content += text
157
+ block.call(content: text)
158
+ end
200
159
  elsif event.dig("delta", "type") == "input_json_delta"
201
160
  if tool_calls.any?
202
161
  tool_calls.last[:input_json] ||= ""
@@ -204,7 +163,9 @@ module LlmProviders
204
163
  end
205
164
  end
206
165
  when "content_block_start"
166
+ puts "[Anthropic] content_block_start: #{event["content_block"]&.dig("type")}"
207
167
  if event.dig("content_block", "type") == "tool_use"
168
+ puts "[Anthropic] Tool use started: #{event.dig("content_block", "name")}"
208
169
  tool_calls << {
209
170
  id: event.dig("content_block", "id"),
210
171
  name: event.dig("content_block", "name"),
@@ -223,17 +184,65 @@ module LlmProviders
223
184
  end
224
185
  when "message_delta"
225
186
  if event["usage"]
226
- yield(usage: {
187
+ usage = {
227
188
  input: event.dig("usage", "input_tokens"),
228
189
  output: event.dig("usage", "output_tokens"),
229
190
  cached_input: event.dig("usage", "cache_read_input_tokens")
230
- })
191
+ }
231
192
  end
232
193
  end
233
194
  rescue JSON::ParserError
234
195
  # Skip invalid JSON
235
196
  end
236
197
  end
198
+
199
+ conn = Faraday.new do |f|
200
+ f.options.open_timeout = 10
201
+ f.options.read_timeout = 300
202
+ f.options.write_timeout = 30
203
+ f.adapter Faraday.default_adapter
204
+ end
205
+
206
+ response = conn.post(API_URL) do |req|
207
+ req.headers["Content-Type"] = "application/json"
208
+ req.headers["x-api-key"] = api_key
209
+ req.headers["anthropic-version"] = API_VERSION
210
+ req.body = payload.to_json
211
+ req.options.on_data = proc do |chunk, _|
212
+ line_buffer += chunk
213
+ lines = line_buffer.split("\n", -1)
214
+ line_buffer = lines.pop || ""
215
+
216
+ lines.each(&process_sse_line)
217
+ end
218
+ end
219
+
220
+ # Process any remaining data in the buffer
221
+ process_sse_line.call(line_buffer) unless line_buffer.empty?
222
+
223
+ unless response.success?
224
+ error_message = begin
225
+ if response.body.is_a?(Hash)
226
+ response.body["error"]&.dig("message")
227
+ else
228
+ response.body.to_s
229
+ end
230
+ rescue StandardError
231
+ response.body.to_s
232
+ end
233
+ raise ProviderError.new(
234
+ (error_message && !error_message.empty? ? error_message : nil) || "API error",
235
+ code: "anthropic_error"
236
+ )
237
+ end
238
+
239
+ {
240
+ content: full_content,
241
+ tool_calls: tool_calls,
242
+ usage: usage,
243
+ latency_ms: ((Time.now - started_at) * 1000).to_i,
244
+ raw_response: { content: full_content, tool_calls: tool_calls }
245
+ }
237
246
  end
238
247
 
239
248
  def sync_response(payload)
@@ -121,6 +121,52 @@ module LlmProviders
121
121
  usage = {}
122
122
  raw_chunks = ""
123
123
  stream_error = nil
124
+ line_buffer = ""
125
+
126
+ process_sse_line = proc do |line|
127
+ next unless line.start_with?("data: ")
128
+
129
+ data = line.sub("data: ", "").strip
130
+ next if data == "[DONE]" || data.empty?
131
+
132
+ begin
133
+ event = JSON.parse(data)
134
+
135
+ if event["error"]
136
+ stream_error = event.dig("error", "message") || event["error"].to_s
137
+ next
138
+ end
139
+
140
+ if event["usage"]
141
+ usage = {
142
+ input: event.dig("usage", "prompt_tokens"),
143
+ output: event.dig("usage", "completion_tokens"),
144
+ cached_input: event.dig("usage", "prompt_tokens_details", "cached_tokens")
145
+ }
146
+ end
147
+
148
+ choice = event.dig("choices", 0)
149
+ next unless choice
150
+
151
+ delta = choice["delta"]
152
+ next unless delta
153
+
154
+ if delta["content"]
155
+ full_content += delta["content"]
156
+ block.call(content: delta["content"])
157
+ end
158
+
159
+ delta["tool_calls"]&.each do |tc|
160
+ idx = tc["index"]
161
+ tool_calls[idx] ||= { id: "", name: "", arguments: "" }
162
+ tool_calls[idx][:id] = tc["id"] if tc["id"]
163
+ tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
164
+ tool_calls[idx][:arguments] += tc.dig("function", "arguments").to_s
165
+ end
166
+ rescue JSON::ParserError
167
+ # Skip invalid JSON
168
+ end
169
+ end
124
170
 
125
171
  conn = Faraday.new do |f|
126
172
  f.options.open_timeout = 10
@@ -135,17 +181,17 @@ module LlmProviders
135
181
  req.body = payload.to_json
136
182
  req.options.on_data = proc do |chunk, _|
137
183
  raw_chunks += chunk
138
- process_stream_chunk(chunk, full_content, tool_calls) do |parsed|
139
- if parsed[:content]
140
- full_content += parsed[:content]
141
- block.call(content: parsed[:content])
142
- end
143
- stream_error = parsed[:error] if parsed[:error]
144
- usage = parsed[:usage] if parsed[:usage]
145
- end
184
+ line_buffer += chunk
185
+ lines = line_buffer.split("\n", -1)
186
+ line_buffer = lines.pop || ""
187
+
188
+ lines.each(&process_sse_line)
146
189
  end
147
190
  end
148
191
 
192
+ # Process any remaining data in the buffer
193
+ process_sse_line.call(line_buffer) unless line_buffer.empty?
194
+
149
195
  raise ProviderError.new(stream_error, code: "openai_error") if stream_error
150
196
 
151
197
  unless response.success?
@@ -174,50 +220,6 @@ module LlmProviders
174
220
  }
175
221
  end
176
222
 
177
- def process_stream_chunk(chunk, _full_content, tool_calls)
178
- chunk.each_line do |line|
179
- next unless line.start_with?("data: ")
180
-
181
- data = line.sub("data: ", "").strip
182
- next if data == "[DONE]"
183
-
184
- begin
185
- event = JSON.parse(data)
186
-
187
- if event["error"]
188
- yield(error: event.dig("error", "message") || event["error"].to_s)
189
- next
190
- end
191
-
192
- if event["usage"]
193
- yield(usage: {
194
- input: event.dig("usage", "prompt_tokens"),
195
- output: event.dig("usage", "completion_tokens"),
196
- cached_input: event.dig("usage", "prompt_tokens_details", "cached_tokens")
197
- })
198
- end
199
-
200
- choice = event.dig("choices", 0)
201
- next unless choice
202
-
203
- delta = choice["delta"]
204
- next unless delta
205
-
206
- yield(content: delta["content"]) if delta["content"]
207
-
208
- delta["tool_calls"]&.each do |tc|
209
- idx = tc["index"]
210
- tool_calls[idx] ||= { id: "", name: "", arguments: "" }
211
- tool_calls[idx][:id] = tc["id"] if tc["id"]
212
- tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
213
- tool_calls[idx][:arguments] += tc.dig("function", "arguments").to_s
214
- end
215
- rescue JSON::ParserError
216
- # Skip invalid JSON
217
- end
218
- end
219
- end
220
-
221
223
  def sync_response(payload)
222
224
  started_at = Time.now
223
225
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmProviders
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: llm_providers
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - kaba