rcrewai 0.7.1 → 0.8.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.
data/lib/rcrewai/crew.rb CHANGED
@@ -28,10 +28,14 @@ module RCrewAI
28
28
  @after_kickoff_hooks = []
29
29
  @last_inputs = {}
30
30
  @process_instance = nil
31
+ @checkpoint_store = options[:checkpoint]
32
+ @run_id = nil
33
+ @parent_run_id = nil
34
+ @completed_from_checkpoint = {}
31
35
  validate_process_type!
32
36
  end
33
37
 
34
- attr_reader :knowledge, :stream_sink, :last_inputs, :consensus_agents
38
+ attr_reader :knowledge, :stream_sink, :last_inputs, :consensus_agents, :run_id
35
39
 
36
40
  def planning?
37
41
  @planning
@@ -59,13 +63,16 @@ module RCrewAI
59
63
  @tasks << task
60
64
  end
61
65
 
62
- def execute(async: false, stream: nil, inputs: {}, **async_options, &block)
66
+ def execute(async: false, stream: nil, inputs: {}, checkpoint: nil, **async_options, &block)
63
67
  sinks = []
64
68
  sinks << block if block_given?
65
69
  Array(stream).each { |s| sinks << s } if stream
66
70
  @stream_sink = sinks.empty? ? nil : RCrewAI::Events.fan_out(sinks)
67
71
  @tasks.each { |t| t.stream_sink = @stream_sink }
68
72
 
73
+ @checkpoint_store = checkpoint if checkpoint
74
+ open_checkpoint_run if @checkpoint_store
75
+
69
76
  run_before_hooks(inputs)
70
77
 
71
78
  distribute_knowledge if @knowledge
@@ -80,6 +87,44 @@ module RCrewAI
80
87
  @tasks.each { |t| t.stream_sink = nil }
81
88
  end
82
89
 
90
+ # Resumes a checkpointed run: tasks that already completed replay their
91
+ # stored results, and only the rest execute. The resumed run gets its own
92
+ # run id linked to +run_id+ via parent_run_id, so the original record stays
93
+ # intact and the chain stays walkable.
94
+ def resume(run_id, checkpoint: nil, **options)
95
+ store = checkpoint || @checkpoint_store
96
+ raise Checkpoint::CheckpointError, 'no checkpoint store configured' unless store
97
+
98
+ record = store.load(run_id)
99
+ raise Checkpoint::CheckpointError, "no checkpoint for run id #{run_id}" unless record
100
+
101
+ @checkpoint_store = store
102
+ @parent_run_id = run_id
103
+ @completed_from_checkpoint = completed_entries(record)
104
+ apply_checkpoint(@completed_from_checkpoint)
105
+
106
+ execute(**options)
107
+ end
108
+
109
+ # Tasks whose results came from a checkpoint rather than this run.
110
+ def restored_task_names
111
+ @completed_from_checkpoint.keys
112
+ end
113
+
114
+ # Records one task's settled state and flushes the run record. Called by
115
+ # Process as each task finishes, so a crash loses at most the task in
116
+ # flight rather than the whole run.
117
+ def checkpoint_task(task, status)
118
+ return unless @checkpoint_store
119
+
120
+ @checkpoint_tasks[task.name] = Checkpoint.task_entry(task, status)
121
+ write_checkpoint
122
+ end
123
+
124
+ def checkpointing?
125
+ !@checkpoint_store.nil?
126
+ end
127
+
83
128
  # Runs the crew once per input set, returning one result per input in order.
84
129
  # Runs are isolated: each execution starts from only its own inputs.
85
130
  def kickoff_for_each(inputs:)
@@ -173,6 +218,41 @@ module RCrewAI
173
218
 
174
219
  private
175
220
 
