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 +4 -4
- data/CHANGELOG.md +8 -0
- data/lib/llm_providers/providers/anthropic.rb +63 -54
- data/lib/llm_providers/providers/openai.rb +54 -52
- data/lib/llm_providers/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2f9351f5b2fcd03887b26b61aad5a7efdd810207d9ae96577455b5450730c96
|
|
4
|
+
data.tar.gz: 17aa0c0c838045102727f5650ba5be88d232ac6e54b7bf283eb5d8bcc7b1b2d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|