llm_cost_tracker 0.15.1 → 0.15.3

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: 5c832e2df79470701d964d7c28c67122c60b5e51f8d81aadfe09f396601b14ef
4
- data.tar.gz: c26760c25db1e3ff3bf90f89a7141c72b83ad7ef5b9acbc76c19ba5468f65b6f
3
+ metadata.gz: 2fcb73e485dc1b20fed09f36b2095911d5268e16e633645329136acbe5ef3d3f
4
+ data.tar.gz: 4d3622d57bd89110efa3c824fcf87bc5b5a7f785ee371b7bc42a8351219f8df5
5
5
  SHA512:
6
- metadata.gz: cd7c26e49a779e97a3f054a41d09aa2a114104ede4ebe4211f08adb5941ecd8f5dcaa325af7ff4aa1b17ca582a2422f50566a6aa8eb691b7cffe2a91e1c79f28
7
- data.tar.gz: 4236c7bbdf92ae68e8ce3f33763795211bb5e97bdf2c7884d18dee6bed7c640abd7796c506a0ce8406b78ecf15b7eaa9c26fa8b02d7a11c8da9c76bfd27e6e38
6
+ metadata.gz: 0ac48c68f138cfe1c7707b7ed1576c8c033a0ccbe0930f500bcc57d0c458c41656889095ec20cfc66b9913c1468fedfae37fac959f5696de678779d6e09fe1cd
7
+ data.tar.gz: e9fc0c48df5d63382e2c98f150ae2ed25130004a80514d540a1e83a06b9b2cbbf838f4ca29ef931609d0d7cf3c95f24252b8be0bf50f03945a99bae95611d4e8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [SemVer](https://semver.org/spec/v2.0.0.html).
4
4
 
5
+ ## [0.15.3] - 2026-11-11
6
+
7
+ ### Changed
8
+
9
+ - Bundled prices add Claude Fable 5.2 and Mythos 5.2, Gemini 4 Carbon, Gemini Omni 1.2 Flash, GPT-6-Cyber, GPT-6 Rosalind and GPT-Image-2.6, and follow Mistral's Large 4 price cut.
10
+
11
+ ### Fixed
12
+
13
+ - openai gem 0.115+ `connection.response.create` on a Responses WebSocket keeps its tags and `:block_requests` budget check.
14
+ - RubyLLM 2.2: an attempt the provider answered with a 5xx before any output is recorded again, with unknown cost.
15
+ - RubyLLM 2.2: transcription without usage is priced by its duration again, and speech without usage is no longer recorded as free.
16
+ - RubyLLM 2.2: Claude on Bedrock, now called through InvokeModel, keeps its inference profile, data residency and service tier.
17
+
18
+ ## [0.15.2] - 2026-10-29
19
+
20
+ ### Changed
21
+
22
+ - Bundled prices add OpenAI GPT-6.1 Terra.
23
+
24
+ ### Fixed
25
+
26
+ - OpenAI Decisions API calls through the official openai gem and Faraday are recorded.
27
+ - OpenAI Decisions output tokens are not billed, RubyLLM 2.x judgments included, as OpenAI prices them.
28
+
5
29
  ## [0.15.1] - 2026-10-26
6
30
 
7
31
  ### Changed
@@ -71,6 +71,12 @@ module LlmCostTracker
71
71
  end
72
72
  end
73
73
 
74
+ module ResponsesConnectionCreatePatch
75
+ def send_response_create(event)
76
+ llm_cost_tracker_capture.sending(event) { super }
77
+ end
78
+ end
79
+
74
80
  module ChatCompletionsPatch
75
81
  include PatchBuilder.build(record_method: :record_response, methods: %i[create])
76
82
  include PatchBuilder.build_stream(methods: %i[stream stream_raw])
@@ -31,6 +31,11 @@ module LlmCostTracker
31
31
  with: ResponsesConnectionPatch,
32
32
  optional: true,
33
33
  since: "0.86.0"),
34
+ patch_target("OpenAI::Responses::Connection",
35
+ with: ResponsesConnectionCreatePatch,
36
+ optional: true,
37
+ skip_when_methods_missing: true,
38
+ since: "0.86.0"),
34
39
  patch_target("OpenAI::Resources::Chat::Completions", with: ChatCompletionsPatch),
35
40
  patch_target("OpenAI::Resources::Completions", with: CreatePatch, optional: true),
36
41
  patch_target("OpenAI::Resources::Embeddings", with: CreatePatch, optional: true),
@@ -47,6 +52,7 @@ module LlmCostTracker
47
52
  patch_target("OpenAI::Resources::Audio::Translations", with: TranslationsPatch, optional: true),
48
53
  patch_target("OpenAI::Resources::Audio::Speech", with: SpeechPatch, optional: true),
49
54
  patch_target("OpenAI::Resources::Moderations", with: ModerationsPatch, optional: true),
55
+ patch_target("OpenAI::Resources::Decisions", with: CreatePatch, optional: true, since: "0.101.0"),
50
56
  patch_target("OpenAI::Resources::Batches", with: BatchesPatch, optional: true)
51
57
  ]
52
58
  end
@@ -7,6 +7,7 @@ module LlmCostTracker
7
7
  class Attempt
8
8
  class TokenCounts
9
9
  REFUSED = { input_tokens: 0, output_tokens: 0 }.freeze
10
+ NON_TOKEN_COUNTS = %i[characters audio_seconds search_units].freeze
10
11
 
11
12
  def initialize(usage, payload, raw:, request:)
12
13
  @usage = usage
@@ -62,7 +63,7 @@ module LlmCostTracker
62
63
 
63
64
  private
64
65
 
