OSVauco/ml/billing_agent.py
Chris Christiansen 9fcb9c354a
Some checks are pending
Check Python Version Consistency / Check Python Version (push) Waiting to run
feat(core): Fresh initialization - Deploy v3.6.1 Singularity Architecture
2026-09-03 04:03:09 +00:00

438 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import datetime
from google.cloud import bigquery
# ── 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",
"expires_env": "GOOGLE_CREDIT_INFRA_EXPIRES",
"label": "Free Trial (Infrastruktur)",
"bq_types": ["PROMOTION"],
"skus": None,
},
{
"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",
"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")
if not self.project_id:
raise ValueError("GOOGLE_CLOUD_PROJECT environment variable not set.")
self.billing_table = os.environ.get("BILLING_TABLE")
if not self.billing_table:
raise ValueError("BILLING_TABLE environment variable not set.")
self.bq_client = bigquery.Client(project=self.project_id)
# ── summary ──────────────────────────────────────────────────────────────
def get_summary(self):
query = f"""
SELECT
DATE(usage_start_time) AS usage_date,
project.id AS project_id,
service.description AS service,
SUM(cost) AS daily_cost
FROM `{self.billing_table}`
WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY usage_date, project_id, service
ORDER BY usage_date DESC, daily_cost DESC
LIMIT 100
"""
results = self.bq_client.query(query).result()
summary = [
{
"usage_date": str(row.usage_date),
"project_id": row.project_id,
"service": row.service,
"daily_cost": row.daily_cost,
}
for row in results
]
if not summary:
return {
"onboarding_status": {
"state": "awaiting_data",
"message": "Fakturaeksport er aktiv, men ingen data for siste 30 dager ennå.",
}
}
return {"summary": summary}
# ── forecast ─────────────────────────────────────────────────────────────
def get_forecast(self):
q7 = f"""
SELECT SUM(cost) + SUM(IFNULL(
(SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost
FROM `{self.billing_table}`
WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
"""
total_7d = list(self.bq_client.query(q7).result())[0].total_cost or 0
daily_average = total_7d / 7
today = datetime.date.today()
next_month = datetime.date(
today.year + (1 if today.month == 12 else 0),
(today.month % 12) + 1, 1
)
remaining_days = (next_month - today).days
q_mtd = f"""
SELECT SUM(cost) + SUM(IFNULL(
(SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS total_cost
FROM `{self.billing_table}`
WHERE EXTRACT(MONTH FROM _PARTITIONTIME) = EXTRACT(MONTH FROM CURRENT_DATE())
AND EXTRACT(YEAR FROM _PARTITIONTIME) = EXTRACT(YEAR FROM CURRENT_DATE())
"""
mtd_cost = list(self.bq_client.query(q_mtd).result())[0].total_cost or 0
return {
"daily_average_last_7_days": daily_average,
"month_to_date_cost": mtd_cost,
"forecasted_remaining_cost": daily_average * remaining_days,
"total_monthly_forecast": mtd_cost + daily_average * remaining_days,
"remaining_days_in_month": remaining_days,
"data_note": "Prognose basert på siste 7 dager. BigQuery kan ha 24-48 timers forsinkelse.",
}
# ── discover_credits ─────────────────────────────────────────────────────
def discover_credits(self, days: int = 90):
q = f"""
SELECT
cr.type AS credit_type,
cr.full_name AS full_name,
ROUND(SUM(cr.amount), 2) AS total_used,
COUNT(DISTINCT DATE(_PARTITIONTIME)) AS active_days
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_used ASC
"""
rows = list(self.bq_client.query(q).result())
discovered = []
for row in rows:
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,
"total_used_nok": abs(float(row.total_used or 0)),
"active_days": row.active_days,
"env_suggestion": env_suggestion,
"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,
"note": "Sett env-vars i Cloud Run for nøyaktig runway-beregning per kreditttype.",
}
# ── get_credits_status ────────────────────────────────────────────────────
def get_credits_status(self, days: int = 90):
"""
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
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
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
"""
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)
"""
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)
"""
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
# ── Per-pool beregning ────────────────────────────────────────────────
credit_pools = []
top_warning_msg = None
for cfg in CREDIT_CONFIG:
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: 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)
)
# 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"],
"configured": configured,
"total_nok": total_nok if configured else None,
"used_nok": used_nok,
"remaining_nok": remaining_nok,
"runway_days": runway_days,
"expires_days": expires_days,
"effective_days": eff,
"exhaustion_date": exhaustion_date,
"badge_class": badge,
"warning": warning,
"warning_msg": pool_warning_msg,
})
return {
"credits_used_total_nok": round(credits_used_total, 4),
"gross_cost_total_nok": gross_total,
"net_cost_total_nok": net_total,
"daily_gross_burn_nok": round(daily_gross, 6),
"daily_credit_burn_nok": round(daily_credit, 6),
"credit_pools": credit_pools,
"credits_by_type": credits_by_type,
"data_as_of": data_as_of,
"period_days": days,
"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 ────────────────────────────────────────────────────────────
def get_anomalies(self):
query = f"""
WITH daily_costs AS (
SELECT
service.description AS service,
DATE(_PARTITIONTIME) AS usage_date,
SUM(cost) + SUM(IFNULL(
(SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS daily_cost
FROM `{self.billing_table}`
WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 14 DAY)
GROUP BY 1, 2
),
costs_with_avg AS (
SELECT
service, usage_date, daily_cost,
AVG(daily_cost) OVER (
PARTITION BY service ORDER BY usage_date
ROWS BETWEEN 7 PRECEDING AND 1 PRECEDING
) AS avg_7day
FROM daily_costs
)
SELECT service, daily_cost AS today_cost, avg_7day,
(daily_cost / avg_7day) AS ratio
FROM costs_with_avg
WHERE usage_date = CURRENT_DATE()
AND avg_7day > 0
AND daily_cost > (2.0 * avg_7day)
"""
results = self.bq_client.query(query).result()
return {
"anomalies": [
{
"service": row.service,
"today_cost": row.today_cost,
"avg_7d": row.avg_7day,
"ratio": row.ratio,
}
for row in results
]
}
# ── daglig historikk (bar-chart) ──────────────────────────────────────────
def get_daily_history(self, days: int = 30):
query = f"""
SELECT
DATE(usage_start_time) AS usage_date,
SUM(cost) + SUM(IFNULL(
(SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS day_cost
FROM `{self.billing_table}`
WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(),
INTERVAL {int(days)} DAY)
GROUP BY usage_date
ORDER BY usage_date ASC
"""
results = list(self.bq_client.query(query).result())
history = []
mtd = 0.0
for row in results:
if history and row.usage_date.day == 1:
mtd = 0.0
mtd += float(row.day_cost or 0)
history.append({"date": str(row.usage_date), "mtd": round(mtd, 6)})
return history
# ── tjenester gruppert per service ────────────────────────────────────────
def get_service_totals(self, days: int = 30):
query = f"""
SELECT
service.description AS service,
sku.description AS sku,
SUM(cost) + SUM(IFNULL(
(SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)) AS sku_cost
FROM `{self.billing_table}`
WHERE _PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(),
INTERVAL {int(days)} DAY)
GROUP BY service, sku
HAVING sku_cost > 0
ORDER BY service, sku_cost DESC
"""
results = self.bq_client.query(query).result()
services: dict = {}
for row in results:
svc = row.service
if svc not in services:
services[svc] = {"service": svc, "total_cost": 0.0, "skus": []}
services[svc]["total_cost"] = round(
services[svc]["total_cost"] + float(row.sku_cost), 6
)
services[svc]["skus"].append({
"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("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())