ruby_llm-agents 3.14.0 → 3.14.1

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: d974a549e2d99bbcd16c8345547d16c16aff7ae602da503974d9db1f32be8ce6
4
- data.tar.gz: 9947da35f39521706e7c7bef349dcf9f6a63f5f732409ff1774ce0607bd6429e
3
+ metadata.gz: 4734a9b91b3dc228b78661cdbe4e336b9053beb6440601482709f99745c6e918
4
+ data.tar.gz: de29b3452e781a363c3330137323364edca7e80afd55680f200161c464d3f027
5
5
  SHA512:
6
- metadata.gz: 8aa785d541d2e00d237a71d5a652643e7e2da83750f8264d754c00fce6c2e33fc7d6d47caa75c1ac94659fd05703a9c488a229064cd1d01157b6845e4ef3970e
7
- data.tar.gz: 89bf72286b0a6e70418268457beea87df58bb16514a170730518adeadae84eac6d6ff6553e9726e481b00c2358ad9d909c9f20170bcf41224eb7efdaf3d7f1ac
6
+ metadata.gz: 180db730cb98f426ce7e774cad42ad146b33cbb09b74340b772a2306cc0249152adbee79da9de71d372eb5f925ebf894db8305ef9df2ca58c60104280fec03e3
7
+ data.tar.gz: 2b2183165d6e2271ffda0b673ed173abde8cd77d8adc6e85b168de24b9744bc6c51ebe2194fcc45ad7d3e3fb35b82603f7830260927b2fabe763c552b93691cd
@@ -934,11 +934,18 @@ module RubyLLM
934
934
  context.output_tokens = metadata.output_tokens if metadata.respond_to?(:output_tokens)
935
935
  context.model_used = (metadata.respond_to?(:model_id) && metadata.model_id) || model
936
936
 
937
- # Capture Anthropic prompt caching metrics
937
+ # Prompt-cache metrics. Every provider that reports them lands here —
938
+ # not just Anthropic: OpenAI caches automatically with no opt-in, so
939
+ # these are populated on ordinary calls with a long stable prefix.
940
+ # Kept on the context's own accessors (and mirrored into metadata for
941
+ # backward compatibility) because #build_result and #calculate_costs
942
+ # both read them back.
938
943
  if metadata.respond_to?(:cached_tokens) && metadata.cached_tokens&.positive?
944
+ context.cached_tokens = metadata.cached_tokens
939
945
  context[:cached_tokens] = metadata.cached_tokens
940
946
  end
941
947
  if metadata.respond_to?(:cache_creation_tokens) && metadata.cache_creation_tokens&.positive?
948
+ context.cache_creation_tokens = metadata.cache_creation_tokens
942
949
  context[:cache_creation_tokens] = metadata.cache_creation_tokens
943
950
  end
944
951
  else
@@ -1001,7 +1008,7 @@ module RubyLLM
1001
1008
  input_price = model_info.pricing&.text_tokens&.input || 0
1002
1009
  output_price = model_info.pricing&.text_tokens&.output || 0
1003
1010
 
1004
- context.input_cost = (input_tokens / 1_000_000.0) * input_price
1011
+ context.input_cost = input_cost_for(input_tokens, input_price, model_info, context)
1005
1012
 
1006
1013
  # Price cache/reasoning extras first so we know whether reasoning was
1007
1014
  # actually billed at the reasoning rate. Only then exclude those tokens
@@ -1014,6 +1021,75 @@ module RubyLLM
1014
1021
  context.total_cost = (context.input_cost + context.output_cost + extra).round(6)
1015
1022
  end
1016
1023
 
1024
+ # Input charge, with cache reads billed at the cached rate rather than the
1025
+ # full one.
1026
+ #
1027
+ # Providers disagree on whether the reported input_tokens ALREADY contains
1028
+ # the cache reads, and the difference decides who has a bug:
1029
+ #
1030
+ # OpenAI prompt_tokens INCLUDES prompt_tokens_details.cached_tokens
1031
+ # Gemini promptTokenCount INCLUDES cachedContentTokenCount
1032
+ # Anthropic input_tokens EXCLUDES cache_read_input_tokens
1033
+ #
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
1051
+
1052
+ 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
1066
+
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")
1091
+ end
1092
+
1017
1093
  # Number of reasoning (thinking) tokens that were actually charged at the
1018
1094
  # reasoning rate, recorded in the cost breakdown by +extra_token_costs+.
1019
1095
  #
@@ -1050,8 +1126,12 @@ module RubyLLM
1050
1126
  cost = response_cost(response, model_info)
1051
1127
  return 0.0 unless cost
1052
1128
 
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.
1053
1133
  components = {
1054
- cache_read: cost.cache_read,
1134
+ cache_read: (cost.cache_read unless context[:cache_read_priced]),
1055
1135
  cache_write: cost.cache_write,
1056
1136
  thinking: cost.thinking
1057
1137
  }.compact.reject { |_, value| value.zero? }
@@ -1156,6 +1236,8 @@ module RubyLLM
1156
1236
  agent_class_name: self.class.name,
1157
1237
  input_tokens: context.input_tokens,
1158
1238
  output_tokens: context.output_tokens,
1239
+ cached_tokens: context.cached_tokens,
1240
+ cache_creation_tokens: context.cache_creation_tokens,
1159
1241
  input_cost: context.input_cost,
1160
1242
  output_cost: context.output_cost,
1161
1243
  total_cost: context.total_cost,
@@ -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.0"
7
+ VERSION = "3.14.1"
8
8
  end
9
9
  end
@@ -43,6 +43,11 @@ module RubyLLM
43
43
  # Cost tracking
44
44
  attr_accessor :input_tokens, :output_tokens, :input_cost, :output_cost, :total_cost
45
45
 
46
+ # Prompt-cache usage. First-class accessors, not metadata keys: these
47
+ # feed both the persisted execution record and the input-cost split, so
48
+ # a generic bag entry would be silently droppable (it was).
49
+ attr_accessor :cached_tokens, :cache_creation_tokens
50
+
46
51
  # Response metadata
47
52
  attr_accessor :model_used, :finish_reason, :time_to_first_token_ms
48
53
 
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.0
4
+ version: 3.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90