Spaces:
Build error
Build error
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 +2 -1
- headroom/evals/memory/runner.py +3 -2
- headroom/evals/memory/runner_v2.py +2 -2
- headroom/evals/memory/runner_v3.py +4 -0
- headroom/image/trained_router.py +8 -7
- headroom/memory/__init__.py +1 -1
- headroom/memory/backends/direct_mem0.py +1 -1
- headroom/memory/backends/mem0.py +2 -2
- headroom/memory/easy.py +5 -5
- headroom/memory/system.py +12 -4
- headroom/memory/wrapper_tools.py +1 -1
- headroom/prediction/feature_extractor.py +3 -1
- headroom/proxy/memory_handler.py +1 -1
- headroom/proxy/server.py +251 -58
- uv.lock +0 -0
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 |
-
|
|
|
|
| 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 |
-
|
| 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(
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
model=model,
|
| 528 |
-
prompt_tokens=
|
| 529 |
completion_tokens=0,
|
| 530 |
)
|
| 531 |
-
|
| 532 |
|
| 533 |
-
total_cost = input_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
|
|
|
|
| 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
|
| 1689 |
cost_usd = None
|
| 1690 |
savings_usd = None
|
| 1691 |
if self.cost_tracker:
|
| 1692 |
cost_usd = self.cost_tracker.estimate_cost(
|
| 1693 |
-
model,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1694 |
)
|
| 1695 |
original_cost = self.cost_tracker.estimate_cost(
|
| 1696 |
-
model,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
#
|
| 2919 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 2950 |
cache_read_tokens = stream_state["cache_read_input_tokens"]
|
|
|
|
| 2951 |
|
| 2952 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2953 |
cost_usd = None
|
| 2954 |
savings_usd = None
|
| 2955 |
if self.cost_tracker:
|
| 2956 |
cost_usd = self.cost_tracker.estimate_cost(
|
| 2957 |
-
model,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2958 |
)
|
| 2959 |
-
|
| 2960 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2961 |
)
|
| 2962 |
-
|
| 2963 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2964 |
self.cost_tracker.record_cost(cost_usd)
|
| 2965 |
-
|
|
|
|
|
|
|
| 2966 |
|
| 2967 |
await self.metrics.record_request(
|
| 2968 |
provider=provider,
|
| 2969 |
model=model,
|
| 2970 |
-
input_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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3175 |
cost_usd = savings_usd = None
|
| 3176 |
if self.cost_tracker:
|
| 3177 |
cost_usd = self.cost_tracker.estimate_cost(
|
| 3178 |
-
model,
|
|
|
|
|
|
|
|
|
|
| 3179 |
)
|
| 3180 |
original_cost = self.cost_tracker.estimate_cost(
|
| 3181 |
-
model,
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3899 |
cost_usd = savings_usd = None
|
| 3900 |
if self.cost_tracker:
|
| 3901 |
cost_usd = self.cost_tracker.estimate_cost(
|
| 3902 |
-
model,
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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}: {
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4154 |
cost_usd = savings_usd = None
|
| 4155 |
if self.cost_tracker:
|
| 4156 |
cost_usd = self.cost_tracker.estimate_cost(
|
| 4157 |
-
model,
|
|
|
|
|
|
|
|
|
|
| 4158 |
)
|
| 4159 |
original_cost = self.cost_tracker.estimate_cost(
|
| 4160 |
-
model,
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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
|
|
|