221
+ def open_checkpoint_run
222
+ @run_id = Checkpoint.new_run_id
223
+ @checkpoint_tasks = {}
224
+ # Carry restored entries into the new record so it describes the whole
225
+ # run, not just the tasks this process happened to execute.
226
+ @completed_from_checkpoint.each { |name, entry| @checkpoint_tasks[name] = entry }
227
+ write_checkpoint
228
+ end
229
+
230
+ def write_checkpoint
231
+ @checkpoint_store.save(
232
+ @run_id,
233
+ Checkpoint.record_for(run_id: @run_id, crew_name: @name,
234
+ tasks: @checkpoint_tasks, parent_run_id: @parent_run_id)
235
+ )
236
+ end
237
+
238
+ def completed_entries(record)
239
+ (record['tasks'] || {}).select { |_name, entry| entry['status'] == 'completed' }
240
+ end
241
+
242
+ # Replays stored results onto the matching tasks so downstream tasks can
243
+ # still read their context. A checkpointed name with no matching task is
244
+ # ignored: the crew may legitimately have been rebuilt differently.
245
+ def apply_checkpoint(entries)
246
+ @tasks.each do |task|
247
+ entry = entries[task.name]
248
+ next unless entry
249
+
250
+ task.result = entry['result']
251
+ task.status = :completed
252
+ task.execution_time = entry['execution_time']
253
+ end
254
+ end
255
+
176
256
  def run_before_hooks(inputs)
177
257
  # Assign before running hooks so a hook that reads #last_inputs sees this
178
258
  # run's own inputs; update it as each hook transforms them.
@@ -1,8 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
4
+
3
5
  module RCrewAI
4
6
  module Events
5
- BASE_ATTRS = %i[type timestamp agent iteration].freeze
7
+ # Every event carries its own :id and, when emitted inside a
8
+ # +with_parent+ scope, the :parent_id of the enclosing span. Together these
9
+ # turn a flat event stream into a tree that a subscriber can reassemble --
10
+ # which is what tracing exporters need.
11
+ BASE_ATTRS = %i[type timestamp agent iteration id parent_id].freeze
6
12
 
7
13
  Event = Struct.new(*BASE_ATTRS, keyword_init: true)
8
14
  TextDelta = Struct.new(*BASE_ATTRS, :text, keyword_init: true)
@@ -16,15 +22,75 @@ module RCrewAI
16
22
  IterationEnd = Struct.new(*BASE_ATTRS, :finish_reason, keyword_init: true)
17
23
  Error = Struct.new(*BASE_ATTRS, :error, keyword_init: true)
18
24
 
25
+ # Auto-assign an :id to any event that did not supply one. Struct's
26
+ # keyword_init initializer is wrapped rather than replaced so every event
27
+ # type gets this without restating the attribute list.
28
+ [Event, TextDelta, TextDone, ToolCallStart, ToolCallResult, ToolCallError,
29
+ Thinking, Usage, IterationStart, IterationEnd, Error].each do |klass|
30
+ klass.prepend(Module.new do
31
+ def initialize(**kwargs)
32
+ kwargs[:id] ||= SecureRandom.uuid
33
+ super(**kwargs)
34
+ end
35
+ end)
36
+ end
37
+
38
+ PARENT_KEY = :rcrewai_event_parent
39
+
40
+ # Runs the block with +id+ as the parent span for any event emitted through
41
+ # +emit+ on this thread. Scopes nest and are restored on exit, including
42
+ # when the block raises. Thread-local: a child thread starts with no parent
43
+ # rather than inheriting one, since it is a separate branch of work.
44
+ def self.with_parent(id)
45
+ previous = Thread.current[PARENT_KEY]
46
+ Thread.current[PARENT_KEY] = id
47
+ yield
48
+ ensure
49
+ Thread.current[PARENT_KEY] = previous
50
+ end
51
+
52
+ def self.current_parent
53
+ Thread.current[PARENT_KEY]
54
+ end
55
+
56
+ # Stamps the enclosing parent span onto the event (unless it already names
57
+ # one) and hands it to the sink.
58
+ def self.emit(sink, event)
59
+ return event if sink.nil?
60
+
61
+ event.parent_id ||= current_parent
62
+ sink.call(event)
63
+ event
64
+ end
65
+
66
+ # Wraps one or more sinks in a single callable.
67
+ #
68
+ # Delivery is serialized: sinks are invoked under a mutex, so a sink shared
69
+ # by concurrently executing agents is never entered from two threads at
70
+ # once and does not need locking of its own. The lock is reentrant, so a
71
+ # sink that emits back through the same fan-out does not deadlock.
72
+ #
73
+ # A sink that raises is reported and skipped -- one bad subscriber must not
74
+ # take down the run or starve the others.
19
75
  def self.fan_out(sinks)
20
76
  sinks = Array(sinks).compact
77
+ mutex = Mutex.new
21
78
  lambda do |event|
