pocketrb 0.1.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 +7 -0
- data/CHANGELOG.md +32 -0
- data/LICENSE.txt +21 -0
- data/README.md +456 -0
- data/exe/pocketrb +6 -0
- data/lib/pocketrb/agent/compaction.rb +187 -0
- data/lib/pocketrb/agent/context.rb +171 -0
- data/lib/pocketrb/agent/loop.rb +276 -0
- data/lib/pocketrb/agent/spawn_tool.rb +72 -0
- data/lib/pocketrb/agent/subagent_manager.rb +196 -0
- data/lib/pocketrb/bus/events.rb +99 -0
- data/lib/pocketrb/bus/message_bus.rb +148 -0
- data/lib/pocketrb/channels/base.rb +69 -0
- data/lib/pocketrb/channels/cli.rb +109 -0
- data/lib/pocketrb/channels/telegram.rb +607 -0
- data/lib/pocketrb/channels/whatsapp.rb +242 -0
- data/lib/pocketrb/cli/base.rb +119 -0
- data/lib/pocketrb/cli/chat.rb +67 -0
- data/lib/pocketrb/cli/config.rb +52 -0
- data/lib/pocketrb/cli/cron.rb +144 -0
- data/lib/pocketrb/cli/gateway.rb +132 -0
- data/lib/pocketrb/cli/init.rb +39 -0
- data/lib/pocketrb/cli/plans.rb +28 -0
- data/lib/pocketrb/cli/skills.rb +34 -0
- data/lib/pocketrb/cli/start.rb +55 -0
- data/lib/pocketrb/cli/telegram.rb +93 -0
- data/lib/pocketrb/cli/version.rb +18 -0
- data/lib/pocketrb/cli/whatsapp.rb +60 -0
- data/lib/pocketrb/cli.rb +124 -0
- data/lib/pocketrb/config.rb +190 -0
- data/lib/pocketrb/cron/job.rb +155 -0
- data/lib/pocketrb/cron/service.rb +395 -0
- data/lib/pocketrb/heartbeat/service.rb +175 -0
- data/lib/pocketrb/mcp/client.rb +172 -0
- data/lib/pocketrb/mcp/memory_tool.rb +133 -0
- data/lib/pocketrb/media/processor.rb +258 -0
- data/lib/pocketrb/memory.rb +283 -0
- data/lib/pocketrb/planning/manager.rb +159 -0
- data/lib/pocketrb/planning/plan.rb +223 -0
- data/lib/pocketrb/planning/tool.rb +176 -0
- data/lib/pocketrb/providers/anthropic.rb +333 -0
- data/lib/pocketrb/providers/base.rb +98 -0
- data/lib/pocketrb/providers/claude_cli.rb +412 -0
- data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
- data/lib/pocketrb/providers/openrouter.rb +205 -0
- data/lib/pocketrb/providers/registry.rb +59 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
- data/lib/pocketrb/providers/types.rb +111 -0
- data/lib/pocketrb/session/manager.rb +192 -0
- data/lib/pocketrb/session/session.rb +204 -0
- data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
- data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
- data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
- data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
- data/lib/pocketrb/skills/create_tool.rb +115 -0
- data/lib/pocketrb/skills/loader.rb +164 -0
- data/lib/pocketrb/skills/modify_tool.rb +123 -0
- data/lib/pocketrb/skills/skill.rb +75 -0
- data/lib/pocketrb/tools/background_job_manager.rb +261 -0
- data/lib/pocketrb/tools/base.rb +118 -0
- data/lib/pocketrb/tools/browser.rb +152 -0
- data/lib/pocketrb/tools/browser_advanced.rb +470 -0
- data/lib/pocketrb/tools/browser_session.rb +167 -0
- data/lib/pocketrb/tools/cron.rb +222 -0
- data/lib/pocketrb/tools/edit_file.rb +101 -0
- data/lib/pocketrb/tools/exec.rb +194 -0
- data/lib/pocketrb/tools/jobs.rb +127 -0
- data/lib/pocketrb/tools/list_dir.rb +102 -0
- data/lib/pocketrb/tools/memory.rb +167 -0
- data/lib/pocketrb/tools/message.rb +70 -0
- data/lib/pocketrb/tools/para_memory.rb +264 -0
- data/lib/pocketrb/tools/read_file.rb +65 -0
- data/lib/pocketrb/tools/registry.rb +160 -0
- data/lib/pocketrb/tools/send_file.rb +158 -0
- data/lib/pocketrb/tools/think.rb +35 -0
- data/lib/pocketrb/tools/web_fetch.rb +150 -0
- data/lib/pocketrb/tools/web_search.rb +102 -0
- data/lib/pocketrb/tools/write_file.rb +55 -0
- data/lib/pocketrb/version.rb +5 -0
- data/lib/pocketrb.rb +75 -0
- data/pocketrb.gemspec +60 -0
- metadata +327 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Pocketrb
|
|
8
|
+
module Providers
|
|
9
|
+
# Claude Max API Proxy provider
|
|
10
|
+
# Uses the claude-max-api-proxy which provides OpenAI-compatible API
|
|
11
|
+
# Install: npm install -g claude-max-api-proxy
|
|
12
|
+
# Start: claude-max-api (runs on localhost:3456)
|
|
13
|
+
class ClaudeMaxProxy < Base
|
|
14
|
+
MODELS = {
|
|
15
|
+
"opus" => "claude-opus-4",
|
|
16
|
+
"sonnet" => "claude-sonnet-4",
|
|
17
|
+
"haiku" => "claude-haiku-4"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
DEFAULT_MODEL = "sonnet"
|
|
21
|
+
DEFAULT_BASE_URL = "http://localhost:3456/v1"
|
|
22
|
+
|
|
23
|
+
def initialize(config = {})
|
|
24
|
+
@config = config
|
|
25
|
+
@base_url = config[:base_url] || ENV["CLAUDE_MAX_PROXY_URL"] || DEFAULT_BASE_URL
|
|
26
|
+
validate_config!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def name
|
|
30
|
+
:claude_max_proxy
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def default_model
|
|
34
|
+
DEFAULT_MODEL
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def available_models
|
|
38
|
+
MODELS.keys
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
|
|
42
|
+
model ||= default_model
|
|
43
|
+
model_id = MODELS[model] || model
|
|
44
|
+
|
|
45
|
+
body = build_request_body(
|
|
46
|
+
messages: messages,
|
|
47
|
+
model: model_id,
|
|
48
|
+
tools: tools,
|
|
49
|
+
temperature: temperature,
|
|
50
|
+
max_tokens: max_tokens,
|
|
51
|
+
stream: false
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
response = make_request("/chat/completions", body)
|
|
55
|
+
parse_response(response, model_id)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &)
|
|
59
|
+
model ||= default_model
|
|
60
|
+
model_id = MODELS[model] || model
|
|
61
|
+
|
|
62
|
+
body = build_request_body(
|
|
63
|
+
messages: messages,
|
|
64
|
+
model: model_id,
|
|
65
|
+
tools: tools,
|
|
66
|
+
temperature: temperature,
|
|
67
|
+
max_tokens: max_tokens,
|
|
68
|
+
stream: true
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
stream_request("/chat/completions", body, &)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if proxy is running
|
|
75
|
+
def available?
|
|
76
|
+
uri = URI.parse("#{@base_url.sub("/v1", "")}/health")
|
|
77
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
78
|
+
http.open_timeout = 2
|
|
79
|
+
http.read_timeout = 2
|
|
80
|
+
response = http.get(uri.path)
|
|
81
|
+
response.code == "200"
|
|
82
|
+
rescue StandardError
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
protected
|
|
87
|
+
|
|
88
|
+
def supported_features
|
|
89
|
+
%i[tools streaming]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_config!
|
|
93
|
+
# No API key needed - proxy uses CLI auth
|
|
94
|
+
Pocketrb.logger.debug("Claude Max Proxy: #{@base_url}")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def build_request_body(messages:, model:, tools:, temperature:, max_tokens:, stream:)
|
|
100
|
+
body = {
|
|
101
|
+
model: model,
|
|
102
|
+
messages: format_messages(messages),
|
|
103
|
+
temperature: temperature,
|
|
104
|
+
max_tokens: max_tokens,
|
|
105
|
+
stream: stream
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if tools && !tools.empty?
|
|
109
|
+
body[:tools] = format_tools(tools)
|
|
110
|
+
body[:tool_choice] = "auto"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
body
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def format_messages(messages)
|
|
117
|
+
messages.map do |msg|
|
|
118
|
+
formatted = { role: msg.role.to_s }
|
|
119
|
+
|
|
120
|
+
formatted[:content] = if msg.content.is_a?(Array)
|
|
121
|
+
# Handle multi-part content (text + images)
|
|
122
|
+
msg.content.map do |part|
|
|
123
|
+
if part[:type] == "media" && part[:media]&.image?
|
|
124
|
+
format_image_content(part[:media])
|
|
125
|
+
elsif part[:type] == "text"
|
|
126
|
+
{ type: "text", text: part[:text] }
|
|
127
|
+
else
|
|
128
|
+
{ type: "text", text: part.to_s }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
msg.content
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Handle tool calls
|
|
136
|
+
if msg.tool_calls && !msg.tool_calls.empty?
|
|
137
|
+
formatted[:tool_calls] = msg.tool_calls.map do |tc|
|
|
138
|
+
{
|
|
139
|
+
id: tc.id,
|
|
140
|
+
type: "function",
|
|
141
|
+
function: {
|
|
142
|
+
name: tc.name,
|
|
143
|
+
arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Handle tool results
|
|
150
|
+
if msg.tool_call_id
|
|
151
|
+
formatted[:tool_call_id] = msg.tool_call_id
|
|
152
|
+
formatted[:name] = msg.name if msg.name
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
formatted
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def format_image_content(media)
|
|
160
|
+
if media.data
|
|
161
|
+
{
|
|
162
|
+
type: "image_url",
|
|
163
|
+
image_url: {
|
|
164
|
+
url: "data:#{media.mime_type};base64,#{media.data}"
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
elsif media.path && File.exist?(media.path)
|
|
168
|
+
require "base64"
|
|
169
|
+
data = Base64.strict_encode64(File.binread(media.path))
|
|
170
|
+
{
|
|
171
|
+
type: "image_url",
|
|
172
|
+
image_url: {
|
|
173
|
+
url: "data:#{media.mime_type};base64,#{data}"
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else
|
|
177
|
+
{ type: "text", text: "[Image: #{media.filename}]" }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def format_tools(tools)
|
|
182
|
+
tools.map do |tool|
|
|
183
|
+
func = tool[:function] || tool
|
|
184
|
+
{
|
|
185
|
+
type: "function",
|
|
186
|
+
function: {
|
|
187
|
+
name: func[:name],
|
|
188
|
+
description: func[:description],
|
|
189
|
+
parameters: func[:parameters] || func[:input_schema] || {}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def make_request(endpoint, body)
|
|
196
|
+
uri = URI.parse("#{@base_url}#{endpoint}")
|
|
197
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
198
|
+
http.use_ssl = uri.scheme == "https"
|
|
199
|
+
http.open_timeout = 30
|
|
200
|
+
http.read_timeout = 300
|
|
201
|
+
|
|
202
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
203
|
+
request["Content-Type"] = "application/json"
|
|
204
|
+
request["Authorization"] = "Bearer not-needed" # Proxy doesn't need auth
|
|
205
|
+
request.body = body.to_json
|
|
206
|
+
|
|
207
|
+
response = http.request(request)
|
|
208
|
+
|
|
209
|
+
unless response.code.to_i == 200
|
|
210
|
+
error_body = begin
|
|
211
|
+
JSON.parse(response.body)
|
|
212
|
+
rescue StandardError
|
|
213
|
+
{ "error" => response.body }
|
|
214
|
+
end
|
|
215
|
+
raise ProviderError, "Claude Max Proxy error (#{response.code}): #{error_body["error"] || error_body}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
JSON.parse(response.body)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def stream_request(endpoint, body, &block)
|
|
222
|
+
uri = URI.parse("#{@base_url}#{endpoint}")
|
|
223
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
224
|
+
http.use_ssl = uri.scheme == "https"
|
|
225
|
+
http.open_timeout = 30
|
|
226
|
+
http.read_timeout = 300
|
|
227
|
+
|
|
228
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
229
|
+
request["Content-Type"] = "application/json"
|
|
230
|
+
request["Authorization"] = "Bearer not-needed"
|
|
231
|
+
request["Accept"] = "text/event-stream"
|
|
232
|
+
request.body = body.to_json
|
|
233
|
+
|
|
234
|
+
full_content = ""
|
|
235
|
+
tool_calls = []
|
|
236
|
+
model_used = body[:model]
|
|
237
|
+
|
|
238
|
+
http.request(request) do |response|
|
|
239
|
+
unless response.code.to_i == 200
|
|
240
|
+
error_body = response.read_body
|
|
241
|
+
raise ProviderError, "Claude Max Proxy stream error (#{response.code}): #{error_body}"
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
buffer = ""
|
|
245
|
+
response.read_body do |chunk|
|
|
246
|
+
buffer += chunk
|
|
247
|
+
while (line_end = buffer.index("\n"))
|
|
248
|
+
line = buffer.slice!(0..line_end).strip
|
|
249
|
+
next if line.empty? || line == "data: [DONE]"
|
|
250
|
+
next unless line.start_with?("data: ")
|
|
251
|
+
|
|
252
|
+
begin
|
|
253
|
+
data = JSON.parse(line[6..])
|
|
254
|
+
delta = data.dig("choices", 0, "delta")
|
|
255
|
+
next unless delta
|
|
256
|
+
|
|
257
|
+
if delta["content"]
|
|
258
|
+
full_content += delta["content"]
|
|
259
|
+
block&.call(delta["content"])
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Handle streaming tool calls
|
|
263
|
+
delta["tool_calls"]&.each do |tc|
|
|
264
|
+
idx = tc["index"]
|
|
265
|
+
tool_calls[idx] ||= { id: "", name: "", arguments: "" }
|
|
266
|
+
tool_calls[idx][:id] = tc["id"] if tc["id"]
|
|
267
|
+
tool_calls[idx][:name] = tc.dig("function", "name") if tc.dig("function", "name")
|
|
268
|
+
tool_calls[idx][:arguments] += tc.dig("function", "arguments") || ""
|
|
269
|
+
end
|
|
270
|
+
rescue JSON::ParserError
|
|
271
|
+
# Skip malformed JSON
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Build final response
|
|
278
|
+
parsed_tool_calls = tool_calls.compact.map do |tc|
|
|
279
|
+
args = begin
|
|
280
|
+
JSON.parse(tc[:arguments])
|
|
281
|
+
rescue JSON::ParserError
|
|
282
|
+
{}
|
|
283
|
+
end
|
|
284
|
+
ToolCall.new(
|
|
285
|
+
id: tc[:id],
|
|
286
|
+
name: tc[:name],
|
|
287
|
+
arguments: args
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
LLMResponse.new(
|
|
292
|
+
content: full_content.empty? ? nil : full_content,
|
|
293
|
+
tool_calls: parsed_tool_calls,
|
|
294
|
+
usage: Usage.new(input_tokens: 0, output_tokens: 0),
|
|
295
|
+
stop_reason: parsed_tool_calls.any? ? :tool_use : :end_turn,
|
|
296
|
+
model: model_used
|
|
297
|
+
)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def parse_response(response, model)
|
|
301
|
+
choice = response.dig("choices", 0)
|
|
302
|
+
message = choice&.dig("message") || {}
|
|
303
|
+
|
|
304
|
+
content = message["content"]
|
|
305
|
+
tool_calls = parse_tool_calls(message["tool_calls"])
|
|
306
|
+
|
|
307
|
+
usage_data = response["usage"] || {}
|
|
308
|
+
usage = Usage.new(
|
|
309
|
+
input_tokens: usage_data["prompt_tokens"] || 0,
|
|
310
|
+
output_tokens: usage_data["completion_tokens"] || 0
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
stop_reason = case choice&.dig("finish_reason")
|
|
314
|
+
when "tool_calls" then :tool_use
|
|
315
|
+
when "stop" then :end_turn
|
|
316
|
+
when "length" then :max_tokens
|
|
317
|
+
else :end_turn
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
LLMResponse.new(
|
|
321
|
+
content: content,
|
|
322
|
+
tool_calls: tool_calls,
|
|
323
|
+
usage: usage,
|
|
324
|
+
stop_reason: stop_reason,
|
|
325
|
+
model: model
|
|
326
|
+
)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def parse_tool_calls(tool_calls)
|
|
330
|
+
return [] unless tool_calls
|
|
331
|
+
|
|
332
|
+
tool_calls.map do |tc|
|
|
333
|
+
args = begin
|
|
334
|
+
JSON.parse(tc.dig("function", "arguments") || "{}")
|
|
335
|
+
rescue JSON::ParserError
|
|
336
|
+
{}
|
|
337
|
+
end
|
|
338
|
+
ToolCall.new(
|
|
339
|
+
id: tc["id"],
|
|
340
|
+
name: tc.dig("function", "name"),
|
|
341
|
+
arguments: args
|
|
342
|
+
)
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Pocketrb
|
|
7
|
+
module Providers
|
|
8
|
+
# OpenRouter API provider for multi-model access
|
|
9
|
+
# Supports Claude, GPT-4, Llama, and many other models
|
|
10
|
+
class OpenRouter < Base
|
|
11
|
+
API_URL = "https://openrouter.ai/api/v1"
|
|
12
|
+
|
|
13
|
+
POPULAR_MODELS = %w[
|
|
14
|
+
anthropic/claude-sonnet-4
|
|
15
|
+
anthropic/claude-3.5-haiku
|
|
16
|
+
openai/gpt-4o
|
|
17
|
+
openai/gpt-4o-mini
|
|
18
|
+
google/gemini-pro-1.5
|
|
19
|
+
meta-llama/llama-3.1-70b-instruct
|
|
20
|
+
mistralai/mistral-large
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
:openrouter
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def default_model
|
|
28
|
+
"anthropic/claude-sonnet-4"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def available_models
|
|
32
|
+
# Could fetch from API, but use popular models for now
|
|
33
|
+
POPULAR_MODELS
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
|
|
37
|
+
model ||= default_model
|
|
38
|
+
body = build_request_body(messages, tools, model, temperature, max_tokens)
|
|
39
|
+
|
|
40
|
+
response = client.post("/api/v1/chat/completions") do |req|
|
|
41
|
+
req.body = body.to_json
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
handle_response(response)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
|
|
48
|
+
model ||= default_model
|
|
49
|
+
body = build_request_body(messages, tools, model, temperature, max_tokens)
|
|
50
|
+
body[:stream] = true
|
|
51
|
+
|
|
52
|
+
accumulated_content = ""
|
|
53
|
+
accumulated_tool_calls = []
|
|
54
|
+
|
|
55
|
+
client.post("/api/v1/chat/completions") do |req|
|
|
56
|
+
req.body = body.to_json
|
|
57
|
+
req.options.on_data = proc do |chunk, _|
|
|
58
|
+
process_stream_chunk(chunk, accumulated_content, accumulated_tool_calls, &block)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
LLMResponse.new(
|
|
63
|
+
content: accumulated_content,
|
|
64
|
+
tool_calls: accumulated_tool_calls,
|
|
65
|
+
model: model
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
protected
|
|
70
|
+
|
|
71
|
+
def supported_features
|
|
72
|
+
%i[tools streaming]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_config!
|
|
76
|
+
require_api_key!(:openrouter_api_key)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def client
|
|
82
|
+
@client ||= Faraday.new(url: API_URL) do |f|
|
|
83
|
+
f.headers["Content-Type"] = "application/json"
|
|
84
|
+
f.headers["Authorization"] = "Bearer #{api_key(:openrouter_api_key)}"
|
|
85
|
+
f.headers["HTTP-Referer"] = @config[:site_url] || "https://github.com/mensfeld/pocketrb"
|
|
86
|
+
f.headers["X-Title"] = @config[:app_name] || "Pocketrb"
|
|
87
|
+
f.adapter Faraday.default_adapter
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_request_body(messages, tools, model, temperature, max_tokens)
|
|
92
|
+
body = {
|
|
93
|
+
model: model,
|
|
94
|
+
messages: format_messages(messages),
|
|
95
|
+
temperature: temperature,
|
|
96
|
+
max_tokens: max_tokens
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
body[:tools] = tools if tools&.any?
|
|
100
|
+
|
|
101
|
+
body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def format_message(message)
|
|
105
|
+
case message.role
|
|
106
|
+
when Role::SYSTEM
|
|
107
|
+
{ role: "system", content: message.content }
|
|
108
|
+
when Role::USER
|
|
109
|
+
{ role: "user", content: message.content }
|
|
110
|
+
when Role::ASSISTANT
|
|
111
|
+
msg = { role: "assistant", content: message.content }
|
|
112
|
+
if message.tool_calls&.any?
|
|
113
|
+
msg[:tool_calls] = message.tool_calls.map do |tc|
|
|
114
|
+
{
|
|
115
|
+
id: tc.id,
|
|
116
|
+
type: "function",
|
|
117
|
+
function: { name: tc.name, arguments: tc.arguments.to_json }
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
msg
|
|
122
|
+
when Role::TOOL
|
|
123
|
+
{
|
|
124
|
+
role: "tool",
|
|
125
|
+
tool_call_id: message.tool_call_id,
|
|
126
|
+
content: message.content.to_s
|
|
127
|
+
}
|
|
128
|
+
else
|
|
129
|
+
raise ArgumentError, "Unknown role: #{message.role}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def handle_response(response)
|
|
134
|
+
unless response.success?
|
|
135
|
+
error_body = begin
|
|
136
|
+
JSON.parse(response.body)
|
|
137
|
+
rescue StandardError
|
|
138
|
+
{ "error" => response.body }
|
|
139
|
+
end
|
|
140
|
+
raise ProviderError, "OpenRouter API error: #{error_body["error"]}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
data = JSON.parse(response.body)
|
|
144
|
+
parse_response(data)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def parse_response(data)
|
|
148
|
+
choice = data.dig("choices", 0)
|
|
149
|
+
return LLMResponse.new(content: nil) unless choice
|
|
150
|
+
|
|
151
|
+
message = choice["message"]
|
|
152
|
+
content = message["content"]
|
|
153
|
+
|
|
154
|
+
tool_calls = (message["tool_calls"] || []).map do |tc|
|
|
155
|
+
ToolCall.new(
|
|
156
|
+
id: tc["id"],
|
|
157
|
+
name: tc.dig("function", "name"),
|
|
158
|
+
arguments: tc.dig("function", "arguments")
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
usage_data = data["usage"] || {}
|
|
163
|
+
usage = Usage.new(
|
|
164
|
+
input_tokens: usage_data["prompt_tokens"] || 0,
|
|
165
|
+
output_tokens: usage_data["completion_tokens"] || 0
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
stop_reason = case choice["finish_reason"]
|
|
169
|
+
when "stop" then :end_turn
|
|
170
|
+
when "tool_calls" then :tool_use
|
|
171
|
+
when "length" then :max_tokens
|
|
172
|
+
else :end_turn
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
LLMResponse.new(
|
|
176
|
+
content: content,
|
|
177
|
+
tool_calls: tool_calls,
|
|
178
|
+
usage: usage,
|
|
179
|
+
stop_reason: stop_reason,
|
|
180
|
+
model: data["model"]
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def process_stream_chunk(chunk, accumulated_content, _accumulated_tool_calls, &block)
|
|
185
|
+
chunk.split("\n").each do |line|
|
|
186
|
+
next unless line.start_with?("data: ")
|
|
187
|
+
next if line == "data: [DONE]"
|
|
188
|
+
|
|
189
|
+
data = begin
|
|
190
|
+
JSON.parse(line[6..])
|
|
191
|
+
rescue StandardError
|
|
192
|
+
next
|
|
193
|
+
end
|
|
194
|
+
delta = data.dig("choices", 0, "delta")
|
|
195
|
+
next unless delta
|
|
196
|
+
|
|
197
|
+
if delta["content"]
|
|
198
|
+
accumulated_content << delta["content"]
|
|
199
|
+
block&.call(delta["content"])
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Providers
|
|
5
|
+
# Registry for LLM providers
|
|
6
|
+
class Registry
|
|
7
|
+
class << self
|
|
8
|
+
def instance
|
|
9
|
+
@instance ||= new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(name, klass)
|
|
13
|
+
instance.register(name, klass)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def get(name, config = {})
|
|
17
|
+
instance.get(name, config)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def available
|
|
21
|
+
instance.available
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@providers = {}
|
|
27
|
+
register_defaults
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Register a provider class
|
|
31
|
+
def register(name, klass)
|
|
32
|
+
@providers[name.to_sym] = klass
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Get an instance of a provider
|
|
36
|
+
def get(name, config = {})
|
|
37
|
+
klass = @providers[name.to_sym]
|
|
38
|
+
raise ConfigurationError, "Unknown provider: #{name}" unless klass
|
|
39
|
+
|
|
40
|
+
klass.new(config)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# List available provider names
|
|
44
|
+
def available
|
|
45
|
+
@providers.keys
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def register_defaults
|
|
51
|
+
register(:anthropic, Anthropic)
|
|
52
|
+
register(:openrouter, OpenRouter)
|
|
53
|
+
register(:ruby_llm, RubyLLMProvider)
|
|
54
|
+
register(:claude_cli, ClaudeCLI)
|
|
55
|
+
register(:claude_max_proxy, ClaudeMaxProxy)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|