openclacky 0.6.3 → 0.6.4

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.
data/lib/clacky/client.rb CHANGED
@@ -8,57 +8,175 @@ module Clacky
8
8
  MAX_RETRIES = 10
9
9
  RETRY_DELAY = 5 # seconds
10
10
 
11
- def initialize(api_key, base_url:)
11
+ def initialize(api_key, base_url:, model: nil)
12
12
  @api_key = api_key
13
13
  @base_url = base_url
14
+ @model = model
14
15
  end
15
16
 
16
- def send_message(content, model:, max_tokens:)
17
- response = connection.post("chat/completions") do |req|
18
- req.body = {
19
- model: model,
20
- max_tokens: max_tokens,
21
- messages: [
22
- {
23
- role: "user",
24
- content: content
25
- }
26
- ]
27
- }.to_json
17
+ # Check if using Anthropic API format
18
+ # Returns true if:
19
+ # - Base URL contains "anthropic" (for direct Anthropic API)
20
+ # - Base URL contains "claude" (for compatible APIs like hongmacc.com)
21
+ # - Model name contains "claude" (for explicit Claude models)
22
+ def anthropic_format?(model = nil)
23
+ base_url_lower = @base_url.to_s.downcase
24
+
25
+ # Check base_url for Anthropic indicators first
26
+ if base_url_lower.include?("anthropic")
27
+ return true
28
+ end
29
+ if base_url_lower.include?("claude")
30
+ return true
28
31
  end
29
32
 
30
- handle_response(response)
33
+ # Fallback to model name check
34
+ model ||= @model
35
+ return false if model.nil?
36
+ model.to_s.downcase.include?("claude")
31
37
  end
32
38
 
33
- def send_messages(messages, model:, max_tokens:)
34
- response = connection.post("chat/completions") do |req|
35
- req.body = {
36
- model: model,
37
- max_tokens: max_tokens,
38
- messages: messages
39
- }.to_json
39
+ def send_message(content, model:, max_tokens:)
40
+ if anthropic_format?(model)
41
+ response = anthropic_connection.post("v1/messages") do |req|
42
+ req.body = {
43
+ model: model,
44
+ max_tokens: max_tokens,
45
+ messages: [
46
+ {
47
+ role: "user",
48
+ content: content
49
+ }
50
+ ]
51
+ }.to_json
52
+ end
53
+ handle_anthropic_simple_response(response)
54
+ else
55
+ response = openai_connection.post("chat/completions") do |req|
56
+ req.body = {
57
+ model: model,
58
+ max_tokens: max_tokens,
59
+ messages: [
60
+ {
61
+ role: "user",
62
+ content: content
63
+ }
64
+ ]
65
+ }.to_json
66
+ end
67
+ handle_response(response)
40
68
  end
69
+ end
41
70
 
42
- handle_response(response)
71
+ def send_messages(messages, model:, max_tokens:)
72
+ if anthropic_format?(model)
73
+ # Convert to Anthropic format
74
+ body = build_anthropic_body(messages, model, [], max_tokens, false)
75
+ response = anthropic_connection.post("v1/messages") do |req|
76
+ req.body = body.to_json
77
+ end
78
+ handle_anthropic_simple_response(response)
79
+ else
80
+ response = openai_connection.post("chat/completions") do |req|
81
+ req.body = {
82
+ model: model,
83
+ max_tokens: max_tokens,
84
+ messages: messages
85
+ }.to_json
86
+ end
87
+
88
+ handle_response(response)
89
+ end
43
90
  end
44
91
 
45
92
  # Send messages with function calling (tools) support
46
93
  # Options:
47
94
  # - enable_caching: Enable prompt caching for system prompt and tools (default: false)
48
95
  def send_messages_with_tools(messages, model:, tools:, max_tokens:, enable_caching: false)
49
- # Apply caching to messages if enabled
50
- caching_supported = supports_prompt_caching?(model)
51
- caching_enabled = enable_caching && caching_supported
96
+ # Auto-detect API format based on model name and base_url
97
+ is_anthropic = anthropic_format?(model)
52
98
 
53
99
  # Deep clone messages to avoid modifying the original array
54
100
  processed_messages = messages.map { |msg| deep_clone(msg) }
55
101
 
