rubycode 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/.env.example +38 -0
  3. data/.rubocop.yml +4 -0
  4. data/CHANGELOG.md +52 -0
  5. data/README.md +101 -23
  6. data/USAGE.md +1 -23
  7. data/config/locales/en.yml +208 -1
  8. data/config/system_prompt.md +6 -1
  9. data/config/tools/bash.json +1 -1
  10. data/config/tools/fetch.json +22 -0
  11. data/config/tools/websearch.json +22 -0
  12. data/docs/images/demo.png +0 -0
  13. data/lib/rubycode/adapters/base.rb +92 -2
  14. data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
  15. data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
  16. data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
  17. data/lib/rubycode/adapters/deepseek.rb +97 -0
  18. data/lib/rubycode/adapters/gemini.rb +133 -0
  19. data/lib/rubycode/adapters/ollama.rb +114 -82
  20. data/lib/rubycode/adapters/openai.rb +97 -0
  21. data/lib/rubycode/adapters/openrouter.rb +102 -0
  22. data/lib/rubycode/agent_loop.rb +110 -18
  23. data/lib/rubycode/client/approval_handler.rb +14 -0
  24. data/lib/rubycode/client/display_formatter.rb +18 -10
  25. data/lib/rubycode/client/response_handler.rb +4 -23
  26. data/lib/rubycode/client.rb +9 -0
  27. data/lib/rubycode/config_manager.rb +81 -0
  28. data/lib/rubycode/configuration.rb +21 -10
  29. data/lib/rubycode/database.rb +19 -0
  30. data/lib/rubycode/errors.rb +12 -0
  31. data/lib/rubycode/models/api_key.rb +118 -0
  32. data/lib/rubycode/models/memory.rb +84 -10
  33. data/lib/rubycode/models.rb +1 -0
  34. data/lib/rubycode/pricing.rb +59 -0
  35. data/lib/rubycode/search_providers/base.rb +66 -0
  36. data/lib/rubycode/search_providers/brave_search.rb +60 -0
  37. data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
  38. data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
  39. data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
  40. data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
  41. data/lib/rubycode/search_providers/exa_ai.rb +171 -0
  42. data/lib/rubycode/search_providers/multi_provider.rb +47 -0
  43. data/lib/rubycode/token_counter.rb +41 -0
  44. data/lib/rubycode/tools/bash.rb +38 -8
  45. data/lib/rubycode/tools/fetch.rb +120 -0
  46. data/lib/rubycode/tools/web_search.rb +122 -0
  47. data/lib/rubycode/tools.rb +5 -1
  48. data/lib/rubycode/value_objects.rb +8 -4
  49. data/lib/rubycode/version.rb +1 -1
  50. data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
  51. data/lib/rubycode/views/adapter/debug_request.rb +33 -0
  52. data/lib/rubycode/views/adapter/debug_response.rb +31 -0
  53. data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
  54. data/lib/rubycode/views/agent_loop.rb +2 -0
  55. data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
  56. data/lib/rubycode/views/cli/config_saved.rb +17 -0
  57. data/lib/rubycode/views/cli/configuration_table.rb +3 -3
  58. data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
  59. data/lib/rubycode/views/cli/restart_message.rb +17 -0
  60. data/lib/rubycode/views/cli/setup_title.rb +17 -0
  61. data/lib/rubycode/views/cli.rb +5 -0
  62. data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
  63. data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
  64. data/lib/rubycode/views/formatter.rb +2 -0
  65. data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
  66. data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
  67. data/lib/rubycode/views/web_search_approval.rb +29 -0
  68. data/lib/rubycode/views.rb +5 -0
  69. data/lib/rubycode.rb +10 -0
  70. data/rubycode_cli.rb +228 -32
  71. metadata +81 -1
@@ -12,7 +12,9 @@ module RubyCode
12
12
  "read" => ["[READ]", "file_path"],
13
13
  "search" => ["[SEARCH]", "pattern"],
14
14
  "write" => ["[WRITE]", "file_path"],
