Samlings- og brukernoder kan nå ha ai_budget i metadata:
{ "ai_budget": { "monthly_limit_usd": 50.0 } }
Før hvert AI-kall aggregeres inneværende måneds forbruk fra
ai_usage_log og sammenlignes med grensen. Ved overskridelse:
- AI-kallet blokkeres med feilmelding
- En work_item-node opprettes med tag "budget_exceeded"
- Work_item knyttes til samlingen via belongs_to-edge
Endringer:
- migrations/029: requested_by-kolonne i ai_usage_log + indekser
- synops-ai: --collection-id/--user-id flagg, budsjettsjekk i prompt
- maskinrommet/ai_budget.rs: delt budsjettsjekk-modul
- maskinrommet/ai_process.rs: budsjettsjekk før AI gateway-kall
- docs/infra/ai_gateway.md: oppdatert § 6.3 fra "fase 2" til implementert
232 lines
7.2 KiB
Rust
232 lines
7.2 KiB
Rust
// AI-budsjettsjekk — sjekker ai_budget-metadata mot ai_usage_log aggregat.
|
|
//
|
|
// Samlings- og brukernoder kan ha budsjettgrense i metadata:
|
|
// { "ai_budget": { "monthly_limit_usd": 50.0 } }
|
|
//
|
|
// Sjekken aggregerer denne månedens forbruk fra ai_usage_log og
|
|
// sammenligner med grensen. Ved overskridelse returneres feil
|
|
// og en work_item-node opprettes.
|
|
//
|
|
// Ref: docs/infra/ai_gateway.md § 6.3, oppgave 28.3
|
|
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
/// Estimerer kostnad fra tokens (samme formel som metrics.rs).
|
|
/// Input: $3/MTok, Output: $15/MTok (konservativt gjennomsnitt).
|
|
pub fn estimate_cost_usd(prompt_tokens: i64, completion_tokens: i64) -> f64 {
|
|
let input_cost = prompt_tokens as f64 * 3.0 / 1_000_000.0;
|
|
let output_cost = completion_tokens as f64 * 15.0 / 1_000_000.0;
|
|
((input_cost + output_cost) * 100.0).round() / 100.0
|
|
}
|
|
|
|
/// Resultat av en budsjettsjekk.
|
|
pub struct BudgetStatus {
|
|
pub current_cost: f64,
|
|
pub limit: f64,
|
|
pub node_title: String,
|
|
}
|
|
|
|
/// Sjekker ai_budget for en samlings-node.
|
|
///
|
|
/// Returnerer Ok(()) hvis budsjettet ikke er overskredet eller ikke er satt.
|
|
/// Returnerer Err med BudgetStatus hvis overskredet.
|
|
pub async fn check_collection_budget(
|
|
db: &PgPool,
|
|
collection_id: Uuid,
|
|
) -> Result<(), BudgetStatus> {
|
|
check_budget_for_node(db, collection_id, "collection_node_id").await
|
|
}
|
|
|
|
/// Sjekker ai_budget for en bruker-node.
|
|
pub async fn check_user_budget(
|
|
db: &PgPool,
|
|
user_id: Uuid,
|
|
) -> Result<(), BudgetStatus> {
|
|
check_budget_for_node(db, user_id, "requested_by").await
|
|
}
|
|
|
|
async fn check_budget_for_node(
|
|
db: &PgPool,
|
|
node_id: Uuid,
|
|
column: &str,
|
|
) -> Result<(), BudgetStatus> {
|
|
// Hent metadata fra noden
|
|
let metadata: Option<serde_json::Value> =
|
|
match sqlx::query_scalar("SELECT metadata FROM nodes WHERE id = $1")
|
|
.bind(node_id)
|
|
.fetch_optional(db)
|
|
.await
|
|
{
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "Kunne ikke hente metadata for budsjettsjekk");
|
|
return Ok(()); // Fail open — ikke blokker AI-kall ved DB-feil
|
|
}
|
|
};
|
|
|
|
let monthly_limit = metadata
|
|
.as_ref()
|
|
.and_then(|m| m.get("ai_budget"))
|
|
.and_then(|b| b.get("monthly_limit_usd"))
|
|
.and_then(|v| v.as_f64());
|
|
|
|
let limit = match monthly_limit {
|
|
Some(l) => l,
|
|
None => return Ok(()), // Ingen budsjettgrense satt
|
|
};
|
|
|
|
// Aggreger denne månedens forbruk
|
|
// Bruker dynamisk kolonne via to separate queries for sikkerhet (unngår SQL injection)
|
|
let (total_prompt, total_completion): (i64, i64) = match column {
|
|
"collection_node_id" => {
|
|
match sqlx::query_as::<_, (i64, i64)>(
|
|
r#"SELECT
|
|
COALESCE(SUM(prompt_tokens)::BIGINT, 0),
|
|
COALESCE(SUM(completion_tokens)::BIGINT, 0)
|
|
FROM ai_usage_log
|
|
WHERE collection_node_id = $1
|
|
AND created_at >= date_trunc('month', now())"#,
|
|
)
|
|
.bind(node_id)
|
|
.fetch_one(db)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "Kunne ikke aggregere AI-forbruk");
|
|
return Ok(()); // Fail open
|
|
}
|
|
}
|
|
}
|
|
"requested_by" => {
|
|
match sqlx::query_as::<_, (i64, i64)>(
|
|
r#"SELECT
|
|
COALESCE(SUM(prompt_tokens)::BIGINT, 0),
|
|
COALESCE(SUM(completion_tokens)::BIGINT, 0)
|
|
FROM ai_usage_log
|
|
WHERE requested_by = $1
|
|
AND created_at >= date_trunc('month', now())"#,
|
|
)
|
|
.bind(node_id)
|
|
.fetch_one(db)
|
|
.await
|
|
{
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "Kunne ikke aggregere bruker-AI-forbruk");
|
|
return Ok(()); // Fail open
|
|
}
|
|
}
|
|
}
|
|
_ => return Ok(()),
|
|
};
|
|
|
|
let current_cost = estimate_cost_usd(total_prompt, total_completion);
|
|
|
|
if current_cost >= limit {
|
|
let node_title: String = sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1")
|
|
.bind(node_id)
|
|
.fetch_optional(db)
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| node_id.to_string());
|
|
|
|
return Err(BudgetStatus {
|
|
current_cost,
|
|
limit,
|
|
node_title,
|
|
});
|
|
}
|
|
|
|
tracing::debug!(
|
|
node_id = %node_id,
|
|
current_cost,
|
|
limit,
|
|
"Budsjettsjekk OK"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Oppretter work_item-node når budsjett er overskredet.
|
|
pub async fn create_budget_work_item(
|
|
db: &PgPool,
|
|
collection_id: Uuid,
|
|
created_by: Option<Uuid>,
|
|
status: &BudgetStatus,
|
|
) {
|
|
let work_item_id = Uuid::now_v7();
|
|
let title = format!("AI-budsjett overskredet: {}", status.node_title);
|
|
let content = format!(
|
|
"AI-budsjettet for \"{}\" er overskredet.\n\
|
|
Forbruk denne måneden: ${:.2}\n\
|
|
Budsjettgrense: ${:.2}\n\n\
|
|
AI-kall er blokkert inntil budsjettet økes eller ny måned starter.",
|
|
status.node_title, status.current_cost, status.limit
|
|
);
|
|
let creator = created_by.unwrap_or(collection_id);
|
|
|
|
let metadata = serde_json::json!({
|
|
"work_item_type": "budget_exceeded",
|
|
"collection_id": collection_id.to_string(),
|
|
"current_cost_usd": status.current_cost,
|
|
"monthly_limit_usd": status.limit,
|
|
});
|
|
|
|
if let Err(e) = sqlx::query(
|
|
r#"INSERT INTO nodes (id, node_kind, title, content, visibility, metadata, created_by)
|
|
VALUES ($1, 'content', $2, $3, 'hidden'::visibility, $4, $5)"#,
|
|
)
|
|
.bind(work_item_id)
|
|
.bind(&title)
|
|
.bind(&content)
|
|
.bind(&metadata)
|
|
.bind(creator)
|
|
.execute(db)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %e, "Kunne ikke opprette work_item for budsjettoverskridelse");
|
|
return;
|
|
}
|
|
|
|
// tagged-edge: budget_exceeded
|
|
let tag_edge_id = Uuid::now_v7();
|
|
if let Err(e) = sqlx::query(
|
|
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
|
VALUES ($1, $2, $2, 'tagged', '{"tag": "budget_exceeded"}'::jsonb, true, $3)"#,
|
|
)
|
|
.bind(tag_edge_id)
|
|
.bind(work_item_id)
|
|
.bind(creator)
|
|
.execute(db)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %e, "Kunne ikke opprette tagged-edge for work_item");
|
|
}
|
|
|
|
// belongs_to-edge til samlingen
|
|
let belongs_edge_id = Uuid::now_v7();
|
|
if let Err(e) = sqlx::query(
|
|
r#"INSERT INTO edges (id, source_id, target_id, edge_type, metadata, system, created_by)
|
|
VALUES ($1, $2, $3, 'belongs_to', '{}'::jsonb, true, $4)"#,
|
|
)
|
|
.bind(belongs_edge_id)
|
|
.bind(work_item_id)
|
|
.bind(collection_id)
|
|
.bind(creator)
|
|
.execute(db)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %e, "Kunne ikke opprette belongs_to-edge for work_item");
|
|
}
|
|
|
|
tracing::info!(
|
|
work_item_id = %work_item_id,
|
|
collection_id = %collection_id,
|
|
current_cost = status.current_cost,
|
|
limit = status.limit,
|
|
"Work item opprettet for budsjettoverskridelse"
|
|
);
|
|
}
|