feat(costs): Pro-Nutzer-Kostenerfassung Stufe 1 (OpenRouter-Ist-Kosten)
CostMeter via ContextVars (app/core/costs.py, multitool-ready). Alle
OpenRouter-Calls (LLM, Sonar, Koreferenz, Fallback) senden usage:{include:true}
und buchen die Ist-Kosten je Kategorie (llm/web_search) — inkl. finalem
Streaming-usage-Chunk. cost_usage-Tabelle (user/day/category) + Store-Methoden;
ws.py/chat.py start_meter pro Turn + persist_costs. Statistik: Spalten
"Kosten (Monat)" + "Ø/Anfrage" + Gesamt. Live: 1 Such-Turn ~ $0,0052
(web_search dominiert, LLM ~ $0,00007). Tests: 318.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
21a5250282
commit
d87f0ce424
9 changed files with 171 additions and 10 deletions
|
|
@ -5,6 +5,7 @@ from collections.abc import AsyncIterator
|
|||
import httpx
|
||||
|
||||
from app.core import clock
|
||||
from app.core.costs import record_openrouter_cost, record_sse_usage
|
||||
from app.providers.llm.base import (
|
||||
LLMProvider,
|
||||
lang_instruction,
|
||||
|
|
@ -122,6 +123,7 @@ class OpenRouterLLMProvider(LLMProvider):
|
|||
payload = {
|
||||
"model": self.model,
|
||||
"messages": self._build_messages(text, history, language=language),
|
||||
"usage": {"include": True},
|
||||
}
|
||||
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
last_error: Exception | None = None
|
||||
|
|
@ -158,6 +160,7 @@ class OpenRouterLLMProvider(LLMProvider):
|
|||
raise RuntimeError(f"OpenRouter LLM transport error: {exc}") from exc
|
||||
|
||||
data = response.json()
|
||||
record_openrouter_cost(data.get("usage"), "llm")
|
||||
try:
|
||||
content = data["choices"][0]["message"]["content"]
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
|
|
@ -182,6 +185,7 @@ class OpenRouterLLMProvider(LLMProvider):
|
|||
"model": self.model,
|
||||
"messages": self._build_messages(text, history, language=language),
|
||||
"stream": True,
|
||||
"usage": {"include": True},
|
||||
}
|
||||
timeout = httpx.Timeout(connect=10.0, read=120.0, write=30.0, pool=10.0)
|
||||
last_error: Exception | None = None
|
||||
|
|
@ -213,6 +217,7 @@ class OpenRouterLLMProvider(LLMProvider):
|
|||
f"{body.decode(errors='replace')}"
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
record_sse_usage(line, "llm")
|
||||
delta = sse_delta(line)
|
||||
if delta:
|
||||
yield delta
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from collections.abc import AsyncIterator
|
|||
import httpx
|
||||
|
||||
from app.core import clock
|
||||
from app.core.costs import record_openrouter_cost
|
||||
from app.metrics import metrics
|
||||
from app.pipeline.search_backstop import should_force_search
|
||||
from app.providers.llm.base import LLMProvider, lang_instruction, with_lang_reminder
|
||||
|
|
@ -180,7 +181,8 @@ class ToolCallingLLM(LLMProvider):
|
|||
|
||||
async def _chat(self, messages: list[dict], allow_tools: bool = True) -> dict:
|
||||
"""Ein Modell-Aufruf; liefert die Assistant-Message (mit/ohne tool_calls)."""
|
||||
payload = {"model": self.model, "messages": messages, "temperature": self.temperature}
|
||||
payload = {"model": self.model, "messages": messages, "temperature": self.temperature,
|
||||
"usage": {"include": True}}
|
||||
if allow_tools and not self._tools_unsupported:
|
||||
payload["tools"] = [WEB_SEARCH_TOOL]
|
||||
payload["tool_choice"] = "auto"
|
||||
|
|
@ -215,6 +217,7 @@ class ToolCallingLLM(LLMProvider):
|
|||
await asyncio.sleep(min(2.0 * attempt, 30.0))
|
||||
continue
|
||||
raise RuntimeError(f"Ungültige OpenRouter-Antwort: {resp.text[:300]}")
|
||||
record_openrouter_cost(data.get("usage"), "llm")
|
||||
return data["choices"][0]["message"]
|
||||
|
||||
raise last_error or RuntimeError("ToolCallingLLM: alle Versuche fehlgeschlagen")
|
||||
|
|
@ -338,8 +341,8 @@ class ToolCallingLLM(LLMProvider):
|
|||
async def _stream_chat(self, messages: list[dict], allow_tools: bool,
|
||||
acc: _StreamAcc) -> AsyncIterator[str]:
|
||||
"""Ein gestreamter Modell-Call. Yieldet Text-Deltas; füllt acc (Text + tool_calls)."""
|
||||
payload = {"model": self.model, "messages": messages,
|
||||
"temperature": self.temperature, "stream": True}
|
||||
payload = {"model": self.model, "messages": messages, "temperature": self.temperature,
|
||||
"stream": True, "usage": {"include": True}}
|
||||
if allow_tools and not self._tools_unsupported:
|
||||
payload["tools"] = [WEB_SEARCH_TOOL]
|
||||
payload["tool_choice"] = "auto"
|
||||
|
|
@ -362,9 +365,15 @@ class ToolCallingLLM(LLMProvider):
|
|||
if not data or data == "[DONE]":
|
||||
continue
|
||||
try:
|
||||
delta = json.loads(data)["choices"][0]["delta"]
|
||||
except (ValueError, KeyError, IndexError, TypeError):
|
||||
obj = json.loads(data)
|
||||
except ValueError:
|
||||
continue
|
||||
if obj.get("usage"): # finaler Chunk -> Ist-Kosten
|
||||
record_openrouter_cost(obj["usage"], "llm")
|
||||
choices = obj.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
acc.add_text(content)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue