ruby_llm-contract 1.1.1 → 1.1.2

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: 390af91f8200f1098440872d22515fa6232888d13d21a149c0b11f0bf3c15d48
4
- data.tar.gz: d39d8a0c2e8914d08e4d996e6a3eb4d2c53613304cabcc6e077af2d9963ad4f5
3
+ metadata.gz: 7ae19c7459f4999409748c604e8b80aa756e5b2333863c8cc16b4070bf57dff9
4
+ data.tar.gz: 3be220b3be5c0c313f4b3f5160da5416dc9be8c35629c0012d756e0d027aa646
5
5
  SHA512:
6
- metadata.gz: 81e03a20720536700acfe5bc840720a84675ca738e248fbdb490dac1dc63479c93924923d16cb28433329b53d24b796a42332736d29ec6f4d42a41240dfd4367
7
- data.tar.gz: a433acb982c831c7ee1e2db5675135c4a42762d75c78e53708673b8ca50f5f165fe2cba9a088828b95738f24f29afb01e2033f694e92c69182662a6307492aa4
6
+ metadata.gz: 16bc0227706b8e1cc16f5d93c7771f82e7835c40c20888cec9163a9537d30f80a86f761c47fd10bd778df8f7023ce041bd0d574248950e11bdad5dd3ac4860da
7
+ data.tar.gz: 4d4374cc2d65aa555f30656c2475bae261b0af1be0656b7449c22c38467b7f1e32f7ba159d215adc17254d0c624f7144f91087644f578b5cb2727fc8f9087c91
data/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.2 (2026-10-10)
4
+
5
+ ### Changed - costs and gates may move
6
+
7
+ - **Costs now count the prompt cache, and may go up.** RubyLLM 2.x reports cached prompt
8
+ tokens apart from `tokens.input`, and the adapter read only input and output: 1,000
9
+ uncached + 9,000 cached + 200 output tokens on gpt-4.1-mini came out at $0.00072
10
+ instead of $0.00162. `trace[:usage][:input_tokens]` is now the whole prompt and
11
+ `trace[:cost]` is the cost RubyLLM puts on the response: the price of the provider the
12
+ call went to, cache and long-context prices, the amount the provider reported when it
13
+ reports one, and RubyLLM's own retries of the request. Eval totals, history and
14
+ `compare_models` figures can rise against older baselines; baselines compare scores,
15
+ not costs, so they do not regress on this.
16
+ - **A call without token counts is no longer free.** When the provider sends no usage
17
+ (for the call or for any of RubyLLM's attempts at it), the counts still read 0 but
18
+ `trace[:usage_complete]` is `false` and, unless the provider reported what it billed,
19
+ the cost is unknown, so the eval cost gates refuse (opt out with
20
+ `on_unknown_pricing: :warn`).
21
+ - **A missing price is unknown, not $0.** A model priced for input but not output has no
22
+ cost instead of a partial one, so `max_cost` refuses it pre-flight. A call that wrote
23
+ to the prompt cache on a model without a cache-write price has an unknown cost
24
+ afterwards (pre-flight estimates do not predict cache writes).
25
+
26
+ ### Fixed
27
+
28
+ - `CostCalculator` prices RubyLLM's models through `Model#cost_for`, so pre-flight
29
+ estimates use cache and long-context prices, and the price of the provider the call
30
+ goes to (12 model ids carry different prices per provider). `register_model` keeps
31
+ overriding the price for its id; cache reads are charged at its input price, cache
32
+ writes have no price.
33
+ - `compare_models` no longer names a model with unpriced cases the cheapest
34
+ (`best_for`), gives it an infinite `cost_per_point`, and `recommend` neither ranks it
35
+ on cost nor promises savings against it.
36
+ - `single_shot_cost` reported a later attempt's cost when the first attempt was unpriced.
37
+ - The step log printed an unknown cost as `$0.000000`; it now prints `cost=unknown`.
38
+ - Docs no longer say RubyLLM has no evaluation framework: `relation_to_agent.md` now
39
+ places `RubyLLM::Evaluation` (2.1) next to `define_eval`. The README and the getting
40
+ started guide describe what a trace's usage and cost count, and the README's 1.x
41
+ promise now says new trace keys may be added.
42
+
43
+ ### Added
44
+
45
+ - `trace[:usage]` breakdown keys when reported: `cache_read_tokens`, `cache_write_tokens`,
46
+ `thinking_tokens` (parts of `input_tokens` / `output_tokens`, not extra tokens).
47
+ - `trace[:finish_reason]`, also on each retry attempt. A failed result whose response
48
+ stopped at a token limit or a content filter gets a last validation error saying so,
49
+ with the `max_output` the call ran with. Statuses and retries are unchanged.
50
+ - An adapter may report its own cost: `Adapters::Response.new(content:, usage:, cost:,
51
+ cost_complete:, usage_complete:, finish_reason:)`, where `cost: nil` means unknown.
52
+ A response without `cost:` is priced from `usage` as before.
53
+ - `provider:` on `CostCalculator.calculate`, `CostCalculator.find_model`,
54
+ `estimate_cost` and `estimate_eval_cost`.
55
+ - Baselines and history mark unpriced cases and runs with `cost_unknown: true`.
56
+ - A pipeline with a `token_budget` warns when a step's provider left out token counts;
57
+ `Pipeline::Trace#usage_complete?`.
58
+
3
59
  ## 1.1.1 (2026-10-10)
4
60
 
5
61
  ### Fixed
data/README.md CHANGED
@@ -91,6 +91,8 @@ result.trace[:attempts]
91
91
  # ]
92
92
  ```
93
93
 
94
+ `usage[:input_tokens]` is the whole prompt, prompt-cache tokens included (broken out as `cache_read_tokens` / `cache_write_tokens` when the provider reports them), and `cost` is what RubyLLM prices the response at: the provider's price, cache and long-context rates, or the amount the provider reported. A cost the gem cannot fully price is marked unknown rather than counted as $0 - see [what a trace's usage and cost count](docs/guide/getting_started.md#what-a-traces-usage-and-cost-count).
95
+
94
96
  If the response is malformed, the TL;DR overflows the card, or the takeaway count is off, the gem moves to the next step. This is model **escalation**, not a fallback list — each step is an independent config (`model`, `reasoning_effort`), so the retry policy spends more compute only when the cheaper one couldn't satisfy the contract.
95
97
 
96
98
  ### Add a CI gate in 6 lines
@@ -128,6 +130,7 @@ Everything below is optional — the example above is a complete step. Reach for
128
130
  - **[Find the cheapest viable fallback list](docs/guide/optimizing_retry_policy.md)** — empirically pick the cheapest model chain that still passes your evals.
129
131
  - **[A/B test prompts](docs/guide/eval_first.md)** — measure whether a new prompt is safe to ship before merging.
130
132
  - **[Budget caps](docs/guide/getting_started.md)** — refuse the request pre-flight when an estimate exceeds the limit.
133
+ - **[Cost tracking](docs/guide/getting_started.md#what-a-traces-usage-and-cost-count)** - per-call cost with prompt-cache and per-provider prices; eval cost gates fail closed when a cost is unknown.
131
134
  - **[Reasoning effort / thinking config](docs/guide/optimizing_retry_policy.md)** — Anthropic / OpenAI thinking configuration on the Step class.
132
135
 
133
136
  Also supports [multi-step pipelines](docs/guide/pipeline.md) with fail-fast and per-step models.
@@ -136,7 +139,7 @@ Also supports [multi-step pipelines](docs/guide/pipeline.md) with fail-fast and
136
139
 
137
140
  ## Relation to `RubyLLM::Agent`
138
141
 
139
- `Step::Base` and `RubyLLM::Agent` (since RubyLLM 1.12) are **siblings** targeting the same niche: reusable, class-based prompts. Both call into `RubyLLM::Chat` directly — Step does not wrap Agent. Step adds the contract layer: `validate` (business invariants), `retry_policy escalate(...)` (model escalation on validation failure), `max_cost` pre-flight refusal, regression-eval framework, pipeline composition. **[Full feature mapping →](docs/guide/relation_to_agent.md)**
142
+ `Step::Base` and `RubyLLM::Agent` (since RubyLLM 1.12) are **siblings** targeting the same niche: reusable, class-based prompts. Both call into `RubyLLM::Chat` directly - Step does not wrap Agent. Step adds the contract layer: `validate` (business invariants), `retry_policy escalate(...)` (model escalation on validation failure), `max_cost` pre-flight refusal, regression-eval framework, pipeline composition. RubyLLM 2.1's `RubyLLM::Evaluation` grades answers; `define_eval` guards against regressions and cost in CI - the mapping covers both. **[Full feature mapping →](docs/guide/relation_to_agent.md)**
140
143
 
141
144
  ## Relation to `ruby_llm-tribunal`
142
145
 
@@ -153,7 +156,7 @@ Different layers, complementary. [`ruby_llm-tribunal`](https://github.com/Alqemi
153
156
  | [Why contracts?](docs/guide/why.md) | Recognise the four production failures the gem exists for |
154
157
  | [Relation to RubyLLM::Agent](docs/guide/relation_to_agent.md) | Sibling abstractions; what each adds; runtime call path; coexistence patterns |
155
158
  | [Relation to ruby_llm-tribunal](docs/guide/relation_to_tribunal.md) | Different layers (test framework vs runtime contract); visual flows; integration recipes |
156
- | [Getting Started](docs/guide/getting_started.md) | Walk the full feature set on one concrete step |
159
+ | [Getting Started](docs/guide/getting_started.md) | Walk the full feature set on one concrete step, including what usage and cost count |
157
160
  | [Rails integration](docs/guide/rails_integration.md) | Directory, initializer, jobs, logging, specs, CI gate — 7 FAQs for Rails devs |
158
161
  | [Adopt in an existing Rails app](docs/guide/migration.md) | Replace raw `LlmClient.call` with a contract, Before/After |
159
162
  | [Prevent silent prompt regressions](docs/guide/eval_first.md) | Evals, baselines, CI gates that block quality drift |
@@ -173,7 +176,11 @@ Stable since **1.0.0**. Semver tracked; breaking changes flagged in [CHANGELOG](
173
176
  What 1.0 promises not to break inside the 1.x line: the documented DSL
174
177
  (`prompt`, `output_schema`, `validate`, `retry_policy`, `max_cost`, `define_eval`,
175
178
  pipelines), the `Result` and `trace` shapes including `trace[:usage]`'s
176
- `{ input_tokens:, output_tokens: }` keys, and the adapter interface. A new major
179
+ `{ input_tokens:, output_tokens: }` keys, and the adapter interface. New keys may be
180
+ added to both (1.1.2 added `cache_read_tokens`, `cache_write_tokens` and
181
+ `thinking_tokens` to `trace[:usage]`, and `usage_complete`, `cost_complete` and
182
+ `finish_reason` to the trace), and a figure may change when it was wrong: since 1.1.2
183
+ `input_tokens` counts prompt-cache tokens. A new major
177
184
  version of a runtime dependency may require a new major version here — that is
178
185
  what 1.0.0 itself was.
179
186
 
@@ -185,6 +192,8 @@ what 1.0.0 itself was.
185
192
 
186
193
  **Where in a Rails app?** Default `app/contracts/`. The Railtie reloads `app/contracts/eval/` and `app/steps/eval/` in development; any autoloaded directory also works. See [Rails integration](docs/guide/rails_integration.md).
187
194
 
195
+ **Costs went up, or an eval cost gate turned red, after upgrading to 1.1.2?** Earlier versions left prompt-cache tokens out of `input_tokens` and the cost, priced every model at its default provider, counted a missing price as $0, and treated a call without token counts as free. The [CHANGELOG](CHANGELOG.md) lists each change; `on_unknown_pricing: :warn` turns the unknown-cost refusal into a warning.
196
+
188
197
  **Upgraded from pre-0.10.0 and getting `:limit_exceeded` with attachments?** Multimodal contracts with `max_cost`/`max_input` need `attachment_token_estimate`. See [multimodal input guide](docs/guide/multimodal_input.md#cost-attachment_token_estimate-is-required) for setup, fail-closed behaviour, and `on_unknown_attachment_size :warn` opt-out.
189
198
 
190
199
  ## License
@@ -173,7 +173,23 @@ max_cost 0.01, on_unknown_pricing: :warn
173
173
 
174
174
  Default is `:refuse`. Use `:warn` only when you accept running without a cost ceiling (fine-tuned models you trust, private endpoints).
175
175
 
176
- The eval-level gates - `with_maximum_cost` on `pass_eval`, `maximum_cost` on the rake task, and `maximum_cost:` on `assert_eval_passes` - follow the same rule since 1.1.0. A report's total counts a case it cannot price as $0, so when any case ran on a model without pricing data the gate fails and names the cases instead of comparing an undercounted total. Opt out with `.on_unknown_pricing(:warn)`, `t.on_unknown_pricing = :warn` or `on_unknown_pricing: :warn`, which checks the budget against the priced cases and prints a warning. Offline runs (`sample_response`, a `Test` adapter without `usage:`) report zero tokens, cost $0 with or without pricing, and are never flagged. The check sees only reported tokens: an adapter that returns no token counts looks like a zero-token run.
176
+ The eval-level gates - `with_maximum_cost` on `pass_eval`, `maximum_cost` on the rake task, and `maximum_cost:` on `assert_eval_passes` - follow the same rule since 1.1.0. A report's total counts a case it cannot price as $0, so when any case ran on a model without pricing data the gate fails and names the cases instead of comparing an undercounted total. Opt out with `.on_unknown_pricing(:warn)`, `t.on_unknown_pricing = :warn` or `on_unknown_pricing: :warn`, which checks the budget against the priced cases and prints a warning. Offline runs (`sample_response`, a `Test` adapter without `usage:`) report zero tokens, cost $0 with or without pricing, and are never flagged. Since 1.1.2 a real call whose provider sent no token counts is flagged too, instead of passing as a free zero-token run.
177
+
178
+ ### What a trace's usage and cost count
179
+
180
+ `trace[:usage][:input_tokens]` is the whole prompt the model took in, including tokens served from or written to the provider's prompt cache. When the provider reports them, the trace adds a breakdown: `cache_read_tokens`, `cache_write_tokens` and `thinking_tokens`. These are parts of `input_tokens` / `output_tokens`, not extra tokens; reasoning tokens are already in `output_tokens` when the provider bills them as output, and a model with a separate reasoning price is charged that price for them.
181
+
182
+ ```ruby
183
+ result.trace[:usage]
184
+ # => { input_tokens: 10_000, output_tokens: 200, cache_read_tokens: 9_000 }
185
+ result.trace[:cost] # => 0.00162 (gpt-4.1-mini: cache reads at the cache price)
186
+ ```
187
+
188
+ With the RubyLLM adapter, `trace[:cost]` is the cost RubyLLM puts on the response (`response.cost.total`): the price of the provider the call went to, cache and long-context prices, and the amount the provider reported when it reports one (OpenRouter, for example). A price set with `CostCalculator.register_model` still overrides it for that model id, unless the provider reported what it billed; there, cache reads are charged at the input price and a call that wrote to the cache has no price, since cache writes can cost more than input.
189
+
190
+ When a cost does not cover the whole call, `result.trace.cost_unknown?` is true and the eval gates above refuse. That happens when pricing is missing for a token category the call used, or when the provider sent no token counts for the call or for one of RubyLLM's attempts at it (`trace[:usage_complete]` is then `false` and missing counts read as 0) and did not report what it billed either. On a retried step `trace[:cost]` is the sum of the attempts that could be priced, and `cost_unknown?` is true if any attempt could not. `max_cost` itself is a pre-flight check on an estimate; it does not inspect the finished call.
191
+
192
+ A custom adapter returning `Response.new(content:, usage:)` is priced from `usage` as before. To report its own cost, pass `cost:` - `nil` means unknown - with `cost_complete:` and `usage_complete:`.
177
193
 
178
194
  ### Preflight cost estimates
179
195
 
@@ -12,8 +12,8 @@
12
12
  | `validate :rule do ... end` business invariants on output | only in `ruby_llm-contract` |
13
13
  | `retry_policy escalate(...)` model escalation on validation failure | only here (different from RubyLLM's network-level retry) |
14
14
  | `max_cost` / `max_input` / `max_output` pre-flight refusal | only here |
15
- | `define_eval` + baseline regression + `compare_models` + `optimize_retry_policy` | only here (RubyLLM does not ship an evaluation framework) |
16
- | Pipeline composition with `step SomeStep, as: :alias` | only here (RubyLLM intentionally leaves workflows as plain Ruby) |
15
+ | `define_eval` + baseline regression + `compare_models` + `optimize_retry_policy` | only here; RubyLLM 2.1's `RubyLLM::Evaluation` grades answers (see below) but keeps no baseline and has no cost gate |
16
+ | Pipeline composition with `step SomeStep, as: :alias` | only here; `RubyLLM.workflow` (2.1) groups calls for tracing, without typed step outputs, fail-fast or budgets |
17
17
  | `around_call`, named `observe` hooks with pass/fail recorded in trace | only here |
18
18
 
19
19
  ## Runtime relationship
@@ -32,6 +32,12 @@ Step.run(input)
32
32
 
33
33
  This may change in a future release if upstream APIs make a layered design natural. The decision is not committed; it depends on adopter signal.
34
34
 
35
+ ## Relation to `RubyLLM::Evaluation`
36
+
37
+ RubyLLM 2.1 ships `RubyLLM::Evaluation`: a dataset of cases run through your `perform` method, graded for correctness by an LLM or by your own criteria and Ruby assertions, with RSpec/Minitest integration and a report of verdicts, tokens and cost. Use it to answer "is this answer right?".
38
+
39
+ `define_eval` answers a different question: did this prompt or model get worse than last time, and does it stay within budget? It keeps baselines and flags regressions, compares prompts (`compare_with`) and models (`compare_models`, `recommend`), runs offline with `sample_response`, and gates CI on score and cost. The two can share a project; an Evaluation's `perform` can call `Step.run`.
40
+
35
41
  ## Coexistence on the same project
36
42
 
37
43
  The two abstractions can live in the same Rails (or non-Rails) project. Pick one per use case:
@@ -3,16 +3,42 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Adapters
6
+ # `content` and `usage` are the whole interface an adapter has to fill.
7
+ # The rest is optional accounting: an adapter that leaves `cost` out gets
8
+ # its usage priced from the registry, as before; one that passes `cost`
9
+ # (nil included) owns the figure, and `cost_complete` / `usage_complete`
10
+ # say whether it and the token counts cover the whole call.
6
11
  class Response
7
12
  include Concerns::DeepFreeze
8
13
 
9
- attr_reader :content, :usage
14
+ attr_reader :content, :usage, :usage_complete, :cost_complete, :finish_reason
10
15
 
11
- def initialize(content:, usage: {})
16
+ def initialize(content:, usage: {}, cost: Step::Trace::COST_UNSET, usage_complete: nil,
17
+ cost_complete: nil, finish_reason: nil)
12
18
  @content = deep_dup_freeze(content)
13
19
  @usage = deep_dup_freeze(usage)
20
+ @cost = cost
21
+ @usage_complete = usage_complete
22
+ @cost_complete = cost_complete
23
+ @finish_reason = finish_reason
14
24
  freeze
15
25
  end
26
+
27
+ def cost
28
+ cost_provided? ? @cost : nil
29
+ end
30
+
31
+ def cost_provided?
32
+ !@cost.equal?(Step::Trace::COST_UNSET)
33
+ end
34
+
35
+ # Keyword arguments for Step::Trace.new. Omits what the adapter did not
36
+ # report, so a plain Response keeps the registry pricing path.
37
+ def trace_accounting
38
+ fields = { usage_complete: @usage_complete, cost_complete: @cost_complete,
39
+ finish_reason: @finish_reason }.compact
40
+ cost_provided? ? fields.merge(cost: @cost) : fields
41
+ end
16
42
  end
17
43
  end
18
44
  end
@@ -13,16 +13,13 @@ module RubyLLM
13
13
  chat = build_chat(options, system_contents)
14
14
  add_history(chat, conversation[0..-2])
15
15
 
16
- # `with: nil` is a documented no-op in RubyLLM (verified against
17
- # 1.15.0: chat.rb:36-37 `build_content(message, nil)` -> content.rb:8-14
18
- # `Content.new(text, nil)` keeps text-only path when attachments
19
- # are empty; raise only fires when BOTH text and attachments are nil,
20
- # and we always pass a non-nil string thanks to `&.fetch(:content, "")`).
16
+ # `with: nil` adds no attachment. The text is never nil (`fetch(:content, "")`),
17
+ # and RubyLLM raises only when both text and attachments are nil.
21
18
  response = chat.ask(
22
19
  conversation.last&.fetch(:content, ""),
23
20
  with: options[:attachment]
24
21
  )
25
- build_response(response)
22
+ build_response(response, options[:model])
26
23
  end
27
24
 
28
25
  CHAT_OPTION_METHODS = {
@@ -64,8 +61,7 @@ module RubyLLM
64
61
  # taking precedence over `:thinking[:effort]`. This is the per-attempt
65
62
  # override path used by `retry_policy { escalate({model:, reasoning_effort:}) }`
66
63
  # — the attempt-specific effort must win over the class-level default.
67
- # Forwarded provider-agnostically via `chat.with_thinking(**)` —
68
- # available since RubyLLM 1.12 (gemspec enforces this minimum).
64
+ # Forwarded provider-agnostically via `chat.with_thinking(**)`.
69
65
  thinking_config = resolve_thinking_config(options)
70
66
  chat.with_thinking(**thinking_config) if thinking_config
71
67
 
@@ -84,26 +80,84 @@ module RubyLLM
84
80
  base.empty? ? nil : base
85
81
  end
86
82
 
87
- def build_response(response)
83
+ def build_response(response, model)
88
84
  content = response.content
89
85
  content = content.to_s unless content.is_a?(Hash) || content.is_a?(Array)
90
86
 
91
- # This is the ONLY place upstream token counts are read. RubyLLM 2.0
92
- # replaced `Message#input_tokens`/`#output_tokens` with a `Tokens` value
93
- # object. The `{ input_tokens:, output_tokens: }` shape below is this
94
- # gem's own public contract (Step::Trace#usage, cost calculation,
95
- # documented in the README) and deliberately does NOT change.
87
+ # This is the ONLY place upstream token counts are read. The
88
+ # `{ input_tokens:, output_tokens: }` shape is this gem's public
89
+ # contract (Step::Trace#usage, README) and does not change; RubyLLM's
90
+ # own counts go into it as described in `usage_from`.
96
91
  tokens = response.tokens
92
+ usage_complete = every_attempt_counted?(response, tokens)
93
+ reported = tokens&.reported_cost
94
+ usage = usage_from(tokens)
95
+ cost = call_cost(response, model, usage, reported)
97
96
 
98
97
  Response.new(
99
98
  content: content,
100
- usage: {
101
- input_tokens: tokens&.input || 0,
102
- output_tokens: tokens&.output || 0
103
- }
99
+ usage: usage,
100
+ cost: cost,
101
+ usage_complete: usage_complete,
102
+ # A cost RubyLLM priced from partial counts covers part of the call;
103
+ # one the provider reported covers all of it.
104
+ cost_complete: !cost.nil? && (usage_complete || !reported.nil?),
105
+ finish_reason: response.finish_reason
104
106
  )
105
107
  end
106
108
 
109
+ # `response.tokens` sums RubyLLM's attempts at the request, skipping
110
+ # counts an attempt never got, so the sum alone can look complete.
111
+ # RubyLLM records each attempt; one that failed after the provider may
112
+ # have billed it keeps nil counts, one never sent or refused gets zeros.
113
+ def every_attempt_counted?(response, tokens)
114
+ entries = response.respond_to?(:ruby_llm_usage_entries) ? Array(response.ruby_llm_usage_entries) : []
115
+ return entries.all? { |entry| reported_both_counts?(entry.tokens) } if entries.any?
116
+
117
+ reported_both_counts?(tokens)
118
+ end
119
+
120
+ # Zero is a reported count (a full cache hit leaves uncached input at 0);
121
+ # nil is a count the provider did not send.
122
+ def reported_both_counts?(tokens)
123
+ !tokens.nil? && !tokens.input.nil? && !tokens.output.nil?
124
+ end
125
+
126
+ # RubyLLM 2.x counts `input` without the prompt-cache tokens, which it
127
+ # reports apart as `cache_read` / `cache_write`. Our input_tokens is the
128
+ # whole prompt, so they are added back and also kept as a breakdown.
129
+ # `output` already includes thinking tokens when the provider bills them
130
+ # as output, so `thinking_tokens` is a breakdown, never added. A count
131
+ # the provider did not report is 0 here; `usage_complete` says so.
132
+ def usage_from(tokens)
133
+ return { input_tokens: 0, output_tokens: 0 } unless tokens
134
+
135
+ cache_read = tokens.cache_read.to_i
136
+ cache_write = tokens.cache_write.to_i
137
+ usage = { input_tokens: tokens.input.to_i + cache_read + cache_write, output_tokens: tokens.output.to_i }
138
+ usage[:cache_read_tokens] = cache_read if cache_read.positive?
139
+ usage[:cache_write_tokens] = cache_write if cache_write.positive?
140
+ usage[:thinking_tokens] = tokens.thinking.to_i if tokens.thinking.to_i.positive?
141
+ usage
142
+ end
143
+
144
+ # RubyLLM prices the response itself: per provider, with cache and
145
+ # long-context prices, the amount the provider reported when it did,
146
+ # and its own retries of the request. It returns nil when it cannot
147
+ # price every attempt. A price set with `register_model` still wins
148
+ # for its id, unless the provider reported what it billed; it prices
149
+ # the summed counts, so it is complete only when every attempt was
150
+ # counted (`cost_complete` in build_response).
151
+ def call_cost(response, model, usage, reported)
152
+ if reported.nil? && CostCalculator.registered?(model)
153
+ CostCalculator.calculate(model_name: model, usage: usage)
154
+ else
155
+ response.cost.total&.round(6)
156
+ end
157
+ rescue StandardError
158
+ nil
159
+ end
160
+
107
161
  def partition_messages(messages)
108
162
  system_contents = []
109
163
  conversation = []
@@ -4,6 +4,10 @@ module RubyLLM
4
4
  module Contract
5
5
  module Concerns
6
6
  module UsageAggregator
7
+ # Breakdown keys an adapter may add next to input/output. They are parts
8
+ # of input_tokens/output_tokens, not extra tokens, so sum_tokens skips them.
9
+ USAGE_DETAIL_KEYS = %i[cache_read_tokens cache_write_tokens thinking_tokens].freeze
10
+
7
11
  private
8
12
 
9
13
  def extract_usage(trace_entry)
@@ -24,18 +28,17 @@ module RubyLLM
24
28
  end
25
29
 
26
30
  def aggregate_usage(traces)
27
- input_total = 0
28
- output_total = 0
31
+ totals = Hash.new(0)
29
32
 
30
33
  traces.each do |trace_entry|
31
34
  usage = extract_usage(trace_entry)
32
35
  next unless usage.is_a?(Hash)
33
36
 
34
- input_total += usage[:input_tokens] || 0
35
- output_total += usage[:output_tokens] || 0
37
+ [:input_tokens, :output_tokens, *USAGE_DETAIL_KEYS].each { |key| totals[key] += usage[key] || 0 }
36
38
  end
37
39
 
38
- { input_tokens: input_total, output_tokens: output_total }
40
+ { input_tokens: totals[:input_tokens], output_tokens: totals[:output_tokens] }
41
+ .merge(totals.slice(*USAGE_DETAIL_KEYS).select { |_, count| count.positive? })
39
42
  end
40
43
  end
41
44
  end
@@ -7,20 +7,22 @@ module RubyLLM
7
7
  # **What this module does (public surface):**
8
8
  #
9
9
  # 1. **Fine-tune / custom-model pricing registry** — `register_model`
10
- # fills the gap left by RubyLLM 1.14's models.json: there is no
11
- # upstream `RubyLLM::Models.register` API, so fine-tuned models
12
- # (e.g. `ft:gpt-4o-custom`) need their pricing supplied locally.
10
+ # fills a gap RubyLLM still has in 2.x: there is no simple
11
+ # upstream API to price a model its registry lacks, so fine-tuned
12
+ # models (e.g. `ft:gpt-4o-custom`) need their pricing supplied
13
+ # locally. A registered price also overrides RubyLLM's for that id.
13
14
  # 2. **Lookup with fallback chain** — `calculate(model_name:, usage:)`
14
15
  # checks the custom registry first, falls back to
15
16
  # `RubyLLM.models.find(model_name)`, returns `nil` on miss.
16
17
  #
17
18
  # **What this module is NOT:**
18
19
  #
19
- # - Not a "cost calculator" feature — the math itself
20
- # (`tokens × price_per_million / 1_000_000`) is trivial and lives
21
- # in `private_class_method :compute_cost` for internal use only.
22
- # - Not a substitute for RubyLLM's pricing data — for any model in
23
- # `RubyLLM.models`, this module simply queries it.
20
+ # - Not a substitute for RubyLLM's pricing — for any model in
21
+ # `RubyLLM.models`, RubyLLM's own `Model#cost_for` does the math.
22
+ # - Not the source of a finished call's cost: the RubyLLM adapter takes
23
+ # that from the response (`response.cost`), which also covers what
24
+ # the provider reported and RubyLLM's own retries. This module prices
25
+ # pre-flight estimates and calls from adapters that report no cost.
24
26
  #
25
27
  # The reason this module exists at all is the registry + retry usage
26
28
  # aggregation across attempts (the latter sits in `Step::RetryExecutor`,
@@ -60,8 +62,9 @@ module RubyLLM
60
62
 
61
63
  # Look up cost for a single model + usage hash.
62
64
  # Returns nil if model is unknown (custom registry miss + RubyLLM miss),
63
- # so callers can decide whether to refuse the call or proceed (see
64
- # `on_unknown_pricing:` step option for the budget-gating policy).
65
+ # or if any used token category has no price, so callers can decide
66
+ # whether to refuse the call or proceed (see `on_unknown_pricing:` step
67
+ # option for the budget-gating policy).
65
68
  #
66
69
  # CostCalculator.calculate(
67
70
  # model_name: "gpt-4o-mini",
@@ -69,13 +72,17 @@ module RubyLLM
69
72
  # )
70
73
  # # => 0.00069 (or nil if model not registered)
71
74
  #
72
- # Math is intentionally simple and private — this method is the
73
- # primary public entry point. Aggregating across retry attempts is
74
- # done in `Step::RetryExecutor`, not here.
75
- def self.calculate(model_name:, usage:)
75
+ # `usage[:input_tokens]` is the whole prompt; `:cache_read_tokens` and
76
+ # `:cache_write_tokens`, when present, are the parts of it served from or
77
+ # written to the provider's prompt cache. `provider:` picks the provider's
78
+ # price for a model id several providers list at different prices.
79
+ #
80
+ # Aggregating across retry attempts is done in `Step::RetryExecutor`,
81
+ # not here.
82
+ def self.calculate(model_name:, usage:, provider: nil)
76
83
  return nil unless model_name && usage.is_a?(Hash)
77
84
 
78
- model_info = find_model(model_name)
85
+ model_info = find_model(model_name, provider: provider)
79
86
  return nil unless model_info
80
87
 
81
88
  compute_cost(model_info, usage)
@@ -83,55 +90,76 @@ module RubyLLM
83
90
  nil
84
91
  end
85
92
 
86
- def self.compute_cost(model_info, usage)
87
- prices = prices_for(model_info)
88
- return nil unless prices
89
-
90
- input_cost = token_cost(usage[:input_tokens], prices[:input])
91
- output_cost = token_cost(usage[:output_tokens], prices[:output])
92
- (input_cost + output_cost).round(6)
93
+ def self.registered?(model_name)
94
+ @custom_models.key?(model_name)
93
95
  end
94
96
 
95
97
  # Two shapes reach here. `RegisteredModel` (our own struct, from
96
- # `register_model`) exposes flat `*_price_per_million` readers. RubyLLM 2.0
97
- # moved provider pricing into a nested value object and dropped those flat
98
- # readers, so it has to be walked: pricing -> text_tokens -> standard.
98
+ # `register_model`) exposes flat `*_price_per_million` readers and is
99
+ # priced here. A RubyLLM model is priced by RubyLLM itself
100
+ # (`Model#cost_for`), which knows cache, long-context and per-provider
101
+ # prices and returns nil when a used category has no price.
99
102
  #
100
- # nil means "unknown pricing". The guards keep the two known shapes
101
- # explicit, but `calculate` rescues StandardError, so a future shape this
102
- # walk cannot read also degrades to nil: `max_cost` then refuses the call
103
- # citing "no pricing data" rather than the real cause, and paths that floor
104
- # unknown cost to 0.0 (eval cost estimates, report totals) undercount.
105
- def self.prices_for(model_info)
103
+ # nil means "unknown pricing". `calculate` rescues StandardError, so a
104
+ # future shape neither branch can read also degrades to nil: `max_cost`
105
+ # then refuses the call citing "no pricing data" rather than the real
106
+ # cause, and paths that floor unknown cost to 0.0 (eval cost estimates,
107
+ # report totals) undercount.
108
+ def self.compute_cost(model_info, usage)
106
109
  if model_info.respond_to?(:input_price_per_million)
107
- return { input: model_info.input_price_per_million,
108
- output: model_info.output_price_per_million }
110
+ registered_cost(model_info, usage)
111
+ elsif model_info.respond_to?(:cost_for)
112
+ model_info.cost_for(tokens_for(usage)).total&.round(6)
109
113
  end
114
+ end
110
115
 
111
- tier = model_info.pricing&.text_tokens&.standard if model_info.respond_to?(:pricing)
112
- return unless tier.respond_to?(:input_per_million)
116
+ # Our registry knows input and output prices only. Cache reads are
117
+ # charged at the input price, which assumes they cost no more than
118
+ # uncached input (true of every provider in RubyLLM's registry, not
119
+ # guaranteed for a custom one). Cache writes can cost more than input
120
+ # (Anthropic charges 1.25x-2x), so a call that wrote to the cache has
121
+ # no price here rather than an undercount.
122
+ def self.registered_cost(model_info, usage)
123
+ return nil if (usage[:cache_write_tokens] || 0).positive?
124
+
125
+ input_cost = token_cost(usage[:input_tokens], model_info.input_price_per_million)
126
+ output_cost = token_cost(usage[:output_tokens], model_info.output_price_per_million)
127
+ (input_cost + output_cost).round(6)
128
+ end
113
129
 
114
- { input: tier.input_per_million, output: tier.output_per_million }
130
+ # RubyLLM counts uncached input apart from cache reads and writes; our
131
+ # usage counts them all in input_tokens.
132
+ def self.tokens_for(usage)
133
+ cache_read = usage[:cache_read_tokens] || 0
134
+ cache_write = usage[:cache_write_tokens] || 0
135
+ ::RubyLLM::Tokens.new(
136
+ input: [(usage[:input_tokens] || 0) - cache_read - cache_write, 0].max,
137
+ output: usage[:output_tokens] || 0,
138
+ cache_read: cache_read.positive? ? cache_read : nil,
139
+ cache_write: cache_write.positive? ? cache_write : nil,
140
+ # Priced by RubyLLM only for models with a separate reasoning price.
141
+ thinking: usage[:thinking_tokens]
142
+ )
115
143
  end
116
- private_class_method :prices_for
117
144
 
118
145
  # Provider pricing is denominated per 1M tokens; divide here to get
119
146
  # the dollar cost for the actual usage count. Named constant for
120
147
  # consistency with how RubyLLM and provider docs express prices.
121
148
  TOKENS_PER_MILLION = 1_000_000.0
122
149
 
150
+ # `register_model` validates both prices, so neither is nil here.
123
151
  def self.token_cost(tokens, price_per_million)
124
- (tokens || 0) * (price_per_million || 0) / TOKENS_PER_MILLION
152
+ (tokens || 0) * price_per_million / TOKENS_PER_MILLION
125
153
  end
126
154
 
127
- def self.find_model(model_name)
155
+ def self.find_model(model_name, provider: nil)
128
156
  # Check custom registry first
129
157
  custom = @custom_models[model_name]
130
158
  return custom if custom
131
159
 
132
160
  return nil unless defined?(RubyLLM)
133
161
 
134
- RubyLLM.models.find(model_name)
162
+ provider ? RubyLLM.models.find(model_name, provider: provider) : RubyLLM.models.find(model_name)
135
163
  rescue StandardError
136
164
  nil
137
165
  end
@@ -146,7 +174,7 @@ module RubyLLM
146
174
  # to inspect model pricing before invoking `calculate` (e.g., to short-
147
175
  # circuit estimate when the model is unknown). Exposing it removes
148
176
  # `CostCalculator.send(:find_model)` workarounds at call sites.
149
- private_class_method :compute_cost, :token_cost, :validate_price!
177
+ private_class_method :compute_cost, :registered_cost, :tokens_for, :token_cost, :validate_price!
150
178
  end
151
179
  end
152
180
  end
@@ -69,7 +69,8 @@ module RubyLLM
69
69
 
70
70
  lines = ["#{runs.length} runs"]
71
71
  runs.last(5).each do |r|
72
- lines << " #{r[:date]} score=#{r[:score].round(2)} cost=$#{format("%.6f", r[:total_cost] || r[:cost] || 0)}"
72
+ cost = format("%.6f", r[:total_cost] || r[:cost] || 0)
73
+ lines << " #{r[:date]} score=#{r[:score].round(2)} cost=$#{cost}#{" + unknown" if r[:cost_unknown]}"
73
74
  end
74
75
  lines.join("\n")
75
76
  end
@@ -36,8 +36,12 @@ module RubyLLM
36
36
  @reports[resolve_key(candidate)]&.total_cost
37
37
  end
38
38
 
39
+ # A report with unpriced cases has only a subtotal, so it cannot be
40
+ # called the cheapest.
39
41
  def best_for(min_score: 0.0)
40
- eligible = @reports.select { |_, report| report.score > 0.0 && report.score >= min_score }
42
+ eligible = @reports.select do |_, report|
43
+ report.score > 0.0 && report.score >= min_score && !cost_unknown?(report)
44
+ end
41
45
  return nil if eligible.empty?
42
46
 
43
47
  eligible.min_by { |_, report| report.total_cost }&.first
@@ -45,6 +49,8 @@ module RubyLLM
45
49
 
46
50
  def cost_per_point
47
51
  @reports.transform_values do |report|
52
+ next Float::INFINITY if cost_unknown?(report)
53
+
48
54
  report.score.positive? ? report.total_cost / report.score : Float::INFINITY
49
55
  end
50
56
  end
@@ -163,6 +169,10 @@ module RubyLLM
163
169
  )
164
170
  end
165
171
 
172
+ def cost_unknown?(report)
173
+ report.respond_to?(:unknown_cost_results) && report.unknown_cost_results.any?
174
+ end
175
+
166
176
  def resolve_key(candidate)
167
177
  case candidate
168
178
  when String then candidate
@@ -51,7 +51,8 @@ module RubyLLM
51
51
  cost_per_call: cost_per_call,
52
52
  latency: report.avg_latency_ms || Float::INFINITY,
53
53
  pass_rate_ratio: report.pass_rate_ratio,
54
- total_cost: report.total_cost
54
+ total_cost: report.total_cost,
55
+ cost_unknown: unknown_cost?(report)
55
56
  }
56
57
  end
57
58
  end
@@ -114,7 +115,7 @@ module RubyLLM
114
115
 
115
116
  current_label = ModelComparison.candidate_label(@current_config)
116
117
  current_report = @comparison.reports[current_label]
117
- return {} unless current_report
118
+ return {} if current_report.nil? || unknown_cost?(current_report)
118
119
 
119
120
  current_evaluated = current_report.results.count { |r| r.step_status != CaseResult::SKIPPED_STATUS }
120
121
  current_cases = [current_evaluated, 1].max
@@ -125,8 +126,14 @@ module RubyLLM
125
126
  { per_call: diff.round(6), monthly_at: { 10_000 => (diff * 10_000).round(2) } }