22
- sinks.each do |s|
23
- s.call(event)
24
- rescue StandardError => e
25
- Kernel.warn "[rcrewai] event sink raised: #{e.class}: #{e.message}"
79
+ if mutex.owned?
80
+ deliver(sinks, event)
81
+ else
82
+ mutex.synchronize { deliver(sinks, event) }
26
83
  end
27
84
  end
28
85
  end
86
+
87
+ def self.deliver(sinks, event)
88
+ sinks.each do |s|
89
+ s.call(event)
90
+ rescue StandardError => e
91
+ Kernel.warn "[rcrewai] event sink raised: #{e.class}: #{e.message}"
92
+ end
93
+ end
94
+ private_class_method :deliver
29
95
  end
30
96
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'securerandom'
3
4
  require_relative 'events'
4
5
 
5
6
  module RCrewAI
@@ -19,6 +20,12 @@ module RCrewAI
19
20
  end
20
21
 
21
22
  def run(messages:)
23
+ Events.with_parent(@run_span_id ||= SecureRandom.uuid) { run_loop(messages: messages) }
24
+ end
25
+
26
+ private
27
+
28
+ def run_loop(messages:)
22
29
  msgs = messages.dup
23
30
  history = []
24
31
  iter = 0
@@ -58,8 +65,6 @@ module RCrewAI
58
65
  finish_reason: :max_iterations, usage: total_usage)
59
66
  end
60
67
 
61
- private
62
-
63
68
  # Trims the message list to the model's context window when the agent
64
69
  # supports it; a no-op otherwise.
65
70
  def fit_context(messages)
@@ -144,13 +149,13 @@ module RCrewAI
144
149
  type_sym = klass.name.split('::').last
145
150
  .gsub(/([A-Z])/) { "_#{Regexp.last_match(1).downcase}" }
146
151
  .sub(/^_/, '').to_sym
147
- @sink.call(klass.new(
148
- type: type_sym,
149
- timestamp: Time.now,
150
- agent: @agent.respond_to?(:name) ? @agent.name : nil,
151
- iteration: iteration,
152
- **attrs
153
- ))
152
+ Events.emit(@sink, klass.new(
153
+ type: type_sym,
154
+ timestamp: Time.now,
155
+ agent: @agent.respond_to?(:name) ? @agent.name : nil,
156
+ iteration: iteration,
157
+ **attrs
158
+ ))
154
159
  end
155
160
 
156
161
  def accumulate_usage(total, partial)
@@ -6,26 +6,31 @@ require_relative 'llm_clients/anthropic'
6
6
  require_relative 'llm_clients/google'
7
7
  require_relative 'llm_clients/azure'
8
8
  require_relative 'llm_clients/ollama'
9
+ require_relative 'llm_clients/openai_compatible'
10
+ require_relative 'llm_clients/bedrock'
11
+ require_relative 'llm_clients/snowflake_cortex'
12
+ require_relative 'llm_clients/openai_responses'
9
13
 
10
14
  module RCrewAI
11
15
  class LLMClient
12
- def self.for_provider(provider = nil, config = RCrewAI.configuration)
16
+ PROVIDERS = {
17
+ openai: LLMClients::OpenAI,
18
+ anthropic: LLMClients::Anthropic,
19
+ google: LLMClients::Google,
20
+ azure: LLMClients::Azure,
21
+ ollama: LLMClients::Ollama,
22
+ openai_compatible: LLMClients::OpenAICompatible,
23
+ bedrock: LLMClients::Bedrock,
24
+ snowflake: LLMClients::SnowflakeCortex,
25
+ openai_responses: LLMClients::OpenAIResponses
26
+ }.freeze
27
+
28
+ def self.for_provider(provider = nil, config = RCrewAI.configuration, **hooks)
13
29
  provider ||= config.llm_provider
30
+ klass = PROVIDERS[provider.to_sym]
31
+ raise ConfigurationError, "Unsupported provider: #{provider}" unless klass
14
32
 
15
- case provider.to_sym
16
- when :openai
17
- LLMClients::OpenAI.new(config)
18
- when :anthropic
19
- LLMClients::Anthropic.new(config)
20
- when :google
21
- LLMClients::Google.new(config)
22
- when :azure
23
- LLMClients::Azure.new(config)
24
- when :ollama
25
- LLMClients::Ollama.new(config)
26
- else
27
- raise ConfigurationError, "Unsupported provider: #{provider}"
28
- end
33
+ klass.new(config, **hooks)
29
34
  end
