chopratejas commited on
Commit
7de545c
·
1 Parent(s): eb1c19a

fix: resolve mypy type errors across codebase

Browse files

- Add type annotations for variables with inferred Any types
- Add close() method to MemoryBackend protocol
- Fix union type assignments in eval runners
- Add explicit casts for return type mismatches
- Fix None callable issues with assertions
- Regenerate uv.lock to fix corrupted pillow dependency

headroom/cli.py CHANGED
@@ -31,6 +31,7 @@ from __future__ import annotations
31
 
32
  import argparse
33
  import sys
 
34
 
35
 
36
  def get_version() -> str:
@@ -224,7 +225,7 @@ def cmd_memory_eval(args: argparse.Namespace) -> int:
224
  return 1
225
 
226
  # Create LLM judge if enabled
227
- llm_judge_fn = None
228
  if args.llm_judge:
229
  # Use answer model for judge if not explicitly set
230
  judge_model = args.judge_model
 
31
 
32
  import argparse
33
  import sys
34
+ from collections.abc import Callable
35
 
36
 
37
  def get_version() -> str:
 
225
  return 1
226
 
227
  # Create LLM judge if enabled
228
+ llm_judge_fn: Callable[[str, str, str], tuple[float, str]] | None = None
229
  if args.llm_judge:
230
  # Use answer model for judge if not explicitly set
231
  judge_model = args.judge_model
headroom/evals/memory/runner.py CHANGED
@@ -338,7 +338,8 @@ IMPORTANT: Every event MUST have a specific date. If you cannot determine the da
338
  json_match = re.search(r"\{.*\}", content, re.DOTALL)
339
  if json_match:
340
  data = json.loads(json_match.group())
341
- return data.get("memories", [])
 
342
  except Exception as e:
343
  logger.warning(f"Memory extraction failed: {e}")
344
 
@@ -359,7 +360,7 @@ IMPORTANT: Every event MUST have a specific date. If you cannot determine the da
359
  if self.memory is None:
360
  raise RuntimeError("Memory system not initialized")
361
 
362
- memories_data = []
363
  user_id = f"locomo_{conversation.sample_id}"
364
 
365
  if self.config.extract_memories:
 
338
  json_match = re.search(r"\{.*\}", content, re.DOTALL)
339
  if json_match:
340
  data = json.loads(json_match.group())
341
+ memories: list[dict[str, str]] = data.get("memories", [])
342
+ return memories
343
  except Exception as e:
344
  logger.warning(f"Memory extraction failed: {e}")
345
 
 
360
  if self.memory is None:
361
  raise RuntimeError("Memory system not initialized")
362
 
363
+ memories_data: list[dict[str, Any]] = []
364
  user_id = f"locomo_{conversation.sample_id}"
365
 
366
  if self.config.extract_memories:
headroom/evals/memory/runner_v2.py CHANGED
@@ -339,7 +339,7 @@ class LoCoMoEvaluatorV2:
339
  answer_model: LLM model for answering questions.
340
  config: Evaluation configuration.
341
  """
342
- self._backend: MemoryBackend | None = backend
343
  self._answer_model = answer_model
344
  self._config = config or MemoryEvalConfigV2()
345
  self._metrics = EvalMetrics()
@@ -675,7 +675,7 @@ The answer should contain ONLY the specific information requested, nothing more.
675
  answer_start = time.time()
676
 
677
  # Allow multiple tool call rounds
678
- messages = [
679
  {"role": "system", "content": system_prompt},
680
  {"role": "user", "content": f"Question: {case.question}"},
681
  ]
 
339
  answer_model: LLM model for answering questions.
340
  config: Evaluation configuration.
341
  """
342
+ self._backend: MemoryBackend | LocalBackend | None = backend
343
  self._answer_model = answer_model
344
  self._config = config or MemoryEvalConfigV2()
345
  self._metrics = EvalMetrics()
 
675
  answer_start = time.time()
676
 
677
  # Allow multiple tool call rounds
678
+ messages: list[dict[str, Any]] = [
679
  {"role": "system", "content": system_prompt},
680
  {"role": "user", "content": f"Question: {case.question}"},
681
  ]
headroom/evals/memory/runner_v3.py CHANGED
@@ -38,6 +38,7 @@ from headroom.evals.memory.locomo import (
38
  from headroom.memory.backends.local import LocalBackend, LocalBackendConfig
39
  from headroom.memory.backends.mem0 import Mem0Backend, Mem0Config
40
  from headroom.memory.models import Memory
 
41
 
42
  logger = logging.getLogger(__name__)
43
 
@@ -288,6 +289,7 @@ class LoCoMoEvaluatorV3:
288
 
289
  No LLM extraction - just raw dialogue with metadata.
290
  """
 
291
  stored = 0
292
  user_id = f"locomo_{conv.sample_id}"
293
 
@@ -334,9 +336,11 @@ class LoCoMoEvaluatorV3:
334
 
335
  async def _evaluate_case(self, case: LoCoMoCase, conv: LoCoMoConversation) -> CaseResultV3:
336
  """Evaluate a single QA case."""
 
337
  user_id = f"locomo_{conv.sample_id}"
338
 
339
  # Retrieve relevant turns - handle different backend interfaces
 
340
  if isinstance(self._backend, Mem0Backend):
341
  # Mem0 has its own optimized search with graph expansion
342
  results = await self._backend.search_memories(
 
38
  from headroom.memory.backends.local import LocalBackend, LocalBackendConfig
39
  from headroom.memory.backends.mem0 import Mem0Backend, Mem0Config
40
  from headroom.memory.models import Memory
41
+ from headroom.memory.ports import MemorySearchResult, VectorSearchResult
42
 
43
  logger = logging.getLogger(__name__)
44
 
 
289
 
290
  No LLM extraction - just raw dialogue with metadata.
291
  """
292
+ assert self._backend is not None, "Backend must be initialized"
293
  stored = 0
294
  user_id = f"locomo_{conv.sample_id}"
295
 
 
336
 
337
  async def _evaluate_case(self, case: LoCoMoCase, conv: LoCoMoConversation) -> CaseResultV3:
338
  """Evaluate a single QA case."""
339
+ assert self._backend is not None, "Backend must be initialized"
340
  user_id = f"locomo_{conv.sample_id}"
341
 
342
  # Retrieve relevant turns - handle different backend interfaces
343
+ results: list[MemorySearchResult] | list[VectorSearchResult]
344
  if isinstance(self._backend, Mem0Backend):
345
  # Mem0 has its own optimized search with graph expansion
346
  results = await self._backend.search_memories(
headroom/image/trained_router.py CHANGED
@@ -14,6 +14,7 @@ import io
14
  from dataclasses import dataclass
15
  from enum import Enum
16
  from pathlib import Path
 
17
 
18
  import torch
19
  from PIL import Image
@@ -112,11 +113,11 @@ class TrainedRouter:
112
  self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
113
 
114
  # Lazy-loaded models
115
- self._classifier = None
116
- self._tokenizer = None
117
- self._siglip_model = None
118
- self._siglip_processor = None
119
- self._text_embeddings = None
120
 
121
  def is_available(self) -> bool:
122
  """Check if required models can be loaded."""
@@ -205,7 +206,7 @@ class TrainedRouter:
205
  with torch.no_grad():
206
  outputs = self._classifier(**inputs)
207
  probs = torch.softmax(outputs.logits, dim=-1)
208
- pred_id = torch.argmax(probs, dim=-1).item()
209
  confidence = probs[0][pred_id].item()
210
 
211
  # Map ID to technique
@@ -229,7 +230,7 @@ class TrainedRouter:
229
  inputs = {k: v.to(self.device) for k, v in inputs.items()}
230
 
231
  with torch.no_grad():
232
- image_embeds = self._siglip_model.get_image_features(**inputs)
233
  image_embeds = image_embeds / image_embeds.norm(dim=-1, keepdim=True)
234
 
235
  return image_embeds
 
14
  from dataclasses import dataclass
15
  from enum import Enum
16
  from pathlib import Path
17
+ from typing import Any
18
 
19
  import torch
20
  from PIL import Image
 
113
  self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
114
 
115
  # Lazy-loaded models
116
+ self._classifier: Any = None
117
+ self._tokenizer: Any = None
118
+ self._siglip_model: Any = None
119
+ self._siglip_processor: Any = None
120
+ self._text_embeddings: Any = None
121
 
122
  def is_available(self) -> bool:
123
  """Check if required models can be loaded."""
 
206
  with torch.no_grad():
207
  outputs = self._classifier(**inputs)
208
  probs = torch.softmax(outputs.logits, dim=-1)
209
+ pred_id = int(torch.argmax(probs, dim=-1).item())
210
  confidence = probs[0][pred_id].item()
211
 
212
  # Map ID to technique
 
230
  inputs = {k: v.to(self.device) for k, v in inputs.items()}
231
 
232
  with torch.no_grad():
233
+ image_embeds: torch.Tensor = self._siglip_model.get_image_features(**inputs)
234
  image_embeds = image_embeds / image_embeds.norm(dim=-1, keepdim=True)
235
 
236
  return image_embeds
headroom/memory/__init__.py CHANGED
@@ -149,7 +149,7 @@ _DirectMem0Adapter = None
149
  _DirectMem0Config = None
150
 
151
 
152
- def __getattr__(name: str):
153
  """Lazy import for optional backend components."""
154
  global _Mem0Backend, _Mem0Config, _DirectMem0Adapter, _DirectMem0Config
155
 
 
149
  _DirectMem0Config = None
150
 
151
 
152
+ def __getattr__(name: str) -> type:
153
  """Lazy import for optional backend components."""
154
  global _Mem0Backend, _Mem0Config, _DirectMem0Adapter, _DirectMem0Config
155
 
headroom/memory/backends/direct_mem0.py CHANGED
@@ -429,7 +429,7 @@ class DirectMem0Adapter:
429
  }
430
  )
431
 
432
- def write_graph():
433
  with self._neo4j_driver.session() as session:
434
  for rel in normalized_rels:
435
  source = rel["source"]
 
429
  }
430
  )
431
 
432
+ def write_graph() -> None:
433
  with self._neo4j_driver.session() as session:
434
  for rel in normalized_rels:
435
  source = rel["source"]
headroom/memory/backends/mem0.py CHANGED
@@ -300,9 +300,9 @@ class Mem0Backend:
300
  if isinstance(result, dict) and "results" in result:
301
  results = result["results"]
302
  if results and len(results) > 0:
303
- return results[0].get("id", memory.id)
304
  elif isinstance(result, list) and len(result) > 0:
305
- return result[0].get("id", memory.id)
306
 
307
  return memory.id
308
 
 
300
  if isinstance(result, dict) and "results" in result:
301
  results = result["results"]
302
  if results and len(results) > 0:
303
+ return str(results[0].get("id", memory.id))
304
  elif isinstance(result, list) and len(result) > 0:
305
+ return str(result[0].get("id", memory.id))
306
 
307
  return memory.id
308
 
headroom/memory/easy.py CHANGED
@@ -136,7 +136,7 @@ class Memory:
136
  Mem0Config,
137
  )
138
 
139
- config = Mem0Config(
140
  qdrant_host=self._qdrant_host,
141
  qdrant_port=self._qdrant_port,
142
  neo4j_uri=self._neo4j_uri,
@@ -144,7 +144,7 @@ class Memory:
144
  neo4j_password=self._neo4j_password,
145
  enable_graph=True,
146
  )
147
- self._backend = DirectMem0Adapter(config)
148
  except ImportError as e:
149
  raise ImportError(
150
  "qdrant-neo4j backend requires additional packages. "
@@ -213,7 +213,7 @@ class Memory:
213
  metadata=metadata,
214
  )
215
 
216
- return result.id
217
 
218
  async def search(
219
  self,
@@ -262,7 +262,7 @@ class Memory:
262
  True if deleted, False if not found.
263
  """
264
  await self._ensure_initialized()
265
- return await self._backend.delete_memory(memory_id)
266
 
267
  async def clear(self, user_id: str) -> int:
268
  """Clear all memories for a user.
@@ -276,7 +276,7 @@ class Memory:
276
  await self._ensure_initialized()
277
 
278
  if hasattr(self._backend, "clear_user"):
279
- return await self._backend.clear_user(user_id)
280
  else:
281
  # Fallback for backends without clear_user
282
  return 0
 
136
  Mem0Config,
137
  )
138
 
139
+ mem0_config = Mem0Config(
140
  qdrant_host=self._qdrant_host,
141
  qdrant_port=self._qdrant_port,
142
  neo4j_uri=self._neo4j_uri,
 
144
  neo4j_password=self._neo4j_password,
145
  enable_graph=True,
146
  )
147
+ self._backend = DirectMem0Adapter(mem0_config)
148
  except ImportError as e:
