rcrewai 0.2.1 → 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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/.rubocop_todo.yml +99 -0
  4. data/CHANGELOG.md +24 -0
  5. data/README.md +2 -2
  6. data/Rakefile +53 -53
  7. data/bin/rcrewai +3 -3
  8. data/docs/mcp.md +109 -0
  9. data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
  10. data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
  11. data/docs/upgrading-to-0.3.md +163 -0
  12. data/examples/async_execution_example.rb +82 -81
  13. data/examples/hierarchical_crew_example.rb +68 -72
  14. data/examples/human_in_the_loop_example.rb +73 -74
  15. data/examples/mcp_example.rb +48 -0
  16. data/examples/native_tools_example.rb +64 -0
  17. data/examples/streaming_example.rb +56 -0
  18. data/lib/rcrewai/agent.rb +148 -287
  19. data/lib/rcrewai/async_executor.rb +43 -43
  20. data/lib/rcrewai/cli.rb +11 -11
  21. data/lib/rcrewai/configuration.rb +14 -9
  22. data/lib/rcrewai/crew.rb +56 -39
  23. data/lib/rcrewai/events.rb +30 -0
  24. data/lib/rcrewai/human_input.rb +104 -114
  25. data/lib/rcrewai/legacy_react_runner.rb +172 -0
  26. data/lib/rcrewai/llm_client.rb +1 -1
  27. data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
  28. data/lib/rcrewai/llm_clients/azure.rb +23 -128
  29. data/lib/rcrewai/llm_clients/base.rb +11 -7
  30. data/lib/rcrewai/llm_clients/google.rb +159 -95
  31. data/lib/rcrewai/llm_clients/ollama.rb +150 -106
  32. data/lib/rcrewai/llm_clients/openai.rb +140 -63
  33. data/lib/rcrewai/mcp/client.rb +101 -0
  34. data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
  35. data/lib/rcrewai/mcp/transport/http.rb +53 -0
  36. data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
  37. data/lib/rcrewai/mcp.rb +8 -0
  38. data/lib/rcrewai/memory.rb +45 -37
  39. data/lib/rcrewai/pricing.rb +34 -0
  40. data/lib/rcrewai/process.rb +86 -95
  41. data/lib/rcrewai/provider_schema.rb +38 -0
  42. data/lib/rcrewai/sse_parser.rb +55 -0
  43. data/lib/rcrewai/task.rb +56 -64
  44. data/lib/rcrewai/tool_runner.rb +132 -0
  45. data/lib/rcrewai/tool_schema.rb +97 -0
  46. data/lib/rcrewai/tools/base.rb +98 -37
  47. data/lib/rcrewai/tools/code_executor.rb +71 -74
  48. data/lib/rcrewai/tools/email_sender.rb +70 -78
  49. data/lib/rcrewai/tools/file_reader.rb +38 -30
  50. data/lib/rcrewai/tools/file_writer.rb +40 -38
  51. data/lib/rcrewai/tools/pdf_processor.rb +115 -130
  52. data/lib/rcrewai/tools/sql_database.rb +58 -55
  53. data/lib/rcrewai/tools/web_search.rb +26 -25
  54. data/lib/rcrewai/version.rb +2 -2
  55. data/lib/rcrewai.rb +18 -10
  56. data/rcrewai.gemspec +39 -39
  57. metadata +65 -47
@@ -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
- # Convert messages to Gemini format
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: formatted_contents,
31
+ contents: contents,
21
32
  generationConfig: {
22
33
  temperature: options[:temperature] || config.temperature,
23
- maxOutputTokens: options[:max_tokens] || config.max_tokens || 2048,
24
- topP: options[:top_p] || 0.8,
25
- topK: options[:top_k] || 10
26
- }
34
+ maxOutputTokens: options[:max_tokens] || config.max_tokens || 2048
35
+ }.compact
27
36
  }
