pocketrb 0.1.0 → 0.3.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 +37 -0
- data/README.md +98 -17
- data/lib/pocketrb/agent/compaction.rb +235 -22
- data/lib/pocketrb/agent/context.rb +26 -3
- data/lib/pocketrb/agent/loop.rb +53 -5
- data/lib/pocketrb/agent/spawn_tool.rb +14 -0
- data/lib/pocketrb/agent/subagent_manager.rb +24 -2
- data/lib/pocketrb/bus/events.rb +42 -1
- data/lib/pocketrb/bus/message_bus.rb +37 -0
- data/lib/pocketrb/channels/base.rb +13 -0
- data/lib/pocketrb/channels/cli.rb +16 -1
- data/lib/pocketrb/channels/telegram.rb +70 -4
- data/lib/pocketrb/channels/whatsapp.rb +49 -7
- data/lib/pocketrb/cli/chat.rb +7 -0
- data/lib/pocketrb/cli/config.rb +4 -0
- data/lib/pocketrb/cli/cron.rb +6 -0
- data/lib/pocketrb/cli.rb +0 -1
- data/lib/pocketrb/config.rb +55 -6
- data/lib/pocketrb/cron/job.rb +41 -0
- data/lib/pocketrb/cron/service.rb +44 -15
- data/lib/pocketrb/heartbeat/service.rb +21 -0
- data/lib/pocketrb/mcp/client.rb +41 -3
- data/lib/pocketrb/mcp/memory_tool.rb +33 -0
- data/lib/pocketrb/media/processor.rb +32 -1
- data/lib/pocketrb/memory.rb +12 -3
- data/lib/pocketrb/planning/manager.rb +29 -1
- data/lib/pocketrb/planning/plan.rb +52 -0
- data/lib/pocketrb/planning/tool.rb +45 -0
- data/lib/pocketrb/providers/anthropic.rb +113 -9
- data/lib/pocketrb/providers/base.rb +39 -4
- data/lib/pocketrb/providers/claude_cli.rb +79 -7
- data/lib/pocketrb/providers/claude_max_proxy.rb +72 -6
- data/lib/pocketrb/providers/openai_compatible.rb +79 -0
- data/lib/pocketrb/providers/openrouter.rb +72 -12
- data/lib/pocketrb/providers/registry.rb +24 -0
- data/lib/pocketrb/providers/ruby_llm_provider.rb +168 -54
- data/lib/pocketrb/providers/types.rb +51 -1
- data/lib/pocketrb/session/manager.rb +34 -2
- data/lib/pocketrb/session/session.rb +53 -11
- data/lib/pocketrb/skills/builtin/tmux/SKILL.md +9 -9
- data/lib/pocketrb/skills/create_tool.rb +28 -0
- data/lib/pocketrb/skills/loader.rb +22 -1
- data/lib/pocketrb/skills/modify_tool.rb +22 -0
- data/lib/pocketrb/skills/skill.rb +19 -3
- data/lib/pocketrb/tools/background_job_manager.rb +29 -0
- data/lib/pocketrb/tools/base.rb +16 -1
- data/lib/pocketrb/tools/browser.rb +40 -0
- data/lib/pocketrb/tools/browser_advanced.rb +94 -4
- data/lib/pocketrb/tools/browser_session.rb +18 -0
- data/lib/pocketrb/tools/cron.rb +46 -0
- data/lib/pocketrb/tools/edit_file.rb +16 -0
- data/lib/pocketrb/tools/exec.rb +46 -0
- data/lib/pocketrb/tools/jobs.rb +26 -2
- data/lib/pocketrb/tools/list_dir.rb +19 -0
- data/lib/pocketrb/tools/memory.rb +34 -0
- data/lib/pocketrb/tools/message.rb +15 -0
- data/lib/pocketrb/tools/para_memory.rb +47 -0
- data/lib/pocketrb/tools/read_file.rb +11 -0
- data/lib/pocketrb/tools/registry.rb +12 -4
- data/lib/pocketrb/tools/send_file.rb +29 -0
- data/lib/pocketrb/tools/think.rb +9 -0
- data/lib/pocketrb/tools/web_fetch.rb +58 -3
- data/lib/pocketrb/tools/web_search.rb +25 -0
- data/lib/pocketrb/tools/write_file.rb +10 -0
- data/lib/pocketrb/version.rb +2 -1
- data/lib/pocketrb.rb +4 -1
- data/pocketrb.gemspec +6 -8
- metadata +24 -44
|
@@ -6,38 +6,56 @@ require "uri"
|
|
|
6
6
|
|
|
7
7
|
module Pocketrb
|
|
8
8
|
module Providers
|
|
9
|
-
# Claude Max API Proxy provider
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# Start: claude-max-api (runs on localhost:3456)
|
|
9
|
+
# Claude Max API Proxy provider. Uses the claude-max-api-proxy which provides OpenAI-compatible API
|
|
10
|
+
# - Install: npm install -g claude-max-api-proxy
|
|
11
|
+
# - Start: claude-max-api (runs on localhost:3456)
|
|
13
12
|
class ClaudeMaxProxy < Base
|
|
13
|
+
# Available model aliases mapped to full model names
|
|
14
14
|
MODELS = {
|
|
15
15
|
"opus" => "claude-opus-4",
|
|
16
16
|
"sonnet" => "claude-sonnet-4",
|
|
17
17
|
"haiku" => "claude-haiku-4"
|
|
18
18
|
}.freeze
|
|
19
19
|
|
|
20
|
+
# Default model alias
|
|
20
21
|
DEFAULT_MODEL = "sonnet"
|
|
22
|
+
# Default base URL for claude-max-api proxy
|
|
21
23
|
DEFAULT_BASE_URL = "http://localhost:3456/v1"
|
|
22
24
|
|
|
25
|
+
# Initialize Claude Max Proxy provider
|
|
26
|
+
# @param config [Hash] Configuration options (base_url, etc.)
|
|
23
27
|
def initialize(config = {})
|
|
24
28
|
@config = config
|
|
25
29
|
@base_url = config[:base_url] || ENV["CLAUDE_MAX_PROXY_URL"] || DEFAULT_BASE_URL
|
|
26
30
|
validate_config!
|
|
27
31
|
end
|
|
28
32
|
|
|
33
|
+
# Provider name
|
|
34
|
+
# @return [Symbol] Provider identifier
|
|
29
35
|
def name
|
|
30
36
|
:claude_max_proxy
|
|
31
37
|
end
|
|
32
38
|
|
|
39
|
+
# Default model for this provider
|
|
40
|
+
# @return [String] Default model alias
|
|
33
41
|
def default_model
|
|
34
42
|
DEFAULT_MODEL
|
|
35
43
|
end
|
|
36
44
|
|
|
45
|
+
# List of available model aliases
|
|
46
|
+
# @return [Array<String>] Model alias names
|
|
37
47
|
def available_models
|
|
38
48
|
MODELS.keys
|
|
39
49
|
end
|
|
40
50
|
|
|
51
|
+
# Send a chat request
|
|
52
|
+
# @param messages [Array<Message>] Conversation history
|
|
53
|
+
# @param tools [Array<Hash>, nil] Available tools for the model to use
|
|
54
|
+
# @param model [String, nil] Model name (defaults to DEFAULT_MODEL)
|
|
55
|
+
# @param temperature [Float] Temperature for response randomness (0.0-1.0)
|
|
56
|
+
# @param max_tokens [Integer] Maximum tokens in response
|
|
57
|
+
# @param thinking [Boolean] Enable extended thinking mode (not used for this provider)
|
|
58
|
+
# @return [LLMResponse] Model response
|
|
41
59
|
def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
|
|
42
60
|
model ||= default_model
|
|
43
61
|
model_id = MODELS[model] || model
|
|
@@ -55,6 +73,14 @@ module Pocketrb
|
|
|
55
73
|
parse_response(response, model_id)
|
|
56
74
|
end
|
|
57
75
|
|
|
76
|
+
# Send a streaming chat request
|
|
77
|
+
# @param messages [Array<Message>] Conversation history
|
|
78
|
+
# @param tools [Array<Hash>, nil] Available tools for the model to use
|
|
79
|
+
# @param model [String, nil] Model name (defaults to DEFAULT_MODEL)
|
|
80
|
+
# @param temperature [Float] Temperature for response randomness (0.0-1.0)
|
|
81
|
+
# @param max_tokens [Integer] Maximum tokens in response
|
|
82
|
+
# @yieldparam chunk [String] Text chunk from streaming response
|
|
83
|
+
# @return [LLMResponse] Final complete response
|
|
58
84
|
def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &)
|
|
59
85
|
model ||= default_model
|
|
60
86
|
model_id = MODELS[model] || model
|
|
@@ -72,6 +98,7 @@ module Pocketrb
|
|
|
72
98
|
end
|
|
73
99
|
|
|
74
100
|
# Check if proxy is running
|
|
101
|
+
# @return [Boolean] true if the proxy health endpoint responds
|
|
75
102
|
def available?
|
|
76
103
|
uri = URI.parse("#{@base_url.sub("/v1", "")}/health")
|
|
77
104
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
@@ -85,10 +112,14 @@ module Pocketrb
|
|
|
85
112
|
|
|
86
113
|
protected
|
|
87
114
|
|
|
115
|
+
# Supported provider features
|
|
116
|
+
# @return [Array<Symbol>]
|
|
88
117
|
def supported_features
|
|
89
118
|
%i[tools streaming]
|
|
90
119
|
end
|
|
91
120
|
|
|
121
|
+
# Validate provider configuration (no API key needed for proxy)
|
|
122
|
+
# @return [void]
|
|
92
123
|
def validate_config!
|
|
93
124
|
# No API key needed - proxy uses CLI auth
|
|
94
125
|
Pocketrb.logger.debug("Claude Max Proxy: #{@base_url}")
|
|
@@ -96,6 +127,14 @@ module Pocketrb
|
|
|
96
127
|
|
|
97
128
|
private
|
|
98
129
|
|
|
130
|
+
# Build the OpenAI-compatible request body
|
|
131
|
+
# @param messages [Array<Message>] conversation messages
|
|
132
|
+
# @param model [String] model identifier
|
|
133
|
+
# @param tools [Array<Hash>, nil] tool definitions
|
|
134
|
+
# @param temperature [Float] sampling temperature
|
|
135
|
+
# @param max_tokens [Integer] maximum tokens to generate
|
|
136
|
+
# @param stream [Boolean] whether to enable streaming
|
|
137
|
+
# @return [Hash] request body
|
|
99
138
|
def build_request_body(messages:, model:, tools:, temperature:, max_tokens:, stream:)
|
|
100
139
|
body = {
|
|
101
140
|
model: model,
|
|
@@ -113,6 +152,9 @@ module Pocketrb
|
|
|
113
152
|
body
|
|
114
153
|
end
|
|
115
154
|
|
|
155
|
+
# Format messages for the OpenAI-compatible proxy API
|
|
156
|
+
# @param messages [Array<Message>] conversation messages
|
|
157
|
+
# @return [Array<Hash>] formatted messages with role, content, and tool data
|
|
116
158
|
def format_messages(messages)
|
|
117
159
|
messages.map do |msg|
|
|
118
160
|
formatted = { role: msg.role.to_s }
|
|
@@ -156,6 +198,9 @@ module Pocketrb
|
|
|
156
198
|
end
|
|
157
199
|
end
|
|
158
200
|
|
|
201
|
+
# Format a media object as an OpenAI image_url content block
|
|
202
|
+
# @param media [Media] image attachment to encode as a data URI or text fallback
|
|
203
|
+
# @return [Hash] image_url or text fallback content block
|
|
159
204
|
def format_image_content(media)
|
|
160
205
|
if media.data
|
|
161
206
|
{
|
|
@@ -178,6 +223,9 @@ module Pocketrb
|
|
|
178
223
|
end
|
|
179
224
|
end
|
|
180
225
|
|
|
226
|
+
# Convert tool definitions to OpenAI function-calling format
|
|
227
|
+
# @param tools [Array<Hash>] tool definitions
|
|
228
|
+
# @return [Array<Hash>] OpenAI-formatted tool definitions
|
|
181
229
|
def format_tools(tools)
|
|
182
230
|
tools.map do |tool|
|
|
183
231
|
func = tool[:function] || tool
|
|
@@ -192,6 +240,11 @@ module Pocketrb
|
|
|
192
240
|
end
|
|
193
241
|
end
|
|
194
242
|
|
|
243
|
+
# Make an HTTP POST request to the proxy
|
|
244
|
+
# @param endpoint [String] API endpoint path
|
|
245
|
+
# @param body [Hash] request body
|
|
246
|
+
# @return [Hash] parsed JSON response
|
|
247
|
+
# @raise [ProviderError] on non-200 response
|
|
195
248
|
def make_request(endpoint, body)
|
|
196
249
|
uri = URI.parse("#{@base_url}#{endpoint}")
|
|
197
250
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
@@ -218,6 +271,12 @@ module Pocketrb
|
|
|
218
271
|
JSON.parse(response.body)
|
|
219
272
|
end
|
|
220
273
|
|
|
274
|
+
# Make a streaming HTTP POST request, yielding text chunks
|
|
275
|
+
# @param endpoint [String] API endpoint path
|
|
276
|
+
# @param body [Hash] request body
|
|
277
|
+
# @param block [Proc] block to receive text chunks
|
|
278
|
+
# @return [LLMResponse] final accumulated response
|
|
279
|
+
# @raise [ProviderError] on non-200 response
|
|
221
280
|
def stream_request(endpoint, body, &block)
|
|
222
281
|
uri = URI.parse("#{@base_url}#{endpoint}")
|
|
223
282
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
@@ -277,7 +336,7 @@ module Pocketrb
|
|
|
277
336
|
# Build final response
|
|
278
337
|
parsed_tool_calls = tool_calls.compact.map do |tc|
|
|
279
338
|
args = begin
|
|
280
|
-
JSON.parse(tc[:arguments])
|
|
339
|
+
JSON.parse(tc[:arguments], allow_duplicate_key: true)
|
|
281
340
|
rescue JSON::ParserError
|
|
282
341
|
{}
|
|
283
342
|
end
|
|
@@ -297,6 +356,10 @@ module Pocketrb
|
|
|
297
356
|
)
|
|
298
357
|
end
|
|
299
358
|
|
|
359
|
+
# Parse the OpenAI-compatible response into an LLMResponse
|
|
360
|
+
# @param response [Hash] parsed JSON response
|
|
361
|
+
# @param model [String] model identifier used
|
|
362
|
+
# @return [LLMResponse] structured response
|
|
300
363
|
def parse_response(response, model)
|
|
301
364
|
choice = response.dig("choices", 0)
|
|
302
365
|
message = choice&.dig("message") || {}
|
|
@@ -326,12 +389,15 @@ module Pocketrb
|
|
|
326
389
|
)
|
|
327
390
|
end
|
|
328
391
|
|
|
392
|
+
# Parse OpenAI-format tool calls into ToolCall objects
|
|
393
|
+
# @param tool_calls [Array<Hash>, nil] raw tool call data
|
|
394
|
+
# @return [Array<ToolCall>] parsed tool calls
|
|
329
395
|
def parse_tool_calls(tool_calls)
|
|
330
396
|
return [] unless tool_calls
|
|
331
397
|
|
|
332
398
|
tool_calls.map do |tc|
|
|
333
399
|
args = begin
|
|
334
|
-
JSON.parse(tc.dig("function", "arguments") || "{}")
|
|
400
|
+
JSON.parse(tc.dig("function", "arguments") || "{}", allow_duplicate_key: true)
|
|
335
401
|
rescue JSON::ParserError
|
|
336
402
|
{}
|
|
337
403
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Pocketrb
|
|
6
|
+
module Providers
|
|
7
|
+
# Provider for any server exposing the OpenAI chat completions API (vLLM, llama.cpp server,
|
|
8
|
+
# llamafile, LM Studio, Ollama, OpenAI itself)
|
|
9
|
+
class OpenAICompatible < OpenRouter
|
|
10
|
+
# Provider name
|
|
11
|
+
# @return [Symbol]
|
|
12
|
+
def name
|
|
13
|
+
:openai_compatible
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Default model, taken from the configuration
|
|
17
|
+
# @return [String, nil]
|
|
18
|
+
def default_model
|
|
19
|
+
@config[:model]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Available models
|
|
23
|
+
# @return [Array<String>]
|
|
24
|
+
def available_models
|
|
25
|
+
[default_model].compact
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
protected
|
|
29
|
+
|
|
30
|
+
# Validate that an http(s) base URL is configured
|
|
31
|
+
# @return [void]
|
|
32
|
+
# @raise [ConfigurationError] if base URL is missing or invalid
|
|
33
|
+
def validate_config!
|
|
34
|
+
if base_url.to_s.empty?
|
|
35
|
+
raise ConfigurationError, "openai_compatible_base_url is required for #{self.class.name}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
return if http_url?(base_url)
|
|
39
|
+
|
|
40
|
+
raise ConfigurationError, "openai_compatible_base_url must be an http(s) URL, got #{base_url.inspect}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Base URL of the API, e.g. http://localhost:8000/v1
|
|
46
|
+
# @return [String, nil]
|
|
47
|
+
def base_url
|
|
48
|
+
@config[:openai_compatible_base_url] || ENV.fetch("OPENAI_COMPATIBLE_BASE_URL", nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check that a value is an http(s) URL with a host
|
|
52
|
+
# @param value [String] URL to check
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def http_url?(value)
|
|
55
|
+
uri = URI.parse(value)
|
|
56
|
+
uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
|
|
57
|
+
rescue URI::InvalidURIError
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Provider label used in error messages
|
|
62
|
+
# @return [String]
|
|
63
|
+
def api_label
|
|
64
|
+
"OpenAI-compatible"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Build Faraday HTTP client, sending the API key only when one is configured
|
|
68
|
+
# @return [Faraday::Connection]
|
|
69
|
+
def client
|
|
70
|
+
@client ||= Faraday.new(url: base_url) do |f|
|
|
71
|
+
f.headers["Content-Type"] = "application/json"
|
|
72
|
+
key = api_key(:openai_compatible_api_key)
|
|
73
|
+
f.headers["Authorization"] = "Bearer #{key}" if key
|
|
74
|
+
f.adapter Faraday.default_adapter
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -5,57 +5,81 @@ require "json"
|
|
|
5
5
|
|
|
6
6
|
module Pocketrb
|
|
7
7
|
module Providers
|
|
8
|
-
# OpenRouter API provider for multi-model access
|
|
9
|
-
# Supports Claude, GPT-4, Llama, and many other models
|
|
8
|
+
# OpenRouter API provider for multi-model access. Supports Claude, GPT-4, Llama, and many other models
|
|
10
9
|
class OpenRouter < Base
|
|
10
|
+
# OpenRouter API base URL
|
|
11
11
|
API_URL = "https://openrouter.ai/api/v1"
|
|
12
12
|
|
|
13
|
+
# Popular models available through OpenRouter
|
|
13
14
|
POPULAR_MODELS = %w[
|
|
14
|
-
anthropic/claude-sonnet-
|
|
15
|
-
anthropic/claude-
|
|
15
|
+
anthropic/claude-sonnet-5.5
|
|
16
|
+
anthropic/claude-haiku-5.5
|
|
17
|
+
anthropic/claude-opus-5.5
|
|
16
18
|
openai/gpt-4o
|
|
17
19
|
openai/gpt-4o-mini
|
|
18
|
-
google/gemini-
|
|
20
|
+
google/gemini-2.5-pro
|
|
19
21
|
meta-llama/llama-3.1-70b-instruct
|
|
20
22
|
mistralai/mistral-large
|
|
21
23
|
].freeze
|
|
22
24
|
|
|
25
|
+
# Provider name
|
|
26
|
+
# @return [Symbol]
|
|
23
27
|
def name
|
|
24
28
|
:openrouter
|
|
25
29
|
end
|
|
26
30
|
|
|
31
|
+
# Default model
|
|
32
|
+
# @return [String]
|
|
27
33
|
def default_model
|
|
28
|
-
"anthropic/claude-sonnet-
|
|
34
|
+
"anthropic/claude-sonnet-5.5"
|
|
29
35
|
end
|
|
30
36
|
|
|
37
|
+
# Available models
|
|
38
|
+
# @return [Array<String>]
|
|
31
39
|
def available_models
|
|
32
40
|
# Could fetch from API, but use popular models for now
|
|
33
41
|
POPULAR_MODELS
|
|
34
42
|
end
|
|
35
43
|
|
|
44
|
+
# Send chat completion request
|
|
45
|
+
# @param messages [Array<Message>] Conversation messages
|
|
46
|
+
# @param tools [Array<Hash>, nil] Tool definitions
|
|
47
|
+
# @param model [String, nil] Model name (e.g., "anthropic/claude-sonnet-5.5")
|
|
48
|
+
# @param temperature [Float] Sampling temperature
|
|
49
|
+
# @param max_tokens [Integer] Maximum tokens to generate
|
|
50
|
+
# @param thinking [Boolean] Enable extended thinking (model-dependent)
|
|
51
|
+
# @return [LLMResponse] Parsed response
|
|
36
52
|
def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
|
|
37
53
|
model ||= default_model
|
|
38
54
|
body = build_request_body(messages, tools, model, temperature, max_tokens)
|
|
39
55
|
|
|
40
|
-
response = client.post("
|
|
56
|
+
response = client.post("chat/completions") do |req|
|
|
41
57
|
req.body = body.to_json
|
|
42
58
|
end
|
|
43
59
|
|
|
44
60
|
handle_response(response)
|
|
45
61
|
end
|
|
46
62
|
|
|
47
|
-
|
|
63
|
+
# Send streaming chat completion request
|
|
64
|
+
# @param messages [Array<Message>] Conversation messages
|
|
65
|
+
# @param tools [Array<Hash>, nil] Tool definitions
|
|
66
|
+
# @param model [String, nil] Model name
|
|
67
|
+
# @param temperature [Float] Sampling temperature
|
|
68
|
+
# @param max_tokens [Integer] Maximum tokens to generate
|
|
69
|
+
# @yieldparam chunk [String] Streamed text chunk
|
|
70
|
+
# @return [LLMResponse] Final response
|
|
71
|
+
def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &)
|
|
48
72
|
model ||= default_model
|
|
49
73
|
body = build_request_body(messages, tools, model, temperature, max_tokens)
|
|
50
74
|
body[:stream] = true
|
|
51
75
|
|
|
52
|
-
accumulated_content = ""
|
|
76
|
+
accumulated_content = +""
|
|
53
77
|
accumulated_tool_calls = []
|
|
54
78
|
|
|
55
|
-
client.post("
|
|
79
|
+
client.post("chat/completions") do |req|
|
|
56
80
|
req.body = body.to_json
|
|
57
81
|
req.options.on_data = proc do |chunk, _|
|
|
58
|
-
process_stream_chunk(chunk, accumulated_content, accumulated_tool_calls, &
|
|
82
|
+
process_stream_chunk(chunk, accumulated_content, accumulated_tool_calls, &)
|
|
59
83
|
end
|
|
60
84
|
end
|
|
61
85
|
|
|
@@ -68,16 +92,29 @@ module Pocketrb
|
|
|
68
92
|
|
|
69
93
|
protected
|
|
70
94
|
|
|
95
|
+
# Supported provider features
|
|
96
|
+
# @return [Array<Symbol>]
|
|
71
97
|
def supported_features
|
|
72
98
|
%i[tools streaming]
|
|
73
99
|
end
|
|
74
100
|
|
|
101
|
+
# Validate that an OpenRouter API key is configured
|
|
102
|
+
# @return [void]
|
|
103
|
+
# @raise [ConfigurationError] if API key is missing
|
|
75
104
|
def validate_config!
|
|
76
105
|
require_api_key!(:openrouter_api_key)
|
|
77
106
|
end
|
|
78
107
|
|
|
79
108
|
private
|
|
80
109
|
|
|
110
|
+
# Provider label used in error messages
|
|
111
|
+
# @return [String]
|
|
112
|
+
def api_label
|
|
113
|
+
"OpenRouter"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Build Faraday HTTP client with OpenRouter headers
|
|
117
|
+
# @return [Faraday::Connection]
|
|
81
118
|
def client
|
|
82
119
|
@client ||= Faraday.new(url: API_URL) do |f|
|
|
83
120
|
f.headers["Content-Type"] = "application/json"
|
|
@@ -88,6 +125,13 @@ module Pocketrb
|
|
|
88
125
|
end
|
|
89
126
|
end
|
|
90
127
|
|
|
128
|
+
# Build the OpenAI-compatible request body for OpenRouter
|
|
129
|
+
# @param messages [Array<Message>] conversation messages
|
|
130
|
+
# @param tools [Array<Hash>, nil] tool definitions
|
|
131
|
+
# @param model [String] model identifier
|
|
132
|
+
# @param temperature [Float] sampling temperature
|
|
133
|
+
# @param max_tokens [Integer] maximum tokens to generate
|
|
134
|
+
# @return [Hash] request body
|
|
91
135
|
def build_request_body(messages, tools, model, temperature, max_tokens)
|
|
92
136
|
body = {
|
|
93
137
|
model: model,
|
|
@@ -101,6 +145,9 @@ module Pocketrb
|
|
|
101
145
|
body
|
|
102
146
|
end
|
|
103
147
|
|
|
148
|
+
# Format a single message for the OpenAI-compatible API
|
|
149
|
+
# @param message [Message] conversation message with role, content, and optional tool data
|
|
150
|
+
# @return [Hash] formatted message with role and content
|
|
104
151
|
def format_message(message)
|
|
105
152
|
case message.role
|
|
106
153
|
when Role::SYSTEM
|
|
@@ -130,6 +177,10 @@ module Pocketrb
|
|
|
130
177
|
end
|
|
131
178
|
end
|
|
132
179
|
|
|
180
|
+
# Handle HTTP response, raising on errors
|
|
181
|
+
# @param response [Faraday::Response] HTTP response
|
|
182
|
+
# @return [LLMResponse] parsed response
|
|
183
|
+
# @raise [ProviderError] on non-success HTTP status
|
|
133
184
|
def handle_response(response)
|
|
134
185
|
unless response.success?
|
|
135
186
|
error_body = begin
|
|
@@ -137,13 +188,16 @@ module Pocketrb
|
|
|
137
188
|
rescue StandardError
|
|
138
189
|
{ "error" => response.body }
|
|
139
190
|
end
|
|
140
|
-
raise ProviderError, "
|
|
191
|
+
raise ProviderError, "#{api_label} API error: #{error_body["error"]}"
|
|
141
192
|
end
|
|
142
193
|
|
|
143
194
|
data = JSON.parse(response.body)
|
|
144
195
|
parse_response(data)
|
|
145
196
|
end
|
|
146
197
|
|
|
198
|
+
# Parse OpenRouter API response into an LLMResponse
|
|
199
|
+
# @param data [Hash] parsed JSON response body
|
|
200
|
+
# @return [LLMResponse] structured response with content, tool calls, and usage
|
|
147
201
|
def parse_response(data)
|
|
148
202
|
choice = data.dig("choices", 0)
|
|
149
203
|
return LLMResponse.new(content: nil) unless choice
|
|
@@ -181,6 +235,12 @@ module Pocketrb
|
|
|
181
235
|
)
|
|
182
236
|
end
|
|
183
237
|
|
|
238
|
+
# Process a single SSE chunk from the streaming response
|
|
239
|
+
# @param chunk [String] raw SSE data chunk
|
|
240
|
+
# @param accumulated_content [String] buffer for accumulated text content
|
|
241
|
+
# @param _accumulated_tool_calls [Array<ToolCall>] buffer for tool calls (unused)
|
|
242
|
+
# @param block [Proc] callback receiving each text delta
|
|
243
|
+
# @return [void]
|
|
184
244
|
def process_stream_chunk(chunk, accumulated_content, _accumulated_tool_calls, &block)
|
|
185
245
|
chunk.split("\n").each do |line|
|
|
186
246
|
next unless line.start_with?("data: ")
|
|
@@ -5,34 +5,54 @@ module Pocketrb
|
|
|
5
5
|
# Registry for LLM providers
|
|
6
6
|
class Registry
|
|
7
7
|
class << self
|
|
8
|
+
# Get registry singleton instance
|
|
9
|
+
# @return [Registry] Registry instance
|
|
8
10
|
def instance
|
|
9
11
|
@instance ||= new
|
|
10
12
|
end
|
|
11
13
|
|
|
14
|
+
# Register a provider (class method)
|
|
15
|
+
# @param name [String, Symbol] Provider name
|
|
16
|
+
# @param klass [Class] Provider class
|
|
17
|
+
# @return [void]
|
|
12
18
|
def register(name, klass)
|
|
13
19
|
instance.register(name, klass)
|
|
14
20
|
end
|
|
15
21
|
|
|
22
|
+
# Get a provider instance (class method)
|
|
23
|
+
# @param name [String, Symbol] Provider name
|
|
24
|
+
# @param config [Hash] Provider configuration (defaults to empty hash)
|
|
25
|
+
# @return [Base] Provider instance
|
|
16
26
|
def get(name, config = {})
|
|
17
27
|
instance.get(name, config)
|
|
18
28
|
end
|
|
19
29
|
|
|
30
|
+
# Get list of available provider names (class method)
|
|
31
|
+
# @return [Array<Symbol>] Provider names
|
|
20
32
|
def available
|
|
21
33
|
instance.available
|
|
22
34
|
end
|
|
23
35
|
end
|
|
24
36
|
|
|
37
|
+
# Initialize registry with default providers
|
|
25
38
|
def initialize
|
|
26
39
|
@providers = {}
|
|
27
40
|
register_defaults
|
|
28
41
|
end
|
|
29
42
|
|
|
30
43
|
# Register a provider class
|
|
44
|
+
# @param name [String, Symbol] Provider name
|
|
45
|
+
# @param klass [Class] Provider class
|
|
46
|
+
# @return [void]
|
|
31
47
|
def register(name, klass)
|
|
32
48
|
@providers[name.to_sym] = klass
|
|
33
49
|
end
|
|
34
50
|
|
|
35
51
|
# Get an instance of a provider
|
|
52
|
+
# @param name [String, Symbol] Provider name
|
|
53
|
+
# @param config [Hash] Provider configuration (defaults to empty hash)
|
|
54
|
+
# @return [Base] Provider instance
|
|
55
|
+
# @raise [ConfigurationError] if provider is not registered
|
|
36
56
|
def get(name, config = {})
|
|
37
57
|
klass = @providers[name.to_sym]
|
|
38
58
|
raise ConfigurationError, "Unknown provider: #{name}" unless klass
|
|
@@ -41,15 +61,19 @@ module Pocketrb
|
|
|
41
61
|
end
|
|
42
62
|
|
|
43
63
|
# List available provider names
|
|
64
|
+
# @return [Array<Symbol>]
|
|
44
65
|
def available
|
|
45
66
|
@providers.keys
|
|
46
67
|
end
|
|
47
68
|
|
|
48
69
|
private
|
|
49
70
|
|
|
71
|
+
# Register all built-in provider classes
|
|
72
|
+
# @return [void]
|
|
50
73
|
def register_defaults
|
|
51
74
|
register(:anthropic, Anthropic)
|
|
52
75
|
register(:openrouter, OpenRouter)
|
|
76
|
+
register(:openai_compatible, OpenAICompatible)
|
|
53
77
|
register(:ruby_llm, RubyLLMProvider)
|
|
54
78
|
register(:claude_cli, ClaudeCLI)
|
|
55
79
|
register(:claude_max_proxy, ClaudeMaxProxy)
|