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.
- checksums.yaml +4 -4
- data/.env.example +38 -0
- data/.rubocop.yml +4 -0
- data/CHANGELOG.md +52 -0
- data/README.md +101 -23
- data/USAGE.md +1 -23
- data/config/locales/en.yml +208 -1
- data/config/system_prompt.md +6 -1
- data/config/tools/bash.json +1 -1
- data/config/tools/fetch.json +22 -0
- data/config/tools/websearch.json +22 -0
- data/docs/images/demo.png +0 -0
- data/lib/rubycode/adapters/base.rb +92 -2
- data/lib/rubycode/adapters/concerns/debugging.rb +32 -0
- data/lib/rubycode/adapters/concerns/error_handling.rb +89 -0
- data/lib/rubycode/adapters/concerns/http_client.rb +67 -0
- data/lib/rubycode/adapters/deepseek.rb +97 -0
- data/lib/rubycode/adapters/gemini.rb +133 -0
- data/lib/rubycode/adapters/ollama.rb +114 -82
- data/lib/rubycode/adapters/openai.rb +97 -0
- data/lib/rubycode/adapters/openrouter.rb +102 -0
- data/lib/rubycode/agent_loop.rb +110 -18
- data/lib/rubycode/client/approval_handler.rb +14 -0
- data/lib/rubycode/client/display_formatter.rb +18 -10
- data/lib/rubycode/client/response_handler.rb +4 -23
- data/lib/rubycode/client.rb +9 -0
- data/lib/rubycode/config_manager.rb +81 -0
- data/lib/rubycode/configuration.rb +21 -10
- data/lib/rubycode/database.rb +19 -0
- data/lib/rubycode/errors.rb +12 -0
- data/lib/rubycode/models/api_key.rb +118 -0
- data/lib/rubycode/models/memory.rb +84 -10
- data/lib/rubycode/models.rb +1 -0
- data/lib/rubycode/pricing.rb +59 -0
- data/lib/rubycode/search_providers/base.rb +66 -0
- data/lib/rubycode/search_providers/brave_search.rb +60 -0
- data/lib/rubycode/search_providers/concerns/debugging.rb +37 -0
- data/lib/rubycode/search_providers/concerns/error_handling.rb +64 -0
- data/lib/rubycode/search_providers/concerns/http_client.rb +67 -0
- data/lib/rubycode/search_providers/duckduckgo_instant.rb +98 -0
- data/lib/rubycode/search_providers/exa_ai.rb +171 -0
- data/lib/rubycode/search_providers/multi_provider.rb +47 -0
- data/lib/rubycode/token_counter.rb +41 -0
- data/lib/rubycode/tools/bash.rb +38 -8
- data/lib/rubycode/tools/fetch.rb +120 -0
- data/lib/rubycode/tools/web_search.rb +122 -0
- data/lib/rubycode/tools.rb +5 -1
- data/lib/rubycode/value_objects.rb +8 -4
- data/lib/rubycode/version.rb +1 -1
- data/lib/rubycode/views/adapter/debug_delay.rb +20 -0
- data/lib/rubycode/views/adapter/debug_request.rb +33 -0
- data/lib/rubycode/views/adapter/debug_response.rb +31 -0
- data/lib/rubycode/views/agent_loop/token_summary.rb +54 -0
- data/lib/rubycode/views/agent_loop.rb +2 -0
- data/lib/rubycode/views/cli/api_key_missing.rb +37 -0
- data/lib/rubycode/views/cli/config_saved.rb +17 -0
- data/lib/rubycode/views/cli/configuration_table.rb +3 -3
- data/lib/rubycode/views/cli/first_time_setup.rb +17 -0
- data/lib/rubycode/views/cli/restart_message.rb +17 -0
- data/lib/rubycode/views/cli/setup_title.rb +17 -0
- data/lib/rubycode/views/cli.rb +5 -0
- data/lib/rubycode/views/formatter/fetch_summary.rb +54 -0
- data/lib/rubycode/views/formatter/web_search_summary.rb +53 -0
- data/lib/rubycode/views/formatter.rb +2 -0
- data/lib/rubycode/views/search_provider/debug_request.rb +30 -0
- data/lib/rubycode/views/search_provider/debug_response.rb +31 -0
- data/lib/rubycode/views/web_search_approval.rb +29 -0
- data/lib/rubycode/views.rb +5 -0
- data/lib/rubycode.rb +10 -0
- data/rubycode_cli.rb +228 -32
- metadata +81 -1
|
@@ -1,15 +1,105 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "concerns/error_handling"
|
|
4
|
+
require_relative "concerns/http_client"
|
|
5
|
+
|
|
3
6
|
module RubyCode
|
|
4
7
|
module Adapters
|
|
5
8
|
# Base adapter class for LLM integrations
|
|
6
9
|
class Base
|
|
10
|
+
include Concerns::ErrorHandling
|
|
11
|
+
include Concerns::HttpClient
|
|
12
|
+
|
|
13
|
+
attr_reader :last_request_time, :current_request_tokens, :total_tokens_counter
|
|
14
|
+
|
|
7
15
|
def initialize(config)
|
|
8
16
|
@config = config
|
|
17
|
+
@last_request_time = nil
|
|
18
|
+
@current_request_tokens = nil
|
|
19
|
+
@total_tokens_counter = TokenCounter.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def generate(messages:, system: nil, tools: nil)
|
|
23
|
+
enforce_rate_limit_delay
|
|
24
|
+
|
|
25
|
+
uri = build_uri
|
|
26
|
+
payload = build_payload(messages, system, tools)
|
|
27
|
+
request = build_request(uri, payload)
|
|
28
|
+
|
|
29
|
+
body = send_request_with_retry(uri, request)
|
|
30
|
+
|
|
31
|
+
@last_request_time = Time.now
|
|
32
|
+
|
|
33
|
+
# Extract and track tokens
|
|
34
|
+
@current_request_tokens = extract_tokens(body)
|
|
35
|
+
@total_tokens_counter += @current_request_tokens
|
|
36
|
+
|
|
37
|
+
convert_response(body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Abstract methods to be implemented by subclasses
|
|
43
|
+
def adapter_name
|
|
44
|
+
raise NotImplementedError, "Subclass must define adapter_name"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def build_payload(_messages, _system, _tools)
|
|
48
|
+
raise NotImplementedError, "Subclass must define build_payload"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_request(_uri, _payload)
|
|
52
|
+
raise NotImplementedError, "Subclass must define build_request"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def convert_response(_raw_response)
|
|
56
|
+
raise NotImplementedError, "Subclass must define convert_response"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def api_endpoint
|
|
60
|
+
raise NotImplementedError, "Subclass must define api_endpoint"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def extract_tokens(_response_body)
|
|
64
|
+
raise NotImplementedError, "Subclass must implement extract_tokens"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Rate limiting enforcement
|
|
68
|
+
def enforce_rate_limit_delay
|
|
69
|
+
return unless @last_request_time
|
|
70
|
+
|
|
71
|
+
elapsed = Time.now - @last_request_time
|
|
72
|
+
min_delay = @config.adapter_request_delay || 1.5
|
|
73
|
+
|
|
74
|
+
return unless elapsed < min_delay
|
|
75
|
+
|
|
76
|
+
sleep_time = min_delay - elapsed
|
|
77
|
+
sleep(sleep_time)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Concrete shared methods
|
|
81
|
+
def api_key
|
|
82
|
+
# Check database first
|
|
83
|
+
db_key = Models::ApiKey.get_key(adapter: adapter_symbol)
|
|
84
|
+
return db_key if db_key
|
|
85
|
+
|
|
86
|
+
# Fall back to environment variable
|
|
87
|
+
ENV.fetch("#{adapter_name.upcase}_API_KEY", nil)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def adapter_symbol
|
|
91
|
+
# Convert class name to symbol: Openai -> :openai
|
|
92
|
+
self.class.name.split("::").last.downcase.to_sym
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def validate_api_key!
|
|
96
|
+
return if api_key
|
|
97
|
+
|
|
98
|
+
raise AdapterError, I18n.t("rubycode.errors.#{adapter_symbol}.api_key_missing")
|
|
9
99
|
end
|
|
10
100
|
|
|
11
|
-
def
|
|
12
|
-
|
|
101
|
+
def build_uri
|
|
102
|
+
URI(api_endpoint)
|
|
13
103
|
end
|
|
14
104
|
end
|
|
15
105
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Adapters
|
|
5
|
+
module Concerns
|
|
6
|
+
# Debug output for adapter requests and responses
|
|
7
|
+
module Debugging
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def debug_request(uri, payload)
|
|
11
|
+
puts Views::Adapter::DebugRequest.build(
|
|
12
|
+
adapter_name: adapter_name,
|
|
13
|
+
url: sanitize_url(uri),
|
|
14
|
+
model: @config.model,
|
|
15
|
+
payload: payload
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def debug_response(body)
|
|
20
|
+
puts Views::Adapter::DebugResponse.build(
|
|
21
|
+
adapter_name: adapter_name,
|
|
22
|
+
body: body
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sanitize_url(uri)
|
|
27
|
+
uri.to_s
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Adapters
|
|
5
|
+
module Concerns
|
|
6
|
+
# Error handling for adapter HTTP responses
|
|
7
|
+
module ErrorHandling
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def handle_network_error(error, uri)
|
|
11
|
+
case error
|
|
12
|
+
when Net::ReadTimeout
|
|
13
|
+
raise_timeout_error(:read_timeout, @config.http_read_timeout, error)
|
|
14
|
+
when Net::OpenTimeout
|
|
15
|
+
raise_timeout_error(:open_timeout, @config.http_open_timeout, error)
|
|
16
|
+
when Errno::ECONNREFUSED
|
|
17
|
+
raise_connection_error(:connection_refused, uri, error)
|
|
18
|
+
when Errno::ETIMEDOUT
|
|
19
|
+
raise_connection_error(:connection_timeout, uri, error)
|
|
20
|
+
when SocketError
|
|
21
|
+
raise AdapterConnectionError,
|
|
22
|
+
I18n.t("rubycode.errors.adapter.host_unreachable",
|
|
23
|
+
hostname: uri.hostname,
|
|
24
|
+
error: error.message)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def raise_timeout_error(key, timeout, error)
|
|
29
|
+
raise AdapterTimeoutError,
|
|
30
|
+
I18n.t("rubycode.errors.adapter.#{key}",
|
|
31
|
+
timeout: timeout,
|
|
32
|
+
error: error.message)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def raise_connection_error(key, uri, error)
|
|
36
|
+
raise AdapterConnectionError,
|
|
37
|
+
I18n.t("rubycode.errors.adapter.#{key}",
|
|
38
|
+
uri: uri,
|
|
39
|
+
error: error.message)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def handle_response(response)
|
|
43
|
+
body = JSON.parse(response.body)
|
|
44
|
+
|
|
45
|
+
case response.code.to_i
|
|
46
|
+
when 200..299
|
|
47
|
+
body
|
|
48
|
+
when 401, 403
|
|
49
|
+
raise_auth_error(response.code)
|
|
50
|
+
when 429
|
|
51
|
+
raise_rate_limit_error(response)
|
|
52
|
+
when 500..599
|
|
53
|
+
raise_server_error(response)
|
|
54
|
+
else
|
|
55
|
+
raise_http_error(response)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def raise_auth_error(code)
|
|
60
|
+
raise AdapterError,
|
|
61
|
+
I18n.t("rubycode.errors.adapter.auth_failed",
|
|
62
|
+
code: code,
|
|
63
|
+
adapter_name: adapter_name.upcase)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def raise_rate_limit_error(response)
|
|
67
|
+
raise AdapterConnectionError,
|
|
68
|
+
I18n.t("rubycode.errors.adapter.rate_limited",
|
|
69
|
+
code: response.code,
|
|
70
|
+
message: response.message)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def raise_server_error(response)
|
|
74
|
+
raise AdapterConnectionError,
|
|
75
|
+
I18n.t("rubycode.errors.adapter.server_error",
|
|
76
|
+
code: response.code,
|
|
77
|
+
message: response.message)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def raise_http_error(response)
|
|
81
|
+
raise AdapterError,
|
|
82
|
+
I18n.t("rubycode.errors.adapter.http_error",
|
|
83
|
+
code: response.code,
|
|
84
|
+
message: response.message)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyCode
|
|
4
|
+
module Adapters
|
|
5
|
+
module Concerns
|
|
6
|
+
# HTTP client with retry logic for adapters
|
|
7
|
+
module HttpClient
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def send_request_with_retry(uri, request)
|
|
11
|
+
attempt = 0
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
attempt += 1
|
|
15
|
+
send_request(uri, request)
|
|
16
|
+
rescue AdapterTimeoutError, AdapterConnectionError => e
|
|
17
|
+
unless attempt <= @config.max_retries
|
|
18
|
+
raise AdapterRetryExhaustedError,
|
|
19
|
+
I18n.t("rubycode.errors.adapter.retry_failed",
|
|
20
|
+
max_retries: @config.max_retries,
|
|
21
|
+
error: e.message)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
delay = @config.retry_base_delay * (2**(attempt - 1))
|
|
25
|
+
display_retry_status(attempt, @config.max_retries, delay, e)
|
|
26
|
+
sleep(delay)
|
|
27
|
+
retry
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def display_retry_status(attempt, max_retries, delay, error)
|
|
32
|
+
puts Views::AgentLoop::RetryStatus.build(
|
|
33
|
+
attempt: attempt,
|
|
34
|
+
max_retries: max_retries,
|
|
35
|
+
delay: delay,
|
|
36
|
+
error: error.message
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def send_request(uri, request)
|
|
41
|
+
response = perform_http_request(uri, request)
|
|
42
|
+
handle_response(response)
|
|
43
|
+
rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError => e
|
|
44
|
+
handle_network_error(e, uri)
|
|
45
|
+
rescue JSON::ParserError => e
|
|
46
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.invalid_json", error: e.message)
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.unexpected_error", error: e.message)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def perform_http_request(uri, request)
|
|
52
|
+
Net::HTTP.start(
|
|
53
|
+
uri.hostname,
|
|
54
|
+
uri.port,
|
|
55
|
+
use_ssl: use_ssl?(uri),
|
|
56
|
+
read_timeout: @config.http_read_timeout,
|
|
57
|
+
open_timeout: @config.http_open_timeout
|
|
58
|
+
) { |http| http.request(request) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def use_ssl?(uri)
|
|
62
|
+
uri.scheme == "https"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Adapters
|
|
8
|
+
# DeepSeek adapter for cloud LLM integration (OpenAI-compatible API)
|
|
9
|
+
# DeepSeek provides powerful reasoning models with competitive pricing
|
|
10
|
+
class Deepseek < Base
|
|
11
|
+
API_ENDPOINT = "https://api.deepseek.com/v1/chat/completions"
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
super
|
|
15
|
+
validate_api_key!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def adapter_name
|
|
21
|
+
"DeepSeek"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def api_endpoint
|
|
25
|
+
API_ENDPOINT
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_payload(messages, system, tools)
|
|
29
|
+
# OpenAI-compatible format: system message goes in messages array
|
|
30
|
+
formatted_messages = messages.dup
|
|
31
|
+
|
|
32
|
+
formatted_messages.unshift({ role: "system", content: system }) if system
|
|
33
|
+
|
|
34
|
+
payload = {
|
|
35
|
+
model: @config.model,
|
|
36
|
+
messages: formatted_messages,
|
|
37
|
+
temperature: 0.7
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# DeepSeek supports function calling format
|
|
41
|
+
payload[:tools] = tools if tools
|
|
42
|
+
|
|
43
|
+
payload
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_request(uri, payload)
|
|
47
|
+
request = Net::HTTP::Post.new(uri)
|
|
48
|
+
request["Content-Type"] = "application/json"
|
|
49
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
50
|
+
request.body = payload.to_json
|
|
51
|
+
request
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def convert_response(deepseek_response)
|
|
55
|
+
# DeepSeek uses OpenAI-compatible format
|
|
56
|
+
# Format: { choices: [{ message: { role, content, tool_calls } }] }
|
|
57
|
+
# Our format: { "message" => { "content" => ..., "tool_calls" => [...] } }
|
|
58
|
+
|
|
59
|
+
choice = deepseek_response["choices"]&.first
|
|
60
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_choices") unless choice
|
|
61
|
+
|
|
62
|
+
message = choice["message"]
|
|
63
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_message") unless message
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
"message" => {
|
|
67
|
+
"content" => message["content"] || "",
|
|
68
|
+
"tool_calls" => format_tool_calls(message["tool_calls"])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_tool_calls(tool_calls)
|
|
74
|
+
return [] unless tool_calls
|
|
75
|
+
|
|
76
|
+
tool_calls.map do |tool_call|
|
|
77
|
+
{
|
|
78
|
+
"function" => {
|
|
79
|
+
"name" => tool_call.dig("function", "name"),
|
|
80
|
+
"arguments" => tool_call.dig("function", "arguments")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def extract_tokens(response_body)
|
|
87
|
+
usage = response_body["usage"] || {}
|
|
88
|
+
|
|
89
|
+
TokenCounter.new(
|
|
90
|
+
input: usage["prompt_tokens"],
|
|
91
|
+
output: usage["completion_tokens"],
|
|
92
|
+
thinking: usage["reasoning_tokens"] # DeepSeek specific
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubyCode
|
|
7
|
+
module Adapters
|
|
8
|
+
# Google Gemini adapter for cloud LLM integration
|
|
9
|
+
# Gemini provides powerful multimodal models with long context windows
|
|
10
|
+
class Gemini < Base
|
|
11
|
+
API_ENDPOINT = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
super
|
|
15
|
+
validate_api_key!
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def adapter_name
|
|
21
|
+
"Gemini"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def api_endpoint
|
|
25
|
+
# Gemini embeds API key in URL
|
|
26
|
+
"https://generativelanguage.googleapis.com/v1beta/models/#{@config.model}:generateContent?key=#{api_key}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build_payload(messages, system, tools)
|
|
30
|
+
# Convert to Gemini format
|
|
31
|
+
contents = messages.map do |msg|
|
|
32
|
+
{
|
|
33
|
+
role: msg[:role] == "assistant" ? "model" : "user",
|
|
34
|
+
parts: [{ text: msg[:content] }]
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
payload = { contents: contents }
|
|
39
|
+
|
|
40
|
+
# Add system instruction if provided
|
|
41
|
+
payload[:systemInstruction] = { parts: [{ text: system }] } if system
|
|
42
|
+
|
|
43
|
+
# Add tools if provided
|
|
44
|
+
payload[:tools] = convert_tools_to_gemini_format(tools) if tools
|
|
45
|
+
|
|
46
|
+
payload
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def convert_tools_to_gemini_format(tools)
|
|
50
|
+
return nil unless tools
|
|
51
|
+
|
|
52
|
+
[{
|
|
53
|
+
functionDeclarations: tools.map do |tool|
|
|
54
|
+
{
|
|
55
|
+
name: tool.dig(:function, :name),
|
|
56
|
+
description: tool.dig(:function, :description),
|
|
57
|
+
parameters: tool.dig(:function, :parameters)
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
}]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_request(uri, payload)
|
|
64
|
+
request = Net::HTTP::Post.new(uri)
|
|
65
|
+
request["Content-Type"] = "application/json"
|
|
66
|
+
request.body = payload.to_json
|
|
67
|
+
request
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def convert_response(gemini_response)
|
|
71
|
+
# Gemini format: { candidates: [{ content: { parts: [...], role: "model" }, finishReason: "STOP" }] }
|
|
72
|
+
# Our format: { "message" => { "content" => ..., "tool_calls" => [...] } }
|
|
73
|
+
|
|
74
|
+
parts = extract_parts_from_response(gemini_response)
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
"message" => {
|
|
78
|
+
"content" => extract_text_content(parts),
|
|
79
|
+
"tool_calls" => extract_tool_calls(parts)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def extract_parts_from_response(gemini_response)
|
|
85
|
+
candidate = gemini_response["candidates"]&.first
|
|
86
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_choices") unless candidate
|
|
87
|
+
|
|
88
|
+
content_obj = candidate["content"]
|
|
89
|
+
raise AdapterError, I18n.t("rubycode.errors.adapter.no_message") unless content_obj
|
|
90
|
+
|
|
91
|
+
content_obj["parts"] || []
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_text_content(parts)
|
|
95
|
+
parts
|
|
96
|
+
.select { |p| p["text"] }
|
|
97
|
+
.map { |p| p["text"] }
|
|
98
|
+
.join("\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def extract_tool_calls(parts)
|
|
102
|
+
parts
|
|
103
|
+
.select { |p| p["functionCall"] }
|
|
104
|
+
.map { |p| convert_function_call(p["functionCall"]) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def convert_function_call(function_call)
|
|
108
|
+
{
|
|
109
|
+
"function" => {
|
|
110
|
+
"name" => function_call["name"],
|
|
111
|
+
"arguments" => function_call["args"] || {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def extract_tokens(response_body)
|
|
117
|
+
usage = response_body["usageMetadata"] || {}
|
|
118
|
+
candidates = usage["candidatesTokenCount"] || 0
|
|
119
|
+
thoughts = usage["thoughtsTokenCount"] || 0
|
|
120
|
+
|
|
121
|
+
TokenCounter.new(
|
|
122
|
+
input: usage["promptTokenCount"],
|
|
123
|
+
output: candidates + thoughts, # Must sum both
|
|
124
|
+
thinking: thoughts
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sanitize_url(uri)
|
|
129
|
+
uri.to_s.gsub(/key=[^&]*/, "key=***")
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|