126
127
  end
127
128
 
129
+ # A subtotal over unpriced cases is not a cost to rank by. Zero stays
130
+ # "unknown pricing" too: offline runs report $0 without any call.
128
131
  def cost_known?(scored_candidate)
129
- scored_candidate[:cost_per_call]&.positive?
132
+ !scored_candidate[:cost_unknown] && scored_candidate[:cost_per_call]&.positive?
133
+ end
134
+
135
+ def unknown_cost?(report)
136
+ report.respond_to?(:unknown_cost_results) && report.unknown_cost_results.any?
130
137
  end
131
138
  end
132
139
  end
@@ -85,7 +85,7 @@ module RubyLLM
85
85
  def single_shot_cost
86
86
  return nil unless production_mode?
87
87
 
88
- evaluated_results.sum { |r| first_attempt_cost(r) || r.cost || 0.0 }
88
+ evaluated_results.sum { |r| first_attempt_cost(r) || 0.0 }
89
89
  end
90
90
 
91
91
  def effective_cost
@@ -116,9 +116,14 @@ module RubyLLM
116
116
 
117
117
  private
118
118
 
119
+ # With attempts, only the first one's cost: falling back to the case
120
+ # cost when it is unpriced would report a later attempt's spend as the
121
+ # single-shot cost.
119
122
  def first_attempt_cost(result)