15
- "update" => ["[UPDATE]", "file_path"]
15
+ "update" => ["[UPDATE]", "file_path"],
16
+ "web_search" => ["[WEB SEARCH]", "query"],
17
+ "fetch" => ["[FETCH]", "url"]
16
18
  }.freeze
17
19
 
18
20
  def initialize(config:)
@@ -21,17 +23,12 @@ module RubyCode
21
23
  end
22
24
 
23
25
  def display_tool_info(tool_name, arguments)
24
- if @config.debug
25
- puts Views::Formatter::DebugToolInfo.build(tool_name: tool_name, arguments: arguments)
26
- else
27
- display_minimal_tool_info(tool_name, arguments)
28
- end
26
+ display_minimal_tool_info(tool_name, arguments)
29
27
  end
30
28
 
31
- def display_result(result)
32
- return unless @config.debug
33
-
34
- puts Views::Formatter::ToolResult.build(result: result)
29
+ def display_result(result, tool_name: nil)
30
+ # Show summaries for web tools
31
+ display_tool_summary(result, tool_name) if %w[web_search fetch].include?(tool_name)
35
32
  end
36
33
 
37
34
  def display_info(message)
@@ -44,6 +41,17 @@ module RubyCode
44
41
 
45
42
  private
46
43
 
44
+ def display_tool_summary(result, tool_name)
45
+ return unless result.is_a?(ToolResult)
46
+
47
+ case tool_name
48
+ when "web_search"
49
+ puts Views::Formatter::WebSearchSummary.build(result: result)
50
+ when "fetch"
51
+ puts Views::Formatter::FetchSummary.build(result: result)
52
+ end
53
+ end
54
+
47
55
  def display_minimal_tool_info(tool_name, arguments)
48
56
  return unless TOOL_LABELS.key?(tool_name)
49
57
 
@@ -21,15 +21,8 @@ module RubyCode
21
21
  end
22
22
 
23
23
  def handle_empty_tool_calls(content, iteration, total_tool_calls)
24
- if @config.enable_tool_injection_workaround && iteration < 10
25
- inject_tool_reminder(iteration)
26
- return nil
27
- end
28
-
29
- unless @config.debug
30
- puts Views::ResponseHandler::CompleteMessage.build(iteration: iteration,
31
- total_tool_calls: total_tool_calls)
32
- end
24
+ puts Views::ResponseHandler::CompleteMessage.build(iteration: iteration,
25
+ total_tool_calls: total_tool_calls)
33
26
  content
34
27
  end
35
28
 
@@ -42,22 +35,10 @@ module RubyCode
42
35
  end
43
36
 
44
37
  def finalize_response(done_result, iteration, total_tool_calls)
45
- unless @config.debug
46
- puts Views::ResponseHandler::AgentFinished.build(iteration: iteration,
47
- total_tool_calls: total_tool_calls + 1)
48
- end
38
+ puts Views::ResponseHandler::AgentFinished.build(iteration: iteration,
39
+ total_tool_calls: total_tool_calls + 1)
49
40
  done_result
50
41
  end
51
-
52
- private
53
-
54
- def inject_tool_reminder(iteration)
55
- puts Views::ResponseHandler::ToolInjectionWarning.build(iteration: iteration) unless @config.debug
56
- @memory.add_message(
57
- role: "user",
58
- content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
59
- )
60
- end
61
42
  end
62
43
  end
63
44
  end
@@ -11,6 +11,7 @@ module RubyCode
11
11
  @config = RubyCode.config
12
12
  @adapter = build_adapter
13
13
  @memory = Memory.new
14
+ @memory.clear # Clear memory at start of each session to prevent payload size issues
14
15
  @read_files = Set.new
15
16
  @tty_prompt = tty_prompt
16
17
  end
@@ -39,6 +40,14 @@ module RubyCode
39
40
  case @config.adapter
40
41
  when :ollama
41
42
  Adapters::Ollama.new(@config)
43
+ when :openrouter
44
+ Adapters::Openrouter.new(@config)
45
+ when :deepseek
46
+ Adapters::Deepseek.new(@config)
47
+ when :gemini
48
+ Adapters::Gemini.new(@config)
49
+ when :openai
50
+ Adapters::Openai.new(@config)
42
51
  else