56
- # Add cache control to messages if caching is enabled
57
- # Strategy: Cache system prompt and first user message for stable prefix caching
58
- if caching_enabled
59
- processed_messages = apply_message_caching(processed_messages)
102
+ # Apply caching if enabled and supported
103
+ caching_supported = supports_prompt_caching?(model)
104
+ caching_enabled = enable_caching && caching_supported
105
+
106
+ if is_anthropic
107
+ send_anthropic_request(processed_messages, model, tools, max_tokens, caching_enabled)
108
+ else
109
+ send_openai_request(processed_messages, model, tools, max_tokens, caching_enabled)
110
+ end
111
+ end
112
+
113
+ # Format tool results based on API type
114
+ # Anthropic API: tool results go in user message content array
115
+ # OpenAI API: tool results are separate messages with role: "tool"
116
+ def format_tool_results(response, tool_results, model:)
117
+ return [] if tool_results.empty?
118
+
119
+ is_anthropic = anthropic_format?(model)
120
+
121
+ # Create a map of tool_call_id -> result for quick lookup
122
+ results_map = tool_results.each_with_object({}) do |result, hash|
123
+ hash[result[:id]] = result
60
124
  end
61
125
 
126
+ if is_anthropic
127
+ # Anthropic format: tool results in user message content array
128
+ tool_result_blocks = response[:tool_calls].map do |tool_call|
129
+ result = results_map[tool_call[:id]]
130
+ if result
131
+ {
132
+ type: "tool_result",
133
+ tool_use_id: tool_call[:id],
134
+ content: result[:content]
135
+ }
136
+ else
137
+ {
138
+ type: "tool_result",
139
+ tool_use_id: tool_call[:id],
140
+ content: JSON.generate({ error: "Tool result missing" })
141
+ }
142
+ end
143
+ end
144
+
145
+ # Return as a user message
146
+ [
147
+ {
148
+ role: "user",
149
+ content: tool_result_blocks
150
+ }
151
+ ]
152
+ else
153
+ # OpenAI format: tool results as separate messages
154
+ response[:tool_calls].map do |tool_call|
155
+ result = results_map[tool_call[:id]]
156
+ if result
157
+ {
158
+ role: "tool",
159
+ tool_call_id: result[:id],
160
+ content: result[:content]
161
+ }
162
+ else
163
+ {
164
+ role: "tool",
165
+ tool_call_id: tool_call[:id],
166
+ content: JSON.generate({ error: "Tool result missing" })
167
+ }
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ private
174
+
175
+ # Send request using OpenAI API format
176
+ def send_openai_request(messages, model, tools, max_tokens, caching_enabled)
177
+ # Apply caching to messages if enabled
178
+ processed_messages = caching_enabled ? apply_message_caching(messages) : messages
179
+
62
180
  body = {
63
181
  model: model,
64
182
  max_tokens: max_tokens,
@@ -66,15 +184,9 @@ module Clacky
66
184
  }
67
185
 
68
186
  # Add tools if provided
69
- # For Claude API with caching: mark the last tool definition with cache_control
70
187
  if tools&.any?
71
- caching_supported = supports_prompt_caching?(model)
72
- caching_enabled = enable_caching && caching_supported
73
-
74
188
  if caching_enabled
75
- # Deep clone tools to avoid modifying original
76
189
  cached_tools = tools.map { |tool| deep_clone(tool) }
77
- # Mark the last tool for caching (Claude caches from cache breakpoint to end)
78
190
  cached_tools.last[:cache_control] = { type: "ephemeral" }
79
191
  body[:tools] = cached_tools
80
192
  else
@@ -86,17 +198,309 @@ module Clacky
86
198
  if ENV['CLACKY_DEBUG_REQUEST']
87
199
  debug_file = "/tmp/clacky_request_#{Time.now.to_i}.json"
88
200
  File.write(debug_file, JSON.pretty_generate(body))
89
- puts "DEBUG: Request saved to #{debug_file}"
90
201
  end
91
202
 
92
- response = connection.post("chat/completions") do |req|
203
+ response = openai_connection.post("chat/completions") do |req|
93
204
  req.body = body.to_json
94
205
  end
95
206
 
96
207
  handle_tool_response(response)
97
208
  end
98
209
 
