AI-kostnadstak per bruker/samling: budsjettsjekk før AI-kall (oppgave 28.3)
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
This commit is contained in:
parent
c0f05ab5e9
commit
2fa5d7ef2f
7 changed files with 502 additions and 15 deletions
|
|
@ -261,14 +261,19 @@ Aggregert oversikt over alle samlings-noder. Tabell med totaler per samlings-nod
|
|||
**Samlings-node (sidebar-widget):**
|
||||
Enkel tekst-indikator i sidebar: `12.4k tokens denne uken`. Klikk åpner detaljert visning med fordeling per jobbtype og modell. Ingen speedometer -- det krever et definert budsjett for å gi mening, og det er overkill for MVP.
|
||||
|
||||
### 6.3 Budsjett per samlings-node (fase 2)
|
||||
### 6.3 Budsjett per samlings-node og bruker (implementert)
|
||||
|
||||
Når token-logging er på plass, kan budsjett-tak legges til:
|
||||
Budsjettgrense for AI-bruk, implementert i oppgave 28.3:
|
||||
|
||||
- Budsjett lagres som JSONB-metadata på samlings-noden: `{ "ai_budget": { "monthly_limit_usd": 50 } }`
|
||||
- Rust-worker sjekker aggregert forbruk før AI-kall
|
||||
- Ved budsjett nær: fall tilbake til `sidelinja/rutine` (billigste)
|
||||
- Ved budsjett nådd: sett jobb i `paused` med varsel i samlings-nodens chat
|
||||
- Budsjett lagres som JSONB-metadata på samlings- eller brukernoden: `{ "ai_budget": { "monthly_limit_usd": 50 } }`
|
||||
- `synops-ai prompt` sjekker budsjett via `--collection-id`/`--user-id` flagg
|
||||
- `maskinrommet/ai_process` sjekker samlings- og brukerbudsjett automatisk før AI-kall
|
||||
- Estimert kostnad beregnes fra tokens: $3/MTok input, $15/MTok output (konservativt gjennomsnitt)
|
||||
- Aggregering: `SUM(prompt_tokens, completion_tokens)` fra `ai_usage_log` for inneværende måned
|
||||
- Ved budsjett overskredet: AI-kall blokkeres, `work_item`-node opprettes med tag `budget_exceeded`
|
||||
- `ai_usage_log.requested_by` sporer hvilken bruker som utløste kallet (migrasjon 029)
|
||||
- Indekser: `(collection_node_id, created_at)` og `(requested_by, created_at)` for effektiv aggregering
|
||||
- Fail-open: ved DB-feil i budsjettsjekk tillates kallet (unngår at feil blokkerer arbeid)
|
||||
|
||||
### 6.4 Per-episode maks-kostnad
|
||||
Podcastfabrikken-jobber (whisper + metadata + oppsummering) kan estimere totalkostnad basert på lydlengde. Jobben avbrytes med varsel hvis estimert kostnad overstiger `max_cost_per_episode` (default: $5).
|
||||
|
|
|
|||
232
maskinrommet/src/ai_budget.rs
Normal file
232
maskinrommet/src/ai_budget.rs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
// 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -172,6 +172,31 @@ pub async fn handle_ai_process(
|
|||
// 3. Map modellprofil → LiteLLM-alias
|
||||
let model_alias = model_profile_to_alias(model_profile);
|
||||
|
||||
// 3.5. Budsjettsjekk for samlingen (oppgave 28.3)
|
||||
let collection_id = resource_usage::find_collection_for_node(db, source_node_id).await;
|
||||
if let Some(coll_id) = collection_id {
|
||||
if let Err(status) = crate::ai_budget::check_collection_budget(db, coll_id).await {
|
||||
crate::ai_budget::create_budget_work_item(
|
||||
db, coll_id, Some(requested_by), &status,
|
||||
).await;
|
||||
return Err(format!(
|
||||
"AI-budsjett overskredet for \"{}\": forbruk ${:.2} >= grense ${:.2}",
|
||||
status.node_title, status.current_cost, status.limit
|
||||
));
|
||||
}
|
||||
}
|
||||
// Budsjettsjekk for brukeren
|
||||
if let Err(status) = crate::ai_budget::check_user_budget(db, requested_by).await {
|
||||
let coll = collection_id.unwrap_or(source_node_id);
|
||||
crate::ai_budget::create_budget_work_item(
|
||||
db, coll, Some(requested_by), &status,
|
||||
).await;
|
||||
return Err(format!(
|
||||
"AI-budsjett overskredet for bruker \"{}\": forbruk ${:.2} >= grense ${:.2}",
|
||||
status.node_title, status.current_cost, status.limit
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
source_node_id = %source_node_id,
|
||||
ai_preset_id = %ai_preset_id,
|
||||
|
|
@ -194,24 +219,25 @@ pub async fn handle_ai_process(
|
|||
);
|
||||
|
||||
// 5. Logg forbruk i ai_usage_log
|
||||
let collection_id = resource_usage::find_collection_for_node(db, source_node_id).await;
|
||||
// collection_id allerede hentet i budsjettsjekk (steg 3.5)
|
||||
let (tokens_in, tokens_out) = usage
|
||||
.as_ref()
|
||||
.map(|u| (u.prompt_tokens, u.completion_tokens))
|
||||
.unwrap_or((0, 0));
|
||||
let total_tokens = tokens_in + tokens_out;
|
||||
|
||||
// ai_usage_log — detaljert AI-forbrukslogg
|
||||
// ai_usage_log — detaljert AI-forbrukslogg (inkl. requested_by)
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO ai_usage_log
|
||||
(collection_node_id, job_id, model_alias, model_actual,
|
||||
(collection_node_id, job_id, requested_by, model_alias, model_actual,
|
||||
prompt_tokens, completion_tokens, total_tokens, job_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'ai_process')
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ai_process')
|
||||
"#,
|
||||
)
|
||||
.bind(collection_id)
|
||||
.bind(job.id)
|
||||
.bind(requested_by)
|
||||
.bind(model_alias)
|
||||
.bind(actual_model.as_deref())
|
||||
.bind(tokens_in as i32)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod agent;
|
||||
pub mod ai_admin;
|
||||
pub mod ai_budget;
|
||||
pub mod ai_edges;
|
||||
pub mod ai_process;
|
||||
pub mod audio;
|
||||
|
|
|
|||
24
migrations/029_ai_budget.sql
Normal file
24
migrations/029_ai_budget.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
-- 029: AI-budsjett per bruker/samling
|
||||
--
|
||||
-- Legger til støtte for kostnadstak (ai_budget) i metadata på bruker- og
|
||||
-- samlingsnoder. Utvider ai_usage_log med requested_by for bruker-attribusjon,
|
||||
-- og legger til indeks for effektiv månedlig aggregering.
|
||||
--
|
||||
-- Budsjett lagres som JSONB-metadata på noden:
|
||||
-- { "ai_budget": { "monthly_limit_usd": 50.0 } }
|
||||
--
|
||||
-- Ref: docs/infra/ai_gateway.md § 6.3
|
||||
|
||||
-- Bruker-attribusjon: hvem utløste AI-kallet
|
||||
ALTER TABLE ai_usage_log
|
||||
ADD COLUMN IF NOT EXISTS requested_by UUID REFERENCES nodes(id) ON DELETE SET NULL;
|
||||
|
||||
-- Indeks for effektiv månedlig aggregering per samling
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_log_collection_month
|
||||
ON ai_usage_log (collection_node_id, created_at)
|
||||
WHERE collection_node_id IS NOT NULL;
|
||||
|
||||
-- Indeks for effektiv månedlig aggregering per bruker
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_log_requested_by_month
|
||||
ON ai_usage_log (requested_by, created_at)
|
||||
WHERE requested_by IS NOT NULL;
|
||||
3
tasks.md
3
tasks.md
|
|
@ -374,8 +374,7 @@ modell som brukes til hva.
|
|||
|
||||
- [x] 28.1 `synops-ai` CLI: direkte LLM-kall via LiteLLM. Input: `--prompt <tekst> [--model <alias>] [--system <systemprompt>]`. Output: tekst til stdout. Ingen fillesing, ingen verktøy, bare prompt inn/ut. Bruker `ai_job_routing`-tabellen for å bestemme modell hvis `--model` ikke er satt. Logger i `ai_usage_log`.
|
||||
- [x] 28.2 AI-rutingskontroll i admin: utvid admin-UI (fase 15.4) med konfigurasjon av hvilken modell som brukes per kontekst. Tabellen `ai_job_routing` mapper `(job_type, context)` → `model_alias`. Kontekster: `orchestration_script`, `orchestration_dream`, `bot_chat`, `bot_triage`, `summarize`, `suggest_edges`, `classify`. Admin kan endre uten redeploy.
|
||||
- [~] 28.3 Kostnadstak per bruker/samling: `ai_budget`-felt i metadata for brukere og samlinger. `synops-ai` sjekker budsjett mot `ai_usage_log` aggregat før kall. Ved overskridelse: returner feilmelding, opprett work_item.
|
||||
> Påbegynt: 2026-03-18T20:10
|
||||
- [x] 28.3 Kostnadstak per bruker/samling: `ai_budget`-felt i metadata for brukere og samlinger. `synops-ai` sjekker budsjett mot `ai_usage_log` aggregat før kall. Ved overskridelse: returner feilmelding, opprett work_item.
|
||||
|
||||
### Øvrige manglende verktøy
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ struct PromptArgs {
|
|||
/// Temperatur (0.0–2.0, default: 0.7)
|
||||
#[arg(long, default_value = "0.7")]
|
||||
temperature: f32,
|
||||
|
||||
/// Samlings-ID for budsjettsjekk og logging
|
||||
#[arg(long)]
|
||||
collection_id: Option<Uuid>,
|
||||
|
||||
/// Bruker-ID som utløste kallet (for budsjettsjekk og logging)
|
||||
#[arg(long)]
|
||||
user_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
|
|
@ -156,6 +164,21 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> {
|
|||
None => resolve_model(&db, &args.job_type).await?,
|
||||
};
|
||||
|
||||
// Sjekk budsjett før LLM-kall
|
||||
if let Some(collection_id) = args.collection_id {
|
||||
if let Err(msg) = check_budget(&db, collection_id, "collection").await {
|
||||
create_budget_work_item(&db, collection_id, args.user_id, &msg).await;
|
||||
return Err(msg);
|
||||
}
|
||||
}
|
||||
if let Some(user_id) = args.user_id {
|
||||
if let Err(msg) = check_budget(&db, user_id, "user").await {
|
||||
let coll = args.collection_id.unwrap_or(user_id);
|
||||
create_budget_work_item(&db, coll, Some(user_id), &msg).await;
|
||||
return Err(msg);
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
model = %model_alias,
|
||||
job_type = %args.job_type,
|
||||
|
|
@ -182,15 +205,17 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> {
|
|||
// Skriv svar til stdout
|
||||
println!("{content}");
|
||||
|
||||
// Logg i ai_usage_log
|
||||
// Logg i ai_usage_log (inkludert collection_node_id og requested_by)
|
||||
let tokens_in = usage.as_ref().map(|u| u.prompt_tokens).unwrap_or(0);
|
||||
let tokens_out = usage.as_ref().map(|u| u.completion_tokens).unwrap_or(0);
|
||||
let actual = actual_model.as_deref().unwrap_or("unknown");
|
||||
|
||||
if let Err(e) = sqlx::query(
|
||||
"INSERT INTO ai_usage_log (model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"INSERT INTO ai_usage_log (collection_node_id, requested_by, model_alias, model_actual, prompt_tokens, completion_tokens, total_tokens, job_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
)
|
||||
.bind(args.collection_id)
|
||||
.bind(args.user_id)
|
||||
.bind(&model_alias)
|
||||
.bind(actual)
|
||||
.bind(tokens_in as i32)
|
||||
|
|
@ -214,6 +239,181 @@ async fn run_prompt(args: PromptArgs) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Budsjettsjekk (oppgave 28.3)
|
||||
// ============================================================
|
||||
|
||||
/// Estimerer kostnad fra tokens (samme formel som maskinrommet/metrics.rs).
|
||||
/// Input: $3/MTok, Output: $15/MTok (konservativt gjennomsnitt).
|
||||
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
|
||||
}
|
||||
|
||||
/// Sjekker ai_budget for en node (samling eller bruker).
|
||||
///
|
||||
/// Leser `metadata.ai_budget.monthly_limit_usd` fra noden,
|
||||
/// aggregerer denne månedens forbruk fra ai_usage_log,
|
||||
/// og returnerer Err med feilmelding hvis budsjettet er overskredet.
|
||||
async fn check_budget(
|
||||
db: &sqlx::PgPool,
|
||||
node_id: Uuid,
|
||||
node_type: &str, // "collection" eller "user"
|
||||
) -> Result<(), String> {
|
||||
// Hent metadata fra noden
|
||||
let metadata: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT metadata FROM nodes WHERE id = $1")
|
||||
.bind(node_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| format!("PG-feil ved budsjettsjekk: {e}"))?;
|
||||
|
||||
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
|
||||
let (total_prompt, total_completion) = match node_type {
|
||||
"collection" => {
|
||||
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
|
||||
.map_err(|e| format!("PG-feil ved aggregering av forbruk: {e}"))?
|
||||
}
|
||||
"user" => {
|
||||
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
|
||||
.map_err(|e| format!("PG-feil ved aggregering av brukerforbruk: {e}"))?
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
let current_cost = estimate_cost_usd(total_prompt, total_completion);
|
||||
|
||||
if current_cost >= limit {
|
||||
let node_title: Option<String> =
|
||||
sqlx::query_scalar("SELECT title FROM nodes WHERE id = $1")
|
||||
.bind(node_id)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
let name = node_title.unwrap_or_else(|| node_id.to_string());
|
||||
return Err(format!(
|
||||
"AI-budsjett overskredet for {node_type} \"{name}\": \
|
||||
forbruk ${current_cost:.2} >= grense ${limit:.2} denne måneden"
|
||||
));
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
node_id = %node_id,
|
||||
node_type,
|
||||
current_cost,
|
||||
limit,
|
||||
"Budsjettsjekk OK"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Oppretter work_item-node når budsjett er overskredet.
|
||||
async fn create_budget_work_item(
|
||||
db: &sqlx::PgPool,
|
||||
collection_id: Uuid,
|
||||
user_id: Option<Uuid>,
|
||||
error_msg: &str,
|
||||
) {
|
||||
let work_item_id = Uuid::now_v7();
|
||||
let title = format!("AI-budsjett overskredet");
|
||||
let created_by = user_id.unwrap_or(collection_id);
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"work_item_type": "budget_exceeded",
|
||||
"collection_id": collection_id.to_string(),
|
||||
"error": error_msg,
|
||||
});
|
||||
|
||||
// Opprett work_item-node
|
||||
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(error_msg)
|
||||
.bind(&metadata)
|
||||
.bind(created_by)
|
||||
.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(created_by)
|
||||
.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(created_by)
|
||||
.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,
|
||||
"Work item opprettet for budsjettoverskridelse"
|
||||
);
|
||||
}
|
||||
|
||||
/// Slå opp modellalias fra ai_job_routing. Fallback: sidelinja/rutine.
|
||||
async fn resolve_model(db: &sqlx::PgPool, job_type: &str) -> Result<String, String> {
|
||||
let row: Option<(String,)> =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue