diff --git a/ml/billing_agent.py b/ml/billing_agent.py index f469ba5..15e828b 100644 --- a/ml/billing_agent.py +++ b/ml/billing_agent.py @@ -3,40 +3,74 @@ import datetime from google.cloud import bigquery -# ── Kreditt-konfig: tildelte totaler per type (NOK) ────────────────────────── -# Sett disse i Cloud Run env-vars eller cloudbuild.yaml --set-env-vars -# GOOGLE_CREDIT_INFRA_NOK = Free Trial (Cloud Run, BQ, Compute, Artifact Registry) -# GOOGLE_CREDIT_VERTEX_NOK = Trial for Gen App Builder (Vertex AI tokens) -# GOOGLE_CREDIT_DIALOGFLOW_NOK = Dialogflow CX Trial +# ── Kreditt-konfig ─────────────────────────────────────────────────────────── +# Env-vars (Cloud Run / cloudbuild.yaml): +# GOOGLE_CREDIT_INFRA_NOK – Free Trial totalbeløp i NOK +# GOOGLE_CREDIT_INFRA_EXPIRES – utløpsdato ISO (YYYY-MM-DD) +# GOOGLE_CREDIT_VERTEX_NOK – Vertex AI totalbeløp i NOK +# GOOGLE_CREDIT_VERTEX_EXPIRES – utløpsdato ISO (YYYY-MM-DD) +# GOOGLE_CREDIT_DIALOGFLOW_NOK – Dialogflow CX totalbeløp i NOK +# GOOGLE_CREDIT_DIALOGFLOW_EXPIRES – utløpsdato ISO (YYYY-MM-DD) +# USDNOK – valutakurs (default 10.5) CREDIT_CONFIG = [ { - "env_key": "GOOGLE_CREDIT_INFRA_NOK", - "label": "Free Trial (Infrastruktur)", - "bq_types": ["PROMOTION"], # BQ credit type - "skus": None, # alle SKU-er (generell) - "expires_days": 27, # kjent utløp fra Console - "currency": "NOK", + "env_key": "GOOGLE_CREDIT_INFRA_NOK", + "expires_env": "GOOGLE_CREDIT_INFRA_EXPIRES", + "label": "Free Trial (Infrastruktur)", + "bq_types": ["PROMOTION"], + "skus": None, }, { - "env_key": "GOOGLE_CREDIT_VERTEX_NOK", - "label": "Trial for Gen App Builder (Vertex AI)", - "bq_types": ["PROMOTION"], - "skus": ["Vertex AI", "Generative AI", "Cloud AI"], - "expires_days": None, - "currency": "NOK", + "env_key": "GOOGLE_CREDIT_VERTEX_NOK", + "expires_env": "GOOGLE_CREDIT_VERTEX_EXPIRES", + "label": "Trial for Gen App Builder (Vertex AI)", + "bq_types": ["PROMOTION"], + "skus": ["Vertex AI", "Generative AI", "Cloud AI"], }, { - "env_key": "GOOGLE_CREDIT_DIALOGFLOW_NOK", - "label": "Dialogflow CX Trial", - "bq_types": ["PROMOTION"], - "skus": ["Dialogflow"], - "expires_days": None, - "currency": "NOK", + "env_key": "GOOGLE_CREDIT_DIALOGFLOW_NOK", + "expires_env": "GOOGLE_CREDIT_DIALOGFLOW_EXPIRES", + "label": "Dialogflow CX Trial", + "bq_types": ["PROMOTION"], + "skus": ["Dialogflow"], }, ] +def _parse_expires_days(env_key: str) -> int | None: + """Les ISO-dato fra env og beregn dager fra i dag. Null hvis mangler/ugyldig.""" + raw = os.environ.get(env_key, "").strip() + if not raw: + return None + try: + exp = datetime.date.fromisoformat(raw) + return max(0, (exp - datetime.date.today()).days) + except ValueError: + return None + + +def _effective_days(runway: int | None, expires: int | None) -> int | None: + """Laveste av de to der begge er satt, ellers den ene, ellers None.""" + if runway is not None and expires is not None: + return min(runway, expires) + if runway is not None: + return runway + if expires is not None: + return expires + return None + + +def _badge_class(eff: int | None) -> str: + if eff is None: + return "inactive" + if eff <= 14: + return "crit" + if eff <= 30: + return "warn" + return "ok" + + class BillingAgent: def __init__(self): self.project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") @@ -49,7 +83,7 @@ class BillingAgent: self.bq_client = bigquery.Client(project=self.project_id) - # ── summary ──────────────────────────────────────────────────────────────────── + # ── summary ────────────────────────────────────────────────────────────── def get_summary(self): query = f""" SELECT @@ -82,7 +116,7 @@ class BillingAgent: } return {"summary": summary} - # ── forecast ────────────────────────────────────────────────────────────────── + # ── forecast ───────────────────────────────────────────────────────────── def get_forecast(self): q7 = f""" SELECT SUM(cost) + SUM(IFNULL( @@ -118,13 +152,8 @@ class BillingAgent: "data_note": "Prognose basert på siste 7 dager. BigQuery kan ha 24-48 timers forsinkelse.", } - # ── discover_credits: aktiv scanner for alle kreditttyper i BQ ─────────────── + # ── discover_credits ───────────────────────────────────────────────────── def discover_credits(self, days: int = 90): - """ - Scanner BQ-billing for alle unike kreditttyper siste {days} dager. - Returnerer liste med type, full_name, total brukt, og forslag til env-var. - Nyttig for å oppdage nye kreditter automatisk. - """ q = f""" SELECT cr.type AS credit_type, @@ -143,45 +172,38 @@ class BillingAgent: label = (row.full_name or row.credit_type or "Ukjent").strip() env_suggestion = "GOOGLE_CREDIT_" + label.upper().replace(" ", "_").replace("-", "_")[:30] + "_NOK" discovered.append({ - "credit_type": row.credit_type, - "full_name": row.full_name, + "credit_type": row.credit_type, + "full_name": row.full_name, "total_used_nok": abs(float(row.total_used or 0)), - "active_days": row.active_days, + "active_days": row.active_days, "env_suggestion": env_suggestion, - "configured": any( + "configured": any( os.environ.get(c["env_key"]) for c in CREDIT_CONFIG if row.credit_type in c["bq_types"] ), }) return { - "discovered": discovered, - "period_days": days, + "discovered": discovered, + "period_days": days, "note": "Sett env-vars i Cloud Run for nøyaktig runway-beregning per kreditttype.", } - # ── credits-status (oppdatert) ──────────────────────────────────────────────── + # ── get_credits_status ──────────────────────────────────────────────────── def get_credits_status(self, days: int = 90): """ - Henter faktiske kreditter fra BigQuery og beregner separat runway - per kreditttype (INFRA, VERTEX, DIALOGFLOW) basert på env-vars i NOK. + Returnerer credit_pools[] med ett objekt per kreditttype: + label, env_key, used_nok, total_nok, remaining_nok, + runway_days, expires_days, effective_days, + badge_class, warning (bool), configured - Returnerer: - - credits_by_type: [{type, full_name, amount}] - - credit_pools: [{label, total_nok, used_nok, remaining_nok, - runway_days, exhaustion_date, expires_days, warning}] - - credits_used_total_nok: total brukt (alle typer) - - gross_cost_total_nok: bruttokostnad - - net_cost_total_nok: nettokostnad etter kreditter - - daily_gross_burn_nok: daglig bruttokostnad (7d snitt) - - daily_credit_burn_nok: daglig kredittforbruk (7d snitt) - - data_as_of: siste dato med data i BQ - - warning: kritisk advarsel (Free Trial 27d) + effective_days = min(runway_days, expires_days) — laveste av de to. + warning = True hvis effective_days is not None and effective_days <= 30. """ q_credits = f""" SELECT - cr.type AS credit_type, - cr.full_name AS full_name, - ROUND(SUM(cr.amount), 4) AS total_amount + cr.type AS credit_type, + cr.full_name AS full_name, + ROUND(SUM(cr.amount), 4) AS total_amount FROM `{self.billing_table}`, UNNEST(credits) AS cr WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {int(days)} DAY) @@ -191,16 +213,16 @@ class BillingAgent: q_burn = f""" SELECT - ROUND(SUM(cost) / 7, 6) AS daily_gross, + ROUND(SUM(cost) / 7, 6) AS daily_gross, ROUND(SUM(IFNULL( - (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) / 7, 6) AS daily_credit + (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) / 7, 6) AS daily_credit FROM `{self.billing_table}` WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) """ q_totals = f""" SELECT - ROUND(SUM(cost), 4) AS gross_total, + ROUND(SUM(cost), 4) AS gross_total, ROUND(SUM(cost) + SUM(IFNULL( (SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)), 4) AS net_total FROM `{self.billing_table}` @@ -232,49 +254,63 @@ class BillingAgent: daily_credit = abs(float(burn_row.daily_credit or 0)) data_as_of = str(latest_row.latest_date) if latest_row.latest_date else None - # ── Per-pool runway ─────────────────────────────────────────────────────── + # ── Per-pool beregning ──────────────────────────────────────────────── credit_pools = [] - top_warning = None + top_warning_msg = None for cfg in CREDIT_CONFIG: - total_nok = float(os.environ.get(cfg["env_key"], "0") or "0") - # Brukt beregnes som andel av total BQ-kreditter (alle PROMOTION er NOK) - used_nok = credits_used_total # samme BQ-total deles; raffineres hvis SKU-filter legges til - remaining_nok = round(total_nok - used_nok, 2) if total_nok > 0 else None + total_nok = float(os.environ.get(cfg["env_key"], "0") or "0") + configured = total_nok > 0 + used_nok = round(credits_used_total, 2) + remaining_nok = round(total_nok - used_nok, 2) if configured else None - runway_days = None - exhaustion_date = None - pool_warning = None - - if total_nok > 0 and daily_credit > 0 and remaining_nok is not None: + # runway_days: dager til pengene er brukt opp + runway_days: int | None = None + exhaustion_date: str | None = None + if configured and daily_credit > 0 and remaining_nok is not None: runway_days = int(remaining_nok / daily_credit) if remaining_nok > 0 else 0 exhaustion_date = str( datetime.date.today() + datetime.timedelta(days=runway_days) - ) if runway_days > 0 else str(datetime.date.today()) + ) - # Utløpsadvarsel fra Console (kjent) - expires_days = cfg.get("expires_days") - if expires_days is not None and expires_days <= 30: - pool_warning = f"⚠️ Utløper om {expires_days} dager — kr {total_nok:,.2f} går tapt!" - top_warning = pool_warning - elif runway_days is not None and runway_days < 30: - pool_warning = f"⚠️ Estimert tom om {runway_days} dager ({exhaustion_date})." - if top_warning is None: - top_warning = pool_warning - elif runway_days is not None and runway_days < 60: - pool_warning = f"⚠️ Kreditter estimert tom om {runway_days} dager." + # expires_days: dager til kalenderutløp (fra env-var ISO-dato) + expires_days = _parse_expires_days(cfg["expires_env"]) + + # effective_days: laveste av de to (null hvis ingen) + eff = _effective_days(runway_days, expires_days) + badge = _badge_class(eff) + warning = eff is not None and eff <= 30 + + # Advarselstekst (for top-level warning-feltet) + pool_warning_msg = None + if warning: + if expires_days is not None and (runway_days is None or expires_days <= runway_days): + pool_warning_msg = ( + f"⚠️ {cfg['label']} utløper om {expires_days} dager" + + (f" — kr {total_nok:,.2f} går tapt!" if configured else "!") + ) + else: + pool_warning_msg = ( + f"⚠️ {cfg['label']} estimert tom om {runway_days} dager" + + (f" ({exhaustion_date})." if exhaustion_date else ".") + ) + if top_warning_msg is None: + top_warning_msg = pool_warning_msg credit_pools.append({ "env_key": cfg["env_key"], "label": cfg["label"], - "total_nok": total_nok if total_nok > 0 else None, - "used_nok": round(used_nok, 2), + "configured": configured, + "total_nok": total_nok if configured else None, + "used_nok": used_nok, "remaining_nok": remaining_nok, "runway_days": runway_days, - "exhaustion_date": exhaustion_date, "expires_days": expires_days, - "configured": total_nok > 0, - "warning": pool_warning, + "effective_days": eff, + "exhaustion_date": exhaustion_date, + "badge_class": badge, + "warning": warning, + "warning_msg": pool_warning_msg, }) return { @@ -287,14 +323,14 @@ class BillingAgent: "credits_by_type": credits_by_type, "data_as_of": data_as_of, "period_days": days, - "warning": top_warning, - "setup_note": None if any(p["configured"] for p in credit_pools) else ( + "warning": top_warning_msg, + "setup_note": None if any(p["configured"] for p in credit_pools) else ( "Sett GOOGLE_CREDIT_INFRA_NOK, GOOGLE_CREDIT_VERTEX_NOK og " "GOOGLE_CREDIT_DIALOGFLOW_NOK i Cloud Run env for nøyaktig runway per kreditttype." ), } - # ── anomalier ────────────────────────────────────────────────────────────────── + # ── anomalier ──────────────────────────────────────────────────────────── def get_anomalies(self): query = f""" WITH daily_costs AS ( @@ -336,7 +372,7 @@ class BillingAgent: ] } - # ── daglig historikk for bar-chart (CG6) ─────────────────────────────────── + # ── daglig historikk (bar-chart) ────────────────────────────────────────── def get_daily_history(self, days: int = 30): query = f""" SELECT @@ -359,7 +395,7 @@ class BillingAgent: history.append({"date": str(row.usage_date), "mtd": round(mtd, 6)}) return history - # ── tjenester gruppert per service (CG5+CG6) ───────────────────────────────── + # ── tjenester gruppert per service ──────────────────────────────────────── def get_service_totals(self, days: int = 30): query = f""" SELECT @@ -392,10 +428,10 @@ class BillingAgent: if __name__ == '__main__': agent = BillingAgent() - print("Summary:", agent.get_summary()) - print("Forecast:", agent.get_forecast()) - print("Credits status:", agent.get_credits_status()) - print("Credits discover:",agent.discover_credits()) - print("Anomalies:", agent.get_anomalies()) - print("By-service:", agent.get_service_totals()) - print("History:", agent.get_daily_history()) + print("Summary:", agent.get_summary()) + print("Forecast:", agent.get_forecast()) + print("Credits status:", agent.get_credits_status()) + print("Credits discover:", agent.discover_credits()) + print("Anomalies:", agent.get_anomalies()) + print("By-service:", agent.get_service_totals()) + print("History:", agent.get_daily_history())