gemini: minimal thinking on correlation, token accounting per call
A day of replay runs (server-26#170) spent ~$5 of Gemini on ~7 two-hour windows (~$0.70 per 290 calls) — several dollars a day per live deployment for correlation alone — and nothing could say where it went (#45). Gemini 3.x thinks by default and bills it as output; the deprecated google-generativeai SDK these calls used cannot set a thinking level. - app/internal/gemini.py: every Gemini call (correlation + transcript correction) goes through google-genai with JSON mode, an explicit thinking level, and logs in/out/thinking tokens. A model that rejects the level is retried without it once and remembered, so the tier is never lost to a config param. API failures still raise for ai_health. - correlator: thinking_level "minimal" (a link/new/orphan choice). transcript correction: "low" until a replay shows minimal is safe. - replay: runs record real Gemini token usage (metrics.gemini_usage), shown in the Replay tab. - requirements: google-genai. c2-core: 474 pass. Frontend typecheck not run (no Node on this box). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
266c958208
commit
0543526eb0
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
One place every Gemini call goes through: JSON-mode generation, an explicit
|
||||
thinking level, and token accounting.
|
||||
|
||||
Why it exists: a day of replay runs (server-26#170) cost ~$5 of Gemini for
|
||||
~7 two-hour windows — roughly $0.70 per 290 calls, which projects to several
|
||||
dollars a day per live deployment for correlation alone — and nothing in DRB
|
||||
could say where it went (server-26#45). Gemini 3.x models "think" by default
|
||||
and bill that as output; the old google-generativeai SDK these calls used
|
||||
cannot even set a thinking level. A link/new/orphan choice or a transcript
|
||||
cleanup does not need extended reasoning.
|
||||
|
||||
Every call logs its token counts, and inside a replay run they are also added
|
||||
to the run's own usage sink (see app/internal/replay.py), so a run reports
|
||||
what it actually spent instead of an estimate.
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
|
||||
_client = None
|
||||
_client_lock = threading.Lock()
|
||||
# Models that rejected a thinking level: retried without one from then on.
|
||||
_no_thinking_level: set[str] = set()
|
||||
|
||||
_usage_sink: ContextVar[Optional[dict]] = ContextVar("drb_gemini_usage", default=None)
|
||||
|
||||
|
||||
def collect_usage(sink: Optional[dict]):
|
||||
"""Route token counts for the current context into `sink` (a replay run). Returns a reset token."""
|
||||
return _usage_sink.set(sink)
|
||||
|
||||
|
||||
def reset_usage(token) -> None:
|
||||
_usage_sink.reset(token)
|
||||
|
||||
|
||||
def _get_client():
|
||||
global _client
|
||||
with _client_lock:
|
||||
if _client is None:
|
||||
from google import genai # lazy — only when a Gemini call is made
|
||||
_client = genai.Client(api_key=settings.gemini_api_key)
|
||||
return _client
|
||||
|
||||
|
||||
def _config(thinking_level: Optional[str]):
|
||||
from google.genai import types
|
||||
kwargs = {"response_mime_type": "application/json"}
|
||||
if thinking_level:
|
||||
kwargs["thinking_config"] = types.ThinkingConfig(thinking_level=thinking_level)
|
||||
return types.GenerateContentConfig(**kwargs)
|
||||
|
||||
|
||||
def _record(purpose: str, model: str, usage) -> None:
|
||||
prompt = getattr(usage, "prompt_token_count", None) or 0
|
||||
output = getattr(usage, "candidates_token_count", None) or 0
|
||||
thoughts = getattr(usage, "thoughts_token_count", None) or 0
|
||||
logger.info(f"gemini usage {purpose} {model}: in={prompt} out={output} thinking={thoughts}")
|
||||
sink = _usage_sink.get()
|
||||
if sink is not None:
|
||||
row = sink.setdefault(f"{purpose}:{model}", {"calls": 0, "in": 0, "out": 0, "thinking": 0})
|
||||
row["calls"] += 1
|
||||
row["in"] += prompt
|
||||
row["out"] += output
|
||||
row["thinking"] += thoughts
|
||||
|
||||
|
||||
def generate_json(model: str, prompt: str, *, purpose: str,
|
||||
thinking_level: Optional[str] = "minimal") -> dict:
|
||||
"""
|
||||
Synchronous (run it via asyncio.to_thread). Returns the parsed JSON body.
|
||||
Raises on API failure, exactly like the old per-module helpers, so callers'
|
||||
ai_health classification (billing / dead model / transient) is unchanged.
|
||||
"""
|
||||
client = _get_client()
|
||||
level = None if model in _no_thinking_level else thinking_level
|
||||
try:
|
||||
resp = client.models.generate_content(model=model, contents=prompt, config=_config(level))
|
||||
except Exception as e:
|
||||
# A model that doesn't accept this thinking level answers 400 for
|
||||
# every call; drop the setting for that model rather than lose the tier.
|
||||
if level and "thinking" in str(e).lower():
|
||||
logger.warning(f"gemini: {model} rejected thinking_level={level!r} ({e}); retrying without it")
|
||||
_no_thinking_level.add(model)
|
||||
resp = client.models.generate_content(model=model, contents=prompt, config=_config(None))
|
||||
else:
|
||||
raise
|
||||
_record(purpose, model, getattr(resp, "usage_metadata", None))
|
||||
return json.loads(resp.text)
|
||||
@@ -20,7 +20,6 @@ Error handling: any Gemini failure returns None from decide() and the
|
||||
rules_decision from tiebreak() so the pipeline never stalls.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from app.internal.logger import logger
|
||||
@@ -190,15 +189,8 @@ def _build_tiebreak_prompt(rules_decision: dict, llm_decision: dict, ctx: dict)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _sync_gemini(model_name: str, prompt: str) -> dict:
|
||||
import google.generativeai as genai # lazy import — only when needed
|
||||
|
||||
genai.configure(api_key=settings.gemini_api_key)
|
||||
model = genai.GenerativeModel(
|
||||
model_name,
|
||||
generation_config={"response_mime_type": "application/json"},
|
||||
)
|
||||
response = model.generate_content(prompt)
|
||||
return json.loads(response.text)
|
||||
from app.internal import gemini
|
||||
return gemini.generate_json(model_name, prompt, purpose="correlation")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -469,6 +469,9 @@ async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
|
||||
fl_token = force_flags(_flags_for(mode))
|
||||
ai_failures: list = []
|
||||
ai_token = ai_health.collect_sandbox_failures(ai_failures)
|
||||
from app.internal import gemini
|
||||
usage: dict = {}
|
||||
usage_token = gemini.collect_usage(usage)
|
||||
try:
|
||||
sem = asyncio.Semaphore(PREFETCH)
|
||||
|
||||
@@ -561,6 +564,7 @@ async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
|
||||
metrics = compute_metrics(incidents, sb_calls)
|
||||
metrics["est_cost_usd"] = _running_cost(progress, metrics, mode)
|
||||
metrics["ai_failures"] = dict(Counter(f"{f['tier']}: {f['problem']}" for f in ai_failures))
|
||||
metrics["gemini_usage"] = usage
|
||||
except Exception as e:
|
||||
status = "failed"
|
||||
errors.append(f"run: {type(e).__name__}: {e}"[:300])
|
||||
@@ -568,6 +572,7 @@ async def _run(run_id: str, org_id: str, calls: list[dict], mode: str,
|
||||
logger.error(f"Replay {run_id} failed: {e}")
|
||||
finally:
|
||||
ai_health._sandbox_failures.reset(ai_token)
|
||||
gemini.reset_usage(usage_token)
|
||||
unforce_flags(fl_token)
|
||||
fstore.exit_sandbox(sb_token)
|
||||
_cancel.discard(run_id)
|
||||
|
||||
@@ -43,7 +43,6 @@ another equally plausible word.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -228,14 +227,11 @@ def build_context_block(context: dict, talkgroup_name: Optional[str]) -> str:
|
||||
|
||||
|
||||
def _sync_gemini(model_name: str, prompt: str) -> dict:
|
||||
import google.generativeai as genai # lazy import — only when needed
|
||||
|
||||
genai.configure(api_key=settings.gemini_api_key)
|
||||
model = genai.GenerativeModel(
|
||||
model_name,
|
||||
generation_config={"response_mime_type": "application/json"},
|
||||
)
|
||||
return json.loads(model.generate_content(prompt).text)
|
||||
from app.internal import gemini
|
||||
# Correction rewrites text against vocabulary; keep a little reasoning
|
||||
# ("low") rather than the correlator's "minimal" until a replay shows
|
||||
# minimal doesn't hurt it.
|
||||
return gemini.generate_json(model_name, prompt, purpose="correction", thinking_level="low")
|
||||
|
||||
|
||||
async def correct(
|
||||
|
||||
@@ -6,6 +6,7 @@ firebase-admin
|
||||
google-cloud-storage
|
||||
openai
|
||||
google-generativeai
|
||||
google-genai
|
||||
numpy
|
||||
httpx
|
||||
python-multipart
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
app/internal/gemini.py — thinking level, fallback when a model rejects it,
|
||||
and token accounting into a replay's usage sink (server-26#170 cost finding).
|
||||
"""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from app.internal import gemini
|
||||
|
||||
|
||||
class _FakeModels:
|
||||
def __init__(self, reject_thinking=False):
|
||||
self.reject_thinking = reject_thinking
|
||||
self.configs = []
|
||||
|
||||
def generate_content(self, model, contents, config):
|
||||
self.configs.append(config)
|
||||
if self.reject_thinking and config.get("thinking_level"):
|
||||
raise RuntimeError("400 INVALID_ARGUMENT: thinking_level is not supported for this model")
|
||||
return SimpleNamespace(
|
||||
text='{"action": "link"}',
|
||||
usage_metadata=SimpleNamespace(prompt_token_count=1200, candidates_token_count=30,
|
||||
thoughts_token_count=0),
|
||||
)
|
||||
|
||||
|
||||
def _patched(models):
|
||||
client = SimpleNamespace(models=models)
|
||||
return (patch.object(gemini, "_get_client", return_value=client),
|
||||
patch.object(gemini, "_config", lambda level: {"thinking_level": level}))
|
||||
|
||||
|
||||
def test_minimal_thinking_by_default_and_usage_lands_in_the_sink():
|
||||
models = _FakeModels()
|
||||
a, b = _patched(models)
|
||||
sink = {}
|
||||
tok = gemini.collect_usage(sink)
|
||||
try:
|
||||
with a, b:
|
||||
assert gemini.generate_json("m1", "p", purpose="correlation") == {"action": "link"}
|
||||
finally:
|
||||
gemini.reset_usage(tok)
|
||||
assert models.configs == [{"thinking_level": "minimal"}]
|
||||
assert sink == {"correlation:m1": {"calls": 1, "in": 1200, "out": 30, "thinking": 0}}
|
||||
|
||||
|
||||
def test_model_that_rejects_thinking_level_falls_back_once():
|
||||
models = _FakeModels(reject_thinking=True)
|
||||
a, b = _patched(models)
|
||||
gemini._no_thinking_level.discard("m2")
|
||||
with a, b:
|
||||
gemini.generate_json("m2", "p", purpose="correlation")
|
||||
gemini.generate_json("m2", "p", purpose="correlation")
|
||||
# first call: tried minimal, retried without; second call: straight without
|
||||
assert models.configs == [{"thinking_level": "minimal"}, {"thinking_level": None}, {"thinking_level": None}]
|
||||
gemini._no_thinking_level.discard("m2")
|
||||
|
||||
|
||||
def test_other_failures_still_raise_for_ai_health():
|
||||
class Boom(_FakeModels):
|
||||
def generate_content(self, **kw):
|
||||
raise RuntimeError("429 insufficient_quota")
|
||||
a, b = _patched(Boom())
|
||||
with a, b:
|
||||
try:
|
||||
gemini.generate_json("m3", "p", purpose="correlation")
|
||||
except RuntimeError as e:
|
||||
assert "insufficient_quota" in str(e)
|
||||
else:
|
||||
raise AssertionError("should raise")
|
||||
Reference in New Issue
Block a user