30
35
 
31
36
  # Resolves a per-agent / per-pass LLM spec into a client.
@@ -21,8 +21,8 @@ module RCrewAI
21
21
  'max_tokens' => :length
22
22
  }.freeze
23
23
 
24
- def initialize(config = RCrewAI.configuration)
25
- super
24
+ def initialize(config = RCrewAI.configuration, **hooks)
25
+ super(config, **hooks)
26
26
  @base_url = BASE_URL
27
27
  end
28
28
 
@@ -74,19 +74,27 @@ module RCrewAI
74
74
  ]
75
75
  end
76
76
 
77
+ def provider_name
78
+ :anthropic
79
+ end
80
+
77
81
  private
78
82
 
79
83
  def plain_chat(payload)
80
84
  url = "#{@base_url}/messages"
85
+ payload = apply_before_request(payload)
86
+ started_at = Time.now
81
87
  log_request(:post, url, payload)
82
88
  response = http_client.post(url, payload, build_headers.merge(auth_header))
83
89
  log_response(response)
84
90
  body = handle_response(response)
85
- normalize_non_streaming(body)
91
+ apply_after_response(normalize_non_streaming(body), started_at)
86
92
  end
87
93
 
88
94
  def stream_chat(payload, sink) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
89
95
  url = "#{@base_url}/messages"
96
+ payload = apply_before_request(payload)
97
+ started_at = Time.now
90
98
  log_request(:post, url, payload)
91
99
 
92
100
  assembled_text = +''
@@ -150,7 +158,7 @@ module RCrewAI
150
158
  ))
151
159
  end
152
160
 
153
- {
161
+ result = {
154
162
  content: assembled_text.empty? ? nil : assembled_text,
155
163
  tool_calls: tool_calls,
156
164
  usage: usage,
@@ -158,6 +166,7 @@ module RCrewAI
158
166
  model: config.model,
159
167
  provider: :anthropic
160
168
  }
169
+ apply_after_response(result, started_at)
161
170
  end
162
171
 
163
172
  def streaming_post(url, payload, &on_chunk)
@@ -8,8 +8,8 @@ module RCrewAI
8
8
  # path with an api-version query param, and authenticates with an api-key
9
9
  # header instead of Authorization: Bearer.
10
10
  class Azure < OpenAI
11
- def initialize(config = RCrewAI.configuration)
12
- super
11
+ def initialize(config = RCrewAI.configuration, **hooks)
12
+ super(config, **hooks)
13
13
  @api_version = config.api_version || '2024-02-01'
14
14
  @deployment_name = config.deployment_name || config.model
15
15
  @base_url = build_endpoint_url
@@ -9,13 +9,31 @@ module RCrewAI
9
9
  class Base
10
10
  attr_reader :config, :logger
11
11
 
12
- def initialize(config = RCrewAI.configuration)
12
+ def initialize(config = RCrewAI.configuration, before_request: nil, after_response: nil)
13
13
  @config = config
14
14
  @logger = Logger.new($stdout)
15
15
  @logger.level = Logger::INFO
16
+ @before_request_hooks = Array(before_request)
17
+ @after_response_hooks = Array(after_response)
16
18
  validate_config!
17
19
  end
18
20
 
21
+ # Registers a hook run just before the request payload is sent.
22
+ # Receives (payload, context) where context carries :provider and :model.
23
+ # Returning a payload replaces it; returning nil keeps the original.
24
+ def before_request(callable = nil, &block)
25
+ @before_request_hooks << (callable || block)
26
+ self
27
+ end
28
+
29
+ # Registers a hook run just after a response is normalized.
30
+ # Receives (result, context) where context adds :duration_ms.
31
+ # Returning a result replaces it; returning nil keeps the original.
32
+ def after_response(callable = nil, &block)
33
+ @after_response_hooks << (callable || block)
34
+ self
35
+ end
36
+
19
37
  def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options)
20
38
  raise NotImplementedError, 'Subclasses must implement #chat method'
21
39
  end
@@ -30,6 +48,42 @@ module RCrewAI
30
48
 
31
49
  protected
32
50
 