43
52
  raise I18n.t("rubycode.errors.unknown_adapter")
44
53
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module RubyCode
7
+ # Manages configuration persistence to ~/.rubycode/config.yml
8
+ class ConfigManager
9
+ CONFIG_DIR = File.join(Dir.home, ".rubycode")
10
+ CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
11
+
12
+ class << self
13
+ # Load configuration from file
14
+ # Returns hash with symbolized keys or nil if file doesn't exist
15
+ def load
16
+ return nil unless exists?
17
+
18
+ yaml_content = File.read(CONFIG_FILE)
19
+ config_hash = YAML.safe_load(yaml_content, permitted_classes: [Symbol])
20
+
21
+ # Symbolize keys
22
+ symbolize_keys(config_hash)
23
+ rescue StandardError => e
24
+ warn "Warning: Failed to load config from #{CONFIG_FILE}: #{e.message}"
25
+ nil
26
+ end
27
+
28
+ # Save configuration to file
29
+ # @param config_hash [Hash] Configuration hash to save
30
+ def save(config_hash)
31
+ FileUtils.mkdir_p(CONFIG_DIR)
32
+
33
+ File.write(CONFIG_FILE, config_hash.to_yaml)
34
+ true
35
+ rescue StandardError => e
36
+ warn "Warning: Failed to save config to #{CONFIG_FILE}: #{e.message}"
37
+ false
38
+ end
39
+
40
+ # Check if config file exists
41
+ # @return [Boolean]
42
+ def exists?
43
+ File.exist?(CONFIG_FILE)
44
+ end
45
+
46
+ # Get default configuration for a given adapter
47
+ # @param adapter [Symbol] The adapter name (:ollama, :groq)
48
+ # @return [Hash] Default configuration
49
+ def defaults_for_adapter(adapter)
50
+ case adapter
51
+ when :groq
52
+ {
53
+ adapter: :groq,
54
+ model: "llama-3.1-8b-instant",
55
+ url: "https://api.groq.com/openai/v1/chat/completions"
56
+ }
57
+ else
58
+ # Default to Ollama for unknown adapters
59
+ {
60
+ adapter: :ollama,
61
+ model: "deepseek-r1:8b",
62
+ url: "http://localhost:11434"
63
+ }
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ # Recursively symbolize hash keys
70
+ def symbolize_keys(hash)
71
+ return hash unless hash.is_a?(Hash)
72
+
73
+ hash.transform_keys do |key|
74
+ key.to_sym
75
+ rescue StandardError
76
+ key
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,26 +3,37 @@
3
3
  module RubyCode
4
4
  # Configuration class for Rubycode settings
5
5
  class Configuration
6
- attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround,
7
- :http_read_timeout, :http_open_timeout, :max_retries, :retry_base_delay
6
+ attr_accessor :adapter, :url, :model, :root_path,
7
+ :http_read_timeout, :http_open_timeout, :max_retries, :retry_base_delay,
8
+ :adapter_request_delay, :memory_window, :prune_tool_results
8
9
 
9
10
  def initialize
10
11
  @adapter = :ollama
11
- @url = "http://localhost:11434"
12
- @model = "deepseek-v3.1:671b-cloud"
12
+ @url = "https://api.ollama.com"
13
+ @model = "qwen3-coder:480b-cloud"
13
14
  @root_path = Dir.pwd
14
- @debug = false # Set to true to see JSON requests/responses
15
-
16
- # WORKAROUND for models that don't follow tool-calling instructions
17
- # When enabled, injects reminder messages if model generates text instead of calling tools
18
- # Enabled by default as most models need this nudge
19
- @enable_tool_injection_workaround = true
20
15
 
21
16
  # HTTP timeout and retry configuration
22
17
  @http_read_timeout = 120 # 2 minutes for LLM inference
23
18
  @http_open_timeout = 10 # 10 seconds for connection
24
19
  @max_retries = 3 # Number of retries (4 total attempts)
25
20
  @retry_base_delay = 2.0 # Base delay for exponential backoff