120
- first = (result.attempts || []).first
121
- first && first[:cost]
123
+ attempts = result.attempts || []
124
+ return result.cost if attempts.empty?
125
+
126
+ attempts.first[:cost]
122
127
  end
123
128
 
124
129
  def first_attempt_latency(result)
@@ -52,8 +52,10 @@ module RubyLLM
52
52
 
53
53
  private
54
54
 
55
+ # cost_unknown is written only when true, so older files and runs that
56
+ # priced every case keep the same shape.
55
57
  def history_entry
56
- {
58
+ entry = {
57
59
  date: Time.now.strftime("%Y-%m-%d"),
58
60
  score: @stats.score,
59
61
  total_cost: @stats.total_cost,
@@ -61,6 +63,8 @@ module RubyLLM
61
63
  pass_rate_ratio: @stats.pass_rate_ratio,
62
64
  cases_count: @stats.evaluated_results_count
63
65
  }
66
+ entry[:cost_unknown] = true if @stats.unknown_cost_results.any?
67
+ entry
64
68
  end
65
69
 
66
70
  def serialize_for_baseline
@@ -74,13 +78,15 @@ module RubyLLM
74
78
  end
75
79
 
76
80
  def serialize_case(result)
77
- {
81
+ serialized = {
78
82
  name: result.name,
79
83
  passed: result.passed?,
80
84
  score: result.score,
81
85
  details: result.details,
82
86
  cost: result.cost
83
87
  }
88
+ serialized[:cost_unknown] = true if result.cost_unknown?
89
+ serialized
84
90
  end
85
91
 
86
92
  def storage_path(root_dir, extension, model:, reasoning_effort: nil)
@@ -83,6 +83,7 @@ module RubyLLM
83
83
  total_usage: aggregate_usage(traces),
84
84
  step_traces: traces
85
85
  )
86
+ warn_incomplete_budget_usage(trace)
86
87
 
87
88
  Result.new(
88
89
  status: execution.status, step_results: execution.step_results,
@@ -91,6 +92,13 @@ module RubyLLM
91
92
  )
92
93
  end
93
94
 
95
+ def warn_incomplete_budget_usage(trace)
96
+ return unless @token_budget && !trace.usage_complete?
97
+
98
+ warn "[ruby_llm-contract] token_budget checked against incomplete token counts: " \
99
+ "a provider left out usage for at least one step, so the total undercounts"
100
+ end
101
+
94
102
  def elapsed_ms(start_time)
95
103
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
96
104
  end
@@ -40,6 +40,16 @@ module RubyLLM
40
40
  @step_traces.any? { |step_trace| step_trace.respond_to?(:cost_unknown?) && step_trace.cost_unknown? }
41
41
  end
42
42
 
43
+ # False when any step's provider left out token counts, so total_usage
44
+ # (and a token_budget checked against it) undercounts.
45
+ def usage_complete?
46
+ return true unless @step_traces.is_a?(Array)
47
+
48
+ @step_traces.none? do |step_trace|
49
+ step_trace.respond_to?(:usage_complete) && step_trace.usage_complete == false
50
+ end
51
+ end
52
+
43
53
  def to_h
44
54
  { trace_id: @trace_id, total_latency_ms: @total_latency_ms,
45
55
  total_usage: @total_usage, step_traces: @step_traces,
@@ -26,9 +26,9 @@ module RubyLLM
26
26
  context: context).results.first
27
27
  end
28
28
 
29
- def estimate_cost(input:, model: nil, attachment: nil)
29
+ def estimate_cost(input:, model: nil, attachment: nil, provider: nil)
30
30
  model_name = estimated_model_name(model)
31
- model_info = CostCalculator.find_model(model_name)
31
+ model_info = CostCalculator.find_model(model_name, provider: provider)
32
32
  return nil unless model_info
33
33
 
34
34
  text_tokens = TokenEstimator.estimate(build_messages(input))
@@ -48,12 +48,13 @@ module RubyLLM
48
48
  output_tokens_estimate: output_tokens,
49
49
  estimated_cost: CostCalculator.calculate(
50
50
  model_name: model_name,
51
- usage: { input_tokens: input_tokens, output_tokens: output_tokens }
51
+ usage: { input_tokens: input_tokens, output_tokens: output_tokens },
52
+ provider: provider
52
53
  )
53
54
  }
