feat(CG3e+CG4): utvidet token-logging — caller_type, module_name, session_id + CG4-fase i ROADMAP

- token_logger.py: nytt schema med caller_type (agent|cli|browser|cron|api), module_name (jason/opax, costguard, threadstone, cli/gemini-tui, billing_agent), session_id
- agent.py: agent_name → strukturert label (module_name + mode), caller_type satt til 'agent'
- ROADMAP.md: CG4 Token Intelligence-fase lagt til (CG4a–CG4d) under NEXT
This commit is contained in:
chrischristiansen-glitch 2026-06-10 00:36:25 +02:00
parent dc7fc0aa79
commit 7686fc7623
3 changed files with 134 additions and 26 deletions

View File

@ -51,10 +51,15 @@ APP_NAME = "opax"
# Token logger — feiler stille, stopper aldri agent # Token logger — feiler stille, stopper aldri agent
try: try:
from token_logger import log_token_usage from token_logger import log_token_usage, create_bq_table_if_not_exists
except ImportError: except ImportError:
def log_token_usage(*args, **kwargs): def log_token_usage(*args, **kwargs):
pass pass
def create_bq_table_if_not_exists():
pass
# Opprett BQ-tabell ved oppstart (idempotent, feiler stille)
create_bq_table_if_not_exists()
def _normalize_mode(mode: str) -> str: def _normalize_mode(mode: str) -> str:
@ -143,10 +148,18 @@ def build_agent(mode: str = "light") -> Agent:
root_agent = build_agent(mode="light") root_agent = build_agent(mode="light")
async def _run_async(message: str, user_id: str, session_id: str, mode: str) -> str: async def _run_async(
message: str,
user_id: str,
session_id: str,
mode: str,
caller_type: str = "agent",
) -> str:
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
agent = build_agent(mode=mode) agent = build_agent(mode=mode)
models = get_models_for_mode(mode) models = get_models_for_mode(mode)
module_name = f"jason/{mode}" # CG4: 'jason/light' eller 'jason/heavy'
session_service = InMemorySessionService() session_service = InMemorySessionService()
session = await session_service.create_session( session = await session_service.create_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id, app_name=APP_NAME, user_id=user_id, session_id=session_id,
@ -163,20 +176,22 @@ async def _run_async(message: str, user_id: str, session_id: str, mode: str) ->
): ):
if event.is_final_response() and event.content and event.content.parts: if event.is_final_response() and event.content and event.content.parts:
final_text = event.content.parts[0].text or "" final_text = event.content.parts[0].text or ""
# Hent token-metadata fra event hvis tilgjengelig
if hasattr(event, "usage_metadata") and event.usage_metadata: if hasattr(event, "usage_metadata") and event.usage_metadata:
um = event.usage_metadata um = event.usage_metadata
input_tokens += getattr(um, "prompt_token_count", 0) or 0 input_tokens += getattr(um, "prompt_token_count", 0) or 0
output_tokens += getattr(um, "candidates_token_count", 0) or 0 output_tokens += getattr(um, "candidates_token_count", 0) or 0
# Logg token-bruk — feiler stille # Logg med full CG4-labeling — feiler stille
if input_tokens > 0 or output_tokens > 0: if input_tokens > 0 or output_tokens > 0:
log_token_usage( log_token_usage(
agent_name=user_id, agent_name=module_name, # bakoverkompatibelt
model_name=models["orchestrator"], model_name=models["orchestrator"],
input_tokens=input_tokens, input_tokens=input_tokens,
output_tokens=output_tokens, output_tokens=output_tokens,
request_id=request_id, request_id=request_id,
module_name=module_name, # CG4: 'jason/light' | 'jason/heavy'
caller_type=caller_type, # CG4: agent|browser|cli|cron|api
session_id=session_id, # CG4: for sesjon-gruppering
) )
return final_text return final_text
@ -187,11 +202,26 @@ def run(
user_id: str = "opax", user_id: str = "opax",
session_id: str = "default", session_id: str = "default",
mode: str = "light", mode: str = "light",
caller_type: str = "agent",
) -> str: ) -> str:
"""
Kjør OPAX-agenten.
caller_type-verdier:
'agent' programmatisk kall fra server/app.py
'browser' direkte fra browser-chat (opax.vauco.no)
'cli' lokal kjøring fra terminalen
'cron' bakgrunnsjobb
'api' ekstern REST-kall
"""
mode = _normalize_mode(mode) mode = _normalize_mode(mode)
authorize_mode(user_id, mode) authorize_mode(user_id, mode)
return asyncio.run(_run_async( return asyncio.run(_run_async(
message=message, user_id=user_id, session_id=session_id, mode=mode, message=message,
user_id=user_id,
session_id=session_id,
mode=mode,
caller_type=caller_type,
)) ))
@ -200,6 +230,7 @@ if __name__ == "__main__":
logging.basicConfig(level=logging.WARNING) logging.basicConfig(level=logging.WARNING)
query = sys.argv[1] if len(sys.argv) > 1 else "Hva er OPAX?" query = sys.argv[1] if len(sys.argv) > 1 else "Hva er OPAX?"
mode = sys.argv[2] if len(sys.argv) > 2 else "light" mode = sys.argv[2] if len(sys.argv) > 2 else "light"
print(f"Mode: {mode} | Query: {query}") caller_type = sys.argv[3] if len(sys.argv) > 3 else "cli"
print(f"Mode: {mode} | Caller: {caller_type} | Query: {query}")
print("-" * 60) print("-" * 60)
print(run(message=query, user_id="opax", mode=mode)) print(run(message=query, user_id="opax", mode=mode, caller_type=caller_type))

