ruby_llm-agents 3.14.1 → 3.15.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4734a9b91b3dc228b78661cdbe4e336b9053beb6440601482709f99745c6e918
4
- data.tar.gz: de29b3452e781a363c3330137323364edca7e80afd55680f200161c464d3f027
3
+ metadata.gz: 1d76f68609f8511674712633294484523bcedebd79b729796e3bee5504e2ed4c
4
+ data.tar.gz: 84ff7562baa359759a0d64a23030fc537addcb5860e0d823cb3a500f62c07a6e
5
5
  SHA512:
6
- metadata.gz: 180db730cb98f426ce7e774cad42ad146b33cbb09b74340b772a2306cc0249152adbee79da9de71d372eb5f925ebf894db8305ef9df2ca58c60104280fec03e3
7
- data.tar.gz: 2b2183165d6e2271ffda0b673ed173abde8cd77d8adc6e85b168de24b9744bc6c51ebe2194fcc45ad7d3e3fb35b82603f7830260927b2fabe763c552b93691cd
6
+ metadata.gz: 0cccf36a2f461c10fb0db268590e4d495a8a35ff0c06e4965051a669e49d10431daa9832dd4c1886100db401fe1dc1f8005d51cdb5f576d701a55973c4212017
7
+ data.tar.gz: 1ca7e6c26bc6973625228efe1bdb36033035d37f91ecc070b82eba31aad0fa527f6ad5b925bdb514bf7f8f4a224dae2c8d0919d1c9497af31406cece1b42e360
@@ -534,16 +534,22 @@ module RubyLLM
534
534
  # @param scope [ActiveRecord::Relation] Pre-filtered scope
535
535
  # @return [Array<Hash>] Model stats sorted by total cost descending
536
536
  def model_stats(scope: all)
537
+ # Attribute spend to the model that actually ran, not the one that
538
+ # was configured — otherwise a fallback's cost is billed to the
539
+ # primary in the comparison table. Older rows predate
540
+ # chosen_model_id, so fall back to model_id for those.
541
+ billed_model = Arel.sql("COALESCE(chosen_model_id, model_id)")
542
+
537
543
  rows = scope.where.not(model_id: nil)
538
544
  .select(
539
- :model_id,
545
+ Arel.sql("#{billed_model} AS billed_model_id"),
540
546
  Arel.sql("COUNT(*) AS exec_count"),
541
547
  Arel.sql("COALESCE(SUM(total_cost), 0) AS sum_cost"),
542
548
  Arel.sql("COALESCE(SUM(total_tokens), 0) AS sum_tokens"),
543
549
  Arel.sql("AVG(duration_ms) AS avg_dur"),
544
550
  Arel.sql("SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_cnt")
545
551
  )
546
- .group(:model_id)
552
+ .group(billed_model)
547
553
 
548
554
  total_cost = rows.sum { |r| r["sum_cost"].to_f }
549
555
 
@@ -554,7 +560,7 @@ module RubyLLM
554
560
  successful = row["success_cnt"].to_i
555
561
 
556
562
  {
557
- model_id: row.model_id,
563
+ model_id: row["billed_model_id"],
558
564
  executions: count,
559
565
  total_cost: model_cost,
560
566
  total_tokens: model_tokens,
@@ -596,20 +602,31 @@ module RubyLLM
596
602
  # @return [Hash] Cache savings data
597
603
  def cache_savings(scope: all)
598
604
  cond = cache_hit_condition
599
- total_count, cache_count, cache_cost = scope.pick(
605
+ total_count, cache_count, miss_count, miss_cost = scope.pick(
600
606
  Arel.sql("COUNT(*)"),
601
607
  Arel.sql("SUM(CASE WHEN #{cond} THEN 1 ELSE 0 END)"),
602
- Arel.sql("COALESCE(SUM(CASE WHEN #{cond} THEN total_cost ELSE 0 END), 0)")
608
+ Arel.sql("SUM(CASE WHEN #{cond} THEN 0 ELSE 1 END)"),
609
+ Arel.sql("COALESCE(SUM(CASE WHEN #{cond} THEN 0 ELSE total_cost END), 0)")
603
610
  )
604
611
 
605
612
  total_count = total_count.to_i
606
613
  cache_count = cache_count.to_i
614
+ miss_count = miss_count.to_i
607
615
 
608
616
  return {count: 0, estimated_savings: 0, hit_rate: 0, total_executions: 0} if total_count.zero?
609
617
 
618
+ # Savings are the cost AVOIDED, not the cost recorded: a cache hit
619
+ # makes no API call, so its own total_cost is always 0 and summing
620
+ # those rows reported $0.00 saved forever. Estimate each hit at the
621
+ # mean cost of the misses in this same scope.
622
+ #
623
+ # ponytail: scope-wide mean, not per-agent. Group by agent_type if
624
+ # a mixed dashboard scope ever skews this enough to matter.
625
+ avg_miss_cost = miss_count.positive? ? (miss_cost.to_f / miss_count) : 0.0
626
+
610
627
  {
611
628
  count: cache_count,
612
- estimated_savings: cache_cost.to_f,
629
+ estimated_savings: (cache_count * avg_miss_cost).round(6),
613
630
  hit_rate: (cache_count.to_f / total_count * 100).round(1),
614
631
  total_executions: total_count
615
632
  }
@@ -120,6 +120,13 @@ module RubyLLM
120
120
  # Used for multi-attempt executions (retries/fallbacks) where different models
121
121
  # may have been used. Calculates total cost by summing individual attempt costs.
122
122
  #
123
+ # Prompt-cache reads and writes are billed alongside the plain input: every
124
+ # provider reports input_tokens NET of the cache, so the cached portion is
125
+ # additive and pricing text tokens alone silently under-bills a cached
126
+ # request — the same defect the pipeline's cost path carried. Cache reads
127
+ # fall back to the full input rate when the registry publishes no cached
128
+ # price, so an unpriced model is never billed at zero.
129
+ #
123
130
  # @return [void]
124
131
  def aggregate_attempt_costs!
125
132
  return if attempts.blank?
@@ -134,13 +141,15 @@ module RubyLLM
134
141
  model_info = resolve_model_info(attempt["model_id"])
135
142
  next unless model_info&.pricing
136
143
 
137
- input_price = model_info.pricing.text_tokens&.input || 0
138
- output_price = model_info.pricing.text_tokens&.output || 0
144
+ tier = model_info.pricing.text_tokens
145
+ input_price = tier&.input || 0
146
+ output_price = tier&.output || 0
139
147
 
140
148
  input_tokens = attempt["input_tokens"] || 0
141
149
  output_tokens = attempt["output_tokens"] || 0
142
150
 
143
151
  total_input_cost += (input_tokens / 1_000_000.0) * input_price
152
+ total_input_cost += attempt_cache_cost(attempt, tier, input_price)
144
153
  total_output_cost += (output_tokens / 1_000_000.0) * output_price
145
154
  end
146
155
 
@@ -509,6 +518,31 @@ module RubyLLM
509
518
  Rails.logger.debug("[RubyLLM::Agents] Model lookup failed for #{lookup_model_id}: #{e.message}") if defined?(Rails) && Rails.logger
510
519
  nil
511
520
  end
521
+
522
+ # Prompt-cache charge for a single attempt, in USD.
523
+ #
524
+ # Reads bill at the registry's cached rate, falling back to the full input
525
+ # rate when it publishes none — never free. Writes bill at the cache-write
526
+ # rate, and are skipped entirely when unpriced, since no provider charges
527
+ # a write at the plain input rate.
528
+ #
529
+ # @param attempt [Hash] One entry from the attempts JSON
530
+ # @param tier [RubyLLM::Model::PricingCategory, nil] Text-token pricing
531
+ # @param input_price [Float] Per-million input rate, used as the read fallback
532
+ # @return [Float] Cache cost for this attempt
533
+ def attempt_cache_cost(attempt, tier, input_price)
534
+ read_tokens = attempt["cached_tokens"].to_i
535
+ write_tokens = attempt["cache_creation_tokens"].to_i
536
+ return 0.0 if read_tokens.zero? && write_tokens.zero?
537
+
538
+ read_price = (tier.respond_to?(:cache_read_input) ? tier.cache_read_input : nil) || input_price
539
+ write_price = (tier.respond_to?(:cache_write_input) ? tier.cache_write_input : nil) || 0
540
+
541
+ (read_tokens / 1_000_000.0) * read_price +
542
+ (write_tokens / 1_000_000.0) * write_price
543
+ rescue
544
+ 0.0
545
+ end
512
546
  end
513
547
  end
514
548
  end
@@ -121,6 +121,9 @@ module RubyLLM
121
121
  return (data[:output_cost_per_character] * 1000).round(6)
122
122
  end
123
123
 
124
+ # Estimate only: audio-token-priced models publish no per-character
125
+ # rate, so convert at ~4 characters per audio token (1000 / 4 = 250).
126
+ # Set config.tts_model_pricing for a model where this matters.
124
127
  if data[:output_cost_per_audio_token]
125
128
  return (data[:output_cost_per_audio_token] * 250).round(6)
126
129
  end
@@ -1008,7 +1008,12 @@ module RubyLLM
1008
1008
  input_price = model_info.pricing&.text_tokens&.input || 0
1009
1009
  output_price = model_info.pricing&.text_tokens&.output || 0
1010
1010
 
1011
- context.input_cost = input_cost_for(input_tokens, input_price, model_info, context)
1011
+ # input_tokens is always the UNCACHED input: every RubyLLM provider
1012
+ # reports it net of the cache reads (Anthropic natively; OpenAI, Gemini,
1013
+ # Bedrock and OpenRouter subtract them when parsing usage). So the cache
1014
+ # is purely additive and #extra_token_costs prices it on top — see
1015
+ # #cache_read_cost.
1016
+ context.input_cost = (input_tokens / 1_000_000.0) * input_price
1012
1017
 
1013
1018
  # Price cache/reasoning extras first so we know whether reasoning was
1014
1019
  # actually billed at the reasoning rate. Only then exclude those tokens
@@ -1021,73 +1026,31 @@ module RubyLLM
1021
1026
  context.total_cost = (context.input_cost + context.output_cost + extra).round(6)
1022
1027
  end
1023
1028
 
1024
- # Input charge, with cache reads billed at the cached rate rather than the
1025
- # full one.
1029
+ # Charge for tokens served from the provider's prompt cache.
1026
1030
  #
1027
- # Providers disagree on whether the reported input_tokens ALREADY contains
1028
- # the cache reads, and the difference decides who has a bug:
1031
+ # Cache reads are ALWAYS additive to input_tokens. Every provider RubyLLM
1032
+ # supports reports input_tokens net of them Anthropic natively, and
1033
+ # OpenAI/Gemini/Bedrock/OpenRouter by subtracting the cache counters while
1034
+ # parsing usage. So the read is never already paid for by the input
1035
+ # charge, and must be added here or it is billed at zero.
1029
1036
  #
1030
- # OpenAI prompt_tokens INCLUDES prompt_tokens_details.cached_tokens
1031
- # Gemini promptTokenCount INCLUDES cachedContentTokenCount
1032
- # Anthropic input_tokens EXCLUDES cache_read_input_tokens
1037
+ # Prefers RubyLLM::Cost's own cache_read (registry cache-read rate). When
1038
+ # the registry publishes no cache-read price for the model, RubyLLM::Cost
1039
+ # returns nil rather than guessing — bill those tokens at the full input
1040
+ # rate instead, so an unpriced model is never under-billed.
1033
1041
  #
1034
- # On Anthropic the cache read is genuinely additive, so charging the full
1035
- # input_tokens at full rate and letting #extra_token_costs add cache_read
1036
- # on top is already correct — that path is left untouched.
1037
- #
1038
- # On OpenAI and Gemini the cached tokens are sitting inside input_tokens,
1039
- # so charging them at the full rate overstates spend by 2-3x on exactly
1040
- # the workload caching exists for (a long stable system prompt replayed
1041
- # every turn) — and would double-charge outright once the provider also
1042
- # reports a cache_read cost component. Those get split here instead.
1043
- #
1044
- # Falls back to the full input rate whenever the split can't be made
1045
- # safely (no cache reported, or no cached price in the registry), so an
1046
- # unknown model is never under-billed.
1047
- #
1048
- # @return [Float] The input cost in USD
1049
- def input_cost_for(input_tokens, input_price, model_info, context)
1050
- full_rate_charge = (input_tokens / 1_000_000.0) * input_price
1042
+ # @param cost [RubyLLM::Cost] The response's cost helper
1043
+ # @param model_info [RubyLLM::Model::Info] Resolved pricing source
1044
+ # @param context [Pipeline::Context] The execution context
1045
+ # @return [Float, nil] Cache-read cost in USD, or nil when nothing to bill
1046
+ def cache_read_cost(cost, model_info, context)
1047
+ priced = cost.cache_read
1048
+ return priced if priced
1051
1049
 
1052
1050
  cached_tokens = context.cached_tokens.to_i
1053
- return full_rate_charge unless cached_tokens.positive?
1054
- return full_rate_charge unless cached_tokens_included_in_input?(model_info)
1055
-
1056
- cached_price = cached_input_price(model_info)
1057
- return full_rate_charge unless cached_price
1058
-
1059
- # Tells extra_token_costs not to charge these same reads a second time.
1060
- context[:cache_read_priced] = true
1061
-
1062
- uncached_tokens = [input_tokens - cached_tokens, 0].max
1063
- (uncached_tokens / 1_000_000.0) * input_price +
1064
- (cached_tokens / 1_000_000.0) * cached_price
1065
- end
1051
+ return nil unless cached_tokens.positive?
1066
1052
 
1067
- # Per-million price for a cache read, or nil when the model registry
1068
- # doesn't publish one. Pricing shapes vary by source (and are stubbed with
1069
- # partial doubles in tests), so an absent field degrades to "unknown" —
1070
- # callers then charge the full input rate rather than guessing a discount.
1071
- #
1072
- # @return [Float, nil]
1073
- def cached_input_price(model_info)
1074
- tier = model_info.pricing&.text_tokens
1075
- return nil unless tier.respond_to?(:cached_input)
1076
-
1077
- tier.cached_input
1078
- rescue
1079
- nil
1080
- end
1081
-
1082
- # Whether this provider's reported input_tokens already contains the cache
1083
- # reads. See #input_cost_for for the per-provider breakdown. Anthropic is
1084
- # the exception; defaulting the unknown case to "included" matches every
1085
- # other provider RubyLLM supports.
1086
- #
1087
- # @return [Boolean]
1088
- def cached_tokens_included_in_input?(model_info)
1089
- provider = model_info.respond_to?(:provider) ? model_info.provider.to_s : ""
1090
- !provider.include?("anthropic")
1053
+ (cached_tokens / 1_000_000.0) * (model_info.pricing&.text_tokens&.input || 0)
1091
1054
  end
1092
1055
 
1093
1056
  # Number of reasoning (thinking) tokens that were actually charged at the
@@ -1126,12 +1089,10 @@ module RubyLLM
1126
1089
  cost = response_cost(response, model_info)
1127
1090
  return 0.0 unless cost
1128
1091
 
1129
- # Skip cache_read when #input_cost_for already billed the cache at the
1130
- # cached rate — otherwise the same tokens are charged twice (once inside
1131
- # input_tokens at full rate, once here). Cache WRITES are always extra:
1132
- # no provider folds them into input_tokens.
1092
+ # Cache reads and writes are both additive: no provider folds either
1093
+ # into the input_tokens RubyLLM reports. See #cache_read_cost.
1133
1094
  components = {
1134
- cache_read: (cost.cache_read unless context[:cache_read_priced]),
1095
+ cache_read: cache_read_cost(cost, model_info, context),
1135
1096
  cache_write: cost.cache_write,
1136
1097
  thinking: cost.thinking
1137
1098
  }.compact.reject { |_, value| value.zero? }
@@ -142,9 +142,14 @@ module RubyLLM
142
142
  # @return [Array<String>] List of model identifiers to try on failure
143
143
 
144
144
  # @!attribute [rw] default_total_timeout
145
- # Default total timeout across all retry attempts.
146
- # Can be overridden per-agent using the `total_timeout` DSL method.
147
- # @return [Integer, nil] Total timeout in seconds, or nil for no limit
145
+ # Wall-clock budget for ALL attempts combined, across every model, for
146
+ # agents that configure reliability without naming their own
147
+ # `total_timeout`. Retries and fallbacks multiply into an attempt matrix
148
+ # that is easy to under-estimate — `retries times: 5` with three
149
+ # fallbacks is 24 attempts, which at the default 60s per-call timeout
150
+ # plus backoff is over 20 minutes of one caller waiting.
151
+ # Set to nil (or `total_timeout false` on an agent) for no limit.
152
+ # @return [Integer, nil] Total timeout in seconds (default: 300)
148
153
 
149
154
  # @!attribute [rw] default_streaming
150
155
  # Whether streaming mode is enabled by default for all agents.
@@ -201,6 +206,13 @@ module RubyLLM
201
206
  # @!attribute [rw] persist_prompts
202
207
  # Whether to persist system and user prompts in execution records.
203
208
  # Set to false to reduce storage or for privacy compliance.
209
+ #
210
+ # Note that prompts are stored as RENDERED — parameter redaction covers
211
+ # execution_details.parameters, not the prompt those parameters were
212
+ # interpolated into. A secret passed as `api_key:` shows up as
213
+ # "[REDACTED]" in parameters while still appearing verbatim in
214
+ # user_prompt. Do not interpolate credentials into prompts, or disable
215
+ # this.
204
216
  # @return [Boolean] Enable prompt persistence (default: true)
205
217
 
206
218
  # @!attribute [rw] persist_responses
@@ -695,7 +707,7 @@ module RubyLLM
695
707
  # Reliability defaults (all disabled by default for backward compatibility)
696
708
  @default_retries = {max: 0, backoff: :exponential, base: 0.4, max_delay: 3.0, on: []}
697
709
  @default_fallback_models = []
698
- @default_total_timeout = nil
710
+ @default_total_timeout = 300
699
711
  @default_retryable_patterns = {
700
712
  rate_limiting: ["rate limit", "rate_limit", "too many requests", "429", "quota"],
701
713
  server_errors: ["500", "502", "503", "504", "service unavailable",
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.14.1"
7
+ VERSION = "3.15.0"
8
8
  end
9
9
  end
@@ -51,7 +51,7 @@ module RubyLLM
51
51
  @retries_config = builder.retries_config if builder.retries_config
52
52
  @fallback_models = builder.fallback_models_list if builder.fallback_models_list.any?
53
53
  @fallback_providers = builder.fallback_providers_list if builder.fallback_providers_list.any?
54
- @total_timeout = builder.total_timeout_value if builder.total_timeout_value
54
+ @total_timeout = builder.total_timeout_value unless builder.total_timeout_value.nil?
55
55
  @circuit_breaker_config = builder.circuit_breaker_config if builder.circuit_breaker_config
56
56
  @retryable_patterns = builder.retryable_patterns_list if builder.retryable_patterns_list
57
57
  @non_fallback_errors = builder.non_fallback_errors_list if builder.non_fallback_errors_list
@@ -80,7 +80,7 @@ module RubyLLM
80
80
  @retries_config = builder.retries_config if builder.retries_config
81
81
  @fallback_models = builder.fallback_models_list if builder.fallback_models_list.any?
82
82
  @fallback_providers = builder.fallback_providers_list if builder.fallback_providers_list.any?
83
- @total_timeout = builder.total_timeout_value if builder.total_timeout_value
83
+ @total_timeout = builder.total_timeout_value unless builder.total_timeout_value.nil?
84
84
  @circuit_breaker_config = builder.circuit_breaker_config if builder.circuit_breaker_config
85
85
  @retryable_patterns = builder.retryable_patterns_list if builder.retryable_patterns_list
86
86
  @non_fallback_errors = builder.non_fallback_errors_list if builder.non_fallback_errors_list
@@ -186,9 +186,25 @@ module RubyLLM
186
186
  # @return [Integer, nil] The current total timeout
187
187
  # @example
188
188
  # total_timeout 30
189
+ # Wall-clock budget for ALL attempts combined, across every model.
190
+ #
191
+ # Falls back to config.default_total_timeout rather than "unbounded":
192
+ # retries and fallbacks multiply, and the product is easy to miss.
193
+ # `retries times: 5` with three fallbacks is 24 attempts — at the
194
+ # default 60s per-call timeout plus backoff that is over 20 minutes of
195
+ # one caller waiting. Pass `total_timeout false` to opt out.
196
+ #
197
+ # @param seconds [Integer, Float, false, nil] Budget in seconds, or
198
+ # false for no bound
199
+ # @return [Integer, Float, nil] The effective budget, nil when unbounded
189
200
  def total_timeout(seconds = nil)
190
- @total_timeout = seconds if seconds
191
- @total_timeout || inherited_total_timeout
201
+ @total_timeout = seconds unless seconds.nil?
202
+ configured = defined?(@total_timeout) ? @total_timeout : nil
203
+ effective = configured.nil? ? inherited_total_timeout : configured
204
+
205
+ return nil if effective == false
206
+
207
+ effective || default_total_timeout_from_config
192
208
  end
193
209
 
194
210
  # Configures circuit breaker for this agent
@@ -266,6 +282,12 @@ module RubyLLM
266
282
  superclass.total_timeout
267
283
  end
268
284
 
285
+ def default_total_timeout_from_config
286
+ RubyLLM::Agents.configuration.default_total_timeout
287
+ rescue
288
+ nil
289
+ end
290
+
269
291
  def inherited_circuit_breaker_config
270
292
  return nil unless superclass.respond_to?(:circuit_breaker_config)
271
293
 
@@ -436,7 +458,11 @@ module RubyLLM
436
458
  #
437
459
  def timeout(duration)
438
460
  # Handle ActiveSupport::Duration
439
- @total_timeout_value = duration.respond_to?(:to_i) ? duration.to_i : duration
461
+ @total_timeout_value = if duration == false
462
+ false
463
+ else
464
+ duration.respond_to?(:to_i) ? duration.to_i : duration
465
+ end
440
466
  end
441
467
 
442
468
  # Also support total_timeout for compatibility
@@ -228,29 +228,20 @@ module RubyLLM
228
228
 
229
229
  # Check if reliability features are enabled for an agent
230
230
  #
231
- # An agent has reliability enabled if it has:
232
- # - retries > 0, OR
233
- # - fallback_models configured
231
+ # Delegates to the same predicate the Reliability middleware itself
232
+ # uses (#reliability_config), so the builder gate and the middleware
233
+ # gate can never disagree. They previously did: this method tested
234
+ # `agent_class.retries` for a positive Integer, but that DSL reader
235
+ # returns the retry config Hash — so `on_failure { retries times: 3 }`
236
+ # never installed the middleware and retries silently never ran.
234
237
  #
235
238
  # @param agent_class [Class] The agent class
236
239
  # @return [Boolean]
237
240
  def reliability_enabled?(agent_class)
238
241
  return false unless agent_class
242
+ return false unless agent_class.respond_to?(:reliability_config)
239
243
 
240
- retries = if agent_class.respond_to?(:retries)
241
- agent_class.retries
242
- else
243
- 0
244
- end
245
-
246
- fallbacks = if agent_class.respond_to?(:fallback_models)
247
- agent_class.fallback_models
248
- else
249
- []
250
- end
251
-
252
- (retries.is_a?(Integer) && retries.positive?) ||
253
- (fallbacks.is_a?(Array) && fallbacks.any?)
244
+ agent_class.reliability_config.present?
254
245
  rescue => e
255
246
  Rails.logger.debug("[RubyLLM::Agents::Pipeline] Failed to check reliability_enabled: #{e.message}") if defined?(Rails) && Rails.logger
256
247
  false
@@ -48,6 +48,16 @@ module RubyLLM
48
48
  # a generic bag entry would be silently droppable (it was).
49
49
  attr_accessor :cached_tokens, :cache_creation_tokens
50
50
 
51
+ # Per-tenant provider API keys, set by the Tenant middleware.
52
+ #
53
+ # A first-class accessor for the opposite reason to the above: the
54
+ # metadata bag is copied wholesale onto the persisted execution record
55
+ # and rendered by the dashboard, so a credential stored there leaked
56
+ # into the database in plaintext and onto the execution page (it did).
57
+ # Keeping it off the bag makes that leak structurally impossible rather
58
+ # than dependent on a redaction list.
59
+ attr_accessor :tenant_api_keys
60
+
51
61
  # Response metadata
52
62
  attr_accessor :model_used, :finish_reason, :time_to_first_token_ms
53
63
 
@@ -185,7 +195,7 @@ module RubyLLM
185
195
  #
186
196
  # @return [RubyLLM::Context, RubyLLM] Scoped context or global module
187
197
  def llm
188
- api_keys = self[:tenant_api_keys]
198
+ api_keys = @tenant_api_keys
189
199
  return RubyLLM if api_keys.nil? || api_keys.empty?
190
200
 
191
201
  @llm_context ||= build_llm_context(api_keys)
@@ -39,7 +39,8 @@ module RubyLLM
39
39
  unless context.skip_cache
40
40
  # Try to read from cache
41
41
  if (cached = cache_read(cache_key))
42
- context.output = cached
42
+ context.output = mark_cached(cached, context)
43
+ replay_to_stream(context)
43
44
  context.cached = true
44
45
  context[:cache_key] = cache_key
45
46
  cache_action = "hit"
@@ -75,6 +76,61 @@ module RubyLLM
75
76
 
76
77
  private
77
78
 
79
+ # Marks a cache-read result as cached and re-points its execution_id
80
+ # at the row being written for THIS call.
81
+ #
82
+ # Without this, a cache hit returned the original result verbatim: it
83
+ # reported no way to tell it was cached, carried the original's
84
+ # execution_id (so it could not be correlated with the execution just
85
+ # created), and replayed the original's cost — double-counting for
86
+ # anyone summing #total_cost across calls.
87
+ #
88
+ # Results that predate #as_cache_hit (older cache entries mid-deploy)
89
+ # are returned untouched rather than raising.
90
+ #
91
+ # @param cached [Object] The value read from the cache store
92
+ # @param context [Context] The execution context
93
+ # @return [Object] The marked result, or the original value
94
+ def mark_cached(cached, context)
95
+ return cached unless cached.respond_to?(:as_cache_hit)
96
+
97
+ cached.as_cache_hit(
98
+ execution_id: context.execution_id,
99
+ cached_at: cached.try(:completed_at) || Time.current
100
+ )
101
+ rescue => e
102
+ debug("Failed to mark result as cached: #{e.message}", context)
103
+ cached
104
+ end
105
+
106
+ # Stand-in for a provider chunk when replaying a cached response.
107
+ ReplayChunk = Struct.new(:content)
108
+
109
+ # Feeds a cached response through the caller's stream block.
110
+ #
111
+ # A streaming agent with `cache_for` used to go silent on a hit: the
112
+ # block never fired and the caller saw an empty stream, even though
113
+ # the content was sitting in the return value.
114
+ #
115
+ # ponytail: replays as a single chunk, not token-by-token. The cache
116
+ # stores the finished response, not the chunk boundaries — recreating
117
+ # them would mean caching the stream itself.
118
+ #
119
+ # @param context [Context] The execution context
120
+ def replay_to_stream(context)
121
+ block = context.stream_block
122
+ return unless block
123
+
124
+ content = context.output.try(:content)
125
+ return if content.nil?
126
+
127
+ block.call(
128
+ context.stream_events? ? StreamEvent.new(:chunk, {content: content}) : ReplayChunk.new(content)
129
+ )
130
+ rescue => e
131
+ debug("Failed to replay cached response to stream: #{e.message}", context)
132
+ end
133
+
78
134
  # Emits an AS::Notification for cache events
79
135
  #
80
136
  # @param event [String] The notification event name
@@ -113,6 +169,14 @@ module RubyLLM
113
169
  config(:cache_ttl)
114
170
  end
115
171
 
172
+ # Execution options that change the response and must therefore change
173
+ # the cache key. Anything omitted here is a silent stale-result bug:
174
+ # attachments especially, since two calls with the same text prompt
175
+ # and different images used to collide.
176
+ KEYED_OPTIONS = %i[
177
+ system_prompt assistant_prefill temperature schema messages thinking attachments
178
+ ].freeze
179
+
116
180
  # Generates a cache key for the context
117
181
  #
118
182
  # Cache keys are content-based, including:
@@ -120,7 +184,9 @@ module RubyLLM
120
184
  # - Agent type
121
185
  # - Agent class name
122
186
  # - Model
187
+ # - Tenant (caches are never shared across tenants)
123
188
  # - SHA256 hash of input
189
+ # - SHA256 hash of the response-affecting execution options
124
190
  #
125
191
  # This means caches automatically invalidate when inputs change.
126
192
  #
@@ -132,12 +198,34 @@ module RubyLLM
132
198
  context.agent_type,
133
199
  context.agent_class&.name,
134
200
  context.model,
135
- hash_input(context.input)
201
+ context.tenant_id,
202
+ hash_input(context.input),
203
+ hash_options(context)
136
204
  ].compact
137
205
 
138
206
  components.join("/")
139
207
  end
140
208
 
209
+ # Hashes the response-affecting execution options.
210
+ #
211
+ # Tools are reduced to their names: they arrive as classes or
212
+ # instances whose default JSON encoding is neither stable nor
213
+ # meaningful, and the name is what actually shapes the request.
214
+ #
215
+ # @param context [Context] The execution context
216
+ # @return [String, nil] SHA256 hash, or nil when there are no options
217
+ def hash_options(context)
218
+ opts = context.options[:options]
219
+ return nil unless opts.is_a?(Hash)
220
+
221
+ keyed = opts.slice(*KEYED_OPTIONS)
222
+ tools = Array(opts[:tools]).map { |t| t.respond_to?(:name) ? t.name : t.class.name }
223
+ keyed[:tools] = tools if tools.any?
224
+ return nil if keyed.empty?
225
+
226
+ hash_input(keyed)
227
+ end
228
+
141
229
  # Hashes the input for cache key
142
230
  #
143
231
  # @param input [Object] The input to hash
@@ -388,6 +388,7 @@ module RubyLLM
388
388
  cache_hit: context.cached?,
389
389
  input_tokens: context.input_tokens || 0,
390
390
  output_tokens: context.output_tokens || 0,
391
+ cached_tokens: context.cached_tokens || 0,
391
392
  input_cost: context.input_cost,
392
393
  output_cost: context.output_cost,
393
394
  total_cost: context.total_cost || 0,
@@ -412,6 +413,7 @@ module RubyLLM
412
413
  if context.cached? && context[:cache_key]
413
414
  merged_metadata["response_cache_key"] = context[:cache_key]
414
415
  end
416
+ merged_metadata = redact_secrets(merged_metadata)
415
417
  data[:metadata] = merged_metadata if merged_metadata.any?
416
418
 
417
419
  # Error class on execution (error_message goes to detail)
@@ -457,6 +459,10 @@ module RubyLLM
457
459
  detail_data[:attempts] = context[:reliability_attempts]
458
460
  end
459
461
 
462
+ if context.cache_creation_tokens.to_i.positive?
463
+ detail_data[:cache_creation_tokens] = context.cache_creation_tokens
464
+ end
465
+
460
466
  if global_config.persist_responses && context.output.respond_to?(:content)
461
467
  detail_data[:response] = serialize_response(context)
462
468
  end
@@ -642,6 +648,30 @@ module RubyLLM
642
648
  access_token refresh_token private_key secret_key
643
649
  ].freeze
644
650
 
651
+ # Credential-shaped metadata keys. The execution's metadata column is
652
+ # rendered verbatim (with a copy button) on the execution page, so
653
+ # anything secret-looking is redacted before it is written.
654
+ #
655
+ # Deliberately word-bounded on `token` so the legitimate token
656
+ # counters — cached_tokens, token_count, thinking_tokens — survive.
657
+ SECRET_METADATA_PATTERN = /
658
+ api_?keys? | secret | password | credential |
659
+ private_key | access_token | refresh_token | auth_token | \btokens?\b
660
+ /xi
661
+
662
+ # Redacts credential-shaped entries from metadata before persistence.
663
+ #
664
+ # @param metadata [Hash] Metadata about to be written
665
+ # @return [Hash] Metadata with secret-shaped values replaced
666
+ def redact_secrets(metadata)
667
+ metadata.to_h do |key, value|
668
+ [key, key.to_s.match?(SECRET_METADATA_PATTERN) ? "[REDACTED]" : value]
669
+ end
670
+ rescue => e
671
+ debug("Failed to redact metadata: #{e.message}")
672
+ metadata
673
+ end
674
+
645
675
  # Internal keys that should be stripped from persisted parameters
646
676
  INTERNAL_KEYS = %w[
647
677
  _replay_source_id _ask_message _parent_execution_id _root_execution_id
@@ -143,6 +143,22 @@ module RubyLLM
143
143
  # Store attempts even on total failure
144
144
  context[:reliability_attempts] = tracker.to_json_array
145
145
 
146
+ # Nothing was ever attempted — every model was short-circuited by an
147
+ # open breaker. That is a distinct condition from "we tried and they
148
+ # all failed", and it has a distinct error class; raising
149
+ # AllModelsExhaustedError here handed back a nil last_error and left
150
+ # CircuitBreakerOpenError impossible to rescue.
151
+ if context.attempts_made.zero?
152
+ emit_reliability_notification(
153
+ "ruby_llm_agents.reliability.circuit_open",
154
+ models_tried: models_to_try
155
+ )
156
+
157
+ raise Agents::Reliability::CircuitBreakerOpenError.new(
158
+ @agent_class&.name, models_to_try.join(", ")
159
+ )
160
+ end
161
+
146
162
  # All models exhausted
147
163
  emit_reliability_notification(
148
164
  "ruby_llm_agents.reliability.all_models_exhausted",
@@ -183,6 +199,13 @@ module RubyLLM
183
199
  original_model = context.model
184
200
  context.model = model
185
201
 
202
+ # Clear any error left by a previous attempt. Context#success?
203
+ # requires #error to be nil, and every middleware outside this
204
+ # one keys off it — a stale error made a recovered retry look
205
+ # like a failure, so Budget skipped recording the spend and
206
+ # Cache skipped writing the result.
207
+ context.error = nil
208
+
186
209
  @app.call(context)
187
210
 
188
211
  # Success - record in circuit breaker and tracker
@@ -238,9 +261,11 @@ module RubyLLM
238
261
  def should_retry?(error, config, attempt_index, max_retries, total_deadline)
239
262
  return false if attempt_index >= max_retries
240
263
  return false if total_deadline && Time.current > total_deadline
241
- # Don't retry if fallback models are available — move to next model instead
242
- return false if fallback_models?(config)
243
264
 
265
+ # Configuring fallbacks does NOT cancel the configured retries: each
266
+ # model exhausts its own retries before the next one is tried. A
267
+ # transient 429 on the primary should not immediately burn the
268
+ # fallback. `total_timeout` is the bound on total attempts.
244
269
  retryable_error?(error, config)
245
270
  end
246
271
 
@@ -254,15 +279,6 @@ module RubyLLM
254
279
  Agents::Reliability.non_fallback_error?(error, custom_errors: custom_errors)
255
280
  end
256
281
 
257
- # Returns whether fallback models are configured
258
- #
259
- # @param config [Hash] The reliability configuration
260
- # @return [Boolean]
261
- def fallback_models?(config)
262
- fallbacks = config[:fallback_models]
263
- fallbacks.is_a?(Array) && fallbacks.any?
264
- end
265
-
266
282
  # Checks if an error is retryable
267
283
  #
268
284
  # @param error [Exception] The error to check
@@ -199,6 +199,10 @@ module RubyLLM
199
199
  # thread-safe), keys are stored on the context. The Pipeline::Context#llm
200
200
  # method creates a scoped RubyLLM::Context with these keys when needed.
201
201
  #
202
+ # Written to the dedicated accessor, never to context[...]: the
203
+ # metadata bag is persisted onto the execution record and rendered by
204
+ # the dashboard, so a credential placed there leaks.
205
+ #
202
206
  # @param context [Context] The execution context
203
207
  def apply_tenant_object_api_keys!(context)
204
208
  tenant_object = context.tenant_object
@@ -207,7 +211,7 @@ module RubyLLM
207
211
  api_keys = tenant_object.llm_api_keys
208
212
  return if api_keys.blank?
209
213
 
210
- context[:tenant_api_keys] = api_keys
214
+ context.tenant_api_keys = api_keys
211
215
  rescue => e
212
216
  # Log but don't fail if API key extraction fails
213
217
  warn_api_key_error("tenant object", e)
@@ -20,6 +20,7 @@ module RubyLLM
20
20
  # @api public
21
21
  class Result
22
22
  extend ActiveSupport::Delegation
23
+ include Trackable
23
24
 
24
25
  # @!attribute [r] content
25
26
  # @return [Hash, String] The processed response content
@@ -175,6 +176,9 @@ module RubyLLM
175
176
  # Execution record
176
177
  @execution_id = options[:execution_id]
177
178
 
179
+ # Caching
180
+ @cached_at = options[:cached_at]
181
+
178
182
  # Tracking
179
183
  @agent_class_name = options[:agent_class_name]
180
184
 
@@ -184,9 +188,10 @@ module RubyLLM
184
188
  # Debug trace
185
189
  @trace = options[:trace]
186
190
 
187
- # Register with active tracker
188
- tracker = Thread.current[:ruby_llm_agents_tracker]
189
- tracker << self if tracker
191
+ # Wrapper results (RoutingResult) opt out: the results they wrap have
192
+ # already registered, and registering the wrapper too would report its
193
+ # combined cost on top of its own parts.
194
+ register_with_tracker unless options[:register_with_tracker] == false
190
195
  end
191
196
 
192
197
  # Loads the associated Execution record from the database
@@ -272,6 +277,7 @@ module RubyLLM
272
277
  # @return [Hash] All result data as a hash
273
278
  def to_h
274
279
  {
280
+ cached_at: cached_at,
275
281
  content: content,
276
282
  input_tokens: input_tokens,
277
283
  output_tokens: output_tokens,
@@ -2,15 +2,54 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Agents
5
- # Mixin that registers a result object with the active Tracker.
5
+ # Shared result lifecycle: registering with the active Tracker, and marking
6
+ # a result that was replayed from the response cache.
6
7
  #
7
- # Included in every result class so that RubyLLM::Agents.track
8
- # can collect results automatically.
8
+ # Both concerns fire at the same moment — when a result is handed back to
9
+ # the caller and both must apply to every result type, since any agent
10
+ # with `cache_for` can have its result served from cache.
9
11
  #
10
12
  # @api private
11
13
  module Trackable
12
14
  def self.included(base)
13
15
  base.attr_reader :agent_class_name unless base.method_defined?(:agent_class_name)
16
+ base.attr_reader :cached_at unless base.method_defined?(:cached_at)
17
+ end
18
+
19
+ # Whether this response was replayed from the response cache rather than
20
+ # generated by a fresh LLM call.
21
+ #
22
+ # The token and cost fields describe the call that ORIGINALLY produced the
23
+ # response — no API call was made this time, and the execution record for
24
+ # this call is written with zero cost. Aggregate spend from the execution
25
+ # records, or exclude cached results, rather than summing #total_cost
26
+ # across every result. RubyLLM::Agents.track does this for you.
27
+ #
28
+ # @return [Boolean]
29
+ def cached?
30
+ !@cached_at.nil?
31
+ end
32
+
33
+ # Returns a copy of this result marked as served from the response cache.
34
+ #
35
+ # Used by the Cache middleware on a hit. Two things the raw cached object
36
+ # gets wrong on its own:
37
+ #
38
+ # - execution_id still points at the ORIGINAL call's row, so the result
39
+ # cannot be correlated with the execution written for this call.
40
+ # - the copy never passes through #initialize (it is deserialized, then
41
+ # duped), so it would never register with an active Tracker — making
42
+ # TrackReport#call_count silently under-report.
43
+ #
44
+ # @param execution_id [Integer, nil] The current call's execution record id
45
+ # @param cached_at [Time] When the cached response was first produced
46
+ # @return [Object] A marked copy of this result
47
+ def as_cache_hit(execution_id:, cached_at:)
48
+ dup.tap do |copy|
49
+ copy.instance_variable_set(:@cached_at, cached_at)
50
+ copy.instance_variable_set(:@execution_id, execution_id)
51
+ copy.send(:register_with_tracker)
52
+ end
14
53
  end
15
54
 
16
55
  private
@@ -47,13 +47,14 @@ module RubyLLM
47
47
  def initialize(base_result:, route_data:)
48
48
  @delegated_result = route_data[:delegated_result]
49
49
  @routing_cost = base_result.total_cost
50
+ @routing_tokens = base_result.total_tokens
50
51
 
51
- # When delegated, merge costs from both classification and delegation
52
- total = if @delegated_result
53
- (base_result.total_cost || 0) + (@delegated_result.respond_to?(:total_cost) ? @delegated_result.total_cost || 0 : 0)
54
- else
55
- base_result.total_cost
56
- end
52
+ # A routed call is two LLM calls: classify, then delegate. Every
53
+ # figure on this result covers BOTH, so they stay consistent with each
54
+ # other total_cost used to include the delegation while the token
55
+ # counts did not, which made cost-per-token nonsense. Classification
56
+ # alone is still available via #routing_cost / #routing_tokens.
57
+ total = combine(base_result, :total_cost)
57
58
 
58
59
  # Use delegated content when available
59
60
  effective_content = if @delegated_result
@@ -64,11 +65,15 @@ module RubyLLM
64
65
 
65
66
  super(
66
67
  content: effective_content,
67
- input_tokens: base_result.input_tokens,
68
- output_tokens: base_result.output_tokens,
69
- input_cost: base_result.input_cost,
70
- output_cost: base_result.output_cost,
68
+ input_tokens: combine(base_result, :input_tokens),
69
+ output_tokens: combine(base_result, :output_tokens),
70
+ input_cost: combine(base_result, :input_cost),
71
+ output_cost: combine(base_result, :output_cost),
71
72
  total_cost: total,
73
+ # The wrapped results already registered with the active Tracker;
74
+ # registering this wrapper too reported the combined figures on top
75
+ # of the parts, doubling the cost of every routed call.
76
+ register_with_tracker: false,
72
77
  model_id: base_result.model_id,
73
78
  chosen_model_id: base_result.chosen_model_id,
74
79
  temperature: base_result.temperature,
@@ -108,6 +113,13 @@ module RubyLLM
108
113
  @routing_cost || 0
109
114
  end
110
115
 
116
+ # Tokens used by the classification step only (excluding delegation)
117
+ #
118
+ # @return [Integer]
119
+ def routing_tokens
120
+ @routing_tokens || 0
121
+ end
122
+
111
123
  # Converts the result to a hash including routing fields.
112
124
  #
113
125
  # @return [Hash] All result data plus route, agent_class, raw_response
@@ -119,6 +131,20 @@ module RubyLLM
119
131
  delegated: delegated?
120
132
  )
121
133
  end
134
+
135
+ private
136
+
137
+ # Sums a numeric field across the classification and the delegated call.
138
+ #
139
+ # @param base_result [Result] The classification result
140
+ # @param field [Symbol] The field to read from both
141
+ # @return [Numeric, nil] The combined value
142
+ def combine(base_result, field)
143
+ base = base_result.public_send(field)
144
+ return base unless @delegated_result&.respond_to?(field)
145
+
146
+ (base || 0) + (@delegated_result.public_send(field) || 0)
147
+ end
122
148
  end
123
149
  end
124
150
  end
@@ -37,32 +37,63 @@ module RubyLLM
37
37
  !successful?
38
38
  end
39
39
 
40
+ # Every agent call made in the block, including ones served from cache.
40
41
  def call_count
41
42
  @results.size
42
43
  end
43
44
 
45
+ # Calls served from the response cache — no API call, no spend.
46
+ def cached_count
47
+ cached_results.size
48
+ end
49
+
50
+ # Calls that actually hit a provider.
51
+ def billable_count
52
+ billable_results.size
53
+ end
54
+
55
+ # Costs and token counts cover only the calls that actually hit a
56
+ # provider. A cached result replays the ORIGINAL call's figures, so
57
+ # including them would report spend that was never incurred — the
58
+ # execution records for cache hits are written with zero cost, and these
59
+ # totals reconcile with them.
44
60
  def total_cost
45
- @results.sum { |r| r.total_cost || 0 }
61
+ billable_results.sum { |r| r.total_cost || 0 }
46
62
  end
47
63
 
48
64
  def input_cost
49
- @results.sum { |r| r.input_cost || 0 }
65
+ billable_results.sum { |r| r.input_cost || 0 }
50
66
  end
51
67
 
52
68
  def output_cost
53
- @results.sum { |r| r.output_cost || 0 }
69
+ billable_results.sum { |r| r.output_cost || 0 }
54
70
  end
55
71
 
56
72
  def total_tokens
57
- @results.sum { |r| r.total_tokens }
73
+ billable_results.sum { |r| r.total_tokens || 0 }
58
74
  end
59
75
 
60
76
  def input_tokens
61
- @results.sum { |r| r.input_tokens || 0 }
77
+ billable_results.sum { |r| r.input_tokens || 0 }
62
78
  end
63
79
 
64
80
  def output_tokens
65
- @results.sum { |r| r.output_tokens || 0 }
81
+ billable_results.sum { |r| r.output_tokens || 0 }
82
+ end
83
+
84
+ # What the cached calls would have cost at the original calls' prices.
85
+ def cache_savings
86
+ cached_results.sum { |r| r.total_cost || 0 }
87
+ end
88
+
89
+ # Results that hit a provider (everything except cache replays).
90
+ def billable_results
91
+ @billable_results ||= @results.reject { |r| cached_result?(r) }
92
+ end
93
+
94
+ # Results replayed from the response cache.
95
+ def cached_results
96
+ @cached_results ||= @results.select { |r| cached_result?(r) }
66
97
  end
67
98
 
68
99
  def duration_ms
@@ -95,8 +126,9 @@ module RubyLLM
95
126
  {
96
127
  agent: r.respond_to?(:agent_class_name) ? r.agent_class_name : nil,
97
128
  model: r.chosen_model_id,
98
- cost: r.total_cost || 0,
99
- tokens: r.total_tokens,
129
+ cost: cached_result?(r) ? 0 : (r.total_cost || 0),
130
+ tokens: cached_result?(r) ? 0 : (r.total_tokens || 0),
131
+ cached: cached_result?(r),
100
132
  duration_ms: r.duration_ms
101
133
  }
102
134
  end
@@ -109,7 +141,10 @@ module RubyLLM
109
141
  error: error&.message,
110
142
  request_id: request_id,
111
143
  call_count: call_count,
144
+ cached_count: cached_count,
145
+ billable_count: billable_count,
112
146
  total_cost: total_cost,
147
+ cache_savings: cache_savings,
113
148
  input_cost: input_cost,
114
149
  output_cost: output_cost,
115
150
  total_tokens: total_tokens,
@@ -122,6 +157,13 @@ module RubyLLM
122
157
  cost_breakdown: cost_breakdown
123
158
  }
124
159
  end
160
+
161
+ private
162
+
163
+ # Older result types may predate #cached?; treat those as billable.
164
+ def cached_result?(result)
165
+ result.respond_to?(:cached?) && result.cached?
166
+ end
125
167
  end
126
168
  end
127
169
  end
@@ -14,6 +14,41 @@ namespace :ruby_llm_agents do
14
14
  puts "Hard purged: #{result[:hard_purged]} executions (rows destroyed)"
15
15
  end
16
16
 
17
+ desc "Redact credential-shaped keys from historical execution metadata. Usage: rake ruby_llm_agents:redact_metadata_secrets [DRY_RUN=1]"
18
+ task redact_metadata_secrets: :environment do
19
+ # Versions before the tenant-API-key fix wrote per-tenant provider keys into
20
+ # executions.metadata in plaintext, and the execution page renders that
21
+ # column verbatim. New rows are safe; this scrubs the ones already written.
22
+ #
23
+ # Rotate the affected keys as well — redacting the copy in the database does
24
+ # not un-expose a key that was already displayed.
25
+ dry_run = ENV["DRY_RUN"].present?
26
+ pattern = RubyLLM::Agents::Pipeline::Middleware::Instrumentation::SECRET_METADATA_PATTERN
27
+ scanned = 0
28
+ redacted = 0
29
+
30
+ RubyLLM::Agents::Execution.where.not(metadata: nil).find_each do |execution|
31
+ scanned += 1
32
+ metadata = execution.metadata
33
+ next unless metadata.is_a?(Hash)
34
+
35
+ offending = metadata.keys.select { |key| key.to_s.match?(pattern) }
36
+ next if offending.empty?
37
+
38
+ redacted += 1
39
+ puts "execution ##{execution.id}: #{offending.join(", ")}"
40
+ next if dry_run
41
+
42
+ execution.update_column(
43
+ :metadata,
44
+ metadata.merge(offending.to_h { |key| [key, "[REDACTED]"] })
45
+ )
46
+ end
47
+
48
+ puts dry_run ? "DRY RUN — #{redacted} of #{scanned} executions would be redacted" : "Redacted #{redacted} of #{scanned} executions"
49
+ puts "Rotate any exposed credential: redaction here does not un-expose it." if redacted.positive?
50
+ end
51
+
17
52
  desc "Rename an agent type in execution records. Usage: rake ruby_llm_agents:rename_agent FROM=OldName TO=NewName [DRY_RUN=1]"
18
53
  task rename_agent: :environment do
19
54
  from = ENV["FROM"]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.14.1
4
+ version: 3.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90