21
+
22
+ # Rate limiting configuration
23
+ @adapter_request_delay = 1.5 # Delay between consecutive requests (seconds)
24
+
25
+ # Memory optimization configuration
26
+ @memory_window = 10 # Keep last N messages for context
27
+ @prune_tool_results = true # Replace old tool results with placeholder
28
+ end
29
+
30
+ # Load configuration from a hash
31
+ # @param hash [Hash] Configuration hash with symbolized keys
32
+ def load_from_hash(hash)
33
+ @adapter = hash[:adapter] if hash.key?(:adapter)
34
+ @model = hash[:model] if hash.key?(:model)
35
+ @url = hash[:url] if hash.key?(:url)
36
+ @root_path = hash[:root_path] if hash.key?(:root_path)
26
37
  end
27
38
  end
28
39
  end
@@ -35,6 +35,7 @@ module RubyCode
35
35
 
36
36
  def run_migrations
37
37
  create_messages_table
38
+ create_api_keys_table
38
39
  end
39
40
 
40
41
  def create_messages_table
@@ -44,6 +45,24 @@ module RubyCode
44
45
  String :content, null: false, text: true
45
46
  DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
46
47
  end
48
+
49
+ # Add tool_calls column if it doesn't exist (migration for existing databases)
50
+ return if @db.schema(:messages).any? { |col| col[0] == :tool_calls }
51
+
52
+ @db.alter_table(:messages) do
53
+ add_column :tool_calls, String, text: true, null: true
54
+ end
55
+ end
56
+
57
+ def create_api_keys_table
58
+ @db.create_table?(:api_keys) do
59
+ primary_key :id
60
+ String :adapter, null: false, unique: true
61
+ String :encrypted_key, null: false, text: true
62
+ String :iv, null: false # Initialization vector for encryption
63
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
64
+ DateTime :updated_at, default: Sequel::CURRENT_TIMESTAMP
65
+ end
47
66
  end
48
67
  end
49
68
  end
@@ -19,6 +19,15 @@ module RubyCode
19
19
  # Raised when command execution fails
20
20
  class CommandExecutionError < ToolError; end
21
21
 
22
+ # Base class for all network-related errors
23
+ class NetworkError < ToolError; end
24
+
25
+ # Raised when HTTP request fails
26
+ class HTTPError < NetworkError; end
27
+
28
+ # Raised when URL is invalid
29
+ class URLError < NetworkError; end
30
+
22
31
  # Base class for all adapter-related errors
23
32
  class AdapterError < Error; end
24
33
 
@@ -30,4 +39,7 @@ module RubyCode
30
39
 
31
40
  # Raised when all retry attempts are exhausted
32
41
  class AdapterRetryExhaustedError < AdapterError; end