149
  raise ImportError(
150
  "qdrant-neo4j backend requires additional packages. "
 
213
  metadata=metadata,
214
  )
215
 
216
+ return str(result.id)
217
 
218
  async def search(
219
  self,
 
262
  True if deleted, False if not found.
263
  """
264
  await self._ensure_initialized()
265
+ return bool(await self._backend.delete_memory(memory_id))
266
 
267
  async def clear(self, user_id: str) -> int:
268
  """Clear all memories for a user.
 
276
  await self._ensure_initialized()
277
 
278
  if hasattr(self._backend, "clear_user"):
279
+ return int(await self._backend.clear_user(user_id))
280
  else:
281
  # Fallback for backends without clear_user
282
  return 0
headroom/memory/system.py CHANGED
@@ -153,6 +153,10 @@ class MemoryBackend(Protocol):
153
  """Whether this backend supports vector similarity search."""
154
  ...
155
 
 
 
 
 
156
 
157
  # =============================================================================
158
  # Memory System Orchestrator
@@ -650,7 +654,8 @@ class MemorySystem:
650
  Returns {"status": "not_supported"} if backend doesn't support async.
651
  """
652
  if hasattr(self._backend, "get_task_status"):
653
- return self._backend.get_task_status(task_id)
 
654
  return {"status": "not_supported", "message": "Backend doesn't support async tasks"}
655
 
656
  def get_pending_tasks(self) -> list[str]:
@@ -660,7 +665,8 @@ class MemorySystem:
660
  List of task IDs, or empty list if not supported.
661
  """
662
  if hasattr(self._backend, "get_pending_tasks"):
663
- return self._backend.get_pending_tasks()
 
664
  return []
665
 
666
  async def wait_for_task(self, task_id: str, timeout: float = 30.0) -> dict[str, Any]:
@@ -674,7 +680,8 @@ class MemorySystem:
674
  Task result or timeout error.
675
  """
676
  if hasattr(self._backend, "wait_for_task"):
677
- return await self._backend.wait_for_task(task_id, timeout)
 
678
  return {"status": "not_supported", "message": "Backend doesn't support async tasks"}
679
 
680
  async def flush_pending(self, timeout: float = 60.0) -> dict[str, Any]:
@@ -690,5 +697,6 @@ class MemorySystem:
690
  Summary of completed and failed tasks.
691
  """
692
  if hasattr(self._backend, "flush_pending"):
693
- return await self._backend.flush_pending(timeout)
 
694
  return {"completed": 0, "failed": 0, "pending": 0}
 
153
  """Whether this backend supports vector similarity search."""
154
  ...
155
 
156
+ async def close(self) -> None:
157
+ """Close the backend and release resources."""
158
+ ...
159
+
160
 
161
  # =============================================================================
162
  # Memory System Orchestrator
 
654
  Returns {"status": "not_supported"} if backend doesn't support async.
655
  """
656
  if hasattr(self._backend, "get_task_status"):
657
+ result: dict[str, Any] = self._backend.get_task_status(task_id)
658
+ return result
659
  return {"status": "not_supported", "message": "Backend doesn't support async tasks"}
660
 
661
  def get_pending_tasks(self) -> list[str]:
 
665
  List of task IDs, or empty list if not supported.
666
  """
667
  if hasattr(self._backend, "get_pending_tasks"):
668
+ tasks: list[str] = self._backend.get_pending_tasks()
669
+ return tasks
670
  return []
671
 
672
  async def wait_for_task(self, task_id: str, timeout: float = 30.0) -> dict[str, Any]:
 
680
  Task result or timeout error.
681
  """
682
  if hasattr(self._backend, "wait_for_task"):
683
+ task_result: dict[str, Any] = await self._backend.wait_for_task(task_id, timeout)
684
+ return task_result
685
  return {"status": "not_supported", "message": "Backend doesn't support async tasks"}
686
 
687
  async def flush_pending(self, timeout: float = 60.0) -> dict[str, Any]:
 
697
  Summary of completed and failed tasks.
698
  """
699
  if hasattr(self._backend, "flush_pending"):
700
+ flush_result: dict[str, Any] = await self._backend.flush_pending(timeout)
701
+ return flush_result
702
  return {"completed": 0, "failed": 0, "pending": 0}
headroom/memory/wrapper_tools.py CHANGED
@@ -99,7 +99,7 @@ class MemoryToolsWrapper:
99
  extraction system prompt into messages so the LLM knows to
100
  extract structured data when calling memory_save.
101
  """
102
- self._client = client
103
  self._memory = MemorySystem(backend, user_id, session_id)
104
  self._auto_handle = auto_handle_tools
105
  self._optimized = optimized
 
99
  extraction system prompt into messages so the LLM knows to
100
  extract structured data when calling memory_save.
101
  """
102
+ self._client: Any = client
103
  self._memory = MemorySystem(backend, user_id, session_id)
104
  self._auto_handle = auto_handle_tools
105
  self._optimized = optimized
headroom/prediction/feature_extractor.py CHANGED
@@ -1155,7 +1155,7 @@ class StructuralExtractor(BaseFeatureExtractor):
1155
  delimiters = self.DELIMITER.findall(text)
1156
  features.delimiter_types = list({d[0] for d in delimiters if d})
1157
  features.has_structured_template = (
1158
- features.xml_tag_count > 2 or features.delimiter_types or features.json_object_count > 0
1159
  )
1160
 
1161
  # Conversation structure
@@ -1743,6 +1743,7 @@ class SemanticExtractor(BaseFeatureExtractor):
1743
 
1744
  self._nlp = spacy.load("en_core_web_sm")
1745
 
 
1746
  doc = self._nlp(text)
1747
  return [(ent.text, ent.label_) for ent in doc.ents]
1748
  except Exception as e:
@@ -2277,6 +2278,7 @@ class PromptFeatureExtractor:
2277
  self.meta_extractor = MetaExtractor(tokenizer=tokenizer)
2278
 
2279
  self.use_embeddings = use_embeddings
 
2280
  if use_embeddings:
2281
  self.embedding_extractor = EmbeddingExtractor(
2282
  model_name=embedding_model, cluster_centers=cluster_centers
 
1155
  delimiters = self.DELIMITER.findall(text)
1156
  features.delimiter_types = list({d[0] for d in delimiters if d})
1157
  features.has_structured_template = (
1158
+ features.xml_tag_count > 2 or bool(features.delimiter_types) or features.json_object_count > 0
1159
  )
1160
 
1161
  # Conversation structure
 
1743
 
1744
  self._nlp = spacy.load("en_core_web_sm")
1745
 
1746
+ assert self._nlp is not None
1747
  doc = self._nlp(text)
1748
  return [(ent.text, ent.label_) for ent in doc.ents]
1749
  except Exception as e:
 
2278
  self.meta_extractor = MetaExtractor(tokenizer=tokenizer)
2279
 
2280
  self.use_embeddings = use_embeddings
2281
+ self.embedding_extractor: EmbeddingExtractor | None
2282
  if use_embeddings:
2283
  self.embedding_extractor = EmbeddingExtractor(
2284
  model_name=embedding_model, cluster_centers=cluster_centers
headroom/proxy/memory_handler.py CHANGED
@@ -268,7 +268,7 @@ Use this context to provide personalized and contextually relevant responses."""
268
  if isinstance(content, list):
269
  for block in content:
270
  if isinstance(block, dict) and block.get("type") == "text":
271
- text = block.get("text", "")
272
  if text:
273
  return text[:500]
274
 
 
268
  if isinstance(content, list):
269
  for block in content:
270
  if isinstance(block, dict) and block.get("type") == "text":
271
+ text = str(block.get("text", ""))
272
  if text:
273
  return text[:500]
274
 
headroom/proxy/server.py CHANGED
@@ -478,9 +478,18 @@ class CostTracker:
478
  model: str,
479
  input_tokens: int,
480
  output_tokens: int,
481
- cached_tokens: int = 0,
 
482
  ) -> float | None:
483
- """Estimate cost in USD using LiteLLM's pricing database."""
 
 
 
 
 
 
 
 
484
  if not LITELLM_AVAILABLE:
485
  logger.warning("LiteLLM not available - cannot calculate costs")
486
  return None
@@ -488,7 +497,13 @@ class CostTracker:
488
  try:
489
  # cost_per_token returns (total_input_cost, total_output_cost) for the given token counts
490
  # Despite the name, it returns total cost not per-token cost
491
- regular_input = input_tokens - cached_tokens
 
 
 
 
 
 
492
 
493
  # Get cost for regular (non-cached) input tokens
494
  input_cost, _ = litellm.cost_per_token(
@@ -504,33 +519,44 @@ class CostTracker:
504
  completion_tokens=output_tokens,
505
  )
506
 
507
- # For cached tokens, use LiteLLM's cache_read_input_token_cost if available
508
- cached_cost = 0.0
509
- if cached_tokens > 0:
510
- try:
511
- # Try to get the specific cache read cost from LiteLLM's pricing database
512
- model_info = litellm.get_model_info(model)
513
- cache_read_cost_per_token = model_info.get("cache_read_input_token_cost")
514
- if cache_read_cost_per_token:
515
- cached_cost = cached_tokens * cache_read_cost_per_token
516
- else:
517
- # Fallback: most providers charge ~10% of input price for cache reads
518
- cached_full_cost, _ = litellm.cost_per_token(
519
- model=model,
520
- prompt_tokens=cached_tokens,
521
- completion_tokens=0,
522
- )
523
- cached_cost = cached_full_cost * 0.1
524
- except Exception:
525
- # Fallback if get_model_info fails
526
- cached_full_cost, _ = litellm.cost_per_token(
 
 
 
 
 
 
 
 
 
 
 
527
  model=model,
528
- prompt_tokens=cached_tokens,
529
  completion_tokens=0,
530
  )
531
- cached_cost = cached_full_cost * 0.1
532
 
533
- total_cost = input_cost + cached_cost + output_cost
534
  return float(total_cost) if total_cost > 0 else None
535
 
536
  except Exception as e:
@@ -1675,25 +1701,39 @@ class HeadroomProxy:
1675
 
1676
  total_latency = (time.time() - start_time) * 1000
1677
 
1678
- # Parse response for output tokens and cache info
 
1679
  output_tokens = 0
1680
  cache_read_tokens = 0
 
1681
  if resp_json:
1682
  usage = resp_json.get("usage", {})
 
1683
  output_tokens = usage.get("output_tokens", 0)
1684
  # Anthropic returns cache_read_input_tokens for cached prompt tokens
1685
  # These are charged at 10% of the input price
1686
  cache_read_tokens = usage.get("cache_read_input_tokens", 0)
 
 
 
1687
 
1688
- # Calculate cost (accounting for cached tokens at discounted rate)
1689
  cost_usd = None
1690
  savings_usd = None
1691
  if self.cost_tracker:
1692
  cost_usd = self.cost_tracker.estimate_cost(
1693
- model, optimized_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
 
1694
  )
1695
  original_cost = self.cost_tracker.estimate_cost(
1696
- model, original_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
 
1697
  )
1698
  if cost_usd and original_cost:
1699
  savings_usd = original_cost - cost_usd
@@ -1710,11 +1750,11 @@ class HeadroomProxy:
1710
  tokens_saved=tokens_saved,
1711
  )
1712
 
1713
- # Record metrics
1714
  await self.metrics.record_request(
1715
  provider="anthropic",
1716
  model=model,
1717
- input_tokens=optimized_tokens,
1718
  output_tokens=output_tokens,
1719
  tokens_saved=tokens_saved,
1720
  latency_ms=total_latency,
@@ -2876,6 +2916,83 @@ class HeadroomProxy:
2876
 
2877
  return None
2878
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2879
  async def _stream_response(
2880
  self,
2881
  url: str,
@@ -2905,6 +3022,7 @@ class HeadroomProxy:
2905
  "cache_read_input_tokens": 0,
2906
  "cache_creation_input_tokens": 0,
2907
  "total_bytes": 0,
 
2908
  }
2909
 
2910
  async def generate():
@@ -2915,8 +3033,11 @@ class HeadroomProxy:
2915
  async for chunk in response.aiter_bytes():
2916
  stream_state["total_bytes"] += len(chunk)
2917
 
2918
- # Parse usage from SSE events
2919
- usage = self._parse_sse_usage(chunk, provider)
 
 
 
2920
  if usage:
2921
  if "input_tokens" in usage:
2922
  stream_state["input_tokens"] = usage["input_tokens"]
@@ -2946,28 +3067,67 @@ class HeadroomProxy:
2946
  f"[{request_id}] No usage in stream, estimated {output_tokens} output tokens"
2947
  )
2948
 
2949
- # Use actual input tokens from API if available
 
 
 
2950
  cache_read_tokens = stream_state["cache_read_input_tokens"]
 
2951
 
2952
- # Calculate cost (accounting for cached tokens at discounted rate)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2953
  cost_usd = None
2954
  savings_usd = None
2955
  if self.cost_tracker:
2956
  cost_usd = self.cost_tracker.estimate_cost(
2957
- model, optimized_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
 
2958
  )
2959
- original_cost = self.cost_tracker.estimate_cost(
2960
- model, original_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
 
2961
  )
2962
- if cost_usd and original_cost:
2963
- savings_usd = original_cost - cost_usd
 
 
 
 
2964
  self.cost_tracker.record_cost(cost_usd)
2965
- self.cost_tracker.record_savings(savings_usd)
 
 
2966
 
2967
  await self.metrics.record_request(
2968
  provider=provider,
2969
  model=model,
2970
- input_tokens=optimized_tokens,
2971
  output_tokens=output_tokens,
2972
  tokens_saved=tokens_saved,
2973
  latency_ms=total_latency,
@@ -3158,11 +3318,13 @@ class HeadroomProxy:
3158
  response = await self._retry_request("POST", url, headers, body)
3159
  total_latency = (time.time() - start_time) * 1000
3160
 
 
3161
  output_tokens = 0
3162
  cache_read_tokens = 0
3163
  try:
3164
  resp_json = response.json()
3165
  usage = resp_json.get("usage", {})
 
3166
  output_tokens = usage.get("completion_tokens", 0)
3167
  # OpenAI returns cached_tokens in prompt_tokens_details
3168
  # These are charged at 50% of the input price
@@ -3171,14 +3333,24 @@ class HeadroomProxy:
3171
  except Exception:
3172
  pass
3173
 
3174
- # Cost tracking (accounting for cached tokens at discounted rate)
 
 
 
 
3175
  cost_usd = savings_usd = None
3176
  if self.cost_tracker:
3177
  cost_usd = self.cost_tracker.estimate_cost(
3178
- model, optimized_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
3179
  )
3180
  original_cost = self.cost_tracker.estimate_cost(
3181
- model, original_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
3182
  )
3183
  if cost_usd and original_cost:
3184
  savings_usd = original_cost - cost_usd
@@ -3191,11 +3363,11 @@ class HeadroomProxy:
3191
  messages, model, response.content, dict(response.headers), tokens_saved
3192
  )
3193
 
3194
- # Metrics
3195
  await self.metrics.record_request(
3196
  provider="openai",
3197
  model=model,
3198
- input_tokens=optimized_tokens,
3199
  output_tokens=output_tokens,
3200
  tokens_saved=tokens_saved,
3201
  latency_ms=total_latency,
@@ -3881,11 +4053,13 @@ class HeadroomProxy:
3881
  response = await self._retry_request("POST", url, headers, body)
3882
  total_latency = (time.time() - start_time) * 1000
3883
 
 
3884
  output_tokens = 0
3885
  cache_read_tokens = 0
3886
  try:
3887
  resp_json = response.json()
3888
  usage = resp_json.get("usage", {})
 
3889
  output_tokens = usage.get("output_tokens", 0)
3890
  # OpenAI returns cached_tokens in prompt_tokens_details (or input_tokens_details)
3891
  prompt_details = usage.get(
@@ -3895,20 +4069,27 @@ class HeadroomProxy:
3895
  except Exception:
3896
  pass
3897
 
3898
- # Cost tracking (accounting for cached tokens at discounted rate)
 
 
 
 
3899
  cost_usd = savings_usd = None
3900
  if self.cost_tracker:
3901
  cost_usd = self.cost_tracker.estimate_cost(
3902
- model, original_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
3903
  )
3904
  if cost_usd:
3905
  self.cost_tracker.record_cost(cost_usd)
3906
 
3907
- # Metrics
3908
  await self.metrics.record_request(
3909
  provider="openai",
3910
  model=model,
3911
- input_tokens=original_tokens,
3912
  output_tokens=output_tokens,
3913
  tokens_saved=tokens_saved,
3914
  latency_ms=total_latency,
@@ -3916,7 +4097,7 @@ class HeadroomProxy:
3916
  savings_usd=savings_usd or 0,
3917
  )
3918
 
3919
- logger.info(f"[{request_id}] /v1/responses {model}: {original_tokens:,} tokens")
3920
 
3921
  # Remove compression headers
3922
  response_headers = dict(response.headers)
@@ -4138,11 +4319,13 @@ class HeadroomProxy:
4138
  response = await self._retry_request("POST", url, headers, body)
4139
  total_latency = (time.time() - start_time) * 1000
4140
 
 
4141
  output_tokens = 0
4142
  cache_read_tokens = 0
4143
  try:
4144
  resp_json = response.json()
4145
  usage = resp_json.get("usageMetadata", {})
 
4146
  output_tokens = usage.get("candidatesTokenCount", 0)
4147
  # Gemini returns cachedContentTokenCount for context-cached tokens
4148
  # These are charged at 10-25% of the input price depending on model
@@ -4150,25 +4333,35 @@ class HeadroomProxy:
4150
  except Exception:
4151
  pass
4152
 
4153
- # Cost tracking (accounting for cached tokens at discounted rate)
 
 
 
 
4154
  cost_usd = savings_usd = None
4155
  if self.cost_tracker:
4156
  cost_usd = self.cost_tracker.estimate_cost(
4157
- model, optimized_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
4158
  )
4159
  original_cost = self.cost_tracker.estimate_cost(
4160
- model, original_tokens, output_tokens, cached_tokens=cache_read_tokens
 
 
 
4161
  )
4162
  if cost_usd and original_cost:
4163
  savings_usd = original_cost - cost_usd
4164
  self.cost_tracker.record_cost(cost_usd)
4165
  self.cost_tracker.record_savings(savings_usd)
4166
 
4167
- # Metrics
4168
  await self.metrics.record_request(
4169
  provider="gemini",
4170
  model=model,
4171
- input_tokens=optimized_tokens,
4172
  output_tokens=output_tokens,
4173
  tokens_saved=tokens_saved,
4174
  latency_ms=total_latency,
 
478
  model: str,
479
  input_tokens: int,
480
  output_tokens: int,
481
+ cache_read_tokens: int = 0,
482
+ cache_write_tokens: int = 0,
483
  ) -> float | None:
484
+ """Estimate cost in USD using LiteLLM's pricing database.
485
+
486
+ Args:
487
+ model: Model name for pricing lookup
488
+ input_tokens: Input tokens sent to API (does NOT include cache_read, which is served from cache)
489
+ output_tokens: Output tokens
490
+ cache_read_tokens: Tokens read from cache (charged at ~10% of input rate)
491
+ cache_write_tokens: Tokens written to cache - this is a SUBSET of input_tokens (charged at ~125% of input rate)
492
+ """
493
  if not LITELLM_AVAILABLE:
494
  logger.warning("LiteLLM not available - cannot calculate costs")
495
  return None
 
497
  try:
498
  # cost_per_token returns (total_input_cost, total_output_cost) for the given token counts
499
  # Despite the name, it returns total cost not per-token cost
500
+
501
+ # Anthropic's token semantics (all three are SEPARATE, not overlapping):
502
+ # - input_tokens: tokens sent that are NOT cached (neither read nor written)
503
+ # - cache_read_input_tokens: tokens served from existing cache
504
+ # - cache_creation_input_tokens: tokens being written to cache
505
+ # Total billable = input_tokens + cache_read + cache_write (each at different rates)
506
+ regular_input = input_tokens # Don't subtract cache_write, they're separate
507
 
508
  # Get cost for regular (non-cached) input tokens
509
  input_cost, _ = litellm.cost_per_token(
 
519
  completion_tokens=output_tokens,
520
  )
521
 
522
+ # Get model info for cache pricing
523
+ model_info: dict[str, Any] = {}
524
+ try:
525
+ model_info = dict(litellm.get_model_info(model))
526
+ except Exception:
527
+ pass
528
+
529
+ # Calculate cache read cost (typically 10% of input price)
530
+ cache_read_cost = 0.0
531
+ if cache_read_tokens > 0:
532
+ cache_read_cost_per_token = model_info.get("cache_read_input_token_cost")
533
+ if cache_read_cost_per_token:
534
+ cache_read_cost = cache_read_tokens * cache_read_cost_per_token
535
+ else:
536
+ # Fallback: most providers charge ~10% of input price for cache reads
537
+ cache_read_full_cost, _ = litellm.cost_per_token(
538
+ model=model,
539
+ prompt_tokens=cache_read_tokens,
540
+ completion_tokens=0,
541
+ )
542
+ cache_read_cost = cache_read_full_cost * 0.1
543
+
544
+ # Calculate cache write cost (typically 125% of input price)
545
+ cache_write_cost = 0.0
546
+ if cache_write_tokens > 0:
547
+ cache_write_cost_per_token = model_info.get("cache_creation_input_token_cost")
548
+ if cache_write_cost_per_token:
549
+ cache_write_cost = cache_write_tokens * cache_write_cost_per_token
550
+ else:
551
+ # Fallback: most providers charge ~125% of input price for cache writes
552
+ cache_write_full_cost, _ = litellm.cost_per_token(
553
  model=model,
554
+ prompt_tokens=cache_write_tokens,
555
  completion_tokens=0,
556
  )
557
+ cache_write_cost = cache_write_full_cost * 1.25
558
 
559
+ total_cost = input_cost + cache_read_cost + cache_write_cost + output_cost
560
  return float(total_cost) if total_cost > 0 else None
561
 
562
  except Exception as e:
 
1701
 
1702
  total_latency = (time.time() - start_time) * 1000
1703
 
1704
+ # Parse response for actual token counts from API
1705
+ actual_input_tokens = optimized_tokens # fallback
1706
  output_tokens = 0
1707
  cache_read_tokens = 0
1708
+ cache_write_tokens = 0
1709
  if resp_json:
1710
  usage = resp_json.get("usage", {})
1711
+ actual_input_tokens = usage.get("input_tokens", optimized_tokens)
1712
  output_tokens = usage.get("output_tokens", 0)
1713
  # Anthropic returns cache_read_input_tokens for cached prompt tokens
1714
  # These are charged at 10% of the input price
1715
  cache_read_tokens = usage.get("cache_read_input_tokens", 0)
1716
+ # Anthropic returns cache_creation_input_tokens for tokens written to cache
1717
+ # These are charged at 125% of the input price
1718
+ cache_write_tokens = usage.get("cache_creation_input_tokens", 0)
1719
 
1720
+ # Calculate cost using actual API tokens with proper cache pricing
1721
  cost_usd = None
1722
  savings_usd = None
1723
  if self.cost_tracker:
1724
  cost_usd = self.cost_tracker.estimate_cost(
1725
+ model,
1726
+ actual_input_tokens,
1727
+ output_tokens,
1728
+ cache_read_tokens=cache_read_tokens,
1729
+ cache_write_tokens=cache_write_tokens,
1730
  )
1731
  original_cost = self.cost_tracker.estimate_cost(
1732
+ model,
1733
+ original_tokens,
1734
+ output_tokens,
1735
+ cache_read_tokens=cache_read_tokens,
1736
+ cache_write_tokens=cache_write_tokens,
1737
  )
1738
  if cost_usd and original_cost:
1739
  savings_usd = original_cost - cost_usd
 
1750
  tokens_saved=tokens_saved,
1751
  )
1752
 
1753
+ # Record metrics with actual API tokens
1754
  await self.metrics.record_request(
1755
  provider="anthropic",
1756
  model=model,
1757
+ input_tokens=actual_input_tokens,
1758
  output_tokens=output_tokens,
1759
  tokens_saved=tokens_saved,
1760
  latency_ms=total_latency,
 
2916
 
2917
  return None
2918
 
2919
+ def _parse_sse_usage_from_buffer(
2920
+ self, stream_state: dict[str, Any], provider: str
2921
+ ) -> dict[str, int] | None:
2922
+ """Parse usage from buffered SSE data, handling split chunks.
2923
+
2924
+ Processes complete SSE events (ending with double newline) from the buffer
2925
+ and removes them from the buffer. Incomplete events are kept in the buffer
2926
+ for the next chunk.
2927
+ """
2928
+ buffer = stream_state["sse_buffer"]
2929
+ usage_found: dict[str, int] = {}
2930
+
2931
+ # Process complete SSE events (separated by double newlines)
2932
+ while "\n\n" in buffer:
2933
+ event_end = buffer.index("\n\n")
2934
+ event_text = buffer[: event_end + 2]
2935
+ buffer = buffer[event_end + 2 :]
2936
+
2937
+ # Parse this complete event
2938
+ for line in event_text.split("\n"):
2939
+ if not line.startswith("data: "):
2940
+ continue
2941
+ data_str = line[6:].strip()
2942
+ if not data_str or data_str == "[DONE]":
2943
+ continue
2944
+
2945
+ try:
2946
+ data = json.loads(data_str)
2947
+ except json.JSONDecodeError:
2948
+ continue
2949
+
2950
+ if provider == "anthropic":
2951
+ event_type = data.get("type", "")
2952
+ if event_type == "message_start":
2953
+ msg = data.get("message", {})
2954
+ msg_usage = msg.get("usage", {})
2955
+ if msg_usage:
2956
+ usage_found["input_tokens"] = msg_usage.get("input_tokens", 0)
2957
+ usage_found["cache_read_input_tokens"] = msg_usage.get(
2958
+ "cache_read_input_tokens", 0
2959
+ )
2960
+ usage_found["cache_creation_input_tokens"] = msg_usage.get(
2961
+ "cache_creation_input_tokens", 0
2962
+ )
2963
+ # INFO logging for cache token tracking (temporary for debugging)
2964
+ logger.info(
2965
+ f"[CACHE] Anthropic usage: input={usage_found.get('input_tokens')}, "
2966
+ f"cache_read={usage_found.get('cache_read_input_tokens')}, "
2967
+ f"cache_write={usage_found.get('cache_creation_input_tokens')}"
2968
+ )
2969
+ elif event_type == "message_delta":
2970
+ delta_usage = data.get("usage", {})
2971
+ if delta_usage:
2972
+ usage_found["output_tokens"] = delta_usage.get("output_tokens", 0)
2973
+
2974
+ elif provider == "openai":
2975
+ chunk_usage = data.get("usage")
2976
+ if chunk_usage:
2977
+ usage_found["input_tokens"] = chunk_usage.get("prompt_tokens", 0)
2978
+ usage_found["output_tokens"] = chunk_usage.get("completion_tokens", 0)
2979
+ details = chunk_usage.get("prompt_tokens_details", {})
2980
+ usage_found["cache_read_input_tokens"] = details.get("cached_tokens", 0)
2981
+
2982
+ elif provider == "gemini":
2983
+ usage_meta = data.get("usageMetadata")
2984
+ if usage_meta:
2985
+ usage_found["input_tokens"] = usage_meta.get("promptTokenCount", 0)
2986
+ usage_found["output_tokens"] = usage_meta.get("candidatesTokenCount", 0)
2987
+ usage_found["cache_read_input_tokens"] = usage_meta.get(
2988
+ "cachedContentTokenCount", 0
2989
+ )
2990
+
2991
+ # Update buffer with remaining incomplete data
2992
+ stream_state["sse_buffer"] = buffer
2993
+
2994
+ return usage_found if usage_found else None
2995
+
2996
  async def _stream_response(
2997
  self,
2998
  url: str,
 
3022
  "cache_read_input_tokens": 0,
3023
  "cache_creation_input_tokens": 0,
3024
  "total_bytes": 0,
3025
+ "sse_buffer": "", # Buffer for incomplete SSE events
3026
  }
3027
 
3028
  async def generate():
 
3033
  async for chunk in response.aiter_bytes():
3034
  stream_state["total_bytes"] += len(chunk)
3035
 
3036
+ # Buffer SSE data to handle chunks split across calls
3037
+ stream_state["sse_buffer"] += chunk.decode("utf-8", errors="ignore")
3038
+
3039
+ # Parse complete SSE events from buffer
3040
+ usage = self._parse_sse_usage_from_buffer(stream_state, provider)
3041
  if usage:
3042
  if "input_tokens" in usage:
3043
  stream_state["input_tokens"] = usage["input_tokens"]
 
3067
  f"[{request_id}] No usage in stream, estimated {output_tokens} output tokens"
3068
  )
3069
 
3070
+ # Use actual tokens from API if available, fallback to estimates
3071
+ # Note: use 'is not None' instead of 'or' to handle 0 correctly
3072
+ api_input_tokens = stream_state["input_tokens"]
3073
+ total_input_tokens = api_input_tokens if api_input_tokens is not None else optimized_tokens
3074
  cache_read_tokens = stream_state["cache_read_input_tokens"]
3075
+ cache_write_tokens = stream_state["cache_creation_input_tokens"]
3076
 
3077
+ # INFO logging for token tracking (temporary for debugging)
3078
+ if api_input_tokens is None:
3079
+ logger.info(
3080
+ f"[{request_id}] No input_tokens from API, using estimate: {optimized_tokens}"
3081
+ )
3082
+ else:
3083
+ logger.info(
3084
+ f"[{request_id}] Final tokens: input={api_input_tokens}, "
3085
+ f"cache_read={cache_read_tokens}, cache_write={cache_write_tokens}"
3086
+ )
3087
+
3088
+ # Normalize input tokens based on provider semantics:
3089
+ # - Anthropic: input_tokens excludes cache_read (it's separate), pass as-is
3090
+ # - OpenAI/Gemini: input_tokens includes cache_read (it's a subset), subtract it
3091
+ if provider == "anthropic":
3092
+ # Anthropic's input_tokens = non-cached tokens sent (excludes cache_read)
3093
+ non_cached_input = total_input_tokens
3094
+ else:
3095
+ # OpenAI/Gemini's input_tokens = total (includes cache_read)
3096
+ non_cached_input = total_input_tokens - cache_read_tokens
3097
+
3098
+ # Calculate cost using actual API tokens with proper cache pricing
3099
  cost_usd = None
3100
  savings_usd = None
3101
  if self.cost_tracker:
3102
  cost_usd = self.cost_tracker.estimate_cost(
3103
+ model,
3104
+ non_cached_input,
3105
+ output_tokens,
3106
+ cache_read_tokens=cache_read_tokens,
3107
+ cache_write_tokens=cache_write_tokens,
3108
  )
3109
+ # For savings calculation, compare compression benefit using base token rates only
3110
+ # (cache effects are Anthropic's feature, not Headroom's compression benefit)
3111
+ compressed_base_cost = self.cost_tracker.estimate_cost(
3112
+ model,
3113
+ non_cached_input,
3114
+ output_tokens,
3115
  )
3116
+ original_base_cost = self.cost_tracker.estimate_cost(
3117
+ model,
3118
+ original_tokens,
3119
+ output_tokens,
3120
+ )
3121
+ if cost_usd:
3122
  self.cost_tracker.record_cost(cost_usd)
3123
+ if compressed_base_cost and original_base_cost:
3124
+ savings_usd = original_base_cost - compressed_base_cost
3125
+ self.cost_tracker.record_savings(max(0, savings_usd))
3126
 
3127
  await self.metrics.record_request(
3128
  provider=provider,
3129
  model=model,
3130
+ input_tokens=total_input_tokens, # Record total for accurate tracking
3131
  output_tokens=output_tokens,
3132
  tokens_saved=tokens_saved,
3133
  latency_ms=total_latency,
 
3318
  response = await self._retry_request("POST", url, headers, body)
3319
  total_latency = (time.time() - start_time) * 1000
3320
 
3321
+ total_input_tokens = optimized_tokens # fallback
3322
  output_tokens = 0
3323
  cache_read_tokens = 0
3324
  try:
3325
  resp_json = response.json()
3326
  usage = resp_json.get("usage", {})
3327
+ total_input_tokens = usage.get("prompt_tokens", optimized_tokens)
3328
  output_tokens = usage.get("completion_tokens", 0)
3329
  # OpenAI returns cached_tokens in prompt_tokens_details
3330
  # These are charged at 50% of the input price
 
3333
  except Exception:
3334
  pass
3335
 
3336
+ # For OpenAI, prompt_tokens is TOTAL (includes cached)
3337
+ # Normalize to non-cached input for consistent cost calculation
3338
+ non_cached_input = total_input_tokens - cache_read_tokens
3339
+
3340
+ # Cost tracking using actual API tokens
3341
  cost_usd = savings_usd = None
3342
  if self.cost_tracker:
3343
  cost_usd = self.cost_tracker.estimate_cost(
3344
+ model,
3345
+ non_cached_input, # Pass non-cached portion
3346
+ output_tokens,
3347
+ cache_read_tokens=cache_read_tokens,
3348
  )
3349
  original_cost = self.cost_tracker.estimate_cost(
3350
+ model,
3351
+ original_tokens,
3352
+ output_tokens,
3353
+ cache_read_tokens=cache_read_tokens,
3354
  )
3355
  if cost_usd and original_cost:
3356
  savings_usd = original_cost - cost_usd
 
3363
  messages, model, response.content, dict(response.headers), tokens_saved
3364
  )
3365
 
3366
+ # Metrics with actual API tokens (total, for accurate tracking)
3367
  await self.metrics.record_request(
3368
  provider="openai",
3369
  model=model,
3370
+ input_tokens=total_input_tokens,
3371
  output_tokens=output_tokens,
3372
  tokens_saved=tokens_saved,
3373
  latency_ms=total_latency,
 
4053
  response = await self._retry_request("POST", url, headers, body)
4054
  total_latency = (time.time() - start_time) * 1000
4055
 
4056
+ total_input_tokens = original_tokens # fallback
4057
  output_tokens = 0
4058
  cache_read_tokens = 0
4059
  try:
4060
  resp_json = response.json()
4061
  usage = resp_json.get("usage", {})
4062
+ total_input_tokens = usage.get("input_tokens", original_tokens)
4063
  output_tokens = usage.get("output_tokens", 0)
4064
  # OpenAI returns cached_tokens in prompt_tokens_details (or input_tokens_details)
4065
  prompt_details = usage.get(
 
4069
  except Exception:
4070
  pass
4071
 
4072
+ # For OpenAI, input_tokens is TOTAL (includes cached)
4073
+ # Normalize to non-cached input for consistent cost calculation
4074
+ non_cached_input = total_input_tokens - cache_read_tokens
4075
+
4076
+ # Cost tracking using actual API tokens
4077
  cost_usd = savings_usd = None
4078
  if self.cost_tracker:
4079
  cost_usd = self.cost_tracker.estimate_cost(
4080
+ model,
4081
+ non_cached_input, # Pass non-cached portion
4082
+ output_tokens,
4083
+ cache_read_tokens=cache_read_tokens,
4084
  )
4085
  if cost_usd:
4086
  self.cost_tracker.record_cost(cost_usd)
4087
 
4088
+ # Metrics with actual API tokens (total, for accurate tracking)
4089
  await self.metrics.record_request(
4090
  provider="openai",
4091
  model=model,
4092
+ input_tokens=total_input_tokens,
4093
  output_tokens=output_tokens,
4094
  tokens_saved=tokens_saved,
4095
  latency_ms=total_latency,
 
4097
  savings_usd=savings_usd or 0,
4098
  )
4099
 
4100
+ logger.info(f"[{request_id}] /v1/responses {model}: {total_input_tokens:,} tokens")
4101
 
4102
  # Remove compression headers
4103
  response_headers = dict(response.headers)
 
4319
  response = await self._retry_request("POST", url, headers, body)
4320
  total_latency = (time.time() - start_time) * 1000
4321
 
4322
+ total_input_tokens = optimized_tokens # fallback
4323
  output_tokens = 0
4324
  cache_read_tokens = 0
4325
  try:
4326
  resp_json = response.json()
4327
  usage = resp_json.get("usageMetadata", {})
4328
+ total_input_tokens = usage.get("promptTokenCount", optimized_tokens)
4329
  output_tokens = usage.get("candidatesTokenCount", 0)
4330
  # Gemini returns cachedContentTokenCount for context-cached tokens
4331
  # These are charged at 10-25% of the input price depending on model
 
4333
  except Exception:
4334
  pass
4335
 
4336
+ # For Gemini, promptTokenCount is TOTAL (includes cached)
4337
+ # Normalize to non-cached input for consistent cost calculation
4338
+ non_cached_input = total_input_tokens - cache_read_tokens
4339
+
4340
+ # Cost tracking using actual API tokens
4341
  cost_usd = savings_usd = None
4342
  if self.cost_tracker:
4343
  cost_usd = self.cost_tracker.estimate_cost(
4344
+ model,
4345
+ non_cached_input, # Pass non-cached portion
4346
+ output_tokens,
4347
+ cache_read_tokens=cache_read_tokens,
4348
  )
4349
  original_cost = self.cost_tracker.estimate_cost(
4350
+ model,
4351
+ original_tokens,
4352
+ output_tokens,
4353
+ cache_read_tokens=cache_read_tokens,
4354
  )
4355
  if cost_usd and original_cost:
4356
  savings_usd = original_cost - cost_usd
4357
  self.cost_tracker.record_cost(cost_usd)
4358
  self.cost_tracker.record_savings(savings_usd)
4359
 
4360
+ # Metrics with actual API tokens (total, for accurate tracking)
4361
  await self.metrics.record_request(
4362
  provider="gemini",
4363
  model=model,
4364
+ input_tokens=total_input_tokens,
4365
  output_tokens=output_tokens,
4366
  tokens_saved=tokens_saved,
4367
  latency_ms=total_latency,
uv.lock CHANGED
The diff for this file is too large to render. See raw diff