65
- def known? = @tokens.to_h.any? || !@tokens.reported_cost.nil?
66
+ def known? = @tokens.to_h.except(*NON_TOKEN_COUNTS).any? || !@tokens.reported_cost.nil?
66
67
 
67
68
  def usage_source(known, result)
68
69
  return Usage::Source::SDK_RESPONSE if known
@@ -8,6 +8,8 @@ module LlmCostTracker
8
8
  module RubyLlm
9
9
  module V2
10
10
  class Attempt
11
+ BEDROCK_CLAUDE = /(?:\A|\.)anthropic\./
12
+
11
13
  attr_reader :usage, :latency_ms
12
14
 
13
15
  class << self
@@ -98,9 +100,9 @@ module LlmCostTracker
98
100
  return unless @events
99
101
 
100
102
  context = raw_context(raw, request).merge(response_status: 200, events: @events)
101
- Attempt.stream_parsers.lazy.filter_map { |parser| parser.parse_stream(**context) }
102
- .find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN }
103
- &.with(provider: usage[:provider], usage_source: Usage::Source::SDK_RESPONSE)
103
+ event = Attempt.stream_parsers.lazy.filter_map { |parser| parser.parse_stream(**context) }
104
+ .find { |parsed| parsed.usage_source != Usage::Source::UNKNOWN }
105
+ sdk_event(event, usage, raw)
104
106
  end
105
107
 
106
108
  def parsed_event(usage, own, request)
@@ -108,8 +110,16 @@ module LlmCostTracker
108
110
  return unless body.is_a?(Hash)
109
111
 
110
112
  response = raw_context(own, request).merge(response_status: own.status, response_body: body)
111
- event = body_event(usage, own, request, response)
112
- event&.with(provider: usage[:provider], usage_source: Usage::Source::SDK_RESPONSE)
113
+ sdk_event(body_event(usage, own, request, response), usage, own)
114
+ end
115
+
116
+ def sdk_event(event, usage, raw)
117
+ event = event&.with(provider: usage[:provider], usage_source: Usage::Source::SDK_RESPONSE)
118
+ return event unless event && usage[:provider] == "bedrock" && usage[:model].to_s.match?(BEDROCK_CLAUDE)
119
+
120
+ request = { model: usage[:model], service_tier: raw.try(:headers).try(:[], "x-amzn-bedrock-service-tier") }
121
+ event.with(model: usage[:model],
122
+ pricing_mode: Providers::Anthropic::UsageExtractor.pricing_mode(request: request, usage: nil))
113
123
  end
114
124
 
115
125
  def body_event(usage, own, request, response)
@@ -36,6 +36,8 @@ module LlmCostTracker
36
36
  end
37
37
 
38
38
  def observe(seam, value, provider)
39
+ return @error_status = value.try(:status) if seam == :parse_error
40
+
39
41
  @provider = provider
40
42
  case seam
41
43
  when :parse_completion_body then @raw = value unless value.is_a?(Hash)
@@ -55,6 +57,8 @@ module LlmCostTracker
55
57
  end
56
58
 
57
59
  def add_attempt(usage)
60
+ usage = usage.merge(tokens: RubyLLM::Tokens.new) if maybe_billed?(usage)
61
+ @error_status = nil
58
62
  raw = @raw unless usage[:status] == :succeeded && usage[:tokens].to_h.empty?
59
63
  @reported << [usage, { latency_ms: @latency_ms, events: @window&.events, raw: raw }]
60
64
  @window = nil
@@ -68,6 +72,13 @@ module LlmCostTracker
68
72
  Attempt.new(usage, payload, final: index == final, response: @response, provider: @provider, **reported)
69
73
  end
70
74
  end
75
+
76
+ private
77
+
78
+ def maybe_billed?(usage)
79
+ usage[:status] != :succeeded && (500..599).cover?(@error_status.to_i) &&
80
+ usage[:tokens].to_h == Attempt::TokenCounts::REFUSED
81
+ end
71
82
  end
72
83
  end
73
84
  end
@@ -40,6 +40,12 @@ module LlmCostTracker
40
40
  BatchBridge = Module.new do
41
41
  %i[messages results].each { |name| define_method(name) { V2.collect(self) { super() } } }
42
42
  end
43
+ ErrorBridge = Module.new do
44
+ def parse_error(*, **options, &)
45
+ V2.observe(:parse_error, options[:response])
46
+ super
47
+ end
48
+ end
43
49
  BRIDGES = (SEAMS.keys - %i[stream_transcription transcribe messages results current_workflow]).to_h do |seam|
44
50
  bridge = Module.new do
45
51
  define_method(seam) do |value, *args, **options, &block|
@@ -63,16 +69,26 @@ module LlmCostTracker
63
69
  owner.prepend(bridge) unless owner == bridge
64
70
  end
65
71
  end
72
+ middleware = error_middleware
73
+ return unless middleware
74
+
75
+ middleware.singleton_class.prepend(ErrorBridge)
66
76
  end
67
77
 
68
78
  def missing
69
- SEAMS.flat_map do |seam, targets|
79
+ seams = SEAMS.flat_map do |seam, targets|
70
80
  targets.reject { |target| owner(target, seam) }.map { |target| "RubyLLM::#{target}##{seam}" }
71
81
  end
82
+ error_middleware ? seams : [*seams, "RubyLLM::Transport::ErrorMiddleware.parse_error"]
72
83
  end
73
84
 
74
85
  private
75
86
 
87
+ def error_middleware
88
+ middleware = "RubyLLM::Transport::ErrorMiddleware".safe_constantize
89
+ middleware if middleware.respond_to?(:parse_error)
90
+ end
91
+
76
92
  def owner(target, seam)
77
93
  "RubyLLM::#{target}".safe_constantize&.instance_method(seam)&.owner
78
94
  rescue NameError