54
55
  end
55
56
 
56
- def estimate_eval_cost(eval_name, models: nil)
57
+ def estimate_eval_cost(eval_name, models: nil, provider: nil)
57
58
  defn = send(:all_eval_definitions)[eval_name.to_s]
58
59
  raise ArgumentError, "No eval '#{eval_name}' defined" unless defn
59
60
 
@@ -61,7 +62,7 @@ module RubyLLM
61
62
  cases = defn.build_dataset.cases
62
63
 
63
64
  model_list.each_with_object({}) do |model_name, result|
64
- result[model_name] = estimate_eval_cost_for_model(cases, model_name)
65
+ result[model_name] = estimate_eval_cost_for_model(cases, model_name, provider)
65
66
  end
66
67
  end
67
68
 
@@ -145,9 +146,9 @@ module RubyLLM
145
146
  [0, true]
146
147
  end
147
148
 
148
- def estimate_eval_cost_for_model(cases, model_name)
149
+ def estimate_eval_cost_for_model(cases, model_name, provider)
149
150
  cases.sum do |test_case|
150
- estimate = estimate_cost(input: test_case.input, model: model_name)
151
+ estimate = estimate_cost(input: test_case.input, model: model_name, provider: provider)
151
152
  # Two misses floor to 0.0, not one: model absent from the registry
152
153
  # (nil estimate), or present with unreadable pricing (hash whose
153
154
  # estimated_cost is nil). Documented as a floor, not a fail-closed.
@@ -287,12 +288,18 @@ module RubyLLM
287
288
  "model=#{trace.model} status=#{result.status} " \
288
289
  "latency=#{trace.latency_ms}ms " \
289
290
  "tokens=#{trace.usage&.dig(:input_tokens) || 0}+#{trace.usage&.dig(:output_tokens) || 0} " \
290
- "cost=$#{format("%.6f", trace.cost || 0)}"
291
+ "cost=#{log_cost(trace)}"
291
292
  logger.info(msg)
292
293
 
293
294
  log_failed_observations(result, logger)
294
295
  end
295
296
 
297
+ def log_cost(trace)
298
+ return "unknown" if trace.respond_to?(:cost_unknown?) && trace.cost_unknown?
299
+
300
+ "$#{format("%.6f", trace.cost || 0)}"
301
+ end
302
+
296
303
  def log_failed_observations(result, logger)
297
304
  failed = result.observations.select { |o| !o[:passed] }
298
305
  return if failed.empty?
@@ -70,7 +70,8 @@ module RubyLLM
70
70
  estimated_output = effective_max_output || (estimated * DEFAULT_OUTPUT_RATIO)