99
- private
210
+ # Send request using Anthropic API format
211
+ def send_anthropic_request(messages, model, tools, max_tokens, caching_enabled)
212
+ # Convert OpenAI message format to Anthropic format
213
+ body = build_anthropic_body(messages, model, tools, max_tokens, caching_enabled)
214
+
215
+ # DEBUG: Always save request body
216
+ debug_file = "/tmp/clacky_request_body.json"
217
+ File.write(debug_file, JSON.pretty_generate(body))
218
+
219
+ response = anthropic_connection.post("v1/messages") do |req|
220
+ req.body = body.to_json
221
+ end
222
+
223
+ # Debug response
224
+ if ENV['CLACKY_DEBUG_REQUEST']
225
+ timestamp = Time.now.to_i
226
+ File.write("/tmp/clacky_debug_#{timestamp}.txt", "URL = #{@base_url}/v1/messages\n")
227
+ File.write("/tmp/clacky_debug_#{timestamp}.txt", "API Key = #{@api_key[0..10]}...\n")
228
+ File.write("/tmp/clacky_debug_#{timestamp}.txt", "Headers = #{anthropic_connection.headers.inspect}\n")
229
+ File.open("/tmp/clacky_debug_#{timestamp}.txt", "a") do |f|
230
+ f.puts "Response status = #{response.status}"
231
+ f.puts "Response body = #{response.body}"
232
+ end
233
+ end
234
+
235
+ handle_anthropic_response(response)
236
+ end
237
+
238
+ # Build request body in Anthropic format
239
+ def build_anthropic_body(messages, model, tools, max_tokens, caching_enabled)
240
+ # Separate system messages from regular messages
241
+ system_messages = messages.select { |m| m[:role] == "system" }
242
+ regular_messages = messages.reject { |m| m[:role] == "system" }
243
+
244
+ # Build system for Anthropic - use string format which is most compatible
245
+ system = if system_messages.any?
246
+ system_messages.map do |msg|
247
+ content = msg[:content]
248
+ if content.is_a?(String)
249
+ content
250
+ elsif content.is_a?(Array)
251
+ content.map { |block| block.is_a?(Hash) ? (block[:text] || block.dig(:text) || "") : block.to_s }.compact.join("\n")
252
+ else
253
+ content.to_s
254
+ end
255
+ end.join("\n\n")
256
+ else
257
+ ""
258
+ end
259
+
260
+ # Convert regular messages to Anthropic format
261
+ anthropic_messages = regular_messages.map { |msg| convert_to_anthropic_message(msg, caching_enabled) }
262
+
263
+ # Convert tools to Anthropic format
264
+ anthropic_tools = tools&.map { |tool| convert_to_anthropic_tool(tool, caching_enabled) }
265
+
266
+ # Add cache_control to last tool if caching is enabled
267
+ if caching_enabled && anthropic_tools&.any?
268
+ anthropic_tools.last[:cache_control] = { type: "ephemeral" }
269
+ end
270
+
271
+ # DEBUG: Log tools transformation
272
+ debug_file = "/tmp/clacky_tools_debug.txt"
273
+ File.write(debug_file, "Tools Debug:\n")
274
+ File.write(debug_file, "Input tools count: #{tools&.length || 0}\n", mode: "a")
275
+ File.write(debug_file, "Anthropic tools count: #{anthropic_tools&.length || 0}\n", mode: "a")
276
+ if tools&.any?
277
+ File.write(debug_file, "First input tool: #{JSON.pretty_generate(tools.first)}\n", mode: "a")
278
+ end
279
+ if anthropic_tools&.any?
280
+ File.write(debug_file, "First anthropic tool: #{JSON.pretty_generate(anthropic_tools.first)}\n", mode: "a")
281
+ end
282
+
283
+ body = {
284
+ model: model,
285
+ max_tokens: max_tokens,
286
+ messages: anthropic_messages
287
+ }
288
+
289
+ # Only include system if it's not empty
290
+ body[:system] = system if system && !system.empty?
291
+
292
+ body[:tools] = anthropic_tools if anthropic_tools&.any?
293
+
294
+ # DEBUG: Log final body
295
+ File.write(debug_file, "Body has tools: #{body.key?(:tools)}\n", mode: "a")
296
+ File.write(debug_file, "Body tools count: #{body[:tools]&.length || 0}\n", mode: "a")
297
+
298
+ body
299
+ end
300
+
301
+ # Convert a message to Anthropic format
302
+ def convert_to_anthropic_message(message, caching_enabled)
303
+ role = message[:role]
304
+ content = message[:content]
305
+ tool_calls = message[:tool_calls]
306
+
307
+ # For assistant messages with tool_calls, convert tool_calls to content blocks
308
+ if role == "assistant" && tool_calls && tool_calls.any?
309
+ # Build content blocks from both content and tool_calls
310
+ blocks = []
311
+
312
+ # Add text content first
313
+ if content.is_a?(String) && !content.empty?
314
+ blocks << { type: "text", text: content }
315
+ elsif content.is_a?(Array)
316
+ blocks.concat(content.map do |block|
317
+ case block[:type]
318
+ when "text"
319
+ { type: "text", text: block[:text] }
320
+ when "image_url"
321
+ url = block.dig(:image_url, :url) || block[:url]
322
+ if url&.start_with?("data:")
323
+ match = url.match(/^data:([^;]+);base64,(.*)$/)
324
+ if match
325
+ { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
326
+ else
327
+ { type: "image", source: { type: "url", url: url } }
328
+ end
329
+ else
330
+ { type: "image", source: { type: "url", url: url } }
331
+ end
332
+ else
333
+ block
334
+ end
335
+ end)
336
+ end
337
+
338
+ # Add tool_use blocks
339
+ tool_calls.each do |call|
340
+ # Handle both OpenAI format (with function key) and direct format
341
+ if call[:function]
342
+ # OpenAI format
343
+ tool_use_block = {
344
+ type: "tool_use",
345
+ id: call[:id],
346
+ name: call[:function][:name],
347
+ input: call[:function][:arguments].is_a?(String) ? JSON.parse(call[:function][:arguments]) : call[:function][:arguments]
348
+ }
349
+ else
350
+ # Direct format
351
+ tool_use_block = {
352
+ type: "tool_use",
353
+ id: call[:id],
354
+ name: call[:name],
355
+ input: call[:arguments].is_a?(String) ? JSON.parse(call[:arguments]) : call[:arguments]
356
+ }
357
+ end
358
+ blocks << tool_use_block
359
+ end
360
+
361
+ return { role: role, content: blocks }
362
+ end
363
+
364
+ # Convert string content to array format
365
+ if content.is_a?(String)
366
+ return { role: role, content: [{ type: "text", text: content }] }
367
+ end
368
+
369
+ # Handle array content (already in some format)
370
+ if content.is_a?(Array)
371
+ blocks = content.map do |block|
372
+ case block[:type]
373
+ when "text"
374
+ { type: "text", text: block[:text] }
375
+ when "image_url"
376
+ url = block.dig(:image_url, :url) || block[:url]
377
+ if url&.start_with?("data:")
378
+ match = url.match(/^data:([^;]+);base64,(.*)$/)
379
+ if match
380
+ { type: "image", source: { type: "base64", media_type: match[1], data: match[2] } }
381
+ else
382
+ { type: "image", source: { type: "url", url: url } }
383
+ end
384
+ else
385
+ { type: "image", source: { type: "url", url: url } }
386
+ end
387
+ else
388
+ block
389
+ end
390
+ end
391
+ return { role: role, content: blocks }
392
+ end
393
+
394
+ { role: role, content: message[:content] }
395
+ end
396
+
397
+ # Convert a tool to Anthropic format
398
+ # Handles both OpenAI format (with nested function key) and direct format
399
+ def convert_to_anthropic_tool(tool, caching_enabled)
400
+ # Handle OpenAI format from to_function_definition
401
+ func = tool[:function] || tool
402
+ {
403
+ name: func[:name],
404
+ description: func[:description],
405
+ input_schema: func[:parameters]
406
+ }
407
+ end
408
+
409
+ # Handle Anthropic API response
410
+ def handle_anthropic_response(response)
411
+ case response.status
412
+ when 200
413
+ data = JSON.parse(response.body)
414
+ content_blocks = data["content"] || []
415
+ usage = data["usage"] || {}
416
+
417
+ # Extract content
418
+ content = content_blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("")
419
+
420
+ # Extract tool calls
421
+ tool_calls = content_blocks.select { |b| b["type"] == "tool_use" }.map do |tc|
422
+ {
423
+ id: tc["id"],
424
+ type: "function",
425
+ name: tc["name"],
426
+ arguments: tc["input"].is_a?(String) ? tc["input"] : tc["input"].to_json
427
+ }
428
+ end
429
+
430
+ # DEBUG: Log what we got
431
+ debug_file = "/tmp/clacky_anthropic_response.txt"
432
+ File.write(debug_file, "Response Analysis:\n")
433
+ File.write(debug_file, "content_blocks count: #{content_blocks.length}\n", mode: "a")
434
+ File.write(debug_file, "content_block types: #{content_blocks.map { |b| b["type"] }.inspect}\n", mode: "a")
435
+ File.write(debug_file, "tool_use blocks found: #{content_blocks.select { |b| b["type"] == "tool_use" }.length}\n", mode: "a")
436
+ File.write(debug_file, "extracted tool_calls: #{tool_calls.length}\n", mode: "a")
437
+ File.write(debug_file, "finish_reason (raw): #{data["stop_reason"]}\n", mode: "a")
438
+ File.write(debug_file, "Full content_blocks: #{JSON.pretty_generate(content_blocks)}\n", mode: "a")
439
+
440
+ # Parse finish reason
441
+ finish_reason = case data["stop_reason"]
442
+ when "end_turn" then "stop"
443
+ when "tool_use" then "tool_calls"
444
+ when "max_tokens" then "length"
445
+ else data["stop_reason"]
446
+ end
447
+
448
+ # Build usage data
449
+ usage_data = {
450
+ prompt_tokens: usage["input_tokens"],
451
+ completion_tokens: usage["output_tokens"],
452
+ total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i
453
+ }
454
+
455
+ # Add cache metrics if present
456
+ if usage["cache_read_input_tokens"]
457
+ usage_data[:cache_read_input_tokens] = usage["cache_read_input_tokens"]
458
+ end
459
+ if usage["cache_creation_input_tokens"]
460
+ usage_data[:cache_creation_input_tokens] = usage["cache_creation_input_tokens"]
461
+ end
462
+
463
+ {
464
+ content: content,
465
+ tool_calls: tool_calls,
466
+ finish_reason: finish_reason,
467
+ usage: usage_data,
468
+ raw_api_usage: usage
469
+ }
470
+ else
471
+ raise_error(response)
472
+ end
473
+ end
474
+
475
+ # Handle simple Anthropic response (without tool calls)
476
+ def handle_anthropic_simple_response(response)
477
+ case response.status
478
+ when 200
479
+ data = JSON.parse(response.body)
480
+ content_blocks = data["content"] || []
481
+ usage = data["usage"] || {}
482
+
483
+ # Extract text content
484
+ content = content_blocks.select { |b| b["type"] == "text" }.map { |b| b["text"] }.join("")
485
+
486
+ # Build usage data
487
+ usage_data = {
488
+ prompt_tokens: usage["input_tokens"],
489
+ completion_tokens: usage["output_tokens"],
490
+ total_tokens: usage["input_tokens"].to_i + usage["output_tokens"].to_i
491
+ }
492
+
493
+ {
494
+ content: content,
495
+ tool_calls: [],
496
+ finish_reason: "stop",
497
+ usage: usage_data,
498
+ raw_api_usage: usage
499
+ }
500
+ else
501
+ raise_error(response)
502
+ end
503
+ end
100
504
 
101
505
  # Check if the model supports prompt caching
102
506
  # Currently only Claude 3.5+ models support this feature
@@ -180,12 +584,26 @@ module Clacky
180
584
  end
181
585
  end
182
586
 
183
- def connection
184
- @connection ||= Faraday.new(url: @base_url) do |conn|
587
+ # Connection for OpenAI API format (uses Bearer token)
588
+ def openai_connection
589
+ @openai_connection ||= Faraday.new(url: @base_url) do |conn|
185
590
  conn.headers["Content-Type"] = "application/json"
186
591
  conn.headers["Authorization"] = "Bearer #{@api_key}"
187
- conn.options.timeout = 120 # Read timeout in seconds
188
- conn.options.open_timeout = 10 # Connection timeout in seconds
592
+ conn.options.timeout = 120
593
+ conn.options.open_timeout = 10
594
+ conn.adapter Faraday.default_adapter
595
+ end
596
+ end
597
+
598
+ # Connection for Anthropic API format (uses x-api-key header)
599
+ def anthropic_connection
600
+ @anthropic_connection ||= Faraday.new(url: @base_url) do |conn|
601
+ conn.headers["Content-Type"] = "application/json"
602
+ conn.headers["x-api-key"] = @api_key
603
+ conn.headers["anthropic-version"] = "2023-06-01"
604
+ conn.headers["anthropic-dangerous-direct-browser-access"] = "true"
605
+ conn.options.timeout = 120
606
+ conn.options.open_timeout = 10
189
607
  conn.adapter Faraday.default_adapter
190
608
  end
191
609
  end
@@ -195,14 +613,8 @@ module Clacky
195
613
  when 200
196
614
  data = JSON.parse(response.body)
197
615
  data["choices"].first["message"]["content"]
198
- when 401
199
- raise Error, "Invalid API key"
200
- when 429
201
- raise Error, "Rate limit exceeded"
202
- when 500..599
203
- raise Error, "Server error: #{response.status}"
204
616
  else
205
- raise Error, "Unexpected error: #{response.status} - #{response.body}"
617
+ raise_error(response)
206
618
  end
207
619
  end
208
620
 
@@ -254,19 +666,68 @@ module Clacky
254
666
  usage: usage_data,
255
667
  raw_api_usage: raw_api_usage
256
668
  }
