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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/llm_providers/providers/anthropic.rb +63 -54
- data/lib/llm_providers/providers/openai.rb +79 -59
- data/lib/llm_providers/providers/openrouter.rb +74 -3
- 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: 11691e71acf36321f3ea66a3fec9ff250eceb606080c4b0d153af8d6ce405329
|
|
4
|
+
data.tar.gz: 64409688e08600e19b7a9cd310b8243783dfba3ae50de28483b2a0c22b0383e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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)
|
|
@@ -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[
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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:
|
|
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[
|
|
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
|
|
232
|
-
code:
|
|
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
|