Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Your PolicyOps team just promoted a semantic answer cache for its large language model (LLM) support assistant. Safe public access-policy hits can now return an evaluated answer without generating new text. That doesn't make the bill disappear. Live account checks, exception cases, and cache misses still invoke a model, and nobody can defend the next release with "it should be cheaper."
The missing artifact is a cost ledger for one evaluated support release. It starts from provider-reported token usage, accounts for two very different forms of caching, tests output savings against the answer contract, defers only eligible offline work, and produces a promotion decision. The next chapter will turn that budget contract into gateway routing and fallback policy. We won't build a router here.
The accounting boundary
Cost engineering begins after a request decision is known:
| Decision | What executes? | What belongs in this ledger? |
|---|---|---|
| Semantic answer hit from the previous lesson | Stored evaluated answer returned | No generation charge; record avoided generation separately |
| Generated answer with provider prompt-cache hit | Model still produces a new answer | Fresh input, cached input, and output token charges |
| Generated answer without prompt-cache hit | Model reads full input and produces a new answer | Fresh input and output token charges |
| Offline evaluation batch | Model runs later, outside interactive path | Batch-eligible token spend and completion contract |
This boundary matters. A semantic answer cache can skip generation. A provider prompt cache reuses matching prefix work but still generates a new output. Treating both as "cache hits" hides both correctness risk and spend.

Use a dated rate card, not remembered prices
Provider pricing is configuration data. For this lab, the teaching fixture snapshots OpenAI's GPT-5.4 short-context rates from May 31, 2026: standard input costs $2.50 per million tokens, standard cached input costs $0.25 per million tokens, and standard output costs $15.00 per million tokens. OpenAI's pricing page publishes Batch rates as a separate row, so the ledger stores those values explicitly too. Its displayed Batch cached-input rate is $0.13 per million tokens; don't replace the published row with a local "divide every rate by two" rule. This dated fixture teaches the ledger shape; it isn't a permanent price promise. Refresh every production rate card from current provider pricing documentation before forecasting a release.[1]
The usage trace, not a character-count estimate, is the source of truth after a model call. A provider can expose total input tokens, the cached subset, and output tokens. The fresh-input charge is the total input minus the cached subset.
For reasoning models such as GPT-5.4, provider-reported output_tokens already includes hidden reasoning tokens billed at the output rate. output_tokens_details.reasoning_tokens is useful diagnostic detail, but adding it to output_tokens again would double-charge the ledger.[2]
1from collections import defaultdict
2from dataclasses import dataclass, replace
3from decimal import Decimal, ROUND_HALF_UP
4
5MILLION = Decimal("1000000")
6CENT = Decimal("0.01")
7
8def dollars(amount: Decimal) -> Decimal:
9 return amount.quantize(CENT, rounding=ROUND_HALF_UP)
10
11@dataclass(frozen=True)
12class TokenRates:
13 input_per_1m: Decimal
14 cached_input_per_1m: Decimal
15 output_per_1m: Decimal
16
17@dataclass(frozen=True)
18class RateCard:
19 rate_card_id: str
20 standard: TokenRates
21 batch: TokenRates
22
23@dataclass(frozen=True)
24class Usage:
25 input_tokens: int
26 cached_input_tokens: int
27 output_tokens: int
28 reasoning_tokens: int = 0
29
30 def __post_init__(self) -> None:
31 if not 0 <= self.cached_input_tokens <= self.input_tokens:
32 raise ValueError("cached input must be a subset of input tokens")
33 if min(self.input_tokens, self.output_tokens) < 0:
34 raise ValueError("token counts cannot be negative")
35 if not 0 <= self.reasoning_tokens <= self.output_tokens:
36 raise ValueError("reasoning tokens must be a subset of output tokens")
37
38rate_card = RateCard(
39 rate_card_id="openai-gpt-5.4-short-context-2026-05-31",
40 standard=TokenRates(
41 input_per_1m=Decimal("2.50"),
42 cached_input_per_1m=Decimal("0.25"),
43 output_per_1m=Decimal("15.00"),
44 ),
45 batch=TokenRates(
46 input_per_1m=Decimal("1.25"),
47 cached_input_per_1m=Decimal("0.13"),
48 output_per_1m=Decimal("7.50"),
49 ),
50)
51
52print(rate_card.rate_card_id)
53print(f"standard_input_per_1m=${rate_card.standard.input_per_1m}")
54print(f"standard_cached_input_per_1m=${rate_card.standard.cached_input_per_1m}")
55print(f"standard_output_per_1m=${rate_card.standard.output_per_1m}")
56print(f"batch_input_per_1m=${rate_card.batch.input_per_1m}")
57print(f"batch_cached_input_per_1m=${rate_card.batch.cached_input_per_1m}")
58print(f"batch_output_per_1m=${rate_card.batch.output_per_1m}")1openai-gpt-5.4-short-context-2026-05-31
2standard_input_per_1m=$2.50
3standard_cached_input_per_1m=$0.25
4standard_output_per_1m=$15.00
5batch_input_per_1m=$1.25
6batch_cached_input_per_1m=$0.13
7batch_output_per_1m=$7.50Price one generated answer
PolicyOps's public-policy-answer path uses a long evaluated instruction prefix and cites policy evidence in every generated response. A cold request reports 1,800 input tokens and 180 output tokens. Split each billable category rather than multiplying one blended token count by one price.
1@dataclass(frozen=True)
2class PricedUsage:
3 fresh_input_usd: Decimal
4 cached_input_usd: Decimal
5 output_usd: Decimal
6
7 @property
8 def total_usd(self) -> Decimal:
9 return self.fresh_input_usd + self.cached_input_usd + self.output_usd
10
11def price_usage(usage: Usage, card: RateCard = rate_card, *, batch: bool = False) -> PricedUsage:
12 rates = card.batch if batch else card.standard
13 fresh_input_tokens = usage.input_tokens - usage.cached_input_tokens
14 return PricedUsage(
15 fresh_input_usd=Decimal(fresh_input_tokens) * rates.input_per_1m / MILLION,
16 cached_input_usd=Decimal(usage.cached_input_tokens) * rates.cached_input_per_1m / MILLION,
17 output_usd=Decimal(usage.output_tokens) * rates.output_per_1m / MILLION,
18 )
19
20cold_policy_answer = Usage(input_tokens=1_800, cached_input_tokens=0, output_tokens=180)
21cold_cost = price_usage(cold_policy_answer)
22
23assert cold_cost.total_usd == Decimal("0.0072")
24assert rate_card.standard.output_per_1m > rate_card.standard.input_per_1m
25
26print(f"fresh_input=${cold_cost.fresh_input_usd:.6f}")
27print(f"output=${cold_cost.output_usd:.6f}")
28print(f"total=${cold_cost.total_usd:.6f}")
29print(f"output_share={(cold_cost.output_usd / cold_cost.total_usd):.1%}")1fresh_input=$0.004500
2output=$0.002700
3total=$0.007200
4output_share=37.5%The output token rate is higher in this rate card, although this long-input request still spends more dollars on input overall. That distinction is why both token volume and per-category rate belong in the ledger. Decode is autoregressive and serving systems manage growing KV-cache state during generation; those mechanisms help explain why output can be priced differently, while the current rate card remains the billing authority.[3][1]
Prefix reuse is still a generated answer
For GPT-5.4, treat prompt-cache eligibility as observed behavior, not a 1,024-token promise. OpenAI's current guidance, checked on July 31, 2026, places GPT-5.4 with GPT-5.5-and-earlier models: the minimum cacheable prefix varies by model from 1,024 to 2,048 tokens, and a prompt just above 1,024 tokens may not cache consistently. Prompt caching still depends on an exact matching prefix at the beginning of the prompt. Count a cache read only when returned usage reports cached_tokens > 0.[4][5] The application design rule is direct: put stable instructions and shared evidence first, then dynamic customer data.
A prefix hit isn't a stored response. The support model still answers the current question and still incurs output spend. The fixture below represents a returned cache read of 1,280 tokens; it doesn't assert that a prompt at any specific threshold will hit.
1prefix_cached_policy_answer = Usage(
2 input_tokens=1_800,
3 cached_input_tokens=1_280,
4 output_tokens=180,
5)
6prefix_cost = price_usage(prefix_cached_policy_answer)
7savings = cold_cost.total_usd - prefix_cost.total_usd
8
9assert prefix_cost.total_usd == Decimal("0.004320")
10assert prefix_cost.output_usd == cold_cost.output_usd
11assert savings == Decimal("0.002880")
12
13print(f"cold_generation=${cold_cost.total_usd:.6f}")
14print(f"prefix_cached_generation=${prefix_cost.total_usd:.6f}")
15print(f"saved_per_generated_answer=${savings:.6f}")
16print(f"still_generated={prefix_cost.output_usd > 0}")1cold_generation=$0.007200
2prefix_cached_generation=$0.004320
3saved_per_generated_answer=$0.002880
4still_generated=TrueWhy does a provider prompt-cache hit still have an output charge?
Answer
It reuses processing for an exactly matching input prefix. The model still generates the new response, so output tokens remain billable.
Carry forward the semantic-cache decision
The preceding lesson promoted semantic response reuse only for stable public-policy answers under one evaluated release scope. Cost accounting consumes that decision; it doesn't rebuild its vector index or threshold. A stored-answer hit records zero generated tokens, while a prompt-prefix hit records a newly generated answer at a reduced input cost.
1@dataclass(frozen=True)
2class RequestOutcome:
3 feature: str
4 decision: str
5 usage: Usage | None
6 counterfactual_usage: Usage | None = None
7
8def generated_cost(outcome: RequestOutcome) -> Decimal:
9 if outcome.usage is None:
10 return Decimal("0")
11 return price_usage(outcome.usage).total_usd
12
13semantic_hit = RequestOutcome(
14 feature="public-policy-answer",
15 decision="SEMANTIC_ANSWER_HIT",
16 usage=None,
17 counterfactual_usage=prefix_cached_policy_answer,
18)
19prefix_hit = RequestOutcome(
20 feature="public-policy-answer",
21 decision="GENERATE_PREFIX_HIT",
22 usage=prefix_cached_policy_answer,
23)
24
25avoided_generation = price_usage(semantic_hit.counterfactual_usage).total_usd
26assert generated_cost(semantic_hit) == Decimal("0")
27assert generated_cost(prefix_hit) == Decimal("0.004320")
28
29print(f"semantic_hit_generation=${generated_cost(semantic_hit):.6f}")
30print(f"semantic_hit_avoided=${avoided_generation:.6f}")
31print(f"prefix_hit_generation=${generated_cost(prefix_hit):.6f}")1semantic_hit_generation=$0.000000
2semantic_hit_avoided=$0.004320
3prefix_hit_generation=$0.004320
Replay a day of traffic
A per-request example is useful, but a release decision needs traffic shape. For a PolicyOps baseline replay day, before testing a shorter exception answer:
- 3,200 stable policy questions safely hit the semantic answer cache.
- 1,800 policy questions still generate, but reuse the evaluated prompt prefix.
- 3,000 live-order questions must generate from current tool evidence.
- 500 return exceptions generate a longer cited answer with the stable prefix.
The semantic-hit counterfactual answers "what cost did safe reuse avoid?" Actual spend answers "what appears on this release ledger?"
1@dataclass(frozen=True)
2class TrafficSlice:
3 outcome: RequestOutcome
4 requests: int
5
6daily_replay = [
7 TrafficSlice(semantic_hit, requests=3_200),
8 TrafficSlice(prefix_hit, requests=1_800),
9 TrafficSlice(
10 RequestOutcome(
11 feature="live-order-answer",
12 decision="GENERATE_LIVE_DATA",
13 usage=Usage(input_tokens=900, cached_input_tokens=0, output_tokens=95),
14 ),
15 requests=3_000,
16 ),
17 TrafficSlice(
18 RequestOutcome(
19 feature="access-exception-answer",
20 decision="GENERATE_PREFIX_HIT",
21 usage=Usage(input_tokens=2_200, cached_input_tokens=1_280, output_tokens=220),
22 ),
23 requests=500,
24 ),
25]
26
27actual_daily_spend = sum(
28 generated_cost(slice_.outcome) * slice_.requests
29 for slice_ in daily_replay
30)
31avoided_daily_generation = sum(
32 price_usage(slice_.outcome.counterfactual_usage).total_usd * slice_.requests
33 for slice_ in daily_replay
34 if slice_.outcome.counterfactual_usage is not None
35)
36
37assert actual_daily_spend == Decimal("21.761000")
38assert avoided_daily_generation == Decimal("13.824000")
39
40print(f"generated_daily_spend=${dollars(actual_daily_spend)}")
41print(f"semantic_hit_avoided_daily=${dollars(avoided_daily_generation)}")
42print(f"request_count={sum(slice_.requests for slice_ in daily_replay)}")1generated_daily_spend=$21.76
2semantic_hit_avoided_daily=$13.82
3request_count=8500Avoided generation is useful evidence, but it isn't a credit and shouldn't be mixed into actual spend. If an answer hit later fails review, the evaluation consequence matters before its apparent savings.
Reduce output only under an answer contract
Output reductions can matter in a rate card where output is expensive. Blind truncation isn't a cost strategy. PolicyOps's cited support answer must state eligibility, cite the policy evidence, and name the requester's next step. Compare three candidate formats generated on the same input:
| Candidate | Intended change | Safe for automatic support? |
|---|---|---|
| Verbose | Full explanation with repetition | Correct but unnecessarily long |
| Concise cited | One decision, one citation, one next step | Candidate for release |
| Truncated | Hard stop before citation and action | Reject |
1@dataclass(frozen=True)
2class AnswerCandidate:
3 label: str
4 output_tokens: int
5 states_decision: bool
6 cites_policy: bool
7 gives_next_step: bool
8
9def satisfies_answer_contract(candidate: AnswerCandidate) -> bool:
10 return (
11 candidate.states_decision
12 and candidate.cites_policy
13 and candidate.gives_next_step
14 )
15
16candidates = [
17 AnswerCandidate("verbose", 220, True, True, True),
18 AnswerCandidate("concise_cited", 130, True, True, True),
19 AnswerCandidate("truncated", 55, True, False, False),
20]
21safe_candidates = [candidate for candidate in candidates if satisfies_answer_contract(candidate)]
22selected_answer = min(safe_candidates, key=lambda candidate: candidate.output_tokens)
23
24verbose_usage = Usage(input_tokens=2_200, cached_input_tokens=1_280, output_tokens=220)
25concise_usage = Usage(input_tokens=2_200, cached_input_tokens=1_280, output_tokens=selected_answer.output_tokens)
26saved_per_exception = price_usage(verbose_usage).total_usd - price_usage(concise_usage).total_usd
27
28assert selected_answer.label == "concise_cited"
29assert not satisfies_answer_contract(candidates[-1])
30assert saved_per_exception == Decimal("0.001350")
31
32print(f"selected={selected_answer.label}")
33print(f"rejected={candidates[-1].label}")
34print(f"saved_per_exception=${saved_per_exception:.6f}")
35print(f"saved_per_replay_day=${dollars(saved_per_exception * 500)}")1selected=concise_cited
2rejected=truncated
3saved_per_exception=$0.001350
4saved_per_replay_day=$0.68The cheapest candidate is rejected. Cost reductions count only after the output still meets the same correctness and evidence contract.
Batch only work that can wait
OpenAI's Batch documentation describes a separate asynchronous workload path with a 50% discount compared with synchronous APIs and a 24-hour completion window.[6] The pricing page publishes Batch token rates explicitly, so the ledger reads that row instead of deriving every category from one multiplier.[1] In this fixture, the displayed $0.13 Batch cached-input row means the replay total isn't the exact result of halving every Standard-row decimal locally. That path is useful for nightly regression evaluation of the support release. It isn't appropriate for a customer waiting in a live conversation.
1@dataclass(frozen=True)
2class Workload:
3 name: str
4 interactive: bool
5 deadline_hours: int
6 endpoint_supported: bool
7 daily_requests: int
8 usage: Usage
9
10def batch_eligible(workload: Workload) -> bool:
11 return (
12 not workload.interactive
13 and workload.deadline_hours >= 24
14 and workload.endpoint_supported
15 )
16
17live_support = Workload(
18 name="live-support-answer",
19 interactive=True,
20 deadline_hours=0,
21 endpoint_supported=True,
22 daily_requests=3_000,
23 usage=Usage(input_tokens=900, cached_input_tokens=0, output_tokens=95),
24)
25nightly_eval = Workload(
26 name="nightly-release-eval",
27 interactive=False,
28 deadline_hours=24,
29 endpoint_supported=True,
30 daily_requests=2_000,
31 usage=Usage(input_tokens=1_800, cached_input_tokens=1_280, output_tokens=80),
32)
33
34nightly_sync_cost = price_usage(nightly_eval.usage).total_usd * nightly_eval.daily_requests
35nightly_batch_cost = price_usage(nightly_eval.usage, batch=True).total_usd * nightly_eval.daily_requests
36
37assert not batch_eligible(live_support)
38assert batch_eligible(nightly_eval)
39assert nightly_batch_cost == Decimal("2.832800")
40assert nightly_batch_cost < nightly_sync_cost
41
42print(f"live_support_batchable={batch_eligible(live_support)}")
43print(f"nightly_eval_batchable={batch_eligible(nightly_eval)}")
44print(f"nightly_eval_sync=${dollars(nightly_sync_cost)}")
45print(f"nightly_eval_batch=${dollars(nightly_batch_cost)}")1live_support_batchable=False
2nightly_eval_batchable=True
3nightly_eval_sync=$5.64
4nightly_eval_batch=$2.83Your nightly evaluation workload is cheaper through Batch. Should the interactive support endpoint use the same path?
Answer
No. The Batch completion window changes latency semantics. Only work that can wait belongs on that path.
Attribute cost to decisions, not models alone
An invoice tells you how much you spent. A release trace tells you why. Each recorded row should name the evaluated release, feature, decision, pricing mode, rate-card identity, token usage, and output-contract evidence. Recording only a model name can't distinguish an unsafe answer-cache promotion from a harmless increase in live-order traffic. Make contract evidence and rate-card identity mandatory on every trace instead of silently repricing old usage with whichever table is loaded now.
1@dataclass(frozen=True)
2class LedgerTrace:
3 release_id: str
4 feature: str
5 decision: str
6 requests: int
7 usage: Usage | None
8 rate_card_id: str
9 contract_passed: bool
10 contract_evidence_id: str
11 batch: bool = False
12
13def trace_unit_spend(trace: LedgerTrace, card: RateCard = rate_card) -> Decimal:
14 if trace.rate_card_id != card.rate_card_id:
15 raise ValueError("trace rate card does not match pricing table")
16 if trace.usage is None:
17 return Decimal("0")
18 return price_usage(trace.usage, card, batch=trace.batch).total_usd
19
20def trace_spend(trace: LedgerTrace, card: RateCard = rate_card) -> Decimal:
21 return trace_unit_spend(trace, card) * trace.requests
22
23release_id = "support-release-2026-05-cost-v1"
24optimized_replay = [
25 TrafficSlice(
26 RequestOutcome(
27 feature=item.outcome.feature,
28 decision=item.outcome.decision,
29 usage=concise_usage if item.outcome.feature == "access-exception-answer" else item.outcome.usage,
30 counterfactual_usage=item.outcome.counterfactual_usage,
31 ),
32 requests=item.requests,
33 )
34 for item in daily_replay
35]
36traces = [
37 LedgerTrace(
38 release_id=release_id,
39 feature=item.outcome.feature,
40 decision=item.outcome.decision,
41 requests=item.requests,
42 usage=item.outcome.usage,
43 rate_card_id=rate_card.rate_card_id,
44 contract_passed=True,
45 contract_evidence_id=(
46 "approved-public-policy-cache@v1"
47 if item.outcome.decision == "SEMANTIC_ANSWER_HIT"
48 else "cited-support-answer-v3@canary"
49 ),
50 )
51 for item in optimized_replay
52]
53traces.append(
54 LedgerTrace(
55 release_id=release_id,
56 feature=nightly_eval.name,
57 decision="BATCH_OFFLINE_EVAL",
58 requests=nightly_eval.daily_requests,
59 usage=nightly_eval.usage,
60 rate_card_id=rate_card.rate_card_id,
61 contract_passed=True,
62 contract_evidence_id="nightly-release-eval@batch-eligible",
63 batch=True,
64 )
65)
66
67spend_by_feature: defaultdict[str, Decimal] = defaultdict(lambda: Decimal("0"))
68for trace in traces:
69 spend_by_feature[trace.feature] += trace_spend(trace)
70
71total_with_eval = sum(spend_by_feature.values())
72trace_contracts_complete = all(
73 trace.contract_passed
74 and trace.contract_evidence_id
75 and trace.rate_card_id == rate_card.rate_card_id
76 for trace in traces
77)
78stale_trace = replace(traces[0], rate_card_id="openai-gpt-5.4-short-context-2026-04-30")
79assert spend_by_feature["public-policy-answer"] == Decimal("7.776000")
80assert actual_daily_spend - sum(trace_spend(trace) for trace in traces[:-1]) == Decimal("0.675000")
81assert trace_contracts_complete
82
83for feature in sorted(spend_by_feature):
84 print(f"{feature}=${dollars(spend_by_feature[feature])}")
85print(f"daily_total_with_eval=${dollars(total_with_eval)}")
86print(f"trace_contracts_complete={trace_contracts_complete}")
87try:
88 trace_spend(stale_trace)
89except ValueError as error:
90 print(f"stale_rate_card_rejected={error}")1access-exception-answer=$2.29
2live-order-answer=$11.03
3nightly-release-eval=$2.83
4public-policy-answer=$7.78
5daily_total_with_eval=$23.92
6trace_contracts_complete=True
7stale_rate_card_rejected=trace rate card does not match pricing table
Forecast and gate the release
A cost target without quality is an incentive to produce short, wrong answers. A quality target without cost leaves a production team unable to plan capacity or margins. Require both.
The canary report below evaluates the concise cited-answer policy for return exceptions. The replay's request mix is projected across 30 days, including its nightly offline evaluation run.
1@dataclass(frozen=True)
2class QualityReport:
3 cited_answer_pass_rate: Decimal
4 unsafe_cache_hits: int
5 evaluated_answers: int
6
7@dataclass(frozen=True)
8class PromotionPolicy:
9 monthly_budget_usd: Decimal
10 minimum_cited_answer_pass_rate: Decimal
11 maximum_unsafe_cache_hits: int
12 minimum_evaluated_answers: int
13
14quality = QualityReport(
15 cited_answer_pass_rate=Decimal("0.997"),
16 unsafe_cache_hits=0,
17 evaluated_answers=2_000,
18)
19promotion_policy = PromotionPolicy(
20 monthly_budget_usd=Decimal("750.00"),
21 minimum_cited_answer_pass_rate=Decimal("0.995"),
22 maximum_unsafe_cache_hits=0,
23 minimum_evaluated_answers=2_000,
24)
25monthly_forecast = total_with_eval * Decimal("30")
26budget_headroom = promotion_policy.monthly_budget_usd - monthly_forecast
27uniform_volume_growth_headroom = budget_headroom / monthly_forecast
28
29sample_size_passed = (
30 quality.evaluated_answers >= promotion_policy.minimum_evaluated_answers
31)
32quality_passed = (
33 trace_contracts_complete
34 and sample_size_passed
35 and quality.cited_answer_pass_rate >= promotion_policy.minimum_cited_answer_pass_rate
36 and quality.unsafe_cache_hits <= promotion_policy.maximum_unsafe_cache_hits
37)
38budget_passed = monthly_forecast <= promotion_policy.monthly_budget_usd
39
40assert dollars(monthly_forecast) == Decimal("717.56")
41assert dollars(budget_headroom) == Decimal("32.44")
42assert quality_passed and budget_passed
43
44print(f"monthly_forecast=${dollars(monthly_forecast)}")
45print(f"budget_headroom=${dollars(budget_headroom)}")
46print(f"uniform_volume_growth_headroom={uniform_volume_growth_headroom:.1%}")
47print(f"sample_size_passed={sample_size_passed}")
48print(f"quality_passed={quality_passed}")
49print(f"budget_passed={budget_passed}")1monthly_forecast=$717.56
2budget_headroom=$32.44
3uniform_volume_growth_headroom=4.5%
4sample_size_passed=True
5quality_passed=True
6budget_passed=TrueThis forecast repeats the measured replay day 30 times. Only 4.5% uniform traffic growth would consume the remaining budget, so production planning should also replay low, expected, and high request-volume scenarios. A point forecast that barely passes isn't a comfortable operating margin.

Two routes have the same cost per attempt, but one often needs a retry before producing a valid answer. Which cost should the release decision compare?
Answer
Compare cost per successful answer or task, including retries and failed attempts. Cost per request would hide the extra spend caused by lower reliability.
Produce the next policy input
The next lesson will implement model gateway routing and fallbacks. Give it a budget contract, not a half-built router embedded in cost analysis. The contract records what a future route must preserve: citation schema, an approved generated-answer ceiling, and the release evidence behind the limit. The ceiling must be a policy value that the observed maximum fits under, not the observed maximum relabeled as a budget.
Treat that ceiling as a lane-admission artifact for one successful generation, not as a hard bound on total request spend. A failed primary can still bill before timeout, and a contract-preserving fallback bills again. Incident TCO can exceed maximum_generated_answer_usd when attempt costs stack; the gateway lesson tracks request-level attempt spend separately from the per-answer admission ceiling.
1@dataclass(frozen=True)
2class GatewayBudgetContract:
3 release_id: str
4 required_answer_schema: str
5 maximum_generated_answer_usd: Decimal
6 monthly_forecast_usd: Decimal
7 rate_card_id: str
8 status: str
9
10observed_max_generated_cost = max(
11 trace_unit_spend(trace)
12 for trace in traces
13 if trace.usage is not None and not trace.batch
14)
15per_answer_budget_usd = Decimal("0.005000")
16assert observed_max_generated_cost <= per_answer_budget_usd
17
18status = "PROMOTE_COST_POLICY" if quality_passed and budget_passed else "HOLD_RELEASE"
19gateway_contract = GatewayBudgetContract(
20 release_id=release_id,
21 required_answer_schema="cited-support-answer-v3",
22 maximum_generated_answer_usd=per_answer_budget_usd,
23 monthly_forecast_usd=dollars(monthly_forecast),
24 rate_card_id=rate_card.rate_card_id,
25 status=status,
26)
27
28assert gateway_contract.status == "PROMOTE_COST_POLICY"
29assert observed_max_generated_cost == Decimal("0.004570")
30assert gateway_contract.maximum_generated_answer_usd == Decimal("0.005000")
31
32print(f"status={gateway_contract.status}")
33print(f"required_schema={gateway_contract.required_answer_schema}")
34print(f"observed_max_generated_answer=${observed_max_generated_cost:.6f}")
35print(f"max_generated_answer=${gateway_contract.maximum_generated_answer_usd:.6f}")
36print("next_step=apply_contract_in_gateway_routing")1status=PROMOTE_COST_POLICY
2required_schema=cited-support-answer-v3
3observed_max_generated_answer=$0.004570
4max_generated_answer=$0.005000
5next_step=apply_contract_in_gateway_routingWhat the ledger proves
This release did not become cheaper because of one vague "cache" switch:
| Evidence | Correct conclusion |
|---|---|
| Semantic answer hit with valid release scope | New generation was avoided for an already approved answer class |
Provider cached_tokens on a generated answer | Repeated prefix processing was charged at the cached-input rate |
| Concise cited-answer candidate passes evaluation | Output reduction is eligible for release |
| Nightly evaluation classified as noninteractive | Batch discount can be modeled without changing customer latency |
| Every usage trace pins the loaded rate-card identity | Historical spend remains reproducible instead of being silently repriced |
| Monthly forecast and quality check pass | Cost policy can be promoted and handed to gateway design |
Wait for usage traces before claiming a prompt rewrite improved pricing through cached tokens. Count semantic-cache savings only when accepted-hit quality remains valid. Never approve a shorter output merely because it costs less.