Skip to main content

lean_ctx/gateway_server/
store.rs

1//! `usage_events` Postgres store (enterprise#17, baseline fields enterprise#18).
2//!
3//! One row per measured LLM turn: who (person/team/project, enterprise#11),
4//! what (provider/model/tokens), what it cost (priced with the shared
5//! `ModelPricing` table) and the counterfactual-baseline inputs that make the
6//! success fee provable (`uncompressed_input_tokens`, `reference_model`,
7//! `reference_cost_usd`, `is_local` — Doc 08 §2).
8//!
9//! Schema management follows the repo rule: `init_schema` is idempotent
10//! `batch_execute` DDL (`CREATE TABLE IF NOT EXISTS …`), no migration files.
11//!
12//! The writer consumes the `proxy::usage_sink` stream: bounded channel, spawned
13//! task, INSERT per event. Fail-open (enterprise#12): insert errors are logged
14//! and counted, never propagated to the request path.
15
16use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
17use tokio_postgres::NoTls;
18use tokio_postgres::config::SslMode;
19
20use crate::core::config::BaselineConfig;
21use crate::core::gain::model_pricing::ModelPricing;
22use crate::core::ocla::OclaRegistry;
23use crate::proxy::usage::RealUsage;
24
25/// Buffered events between the proxy choke-point and the Postgres writer.
26/// Sized for bursts (a full channel drops events, counted in `usage_sink`).
27pub const WRITER_QUEUE: usize = 4096;
28
29/// Env var overriding the store pool's `max_size` (chart: `database.poolMaxSize`).
30pub const POOL_MAX_SIZE_ENV: &str = "LEAN_CTX_PG_POOL_MAX_SIZE";
31
32/// Default pool size. The writer is a single sequential task (one connection),
33/// the rest serves the admin API/report queries — 8 is comfortable for one
34/// replica; K8s replicas each get their own pool (load-test: deploy-repo
35/// `docs/ops/load-test.md`).
36const POOL_MAX_SIZE_DEFAULT: usize = 8;
37
38/// Pool size from `LEAN_CTX_PG_POOL_MAX_SIZE`, clamped to a sane band.
39/// Invalid/unset values fall back to the default — a typo can never produce
40/// a 1-connection or 10k-connection pool.
41fn pool_max_size() -> usize {
42    std::env::var(POOL_MAX_SIZE_ENV)
43        .ok()
44        .and_then(|v| v.trim().parse::<usize>().ok())
45        .map_or(POOL_MAX_SIZE_DEFAULT, |n| n.clamp(2, 64))
46}
47
48/// Builds the store pool from a `DATABASE_URL`, honoring `sslmode` (#54/#58).
49///
50/// - `sslmode=disable`/`prefer`/unset: plain TCP (the pilot/in-cluster case;
51///   `prefer`'s opportunistic upgrade would mask misconfiguration, so it stays
52///   plain — deployments that need TLS must say `require`).
53/// - `sslmode=require`: rustls with the webpki root store — the managed-
54///   Postgres case (Azure/AWS/GCP enforce TLS). Unlike libpq's `require`,
55///   certificate and hostname are **always verified** (verify-full rigor);
56///   lean-ctx does not implement an unverified-TLS downgrade.
57pub fn pool_from_database_url(database_url: &str) -> anyhow::Result<Pool> {
58    let pg_cfg: tokio_postgres::Config = database_url.parse()?;
59    let mgr_cfg = ManagerConfig {
60        recycling_method: RecyclingMethod::Fast,
61    };
62    let mgr = if wants_tls(&pg_cfg) {
63        // The pool is built before the proxy installs the process-default
64        // CryptoProvider (#597) — make sure one exists (idempotent).
65        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
66        let roots = rustls::RootCertStore {
67            roots: webpki_roots::TLS_SERVER_ROOTS.to_vec(),
68        };
69        let tls_cfg = rustls::ClientConfig::builder()
70            .with_root_certificates(roots)
71            .with_no_client_auth();
72        Manager::from_config(
73            pg_cfg,
74            tokio_postgres_rustls::MakeRustlsConnect::new(tls_cfg),
75            mgr_cfg,
76        )
77    } else {
78        Manager::from_config(pg_cfg, NoTls, mgr_cfg)
79    };
80    Ok(Pool::builder(mgr).max_size(pool_max_size()).build()?)
81}
82
83/// True when the URL's `sslmode` asks for TLS. tokio-postgres 0.7 models
84/// `disable`/`prefer`/`require`; anything else fails URL parsing upstream.
85fn wants_tls(cfg: &tokio_postgres::Config) -> bool {
86    matches!(cfg.get_ssl_mode(), SslMode::Require)
87}
88
89/// Idempotent DDL (Doc 08 §2): `IF NOT EXISTS` only, run on every start.
90/// Columns added after the first release ride along as idempotent
91/// `ALTER TABLE … ADD COLUMN IF NOT EXISTS` — same rule, no migration files.
92const USAGE_EVENTS_DDL: &str = r"
93CREATE TABLE IF NOT EXISTS usage_events (
94  id                 BIGSERIAL PRIMARY KEY,
95  ts                 TIMESTAMPTZ      NOT NULL DEFAULT now(),
96  person             TEXT             NOT NULL,
97  team               TEXT,
98  project            TEXT             NOT NULL,
99  tool               TEXT,
100  provider           TEXT             NOT NULL,
101  model              TEXT             NOT NULL,
102  routed_from        TEXT,
103  input_tokens       BIGINT           NOT NULL,
104  output_tokens      BIGINT           NOT NULL,
105  cache_read_tokens  BIGINT           NOT NULL DEFAULT 0,
106  cache_write_tokens BIGINT           NOT NULL DEFAULT 0,
107  reasoning_tokens   BIGINT           NOT NULL DEFAULT 0,
108  cost_usd           DOUBLE PRECISION NOT NULL,
109  saved_tokens       BIGINT           NOT NULL DEFAULT 0,
110  saved_usd          DOUBLE PRECISION NOT NULL DEFAULT 0,
111  -- Avoided-cost baseline for the success fee (enterprise#18, Doc 04 §6):
112  uncompressed_input_tokens BIGINT    NOT NULL DEFAULT 0,
113  reference_model    TEXT,
114  reference_cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0,
115  is_local           BOOLEAN          NOT NULL DEFAULT false,
116  -- Cost provenance (#1179): provider | shadow | list | live | heuristic.
117  cost_source        TEXT             NOT NULL DEFAULT 'list'
118);
119ALTER TABLE usage_events ADD COLUMN IF NOT EXISTS cost_source TEXT NOT NULL DEFAULT 'list';
120CREATE INDEX IF NOT EXISTS idx_usage_events_person_ts  ON usage_events (person, ts);
121CREATE INDEX IF NOT EXISTS idx_usage_events_project_ts ON usage_events (project, ts);
122CREATE INDEX IF NOT EXISTS idx_usage_events_model_ts   ON usage_events (model, ts);
123";
124
125/// Applies the usage-store DDL. Safe to run on every start (idempotent).
126pub async fn init_schema(pool: &Pool) -> anyhow::Result<()> {
127    let client = pool.get().await?;
128    client.batch_execute(USAGE_EVENTS_DDL).await?;
129    Ok(())
130}
131
132/// One `usage_events` row, fully derived from a finalized [`RealUsage`].
133#[derive(Debug, Clone, PartialEq)]
134pub struct UsageEvent {
135    pub person: String,
136    pub team: Option<String>,
137    pub project: String,
138    pub provider: String,
139    pub model: String,
140    pub routed_from: Option<String>,
141    pub input_tokens: i64,
142    pub output_tokens: i64,
143    pub cache_read_tokens: i64,
144    pub cache_write_tokens: i64,
145    pub reasoning_tokens: i64,
146    pub cost_usd: f64,
147    pub saved_tokens: i64,
148    pub saved_usd: f64,
149    pub uncompressed_input_tokens: i64,
150    pub reference_model: Option<String>,
151    pub reference_cost_usd: f64,
152    pub is_local: bool,
153    /// Where `cost_usd` came from (#1179): `provider` (the response's own
154    /// charge), `shadow` (local shadow rate), `live` (current provider price
155    /// list), `list` (embedded list price) or `heuristic` (estimate).
156    pub cost_source: &'static str,
157}
158
159/// Maps a pricing match onto the stored `cost_source` value for table-priced
160/// rows. `provider`/`shadow` are decided before pricing is consulted.
161fn cost_source_of(kind: crate::core::gain::model_pricing::PricingMatchKind) -> &'static str {
162    use crate::core::gain::model_pricing::PricingMatchKind as K;
163    match kind {
164        K::Exact => "list",
165        K::Live => "live",
166        K::Alias | K::Heuristic | K::Fallback => "heuristic",
167    }
168}
169
170/// Identity fallbacks when a request carried no gateway key/tags: the row must
171/// still be attributable (`NOT NULL`), and "anonymous/default" is honest about
172/// what the gateway knew. Strict deployments make keys mandatory via
173/// `proxy_require_token` + gateway-keys, so these appear only in solo mode.
174const ANONYMOUS_PERSON: &str = "anonymous";
175const DEFAULT_PROJECT: &str = "default";
176
177impl UsageEvent {
178    /// Derives the row from a measured turn, pricing both the actual cost and
179    /// the compression saving with the shared pricing table, and stamping the
180    /// counterfactual baseline (enterprise#15/#18):
181    ///
182    /// - `cost_usd`, in precedence order (#1179): the provider's own reported
183    ///   charge (`usage.cost`, OpenRouter) — except `is_local`, which books
184    ///   the transparent `local_shadow_rate` (never $0; Doc 04 §6) — then the
185    ///   live/list price table. `cost_source` records which one applied.
186    /// - `reference_cost_usd`: the request's **uncompressed** input tokens
187    ///   priced at the contract-frozen `reference_model`'s input rate (Doc 08
188    ///   §2) — the counterfactual the avoided-cost ledger settles against.
189    /// - `saved_usd`: the SEE (compression) component only — saved request
190    ///   tokens at the served model's input rate. Full mechanism attribution
191    ///   (routing/caching) is the signed ledger's job (wave 4, enterprise#19).
192    #[must_use]
193    pub fn from_usage(
194        usage: &RealUsage,
195        pricing: &ModelPricing,
196        baseline: &BaselineConfig,
197    ) -> Self {
198        let wire = usage.wire.as_deref();
199        let quote = pricing.quote(Some(&usage.model));
200        let is_local = wire.is_some_and(|w| w.is_local);
201        #[allow(clippy::cast_precision_loss)]
202        let (cost_usd, cost_source) = if is_local {
203            let billable = usage.input_tokens
204                + usage.output_tokens
205                + usage.cache_read_tokens
206                + usage.cache_write_tokens;
207            (
208                baseline.effective_local_shadow_rate() / 1_000_000.0 * billable as f64,
209                "shadow",
210            )
211        } else if let Some(measured) = usage.provider_cost_usd {
212            (measured, "provider")
213        } else {
214            let estimated = quote.cost.estimate_usd(
215                usage.input_tokens,
216                usage.output_tokens,
217                usage.cache_write_tokens,
218                usage.cache_read_tokens,
219            );
220            (estimated, cost_source_of(quote.match_kind))
221        };
222        let saved_tokens = wire.map_or(0, |w| w.saved_tokens);
223        // Input-side saving: input-rate USD per token × saved request tokens.
224        #[allow(clippy::cast_precision_loss)]
225        let saved_usd = quote.cost.input_per_m / 1_000_000.0 * saved_tokens as f64;
226
227        let uncompressed_input_tokens = wire.map_or(0, |w| w.uncompressed_input_tokens);
228        let reference_model = baseline
229            .reference_model
230            .as_deref()
231            .map(str::trim)
232            .filter(|m| !m.is_empty())
233            .map(str::to_string);
234        #[allow(clippy::cast_precision_loss)]
235        let reference_cost_usd = reference_model.as_deref().map_or(0.0, |reference| {
236            pricing.quote(Some(reference)).cost.input_per_m / 1_000_000.0
237                * uncompressed_input_tokens as f64
238        });
239
240        Self {
241            person: wire
242                .and_then(|w| w.person.clone())
243                .unwrap_or_else(|| ANONYMOUS_PERSON.to_string()),
244            team: wire.and_then(|w| w.team.clone()),
245            project: wire
246                .and_then(|w| w.project.clone())
247                .unwrap_or_else(|| DEFAULT_PROJECT.to_string()),
248            provider: wire.map_or_else(String::new, |w| w.provider.clone()),
249            model: usage.model.clone(),
250            routed_from: wire.and_then(|w| w.routed_from.clone()),
251            input_tokens: to_i64(usage.input_tokens),
252            output_tokens: to_i64(usage.output_tokens),
253            cache_read_tokens: to_i64(usage.cache_read_tokens),
254            cache_write_tokens: to_i64(usage.cache_write_tokens),
255            reasoning_tokens: to_i64(usage.reasoning_tokens),
256            cost_usd,
257            saved_tokens: to_i64(saved_tokens),
258            saved_usd,
259            uncompressed_input_tokens: to_i64(uncompressed_input_tokens),
260            reference_model,
261            reference_cost_usd,
262            is_local,
263            cost_source,
264        }
265    }
266}
267
268fn to_i64(v: u64) -> i64 {
269    i64::try_from(v).unwrap_or(i64::MAX)
270}
271
272/// Inserts one event. Errors bubble to the writer loop, which logs and moves on.
273pub async fn insert_event(
274    client: &deadpool_postgres::Client,
275    e: &UsageEvent,
276) -> anyhow::Result<()> {
277    client
278        .execute(
279            "INSERT INTO usage_events \
280             (person, team, project, provider, model, routed_from, \
281              input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, \
282              reasoning_tokens, cost_usd, saved_tokens, saved_usd, \
283              uncompressed_input_tokens, reference_model, reference_cost_usd, is_local, \
284              cost_source) \
285             VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)",
286            &[
287                &e.person,
288                &e.team,
289                &e.project,
290                &e.provider,
291                &e.model,
292                &e.routed_from,
293                &e.input_tokens,
294                &e.output_tokens,
295                &e.cache_read_tokens,
296                &e.cache_write_tokens,
297                &e.reasoning_tokens,
298                &e.cost_usd,
299                &e.saved_tokens,
300                &e.saved_usd,
301                &e.uncompressed_input_tokens,
302                &e.reference_model,
303                &e.reference_cost_usd,
304                &e.is_local,
305                &e.cost_source,
306            ],
307        )
308        .await?;
309    Ok(())
310}
311
312/// Current-window spend sums for the budget gate (enterprise#25):
313/// per-person spend for the running UTC day and per-project spend for the
314/// running UTC month, straight from `usage_events`.
315pub async fn budget_window_sums(
316    pool: &Pool,
317) -> anyhow::Result<(
318    std::collections::HashMap<String, f64>,
319    std::collections::HashMap<String, f64>,
320)> {
321    let client = pool.get().await?;
322    let mut person_day = std::collections::HashMap::new();
323    for row in client
324        .query(
325            "SELECT person, SUM(cost_usd) FROM usage_events \
326             WHERE ts >= date_trunc('day', now() AT TIME ZONE 'utc') AT TIME ZONE 'utc' \
327             GROUP BY person",
328            &[],
329        )
330        .await?
331    {
332        person_day.insert(row.get::<_, String>(0), row.get::<_, f64>(1));
333    }
334    let mut project_month = std::collections::HashMap::new();
335    for row in client
336        .query(
337            "SELECT project, SUM(cost_usd) FROM usage_events \
338             WHERE ts >= date_trunc('month', now() AT TIME ZONE 'utc') AT TIME ZONE 'utc' \
339             GROUP BY project",
340            &[],
341        )
342        .await?
343    {
344        project_month.insert(row.get::<_, String>(0), row.get::<_, f64>(1));
345    }
346    Ok((person_day, project_month))
347}
348
349/// Deletes `usage_events` rows older than `days` (enterprise#36). Returns the
350/// number of purged rows. `days == 0` is rejected by the caller (retention
351/// disabled), never here — this function always deletes what it is told.
352pub async fn purge_events_older_than(pool: &Pool, days: u32) -> anyhow::Result<u64> {
353    let client = pool.get().await?;
354    let purged = client
355        .execute(
356            "DELETE FROM usage_events WHERE ts < now() - make_interval(days => $1)",
357            &[&i32::try_from(days).unwrap_or(i32::MAX)],
358        )
359        .await?;
360    Ok(purged)
361}
362
363/// All events attributed to one of `person_keys` (raw + pseudonym, GDPR
364/// Art. 15 export), as self-describing JSON rows.
365pub async fn person_events(
366    pool: &Pool,
367    person_keys: &[String],
368) -> anyhow::Result<Vec<serde_json::Value>> {
369    let client = pool.get().await?;
370    let rows = client
371        .query(
372            "SELECT to_jsonb(usage_events) FROM usage_events \
373             WHERE person = ANY($1) ORDER BY ts",
374            &[&person_keys],
375        )
376        .await?;
377    Ok(rows
378        .into_iter()
379        .map(|r| r.get::<_, serde_json::Value>(0))
380        .collect())
381}
382
383/// Deletes all events of `person_keys` (GDPR Art. 17). Returns rows removed.
384pub async fn delete_person_events(pool: &Pool, person_keys: &[String]) -> anyhow::Result<u64> {
385    let client = pool.get().await?;
386    let deleted = client
387        .execute(
388            "DELETE FROM usage_events WHERE person = ANY($1)",
389            &[&person_keys],
390        )
391        .await?;
392    Ok(deleted)
393}
394
395/// Daily evidence aggregates for the export window (enterprise#36): bounded
396/// output regardless of event volume, yet fine-grained enough for an EU-AI-Act
397/// usage-evidence audit (per day × person × project × model).
398pub async fn evidence_rows(
399    pool: &Pool,
400    from: chrono::DateTime<chrono::Utc>,
401    to: chrono::DateTime<chrono::Utc>,
402) -> anyhow::Result<Vec<serde_json::Value>> {
403    let client = pool.get().await?;
404    let rows = client
405        .query(
406            "SELECT jsonb_build_object(
407               'date', to_char(date_trunc('day', ts AT TIME ZONE 'utc'), 'YYYY-MM-DD'),
408               'person', person,
409               'project', project,
410               'model', model,
411               'provider', provider,
412               'requests', count(*),
413               'input_tokens', sum(input_tokens)::BIGINT,
414               'output_tokens', sum(output_tokens)::BIGINT,
415               'cache_read_tokens', sum(cache_read_tokens)::BIGINT,
416               'cost_usd', round(sum(cost_usd)::numeric, 6),
417               'saved_usd', round(sum(saved_usd)::numeric, 6),
418               'reference_cost_usd', round(sum(reference_cost_usd)::numeric, 6),
419               'local_requests', count(*) FILTER (WHERE is_local),
420               'measured_requests', count(*) FILTER (WHERE cost_source = 'provider'),
421               'estimated_requests', count(*) FILTER (WHERE cost_source = 'heuristic')
422             )
423             FROM usage_events WHERE ts >= $1 AND ts <= $2
424             GROUP BY
425               date_trunc('day', ts AT TIME ZONE 'utc'), person, project, model, provider
426             ORDER BY
427               date_trunc('day', ts AT TIME ZONE 'utc'), person, project, model, provider",
428            &[&from, &to],
429        )
430        .await?;
431    Ok(rows
432        .into_iter()
433        .map(|r| r.get::<_, serde_json::Value>(0))
434        .collect())
435}
436
437/// Wires the usage stream into Postgres: installs the process-wide sink
438/// (`proxy::usage_sink`) and spawns the writer task. Call once at gateway
439/// startup, after `init_schema`.
440///
441/// Returns `false` when a sink was already installed (double start).
442pub fn spawn_writer(pool: Pool) -> bool {
443    let (tx, mut rx) = tokio::sync::mpsc::channel::<RealUsage>(WRITER_QUEUE);
444    if !crate::proxy::usage_sink::install(tx) {
445        return false;
446    }
447    tokio::spawn(async move {
448        // One pricing table + baseline for the writer's lifetime: rows are
449        // priced at insert time (the ledger re-values against frozen
450        // references); the baseline is contract-frozen anyway (#41).
451        let pricing = ModelPricing::load();
452        let baseline = crate::core::config::Config::load().proxy.baseline.clone();
453        while let Some(usage) = rx.recv().await {
454            let event = UsageEvent::from_usage(&usage, &pricing, &baseline);
455            match pool.get().await {
456                Ok(client) => {
457                    match insert_event(&client, &event).await {
458                        Ok(()) => {
459                            // OCLA projection (parallel to Postgres write)
460                            if let Ok(Some(ocla_record)) = usage.to_ocla_usage_record()
461                                && let Err(e) =
462                                    OclaRegistry::global().usage_sink.record_usage(ocla_record)
463                            {
464                                tracing::debug!("OCLA usage projection: {e}");
465                            }
466                        }
467                        Err(e) => {
468                            tracing::warn!("usage_events insert failed (fail-open): {e:#}");
469                        }
470                    }
471                }
472                Err(e) => {
473                    tracing::warn!("usage_events pool unavailable (fail-open): {e:#}");
474                }
475            }
476        }
477    });
478    true
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::proxy::usage::WireContext;
485
486    fn usage_with_wire(wire: Option<Box<WireContext>>) -> RealUsage {
487        RealUsage {
488            model: "claude-sonnet-4-5".into(),
489            input_tokens: 1000,
490            output_tokens: 500,
491            cache_read_tokens: 200,
492            cache_write_tokens: 100,
493            reasoning_tokens: 50,
494            provider_cost_usd: None,
495            cohort: None,
496            wire,
497        }
498    }
499
500    #[test]
501    fn event_carries_identity_and_baseline_fields() {
502        let usage = usage_with_wire(Some(Box::new(WireContext {
503            provider: "Anthropic".into(),
504            person: Some("yves".into()),
505            team: Some("platform".into()),
506            project: Some("ai-gateway".into()),
507            saved_tokens: 4000,
508            uncompressed_input_tokens: 5000,
509            is_local: false,
510            routed_from: Some("claude-opus-4-5".into()),
511            counterfactual: None,
512            lineage: None,
513        })));
514        let event = UsageEvent::from_usage(
515            &usage,
516            &ModelPricing::load(),
517            &BaselineConfig {
518                reference_model: Some("claude-opus-4.5".into()),
519                local_shadow_rate_per_mtok: None,
520            },
521        );
522
523        assert_eq!(event.person, "yves");
524        assert_eq!(event.team.as_deref(), Some("platform"));
525        assert_eq!(event.project, "ai-gateway");
526        assert_eq!(event.provider, "Anthropic");
527        assert_eq!(event.model, "claude-sonnet-4-5");
528        assert_eq!(event.routed_from.as_deref(), Some("claude-opus-4-5"));
529        assert_eq!(event.input_tokens, 1000);
530        assert_eq!(event.saved_tokens, 4000);
531        assert_eq!(event.uncompressed_input_tokens, 5000);
532        assert!(!event.is_local);
533        assert!(event.cost_usd > 0.0, "known model must be priced");
534        assert_eq!(
535            event.cost_source, "list",
536            "exact table match books list price"
537        );
538        assert!(
539            event.saved_usd > 0.0,
540            "saved tokens on a priced model must yield saved USD"
541        );
542        // Counterfactual (enterprise#15): 5000 uncompressed input tokens at
543        // claude-opus-4.5's $5/MTok input rate = $0.025.
544        assert_eq!(event.reference_model.as_deref(), Some("claude-opus-4.5"));
545        assert!((event.reference_cost_usd - 0.025).abs() < 1e-9);
546    }
547
548    #[test]
549    fn provider_reported_cost_beats_the_price_table() {
550        // #1179: OpenRouter's `usage.cost` is the bill — the table estimate for
551        // these tokens (claude-sonnet at list price) would be ~50× higher and
552        // must NOT be booked when a measured figure exists.
553        let mut usage = usage_with_wire(Some(Box::new(WireContext {
554            provider: "openrouter".into(),
555            person: Some("nicolas".into()),
556            team: None,
557            project: Some("bot".into()),
558            saved_tokens: 0,
559            uncompressed_input_tokens: 1000,
560            is_local: false,
561            routed_from: None,
562            counterfactual: None,
563            lineage: None,
564        })));
565        usage.provider_cost_usd = Some(0.0123);
566        let event =
567            UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
568        assert!((event.cost_usd - 0.0123).abs() < 1e-12);
569        assert_eq!(event.cost_source, "provider");
570
571        // A measured zero (":free" model) is a real price, not a missing one.
572        usage.provider_cost_usd = Some(0.0);
573        let event =
574            UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
575        assert_eq!(event.cost_usd, 0.0);
576        assert_eq!(event.cost_source, "provider");
577    }
578
579    #[test]
580    fn unknown_model_is_marked_heuristic_and_local_shadow_beats_measured() {
581        // An unpriced model falls into the blended fallback → cost_source
582        // must say so instead of presenting the estimate as exact.
583        let mut usage = usage_with_wire(None);
584        usage.model = "vendor/brand-new-model-20990101".into();
585        let event =
586            UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
587        assert_eq!(event.cost_source, "heuristic");
588
589        // Local turns book the shadow rate even if a cost slipped through —
590        // the shadow rate is the contract for local inference (Doc 04 §6).
591        let mut usage = usage_with_wire(Some(Box::new(WireContext {
592            provider: "ollama".into(),
593            person: None,
594            team: None,
595            project: None,
596            saved_tokens: 0,
597            uncompressed_input_tokens: 0,
598            is_local: true,
599            routed_from: None,
600            counterfactual: None,
601            lineage: None,
602        })));
603        usage.provider_cost_usd = Some(9.99);
604        let event =
605            UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
606        assert_eq!(event.cost_source, "shadow");
607        assert!(
608            event.cost_usd < 1.0,
609            "shadow rate, not the stray measured figure"
610        );
611    }
612
613    #[test]
614    fn event_without_wire_context_uses_honest_fallbacks() {
615        let event = UsageEvent::from_usage(
616            &usage_with_wire(None),
617            &ModelPricing::load(),
618            &BaselineConfig::default(),
619        );
620        assert_eq!(event.person, ANONYMOUS_PERSON);
621        assert_eq!(event.project, DEFAULT_PROJECT);
622        assert_eq!(event.team, None);
623        assert_eq!(event.saved_tokens, 0);
624        assert_eq!(event.saved_usd, 0.0);
625        assert_eq!(event.uncompressed_input_tokens, 0);
626        assert!(!event.is_local);
627        // No reference model configured → no counterfactual claimed.
628        assert_eq!(event.reference_model, None);
629        assert_eq!(event.reference_cost_usd, 0.0);
630    }
631
632    #[test]
633    fn local_usage_books_shadow_rate_never_zero() {
634        // enterprise#15/#18: local inference is billed via the transparent
635        // shadow rate — savings against local models stay honest, not infinite.
636        let usage = usage_with_wire(Some(Box::new(WireContext {
637            provider: "ollama".into(),
638            person: Some("yves".into()),
639            team: None,
640            project: None,
641            saved_tokens: 0,
642            uncompressed_input_tokens: 2000,
643            is_local: true,
644            routed_from: None,
645            counterfactual: None,
646            lineage: None,
647        })));
648        let event =
649            UsageEvent::from_usage(&usage, &ModelPricing::load(), &BaselineConfig::default());
650        assert!(event.is_local);
651        // billable = 1000 in + 500 out + 200 cache-read + 100 cache-write =
652        // 1800 tokens × $0.25/MTok default shadow rate.
653        assert!((event.cost_usd - 0.25 / 1_000_000.0 * 1800.0).abs() < 1e-12);
654        assert!(event.cost_usd > 0.0, "local cost must never be zero");
655
656        // A configured rate wins; a zero/negative config falls back to default.
657        let cfg = BaselineConfig {
658            reference_model: None,
659            local_shadow_rate_per_mtok: Some(1.0),
660        };
661        let event = UsageEvent::from_usage(&usage, &ModelPricing::load(), &cfg);
662        assert!((event.cost_usd - 1.0 / 1_000_000.0 * 1800.0).abs() < 1e-12);
663        let zero = BaselineConfig {
664            reference_model: None,
665            local_shadow_rate_per_mtok: Some(0.0),
666        };
667        assert!(zero.effective_local_shadow_rate() > 0.0);
668    }
669
670    #[test]
671    fn sslmode_selects_tls_and_pool_builds_for_both() {
672        // require → TLS connector; disable/unset → plain (#54/#58).
673        let tls: tokio_postgres::Config = "postgres://u:p@db.example.com:5432/app?sslmode=require"
674            .parse()
675            .unwrap();
676        assert!(wants_tls(&tls));
677        let plain: tokio_postgres::Config = "postgres://u:p@localhost:5432/app".parse().unwrap();
678        assert!(!wants_tls(&plain));
679        let disabled: tokio_postgres::Config = "postgres://u:p@localhost:5432/app?sslmode=disable"
680            .parse()
681            .unwrap();
682        assert!(!wants_tls(&disabled));
683
684        // Pool construction (no connection attempt) must succeed on both paths —
685        // this exercises the rustls config + root store wiring.
686        assert!(
687            pool_from_database_url("postgres://u:p@db.example.com:5432/app?sslmode=require")
688                .is_ok()
689        );
690        assert!(pool_from_database_url("postgres://u:p@localhost:5432/app").is_ok());
691    }
692
693    #[test]
694    fn pool_size_env_is_clamped_and_falls_back() {
695        // Env mutation is serialized process-wide through test_env_lock().
696        let _guard = crate::core::data_dir::test_env_lock();
697        crate::test_env::remove_var(POOL_MAX_SIZE_ENV);
698        assert_eq!(pool_max_size(), 8, "unset -> default");
699        crate::test_env::set_var(POOL_MAX_SIZE_ENV, "24");
700        assert_eq!(pool_max_size(), 24, "explicit value wins");
701        crate::test_env::set_var(POOL_MAX_SIZE_ENV, "0");
702        assert_eq!(pool_max_size(), 2, "clamped low");
703        crate::test_env::set_var(POOL_MAX_SIZE_ENV, "9999");
704        assert_eq!(pool_max_size(), 64, "clamped high");
705        crate::test_env::set_var(POOL_MAX_SIZE_ENV, "not-a-number");
706        assert_eq!(pool_max_size(), 8, "garbage -> default, never panic");
707        crate::test_env::remove_var(POOL_MAX_SIZE_ENV);
708    }
709
710    #[test]
711    fn schema_ddl_is_idempotent_by_construction() {
712        // The gateway runs this DDL on every start against a live database, so
713        // every CREATE must carry IF NOT EXISTS, and post-release columns ride
714        // along as ALTER TABLE … ADD COLUMN IF NOT EXISTS.
715        for stmt in ["CREATE TABLE", "CREATE INDEX"] {
716            for (i, _) in USAGE_EVENTS_DDL.match_indices(stmt) {
717                let tail = &USAGE_EVENTS_DDL[i..(i + stmt.len() + 14).min(USAGE_EVENTS_DDL.len())];
718                assert!(
719                    tail.contains("IF NOT EXISTS"),
720                    "non-idempotent DDL statement: {tail}"
721                );
722            }
723        }
724        for (i, _) in USAGE_EVENTS_DDL.match_indices("ADD COLUMN") {
725            let tail = &USAGE_EVENTS_DDL[i..(i + 25).min(USAGE_EVENTS_DDL.len())];
726            assert!(
727                tail.contains("IF NOT EXISTS"),
728                "non-idempotent ALTER statement: {tail}"
729            );
730        }
731        // And the baseline fields (enterprise#18) are part of the schema.
732        for col in [
733            "uncompressed_input_tokens",
734            "reference_model",
735            "reference_cost_usd",
736            "is_local",
737            "cost_source",
738        ] {
739            assert!(
740                USAGE_EVENTS_DDL.contains(col),
741                "baseline column {col} missing from schema"
742            );
743        }
744    }
745}