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,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Planning
|
|
5
|
+
# Tool for creating and managing execution plans
|
|
6
|
+
class Tool < Tools::Base
|
|
7
|
+
def name
|
|
8
|
+
"plan"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def description
|
|
12
|
+
"Create and manage execution plans for complex tasks. Plans help organize multi-step work and track progress."
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def parameters
|
|
16
|
+
{
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: {
|
|
19
|
+
action: {
|
|
20
|
+
type: "string",
|
|
21
|
+
enum: %w[create update complete fail list show delete],
|
|
22
|
+
description: "Action to perform"
|
|
23
|
+
},
|
|
24
|
+
plan_name: {
|
|
25
|
+
type: "string",
|
|
26
|
+
description: "Name of the plan"
|
|
27
|
+
},
|
|
28
|
+
plan_description: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description: "Description of the plan (for create)"
|
|
31
|
+
},
|
|
32
|
+
steps: {
|
|
33
|
+
type: "array",
|
|
34
|
+
items: { type: "string" },
|
|
35
|
+
description: "Steps to add (for create or update)"
|
|
36
|
+
},
|
|
37
|
+
step_index: {
|
|
38
|
+
type: "integer",
|
|
39
|
+
description: "Step index to update (0-indexed)"
|
|
40
|
+
},
|
|
41
|
+
notes: {
|
|
42
|
+
type: "string",
|
|
43
|
+
description: "Notes for the step completion/failure"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
required: ["action"]
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def execute(
|
|
51
|
+
action:,
|
|
52
|
+
plan_name: nil,
|
|
53
|
+
plan_description: nil,
|
|
54
|
+
steps: nil,
|
|
55
|
+
step_index: nil,
|
|
56
|
+
notes: nil
|
|
57
|
+
)
|
|
58
|
+
case action
|
|
59
|
+
when "create"
|
|
60
|
+
create_plan(plan_name, plan_description, steps)
|
|
61
|
+
when "update"
|
|
62
|
+
update_plan(plan_name, steps, step_index, notes)
|
|
63
|
+
when "complete"
|
|
64
|
+
complete_step(plan_name, step_index, notes)
|
|
65
|
+
when "fail"
|
|
66
|
+
fail_step(plan_name, step_index, notes)
|
|
67
|
+
when "list"
|
|
68
|
+
list_plans
|
|
69
|
+
when "show"
|
|
70
|
+
show_plan(plan_name)
|
|
71
|
+
when "delete"
|
|
72
|
+
delete_plan(plan_name)
|
|
73
|
+
else
|
|
74
|
+
error("Unknown action: #{action}")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def manager
|
|
81
|
+
@manager ||= Manager.new(workspace: workspace)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def create_plan(name, description, steps)
|
|
85
|
+
return error("Plan name is required") unless name
|
|
86
|
+
return error("At least one step is required") if steps.nil? || steps.empty?
|
|
87
|
+
|
|
88
|
+
plan = manager.create_plan(name: name, description: description, steps: steps)
|
|
89
|
+
plan.activate!
|
|
90
|
+
manager.update_plan(name: name) # Save activated state
|
|
91
|
+
|
|
92
|
+
success("Created and activated plan '#{name}' with #{steps.length} steps\n\n#{plan.to_markdown}")
|
|
93
|
+
rescue Error => e
|
|
94
|
+
error(e.message)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def update_plan(name, new_steps, step_index, notes)
|
|
98
|
+
return error("Plan name is required") unless name
|
|
99
|
+
|
|
100
|
+
plan = manager.update_plan(
|
|
101
|
+
name: name,
|
|
102
|
+
completed_step: step_index,
|
|
103
|
+
new_steps: new_steps,
|
|
104
|
+
notes: notes
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
success("Updated plan '#{name}'\n\n#{plan.to_markdown}")
|
|
108
|
+
rescue Error => e
|
|
109
|
+
error(e.message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def complete_step(name, step_index, notes)
|
|
113
|
+
return error("Plan name is required") unless name
|
|
114
|
+
return error("Step index is required") if step_index.nil?
|
|
115
|
+
|
|
116
|
+
plan = manager.update_plan(name: name, completed_step: step_index, notes: notes)
|
|
117
|
+
|
|
118
|
+
if plan.complete?
|
|
119
|
+
success("Completed step #{step_index + 1}. Plan '#{name}' is now complete!\n\n#{plan.to_markdown}")
|
|
120
|
+
else
|
|
121
|
+
next_step = plan.next_step
|
|
122
|
+
success("Completed step #{step_index + 1}. Next: Step #{next_step.index + 1} - #{next_step.description}\n\n#{plan.to_markdown}")
|
|
123
|
+
end
|
|
124
|
+
rescue Error => e
|
|
125
|
+
error(e.message)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def fail_step(name, step_index, notes)
|
|
129
|
+
return error("Plan name is required") unless name
|
|
130
|
+
return error("Step index is required") if step_index.nil?
|
|
131
|
+
|
|
132
|
+
plan = manager.fail_step(name: name, step_index: step_index, notes: notes)
|
|
133
|
+
success("Marked step #{step_index + 1} as failed\n\n#{plan.to_markdown}")
|
|
134
|
+
rescue Error => e
|
|
135
|
+
error(e.message)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def list_plans
|
|
139
|
+
plans = manager.list_plans
|
|
140
|
+
|
|
141
|
+
return "No plans found" if plans.empty?
|
|
142
|
+
|
|
143
|
+
output = ["# Plans\n"]
|
|
144
|
+
plans.each do |plan|
|
|
145
|
+
status_emoji = case plan.status
|
|
146
|
+
when Plan::PlanStatus::ACTIVE then "🔄"
|
|
147
|
+
when Plan::PlanStatus::COMPLETED then "✅"
|
|
148
|
+
when Plan::PlanStatus::FAILED then "❌"
|
|
149
|
+
when Plan::PlanStatus::CANCELLED then "🚫"
|
|
150
|
+
else "📝"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
output << "#{status_emoji} **#{plan.name}** - #{plan.progress}% (#{plan.status})"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
output.join("\n")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def show_plan(name)
|
|
160
|
+
return error("Plan name is required") unless name
|
|
161
|
+
|
|
162
|
+
plan = manager.get_plan(name)
|
|
163
|
+
return error("Plan '#{name}' not found") unless plan
|
|
164
|
+
|
|
165
|
+
plan.to_markdown
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def delete_plan(name)
|
|
169
|
+
return error("Plan name is required") unless name
|
|
170
|
+
|
|
171
|
+
manager.delete_plan(name)
|
|
172
|
+
success("Deleted plan '#{name}'")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Pocketrb
|
|
7
|
+
module Providers
|
|
8
|
+
# Direct Anthropic Claude API provider
|
|
9
|
+
# Supports extended thinking and all Claude-specific features
|
|
10
|
+
class Anthropic < Base
|
|
11
|
+
API_URL = "https://api.anthropic.com/v1"
|
|
12
|
+
API_VERSION = "2023-06-01"
|
|
13
|
+
|
|
14
|
+
MODELS = {
|
|
15
|
+
"claude-opus-4-20250514" => { context: 200_000, output: 32_000 },
|
|
16
|
+
"claude-sonnet-4-20250514" => { context: 200_000, output: 64_000 },
|
|
17
|
+
"claude-3-5-haiku-20241022" => { context: 200_000, output: 8192 }
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def name
|
|
21
|
+
:anthropic
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def default_model
|
|
25
|
+
"claude-sonnet-4-20250514"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def available_models
|
|
29
|
+
MODELS.keys
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
|
|
33
|
+
model ||= default_model
|
|
34
|
+
body = build_request_body(messages, tools, model, temperature, max_tokens, thinking)
|
|
35
|
+
|
|
36
|
+
response = client.post("/v1/messages") do |req|
|
|
37
|
+
req.body = body.to_json
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
handle_response(response)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
|
|
44
|
+
model ||= default_model
|
|
45
|
+
body = build_request_body(messages, tools, model, temperature, max_tokens, false)
|
|
46
|
+
body[:stream] = true
|
|
47
|
+
|
|
48
|
+
accumulated_content = ""
|
|
49
|
+
accumulated_tool_calls = []
|
|
50
|
+
usage = nil
|
|
51
|
+
|
|
52
|
+
client.post("/v1/messages") do |req|
|
|
53
|
+
req.body = body.to_json
|
|
54
|
+
req.options.on_data = proc do |chunk, _|
|
|
55
|
+
process_stream_chunk(chunk, accumulated_content, accumulated_tool_calls, &block)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
LLMResponse.new(
|
|
60
|
+
content: accumulated_content,
|
|
61
|
+
tool_calls: accumulated_tool_calls,
|
|
62
|
+
usage: usage,
|
|
63
|
+
model: model
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
protected
|
|
68
|
+
|
|
69
|
+
def supported_features
|
|
70
|
+
%i[tools streaming thinking vision]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_config!
|
|
74
|
+
return if oauth_token
|
|
75
|
+
return if api_key(:anthropic_api_key)
|
|
76
|
+
|
|
77
|
+
raise ConfigurationError,
|
|
78
|
+
"Either ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY is required for #{self.class.name}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
# Check for OAuth token (Max subscription via `claude setup-token`)
|
|
84
|
+
def oauth_token
|
|
85
|
+
@config[:anthropic_oauth_token] || ENV.fetch("ANTHROPIC_OAUTH_TOKEN", nil)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def using_oauth?
|
|
89
|
+
!oauth_token.nil?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def client
|
|
93
|
+
@client ||= Faraday.new(url: API_URL) do |f|
|
|
94
|
+
f.headers["Content-Type"] = "application/json"
|
|
95
|
+
f.headers["anthropic-version"] = API_VERSION
|
|
96
|
+
|
|
97
|
+
if using_oauth?
|
|
98
|
+
# OAuth authentication for Max subscription
|
|
99
|
+
# Token generated via: claude setup-token
|
|
100
|
+
f.headers["Authorization"] = "Bearer #{oauth_token}"
|
|
101
|
+
f.headers["anthropic-beta"] = "oauth-2025-04-20"
|
|
102
|
+
else
|
|
103
|
+
# Standard API key authentication
|
|
104
|
+
f.headers["x-api-key"] = api_key(:anthropic_api_key)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
f.adapter Faraday.default_adapter
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_request_body(messages, tools, model, temperature, max_tokens, thinking)
|
|
112
|
+
system_message = extract_system_message(messages)
|
|
113
|
+
conversation = format_messages(messages.reject { |m| m.role == Role::SYSTEM })
|
|
114
|
+
|
|
115
|
+
body = {
|
|
116
|
+
model: model,
|
|
117
|
+
messages: conversation,
|
|
118
|
+
max_tokens: max_tokens
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
body[:system] = system_message if system_message
|
|
122
|
+
body[:temperature] = temperature unless thinking
|
|
123
|
+
body[:tools] = format_tools(tools) if tools&.any?
|
|
124
|
+
|
|
125
|
+
body[:thinking] = { type: "enabled", budget_tokens: [max_tokens / 2, 10_000].min } if thinking
|
|
126
|
+
|
|
127
|
+
body
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def extract_system_message(messages)
|
|
131
|
+
system_msg = messages.find { |m| m.role == Role::SYSTEM }
|
|
132
|
+
system_msg&.content
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def format_message(message)
|
|
136
|
+
case message.role
|
|
137
|
+
when Role::USER
|
|
138
|
+
{ role: "user", content: format_user_content(message.content) }
|
|
139
|
+
when Role::ASSISTANT
|
|
140
|
+
{ role: "assistant", content: format_assistant_content(message) }
|
|
141
|
+
|
|
142
|
+
when Role::TOOL
|
|
143
|
+
{
|
|
144
|
+
role: "user",
|
|
145
|
+
content: [{
|
|
146
|
+
type: "tool_result",
|
|
147
|
+
tool_use_id: message.tool_call_id,
|
|
148
|
+
content: message.content.to_s
|
|
149
|
+
}]
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
raise ArgumentError, "Unknown role: #{message.role}"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def format_user_content(content)
|
|
157
|
+
return content if content.is_a?(String)
|
|
158
|
+
|
|
159
|
+
# Handle content blocks array (text + media)
|
|
160
|
+
if content.is_a?(Array)
|
|
161
|
+
content.map do |block|
|
|
162
|
+
if block.is_a?(Hash) && block[:type] == "media"
|
|
163
|
+
format_media_block(block[:media])
|
|
164
|
+
elsif block.is_a?(Hash) && block[:type] == "text"
|
|
165
|
+
{ type: "text", text: block[:text] }
|
|
166
|
+
elsif block.is_a?(String)
|
|
167
|
+
{ type: "text", text: block }
|
|
168
|
+
else
|
|
169
|
+
block
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
else
|
|
173
|
+
content.to_s
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def format_media_block(media)
|
|
178
|
+
return { type: "text", text: "[unsupported media]" } unless media
|
|
179
|
+
|
|
180
|
+
# Only images are supported for vision
|
|
181
|
+
unless media.image? && Media::Processor::VISION_IMAGE_TYPES.include?(media.mime_type)
|
|
182
|
+
return { type: "text", text: "[Attached: #{media.filename} (#{media.mime_type})]" }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Get base64 data
|
|
186
|
+
data = if media.data
|
|
187
|
+
media.data
|
|
188
|
+
elsif media.path && File.exist?(media.path)
|
|
189
|
+
require "base64"
|
|
190
|
+
Base64.strict_encode64(File.binread(media.path))
|
|
191
|
+
else
|
|
192
|
+
return { type: "text", text: "[Image not available]" }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
type: "image",
|
|
197
|
+
source: {
|
|
198
|
+
type: "base64",
|
|
199
|
+
media_type: media.mime_type,
|
|
200
|
+
data: data
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def format_content(content)
|
|
206
|
+
return content if content.is_a?(String)
|
|
207
|
+
return content if content.is_a?(Array)
|
|
208
|
+
|
|
209
|
+
content.to_s
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def format_assistant_content(message)
|
|
213
|
+
blocks = []
|
|
214
|
+
|
|
215
|
+
blocks << { type: "text", text: message.content } if message.content && !message.content.empty?
|
|
216
|
+
|
|
217
|
+
message.tool_calls&.each do |tc|
|
|
218
|
+
blocks << {
|
|
219
|
+
type: "tool_use",
|
|
220
|
+
id: tc.id,
|
|
221
|
+
name: tc.name,
|
|
222
|
+
input: tc.arguments
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
blocks.empty? ? "" : blocks
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def format_tools(tools)
|
|
230
|
+
return nil if tools.nil? || tools.empty?
|
|
231
|
+
|
|
232
|
+
tools.map do |tool|
|
|
233
|
+
if tool[:function]
|
|
234
|
+
# OpenAI-style format
|
|
235
|
+
{
|
|
236
|
+
name: tool[:function][:name],
|
|
237
|
+
description: tool[:function][:description],
|
|
238
|
+
input_schema: tool[:function][:parameters] || { type: "object", properties: {} }
|
|
239
|
+
}
|
|
240
|
+
else
|
|
241
|
+
# Already in Anthropic format
|
|
242
|
+
tool
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def handle_response(response)
|
|
248
|
+
unless response.success?
|
|
249
|
+
error_body = begin
|
|
250
|
+
JSON.parse(response.body)
|
|
251
|
+
rescue StandardError
|
|
252
|
+
{ "error" => response.body }
|
|
253
|
+
end
|
|
254
|
+
raise ProviderError, "Anthropic API error: #{error_body["error"]}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
data = JSON.parse(response.body)
|
|
258
|
+
parse_response(data)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def parse_response(data)
|
|
262
|
+
content = ""
|
|
263
|
+
thinking = nil
|
|
264
|
+
tool_calls = []
|
|
265
|
+
|
|
266
|
+
data["content"]&.each do |block|
|
|
267
|
+
case block["type"]
|
|
268
|
+
when "text"
|
|
269
|
+
content += block["text"]
|
|
270
|
+
when "thinking"
|
|
271
|
+
thinking = block["thinking"]
|
|
272
|
+
when "tool_use"
|
|
273
|
+
tool_calls << ToolCall.new(
|
|
274
|
+
id: block["id"],
|
|
275
|
+
name: block["name"],
|
|
276
|
+
arguments: block["input"]
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
usage_data = data["usage"] || {}
|
|
282
|
+
usage = Usage.new(
|
|
283
|
+
input_tokens: usage_data["input_tokens"] || 0,
|
|
284
|
+
output_tokens: usage_data["output_tokens"] || 0,
|
|
285
|
+
cache_read: usage_data["cache_read_input_tokens"],
|
|
286
|
+
cache_write: usage_data["cache_creation_input_tokens"]
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
stop_reason = case data["stop_reason"]
|
|
290
|
+
when "end_turn" then :end_turn
|
|
291
|
+
when "tool_use" then :tool_use
|
|
292
|
+
when "max_tokens" then :max_tokens
|
|
293
|
+
when "stop_sequence" then :stop_sequence
|
|
294
|
+
else :end_turn
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
LLMResponse.new(
|
|
298
|
+
content: content.empty? ? nil : content,
|
|
299
|
+
tool_calls: tool_calls,
|
|
300
|
+
usage: usage,
|
|
301
|
+
stop_reason: stop_reason,
|
|
302
|
+
model: data["model"],
|
|
303
|
+
thinking: thinking
|
|
304
|
+
)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def process_stream_chunk(chunk, accumulated_content, _accumulated_tool_calls, &block)
|
|
308
|
+
chunk.split("\n").each do |line|
|
|
309
|
+
next unless line.start_with?("data: ")
|
|
310
|
+
|
|
311
|
+
data = begin
|
|
312
|
+
JSON.parse(line[6..])
|
|
313
|
+
rescue StandardError
|
|
314
|
+
next
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
case data["type"]
|
|
318
|
+
when "content_block_delta"
|
|
319
|
+
if data.dig("delta", "type") == "text_delta"
|
|
320
|
+
text = data.dig("delta", "text")
|
|
321
|
+
accumulated_content << text if text
|
|
322
|
+
block&.call(text)
|
|
323
|
+
end
|
|
324
|
+
when "content_block_start"
|
|
325
|
+
if data.dig("content_block", "type") == "tool_use"
|
|
326
|
+
# Tool use block starting
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pocketrb
|
|
4
|
+
module Providers
|
|
5
|
+
# Base class for LLM providers
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :config
|
|
8
|
+
|
|
9
|
+
def initialize(config = {})
|
|
10
|
+
@config = config
|
|
11
|
+
validate_config!
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Send a chat completion request
|
|
15
|
+
# @param messages [Array<Message>] Conversation history
|
|
16
|
+
# @param tools [Array<Hash>|nil] Tool definitions
|
|
17
|
+
# @param model [String|nil] Model to use (defaults to provider default)
|
|
18
|
+
# @param temperature [Float] Sampling temperature
|
|
19
|
+
# @param max_tokens [Integer] Maximum tokens to generate
|
|
20
|
+
# @param thinking [Boolean] Enable extended thinking (Claude only)
|
|
21
|
+
# @return [LLMResponse]
|
|
22
|
+
def chat(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, thinking: false)
|
|
23
|
+
raise NotImplementedError, "#{self.class}#chat must be implemented"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Stream a chat completion request
|
|
27
|
+
# @yield [String|ToolCall] Chunks of content or tool calls
|
|
28
|
+
# @return [LLMResponse]
|
|
29
|
+
def chat_stream(messages:, tools: nil, model: nil, temperature: 0.7, max_tokens: 4096, &block)
|
|
30
|
+
raise NotImplementedError, "#{self.class}#chat_stream must be implemented"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get the default model for this provider
|
|
34
|
+
# @return [String]
|
|
35
|
+
def default_model
|
|
36
|
+
raise NotImplementedError, "#{self.class}#default_model must be implemented"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# List available models
|
|
40
|
+
# @return [Array<String>]
|
|
41
|
+
def available_models
|
|
42
|
+
raise NotImplementedError, "#{self.class}#available_models must be implemented"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Provider name
|
|
46
|
+
# @return [Symbol]
|
|
47
|
+
def name
|
|
48
|
+
raise NotImplementedError, "#{self.class}#name must be implemented"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if provider supports a feature
|
|
52
|
+
# @param feature [Symbol] :tools, :streaming, :thinking, :vision
|
|
53
|
+
# @return [Boolean]
|
|
54
|
+
def supports?(feature)
|
|
55
|
+
supported_features.include?(feature)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
protected
|
|
59
|
+
|
|
60
|
+
def supported_features
|
|
61
|
+
%i[tools streaming]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def validate_config!
|
|
65
|
+
# Override in subclasses to validate required config
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def require_api_key!(key_name)
|
|
69
|
+
return if @config[key_name] || ENV[key_name.to_s.upcase]
|
|
70
|
+
|
|
71
|
+
raise ConfigurationError, "#{key_name} is required for #{self.class.name}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def api_key(key_name)
|
|
75
|
+
@config[key_name] || ENV.fetch(key_name.to_s.upcase, nil)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Convert internal message format to provider-specific format
|
|
79
|
+
def format_messages(messages)
|
|
80
|
+
messages.map { |msg| format_message(msg) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def format_message(message)
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Convert provider response to internal format
|
|
88
|
+
def parse_response(response)
|
|
89
|
+
raise NotImplementedError
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Convert tool definitions to provider-specific format
|
|
93
|
+
def format_tools(tools)
|
|
94
|
+
tools
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|