From 68040804823617e8342ecdbdbaf2f2ec983324bf Mon Sep 17 00:00:00 2001 From: chrischristiansen-glitch Date: Sat, 13 Jun 2026 06:19:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(CG4-credits):=20GET=20/billing/credits=20?= =?UTF-8?q?=E2=80=94=20kreditt-saldo,=20burn=20rate=20og=20tom-dato?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ml/billing_agent.py | 155 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 22 deletions(-) diff --git a/ml/billing_agent.py b/ml/billing_agent.py index e024e31..11f990c 100644 --- a/ml/billing_agent.py +++ b/ml/billing_agent.py @@ -15,7 +15,7 @@ class BillingAgent: self.bq_client = bigquery.Client(project=self.project_id) - # ── summary ────────────────────────────────────────────────────────────── + # ── summary ──────────────────────────────────────────────────────────────────── def get_summary(self): query = f""" SELECT @@ -48,7 +48,7 @@ class BillingAgent: } return {"summary": summary} - # ── forecast ────────────────────────────────────────────────────────────── + # ── forecast ────────────────────────────────────────────────────────────────── def get_forecast(self): q7 = f""" SELECT SUM(cost) + SUM(IFNULL( @@ -84,7 +84,129 @@ class BillingAgent: "data_note": "Prognose basert på siste 7 dager. BigQuery kan ha 24-48 timers forsinkelse.", } - # ── anomalier ───────────────────────────────────────────────────────────── + # ── credits-status (ny) ─────────────────────────────────────────────────────── + def get_credits_status(self, days: int = 90): + """ + CG4-credits: Henter faktiske kreditter fra BigQuery billing export. + + Returnerer: + - credits_used_total: total kreditter brukt hittil (alle typer) + - credits_by_type: [{type, full_name, amount}] sortert størst først + - gross_cost_total: bruttokostnad uten kreditter + - net_cost_total: nettokostnad etter kreditter + - daily_gross_burn: gjennomsnittlig daglig bruttokostnad (7d) + - daily_credit_burn: gjennomsnittlig daglig kredittforbruk (7d) + - credit_runway_days: estimert antall dager til credits er tom + - credit_exhaustion_date: estimert dato når credits går tom + - data_as_of: siste dato med data i BQ (24-48t forsinkelse) + - warning: settes hvis runway < 30 dager + """ + # 1. Total kreditter brukt og type-breakdown + q_credits = f""" + SELECT + 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) + GROUP BY credit_type, full_name + ORDER BY total_amount ASC + """ + + # 2. Daglig burn siste 7 dager (brutto og kreditter separat) + q_burn = f""" + SELECT + 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 + FROM `{self.billing_table}` + WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) + """ + + # 3. Total brutto + netto hittil + q_totals = f""" + SELECT + 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}` + WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL {int(days)} DAY) + """ + + # 4. Siste dato med data + q_latest = f""" + SELECT MAX(DATE(usage_start_time)) AS latest_date + FROM `{self.billing_table}` + """ + + credits_rows = list(self.bq_client.query(q_credits).result()) + burn_row = list(self.bq_client.query(q_burn).result())[0] + totals_row = list(self.bq_client.query(q_totals).result())[0] + latest_row = list(self.bq_client.query(q_latest).result())[0] + + credits_by_type = [ + { + "type": row.credit_type, + "full_name": row.full_name, + "amount": float(row.total_amount), + } + for row in credits_rows + ] + credits_used_total = abs(sum(r["amount"] for r in credits_by_type)) + gross_total = float(totals_row.gross_total or 0) + net_total = float(totals_row.net_total or 0) + daily_gross = float(burn_row.daily_gross or 0) + 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 + + # Runway: Google Vertex AI free tier er typisk $300 USD per prosjekt + # Vi beregner gjenstående basert på faktisk brukt vs antatt total-kreditt + # Brukeren må sette GOOGLE_CREDIT_TOTAL_USD i env for nøyaktig beregning + credit_total_usd = float(os.environ.get("GOOGLE_CREDIT_TOTAL_USD", "0")) + runway_days = None + exhaustion_date = None + credits_remaining = None + + if credit_total_usd > 0 and daily_credit > 0: + credits_remaining = round(credit_total_usd - credits_used_total, 2) + runway_days = int(credits_remaining / daily_credit) if credits_remaining > 0 else 0 + exhaustion_date = str( + datetime.date.today() + datetime.timedelta(days=runway_days) + ) if runway_days > 0 else str(datetime.date.today()) + elif daily_credit > 0: + # Ingen total satt: gi burn rate men ikke runway + credits_remaining = None + runway_days = None + exhaustion_date = None + + warning = None + if runway_days is not None and runway_days < 30: + warning = f"⚠️ Kreditter estimert tom om {runway_days} dager ({exhaustion_date}). Aktiver fakturering!" + elif runway_days is not None and runway_days < 60: + warning = f"⚠️ Kreditter estimert tom om {runway_days} dager ({exhaustion_date})." + + return { + "credits_used_total_usd": round(credits_used_total, 4), + "credits_remaining_usd": credits_remaining, + "credit_total_usd": credit_total_usd if credit_total_usd > 0 else None, + "gross_cost_total_usd": gross_total, + "net_cost_total_usd": net_total, + "daily_gross_burn_usd": round(daily_gross, 6), + "daily_credit_burn_usd": round(daily_credit, 6), + "credit_runway_days": runway_days, + "credit_exhaustion_date": exhaustion_date, + "credits_by_type": credits_by_type, + "data_as_of": data_as_of, + "period_days": days, + "warning": warning, + "setup_note": None if credit_total_usd > 0 else ( + "Sett GOOGLE_CREDIT_TOTAL_USD i Cloud Run env for nøyaktig runway-beregning. " + "Eksempel: 300 for $300 Google gratis-kreditter." + ), + } + + # ── anomalier ────────────────────────────────────────────────────────────────── def get_anomalies(self): query = f""" WITH daily_costs AS ( @@ -126,12 +248,8 @@ class BillingAgent: ] } - # ── daglig historikk for bar-chart (CG6) ────────────────────────────────── + # ── daglig historikk for bar-chart (CG6) ─────────────────────────────────── def get_daily_history(self, days: int = 30): - """ - Returnerer [{date: str, mtd: float}] for siste `days` dager, sortert ASC. - MTD er kumulativ sum fra 1. i måneden til den dato. - """ query = f""" SELECT DATE(usage_start_time) AS usage_date, @@ -153,13 +271,8 @@ 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 (CG5+CG6) ───────────────────────────────── def get_service_totals(self, days: int = 30): - """ - Grupperer kostnader per tjeneste (ikke per SKU). - Compute Engine vises som én rad med total, med SKU-detaljer som sub-liste. - Unngår duplikate tjeneste-rader i dashbordet. - """ query = f""" SELECT service.description AS service, @@ -174,8 +287,6 @@ class BillingAgent: ORDER BY service, sku_cost DESC """ results = self.bq_client.query(query).result() - - # Grupper SKU-er under tjeneste — én rad per tjeneste i dashbordet services: dict = {} for row in results: svc = row.service @@ -188,14 +299,14 @@ class BillingAgent: "sku": row.sku, "sku_cost": round(float(row.sku_cost), 6), }) - return sorted(services.values(), key=lambda x: x["total_cost"], reverse=True) if __name__ == '__main__': agent = BillingAgent() - print("Summary:", agent.get_summary()) - print("Forecast:", agent.get_forecast()) - 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("Anomalies:", agent.get_anomalies()) + print("By-service:", agent.get_service_totals()) + print("History:", agent.get_daily_history())