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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCrewAI
|
|
4
|
+
module Knowledge
|
|
5
|
+
# In-memory vector store with cosine-similarity search. The default backing
|
|
6
|
+
# store for Knowledge — no external service required. The interface
|
|
7
|
+
# (#add, #search) is intentionally small so a Chroma/Qdrant-backed store can
|
|
8
|
+
# be swapped in later.
|
|
9
|
+
class Store
|
|
10
|
+
Entry = Struct.new(:text, :vector)
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@entries = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add(text, vector)
|
|
17
|
+
@entries << Entry.new(text, vector)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Returns the texts of the top-k entries most similar to +query_vector+.
|
|
21
|
+
def search(query_vector, k: 3)
|
|
22
|
+
return [] if @entries.empty?
|
|
23
|
+
|
|
24
|
+
@entries
|
|
25
|
+
.map { |e| [e.text, cosine_similarity(query_vector, e.vector)] }
|
|
26
|
+
.sort_by { |(_text, score)| -score }
|
|
27
|
+
.first(k)
|
|
28
|
+
.map(&:first)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size
|
|
32
|
+
@entries.length
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def empty?
|
|
36
|
+
@entries.empty?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def cosine_similarity(a, b)
|
|
42
|
+
dot = 0.0
|
|
43
|
+
norm_a = 0.0
|
|
44
|
+
norm_b = 0.0
|
|
45
|
+
a.each_index do |i|
|
|
46
|
+
ai = a[i].to_f
|
|
47
|
+
bi = (b[i] || 0).to_f
|
|
48
|
+
dot += ai * bi
|
|
49
|
+
norm_a += ai * ai
|
|
50
|
+
norm_b += bi * bi
|
|
51
|
+
end
|
|
52
|
+
return 0.0 if norm_a.zero? || norm_b.zero?
|
|
53
|
+
|
|
54
|
+
dot / (Math.sqrt(norm_a) * Math.sqrt(norm_b))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'knowledge/chunker'
|
|
4
|
+
require_relative 'knowledge/store'
|
|
5
|
+
require_relative 'knowledge/sources'
|
|
6
|
+
require_relative 'knowledge/embedder'
|
|
7
|
+
require_relative 'knowledge/base'
|
|
8
|
+
|
|
9
|
+
module RCrewAI
|
|
10
|
+
# Retrieval-augmented knowledge for agents and crews. See Knowledge::Base.
|
|
11
|
+
module Knowledge
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'events'
|
|
4
|
+
|
|
5
|
+
module RCrewAI
|
|
6
|
+
# Behavior-preserving extraction of the prompt-parsed `USE_TOOL[]` /
|
|
7
|
+
# `FINAL_ANSWER[]` loop that lived in Agent. Used as a fallback when an
|
|
8
|
+
# agent's tools have no DSL schemas declared OR the configured LLM does
|
|
9
|
+
# not support native function calling.
|
|
10
|
+
class LegacyReactRunner
|
|
11
|
+
DEFAULT_MAX_ITERATIONS = 10
|
|
12
|
+
|
|
13
|
+
def initialize(agent:, llm:, tools:, max_iterations: DEFAULT_MAX_ITERATIONS, event_sink: nil)
|
|
14
|
+
@agent = agent
|
|
15
|
+
@llm = llm
|
|
16
|
+
@tools = tools
|
|
17
|
+
@max_iterations = max_iterations
|
|
18
|
+
@sink = event_sink || ->(_) {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(messages:)
|
|
22
|
+
msgs = messages.dup
|
|
23
|
+
history = []
|
|
24
|
+
iter = 0
|
|
25
|
+
total_usage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }
|
|
26
|
+
last_reasoning = nil
|
|
27
|
+
last_action_result = nil
|
|
28
|
+
|
|
29
|
+
while iter < @max_iterations
|
|
30
|
+
iter += 1
|
|
31
|
+
emit(Events::IterationStart, iteration: iter, iteration_index: iter)
|
|
32
|
+
|
|
33
|
+
response = @llm.chat(messages: msgs)
|
|
34
|
+
accumulate_usage(total_usage, response[:usage])
|
|
35
|
+
reasoning = response[:content] || ''
|
|
36
|
+
last_reasoning = reasoning
|
|
37
|
+
|
|
38
|
+
action_result, iteration_history = parse_and_execute_actions(reasoning, iter)
|
|
39
|
+
history.concat(iteration_history)
|
|
40
|
+
last_action_result = action_result
|
|
41
|
+
|
|
42
|
+
msgs << { role: 'assistant', content: reasoning }
|
|
43
|
+
msgs << { role: 'user', content: action_result } if action_result && !action_result.empty?
|
|
44
|
+
|
|
45
|
+
finish_reason = response[:finish_reason]
|
|
46
|
+
emit(Events::IterationEnd, iteration: iter, finish_reason: finish_reason)
|
|
47
|
+
|
|
48
|
+
next unless task_complete?(reasoning, action_result) || finish_reason == :stop
|
|
49
|
+
|
|
50
|
+
final = extract_final_result(reasoning, action_result)
|
|
51
|
+
return finalize(content: final, history: history, iter: iter,
|
|
52
|
+
finish_reason: finish_reason || :stop, usage: total_usage)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
final = extract_final_result(last_reasoning || '', last_action_result) ||
|
|
56
|
+
'Task execution reached limits without clear completion'
|
|
57
|
+
finalize(content: final, history: history, iter: iter,
|
|
58
|
+
finish_reason: :max_iterations, usage: total_usage)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_and_execute_actions(reasoning, iter)
|
|
64
|
+
results = []
|
|
65
|
+
iteration_history = []
|
|
66
|
+
reasoning.scan(/USE_TOOL\[(\w+)\]\(([^)]*)\)/).each do |tool_name, params_str|
|
|
67
|
+
params = parse_tool_params(params_str)
|
|
68
|
+
tool = find_tool(tool_name)
|
|
69
|
+
|
|
70
|
+
emit(Events::ToolCallStart, iteration: iter, tool: tool_name,
|
|
71
|
+
args: params, call_id: nil)
|
|
72
|
+
|
|
73
|
+
if tool.nil?
|
|
74
|
+
err = "tool not found: #{tool_name}"
|
|
75
|
+
emit(Events::ToolCallError, iteration: iter, tool: tool_name, call_id: nil, error: err)
|
|
76
|
+
results << "Tool #{tool_name} failed: #{err}"
|
|
77
|
+
next
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
started = monotonic_ms
|
|
81
|
+
begin
|
|
82
|
+
result = tool.execute(**params)
|
|
83
|
+
duration = monotonic_ms - started
|
|
84
|
+
@agent.memory.add_tool_usage(tool_name, params, result) if @agent.respond_to?(:memory) && @agent.memory
|
|
85
|
+
emit(Events::ToolCallResult, iteration: iter, tool: tool_name,
|
|
86
|
+
call_id: nil, result: result, duration_ms: duration)
|
|
87
|
+
iteration_history << { tool: tool_name, args: params, result: result, duration_ms: duration }
|
|
88
|
+
results << "Tool #{tool_name} result: #{result}"
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
emit(Events::ToolCallError, iteration: iter, tool: tool_name,
|
|
91
|
+
call_id: nil, error: e.message)
|
|
92
|
+
results << "Tool #{tool_name} failed: #{e.message}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
[results.join("\n"), iteration_history]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_tool_params(params_str)
|
|
100
|
+
params = {}
|
|
101
|
+
return params if params_str.strip.empty?
|
|
102
|
+
|
|
103
|
+
params_str.split(',').map(&:strip).each do |pair|
|
|
104
|
+
key, value = pair.split('=', 2).map(&:strip)
|
|
105
|
+
next unless key && value
|
|
106
|
+
|
|
107
|
+
value = value.gsub(/^["']|["']$/, '')
|
|
108
|
+
params[key.to_sym] = value
|
|
109
|
+
end
|
|
110
|
+
params
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def find_tool(name)
|
|
114
|
+
@tools.find do |t|
|
|
115
|
+
t.name == name || t.class.name.split('::').last.downcase == name.downcase
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def task_complete?(reasoning, _action_result)
|
|
120
|
+
reasoning.include?('FINAL_ANSWER[') ||
|
|
121
|
+
reasoning.downcase.include?('task complete') ||
|
|
122
|
+
reasoning.downcase.include?('finished')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_final_result(reasoning, action_result)
|
|
126
|
+
if (match = reasoning.match(/FINAL_ANSWER\[(.*?)\]$/m))
|
|
127
|
+
return match[1].strip
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
lines = reasoning.split("\n").map(&:strip).reject(&:empty?)
|
|
131
|
+
final_lines = lines.last(3).join(' ')
|
|
132
|
+
return final_lines if final_lines.length > 20
|
|
133
|
+
|
|
134
|
+
action_result
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def emit(klass, iteration:, **attrs)
|
|
138
|
+
type_sym = klass.name.split('::').last
|
|
139
|
+
.gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
|
|
140
|
+
.sub(/^_/, '').to_sym
|
|
141
|
+
@sink.call(klass.new(
|
|
142
|
+
type: type_sym,
|
|
143
|
+
timestamp: Time.now,
|
|
144
|
+
agent: @agent.respond_to?(:name) ? @agent.name : nil,
|
|
145
|
+
iteration: iteration,
|
|
146
|
+
**attrs
|
|
147
|
+
))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def accumulate_usage(total, partial)
|
|
151
|
+
return unless partial.is_a?(Hash)
|
|
152
|
+
|
|
153
|
+
total[:prompt_tokens] += partial[:prompt_tokens] || 0
|
|
154
|
+
total[:completion_tokens] += partial[:completion_tokens] || 0
|
|
155
|
+
total[:total_tokens] += partial[:total_tokens] || 0
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def finalize(content:, history:, iter:, finish_reason:, usage:)
|
|
159
|
+
{
|
|
160
|
+
content: content,
|
|
161
|
+
tool_calls_history: history,
|
|
162
|
+
usage: usage,
|
|
163
|
+
iterations: iter,
|
|
164
|
+
finish_reason: finish_reason
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def monotonic_ms
|
|
169
|
+
(::Process.clock_gettime(::Process::CLOCK_MONOTONIC) * 1000).to_i
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
data/lib/rcrewai/llm_client.rb
CHANGED
|
@@ -28,6 +28,29 @@ module RCrewAI
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Resolves a per-agent / per-pass LLM spec into a client.
|
|
32
|
+
# nil -> global provider
|
|
33
|
+
# Symbol/String -> that provider, global model
|
|
34
|
+
# Hash -> { provider:, model:, api_key:, temperature: } overrides
|
|
35
|
+
# client object -> returned as-is (anything responding to #chat)
|
|
36
|
+
def self.resolve(spec, config = RCrewAI.configuration)
|
|
37
|
+
case spec
|
|
38
|
+
when nil
|
|
39
|
+
for_provider(nil, config)
|
|
40
|
+
when Symbol, String
|
|
41
|
+
overridden = config.with_overrides(provider: spec)
|
|
42
|
+
for_provider(overridden.llm_provider, overridden)
|
|
43
|
+
when Hash
|
|
44
|
+
overridden = config.with_overrides(**spec)
|
|
45
|
+
for_provider(overridden.llm_provider, overridden)
|
|
46
|
+
else
|
|
47
|
+
return spec if spec.respond_to?(:chat)
|
|
48
|
+
|
|
49
|
+
raise ConfigurationError,
|
|
50
|
+
"Invalid llm: expected a provider symbol, an options hash, or a client responding to #chat, got #{spec.class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
31
54
|
def self.chat(messages:, **options)
|
|
32
55
|
client = for_provider
|
|
33
56
|
client.chat(messages: messages, **options)
|
|
@@ -38,4 +61,4 @@ module RCrewAI
|
|
|
38
61
|
client.complete(prompt: prompt, **options)
|
|
39
62
|
end
|
|
40
63
|
end
|
|
41
|
-
end
|
|
64
|
+
end
|
|
@@ -1,6 +1,12 @@
|
|
|
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
|
|
@@ -8,101 +14,215 @@ module RCrewAI
|
|
|
8
14
|
BASE_URL = 'https://api.anthropic.com/v1'
|
|
9
15
|
API_VERSION = '2023-06-01'
|
|
10
16
|
|
|
17
|
+
STOP_REASON_MAP = {
|
|
18
|
+
'tool_use' => :tool_calls,
|
|
19
|
+
'end_turn' => :stop,
|
|
20
|
+
'stop_sequence' => :stop,
|
|
21
|
+
'max_tokens' => :length
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
11
24
|
def initialize(config = RCrewAI.configuration)
|
|
12
25
|
super
|
|
13
26
|
@base_url = BASE_URL
|
|
14
27
|
end
|
|
15
28
|
|
|
16
|
-
def chat(messages:, **options)
|
|
17
|
-
# Convert messages to Anthropic format
|
|
29
|
+
def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
|
|
18
30
|
system_message = extract_system_message(messages)
|
|
19
|
-
|
|
31
|
+
non_system = messages.reject { |m| m.is_a?(Hash) && m[:role] == 'system' }
|
|
20
32
|
|
|
21
33
|
payload = {
|
|
22
34
|
model: config.model,
|
|
23
|
-
messages:
|
|
35
|
+
messages: format_messages(non_system),
|
|
24
36
|
max_tokens: options[:max_tokens] || config.max_tokens || 1000,
|
|
25
37
|
temperature: options[:temperature] || config.temperature
|
|
26
|
-
}
|
|
38
|
+
}.compact
|
|
39
|
+
|
|
40
|
+
if system_message
|
|
41
|
+
payload[:system] = if options[:cache_system]
|
|
42
|
+
[{ type: 'text', text: system_message,
|
|
43
|
+
cache_control: { type: 'ephemeral' } }]
|
|
44
|
+
else
|
|
45
|
+
system_message
|
|
46
|
+
end
|
|
47
|
+
end
|
|
27
48
|
|
|
28
|
-
|
|
49
|
+
if tools && !tools.empty?
|
|
50
|
+
payload[:tools] = ProviderSchema.for_many(:anthropic, tools)
|
|
51
|
+
payload[:tool_choice] = { type: tool_choice.to_s } if tool_choice != :auto && tool_choice.is_a?(Symbol)
|
|
52
|
+
end
|
|
29
53
|
|
|
30
|
-
# Add Anthropic-specific options
|
|
31
54
|
payload[:top_p] = options[:top_p] if options[:top_p]
|
|
32
55
|
payload[:top_k] = options[:top_k] if options[:top_k]
|
|
33
56
|
payload[:stop_sequences] = options[:stop_sequences] if options[:stop_sequences]
|
|
34
57
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
58
|
+
if stream
|
|
59
|
+
payload[:stream] = true
|
|
60
|
+
stream_chat(payload, stream)
|
|
61
|
+
else
|
|
62
|
+
plain_chat(payload)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
40
65
|
|
|
41
|
-
|
|
42
|
-
|
|
66
|
+
def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
|
|
67
|
+
true
|
|
43
68
|
end
|
|
44
69
|
|
|
45
70
|
def models
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
'claude-3-sonnet-20240229',
|
|
50
|
-
'claude-3-haiku-20240307',
|
|
51
|
-
'claude-2.1',
|
|
52
|
-
'claude-2.0',
|
|
53
|
-
'claude-instant-1.2'
|
|
71
|
+
%w[
|
|
72
|
+
claude-opus-4-7 claude-sonnet-4-6 claude-haiku-4-5
|
|
73
|
+
claude-3-5-sonnet-20241022 claude-3-haiku-20240307
|
|
54
74
|
]
|
|
55
75
|
end
|
|
56
76
|
|
|
57
77
|
private
|
|
58
78
|
|
|
59
|
-
def
|
|
79
|
+
def plain_chat(payload)
|
|
80
|
+
url = "#{@base_url}/messages"
|
|
81
|
+
log_request(:post, url, payload)
|
|
82
|
+
response = http_client.post(url, payload, build_headers.merge(auth_header))
|
|
83
|
+
log_response(response)
|
|
84
|
+
body = handle_response(response)
|
|
85
|
+
normalize_non_streaming(body)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def stream_chat(payload, sink) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
89
|
+
url = "#{@base_url}/messages"
|
|
90
|
+
log_request(:post, url, payload)
|
|
91
|
+
|
|
92
|
+
assembled_text = +''
|
|
93
|
+
# tool_use blocks keyed by content-block index
|
|
94
|
+
blocks = {}
|
|
95
|
+
finish_reason = nil
|
|
96
|
+
prompt_tokens = nil
|
|
97
|
+
completion_tokens = nil
|
|
98
|
+
|
|
99
|
+
parser = SSEParser.new do |sse|
|
|
100
|
+
data = JSON.parse(sse[:data])
|
|
101
|
+
case data['type']
|
|
102
|
+
when 'message_start'
|
|
103
|
+
prompt_tokens = data.dig('message', 'usage', 'input_tokens')
|
|
104
|
+
when 'content_block_start'
|
|
105
|
+
cb = data['content_block'] || {}
|
|
106
|
+
blocks[data['index']] = { id: cb['id'], name: cb['name'], arguments: +'' } if cb['type'] == 'tool_use'
|
|
107
|
+
when 'content_block_delta'
|
|
108
|
+
delta = data['delta'] || {}
|
|
109
|
+
case delta['type']
|
|
110
|
+
when 'text_delta'
|
|
111
|
+
text = delta['text'].to_s
|
|
112
|
+
assembled_text << text
|
|
113
|
+
sink.call(Events::TextDelta.new(type: :text_delta, timestamp: Time.now,
|
|
114
|
+
agent: nil, iteration: nil, text: text))
|
|
115
|
+
when 'input_json_delta'
|
|
116
|
+
block = blocks[data['index']]
|
|
117
|
+
block[:arguments] << delta['partial_json'].to_s if block
|
|
118
|
+
end
|
|
119
|
+
when 'message_delta'
|
|
120
|
+
finish_reason ||= STOP_REASON_MAP[data.dig('delta', 'stop_reason')] ||
|
|
121
|
+
data.dig('delta', 'stop_reason')&.to_sym
|
|
122
|
+
completion_tokens = data.dig('usage', 'output_tokens') || completion_tokens
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
streaming_post(url, payload) { |chunk| parser.feed(chunk) }
|
|
127
|
+
|
|
128
|
+
tool_calls = blocks.values.map do |b|
|
|
129
|
+
{
|
|
130
|
+
id: b[:id],
|
|
131
|
+
name: b[:name],
|
|
132
|
+
arguments: b[:arguments].empty? ? {} : JSON.parse(b[:arguments])
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
usage = {
|
|
137
|
+
prompt_tokens: prompt_tokens,
|
|
138
|
+
completion_tokens: completion_tokens,
|
|
139
|
+
total_tokens: (prompt_tokens || 0) + (completion_tokens || 0)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if prompt_tokens || completion_tokens
|
|
143
|
+
sink.call(Events::Usage.new(
|
|
144
|
+
type: :usage, timestamp: Time.now, agent: nil, iteration: nil,
|
|
145
|
+
prompt_tokens: prompt_tokens, completion_tokens: completion_tokens,
|
|
146
|
+
total_tokens: usage[:total_tokens],
|
|
147
|
+
cost_usd: Pricing.cost_for(config.model,
|
|
148
|
+
prompt_tokens: prompt_tokens || 0,
|
|
149
|
+
completion_tokens: completion_tokens || 0)
|
|
150
|
+
))
|
|
151
|
+
end
|
|
152
|
+
|
|
60
153
|
{
|
|
61
|
-
|
|
154
|
+
content: assembled_text.empty? ? nil : assembled_text,
|
|
155
|
+
tool_calls: tool_calls,
|
|
156
|
+
usage: usage,
|
|
157
|
+
finish_reason: finish_reason || :stop,
|
|
158
|
+
model: config.model,
|
|
159
|
+
provider: :anthropic
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def streaming_post(url, payload, &on_chunk)
|
|
164
|
+
conn = Faraday.new do |f|
|
|
165
|
+
f.request :json
|
|
166
|
+
f.options.timeout = config.timeout
|
|
167
|
+
f.adapter Faraday.default_adapter
|
|
168
|
+
end
|
|
169
|
+
conn.post(url) do |req|
|
|
170
|
+
req.headers = build_headers.merge(auth_header)
|
|
171
|
+
req.body = payload.to_json
|
|
172
|
+
req.options.on_data = proc { |chunk, _| on_chunk.call(chunk) }
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def normalize_non_streaming(body)
|
|
177
|
+
content_blocks = Array(body['content'])
|
|
178
|
+
text = content_blocks.select { |b| b['type'] == 'text' }.map { |b| b['text'] }.join
|
|
179
|
+
tool_calls = content_blocks.select { |b| b['type'] == 'tool_use' }.map do |b|
|
|
180
|
+
{ id: b['id'], name: b['name'], arguments: b['input'] || {} }
|
|
181
|
+
end
|
|
182
|
+
prompt_tokens = body.dig('usage', 'input_tokens')
|
|
183
|
+
completion_tokens = body.dig('usage', 'output_tokens')
|
|
184
|
+
|
|
185
|
+
{
|
|
186
|
+
content: text.empty? ? nil : text,
|
|
187
|
+
tool_calls: tool_calls,
|
|
188
|
+
usage: {
|
|
189
|
+
prompt_tokens: prompt_tokens,
|
|
190
|
+
completion_tokens: completion_tokens,
|
|
191
|
+
total_tokens: (prompt_tokens || 0) + (completion_tokens || 0)
|
|
192
|
+
},
|
|
193
|
+
finish_reason: STOP_REASON_MAP[body['stop_reason']] || body['stop_reason']&.to_sym || :stop,
|
|
194
|
+
model: body['model'] || config.model,
|
|
195
|
+
provider: :anthropic
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def auth_header
|
|
200
|
+
{
|
|
201
|
+
'x-api-key' => config.anthropic_api_key || config.api_key,
|
|
62
202
|
'anthropic-version' => API_VERSION
|
|
63
203
|
}
|
|
64
204
|
end
|
|
65
205
|
|
|
66
206
|
def extract_system_message(messages)
|
|
67
207
|
return nil unless messages.is_a?(Array)
|
|
68
|
-
|
|
69
|
-
|
|
208
|
+
|
|
209
|
+
msg = messages.find { |m| m.is_a?(Hash) && m[:role] == 'system' }
|
|
210
|
+
msg&.dig(:content)
|
|
70
211
|
end
|
|
71
212
|
|
|
72
213
|
def format_messages(messages)
|
|
73
214
|
messages.map do |msg|
|
|
74
215
|
if msg.is_a?(Hash)
|
|
75
|
-
{
|
|
76
|
-
role: msg[:role] == 'assistant' ? 'assistant' : 'user',
|
|
77
|
-
content: msg[:content]
|
|
78
|
-
}
|
|
216
|
+
{ role: msg[:role] == 'assistant' ? 'assistant' : 'user', content: msg[:content] }
|
|
79
217
|
else
|
|
80
218
|
{ role: 'user', content: msg.to_s }
|
|
81
219
|
end
|
|
82
220
|
end
|
|
83
221
|
end
|
|
84
222
|
|
|
85
|
-
def format_response(response)
|
|
86
|
-
content = response.dig('content', 0, 'text') if response['content']&.any?
|
|
87
|
-
|
|
88
|
-
{
|
|
89
|
-
content: content,
|
|
90
|
-
role: 'assistant',
|
|
91
|
-
finish_reason: response['stop_reason'],
|
|
92
|
-
usage: {
|
|
93
|
-
'prompt_tokens' => response.dig('usage', 'input_tokens'),
|
|
94
|
-
'completion_tokens' => response.dig('usage', 'output_tokens'),
|
|
95
|
-
'total_tokens' => (response.dig('usage', 'input_tokens') || 0) +
|
|
96
|
-
(response.dig('usage', 'output_tokens') || 0)
|
|
97
|
-
},
|
|
98
|
-
model: response['model'],
|
|
99
|
-
provider: :anthropic
|
|
100
|
-
}
|
|
101
|
-
end
|
|
102
|
-
|
|
103
223
|
def validate_config!
|
|
104
|
-
raise ConfigurationError,
|
|
105
|
-
raise ConfigurationError,
|
|
224
|
+
raise ConfigurationError, 'Anthropic API key is required' unless config.anthropic_api_key || config.api_key
|
|
225
|
+
raise ConfigurationError, 'Model is required' unless config.model
|
|
106
226
|
end
|
|
107
227
|
|
|
108
228
|
def handle_response(response)
|
|
@@ -110,12 +230,12 @@ module RCrewAI
|
|
|
110
230
|
when 200..299
|
|
111
231
|
response.body
|
|
112
232
|
when 400
|
|
113
|
-
error_details = response.body.dig('error', 'message')
|
|
233
|
+
error_details = response.body.is_a?(Hash) ? response.body.dig('error', 'message') : response.body
|
|
114
234
|
raise APIError, "Bad request: #{error_details}"
|
|
115
235
|
when 401
|
|
116
|
-
raise AuthenticationError,
|
|
236
|
+
raise AuthenticationError, 'Invalid API key'
|
|
117
237
|
when 429
|
|
118
|
-
raise RateLimitError,
|
|
238
|
+
raise RateLimitError, 'Rate limit exceeded'
|
|
119
239
|
when 500..599
|
|
120
240
|
raise APIError, "Server error: #{response.status}"
|
|
121
241
|
else
|
|
@@ -124,4 +244,4 @@ module RCrewAI
|
|
|
124
244
|
end
|
|
125
245
|
end
|
|
126
246
|
end
|
|
127
|
-
end
|
|
247
|
+
end
|