42
+
43
+ # Base class for all search provider-related errors
44
+ class SearchProviderError < Error; end
33
45
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module RubyCode
7
+ module Models
8
+ # Manages encrypted API keys for LLM providers
9
+ class ApiKey < Base
10
+ class << self
11
+ def table_name
12
+ :api_keys
13
+ end
14
+
15
+ # Save an API key for a specific adapter
16
+ # @param adapter [Symbol] The adapter name (:ollama, :groq, etc.)
17
+ # @param api_key [String] The plaintext API key
18
+ def save_key(adapter:, api_key:)
19
+ encrypted_data = encrypt(api_key)
20
+ existing = dataset.where(adapter: adapter.to_s).first
21
+
22
+ if existing
23
+ update_existing_key(adapter, encrypted_data)
24
+ else
25
+ insert_new_key(adapter, encrypted_data)
26
+ end
27
+ end
28
+
29
+ def update_existing_key(adapter, encrypted_data)
30
+ dataset.where(adapter: adapter.to_s).update(
31
+ encrypted_key: encrypted_data[:encrypted],
32
+ iv: encrypted_data[:iv],
33
+ updated_at: Time.now
34
+ )
35
+ end
36
+
37
+ def insert_new_key(adapter, encrypted_data)
38
+ dataset.insert(
39
+ adapter: adapter.to_s,
40
+ encrypted_key: encrypted_data[:encrypted],
41
+ iv: encrypted_data[:iv]
42
+ )
43
+ end
44
+
45
+ # Retrieve and decrypt an API key for a specific adapter
46
+ # @param adapter [Symbol] The adapter name
47
+ # @return [String, nil] The decrypted API key or nil if not found
48
+ def get_key(adapter:)
49
+ row = dataset.where(adapter: adapter.to_s).first
50
+ return nil unless row
51
+
52
+ decrypt(
53
+ encrypted: row[:encrypted_key],
54
+ init_vector: row[:iv]
55
+ )
56
+ end
57
+
58
+ # Delete an API key for a specific adapter
59
+ # @param adapter [Symbol] The adapter name
60
+ def delete_key(adapter:)
61
+ dataset.where(adapter: adapter.to_s).delete
62
+ end
63
+
64
+ # Check if an API key exists for a specific adapter
65
+ # @param adapter [Symbol] The adapter name
66
+ # @return [Boolean]
67
+ def key_exists?(adapter:)
68
+ dataset.where(adapter: adapter.to_s).any?
69
+ end
70
+
71
+ private
72
+
73
+ # Encrypt a plaintext API key
74
+ # @param plaintext [String] The API key to encrypt
75
+ # @return [Hash] Contains :encrypted and :iv
76
+ def encrypt(plaintext)
77
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
78
+ cipher.encrypt
79
+ cipher.key = encryption_key
80
+ iv = cipher.random_iv
81
+
82
+ encrypted = cipher.update(plaintext) + cipher.final
83
+
84
+ {
85
+ encrypted: Base64.strict_encode64(encrypted),
86
+ iv: Base64.strict_encode64(iv)
87
+ }
88
+ end
89
+
90
+ # Decrypt an encrypted API key
91
+ # @param encrypted [String] Base64-encoded encrypted data
92
+ # @param init_vector [String] Base64-encoded initialization vector
93
+ # @return [String] The decrypted API key
94
+ def decrypt(encrypted:, init_vector:)
95
+ cipher = OpenSSL::Cipher.new("AES-256-CBC")
96
+ cipher.decrypt
97
+ cipher.key = encryption_key
98
+ cipher.iv = Base64.strict_decode64(init_vector)
99
+
100
+ cipher.update(Base64.strict_decode64(encrypted)) + cipher.final
101
+ end
102
+
103
+ # Generate a consistent encryption key based on user's environment
104
+ # This creates a per-user encryption key
105
+ # @return [String] 32-byte encryption key
106
+ def encryption_key
107
+ # Use a combination of home directory and a salt to generate a consistent key
108
+ # This provides per-user encryption without requiring password entry
109
+ salt = "rubycode_api_key_encryption_v1"
110
+ key_base = "#{Dir.home}#{salt}"
111
+
112
+ # Use SHA-256 to generate a 32-byte key
113
+ OpenSSL::Digest::SHA256.digest(key_base)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -10,11 +10,11 @@ module RubyCode
10
10
  end
11
11
 
12
12
  # Accept either Message objects or keyword arguments for backwards compatibility
13
- def add_message(message = nil, role: nil, content: nil)
13
+ def add_message(message = nil, role: nil, content: nil, tool_calls: nil)
14
14
  if message.is_a?(Message)
15
- insert_message(message.role, message.content)
15
+ insert_message(message.role, message.content, message.tool_calls)
16
16
  elsif role && content
17
- insert_message(role, content)
17
+ insert_message(role, content, tool_calls)
18
18
  else
19
19
  raise ArgumentError, "Must provide either a Message object or role: and content: keyword arguments"
20
20
  end
@@ -22,12 +22,60 @@ module RubyCode
22
22
 
23
23
  def messages
24
24
  db[:messages].order(:id).map do |row|
25
- Message.new(role: row[:role], content: row[:content])
25
+ Message.new(
26
+ role: row[:role],
27
+ content: row[:content],
28
+ tool_calls: deserialize_tool_calls(row[:tool_calls])
29
+ )
26
30
  end
27
31
  end
28
32
 