669
+ else
670
+ raise_error(response)
671
+ end
672
+ end
673
+
674
+ private
675
+
676
+ def raise_error(response)
677
+ # Try to parse error body as JSON for better error messages
678
+ error_body = begin
679
+ JSON.parse(response.body)
680
+ rescue JSON::ParserError
681
+ nil
682
+ end
683
+
684
+ # Extract meaningful error message from response
685
+ error_message = extract_error_message(error_body, response.body)
686
+
687
+ case response.status
688
+ when 400
689
+ # Bad request - could be invalid model, quota exceeded, etc.
690
+ hint = if error_message.downcase.include?("unavailable") || error_message.downcase.include?("quota")
691
+ " (possibly out of credits)"
692
+ else
693
+ ""
694
+ end
695
+ raise AgentError, "API request failed (400): #{error_message}#{hint}"
257
696
  when 401
258
- raise Error, "Invalid API key"
697
+ raise AgentError, "Invalid API key"
698
+ when 403
699
+ raise AgentError, "Access denied: #{error_message}"
700
+ when 404
701
+ raise AgentError, "API endpoint not found: #{error_message}"
259
702
  when 429
260
- raise Error, "Rate limit exceeded"
703
+ raise AgentError, "Rate limit exceeded"
261
704
  when 500..599
