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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +21 -0
  3. data/.rubocop_todo.yml +99 -0
  4. data/CHANGELOG.md +64 -1
  5. data/README.md +170 -2
  6. data/ROADMAP.md +84 -0
  7. data/Rakefile +53 -53
  8. data/bin/rcrewai +3 -3
  9. data/docs/mcp.md +109 -0
  10. data/docs/superpowers/plans/2026-05-11-llm-modernization.md +2753 -0
  11. data/docs/superpowers/specs/2026-05-11-llm-modernization-design.md +479 -0
  12. data/docs/upgrading-to-0.3.md +163 -0
  13. data/examples/async_execution_example.rb +82 -81
  14. data/examples/hierarchical_crew_example.rb +68 -72
  15. data/examples/human_in_the_loop_example.rb +73 -74
  16. data/examples/mcp_example.rb +48 -0
  17. data/examples/native_tools_example.rb +64 -0
  18. data/examples/streaming_example.rb +56 -0
  19. data/lib/rcrewai/agent.rb +181 -286
  20. data/lib/rcrewai/async_executor.rb +43 -43
  21. data/lib/rcrewai/cli.rb +11 -11
  22. data/lib/rcrewai/configuration.rb +34 -9
  23. data/lib/rcrewai/crew.rb +134 -39
  24. data/lib/rcrewai/events.rb +30 -0
  25. data/lib/rcrewai/flow/state.rb +47 -0
  26. data/lib/rcrewai/flow/state_store.rb +50 -0
  27. data/lib/rcrewai/flow.rb +243 -0
  28. data/lib/rcrewai/human_input.rb +104 -114
  29. data/lib/rcrewai/knowledge/base.rb +52 -0
  30. data/lib/rcrewai/knowledge/chunker.rb +31 -0
  31. data/lib/rcrewai/knowledge/embedder.rb +48 -0
  32. data/lib/rcrewai/knowledge/sources.rb +83 -0
  33. data/lib/rcrewai/knowledge/store.rb +58 -0
  34. data/lib/rcrewai/knowledge.rb +13 -0
  35. data/lib/rcrewai/legacy_react_runner.rb +172 -0
  36. data/lib/rcrewai/llm_client.rb +24 -1
  37. data/lib/rcrewai/llm_clients/anthropic.rb +174 -54
  38. data/lib/rcrewai/llm_clients/azure.rb +23 -128
  39. data/lib/rcrewai/llm_clients/base.rb +11 -7
  40. data/lib/rcrewai/llm_clients/google.rb +159 -95
  41. data/lib/rcrewai/llm_clients/ollama.rb +150 -106
  42. data/lib/rcrewai/llm_clients/openai.rb +140 -63
  43. data/lib/rcrewai/mcp/client.rb +101 -0
  44. data/lib/rcrewai/mcp/tool_adapter.rb +59 -0
  45. data/lib/rcrewai/mcp/transport/http.rb +53 -0
  46. data/lib/rcrewai/mcp/transport/stdio.rb +55 -0
  47. data/lib/rcrewai/mcp.rb +8 -0
  48. data/lib/rcrewai/memory.rb +45 -37
  49. data/lib/rcrewai/output_schema.rb +79 -0
  50. data/lib/rcrewai/planning.rb +65 -0
  51. data/lib/rcrewai/pricing.rb +34 -0
  52. data/lib/rcrewai/process.rb +86 -95
  53. data/lib/rcrewai/provider_schema.rb +38 -0
  54. data/lib/rcrewai/sse_parser.rb +55 -0
  55. data/lib/rcrewai/task.rb +145 -66
  56. data/lib/rcrewai/tool_runner.rb +132 -0
  57. data/lib/rcrewai/tool_schema.rb +97 -0
  58. data/lib/rcrewai/tools/base.rb +98 -37
  59. data/lib/rcrewai/tools/code_executor.rb +71 -74
  60. data/lib/rcrewai/tools/email_sender.rb +70 -78
  61. data/lib/rcrewai/tools/file_reader.rb +38 -30
  62. data/lib/rcrewai/tools/file_writer.rb +40 -38
  63. data/lib/rcrewai/tools/pdf_processor.rb +115 -130
  64. data/lib/rcrewai/tools/sql_database.rb +58 -55
  65. data/lib/rcrewai/tools/web_search.rb +26 -25
  66. data/lib/rcrewai/version.rb +2 -2
  67. data/lib/rcrewai.rb +20 -10
  68. data/rcrewai.gemspec +39 -39
  69. metadata +77 -47
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'transport/stdio'
5
+ require_relative 'transport/http'
6
+ require_relative 'tool_adapter'
7
+
8
+ module RCrewAI
9
+ module MCP
10
+ class Error < RCrewAI::Error; end
11
+
12
+ # Minimal MCP (Model Context Protocol) JSON-RPC client. Connects to a
13
+ # server via stdio or streamable HTTP, performs the initialize/initialized
14
+ # handshake, lists tools, and exposes them as RCrewAI::Tools::Base instances.
15
+ class Client
16
+ PROTOCOL_VERSION = '2024-11-05'
17
+
18
+ attr_reader :server_name, :tools
19
+
20
+ def self.connect(**opts)
21
+ new(**opts).tap(&:open)
22
+ end
23
+
24
+ def self.with_connection(**opts)
25
+ c = connect(**opts)
26
+ begin
27
+ yield c
28
+ ensure
29
+ c&.close
30
+ end
31
+ end
32
+
33
+ def initialize(command: nil, args: [], env: {}, url: nil, headers: {})
34
+ @transport = if url
35
+ Transport::Http.new(url: url, headers: headers)
36
+ else
37
+ Transport::Stdio.new(command: command, args: args, env: env)
38
+ end
39
+ @request_id = 0
40
+ @tools = []
41
+ @server_name = nil
42
+ end
43
+
44
+ def open
45
+ @transport.open
46
+ handshake
47
+ load_tools
48
+ end
49
+
50
+ def close
51
+ @transport.close
52
+ end
53
+
54
+ def call_tool(name, args)
55
+ result = request('tools/call',
56
+ name: strip_prefix(name),
57
+ arguments: args)
58
+ text = result.dig('content', 0, 'text')
59
+ text || result['content']
60
+ end
61
+
62
+ private
63
+
64
+ def handshake
65
+ info = request('initialize',
66
+ protocolVersion: PROTOCOL_VERSION,
67
+ capabilities: { tools: {} },
68
+ clientInfo: { name: 'rcrewai', version: RCrewAI::VERSION })
69
+ @server_name = info.dig('serverInfo', 'name') || 'mcp'
70
+ notify('notifications/initialized', {})
71
+ end
72
+
73
+ def load_tools
74
+ result = request('tools/list', {})
75
+ @tools = Array(result['tools']).map { |t| ToolAdapter.new(self, t, @server_name) }
76
+ end
77
+
78
+ def request(method, params)
79
+ @request_id += 1
80
+ msg = { jsonrpc: '2.0', id: @request_id, method: method, params: params }
81
+ @transport.send_line(msg.to_json)
82
+ line = @transport.recv_line
83
+ raise Error, 'connection closed before response' if line.nil?
84
+
85
+ reply = JSON.parse(line)
86
+ raise Error, reply['error']['message'] if reply['error']
87
+
88
+ reply['result']
89
+ end
90
+
91
+ def notify(method, params)
92
+ msg = { jsonrpc: '2.0', method: method, params: params }
93
+ @transport.send_line(msg.to_json)
94
+ end
95
+
96
+ def strip_prefix(prefixed_name)
97
+ prefixed_name.sub(/^#{Regexp.escape(@server_name)}__/, '')
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../tools/base'
4
+
5
+ module RCrewAI
6
+ module MCP
7
+ # Wraps a tool advertised by an MCP server as an RCrewAI::Tools::Base.
8
+ # Names are prefixed with the server name to avoid collisions when an
9
+ # agent has tools from multiple MCP servers.
10
+ class ToolAdapter < RCrewAI::Tools::Base
11
+ def initialize(client, mcp_tool_descriptor, server_name)
12
+ super()
13
+ @client = client
14
+ @descriptor = mcp_tool_descriptor
15
+ @server_name = server_name
16
+ @adapter_name = "#{server_name}__#{mcp_tool_descriptor['name']}"
17
+ @adapter_description = mcp_tool_descriptor['description'].to_s
18
+ end
19
+
20
+ def name
21
+ @adapter_name
22
+ end
23
+
24
+ def description
25
+ @adapter_description
26
+ end
27
+
28
+ def json_schema
29
+ {
30
+ name: @adapter_name,
31
+ description: @adapter_description,
32
+ parameters: stringify_keys(@descriptor['inputSchema'] ||
33
+ { 'type' => 'object', 'additionalProperties' => true })
34
+ }
35
+ end
36
+
37
+ def execute(**args)
38
+ @client.call_tool(@adapter_name, args)
39
+ end
40
+
41
+ def execute_with_validation(args_hash)
42
+ execute(**args_hash.transform_keys(&:to_sym))
43
+ end
44
+
45
+ private
46
+
47
+ def stringify_keys(value)
48
+ case value
49
+ when Hash
50
+ value.each_with_object({}) { |(k, v), out| out[k.to_s] = stringify_keys(v) }
51
+ when Array
52
+ value.map { |v| stringify_keys(v) }
53
+ else
54
+ value
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require_relative '../../sse_parser'
5
+
6
+ module RCrewAI
7
+ module MCP
8
+ module Transport
9
+ # Streamable HTTP transport for MCP: server pushes JSON-RPC responses
10
+ # via a long-lived SSE stream (GET); client sends requests via POST.
11
+ class Http
12
+ def initialize(url:, headers: {})
13
+ @url = url
14
+ @headers = headers
15
+ @queue = Queue.new
16
+ @sse_thread = nil
17
+ end
18
+
19
+ def open
20
+ @http = Faraday.new(url: @url) { |f| f.adapter Faraday.default_adapter }
21
+ @sse_thread = Thread.new { start_sse_stream }
22
+ end
23
+
24
+ def send_line(json)
25
+ @http.post('') do |req|
26
+ req.headers.merge!(@headers).merge!('Content-Type' => 'application/json')
27
+ req.body = json
28
+ end
29
+ end
30
+
31
+ def recv_line
32
+ @queue.pop
33
+ end
34
+
35
+ def close
36
+ @sse_thread&.kill
37
+ end
38
+
39
+ private
40
+
41
+ def start_sse_stream
42
+ parser = SSEParser.new do |evt|
43
+ @queue << "#{evt[:data]}\n" if evt[:event] == 'message' || evt[:event].nil?
44
+ end
45
+ @http.get('') do |req|
46
+ req.headers.merge!(@headers).merge!('Accept' => 'text/event-stream')
47
+ req.options.on_data = proc { |chunk, _| parser.feed(chunk) }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module RCrewAI
7
+ module MCP
8
+ module Transport
9
+ class Stdio
10
+ def initialize(command:, args: [], env: {})
11
+ @command = command
12
+ @args = args
13
+ @env = env
14
+ @stdin = nil
15
+ @stdout = nil
16
+ @stderr_thread = nil
17
+ @wait_thr = nil
18
+ end
19
+
20
+ def open
21
+ @stdin, @stdout, stderr, @wait_thr = Open3.popen3(@env, @command, *@args)
22
+ @stderr_thread = Thread.new do
23
+ stderr.each_line { |l| Kernel.warn "[mcp-stderr] #{l}" }
24
+ rescue IOError
25
+ # stream closed
26
+ end
27
+ end
28
+
29
+ def send_line(json)
30
+ @stdin.write("#{json}\n")
31
+ @stdin.flush
32
+ end
33
+
34
+ def recv_line
35
+ @stdout.gets
36
+ end
37
+
38
+ def close
39
+ return unless @wait_thr&.alive?
40
+
41
+ begin
42
+ ::Process.kill('TERM', @wait_thr.pid)
43
+ rescue Errno::ESRCH, Errno::EPERM
44
+ # already dead
45
+ end
46
+ @stdin&.close
47
+ @stdout&.close
48
+ @stderr_thread&.kill
49
+ rescue IOError
50
+ # streams already closed
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'mcp/client'
4
+
5
+ module RCrewAI
6
+ module MCP
7
+ end
8
+ end
@@ -32,16 +32,16 @@ module RCrewAI
32
32
  @short_term = @short_term.first(@max_short_term)
33
33
 
34
34
  # Add to long-term memory if successful
35
- if execution_data[:success]
36
- task_type = execution_data[:task_type]
37
- @long_term[task_type] ||= []
38
- @long_term[task_type] << execution_data
39
-
40
- # Keep only best executions for each type
41
- @long_term[task_type] = @long_term[task_type]
42
- .sort_by { |e| [e[:success] ? 0 : 1, -e[:execution_time]] }
43
- .first(10)
44
- end
35
+ return unless execution_data[:success]
36
+
37
+ task_type = execution_data[:task_type]
38
+ @long_term[task_type] ||= []
39
+ @long_term[task_type] << execution_data
40
+
41
+ # Keep only best executions for each type
42
+ @long_term[task_type] = @long_term[task_type]
43
+ .sort_by { |e| [e[:success] ? 0 : 1, -e[:execution_time]] }
44
+ .first(10)
45
45
  end
46
46
 
47
47
  def add_tool_usage(tool_name, params, result)
@@ -54,7 +54,7 @@ module RCrewAI
54
54
  }