71
71
  estimated_cost = CostCalculator.calculate(
72
72
  model_name: model_name,
73
- usage: { input_tokens: estimated, output_tokens: estimated_output }
73
+ usage: { input_tokens: estimated, output_tokens: estimated_output },
74
+ provider: pricing_provider
74
75
  )
75
76
 
76
77
  if estimated_cost.nil?
@@ -4,12 +4,21 @@ module RubyLLM
4
4
  module Contract
5
5
  module Step
6
6
  class ResultBuilder
7
- def initialize(contract_definition:, output_type:, output_schema:, model:, observers:)
7
+ # Why the provider stopped, worded for a failed result. Anthropic also
8
+ # reports an exhausted context window as :max_tokens, so the message
9
+ # does not claim max_output was the limit hit.
10
+ FINISH_REASON_ERRORS = {
11
+ max_tokens: "provider reported token-limit termination",
12
+ content_filter: "provider reported content filtering or refusal"
13
+ }.freeze
14
+
15
+ def initialize(contract_definition:, output_type:, output_schema:, model:, observers:, max_output: nil)
8
16
  @contract_definition = contract_definition
9
17
  @output_type = output_type
10
18
  @output_schema = output_schema
11
19
  @model = model
12
20
  @observers = observers
21
+ @max_output = max_output
13
22
  end
14
23
 
15
24
  def error_result(error_result:, messages:)
@@ -25,13 +34,14 @@ module RubyLLM
25
34
  def success_result(response:, messages:, latency_ms:, input:)
26
35
  raw_output = response.content
27
36
  validation_result = validate_output(raw_output, input)
28
- trace = Trace.new(messages: messages, model: @model, latency_ms: latency_ms, usage: response.usage)
37
+ trace = Trace.new(messages: messages, model: @model, latency_ms: latency_ms, usage: response.usage,
38
+ **response_accounting(response))
29
39
 
30
40
  Result.new(
31
41
  status: validation_result[:status],
32
42
  raw_output: raw_output,
33
43
  parsed_output: validation_result[:parsed_output],
34
- validation_errors: validation_result[:errors],
44
+ validation_errors: validation_errors(validation_result, response),
35
45
  trace: trace,
36
46
  observations: observations_for(validation_result, input)
37
47
  )
@@ -39,6 +49,25 @@ module RubyLLM
39
49
 
40
50
  private
41
51
 
52
+ # Only a failed result says why the provider stopped; a result that
53
+ # passed validation keeps finish_reason in its trace alone.
54
+ def validation_errors(validation_result, response)
55
+ errors = validation_result[:errors]
56
+ return errors if validation_result[:status] == :ok
57
+
58
+ reason = response.respond_to?(:finish_reason) ? response.finish_reason : nil
59
+ message = FINISH_REASON_ERRORS[reason]
60
+ return errors unless message
61
+
62
+ message += "; configured max_output: #{@max_output}" if reason == :max_tokens && @max_output
63
+ [*errors, message]
64
+ end
65
+
66
+ # Custom adapters may return any object with `content` and `usage`.
67
+ def response_accounting(response)
68
+ response.respond_to?(:trace_accounting) ? response.trace_accounting : {}
69
+ end
70
+
42
71
  def observations_for(validation_result, input)
43
72
  return [] unless validation_result[:status] == :ok && @observers.any?
44
73
 
@@ -6,6 +6,9 @@ module RubyLLM
6
6
  module RetryExecutor
7
7
  include Concerns::UsageAggregator
8
8
 
9
+ # Copied from each attempt's trace into its attempts entry when present.
10
+ ATTEMPT_TRACE_FIELDS = %i[usage latency_ms cost finish_reason].freeze
11
+
9
12
  private
10
13
 
11
14
  def run_with_retry(input, adapter:, default_model:, policy:, context_temperature: nil, extra_options: {})
@@ -39,7 +42,9 @@ module RubyLLM
39
42
  observations: last.observations,
40
43
  trace: last.trace.merge(
41
44
  attempts: attempt_log, usage: aggregated_usage,
42
- cost: total_cost, latency_ms: total_latency
45
+ cost: total_cost, latency_ms: total_latency,
46
+ usage_complete: aggregate_flag(all_attempts, :usage_complete),
47
+ cost_complete: aggregate_cost_complete(all_attempts)
43
48
  )
44
49
  )
45
50
  end
@@ -53,12 +58,38 @@ module RubyLLM
53
58
  end
54
59
 
55
60
  def append_trace_fields(entry, trace)
56
- entry[:usage] = trace.usage if trace.respond_to?(:usage) && trace.usage
57
- entry[:latency_ms] = trace.latency_ms if trace.respond_to?(:latency_ms) && trace.latency_ms
58
- entry[:cost] = trace.cost if trace.respond_to?(:cost) && trace.cost
61
+ ATTEMPT_TRACE_FIELDS.each do |field|
62
+ value = trace_value(trace, field)
63
+ entry[field] = value if value
64
+ end
65
+ entry[:cost_unknown] = true if trace_value(trace, :cost_unknown?)
66
+ entry[:usage_complete] = false if trace_value(trace, :usage_complete) == false
59
67
  entry
60
68
  end
61
69
 
70
+ def trace_value(trace, method)
71
+ trace.public_send(method) if trace.respond_to?(method)
72
+ end
73
+
74
+ # The subtotal is complete only if every attempt was; the last attempt's
75
+ # flag must not speak for it. nil when no attempt reported accounting
76
+ # state, so a retried Test-adapter step serializes exactly as before.
77
+ def aggregate_cost_complete(all_attempts)
78
+ traces = all_attempts.map { |a| a[:result].trace }
79
+ return false if traces.any? { |trace| trace.respond_to?(:cost_unknown?) && trace.cost_unknown? }
80
+
81
+ aggregate_flag(all_attempts, :cost_complete)
82
+ end
83
+
84
+ def aggregate_flag(all_attempts, flag)
85
+ flags = all_attempts.map { |a| a[:result].trace }
86
+ .select { |trace| trace.respond_to?(flag) }
87
+ .map { |trace| trace.public_send(flag) }.compact
88
+ return nil if flags.empty?
89
+
90
+ flags.all?
91
+ end
92
+
62
93
  def sum_attempt_costs(all_attempts)
63
94
  costs = extract_trace_values(all_attempts, :cost)
64
95
  return nil if costs.empty?
