llm_providers 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd68399074379aa3cca94d766217abe7270698fa764b1656d1fc585977c47e49
4
- data.tar.gz: 6b82fe10d4ae4bb414317c1fbbca2252976032032fcc846c2864154cb54aee62
3
+ metadata.gz: 11691e71acf36321f3ea66a3fec9ff250eceb606080c4b0d153af8d6ce405329
4
+ data.tar.gz: 64409688e08600e19b7a9cd310b8243783dfba3ae50de28483b2a0c22b0383e4
5
5
  SHA512:
6
- metadata.gz: 77759c7d037da031e31db5ce76456279d5c1ed104b7df60d19289ceb6cba0ad26b7de1aa80f1e78b1afcc18b41d780ffd34718cb318b6cf905a1947adde806e0
7
- data.tar.gz: 598b707ba1f7160b7b6f03b3d291a7d2bf0d726afe55d6fd86b60747c8ebc440438c5e5753597eb36016b0012552404aedc98f3cc3dc28e5ba93465c78a8b73a
6
+ metadata.gz: f481b19793475b67cb25ba44f05e63ecd0b16ac9527e8d2274b3e2a9b8bb21f84639e5980cb1049e10ab4146c9476c0dd53051802c12a52f50655635b96e2ecb
7
+ data.tar.gz: 688e43303a8b97593698091710f4f1e945278974a941e9d5f60a6e4da5262118786d6a10522a4a2f7791242958ad7b57df1a80dccdd88ed6642b0c8315040575
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-02-27
4
+
5
+ ### Added
6
+
7
+ - OpenRouter provider is now fully supported (no longer experimental)
8
+ - Custom headers: `X-Title`, `HTTP-Referer` via `app_name:` / `app_url:` options or ENV
9
+ - Provider routing: `provider:` option for order, fallback, data collection preferences
10
+ - `Openrouter.models` class method for model discovery
11
+ - Improved error handling with upstream provider name from OpenRouter metadata
12
+
13
+ ### Changed
14
+
15
+ - Extracted `request_headers` method in OpenAI provider for extensibility
16
+ - Extracted `format_stream_error` / `parse_sync_error` methods in OpenAI provider for extensibility
17
+
18
+ ## [0.1.1] - 2026-02-27
19
+
20
+ ### Fixed
21
+
22
+ - Fix SSE streaming parser losing data when HTTP chunk boundaries split SSE data lines mid-line
23
+ - Added line buffering to `stream_response` in Anthropic, OpenAI, and OpenRouter providers
24
+ - Fixes tool call `input_json_delta` events being dropped, which caused tool calls with empty `{}` input
25
+
3
26
  ## [0.1.0] - 2026-02-13
4
27
 
5
28
  - 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)
@@ -111,6 +111,13 @@ module LlmProviders
111
111
  end
112
112
  end
113
113
 
114
+ def request_headers
115
+ {
116
+ "Content-Type" => "application/json",
117
+ "Authorization" => "Bearer #{api_key}"
118
+ }
119
+ end
120
+
114
121
  def stream_response(payload, &block)
115
122
  payload[:stream] = true
116
123
  payload[:stream_options] = { include_usage: true }
@@ -121,6 +128,52 @@ module LlmProviders
121
128
  usage = {}
122
129
  raw_chunks = ""
123
130
  stream_error = nil
131
+ line_buffer = ""
132
+
133
+ process_sse_line = proc do |line|
134
+ next unless line.start_with?("data: ")
135
+
136
+ data = line.sub("data: ", "").strip
137
+ next if data == "[DONE]" || data.empty?
138
+
139
+ begin
140
+ event = JSON.parse(data)
141
+
142
+ if event["error"]
143
+ stream_error = format_stream_error(event)
144
+ next
145
+ end
146
+
147
+ if event["usage"]
148
+ usage = {
149
+ input: event.dig("usage", "prompt_tokens"),
150
+ output: event.dig("usage", "completion_tokens"),
151
+ cached_input: event.dig("usage", "prompt_tokens_details", "cached_tokens")
152
+ }
153
+ end
154
+
155
+ choice = event.dig("choices", 0)
156
+ next unless choice
157
+
158
+ delta = choice["delta"]
159
+ next unless delta
160
+
161
+ if delta["content"]
162
+ full_content += delta["content"]
163
+ block.call(content: delta["content"])
164
+ end
165
+
166
+ delta["tool_calls"]&.each do |tc|
167
+ idx = tc["index"]
168
+ tool_calls[idx] ||= { id: "", name: "", arguments: "" }
169
+ tool_calls[idx][:id] = tc["id"] if tc["id"]
170
+ tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
171
+ tool_calls[idx][:arguments] += tc.dig("function", "arguments").to_s
172
+ end
173
+ rescue JSON::ParserError
174
+ # Skip invalid JSON
175
+ end
176
+ end
124
177
 
125
178
  conn = Faraday.new do |f|
126
179
  f.options.open_timeout = 10
@@ -130,23 +183,22 @@ module LlmProviders
130
183
  end
131
184
 
132
185
  response = conn.post(self.class::API_URL) do |req|
133
- req.headers["Content-Type"] = "application/json"
134
- req.headers["Authorization"] = "Bearer #{api_key}"
186
+ request_headers.each { |k, v| req.headers[k] = v }
135
187
  req.body = payload.to_json
136
188
  req.options.on_data = proc do |chunk, _|
137
189
  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
