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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -0
- data/docs/why-openclacky.md +267 -0
- data/lib/clacky/agent.rb +52 -60
- data/lib/clacky/cli.rb +9 -7
- data/lib/clacky/client.rb +519 -58
- data/lib/clacky/config.rb +71 -4
- data/lib/clacky/skill.rb +6 -6
- data/lib/clacky/skill_loader.rb +3 -3
- data/lib/clacky/tools/edit.rb +111 -8
- data/lib/clacky/tools/glob.rb +9 -2
- data/lib/clacky/ui2/ui_controller.rb +4 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +2 -1
- metadata +2 -1
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
50
|
-
|
|
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
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
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
|
|
188
|
-
conn.options.open_timeout = 10
|
|
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
|
-
|
|
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
|
|
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
|
|
703
|
+
raise AgentError, "Rate limit exceeded"
|
|
261
704
|
when 500..599
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
730
|
+
raw_body
|
|
270
731
|
end
|
|
271
732
|
end
|
|
272
733
|
|