29
- def to_llm_format
30
- messages.map(&:to_h)
33
+ def to_llm_format(window_size: 10, prune_tool_results: true)
34
+ all_messages = messages
35
+ return all_messages.map(&:to_h) if all_messages.length <= window_size
36
+
37
+ build_windowed_format(all_messages, window_size, prune_tool_results)
38
+ end
39
+
40
+ def build_windowed_format(all_messages, window_size, prune_tool_results)
41
+ first_msg, recent, middle = partition_messages(all_messages, window_size)
42
+
43
+ return recent.map(&:to_h) if recent.include?(first_msg)
44
+
45
+ result_messages = build_message_list(first_msg, middle, recent, prune_tool_results)
46
+ result_messages.map(&:to_h)
47
+ end
48
+
49
+ def build_message_list(first_msg, middle, recent, prune_tool_results)
50
+ return [first_msg] + recent unless should_prune_middle?(prune_tool_results, middle)
51
+
52
+ pruned_middle = prune_tool_results_from_middle(middle)
53
+ [first_msg] + pruned_middle + recent
54
+ end
55
+
56
+ def partition_messages(all_messages, window_size)
57
+ first_msg = all_messages.first
58
+ recent = all_messages.last(window_size)
59
+ middle = all_messages[1..-(window_size + 1)] || []
60
+ [first_msg, recent, middle]
61
+ end
62
+
63
+ def should_prune_middle?(prune_tool_results, middle)
64
+ prune_tool_results && middle.any? { |msg| tool_result_message?(msg) }
65
+ end
66
+
67
+ def tool_result_message?(message)
68
+ message.role == "user" && message.content.start_with?("Tool '")
69
+ end
70
+
71
+ def prune_tool_results_from_middle(middle)
72
+ middle.map do |msg|
73
+ if tool_result_message?(msg)
74
+ Message.new(role: msg.role, content: "[Tool result cleared]")
75
+ else
76
+ msg
77
+ end
78
+ end
31
79
  end
32
80
 
33
81
  def clear
@@ -38,20 +86,46 @@ module RubyCode
38
86
  row = self.class.latest.where(role: "user").first
39
87
  return nil unless row
40
88
 
41
- Message.new(role: row[:role], content: row[:content])
89
+ Message.new(
90
+ role: row[:role],
91
+ content: row[:content],
92
+ tool_calls: deserialize_tool_calls(row[:tool_calls])
93
+ )
42
94
  end
43
95
 
44
96
  def last_assistant_message
45
97
  row = self.class.latest.where(role: "assistant").first
46
98
  return nil unless row
47
99
 
48
- Message.new(role: row[:role], content: row[:content])
100
+ Message.new(
101
+ role: row[:role],
102
+ content: row[:content],
103
+ tool_calls: deserialize_tool_calls(row[:tool_calls])
104
+ )
49
105
  end
50
106
 
51
107
  private
52
108
 
53
- def insert_message(role, content)
54
- db[:messages].insert(role: role, content: content)
109
+ def insert_message(role, content, tool_calls = nil)
110
+ db[:messages].insert(
111
+ role: role,
112
+ content: content,
113
+ tool_calls: serialize_tool_calls(tool_calls)
114
+ )
115
+ end
116
+
117
+ def serialize_tool_calls(tool_calls)
118
+ return nil if tool_calls.nil? || tool_calls.empty?
119
+
120
+ JSON.generate(tool_calls)
121
+ end
122
+
123
+ def deserialize_tool_calls(tool_calls_json)
124
+ return nil if tool_calls_json.nil? || tool_calls_json.empty?
125
+
126
+ JSON.parse(tool_calls_json)
127
+ rescue JSON::ParserError
128
+ nil
55
129
  end
56
130
  end
57
131
  end
@@ -2,3 +2,4 @@
2
2
 
3
3
  require_relative "models/base"
4
4
  require_relative "models/memory"