51
+ # Threads the payload through every before_request hook. A hook that
52
+ # raises is reported and skipped -- observability must never break a call.
53
+ def apply_before_request(payload)
54
+ return payload if @before_request_hooks.empty?
55
+
56
+ ctx = hook_context
57
+ @before_request_hooks.reduce(payload) do |acc, hook|
58
+ hook.call(acc, ctx) || acc
59
+ rescue StandardError => e
60
+ Kernel.warn "[rcrewai] before_request hook raised: #{e.class}: #{e.message}"
61
+ acc
62
+ end
63
+ end
64
+
65
+ # Threads the normalized result through every after_response hook.
66
+ def apply_after_response(result, started_at)
67
+ return result if @after_response_hooks.empty?
68
+
69
+ ctx = hook_context.merge(duration_ms: ((Time.now - started_at) * 1000).round(3))
70
+ @after_response_hooks.reduce(result) do |acc, hook|
71
+ hook.call(acc, ctx) || acc
72
+ rescue StandardError => e
73
+ Kernel.warn "[rcrewai] after_response hook raised: #{e.class}: #{e.message}"
74
+ acc
75
+ end
76
+ end
77
+
78
+ def hook_context
79
+ { provider: provider_name, model: config.model }
80
+ end
81
+
82
+ # Providers override this; Base has no wire identity of its own.
83
+ def provider_name
84
+ nil
85
+ end
86
+
33
87
  def validate_config!
34
88
  raise ConfigurationError, 'API key is required' unless config.api_key
35
89
  raise ConfigurationError, 'Model is required' unless config.model
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+ require 'faraday'
5
+ require 'json'
6
+ require_relative 'base'
7
+ require_relative '../events'
8
+ require_relative '../pricing'
9
+
10
+ module RCrewAI
11
+ module LLMClients
12
+ # AWS Bedrock via the Converse API (v4). Converse gives every Bedrock model
13
+ # one request/response shape regardless of the underlying vendor, so this
14
+ # client speaks Converse rather than each model's native format.
15
+ #
16
+ # Authentication: the configured api_key is sent as a bearer token, which
17
+ # covers Bedrock API keys and any gateway fronting Bedrock. Full SigV4
18
+ # request signing is not implemented -- it needs the aws-sigv4 gem, and
19
+ # adding a hard AWS dependency for one provider is not worth it. Users
20
+ # needing SigV4 can sign via a before_request hook.
21
+ class Bedrock < Base
22
+ STOP_REASONS = {
23
+ 'end_turn' => :stop,
24
+ 'stop_sequence' => :stop,
25
+ 'max_tokens' => :length,
26
+ 'tool_use' => :tool_calls,
27
+ 'content_filtered' => :content_filter
28
+ }.freeze
29
+
30
+ def initialize(config = RCrewAI.configuration, **hooks)
31
+ super
32
+ @region = config.aws_region
33
+ end
34
+
35
+ def provider_name
36
+ :bedrock
37
+ end
38
+
39
+ def chat(messages:, tools: nil, tool_choice: :auto, stream: nil, **options) # rubocop:disable Lint/UnusedMethodArgument
40
+ system_text = extract_system(messages)
41
+ payload = {
42
+ messages: format_messages(messages),
43
+ inferenceConfig: {
44
+ temperature: options[:temperature] || config.temperature,
45
+ maxTokens: options[:max_tokens] || config.max_tokens
46
+ }.compact
47
+ }
48
+ payload[:system] = [{ text: system_text }] if system_text
49
+ payload[:toolConfig] = { tools: format_tools(tools) } if tools && !tools.empty?
50
+
51
+ plain_chat(payload)
52
+ end
53
+
54
+ # Converse exposes tool use uniformly, but streaming uses a separate
55
+ # endpoint and event-stream framing that this client does not implement.
56
+ def supports_native_tools?(model: config.model) # rubocop:disable Lint/UnusedMethodArgument
57
+ true
58
+ end
59
+
60
+ private
61
+
62
+ def plain_chat(payload)
63
+ url = converse_url
64
+ payload = apply_before_request(payload)
65
+ started_at = Time.now
66
+ log_request(:post, url, payload)
67
+ response = http_client.post(url, payload, build_headers.merge(auth_header))
68
+ log_response(response)
69
+ body = handle_response(response)
70
+ apply_after_response(normalize(body), started_at)
71
+ end
72
+
73
+ def converse_url
74
+ "https://bedrock-runtime.#{@region}.amazonaws.com/model/#{CGI.escape(config.model)}/converse"
75
+ end
76
+
77
+ # Converse carries the system prompt at the top level, not in messages.
78
+ def extract_system(messages)
79
+ systems = messages.select { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }
80
+ return nil if systems.empty?
81
+
82
+ systems.map { |m| m[:content] }.join("\n\n")
83
+ end
84
+
85
+ # Every message content is a list of typed blocks.
86
+ def format_messages(messages)
87
+ messages.reject { |m| m.is_a?(Hash) && m[:role].to_s == 'system' }.map do |m|
88
+ { role: m[:role].to_s, content: [{ text: m[:content].to_s }] }
89
+ end
90
+ end
91
+
92
+ def format_tools(tools)
93
+ tools.map do |t|
94
+ { toolSpec: { name: t[:name], description: t[:description],
95
+ inputSchema: { json: t[:parameters] } } }
96
+ end
97
+ end
98
+
99
+ def normalize(body)
100
+ blocks = body.dig('output', 'message', 'content') || []
101
+ text = blocks.filter_map { |b| b['text'] }.join
102
+ tool_calls = blocks.filter_map do |b|
103
+ use = b['toolUse']
104
+ next unless use
105
+
106
+ { id: use['toolUseId'], name: use['name'], arguments: use['input'] || {} }
107
+ end
108
+
109
+ {
110
+ content: text.empty? ? nil : text,
111
+ tool_calls: tool_calls,
112
+ usage: {
113
+ prompt_tokens: body.dig('usage', 'inputTokens'),
114
+ completion_tokens: body.dig('usage', 'outputTokens'),
115
+ total_tokens: body.dig('usage', 'totalTokens')
116
+ },
117
+ finish_reason: STOP_REASONS.fetch(body['stopReason'], :stop),
118
+ model: config.model,
119
+ provider: provider_name
120
+ }
121
+ end
122
+
123
+ def auth_header
124
+ { 'Authorization' => "Bearer #{config.api_key}" }
125
+ end
126
+
127
+ def validate_config!
128
+ raise ConfigurationError, 'Bedrock API key is required' unless config.api_key
129
+ raise ConfigurationError, 'An AWS region is required for Bedrock' unless config.aws_region
130
+ raise ConfigurationError, 'Model is required' unless config.model
131
+ end
132
+ end
133
+ end
134
+ end
@@ -20,8 +20,8 @@ module RCrewAI
20
20
  'RECITATION' => :stop
