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.
Files changed (83) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +32 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +456 -0
  5. data/exe/pocketrb +6 -0
  6. data/lib/pocketrb/agent/compaction.rb +187 -0
  7. data/lib/pocketrb/agent/context.rb +171 -0
  8. data/lib/pocketrb/agent/loop.rb +276 -0
  9. data/lib/pocketrb/agent/spawn_tool.rb +72 -0
  10. data/lib/pocketrb/agent/subagent_manager.rb +196 -0
  11. data/lib/pocketrb/bus/events.rb +99 -0
  12. data/lib/pocketrb/bus/message_bus.rb +148 -0
  13. data/lib/pocketrb/channels/base.rb +69 -0
  14. data/lib/pocketrb/channels/cli.rb +109 -0
  15. data/lib/pocketrb/channels/telegram.rb +607 -0
  16. data/lib/pocketrb/channels/whatsapp.rb +242 -0
  17. data/lib/pocketrb/cli/base.rb +119 -0
  18. data/lib/pocketrb/cli/chat.rb +67 -0
  19. data/lib/pocketrb/cli/config.rb +52 -0
  20. data/lib/pocketrb/cli/cron.rb +144 -0
  21. data/lib/pocketrb/cli/gateway.rb +132 -0
  22. data/lib/pocketrb/cli/init.rb +39 -0
  23. data/lib/pocketrb/cli/plans.rb +28 -0
  24. data/lib/pocketrb/cli/skills.rb +34 -0
  25. data/lib/pocketrb/cli/start.rb +55 -0
  26. data/lib/pocketrb/cli/telegram.rb +93 -0
  27. data/lib/pocketrb/cli/version.rb +18 -0
  28. data/lib/pocketrb/cli/whatsapp.rb +60 -0
  29. data/lib/pocketrb/cli.rb +124 -0
  30. data/lib/pocketrb/config.rb +190 -0
  31. data/lib/pocketrb/cron/job.rb +155 -0
  32. data/lib/pocketrb/cron/service.rb +395 -0
  33. data/lib/pocketrb/heartbeat/service.rb +175 -0
  34. data/lib/pocketrb/mcp/client.rb +172 -0
  35. data/lib/pocketrb/mcp/memory_tool.rb +133 -0
  36. data/lib/pocketrb/media/processor.rb +258 -0
  37. data/lib/pocketrb/memory.rb +283 -0
  38. data/lib/pocketrb/planning/manager.rb +159 -0
  39. data/lib/pocketrb/planning/plan.rb +223 -0
  40. data/lib/pocketrb/planning/tool.rb +176 -0
  41. data/lib/pocketrb/providers/anthropic.rb +333 -0
  42. data/lib/pocketrb/providers/base.rb +98 -0
  43. data/lib/pocketrb/providers/claude_cli.rb +412 -0
  44. data/lib/pocketrb/providers/claude_max_proxy.rb +347 -0
  45. data/lib/pocketrb/providers/openrouter.rb +205 -0
  46. data/lib/pocketrb/providers/registry.rb +59 -0
  47. data/lib/pocketrb/providers/ruby_llm_provider.rb +136 -0
  48. data/lib/pocketrb/providers/types.rb +111 -0
  49. data/lib/pocketrb/session/manager.rb +192 -0
  50. data/lib/pocketrb/session/session.rb +204 -0
  51. data/lib/pocketrb/skills/builtin/github/SKILL.md +113 -0
  52. data/lib/pocketrb/skills/builtin/proactive/SKILL.md +101 -0
  53. data/lib/pocketrb/skills/builtin/reflection/SKILL.md +109 -0
  54. data/lib/pocketrb/skills/builtin/tmux/SKILL.md +130 -0
  55. data/lib/pocketrb/skills/builtin/weather/SKILL.md +130 -0
  56. data/lib/pocketrb/skills/create_tool.rb +115 -0
  57. data/lib/pocketrb/skills/loader.rb +164 -0
  58. data/lib/pocketrb/skills/modify_tool.rb +123 -0
  59. data/lib/pocketrb/skills/skill.rb +75 -0
  60. data/lib/pocketrb/tools/background_job_manager.rb +261 -0
  61. data/lib/pocketrb/tools/base.rb +118 -0
  62. data/lib/pocketrb/tools/browser.rb +152 -0
  63. data/lib/pocketrb/tools/browser_advanced.rb +470 -0
  64. data/lib/pocketrb/tools/browser_session.rb +167 -0
  65. data/lib/pocketrb/tools/cron.rb +222 -0
  66. data/lib/pocketrb/tools/edit_file.rb +101 -0
  67. data/lib/pocketrb/tools/exec.rb +194 -0
  68. data/lib/pocketrb/tools/jobs.rb +127 -0
  69. data/lib/pocketrb/tools/list_dir.rb +102 -0
  70. data/lib/pocketrb/tools/memory.rb +167 -0
  71. data/lib/pocketrb/tools/message.rb +70 -0
  72. data/lib/pocketrb/tools/para_memory.rb +264 -0
  73. data/lib/pocketrb/tools/read_file.rb +65 -0
  74. data/lib/pocketrb/tools/registry.rb +160 -0
  75. data/lib/pocketrb/tools/send_file.rb +158 -0
  76. data/lib/pocketrb/tools/think.rb +35 -0
  77. data/lib/pocketrb/tools/web_fetch.rb +150 -0
  78. data/lib/pocketrb/tools/web_search.rb +102 -0
  79. data/lib/pocketrb/tools/write_file.rb +55 -0
  80. data/lib/pocketrb/version.rb +5 -0
  81. data/lib/pocketrb.rb +75 -0
  82. data/pocketrb.gemspec +60 -0
  83. 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