rcrewai 0.2.0 → 0.3.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 +1 -0
- data/.rubocop_todo.yml +99 -0
- data/CHANGELOG.md +24 -0
- data/README.md +33 -1
- 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 +148 -287
- data/lib/rcrewai/async_executor.rb +43 -43
- data/lib/rcrewai/cli.rb +11 -11
- data/lib/rcrewai/configuration.rb +14 -9
- data/lib/rcrewai/crew.rb +56 -39
- data/lib/rcrewai/events.rb +30 -0
- data/lib/rcrewai/human_input.rb +104 -114
- data/lib/rcrewai/legacy_react_runner.rb +172 -0
- data/lib/rcrewai/llm_client.rb +1 -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/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 +56 -64
- 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 +18 -10
- data/rcrewai.gemspec +55 -36
- metadata +86 -50
|
@@ -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
|
|
@@ -1,18 +1,32 @@
|
|
|
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 '../provider_schema'
|
|
8
|
+
require_relative '../pricing'
|
|
4
9
|
|
|
5
10
|
module RCrewAI
|
|
6
11
|
module LLMClients
|
|
7
12
|
class Ollama < Base
|
|
8
13
|
DEFAULT_URL = 'http://localhost:11434'
|
|
9
14
|
|
|
15
|
+
NATIVE_TOOL_MODELS = %w[
|
|
16
|
+
llama3.1 llama3.1:8b llama3.1:70b llama3.1:405b
|
|
17
|
+
llama3.2 llama3.2:1b llama3.2:3b
|
|
18
|
+
qwen2.5 qwen2.5:7b qwen2.5:14b qwen2.5:32b qwen2.5:72b
|
|
19
|
+
mistral-nemo mistral-large
|
|
20
|
+
command-r command-r-plus
|
|
21
|
+
firefunction-v2
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
10
24
|
def initialize(config = RCrewAI.configuration)
|
|
11
25
|
super
|
|
12
26
|
@base_url = config.base_url || ollama_url || DEFAULT_URL
|
|
13
27
|
end
|
|
14
28
|
|
|
15
|
-
def chat(messages:, **options)
|
|
29
|
+
def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options) # rubocop:disable Lint/UnusedMethodArgument
|
|
16
30
|
payload = {
|
|
17
31
|
model: config.model,
|
|
18
32
|
messages: format_messages(messages),
|
|
@@ -24,147 +38,174 @@ module RCrewAI
|
|
|
24
38
|
repeat_penalty: options[:repeat_penalty]
|
|
25
39
|
}.compact
|
|
26
40
|
}
|
|
27
|
-
|
|
28
|
-
# Add stop sequences if provided
|
|
29
41
|
payload[:options][:stop] = options[:stop] if options[:stop]
|
|
30
42
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
response = http_client.post(url, payload, build_headers)
|
|
35
|
-
log_response(response)
|
|
43
|
+
if tools && !tools.empty?
|
|
44
|
+
payload[:tools] = ProviderSchema.for_many(:ollama, tools)
|
|
45
|
+
end
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
url = "#{@base_url}/api/chat"
|
|
48
|
+
if stream
|
|
49
|
+
payload[:stream] = true
|
|
50
|
+
stream_chat(url, payload, stream)
|
|
51
|
+
else
|
|
52
|
+
payload[:stream] = false
|
|
53
|
+
plain_chat(url, payload)
|
|
54
|
+
end
|
|
39
55
|
end
|
|
40
56
|
|
|
41
|
-
def
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
prompt: prompt,
|
|
45
|
-
options: {
|
|
46
|
-
temperature: options[:temperature] || config.temperature,
|
|
47
|
-
num_predict: options[:max_tokens] || config.max_tokens,
|
|
48
|
-
top_p: options[:top_p],
|
|
49
|
-
top_k: options[:top_k],
|
|
50
|
-
repeat_penalty: options[:repeat_penalty]
|
|
51
|
-
}.compact
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
payload[:options][:stop] = options[:stop] if options[:stop]
|
|
55
|
-
|
|
56
|
-
url = "#{@base_url}/api/generate"
|
|
57
|
-
log_request(:post, url, payload)
|
|
58
|
-
|
|
59
|
-
response = http_client.post(url, payload, build_headers)
|
|
60
|
-
log_response(response)
|
|
57
|
+
def supports_native_tools?(model: config.model)
|
|
58
|
+
override = RCrewAI.configuration.respond_to?(:ollama_native_tools) ? RCrewAI.configuration.ollama_native_tools : nil
|
|
59
|
+
return override unless override.nil?
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
base = model.to_s.split(':').first
|
|
62
|
+
NATIVE_TOOL_MODELS.any? { |m| m == model || m.split(':').first == base }
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
def models
|
|
67
66
|
url = "#{@base_url}/api/tags"
|
|
68
67
|
response = http_client.get(url, {}, build_headers)
|
|
69
68
|
result = handle_response(response)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
result['models'].map { |model| model['name'] }
|
|
73
|
-
else
|
|
74
|
-
[]
|
|
75
|
-
end
|
|
76
|
-
rescue => e
|
|
69
|
+
Array(result['models']).map { |m| m['name'] }
|
|
70
|
+
rescue StandardError => e
|
|
77
71
|
logger.warn "Failed to fetch Ollama models: #{e.message}"
|
|
78
72
|
[]
|
|
79
73
|
end
|
|
80
74
|
|
|
81
75
|
def pull_model(model_name)
|
|
82
|
-
payload = { name: model_name }
|
|
83
76
|
url = "#{@base_url}/api/pull"
|
|
84
|
-
|
|
85
|
-
response = http_client.post(url, payload, build_headers)
|
|
77
|
+
response = http_client.post(url, { name: model_name }, build_headers)
|
|
86
78
|
handle_response(response)
|
|
87
79
|
end
|
|
88
80
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
url
|
|
93
|
-
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def plain_chat(url, payload)
|
|
84
|
+
log_request(:post, url, payload)
|
|
94
85
|
response = http_client.post(url, payload, build_headers)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
nil
|
|
86
|
+
log_response(response)
|
|
87
|
+
body = handle_response(response)
|
|
88
|
+
normalize_non_streaming(body)
|
|
99
89
|
end
|
|
100
90
|
|
|
101
|
-
|
|
91
|
+
def stream_chat(url, payload, sink)
|
|
92
|
+
log_request(:post, url, payload)
|
|
102
93
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
94
|
+
assembled_text = +''
|
|
95
|
+
tool_calls = []
|
|
96
|
+
finish_reason = nil
|
|
97
|
+
prompt_tokens = nil
|
|
98
|
+
completion_tokens = nil
|
|
99
|
+
buffer = String.new(encoding: Encoding::UTF_8)
|
|
100
|
+
|
|
101
|
+
process_line = lambda do |line|
|
|
102
|
+
line = line.strip
|
|
103
|
+
return if line.empty?
|
|
104
|
+
|
|
105
|
+
data = JSON.parse(line)
|
|
106
|
+
if (msg = data['message'])
|
|
107
|
+
if msg['content']
|
|
108
|
+
assembled_text << msg['content']
|
|
109
|
+
sink.call(Events::TextDelta.new(type: :text_delta, timestamp: Time.now,
|
|
110
|
+
agent: nil, iteration: nil,
|
|
111
|
+
text: msg['content']))
|
|
112
|
+
end
|
|
113
|
+
Array(msg['tool_calls']).each do |tc|
|
|
114
|
+
fn = tc['function'] || {}
|
|
115
|
+
tool_calls << {
|
|
116
|
+
id: tc['id'],
|
|
117
|
+
name: fn['name'],
|
|
118
|
+
arguments: fn['arguments'].is_a?(String) ? JSON.parse(fn['arguments']) : (fn['arguments'] || {})
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
if data['done']
|
|
123
|
+
finish_reason = tool_calls.any? ? :tool_calls : :stop
|
|
124
|
+
prompt_tokens = data['prompt_eval_count']
|
|
125
|
+
completion_tokens = data['eval_count']
|
|
112
126
|
end
|
|
113
127
|
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def format_response(response)
|
|
117
|
-
message = response['message']
|
|
118
|
-
return nil unless message
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
streaming_post(url, payload) do |chunk|
|
|
130
|
+
chunk = chunk.dup.force_encoding(Encoding::UTF_8) unless chunk.encoding == Encoding::UTF_8
|
|
131
|
+
buffer << chunk
|
|
132
|
+
while (idx = buffer.index("\n"))
|
|
133
|
+
line = buffer.slice!(0, idx + 1)
|
|
134
|
+
process_line.call(line)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
process_line.call(buffer) unless buffer.empty?
|
|
138
|
+
|
|
139
|
+
if prompt_tokens || completion_tokens
|
|
140
|
+
sink.call(Events::Usage.new(
|
|
141
|
+
type: :usage, timestamp: Time.now, agent: nil, iteration: nil,
|
|
142
|
+
prompt_tokens: prompt_tokens, completion_tokens: completion_tokens,
|
|
143
|
+
total_tokens: (prompt_tokens || 0) + (completion_tokens || 0),
|
|
144
|
+
cost_usd: nil
|
|
145
|
+
))
|
|
146
|
+
end
|
|
126
147
|
|
|
127
148
|
{
|
|
128
|
-
content:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
content: assembled_text.empty? ? nil : assembled_text,
|
|
150
|
+
tool_calls: tool_calls,
|
|
151
|
+
usage: {
|
|
152
|
+
prompt_tokens: prompt_tokens,
|
|
153
|
+
completion_tokens: completion_tokens,
|
|
154
|
+
total_tokens: (prompt_tokens || 0) + (completion_tokens || 0)
|
|
155
|
+
},
|
|
156
|
+
finish_reason: finish_reason || :stop,
|
|
157
|
+
model: config.model,
|
|
133
158
|
provider: :ollama
|
|
134
159
|
}
|
|
135
160
|
end
|
|
136
161
|
|
|
137
|
-
def
|
|
162
|
+
def streaming_post(url, payload, &on_chunk)
|
|
163
|
+
conn = Faraday.new do |f|
|
|
164
|
+
f.request :json
|
|
165
|
+
f.options.timeout = config.timeout
|
|
166
|
+
f.adapter Faraday.default_adapter
|
|
167
|
+
end
|
|
168
|
+
conn.post(url) do |req|
|
|
169
|
+
req.headers = build_headers
|
|
170
|
+
req.body = payload.to_json
|
|
171
|
+
req.options.on_data = proc { |chunk, _| on_chunk.call(chunk) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def normalize_non_streaming(body)
|
|
176
|
+
msg = body['message'] || {}
|
|
177
|
+
text = msg['content']
|
|
178
|
+
tool_calls = Array(msg['tool_calls']).map do |tc|
|
|
179
|
+
fn = tc['function'] || {}
|
|
180
|
+
args = fn['arguments']
|
|
181
|
+
args = JSON.parse(args) if args.is_a?(String)
|
|
182
|
+
{ id: tc['id'], name: fn['name'], arguments: args || {} }
|
|
183
|
+
end
|
|
184
|
+
prompt_tokens = body['prompt_eval_count']
|
|
185
|
+
completion_tokens = body['eval_count']
|
|
186
|
+
|
|
138
187
|
{
|
|
139
|
-
content:
|
|
140
|
-
|
|
188
|
+
content: text && !text.empty? ? text : nil,
|
|
189
|
+
tool_calls: tool_calls,
|
|
141
190
|
usage: {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
191
|
+
prompt_tokens: prompt_tokens,
|
|
192
|
+
completion_tokens: completion_tokens,
|
|
193
|
+
total_tokens: (prompt_tokens || 0) + (completion_tokens || 0)
|
|
194
|
+
},
|
|
195
|
+
finish_reason: tool_calls.any? ? :tool_calls : :stop,
|
|
196
|
+
model: body['model'] || config.model,
|
|
147
197
|
provider: :ollama
|
|
148
198
|
}
|
|
149
199
|
end
|
|
150
200
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def test_connection
|
|
160
|
-
url = "#{@base_url}/api/tags"
|
|
161
|
-
response = http_client.get(url, {}, build_headers)
|
|
162
|
-
|
|
163
|
-
unless (200..299).include?(response.status)
|
|
164
|
-
raise ConfigurationError, "Cannot connect to Ollama server at #{@base_url}"
|
|
201
|
+
def format_messages(messages)
|
|
202
|
+
messages.map do |msg|
|
|
203
|
+
if msg.is_a?(Hash)
|
|
204
|
+
{ role: msg[:role], content: msg[:content] }
|
|
205
|
+
else
|
|
206
|
+
{ role: 'user', content: msg.to_s }
|
|
207
|
+
end
|
|
165
208
|
end
|
|
166
|
-
rescue Faraday::ConnectionFailed
|
|
167
|
-
raise ConfigurationError, "Cannot connect to Ollama server at #{@base_url}. Is Ollama running?"
|
|
168
209
|
end
|
|
169
210
|
|
|
170
211
|
def ollama_url
|
|
@@ -172,22 +213,25 @@ module RCrewAI
|
|
|
172
213
|
end
|
|
173
214
|
|
|
174
215
|
def build_headers
|
|
175
|
-
# Ollama doesn't require special headers
|
|
176
216
|
{
|
|
177
217
|
'Content-Type' => 'application/json',
|
|
178
218
|
'User-Agent' => "rcrewai/#{RCrewAI::VERSION}"
|
|
179
219
|
}
|
|
180
220
|
end
|
|
181
221
|
|
|
222
|
+
def validate_config!
|
|
223
|
+
raise ConfigurationError, 'Model is required' unless config.model
|
|
224
|
+
end
|
|
225
|
+
|
|
182
226
|
def handle_response(response)
|
|
183
227
|
case response.status
|
|
184
228
|
when 200..299
|
|
185
229
|
response.body
|
|
186
230
|
when 400
|
|
187
|
-
|
|
188
|
-
raise APIError, "Bad request: #{
|
|
231
|
+
details = response.body.is_a?(Hash) ? response.body['error'] : response.body
|
|
232
|
+
raise APIError, "Bad request: #{details}"
|
|
189
233
|
when 404
|
|
190
|
-
raise ModelNotFoundError, "Model '#{config.model}' not found. Try
|
|
234
|
+
raise ModelNotFoundError, "Model '#{config.model}' not found. Try: ollama pull #{config.model}"
|
|
191
235
|
when 500..599
|
|
192
236
|
raise APIError, "Ollama server error: #{response.status}"
|
|
193
237
|
else
|
|
@@ -196,4 +240,4 @@ module RCrewAI
|
|
|
196
240
|
end
|
|
197
241
|
end
|
|
198
242
|
end
|
|
199
|
-
end
|
|
243
|
+
end
|