Skip to content

Commit 1f7d131

Browse files
authored
Merge pull request #1 from yanpgwang/feature/cost-and-forecast
Add cost estimation and context budget forecasting
2 parents 6bf0a7b + c8b8fff commit 1f7d131

7 files changed

Lines changed: 748 additions & 0 deletions

File tree

src/context_profiler/analyzers/token_counter.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from context_profiler.analyzers.base import AnalyzerResult, BaseAnalyzer
88
from context_profiler.models import APIRequest, BlockType, Role
9+
from context_profiler.pricing import estimate_cost
910

1011

1112
class TokenCounterAnalyzer(BaseAnalyzer):
@@ -60,12 +61,19 @@ def analyze(self, request: APIRequest) -> AnalyzerResult:
6061
tool_use_tokens = by_content_type.get("tool_use", 0)
6162
tool_result_tokens = by_content_type.get("tool_result", 0)
6263

64+
cost = estimate_cost(
65+
input_tokens=total_tokens,
66+
output_tokens=0,
67+
model=request.model,
68+
)
69+
6370
summary = {
6471
"total_input_tokens": total_tokens,
6572
"message_tokens": total_tokens - tool_def_tokens,
6673
"tool_definition_tokens": tool_def_tokens,
6774
"system_prompt_tokens": request.system_prompt_tokens,
6875
"source_format": request.source_format,
76+
"model": request.model,
6977
"by_role": dict(by_role),
7078
"by_content_type": dict(by_content_type),
7179
"tool_use_tokens": tool_use_tokens,
@@ -76,6 +84,9 @@ def analyze(self, request: APIRequest) -> AnalyzerResult:
7684
"tool_definitions": tool_defs_detail,
7785
}
7886

87+
if cost is not None:
88+
summary["cost"] = cost
89+
7990
warnings = []
8091
if tool_def_tokens > total_tokens * 0.3:
8192
warnings.append(

src/context_profiler/diagnostics.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from context_profiler.context_diff import analyze_context_diff
88
from context_profiler.formats import describe_format
99
from context_profiler.models import Session
10+
from context_profiler.pricing import estimate_cost
1011
from context_profiler.profiler import ProfileResult
1112
from context_profiler.session_insights import analyze_session_insights
1213

@@ -158,6 +159,37 @@ def diagnose_result(result: ProfileResult, session: Session | None = None) -> di
158159
"recommendation": "Move stable repeated arguments into references or shorter identifiers.",
159160
})
160161

162+
# Context overflow risk from budget forecast
163+
forecast = session_insights.get("budget_forecast")
164+
if forecast and forecast.get("estimated_overflow_turn") is not None:
165+
current_turn_count = len(session.requests) if session else 0
166+
overflow_turn = forecast["estimated_overflow_turn"]
167+
utilization = forecast["current_utilization"]
168+
# Trigger if overflow is within 2x the current turn count
169+
if current_turn_count > 0 and overflow_turn <= current_turn_count * 2:
170+
if utilization > 0.8:
171+
severity = "critical"
172+
elif utilization > 0.5:
173+
severity = "warning"
174+
else:
175+
severity = "info"
176+
issues.append({
177+
"code": "CONTEXT_OVERFLOW_RISK",
178+
"severity": severity,
179+
"message": "Context is projected to overflow the model window at the current growth rate.",
180+
"evidence": {
181+
"growth_rate_per_turn": forecast["growth_rate_per_turn"],
182+
"current_utilization": forecast["current_utilization"],
183+
"estimated_overflow_turn": forecast["estimated_overflow_turn"],
184+
"context_window_tokens": forecast["context_window_tokens"],
185+
"model": forecast["model"],
186+
},
187+
"recommendation": "Consider compacting earlier turns, summarizing tool results, or removing stale context before the window fills.",
188+
})
189+
190+
# Cost estimation
191+
cost_info = _compute_cost(result, session)
192+
161193
return {
162194
"schema_version": "0.1",
163195
"source": result.source,
@@ -168,12 +200,39 @@ def diagnose_result(result: ProfileResult, session: Session | None = None) -> di
168200
"warnings": result.all_warnings,
169201
},
170202
"issues": issues,
203+
"cost": cost_info,
171204
"diff_summary": diff["diff_summary"],
172205
"diff_hints": diff["diff_hints"] + session_insights["hints"],
173206
"session_insights": session_insights,
174207
}
175208

176209

210+
def _compute_cost(result: ProfileResult, session: Session | None = None) -> dict[str, Any] | None:
211+
"""Compute cost estimation for the profiled request or session."""
212+
token = result.analyzer_results.get("token_counter")
213+
if not token:
214+
return None
215+
216+
summary = token.summary
217+
model = summary.get("model", "unknown")
218+
219+
if session and session.requests:
220+
# Session mode: sum input tokens across all requests
221+
total_input = sum(req.total_input_tokens for req in session.requests)
222+
cost = estimate_cost(input_tokens=total_input, model=model)
223+
if cost:
224+
cost["mode"] = "session"
225+
cost["num_requests"] = len(session.requests)
226+
return cost
227+
else:
228+
# Snapshot mode: single request
229+
total_input = summary.get("total_input_tokens", 0)
230+
cost = estimate_cost(input_tokens=total_input, model=model)
231+
if cost:
232+
cost["mode"] = "snapshot"
233+
return cost
234+
235+
177236
def _analysis_scope(result: ProfileResult, session: Session | None = None) -> dict[str, Any]:
178237
source_format = _source_format(result) or (
179238
session.metadata.get("source_format") if session is not None else None

src/context_profiler/pricing.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Model pricing table for cost estimation.
2+
3+
Maps model name patterns to input/output pricing per 1M tokens (USD).
4+
Prices are approximate and should be updated as providers change rates.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from dataclasses import dataclass
10+
from typing import Any
11+
12+
13+
@dataclass
14+
class ModelPricing:
15+
"""Pricing for a single model tier."""
16+
17+
input_per_1m: float # USD per 1M input tokens
18+
output_per_1m: float # USD per 1M output tokens
19+
display_name: str
20+
21+
22+
# Patterns are matched in order; first match wins.
23+
# Use lowercase substrings for matching against model identifiers.
24+
PRICING_TABLE: list[tuple[list[str], ModelPricing]] = [
25+
# Claude models
26+
(
27+
["claude-opus-4", "claude-4-opus"],
28+
ModelPricing(input_per_1m=15.0, output_per_1m=75.0, display_name="Claude Opus 4"),
29+
),
30+
(
31+
["claude-sonnet-4", "claude-4-sonnet"],
32+
ModelPricing(input_per_1m=3.0, output_per_1m=15.0, display_name="Claude Sonnet 4"),
33+
),
34+
(
35+
["claude-3-5-sonnet", "claude-3.5-sonnet"],
36+
ModelPricing(input_per_1m=3.0, output_per_1m=15.0, display_name="Claude 3.5 Sonnet"),
37+
),
38+
(
39+
["claude-3-5-haiku", "claude-3.5-haiku"],
40+
ModelPricing(input_per_1m=0.80, output_per_1m=4.0, display_name="Claude 3.5 Haiku"),
41+
),
42+
(
43+
["claude-3-opus"],
44+
ModelPricing(input_per_1m=15.0, output_per_1m=75.0, display_name="Claude 3 Opus"),
45+
),
46+
(
47+
["claude-3-sonnet"],
48+
ModelPricing(input_per_1m=3.0, output_per_1m=15.0, display_name="Claude 3 Sonnet"),
49+
),
50+
(
51+
["claude-3-haiku"],
52+
ModelPricing(input_per_1m=0.25, output_per_1m=1.25, display_name="Claude 3 Haiku"),
53+
),
54+
# GPT models
55+
(
56+
["gpt-4o-mini"],
57+
ModelPricing(input_per_1m=0.15, output_per_1m=0.60, display_name="GPT-4o mini"),
58+
),
59+
(
60+
["gpt-4o"],
61+
ModelPricing(input_per_1m=2.50, output_per_1m=10.0, display_name="GPT-4o"),
62+
),
63+
(
64+
["gpt-4-turbo"],
65+
ModelPricing(input_per_1m=10.0, output_per_1m=30.0, display_name="GPT-4 Turbo"),
66+
),
67+
]
68+
69+
70+
def lookup_pricing(model: str) -> ModelPricing | None:
71+
"""Find pricing for a model by matching name patterns.
72+
73+
Returns None if no match is found.
74+
"""
75+
if not model or model == "unknown":
76+
return None
77+
78+
model_lower = model.lower()
79+
for patterns, pricing in PRICING_TABLE:
80+
for pattern in patterns:
81+
if pattern in model_lower:
82+
return pricing
83+
return None
84+
85+
86+
def estimate_cost(
87+
input_tokens: int,
88+
output_tokens: int = 0,
89+
model: str = "unknown",
90+
) -> dict[str, Any] | None:
91+
"""Estimate cost for a request given token counts and model.
92+
93+
Returns a dict with cost breakdown, or None if model is unknown.
94+
"""
95+
pricing = lookup_pricing(model)
96+
if pricing is None:
97+
return None
98+
99+
input_cost = (input_tokens / 1_000_000) * pricing.input_per_1m
100+
output_cost = (output_tokens / 1_000_000) * pricing.output_per_1m
101+
102+
return {
103+
"estimated_input_cost_usd": round(input_cost, 6),
104+
"estimated_output_cost_usd": round(output_cost, 6),
105+
"estimated_total_cost_usd": round(input_cost + output_cost, 6),
106+
"estimated_model": pricing.display_name,
107+
"input_tokens": input_tokens,
108+
"output_tokens": output_tokens,
109+
}

src/context_profiler/reporters/cli_reporter.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,15 @@ def _render_token_summary(console: Console, summary: dict) -> None:
114114
tool_table.add_row(f" {tool_name}", _format_tokens(tokens), _pct(tokens, total))
115115
console.print(tool_table)
116116

117+
cost = summary.get("cost")
118+
if cost:
119+
console.print()
120+
console.print("[bold] Estimated Cost[/bold]")
121+
model_name = cost.get("estimated_model", "unknown")
122+
input_cost = cost.get("estimated_input_cost_usd", 0)
123+
console.print(f" Model: {model_name}")
124+
console.print(f" Input cost: ${input_cost:.4f}")
125+
117126

118127
def _render_timeline(console: Console, timeline: list[dict]) -> None:
119128
console.print()

src/context_profiler/session_insights.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
_COMPRESSION_DROP_MIN_TOKENS = 5_000
2323
_COMPRESSION_DROP_RATIO = 0.15
2424

25+
# Context window sizes by model family (tokens)
26+
# Ordered longest-prefix-first for correct matching
27+
_CONTEXT_WINDOW_SIZES: list[tuple[str, int]] = [
28+
("gpt-4o-mini", 128_000),
29+
("gpt-4-turbo", 128_000),
30+
("gpt-4o", 128_000),
31+
("claude", 200_000),
32+
]
33+
_DEFAULT_CONTEXT_WINDOW = 128_000
34+
_OVERFLOW_RISK_HORIZON_MULTIPLIER = 2 # warn if overflow within 2x current turn count
35+
2536

2637
def analyze_session_insights(session: Session | None) -> dict[str, Any]:
2738
"""Summarize session-level carryover, budget, and artifact lifecycle signals."""
@@ -41,6 +52,7 @@ def analyze_session_insights(session: Session | None) -> dict[str, Any]:
4152
artifacts = _artifact_lifecycles(blocks_by_request)
4253
artifact_duplications = _artifact_duplications(session)
4354
propagation = _propagation_graph(blocks_by_request)
55+
forecast = budget_forecast(session)
4456
hints = _build_hints(carryover, budget_events, artifacts, artifact_duplications)
4557

4658
return {
@@ -49,6 +61,7 @@ def analyze_session_insights(session: Session | None) -> dict[str, Any]:
4961
"artifact_lifecycles": artifacts,
5062
"artifact_duplications": artifact_duplications,
5163
"propagation": propagation,
64+
"budget_forecast": forecast,
5265
"hints": hints,
5366
}
5467

@@ -525,3 +538,58 @@ def _find_first_key(value: Any, keys: tuple[str, ...]) -> Any | None:
525538
if found is not None:
526539
return found
527540
return None
541+
542+
543+
# ---------------------------------------------------------------------------
544+
# Budget Forecast
545+
# ---------------------------------------------------------------------------
546+
547+
548+
def _resolve_context_window(model: str) -> tuple[str, int]:
549+
"""Match a model string to a known context window size.
550+
551+
Returns (matched_model_family, context_window_tokens).
552+
"""
553+
lowered = model.lower()
554+
for prefix, size in _CONTEXT_WINDOW_SIZES:
555+
if prefix in lowered:
556+
return prefix, size
557+
return "default", _DEFAULT_CONTEXT_WINDOW
558+
559+
560+
def budget_forecast(session: Session) -> dict[str, Any] | None:
561+
"""Predict when a session will hit the context window limit.
562+
563+
Returns None for sessions with fewer than 2 requests (not enough data).
564+
"""
565+
if session is None or len(session.requests) < 2:
566+
return None
567+
568+
token_counts = [req.total_input_tokens for req in session.requests]
569+
num_turns = len(token_counts)
570+
571+
# Determine model from the last request (most representative)
572+
model_raw = session.requests[-1].model or "unknown"
573+
matched_model, context_window = _resolve_context_window(model_raw)
574+
575+
# Calculate turn-over-turn deltas for growth rate
576+
deltas = [token_counts[i] - token_counts[i - 1] for i in range(1, num_turns)]
577+
growth_rate = sum(deltas) / len(deltas) if deltas else 0.0
578+
579+
current_tokens = token_counts[-1]
580+
current_utilization = current_tokens / context_window if context_window else 0.0
581+
582+
# Predict overflow turn (linear extrapolation from current position)
583+
estimated_overflow_turn: int | None = None
584+
if growth_rate > 0:
585+
remaining = context_window - current_tokens
586+
turns_until_overflow = remaining / growth_rate
587+
estimated_overflow_turn = num_turns + int(turns_until_overflow)
588+
589+
return {
590+
"growth_rate_per_turn": round(growth_rate, 1),
591+
"current_utilization": round(current_utilization, 4),
592+
"estimated_overflow_turn": estimated_overflow_turn,
593+
"context_window_tokens": context_window,
594+
"model": matched_model,
595+
}

0 commit comments

Comments
 (0)