55
55
 
56
56
  @tool_usage.unshift(usage_data)
57
- @tool_usage = @tool_usage.first(50) # Keep last 50 tool usages
57
+ @tool_usage = @tool_usage.first(50) # Keep last 50 tool usages
58
58
  end
59
59
 
60
60
  def relevant_executions(task, limit = 3)
@@ -75,18 +75,16 @@ module RCrewAI
75
75
  end
76
76
 
77
77
  # Check long-term memory
78
- if @long_term[task_type]
79
- @long_term[task_type].each do |execution|
80
- similarity = calculate_similarity(task, execution)
81
- candidates << { execution: execution, similarity: similarity } if similarity > @similarity_threshold
82
- end
78
+ @long_term[task_type]&.each do |execution|
79
+ similarity = calculate_similarity(task, execution)
80
+ candidates << { execution: execution, similarity: similarity } if similarity > @similarity_threshold
83
81
  end
84
82
 
85
83
  # Sort by similarity and success, return top results
86
84
  relevant = candidates
87
- .sort_by { |c| [-c[:similarity], c[:execution][:success] ? 0 : 1] }
88
- .first(limit)
89
- .map { |c| format_execution_for_context(c[:execution]) }
85
+ .sort_by { |c| [-c[:similarity], c[:execution][:success] ? 0 : 1] }
86
+ .first(limit)
87
+ .map { |c| format_execution_for_context(c[:execution]) }
90
88
 
91
89
  relevant.empty? ? nil : relevant.join("\n---\n")
92
90
  end
@@ -123,13 +121,23 @@ module RCrewAI
123
121
 
124
122
  def classify_task_type(task)
125
123
  description = task.description.downcase
126
-
127
- return :research if description.include?('research') || description.include?('find') || description.include?('search')
128
- return :analysis if description.include?('analyze') || description.include?('examine') || description.include?('study')
129
- return :writing if description.include?('write') || description.include?('create') || description.include?('compose')
130
- return :coding if description.include?('code') || description.include?('program') || description.include?('develop')
131
- return :planning if description.include?('plan') || description.include?('strategy') || description.include?('organize')
132
-
124
+
125
+ if description.include?('research') || description.include?('find') || description.include?('search')
126
+ return :research
127
+ end
128
+ if description.include?('analyze') || description.include?('examine') || description.include?('study')
129
+ return :analysis
130
+ end
131
+ if description.include?('write') || description.include?('create') || description.include?('compose')
132
+ return :writing
133
+ end
134
+ if description.include?('code') || description.include?('program') || description.include?('develop')
135
+ return :coding
136
+ end
137
+ if description.include?('plan') || description.include?('strategy') || description.include?('organize')
138
+ return :planning
139
+ end
140
+
133
141
  :general