262
- error_body = begin
263
- JSON.parse(response.body)
264
- rescue JSON::ParserError
265
- response.body
266
- end
267
- raise Error, "Server error: #{response.status}\nResponse: #{error_body.inspect}"
705
+ raise AgentError, "Server error (#{response.status}): #{error_message}"
706
+ else
707
+ raise AgentError, "Unexpected error (#{response.status}): #{error_message}"
708
+ end
709
+ end
710
+
711
+ # Extract the most meaningful error message from API response
712
+ private def extract_error_message(error_body, raw_body)
713
+ return raw_body unless error_body.is_a?(Hash)
714
+
715
+ # Priority order for error messages:
716
+ # 1. upstreamMessage (often contains the real reason)
717
+ # 2. error.message (Anthropic format)
718
+ # 3. message
719
+ # 4. error (string)
720
+ # 5. raw body
721
+ if error_body["upstreamMessage"] && !error_body["upstreamMessage"].empty?
722
+ error_body["upstreamMessage"]
723
+ elsif error_body.dig("error", "message")
724
+ error_body.dig("error", "message")
725
+ elsif error_body["message"]
726
+ error_body["message"]
727
+ elsif error_body["error"].is_a?(String)
728
+ error_body["error"]
268
729
  else
269
- raise Error, "Unexpected error: #{response.status} - #{response.body}"
730
+ raw_body
270
731
  end
271
732
  end
272
733