5
+ require_relative "models/api_key"
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyCode
4
+ # Calculates cost estimates based on provider pricing
5
+ module Pricing
6
+ # Prices per 1M tokens (as of March 2026)
7
+ RATES = {
8
+ gemini: {
9
+ "gemini-2.5-flash" => { input: 0.0375, output: 0.15, cached: 0.01 },
10
+ "gemini-2.5-pro" => { input: 1.25, output: 5.00, cached: 0.31 },
11
+ "gemini-3-flash-preview" => { input: 0.0375, output: 0.15, cached: 0.01 }
12
+ },
13
+ openai: {
14
+ "gpt-4o" => { input: 2.50, output: 10.00, cached: 1.25 },
15
+ "gpt-4o-mini" => { input: 0.15, output: 0.60, cached: 0.075 },
16
+ "o1" => { input: 15.00, output: 60.00 }
17
+ },
18
+ deepseek: {
19
+ "deepseek-chat" => { input: 0.14, output: 0.28, cached: 0.014 },
20
+ "deepseek-reasoner" => { input: 0.55, output: 2.19, cached: 0.014 }
21
+ },
22
+ openrouter: {
23
+ "anthropic/claude-sonnet-4.5" => { input: 3.00, output: 15.00, cached: 0.30 },
24
+ "anthropic/claude-opus-4.6" => { input: 15.00, output: 75.00, cached: 1.50 }
25
+ },
26
+ ollama: {
27
+ "default" => { input: 0.0, output: 0.0 }
28
+ }
29
+ }.freeze
30
+
31
+ def self.calculate_cost(adapter:, model:, tokens:)
32
+ rates = find_rates(adapter, model)
33
+ return 0.0 unless rates
34
+
35
+ input_cost = calculate_token_cost(tokens.input, rates[:input])
36
+ output_cost = calculate_token_cost(tokens.output, rates[:output])
37
+ cached_cost = calculate_token_cost(tokens.cached, rates[:cached] || 0)
38
+
39
+ input_cost + output_cost - cached_cost # Cached reduces cost
40
+ end
41
+
42
+ def self.find_rates(adapter, model)
43
+ RATES.dig(adapter.to_sym, model) || RATES.dig(adapter.to_sym, "default")
44
+ end
45
+
46
+ def self.calculate_token_cost(token_count, rate_per_million)
47
+ (token_count / 1_000_000.0) * rate_per_million
48
+ end
49
+
50
+ def self.format_cost(cost_usd)
51
+ if cost_usd < 0.01
52
+ cents = (cost_usd * 100).round(4)
53
+ "$#{cents}¢"
54
+ else
55
+ "$#{cost_usd.round(4)}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "concerns/http_client"
4
+ require_relative "concerns/error_handling"
5
+
6
+ module RubyCode
7
+ module SearchProviders
8
+ # Base class for search providers
9
+ class Base
10
+ include Concerns::HttpClient
11
+ include Concerns::ErrorHandling
12
+
13
+ def initialize(config: nil, api_key: nil)
14
+ @config = config
15
+ @api_key = api_key
16
+ validate_api_key! if requires_api_key?
17
+ end
18
+
19
+ def search(query, max_results: 5)
20
+ uri_or_request = build_request(query, max_results)
21
+
22
+ debug_search_request(query, max_results, uri_or_request) if debug_enabled?
23
+
24
+ response = make_http_request(uri_or_request)
25
+
26
+ debug_search_response(response) if debug_enabled?
27
+
28
+ parse_results(response.body, max_results)
29
+ end
30
+
31
+ private
32
+
33
+ # Abstract methods to be implemented by subclasses
34
+ def provider_name
35
+ raise NotImplementedError, "Subclass must define provider_name"
36
+ end
37
+
38
+ def build_request(_query, _max_results)
39
+ raise NotImplementedError, "Subclass must define build_request"
40
+ end
41
+
42
+ def parse_results(_body, _max_results)
43
+ raise NotImplementedError, "Subclass must define parse_results"
44
+ end
45
+
46
+ # Concrete shared methods
47
+ def requires_api_key?
48
+ # Override in subclass if API key is required
49
+ false
50
+ end
51
+
52
+ attr_reader :api_key
53
+
54
+ def validate_api_key!
55
+ return if api_key && !api_key.empty?
56
+
57
+ raise SearchProviderError, I18n.t("rubycode.errors.search_provider.api_key_missing",
58
+ provider: provider_name)
59
+ end
60
+
61
+ def debug_enabled?
62
+ @config&.debug || false
63
+ end
64
+ end
65
+ end
66
+ end