@@ -59,7 +59,8 @@ module RubyLLM
59
59
  output_type: @config.output_type,
60
60
  output_schema: @config.output_schema,
61
61
  model: @config.model,
62
- observers: @config.observers
62
+ observers: @config.observers,
63
+ max_output: @config.effective_max_output
63
64
  )
64
65
  end
65
66
 
@@ -87,6 +88,11 @@ module RubyLLM
87
88
  @config.on_unknown_attachment_size
88
89
  end
89
90
 
91
+ # The provider the adapter will call, so the estimate uses its price.
92
+ def pricing_provider
93
+ @config.extra_options&.dig(:provider)
94
+ end
95
+
90
96
  def attachment_present?
91
97
  opts = @config.extra_options
92
98
  !opts.nil? && !opts[:attachment].nil?
@@ -7,19 +7,34 @@ module RubyLLM
7
7
  include Concerns::TraceEquality
8
8
  include Concerns::DeepFreeze
9
9
 
10
- attr_reader :messages, :model, :latency_ms, :usage, :attempts, :cost
11
-
12
- def initialize(messages: nil, model: nil, latency_ms: nil, usage: nil, attempts: nil, cost: nil)
10
+ attr_reader :messages, :model, :latency_ms, :usage, :attempts, :cost,
11
+ :usage_complete, :cost_complete, :finish_reason
12
+
13
+ # Marks `cost:` as not given, as distinct from an explicit nil. Only an
14
+ # omitted cost is priced from the registry; nil from an adapter that
15
+ # reports its own cost means "unknown" and must stay unknown.
16
+ COST_UNSET = Object.new.freeze
17
+
18
+ # usage_complete / cost_complete are nil for traces that carry no
19
+ # accounting state (Test adapter, older adapters, hand-built traces);
20
+ # those keep the token-count rule in `unpriced?`.
21
+ def initialize(messages: nil, model: nil, latency_ms: nil, usage: nil, attempts: nil, cost: COST_UNSET,
22
+ usage_complete: nil, cost_complete: nil, finish_reason: nil)
13
23
  @messages = deep_dup_freeze(messages)
14
24
  @model = model.frozen? ? model : model&.dup&.freeze
15
25
  @latency_ms = latency_ms
16
26
  @usage = deep_dup_freeze(usage)
17
27
  @attempts = deep_dup_freeze(attempts)
18
- @cost = cost || CostCalculator.calculate(model_name: model, usage: usage)
28
+ @usage_complete = usage_complete
29
+ @cost_complete = cost_complete
30
+ @finish_reason = finish_reason
31
+ @cost = resolve_cost(cost)
32
+ @keep_nil_cost = keep_nil_cost?(cost)
19
33
  freeze
20
34
  end
21
35
 
22
- KNOWN_KEYS = %i[messages model latency_ms usage attempts cost].freeze
36
+ KNOWN_KEYS = %i[messages model latency_ms usage attempts cost
37
+ usage_complete cost_complete finish_reason].freeze
23
38
 
24
39
  def [](key)
25
40
  return nil unless KNOWN_KEYS.include?(key.to_sym)
@@ -39,27 +54,27 @@ module RubyLLM
39
54
  end
40
55
  alias has_key? key?
41
56
 
57
+ # Never re-prices: a retry merges a subtotal and an aggregate usage that
58
+ # no single model's pricing describes.
42
59
  def merge(**overrides)
43
- self.class.new(
44
- messages: overrides.fetch(:messages, @messages),
45
- model: overrides.fetch(:model, @model),
46
- latency_ms: overrides.fetch(:latency_ms, @latency_ms),
47
- usage: overrides.fetch(:usage, @usage),
48
- attempts: overrides.fetch(:attempts, @attempts),
49
- cost: overrides.fetch(:cost, @cost)
50
- )
60
+ self.class.new(**KNOWN_KEYS.to_h { |key| [key, overrides.fetch(key) { public_send(key) }] })
51
61
  end
52
62
 
53
- # True when a call ran on a named model, used tokens, and still got no
54
- # cost, so any total built from this trace undercounts. A zero-token call
63
+ # True when a total built from this trace undercounts: the adapter said
64
+ # its cost does not cover the call, or (no accounting state) a call ran
65
+ # on a named model, used tokens, and still got no cost. A zero-token call
55
66
  # (Test adapter, sample_response) costs 0 whatever the pricing. A retried
56
67
  # step is judged per attempt: a priced subtotal must not hide an unpriced
57
- # attempt, and the merged trace may have re-priced on the last model.
68
+ # attempt.
58
69
  def cost_unknown?
70
+ return true if @cost_complete == false
71
+
59
72
  if @attempts.is_a?(Array) && !@attempts.empty?
60
- @attempts.any? { |attempt| self.class.unpriced?(attempt[:model], attempt[:usage], attempt[:cost]) }
73
+ @attempts.any? do |attempt|
74
+ attempt[:cost_unknown] || self.class.unpriced?(attempt[:model], attempt[:usage], attempt[:cost])
75
+ end
61
76
  else
62
- self.class.unpriced?(@model, @usage, @cost)
77
+ @cost_complete.nil? && self.class.unpriced?(@model, @usage, @cost)
63
78
  end
64
79
  end
65
80
 
@@ -70,8 +85,11 @@ module RubyLLM
70
85
  end
71
86
 
72
87
  def to_h
73
- { messages: @messages, model: @model, latency_ms: @latency_ms,
74
- usage: @usage, attempts: @attempts, cost: @cost }.compact
88
+ hash = { messages: @messages, model: @model, latency_ms: @latency_ms,
89
+ usage: @usage, attempts: @attempts, cost: @cost, usage_complete: @usage_complete,
90
+ cost_complete: @cost_complete, finish_reason: @finish_reason }.compact
91
+ hash[:cost] = nil if @keep_nil_cost
92
+ hash
75
93
  end
76
94
 
77
95
  def to_s
@@ -80,6 +98,22 @@ module RubyLLM
80
98
 
81
99
  private
82
100
 
101
+ # A given nil cost that the registry would price must stay `cost: nil` in
102
+ # to_h, or `Trace.new(**to_h)` would come back priced. Otherwise it is
103
+ # left out, so a merged trace compares equal to the one it came from.
104
+ def keep_nil_cost?(cost)
105
+ return false unless cost.nil? && @cost_complete.nil?
106
+
107
+ !CostCalculator.calculate(model_name: @model, usage: @usage).nil?
108
+ end
109
+
110
+ def resolve_cost(cost)
111
+ return cost unless cost.equal?(COST_UNSET)
112
+ return nil unless @cost_complete.nil?
113
+
114
+ CostCalculator.calculate(model_name: @model, usage: @usage)
115
+ end
116
+
83
117
  def build_summary_parts
84
118
  parts = [@model || "no-model"]
85
119
  parts << "#{@latency_ms}ms" if @latency_ms
@@ -11,9 +11,11 @@ module RubyLLM
11
11
  # - Worse for non-English text, code, structured data, and unusual scripts
12
12
  # - Useless for models with very different tokenizers (e.g. some open-source models)
13
13
  #
14
- # RubyLLM 1.14 ships no pre-flight tokenizer either; once the API call
15
- # returns, `RubyLLM::Tokens` provides accurate counts from provider usage
16
- # data. This estimator is for the *pre-flight refusal* path only — its job
14
+ # RubyLLM 2.x can count tokens exactly before a call (`chat.count_tokens`),
15
+ # but only on some providers and at the price of an extra request, so this
16
+ # local estimate stays the default; once the API call returns,
17
+ # `RubyLLM::Tokens` provides accurate counts from provider usage data.
18
+ # This estimator is for the *pre-flight refusal* path only - its job
17
19
  # is to answer "is this call almost certainly within budget?" with enough
18
20
  # accuracy that runaway prompts get caught, while accepting that the
19
21
  # boundary cases will be wrong.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "1.1.1"
5
+ VERSION = "1.1.2"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-contract
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna