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 +4 -4
- data/app/models/ruby_llm/agents/execution/analytics.rb +23 -6
- data/app/models/ruby_llm/agents/execution.rb +36 -2
- data/lib/ruby_llm/agents/audio/speech_pricing.rb +3 -0
- data/lib/ruby_llm/agents/base_agent.rb +28 -67
- data/lib/ruby_llm/agents/core/configuration.rb +16 -4
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/dsl/reliability.rb +31 -5
- data/lib/ruby_llm/agents/pipeline/builder.rb +8 -17
- data/lib/ruby_llm/agents/pipeline/context.rb +11 -1
- data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +90 -2
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +30 -0
- data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +27 -11
- data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +5 -1
- data/lib/ruby_llm/agents/results/base.rb +9 -3
- data/lib/ruby_llm/agents/results/trackable.rb +42 -3
- data/lib/ruby_llm/agents/routing/result.rb +36 -10
- data/lib/ruby_llm/agents/track_report.rb +50 -8
- data/lib/tasks/ruby_llm_agents.rake +35 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1d76f68609f8511674712633294484523bcedebd79b729796e3bee5504e2ed4c
|
|
4
|
+
data.tar.gz: 84ff7562baa359759a0d64a23030fc537addcb5860e0d823cb3a500f62c07a6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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("
|
|
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:
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
1025
|
-
# full one.
|
|
1029
|
+
# Charge for tokens served from the provider's prompt cache.
|
|
1026
1030
|
#
|
|
1027
|
-
#
|
|
1028
|
-
#
|
|
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
|
-
#
|
|
1031
|
-
#
|
|
1032
|
-
#
|
|
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
|
-
#
|
|
1035
|
-
#
|
|
1036
|
-
#
|
|
1037
|
-
#
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
1130
|
-
#
|
|
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
|
|
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
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
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 =
|
|
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",
|
|
@@ -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
|
|
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
|
|
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
|
|
191
|
-
@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 =
|
|
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
|
-
#
|
|
232
|
-
#
|
|
233
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
#
|
|
5
|
+
# Shared result lifecycle: registering with the active Tracker, and marking
|
|
6
|
+
# a result that was replayed from the response cache.
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
68
|
-
output_tokens: base_result
|
|
69
|
-
input_cost: base_result
|
|
70
|
-
output_cost: base_result
|
|
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
|
-
|
|
61
|
+
billable_results.sum { |r| r.total_cost || 0 }
|
|
46
62
|
end
|
|
47
63
|
|
|
48
64
|
def input_cost
|
|
49
|
-
|
|
65
|
+
billable_results.sum { |r| r.input_cost || 0 }
|
|
50
66
|
end
|
|
51
67
|
|
|
52
68
|
def output_cost
|
|
53
|
-
|
|
69
|
+
billable_results.sum { |r| r.output_cost || 0 }
|
|
54
70
|
end
|
|
55
71
|
|
|
56
72
|
def total_tokens
|
|
57
|
-
|
|
73
|
+
billable_results.sum { |r| r.total_tokens || 0 }
|
|
58
74
|
end
|
|
59
75
|
|
|
60
76
|
def input_tokens
|
|
61
|
-
|
|
77
|
+
billable_results.sum { |r| r.input_tokens || 0 }
|
|
62
78
|
end
|
|
63
79
|
|
|
64
80
|
def output_tokens
|
|
65
|
-
|
|
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"]
|