190
+ line_buffer += chunk
191
+ lines = line_buffer.split("\n", -1)
192
+ line_buffer = lines.pop || ""
193
+
194
+ lines.each(&process_sse_line)
146
195
  end
147
196
  end
148
197
 
149
- raise ProviderError.new(stream_error, code: "openai_error") if stream_error
198
+ # Process any remaining data in the buffer
199
+ process_sse_line.call(line_buffer) unless line_buffer.empty?
200
+
201
+ raise ProviderError.new(stream_error, code: error_code) if stream_error
150
202
 
151
203
  unless response.success?
152
204
  error_body = begin
@@ -157,7 +209,7 @@ module LlmProviders
157
209
  error_msg = error_body.dig("error", "message") || (raw_chunks.empty? ? nil : raw_chunks) || response.body.to_s
158
210
  raise ProviderError.new(
159
211
  error_msg[0, 500],
160
- code: "openai_error"
212
+ code: error_code
161
213
  )
162
214
  end
163
215
 
@@ -174,62 +226,18 @@ module LlmProviders
174
226
  }
175
227
  end
176
228
 
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
229
  def sync_response(payload)
222
230
  started_at = Time.now
223
231
 
224
232
  response = http_client.post(self.class::API_URL) do |req|
225
- req.headers["Authorization"] = "Bearer #{api_key}"
233
+ request_headers.each { |k, v| req.headers[k] = v }
226
234
  req.body = payload
227
235
  end
228
236
 
229
237
  unless response.success?
230
238
  raise ProviderError.new(
231
- response.body.dig("error", "message") || "API error",
232
- code: "openai_error"
239
+ parse_sync_error(response),
240
+ code: error_code
233
241
  )
234
242
  end
235
243
 
@@ -258,6 +266,18 @@ module LlmProviders
258
266
  }
259
267
  end
260
268
 
269
+ def error_code
270
+ "openai_error"
271
+ end
272
+
273
+ def format_stream_error(event)
274
+ event.dig("error", "message") || event["error"].to_s
275
+ end
276
+
277
+ def parse_sync_error(response)
278
+ response.body.dig("error", "message") || "API error"
279
+ end
280
+
261
281
  def parse_tool_input(arguments)
262
282
  return {} if arguments.nil? || arguments.empty?
263
283
 
@@ -2,11 +2,45 @@
2
2
 
3
3
  module LlmProviders
4
4
  module Providers
5
- # Experimental: OpenRouter support is provided as-is.
6
- # It wraps the OpenAI-compatible API at openrouter.ai.
7
- # Not all features may work as expected with every model.
8
5
  class Openrouter < Openai
9
6
  API_URL = "https://openrouter.ai/api/v1/chat/completions"
7
+ MODELS_URL = "https://openrouter.ai/api/v1/models"
8
+
9
+ def self.models
10
+ api_key = ENV.fetch("OPENROUTER_API_KEY")
11
+ conn = Faraday.new do |f|
12
+ f.response :json
13
+ f.adapter Faraday.default_adapter
14
+ end
15
+
16
+ response = conn.get(MODELS_URL) do |req|
17
+ req.headers["Authorization"] = "Bearer #{api_key}"
18
+ end
19
+
20
+ unless response.success?
21
+ error_msg = response.body.dig("error", "message") || "Failed to fetch models"
22
+ raise ProviderError.new(error_msg, code: "openrouter_error")
23
+ end
24
+
25
+ (response.body["data"] || []).map do |model|
26
+ {
27
+ id: model["id"],
28
+ name: model["name"],
29
+ context_length: model["context_length"],
30
+ pricing: {
31
+ prompt: model.dig("pricing", "prompt"),
32
+ completion: model.dig("pricing", "completion")
33
+ }
34
+ }
35
+ end
36
+ end
37
+
38
+ def initialize(app_name: nil, app_url: nil, provider: nil, **options)
39
+ super(**options)
40
+ @app_name = app_name || ENV["OPENROUTER_APP_NAME"]
41
+ @app_url = app_url || ENV["OPENROUTER_APP_URL"]
42
+ @provider_preferences = provider
43
+ end
10
44
 
11
45
  protected
12
46
 
@@ -17,6 +51,43 @@ module LlmProviders
17
51
  def api_key
18
52
  ENV.fetch("OPENROUTER_API_KEY")
19
53
  end
54
+
55
+ private
56
+
57
+ def build_payload(messages, system, tools)
58
+ payload = super
59
+ payload[:provider] = @provider_preferences if @provider_preferences
60
+ payload
61
+ end
62
+
63
+ def request_headers
64
+ headers = super
65
+ headers["X-Title"] = @app_name if @app_name
66
+ headers["HTTP-Referer"] = @app_url if @app_url
67
+ headers
68
+ end
69
+
70
+ def error_code
71
+ "openrouter_error"
72
+ end
73
+
74
+ def format_stream_error(event)
75
+ message = event.dig("error", "message") || event["error"].to_s
76
+ provider_name = event.dig("error", "metadata", "provider_name")
77
+ provider_name ? "[#{provider_name}] #{message}" : message
78
+ end
79
+
80
+ def parse_sync_error(response)
81
+ body = response.body
82
+ body = begin
83
+ JSON.parse(body)
84
+ rescue StandardError
85
+ {}
86
+ end if body.is_a?(String)
87
+ message = body.dig("error", "message") || "API error"
88
+ provider_name = body.dig("error", "metadata", "provider_name")
89
+ provider_name ? "[#{provider_name}] #{message}" : message
90
+ end
20
91
  end
21
92
  end
22
93
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LlmProviders
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
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.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - kaba