View File

@ -1,10 +1,10 @@
# token_logger.py — Logg LLM token-bruk til BigQuery (CG3e) # token_logger.py — Logg LLM token-bruk til BigQuery (CG3e + CG4)
import os import os
import logging import logging
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional, Literal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -13,6 +13,23 @@ DATASET = os.environ.get("BQ_BILLING_DATASET", "billing_data")
TABLE = "llm_token_usage" TABLE = "llm_token_usage"
FULL_TABLE = f"{PROJECT_ID}.{DATASET}.{TABLE}" FULL_TABLE = f"{PROJECT_ID}.{DATASET}.{TABLE}"
# Gyldige caller_type-verdier
# agent = OPAX/Jason kjørt som agent via /run eller /run/dag
# browser = direkte kall fra browser-chat (opax.vauco.no)
# cli = Gemini TUI CLI eller lokal agent.py kjørt manuelt
# cron = bakgrunnsjobb (billing_agent, anomaly_detector, dag-runner)
# api = ekstern REST-kall til /run uten sesjon
CallerType = Literal["agent", "browser", "cli", "cron", "api"]
# Gyldige module_name-verdier (utvides etter hvert som moduler comes online)
# jason/light = OPAX-agenten, gemini-2.5-flash
# jason/heavy = OPAX-agenten, gemini-2.5-pro
# costguard = CostGuard-analyse og anomali-endepunkter
# billing_agent = ml/billing_agent.py bakgrunns-analyse
# threadstone = Threadstone-modul (fremtidig)
# cli/gemini-tui = lokal Gemini TUI (arkivert, men kan aktiveres)
ModuleName = str # ikke enum — for fremtidssikkerhet
# Prismodell (USD per 1M tokens) — oppdater ved modellbytte # Prismodell (USD per 1M tokens) — oppdater ved modellbytte
MODEL_PRICING = { MODEL_PRICING = {
"gemini-2.5-pro": {"input": 1.25, "output": 10.00}, "gemini-2.5-pro": {"input": 1.25, "output": 10.00},
@ -29,26 +46,39 @@ def _estimate_cost(model_name: str, input_tokens: int, output_tokens: int) -> fl
def log_token_usage( def log_token_usage(
agent_name: str, agent_name: str, # deprecated alias — bruk module_name
model_name: str, model_name: str,
input_tokens: int, input_tokens: int,
output_tokens: int, output_tokens: int,
request_id: Optional[str] = None, request_id: Optional[str] = None,
module_name: Optional[str] = None, # CG4: f.eks. 'jason/light', 'costguard'
caller_type: CallerType = "agent", # CG4: hvem/hva som kalte
session_id: Optional[str] = None, # CG4: sesjon-ID for gruppering
) -> None: ) -> None:
""" """
Logg ett LLM-kall til BigQuery-tabellen llm_token_usage. Logg ett LLM-kall til BigQuery-tabellen llm_token_usage.
Feiler stille slik at applikasjonen aldri krasjer pga logging. Feiler stille slik at applikasjonen aldri krasjer pga logging.
Labeling-konvensjoner:
module_name hvilken komponent (jason/light, costguard, billing_agent, )
caller_type hvordan kallet ble trigget (agent, browser, cli, cron, api)
agent_name beholdt for bakoverkompatibilitet; settes til module_name om gitt
""" """
try: try:
from google.cloud import bigquery from google.cloud import bigquery
client = bigquery.Client(project=PROJECT_ID) client = bigquery.Client(project=PROJECT_ID)
effective_module = module_name or agent_name
total_tokens = input_tokens + output_tokens total_tokens = input_tokens + output_tokens
estimated_cost = _estimate_cost(model_name, input_tokens, output_tokens) estimated_cost = _estimate_cost(model_name, input_tokens, output_tokens)
row = { row = {
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"agent_name": agent_name, "agent_name": effective_module, # bakoverkompatibelt felt
"module_name": effective_module, # CG4: nytt felt
"caller_type": caller_type, # CG4: nytt felt
"session_id": session_id or "", # CG4: nytt felt
"model_name": model_name, "model_name": model_name,
"input_tokens": input_tokens, "input_tokens": input_tokens,
"output_tokens": output_tokens, "output_tokens": output_tokens,
@ -62,17 +92,21 @@ def log_token_usage(
logger.warning(f"[token_logger] BQ insert errors: {errors}") logger.warning(f"[token_logger] BQ insert errors: {errors}")
else: else:
logger.info( logger.info(
f"[token_logger] Logged: agent={agent_name} model={model_name} " f"[token_logger] Logged: module={effective_module} "
f"caller={caller_type} model={model_name} "
f"in={input_tokens} out={output_tokens} cost=${estimated_cost:.6f}" f"in={input_tokens} out={output_tokens} cost=${estimated_cost:.6f}"
) )
except Exception as e: except Exception as e:
logger.warning(f"[token_logger] Failed to log token usage (non-fatal): {e}") logger.warning(f"[token_logger] Failed to log token usage (non-fatal): {e}")
# BQ table schema — brukes som referanse ved manuell oppretting eller Terraform # BQ table schema — brukes som referanse ved manuell oppretting, Terraform eller create_bq_table()
BQ_SCHEMA = [ BQ_SCHEMA = [
{"name": "timestamp", "type": "TIMESTAMP", "mode": "REQUIRED"}, {"name": "timestamp", "type": "TIMESTAMP", "mode": "REQUIRED"},
{"name": "agent_name", "type": "STRING", "mode": "REQUIRED"}, {"name": "agent_name", "type": "STRING", "mode": "REQUIRED"}, # bakoverkompatibelt
{"name": "module_name", "type": "STRING", "mode": "REQUIRED"}, # CG4
{"name": "caller_type", "type": "STRING", "mode": "REQUIRED"}, # CG4: agent|browser|cli|cron|api
{"name": "session_id", "type": "STRING", "mode": "NULLABLE"}, # CG4
{"name": "model_name", "type": "STRING", "mode": "REQUIRED"}, {"name": "model_name", "type": "STRING", "mode": "REQUIRED"},
{"name": "input_tokens", "type": "INTEGER", "mode": "REQUIRED"}, {"name": "input_tokens", "type": "INTEGER", "mode": "REQUIRED"},
{"name": "output_tokens", "type": "INTEGER", "mode": "REQUIRED"}, {"name": "output_tokens", "type": "INTEGER", "mode": "REQUIRED"},
@ -80,3 +114,29 @@ BQ_SCHEMA = [
{"name": "estimated_cost_usd", "type": "FLOAT", "mode": "REQUIRED"}, {"name": "estimated_cost_usd", "type": "FLOAT", "mode": "REQUIRED"},
{"name": "request_id", "type": "STRING", "mode": "NULLABLE"}, {"name": "request_id", "type": "STRING", "mode": "NULLABLE"},
] ]
def create_bq_table_if_not_exists() -> None:
"""
Opprett llm_token_usage-tabellen i BigQuery hvis den ikke finnes.
Trygt å kalle ved app-oppstart (CREATE TABLE IF NOT EXISTS-semantikk).
"""
try:
from google.cloud import bigquery
client = bigquery.Client(project=PROJECT_ID)
dataset_ref = client.dataset(DATASET)
table_ref = dataset_ref.table(TABLE)
try:
client.get_table(table_ref)
logger.info(f"[token_logger] Tabell {FULL_TABLE} finnes allerede.")
return
except Exception:
pass # tabell finnes ikke — opprett
schema = [bigquery.SchemaField(f["name"], f["type"], mode=f["mode"]) for f in BQ_SCHEMA]
table = bigquery.Table(table_ref, schema=schema)
client.create_table(table)
logger.info(f"[token_logger] Opprettet tabell {FULL_TABLE}.")
except Exception as e:
logger.warning(f"[token_logger] Kunne ikke opprette BQ-tabell (non-fatal): {e}")

View File

@ -35,10 +35,12 @@
- Alle dashboard-moduler draggable + collapsible + layout persistert i localStorage - Alle dashboard-moduler draggable + collapsible + layout persistert i localStorage
- Compute Engine SKU-duplikater fikset — grupperes nå per tjeneste - Compute Engine SKU-duplikater fikset — grupperes nå per tjeneste
- TODO Fase-C: `/billing/budget` migreres til Firestore per bruker - TODO Fase-C: `/billing/budget` migreres til Firestore per bruker
- [ ] **CG3e — Tokenmeter for agenter (LLM-bruk i CostGuard)** - [x] **CG3e — Tokenmeter: token-logging til BigQuery** · 2026-06-10
- Logg LLM-bruk (tokens, modell, agent) til egen BigQuery-tabell `llm_token_usage` - `token_logger.py`: utvidet schema med `module_name`, `caller_type`, `session_id`
- Endpoint `/billing/tokens/summary` eksisterer ✅ — mangler data (tabellen ikke populert ennå) - `agent.py`: labeling `jason/light` | `jason/heavy`, `caller_type` propagert fra call-site
- Kort på dashbordet vises ✅ — venter på data - BQ-tabell opprettes automatisk ved oppstart (`create_bq_table_if_not_exists()`)
- `/billing/tokens/summary` endepunkt finnes ✅ — vil nå få data
- Tokenkortet på dashbordet vil vise data ved neste agent-kall
--- ---
@ -57,7 +59,7 @@
- **Dobbelt formål:** Egenbruk (intern kostnadsovervåkning) + Salgsmodus (kundedemoer og pitch) - **Dobbelt formål:** Egenbruk (intern kostnadsovervåkning) + Salgsmodus (kundedemoer og pitch)
- **Modellstrategi:** `gemini-2.5-pro` for high-touch, Flash-modell (fremtidig) for bakgrunnsoppgaver - **Modellstrategi:** `gemini-2.5-pro` for high-touch, Flash-modell (fremtidig) for bakgrunnsoppgaver
- **Hierarkisk koststruktur:** Kostnader grupperes per tjeneste (ikke flate SKU-labels) - **Hierarkisk koststruktur:** Kostnader grupperes per tjeneste (ikke flate SKU-labels)
- **Token-logging:** All AI-bruk logges til `llm_token_usage`-tabell - **Token-logging:** All AI-bruk logges til `llm_token_usage`-tabell med full labeling
### CostGuard frontend wiring ### CostGuard frontend wiring
- [x] **CG3a** — Bygg `static/costguard.html` - [x] **CG3a** — Bygg `static/costguard.html`
@ -66,6 +68,20 @@
- [x] **CG3d** — Test OAuth-flow - [x] **CG3d** — Test OAuth-flow
- [ ] **OQ-21** — Bygg `app.threadstone.vauco.no` eller fjern CNAME - [ ] **OQ-21** — Bygg `app.threadstone.vauco.no` eller fjern CNAME
### CG4 — Token Intelligence (neste etter CG3e)
> Bygger på `llm_token_usage`-rådata fra CG3e. Formål: full synlighet på AI-kostnader per modul,
> prosjektestimering FØR oppgave startes, og korrekt prising av oppdrag til kunder.
| # | Oppgave | Formål |
|---|---------|--------|
| CG4a | `/billing/tokens/by-module` endepunkt — aggreger token-bruk per `module_name` og `caller_type` | Vise hvilken komponent som spiser tokens |
| CG4b | Dashboard-utvidelse: tokenmodul viser breakdown per `jason/light`, `jason/heavy`, `costguard`, `billing_agent`, `cli/gemini-tui` | Visuell oversikt per kaller |
| CG4c | Prosjektestimator: historisk snitt-tokens per oppgavetype × kompleksitetsfaktor → estimert kostnad i USD før oppgave startes | Estimer hva et prosjekt koster FØR det bygges |
| CG4d | Kundeprisingskalkulator: basert på token-historikk per modul → autogenerer prisforslag per oppdrag (basis for CostGuard-faktura til kunder) | Korrekt prising av AI-arbeid |
**Trigger for CG4:** CG3e live og produserer data (minst 50 loggede kall).
--- ---
## LATER — Backlog (vent på trigger) ## LATER — Backlog (vent på trigger)
@ -134,6 +150,7 @@
- [x] **K1 — Repo-konsolidering** · 2026-06-09 - [x] **K1 — Repo-konsolidering** · 2026-06-09
- [x] **K3 — Fiks nattlig GDrive backup** · 2026-06-09 - [x] **K3 — Fiks nattlig GDrive backup** · 2026-06-09
- [x] **CG6 — Modulært dashboard, anomalier, historikk, budsjett** · 2026-06-10 - [x] **CG6 — Modulært dashboard, anomalier, historikk, budsjett** · 2026-06-10
- [x] **CG3e — Token-logging til BQ med full CG4-labeling** · 2026-06-10
--- ---