rcrewai 0.2.1 → 0.4.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/.rubocop.yml +21 -0
- data/.rubocop_todo.yml +99 -0
- data/CHANGELOG.md +64 -1
- data/README.md +170 -2
- data/ROADMAP.md +84 -0
- data/Rakefile +53 -53
- data/bin/rcrewai +3 -3
- data/docs/mcp.md +109 -0
- data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
- data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
- data/docs/upgrading-to-0.3.md +163 -0
- data/examples/async_execution_example.rb +82 -81
- data/examples/hierarchical_crew_example.rb +68 -72
- data/examples/human_in_the_loop_example.rb +73 -74
- data/examples/mcp_example.rb +48 -0
- data/examples/native_tools_example.rb +64 -0
- data/examples/streaming_example.rb +56 -0
- data/lib/rcrewai/agent.rb +181 -286
- data/lib/rcrewai/async_executor.rb +43 -43
- data/lib/rcrewai/cli.rb +11 -11
- data/lib/rcrewai/configuration.rb +34 -9
- data/lib/rcrewai/crew.rb +134 -39
- data/lib/rcrewai/events.rb +30 -0
- data/lib/rcrewai/flow/state.rb +47 -0
- data/lib/rcrewai/flow/state_store.rb +50 -0
- data/lib/rcrewai/flow.rb +243 -0
- data/lib/rcrewai/human_input.rb +104 -114
- data/lib/rcrewai/knowledge/base.rb +52 -0
- data/lib/rcrewai/knowledge/chunker.rb +31 -0
- data/lib/rcrewai/knowledge/embedder.rb +48 -0
- data/lib/rcrewai/knowledge/sources.rb +83 -0
- data/lib/rcrewai/knowledge/store.rb +58 -0
- data/lib/rcrewai/knowledge.rb +13 -0
- data/lib/rcrewai/legacy_react_runner.rb +172 -0
- data/lib/rcrewai/llm_client.rb +24 -1
- data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
- data/lib/rcrewai/llm_clients/azure.rb +23 -128
- data/lib/rcrewai/llm_clients/base.rb +11 -7
- data/lib/rcrewai/llm_clients/google.rb +159 -95
- data/lib/rcrewai/llm_clients/ollama.rb +150 -106
- data/lib/rcrewai/llm_clients/openai.rb +140 -63
- data/lib/rcrewai/mcp/client.rb +101 -0
- data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
- data/lib/rcrewai/mcp/transport/http.rb +53 -0
- data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
- data/lib/rcrewai/mcp.rb +8 -0
- data/lib/rcrewai/memory.rb +45 -37
- data/lib/rcrewai/output_schema.rb +79 -0
- data/lib/rcrewai/planning.rb +65 -0
- data/lib/rcrewai/pricing.rb +34 -0
- data/lib/rcrewai/process.rb +86 -95
- data/lib/rcrewai/provider_schema.rb +38 -0
- data/lib/rcrewai/sse_parser.rb +55 -0
- data/lib/rcrewai/task.rb +145 -66
- data/lib/rcrewai/tool_runner.rb +132 -0
- data/lib/rcrewai/tool_schema.rb +97 -0
- data/lib/rcrewai/tools/base.rb +98 -37
- data/lib/rcrewai/tools/code_executor.rb +71 -74
- data/lib/rcrewai/tools/email_sender.rb +70 -78
- data/lib/rcrewai/tools/file_reader.rb +38 -30
- data/lib/rcrewai/tools/file_writer.rb +40 -38
- data/lib/rcrewai/tools/pdf_processor.rb +115 -130
- data/lib/rcrewai/tools/sql_database.rb +58 -55
- data/lib/rcrewai/tools/web_search.rb +26 -25
- data/lib/rcrewai/version.rb +2 -2
- data/lib/rcrewai.rb +20 -10
- data/rcrewai.gemspec +39 -39
- metadata +77 -47
|
@@ -1,158 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
3
|
+
require_relative 'openai'
|
|
4
4
|
|
|
5
5
|
module RCrewAI
|
|
6
6
|
module LLMClients
|
|
7
|
-
|
|
7
|
+
# Azure OpenAI: same wire format as OpenAI but routes through a deployment
|
|
8
|
+
# path with an api-version query param, and authenticates with an api-key
|
|
9
|
+
# header instead of Authorization: Bearer.
|
|
10
|
+
class Azure < OpenAI
|
|
8
11
|
def initialize(config = RCrewAI.configuration)
|
|
9
12
|
super
|
|
10
|
-
@base_url = config.base_url || build_azure_url
|
|
11
13
|
@api_version = config.api_version || '2024-02-01'
|
|
12
14
|
@deployment_name = config.deployment_name || config.model
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def chat(messages:, **options)
|
|
16
|
-
payload = {
|
|
17
|
-
messages: format_messages(messages),
|
|
18
|
-
temperature: options[:temperature] || config.temperature,
|
|
19
|
-
max_tokens: options[:max_tokens] || config.max_tokens
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
# Add additional OpenAI-compatible options
|
|
23
|
-
payload[:top_p] = options[:top_p] if options[:top_p]
|
|
24
|
-
payload[:frequency_penalty] = options[:frequency_penalty] if options[:frequency_penalty]
|
|
25
|
-
payload[:presence_penalty] = options[:presence_penalty] if options[:presence_penalty]
|
|
26
|
-
payload[:stop] = options[:stop] if options[:stop]
|
|
27
|
-
|
|
28
|
-
url = "#{@base_url}/openai/deployments/#{@deployment_name}/chat/completions?api-version=#{@api_version}"
|
|
29
|
-
log_request(:post, url, payload)
|
|
30
|
-
|
|
31
|
-
response = http_client.post(url, payload, build_headers.merge(authorization_header))
|
|
32
|
-
log_response(response)
|
|
33
|
-
|
|
34
|
-
result = handle_response(response)
|
|
35
|
-
format_response(result)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def complete(prompt:, **options)
|
|
39
|
-
# For older models that use completions endpoint
|
|
40
|
-
payload = {
|
|
41
|
-
prompt: prompt,
|
|
42
|
-
temperature: options[:temperature] || config.temperature,
|
|
43
|
-
max_tokens: options[:max_tokens] || config.max_tokens
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
url = "#{@base_url}/openai/deployments/#{@deployment_name}/completions?api-version=#{@api_version}"
|
|
47
|
-
log_request(:post, url, payload)
|
|
48
|
-
|
|
49
|
-
response = http_client.post(url, payload, build_headers.merge(authorization_header))
|
|
50
|
-
log_response(response)
|
|
51
|
-
|
|
52
|
-
result = handle_response(response)
|
|
53
|
-
format_completion_response(result)
|
|
15
|
+
@base_url = build_endpoint_url
|
|
54
16
|
end
|
|
55
17
|
|
|
56
18
|
def models
|
|
57
|
-
# Azure OpenAI uses deployments instead of models
|
|
58
19
|
url = "#{@base_url}/openai/deployments?api-version=#{@api_version}"
|
|
59
|
-
response = http_client.get(url, {}, build_headers.merge(
|
|
20
|
+
response = http_client.get(url, {}, build_headers.merge(auth_header))
|
|
60
21
|
result = handle_response(response)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else
|
|
65
|
-
[@deployment_name].compact
|
|
66
|
-
end
|
|
22
|
+
Array(result['data']).map { |d| d['id'] }
|
|
23
|
+
rescue StandardError
|
|
24
|
+
[@deployment_name].compact
|
|
67
25
|
end
|
|
68
26
|
|
|
69
27
|
private
|
|
70
28
|
|
|
71
|
-
def
|
|
72
|
-
{
|
|
29
|
+
def chat_url
|
|
30
|
+
"#{@base_url}/openai/deployments/#{@deployment_name}/chat/completions?api-version=#{@api_version}"
|
|
73
31
|
end
|
|
74
32
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
msg
|
|
79
|
-
else
|
|
80
|
-
{ role: 'user', content: msg.to_s }
|
|
81
|
-
end
|
|
82
|
-
end
|
|
33
|
+
def build_endpoint_url
|
|
34
|
+
endpoint = config.base_url || ENV['AZURE_OPENAI_ENDPOINT'] || ENV['AZURE_ENDPOINT']
|
|
35
|
+
endpoint&.chomp('/')
|
|
83
36
|
end
|
|
84
37
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
return nil unless choice
|
|
88
|
-
|
|
89
|
-
{
|
|
90
|
-
content: choice.dig('message', 'content'),
|
|
91
|
-
role: choice.dig('message', 'role'),
|
|
92
|
-
finish_reason: choice['finish_reason'],
|
|
93
|
-
usage: response['usage'],
|
|
94
|
-
model: @deployment_name,
|
|
95
|
-
provider: :azure
|
|
96
|
-
}
|
|
38
|
+
def provider_name
|
|
39
|
+
:azure
|
|
97
40
|
end
|
|
98
41
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
return nil unless choice
|
|
102
|
-
|
|
103
|
-
{
|
|
104
|
-
content: choice['text'],
|
|
105
|
-
finish_reason: choice['finish_reason'],
|
|
106
|
-
usage: response['usage'],
|
|
107
|
-
model: @deployment_name,
|
|
108
|
-
provider: :azure
|
|
109
|
-
}
|
|
42
|
+
def auth_header
|
|
43
|
+
{ 'api-key' => config.azure_api_key || config.api_key }
|
|
110
44
|
end
|
|
111
45
|
|
|
112
46
|
def validate_config!
|
|
113
|
-
|
|
114
|
-
raise ConfigurationError,
|
|
115
|
-
raise ConfigurationError,
|
|
116
|
-
raise ConfigurationError, "Azure deployment name is required" unless config.deployment_name || config.model
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def build_azure_url
|
|
120
|
-
endpoint = azure_endpoint
|
|
121
|
-
return nil unless endpoint
|
|
122
|
-
|
|
123
|
-
# Remove trailing slash and add proper path
|
|
124
|
-
endpoint = endpoint.chomp('/')
|
|
125
|
-
"#{endpoint}"
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
def azure_endpoint
|
|
129
|
-
# Try multiple environment variable names
|
|
130
|
-
ENV['AZURE_OPENAI_ENDPOINT'] ||
|
|
131
|
-
ENV['AZURE_ENDPOINT'] ||
|
|
132
|
-
config.instance_variable_get(:@azure_endpoint)
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def handle_response(response)
|
|
136
|
-
case response.status
|
|
137
|
-
when 200..299
|
|
138
|
-
response.body
|
|
139
|
-
when 400
|
|
140
|
-
error_details = response.body.dig('error', 'message') || response.body
|
|
141
|
-
raise APIError, "Bad request: #{error_details}"
|
|
142
|
-
when 401
|
|
143
|
-
raise AuthenticationError, "Invalid API key or authentication failed"
|
|
144
|
-
when 403
|
|
145
|
-
raise AuthenticationError, "Access denied - check your API key and permissions"
|
|
146
|
-
when 404
|
|
147
|
-
raise ModelNotFoundError, "Deployment '#{@deployment_name}' not found"
|
|
148
|
-
when 429
|
|
149
|
-
raise RateLimitError, "Rate limit exceeded or quota exhausted"
|
|
150
|
-
when 500..599
|
|
151
|
-
raise APIError, "Azure OpenAI service error: #{response.status}"
|
|
152
|
-
else
|
|
153
|
-
raise APIError, "Unexpected response: #{response.status}"
|
|
154
|
-
end
|
|
47
|
+
raise ConfigurationError, 'Azure API key is required' unless config.azure_api_key || config.api_key
|
|
48
|
+
raise ConfigurationError, 'Azure endpoint is required' unless config.base_url || ENV['AZURE_OPENAI_ENDPOINT'] || ENV['AZURE_ENDPOINT']
|
|
49
|
+
raise ConfigurationError, 'Azure deployment name is required' unless config.deployment_name || config.model
|
|
155
50
|
end
|
|
156
51
|
end
|
|
157
52
|
end
|
|
158
|
-
end
|
|
53
|
+
end
|
|
@@ -16,8 +16,12 @@ module RCrewAI
|
|
|
16
16
|
validate_config!
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def chat(messages:, **options)
|
|
20
|
-
raise NotImplementedError,
|
|
19
|
+
def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
|
|
20
|
+
raise NotImplementedError, 'Subclasses must implement #chat method'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
|
|
24
|
+
true
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def complete(prompt:, **options)
|
|
@@ -27,8 +31,8 @@ module RCrewAI
|
|
|
27
31
|
protected
|
|
28
32
|
|
|
29
33
|
def validate_config!
|
|
30
|
-
raise ConfigurationError,
|
|
31
|
-
raise ConfigurationError,
|
|
34
|
+
raise ConfigurationError, 'API key is required' unless config.api_key
|
|
35
|
+
raise ConfigurationError, 'Model is required' unless config.model
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def build_headers
|
|
@@ -54,9 +58,9 @@ module RCrewAI
|
|
|
54
58
|
when 400
|
|
55
59
|
raise APIError, "Bad request: #{response.body}"
|
|
56
60
|
when 401
|
|
57
|
-
raise AuthenticationError,
|
|
61
|
+
raise AuthenticationError, 'Invalid API key'
|
|
58
62
|
when 429
|
|
59
|
-
raise RateLimitError,
|
|
63
|
+
raise RateLimitError, 'Rate limit exceeded'
|
|
60
64
|
when 500..599
|
|
61
65
|
raise APIError, "Server error: #{response.status}"
|
|
62
66
|
else
|
|
@@ -79,4 +83,4 @@ module RCrewAI
|
|
|
79
83
|
class RateLimitError < APIError; end
|
|
80
84
|
class ModelNotFoundError < APIError; end
|
|
81
85
|
end
|
|
82
|
-
end
|
|
86
|
+
end
|
|
@@ -1,137 +1,201 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
3
5
|
require_relative 'base'
|
|
6
|
+
require_relative '../events'
|
|
7
|
+
require_relative '../sse_parser'
|
|
8
|
+
require_relative '../provider_schema'
|
|
9
|
+
require_relative '../pricing'
|
|
4
10
|
|
|
5
11
|
module RCrewAI
|
|
6
12
|
module LLMClients
|
|
7
13
|
class Google < Base
|
|
8
14
|
BASE_URL = 'https://generativelanguage.googleapis.com/v1beta'
|
|
9
15
|
|
|
16
|
+
FINISH_REASON_MAP = {
|
|
17
|
+
'STOP' => :stop,
|
|
18
|
+
'MAX_TOKENS' => :length,
|
|
19
|
+
'SAFETY' => :stop,
|
|
20
|
+
'RECITATION' => :stop
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
10
23
|
def initialize(config = RCrewAI.configuration)
|
|
11
24
|
super
|
|
12
25
|
@base_url = BASE_URL
|
|
13
26
|
end
|
|
14
27
|
|
|
15
|
-
def chat(messages:, **options)
|
|
16
|
-
|
|
17
|
-
formatted_contents = format_messages(messages)
|
|
18
|
-
|
|
28
|
+
def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options) # rubocop:disable Lint/UnusedMethodArgument
|
|
29
|
+
contents = format_messages(messages)
|
|
19
30
|
payload = {
|
|
20
|
-
contents:
|
|
31
|
+
contents: contents,
|
|
21
32
|
generationConfig: {
|
|
22
33
|
temperature: options[:temperature] || config.temperature,
|
|
23
|
-
maxOutputTokens: options[:max_tokens] || config.max_tokens || 2048
|
|
24
|
-
|
|
25
|
-
topK: options[:top_k] || 10
|
|
26
|
-
}
|
|
34
|
+
maxOutputTokens: options[:max_tokens] || config.max_tokens || 2048
|
|
35
|
+
}.compact
|
|
27
36
|
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if options[:
|
|
31
|
-
|
|
37
|
+
payload[:generationConfig][:topP] = options[:top_p] if options[:top_p]
|
|
38
|
+
payload[:generationConfig][:topK] = options[:top_k] if options[:top_k]
|
|
39
|
+
payload[:generationConfig][:stopSequences] = options[:stop_sequences] if options[:stop_sequences]
|
|
40
|
+
payload[:safetySettings] = options[:safety_settings] if options[:safety_settings]
|
|
41
|
+
|
|
42
|
+
if tools && !tools.empty?
|
|
43
|
+
payload[:tools] = [ProviderSchema.for_many(:google, tools)]
|
|
44
|
+
# tool_choice: Google has toolConfig; left as default unless explicitly set
|
|
32
45
|
end
|
|
33
46
|
|
|
34
|
-
|
|
35
|
-
if
|
|
36
|
-
|
|
47
|
+
api_key = config.google_api_key || config.api_key
|
|
48
|
+
if stream
|
|
49
|
+
url = "#{@base_url}/models/#{config.model}:streamGenerateContent?alt=sse&key=#{api_key}"
|
|
50
|
+
stream_chat(url, payload, stream)
|
|
51
|
+
else
|
|
52
|
+
url = "#{@base_url}/models/#{config.model}:generateContent?key=#{api_key}"
|
|
53
|
+
plain_chat(url, payload)
|
|
37
54
|
end
|
|
55
|
+
end
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
response = http_client.post(url, payload, build_headers)
|
|
43
|
-
log_response(response)
|
|
44
|
-
|
|
45
|
-
result = handle_response(response)
|
|
46
|
-
format_response(result)
|
|
57
|
+
def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
|
|
58
|
+
true
|
|
47
59
|
end
|
|
48
60
|
|
|
49
61
|
def models
|
|
50
|
-
|
|
51
|
-
# Return known Gemini models
|
|
52
|
-
[
|
|
53
|
-
'gemini-pro',
|
|
54
|
-
'gemini-pro-vision',
|
|
55
|
-
'gemini-1.5-pro',
|
|
56
|
-
'gemini-1.5-flash',
|
|
57
|
-
'text-bison-001',
|
|
58
|
-
'chat-bison-001'
|
|
59
|
-
]
|
|
62
|
+
%w[gemini-pro gemini-1.5-pro gemini-1.5-flash gemini-pro-vision]
|
|
60
63
|
end
|
|
61
64
|
|
|
62
65
|
private
|
|
63
66
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
67
|
+
def plain_chat(url, payload)
|
|
68
|
+
log_request(:post, url, payload)
|
|
69
|
+
response = http_client.post(url, payload, build_headers)
|
|
70
|
+
log_response(response)
|
|
71
|
+
body = handle_response(response)
|
|
72
|
+
normalize_non_streaming(body)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def stream_chat(url, payload, sink)
|
|
76
|
+
log_request(:post, url, payload)
|
|
77
|
+
|
|
78
|
+
assembled_text = +''
|
|
79
|
+
tool_calls = []
|
|
80
|
+
finish_reason = nil
|
|
81
|
+
usage = nil
|
|
82
|
+
|
|
83
|
+
parser = SSEParser.new do |sse|
|
|
84
|
+
data = JSON.parse(sse[:data])
|
|
85
|
+
candidate = data.dig('candidates', 0) || {}
|
|
86
|
+
parts = candidate.dig('content', 'parts') || []
|
|
87
|
+
|
|
88
|
+
parts.each do |part|
|
|
89
|
+
if part['text']
|
|
90
|
+
text = part['text']
|
|
91
|
+
assembled_text << text
|
|
92
|
+
sink.call(Events::TextDelta.new(type: :text_delta, timestamp: Time.now,
|
|
93
|
+
agent: nil, iteration: nil, text: text))
|
|
94
|
+
elsif part['functionCall']
|
|
95
|
+
fc = part['functionCall']
|
|
96
|
+
tool_calls << { id: nil, name: fc['name'], arguments: fc['args'] || {} }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
91
99
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
finish_reason ||= FINISH_REASON_MAP[candidate['finishReason']]
|
|
101
|
+
if data['usageMetadata']
|
|
102
|
+
usage = {
|
|
103
|
+
prompt_tokens: data.dig('usageMetadata', 'promptTokenCount'),
|
|
104
|
+
completion_tokens: data.dig('usageMetadata', 'candidatesTokenCount'),
|
|
105
|
+
total_tokens: data.dig('usageMetadata', 'totalTokenCount')
|
|
106
|
+
}
|
|
99
107
|
end
|
|
100
108
|
end
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
streaming_post(url, payload) { |chunk| parser.feed(chunk) }
|
|
111
|
+
|
|
112
|
+
if usage
|
|
113
|
+
sink.call(Events::Usage.new(
|
|
114
|
+
type: :usage, timestamp: Time.now, agent: nil, iteration: nil,
|
|
115
|
+
prompt_tokens: usage[:prompt_tokens],
|
|
116
|
+
completion_tokens: usage[:completion_tokens],
|
|
117
|
+
total_tokens: usage[:total_tokens],
|
|
118
|
+
cost_usd: Pricing.cost_for(config.model,
|
|
119
|
+
prompt_tokens: usage[:prompt_tokens] || 0,
|
|
120
|
+
completion_tokens: usage[:completion_tokens] || 0)
|
|
121
|
+
))
|
|
122
|
+
end
|
|
104
123
|
|
|
105
|
-
|
|
106
|
-
candidate = response.dig('candidates', 0)
|
|
107
|
-
return nil unless candidate
|
|
124
|
+
finish_reason = :tool_calls if tool_calls.any?
|
|
108
125
|
|
|
109
|
-
|
|
110
|
-
|
|
126
|
+
{
|
|
127
|
+
content: assembled_text.empty? ? nil : assembled_text,
|
|
128
|
+
tool_calls: tool_calls,
|
|
129
|
+
usage: usage || {},
|
|
130
|
+
finish_reason: finish_reason || :stop,
|
|
131
|
+
model: config.model,
|
|
132
|
+
provider: :google
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def streaming_post(url, payload, &on_chunk)
|
|
137
|
+
conn = Faraday.new do |f|
|
|
138
|
+
f.request :json
|
|
139
|
+
f.options.timeout = config.timeout
|
|
140
|
+
f.adapter Faraday.default_adapter
|
|
141
|
+
end
|
|
142
|
+
conn.post(url) do |req|
|
|
143
|
+
req.headers = build_headers
|
|
144
|
+
req.body = payload.to_json
|
|
145
|
+
req.options.on_data = proc { |chunk, _| on_chunk.call(chunk) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
111
148
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
149
|
+
def normalize_non_streaming(body)
|
|
150
|
+
candidate = body.dig('candidates', 0) || {}
|
|
151
|
+
parts = candidate.dig('content', 'parts') || []
|
|
152
|
+
text = parts.select { |p| p['text'] }.map { |p| p['text'] }.join
|
|
153
|
+
tool_calls = parts.select { |p| p['functionCall'] }.map do |p|
|
|
154
|
+
{ id: nil, name: p.dig('functionCall', 'name'), arguments: p.dig('functionCall', 'args') || {} }
|
|
155
|
+
end
|
|
156
|
+
usage_md = body['usageMetadata'] || {}
|
|
157
|
+
finish_reason = tool_calls.any? ? :tool_calls : (FINISH_REASON_MAP[candidate['finishReason']] || :stop)
|
|
121
158
|
|
|
122
159
|
{
|
|
123
|
-
content:
|
|
124
|
-
|
|
160
|
+
content: text.empty? ? nil : text,
|
|
161
|
+
tool_calls: tool_calls,
|
|
162
|
+
usage: {
|
|
163
|
+
prompt_tokens: usage_md['promptTokenCount'],
|
|
164
|
+
completion_tokens: usage_md['candidatesTokenCount'],
|
|
165
|
+
total_tokens: usage_md['totalTokenCount']
|
|
166
|
+
},
|
|
125
167
|
finish_reason: finish_reason,
|
|
126
|
-
usage: usage,
|
|
127
168
|
model: config.model,
|
|
128
169
|
provider: :google
|
|
129
170
|
}
|
|
130
171
|
end
|
|
131
172
|
|
|
173
|
+
def format_messages(messages)
|
|
174
|
+
contents = []
|
|
175
|
+
system_msg = nil
|
|
176
|
+
|
|
177
|
+
messages.each do |msg|
|
|
178
|
+
case msg[:role]
|
|
179
|
+
when 'system'
|
|
180
|
+
system_msg = msg[:content]
|
|
181
|
+
next
|
|
182
|
+
when 'assistant'
|
|
183
|
+
contents << { role: 'model', parts: [{ text: msg[:content].to_s }] }
|
|
184
|
+
else
|
|
185
|
+
contents << { role: 'user', parts: [{ text: msg[:content].to_s }] }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
if system_msg && (first_user = contents.find { |c| c[:role] == 'user' })
|
|
190
|
+
first_user[:parts].first[:text] = "#{system_msg}\n\n#{first_user[:parts].first[:text]}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
contents
|
|
194
|
+
end
|
|
195
|
+
|
|
132
196
|
def validate_config!
|
|
133
|
-
|
|
134
|
-
raise ConfigurationError,
|
|
197
|
+
raise ConfigurationError, 'Google API key is required' unless config.google_api_key || config.api_key
|
|
198
|
+
raise ConfigurationError, 'Model is required' unless config.model
|
|
135
199
|
end
|
|
136
200
|
|
|
137
201
|
def handle_response(response)
|
|
@@ -139,14 +203,14 @@ module RCrewAI
|
|
|
139
203
|
when 200..299
|
|
140
204
|
response.body
|
|
141
205
|
when 400
|
|
142
|
-
|
|
143
|
-
raise APIError, "Bad request: #{
|
|
206
|
+
details = response.body.is_a?(Hash) ? response.body.dig('error', 'message') : response.body
|
|
207
|
+
raise APIError, "Bad request: #{details}"
|
|
144
208
|
when 401
|
|
145
|
-
raise AuthenticationError,
|
|
209
|
+
raise AuthenticationError, 'Invalid API key'
|
|
146
210
|
when 403
|
|
147
|
-
raise AuthenticationError,
|
|
211
|
+
raise AuthenticationError, 'API key does not have permission'
|
|
148
212
|
when 429
|
|
149
|
-
raise RateLimitError,
|
|
213
|
+
raise RateLimitError, 'Rate limit exceeded'
|
|
150
214
|
when 500..599
|
|
151
215
|
raise APIError, "Server error: #{response.status}"
|
|
152
216
|
else
|
|
@@ -155,4 +219,4 @@ module RCrewAI
|
|
|
155
219
|
end
|
|
156
220
|
end
|
|
157
221
|
end
|
|
158
|
-
end
|
|
222
|
+
end
|