134
142
  end
135
143
 
@@ -142,17 +150,17 @@ module RCrewAI
142
150
  # Simple similarity based on common words and task type
143
151
  task_words = extract_keywords(task.description)
144
152
  execution_words = extract_keywords(execution[:task_description])
145
-
153
+
146
154
  common_words = (task_words & execution_words).length
147
155
  total_words = (task_words | execution_words).length
148
-
149
- return 0.0 if total_words == 0
150
-
156
+
157
+ return 0.0 if total_words.zero?
158
+
151
159
  word_similarity = common_words.to_f / total_words
152
-
160
+
153
161
  # Boost similarity if task types match
154
- type_bonus = (classify_task_type(task) == execution[:task_type]) ? 0.2 : 0.0
155
-
162
+ type_bonus = classify_task_type(task) == execution[:task_type] ? 0.2 : 0.0
163
+
156
164
  [word_similarity + type_bonus, 1.0].min
157
165
  end
158
166
 
@@ -163,7 +171,7 @@ module RCrewAI
163
171
  end
164
172
 
165
173
  def format_execution_for_context(execution)
166
- success_indicator = execution[:success] ? "" : ""
174
+ success_indicator = execution[:success] ? '' : ''
167
175
  <<~CONTEXT
168
176
  #{success_indicator} Task: #{execution[:task_name]}
169
177
  Description: #{execution[:task_description]}
@@ -174,7 +182,7 @@ module RCrewAI
174
182
  end
175
183
 
176
184
  def format_tool_usage_for_context(usage)
177
- success_indicator = usage[:success] ? "" : ""
185
+ success_indicator = usage[:success] ? '' : ''
178
186
  params_str = usage[:params].map { |k, v| "#{k}=#{v}" }.join(', ')
179
187
  <<~CONTEXT
180
188
  #{success_indicator} Tool: #{usage[:tool_name]}
@@ -186,9 +194,9 @@ module RCrewAI
186
194
 
187
195
  def calculate_success_rate
188
196
  return 0.0 if @short_term.empty?
189
-
197
+
190
198
  successful = @short_term.count { |e| e[:success] }
191
199
  (successful.to_f / @short_term.length * 100).round(1)
192
200
  end
193
201
  end
194
- end
202
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RCrewAI
6
+ # Validates and coerces a task's raw string output against a JSON-Schema
7
+ # subset (object / type / required / property types). Used by Task for the
8
+ # `output_schema:` option. Kept intentionally small: it covers the shapes an
9
+ # LLM is realistically asked to emit, not the whole JSON Schema spec.
10
+ module OutputSchema
11
+ module_function
12
+
13
+ # Returns the validated/coerced object.
14
+ # Raises OutputSchemaError if the string can't be parsed or doesn't conform.
15
+ def coerce(raw, schema)
16
+ data = parse(raw)
17
+ validate!(data, schema)
18
+ data
19
+ end
20
+
21
+ # Extracts a JSON document from a string that may contain surrounding prose,
22
+ # then parses it. Prefers a fenced ```json block, then the first balanced
23
+ # object/array, then the whole string.
24
+ def parse(raw)
25
+ candidate = extract_json(raw.to_s)
26
+ JSON.parse(candidate)
27
+ rescue JSON::ParserError => e
28
+ raise OutputSchemaError, "output is not valid JSON: #{e.message}"
29
+ end
30
+
31
+ def extract_json(text)
32
+ if (fenced = text[/```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```/m, 1])
33
+ return fenced
34
+ end
35
+
36
+ first = text.index(/[{\[]/)
37
+ last = text.rindex(/[}\]]/)
38
+ return text if first.nil? || last.nil? || last < first
39
+
40
+ text[first..last]
41
+ end
42
+
43
+ def validate!(data, schema)
44
+ type = (schema[:type] || schema['type'])&.to_s
45
+ case type
46
+ when 'object' then validate_object!(data, schema)
47
+ when 'array' then raise_unless(data.is_a?(Array), 'expected an array')
48
+ when 'string' then raise_unless(data.is_a?(String), 'expected a string')
49
+ when 'integer' then raise_unless(data.is_a?(Integer), 'expected an integer')
50
+ when 'number' then raise_unless(data.is_a?(Numeric), 'expected a number')
51
+ when 'boolean' then raise_unless([true, false].include?(data), 'expected a boolean')
52
+ end
53
+ data
54
+ end
55
+
56
+ def validate_object!(data, schema)
57
+ raise_unless(data.is_a?(Hash), 'expected a JSON object')
58
+
59
+ required = schema[:required] || schema['required'] || []
60
+ required.each do |key|
61
+ raise_unless(data.key?(key.to_s), "missing required property '#{key}'")
62
+ end
63
+
64
+ props = schema[:properties] || schema['properties'] || {}
65
+ props.each do |name, subschema|
66
+ value = data[name.to_s]
67
+ next if value.nil?
68
+
69
+ validate!(value, subschema)
70
+ end
71
+ end
72
+
73
+ def raise_unless(condition, message)
74
+ raise OutputSchemaError, message unless condition
75
+ end
76
+ end
77
+
78
+ class OutputSchemaError < Error; end
79
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'output_schema'
5
+
6
+ module RCrewAI
7
+ # Runs a single planning pass over a crew's tasks before execution. Asks an
8
+ # LLM to draft a short, concrete plan for each task and folds that plan into
9
+ # the task's description, so the executing agent starts with a game plan.
10
+ #
11
+ # Mirrors CrewAI's `planning=True`. Best-effort: if the planner errors or
12
+ # returns unparseable output, execution proceeds with the original tasks.
13
+ class Planning
14
+ def initialize(crew, llm: nil, logger: nil)
15
+ @crew = crew
16
+ @llm = llm || LLMClient.for_provider
17
+ @logger = logger
18
+ end
19
+
20
+ def plan!
21
+ return if @crew.tasks.empty?
22
+
23
+ plans = request_plans
24
+ return if plans.nil? || plans.empty?
25
+
26
+ @crew.tasks.each do |task|
27
+ step = plans[task.name] || plans[task.name.to_s]
28
+ task.enrich_description("Plan: #{step}") if step
29
+ end
30
+ rescue StandardError => e
31
+ @logger&.warn("Planning pass failed, continuing without a plan: #{e.message}")
32
+ nil
33
+ end
34
+
35
+ private
36
+
37
+ def request_plans
38
+ response = @llm.chat(messages: [
39
+ { role: 'system', content: system_prompt },
40
+ { role: 'user', content: user_prompt }
41
+ ])
42
+ content = response.is_a?(Hash) ? response[:content].to_s : response.to_s
43
+ parse_plans(content)
44
+ end
45
+
46
+ def parse_plans(content)
47
+ OutputSchema.parse(content)
48
+ rescue OutputSchemaError
49
+ nil
50
+ end
51
+
52
+ def system_prompt
53
+ 'You are a planning assistant. Given a list of tasks, produce a short, ' \
54
+ 'concrete plan for each. Respond ONLY with a JSON object mapping each ' \
55
+ 'task name to a one-sentence plan string.'
56
+ end
57
+
58
+ def user_prompt
59
+ lines = @crew.tasks.map do |t|
60
+ "- #{t.name}: #{t.description} (expected: #{t.expected_output || 'n/a'})"
61
+ end
62
+ "Tasks:\n#{lines.join("\n")}\n\nReturn JSON: { \"<task name>\": \"<plan>\", ... }"
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCrewAI
4
+ module Pricing
5
+ # Prices in USD per 1M tokens. List prices as of 2026-05; users can override.
6
+ DEFAULT_PRICES = {
7
+ # OpenAI
8
+ 'gpt-4o' => { input: 2.50, output: 10.00 },
9
+ 'gpt-4o-mini' => { input: 0.15, output: 0.60 },
10
+ 'gpt-4-turbo' => { input: 10.00, output: 30.00 },
11
+ 'gpt-4' => { input: 30.00, output: 60.00 },
12
+ 'gpt-3.5-turbo' => { input: 0.50, output: 1.50 },
13
+ # Anthropic
14
+ 'claude-opus-4-7' => { input: 15.00, output: 75.00 },
15
+ 'claude-sonnet-4-6' => { input: 3.00, output: 15.00 },
16
+ 'claude-haiku-4-5' => { input: 0.80, output: 4.00 },
17
+ 'claude-3-5-sonnet-20241022' => { input: 3.00, output: 15.00 },
18
+ 'claude-3-haiku-20240307' => { input: 0.25, output: 1.25 },
19
+ # Google
20
+ 'gemini-1.5-pro' => { input: 1.25, output: 5.00 },
21
+ 'gemini-1.5-flash' => { input: 0.075, output: 0.30 }
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ def cost_for(model, prompt_tokens:, completion_tokens:)
27
+ table = RCrewAI.configuration.pricing || {}
28
+ entry = table[model] || DEFAULT_PRICES[model]
29
+ return nil unless entry
30
+
31
+ ((prompt_tokens * entry[:input]) + (completion_tokens * entry[:output])) / 1_000_000.0
32
+ end
33
+ end
34
+ end