21
21
  }.freeze
22
22
 
23
- def initialize(config = RCrewAI.configuration)
24
- super
23
+ def initialize(config = RCrewAI.configuration, **hooks)
24
+ super(config, **hooks)
25
25
  @base_url = BASE_URL
26
26
  end
27
27
 
@@ -62,17 +62,25 @@ module RCrewAI
62
62
  %w[gemini-pro gemini-1.5-pro gemini-1.5-flash gemini-pro-vision]
63
63
  end
64
64
 
65
+ def provider_name
66
+ :google
67
+ end
68
+
65
69
  private
66
70
 
67
71
  def plain_chat(url, payload)
72
+ payload = apply_before_request(payload)
73
+ started_at = Time.now
68
74
  log_request(:post, url, payload)
69
75
  response = http_client.post(url, payload, build_headers)
70
76
  log_response(response)
71
77
  body = handle_response(response)
72
- normalize_non_streaming(body)
78
+ apply_after_response(normalize_non_streaming(body), started_at)
73
79
  end
74
80
 
75
81
  def stream_chat(url, payload, sink)
82
+ payload = apply_before_request(payload)
83
+ started_at = Time.now
76
84
  log_request(:post, url, payload)
77
85
 
78
86
  assembled_text = +''
@@ -123,7 +131,7 @@ module RCrewAI
123
131
 
124
132
  finish_reason = :tool_calls if tool_calls.any?
125
133
 
126
- {
134
+ result = {
127
135
  content: assembled_text.empty? ? nil : assembled_text,
128
136
  tool_calls: tool_calls,
129
137
  usage: usage || {},
@@ -131,6 +139,7 @@ module RCrewAI
131
139
  model: config.model,
132
140
  provider: :google
133
141
  }
142
+ apply_after_response(result, started_at)
134
143
  end
135
144
 
136
145
  def streaming_post(url, payload, &on_chunk)