28
-
29
- # Add safety settings if provided
30
- if options[:safety_settings]
31
- payload[:safetySettings] = options[:safety_settings]
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
- # Add stop sequences if provided
35
- if options[:stop_sequences]
36
- payload[:generationConfig][:stopSequences] = options[:stop_sequences]
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
- url = "#{@base_url}/models/#{config.model}:generateContent?key=#{config.api_key}"
40
- log_request(:post, url, payload)
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
- # Google AI Studio doesn't provide a models list endpoint with API key auth
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 format_messages(messages)
65
- contents = []
66
-
67
- messages.each do |msg|
68
- role = case msg[:role]
69
- when 'user'
70
- 'user'
71
- when 'assistant'
72
- 'model'
73
- when 'system'
74
- # Gemini doesn't have system role, prepend to first user message
75
- next
76
- else
77
- 'user'
78
- end
79
-
80
- content = if msg.is_a?(Hash)
81
- msg[:content]
82
- else
83
- msg.to_s
84
- end
85
-
86
- contents << {
87
- role: role,
88
- parts: [{ text: content }]
89
- }
90
- end
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
- # Handle system message by prepending to first user message
93
- system_msg = messages.find { |m| m[:role] == 'system' }
94
- if system_msg && contents.any?
95
- first_user_content = contents.find { |c| c[:role] == 'user' }
96
- if first_user_content
97
- original_text = first_user_content[:parts].first[:text]
98
- first_user_content[:parts].first[:text] = "#{system_msg[:content]}\n\n#{original_text}"
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
- contents
103
- end
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
- def format_response(response)
106
- candidate = response.dig('candidates', 0)
107
- return nil unless candidate
124
+ finish_reason = :tool_calls if tool_calls.any?
108
125
 
109
- content = candidate.dig('content', 'parts', 0, 'text')
110
- finish_reason = candidate['finishReason']
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
- # Extract usage information if available
113
- usage_metadata = response['usageMetadata']
114
- usage = if usage_metadata
115
- {
116
- 'prompt_tokens' => usage_metadata['promptTokenCount'],
117
- 'completion_tokens' => usage_metadata['candidatesTokenCount'],
118
- 'total_tokens' => usage_metadata['totalTokenCount']
119
- }
120
- end
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: content,
124
- role: 'assistant',
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
- super
134
- raise ConfigurationError, "Google API key is required" unless config.google_api_key || config.api_key
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
- error_details = response.body.dig('error', 'message') || response.body
143
- raise APIError, "Bad request: #{error_details}"
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, "Invalid API key"
209
+ raise AuthenticationError, 'Invalid API key'
146
210
  when 403
147
- raise AuthenticationError, "API key does not have permission"
211
+ raise AuthenticationError, 'API key does not have permission'
148
212
  when 429
149
- raise RateLimitError, "Rate limit exceeded or quota exhausted"
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
- url = "#{@base_url}/api/chat"
32
- log_request(:post, url, payload)
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
- result = handle_response(response)
38
- format_response(result)
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 complete(prompt:, **options)
42
- payload = {
43
- model: config.model,
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
- result = handle_response(response)
63
- format_completion_response(result)
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
- if result['models']
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
- def model_info(model_name = nil)
90
- model_name ||= config.model
91
- payload = { name: model_name }
92
- url = "#{@base_url}/api/show"
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
- handle_response(response)
96
- rescue => e
97
- logger.warn "Failed to get model info for #{model_name}: #{e.message}"
98
- nil
86
+ log_response(response)
87
+ body = handle_response(response)
88
+ normalize_non_streaming(body)
99
89
  end
100
90
 
101
- private
91
+ def stream_chat(url, payload, sink)
92
+ log_request(:post, url, payload)
102
93
 
103
- def format_messages(messages)
104
- messages.map do |msg|
105
- if msg.is_a?(Hash)
106
- {
107
- role: msg[:role],
108
- content: msg[:content]
109
- }
110
- else
111
- { role: 'user', content: msg.to_s }
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
- # Ollama doesn't provide detailed usage stats by default
121
- usage = {
122
- 'prompt_tokens' => response['prompt_eval_count'],
123
- 'completion_tokens' => response['eval_count'],
124
- 'total_tokens' => (response['prompt_eval_count'] || 0) + (response['eval_count'] || 0)
125
- }.compact
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: message['content'],
129
- role: message['role'] || 'assistant',
130
- finish_reason: response['done'] ? 'stop' : nil,
131
- usage: usage,
132
- model: response['model'] || config.model,
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 format_completion_response(response)
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: response['response'],
140
- finish_reason: response['done'] ? 'stop' : nil,
188
+ content: text && !text.empty? ? text : nil,
189
+ tool_calls: tool_calls,
141
190
  usage: {
142
- 'prompt_tokens' => response['prompt_eval_count'],
143
- 'completion_tokens' => response['eval_count'],
144
- 'total_tokens' => (response['prompt_eval_count'] || 0) + (response['eval_count'] || 0)
145
- }.compact,
146
- model: response['model'] || config.model,
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 validate_config!
152
- # Ollama doesn't require an API key
153
- raise ConfigurationError, "Model is required" unless config.model
154
-
155
- # Test connection to Ollama server
156
- test_connection
157
- end
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
- error_details = response.body['error'] || response.body
188
- raise APIError, "Bad request: #{error_details}"
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 running: ollama pull #{config.model}"
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