Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 4 additions & 19 deletions prek.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,25 +54,10 @@ args = ["-c", "lychee --offline --no-progress --include-fragments docs/**/*.md R
types = ["markdown"]
pass_filenames = false

# Tests — expensive, runs only on pre-push
[[repos.hooks]]
id = "cargo-test"
name = "cargo test"
language = "system"
entry = "cargo nextest run --profile ci"
types = ["rust"]
pass_filenames = false
stages = ["pre-push"]

# Doc tests — not covered by nextest
[[repos.hooks]]
id = "cargo-doctest"
name = "cargo doc tests"
language = "system"
entry = "cargo test --doc"
types = ["rust"]
pass_filenames = false
stages = ["pre-push"]
# NOTE: cargo-test and cargo-doctest were removed from pre-push to keep
# the gate fast-fail. CI re-runs the full suite on every PR (see
# `.github/workflows/ci.yml` `tests` job), so duplicating ~20 min of work
# locally only delays the developer feedback loop without adding signal.

# Cargo deny — license & advisory & dependency checks
# Only runs when Cargo.toml/lock or Rust files change.
Expand Down
1 change: 1 addition & 0 deletions src/cli/config/budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::cli::BudgetUsd;

/// Budget configuration
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct BudgetConfig {
/// Global monthly hard cap in USD (0 = unlimited)
#[serde(default)]
Expand Down
1 change: 1 addition & 0 deletions src/cli/config/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};

/// LLM response cache configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CacheConfig {
/// Enable response caching (only for temperature=0 requests)
#[serde(default)]
Expand Down
12 changes: 11 additions & 1 deletion src/cli/config/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub enum AuthType {

/// Provider configuration from TOML.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderConfig {
/// Unique provider name used in routing and logging.
pub name: String,
Expand Down Expand Up @@ -119,7 +120,16 @@ pub struct ProviderConfig {
}

impl ProviderConfig {
/// Returns `true` if the provider is enabled (defaults to `true`).
/// Returns `true` if the provider is enabled.
///
/// Semantics:
/// - `enabled = true` → enabled.
/// - `enabled = false` → disabled.
/// - `enabled` absent → enabled (sensible default for newly added blocks).
///
/// Typo safety: `#[serde(deny_unknown_fields)]` on [`ProviderConfig`]
/// rejects misspelled keys (e.g. `enbaled`) at parse time, so an absent
/// `enabled` field genuinely means "not specified" rather than "typo'd".
pub fn is_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
Expand Down
3 changes: 3 additions & 0 deletions src/cli/config/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use super::user::PresetConfig;

/// Router configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct RouterConfig {
/// Default model for unclassified requests
pub default: String,
Expand Down Expand Up @@ -102,6 +103,7 @@ pub struct FanOutConfig {

/// Model configuration with 1:N provider mappings
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ModelConfig {
/// External model name (used in API requests)
pub name: String,
Expand Down Expand Up @@ -203,6 +205,7 @@ pub struct TierMatchCondition {
/// When the scoring heuristic classifies a request, the dispatch pipeline
/// resolves providers from the matching tier instead of the default model mappings.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct TierConfig {
/// Tier name — must match a `ComplexityTier` variant (case-insensitive).
pub name: String,
Expand Down
1 change: 1 addition & 0 deletions src/cli/config/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use super::default_true;

/// Security configuration (wired into middleware stack)
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SecurityConfig {
/// Master switch for security middleware
#[serde(default = "default_true")]
Expand Down
1 change: 1 addition & 0 deletions src/features/dlp/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize};

/// Top-level DLP configuration, mapped from `[dlp]` in TOML.
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
#[serde(deny_unknown_fields)]
pub struct DlpConfig {
/// Enables the DLP pipeline globally.
#[serde(default)]
Expand Down
10 changes: 10 additions & 0 deletions src/features/token_pricing/spend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ pub struct BudgetLimits {
pub struct BudgetError {
/// Human-readable budget exceeded message.
pub message: String,
/// Configured budget limit in USD (whichever scope tripped first).
pub limit_usd: f64,
/// Recorded spend in USD at check time.
pub actual_usd: f64,
}

impl std::fmt::Display for BudgetError {
Expand Down Expand Up @@ -216,6 +220,8 @@ impl SpendTracker {
"Monthly budget for model '{}' reached: ${:.2}/${:.2}",
model, spend, limit
),
limit_usd: limit,
actual_usd: spend,
});
}
}
Expand All @@ -228,6 +234,8 @@ impl SpendTracker {
"Monthly budget for provider '{}' reached: ${:.2}/${:.2}",
provider, spend, limit
),
limit_usd: limit,
actual_usd: spend,
});
}
}
Expand All @@ -240,6 +248,8 @@ impl SpendTracker {
"Monthly global budget reached: ${:.2}/${:.2}",
total, global_limit
),
limit_usd: global_limit,
actual_usd: total,
});
}
}
Expand Down
1 change: 1 addition & 0 deletions src/models/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use crate::features::tap::TapConfig;

/// Application configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AppConfig {
/// Config schema version (for forward compatibility)
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down
1 change: 1 addition & 0 deletions src/routing/classify/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ impl Default for ScoringThresholds {

/// Scoring configuration combining weights and thresholds.
#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
#[serde(deny_unknown_fields)]
pub struct ScoringConfig {
/// Per-signal weights.
pub weights: ScoringWeights,
Expand Down
4 changes: 4 additions & 0 deletions src/security/audit_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,10 @@ pub enum AuditEvent {
HitApproval,
/// TEE attestation report generated at startup.
TeeAttestation,
/// HTTP request fully processed (emitted by the audit middleware once a
/// response has been produced — covers the entire request lifecycle from
/// authentication through dispatch and error handling).
RequestProcessed,
}

/// Immutable audit log entry.
Expand Down
11 changes: 7 additions & 4 deletions src/server/budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use crate::providers::AuthType;
use std::sync::Arc;
use tracing::warn;

use super::{AppError, AppState, ReloadableState};
use super::{AppState, ReloadableState, RequestError};

/// Maximum retries per provider before falling back to the next mapping.
/// NOTE: 2 retries (3 total attempts) balances latency vs resilience — most
Expand Down Expand Up @@ -62,13 +62,13 @@ pub(crate) fn record_request_metrics(m: &RequestMetrics<'_>) {
}
}

/// Check budget before a request. Returns Err(AppError::BudgetExceeded) if any limit is hit.
/// Check budget before a request. Returns `Err(RequestError::BudgetExceeded)` if any limit is hit.
pub(crate) async fn check_budget(
state: &Arc<AppState>,
inner: &Arc<ReloadableState>,
provider_name: &str,
model_name: &str,
) -> Result<(), AppError> {
) -> Result<(), RequestError> {
let budget_config = &inner.config.budget;
let global_limit = budget_config.monthly_limit_usd.value();

Expand All @@ -92,7 +92,10 @@ pub(crate) async fn check_budget(
provider_limit,
model_limit,
) {
return Err(AppError::BudgetExceeded(e.message));
return Err(RequestError::BudgetExceeded {
limit_usd: e.limit_usd,
actual_usd: e.actual_usd,
});
}

if let Some(warning) = tracker.check_warnings(
Expand Down
22 changes: 11 additions & 11 deletions src/server/config_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::sync::Arc;
use tracing::{error, info, warn};

use super::config_guard::is_section_or_key_denied;
use super::{AppError, AppState, ReloadableState};
use super::{AppState, ReloadableState, RequestError};

/// Redact an API key for safe display (show first 4 + last 4 chars)
pub(crate) fn redact_api_key(key: &str) -> String {
Expand Down Expand Up @@ -89,7 +89,7 @@ pub(crate) async fn get_config_json(State(state): State<Arc<AppState>>) -> impl
pub(crate) async fn update_config_json(
State(state): State<Arc<AppState>>,
Json(mut new_config): Json<serde_json::Value>,
) -> Result<Json<serde_json::Value>, AppError> {
) -> Result<Json<serde_json::Value>, RequestError> {
// Remove null values (TOML doesn't support null)
remove_null_values(&mut new_config);

Expand All @@ -99,7 +99,7 @@ pub(crate) async fn update_config_json(
// Whole-section deny check (providers, dlp).
if is_section_or_key_denied(section, "") {
warn!(section = %section, "config API: denied write to protected section");
return Err(AppError::ParseError(format!(
return Err(RequestError::Forbidden(format!(
"denied: section '{}' cannot be modified via the config API",
section
)));
Expand All @@ -109,7 +109,7 @@ pub(crate) async fn update_config_json(
for key in inner.keys() {
if is_section_or_key_denied(section, key) {
warn!(section = %section, key = %key, "config API: denied write to protected key");
return Err(AppError::ParseError(format!(
return Err(RequestError::Forbidden(format!(
"denied: {}.{} cannot be modified via the config API",
section, key
)));
Expand All @@ -123,7 +123,7 @@ pub(crate) async fn update_config_json(
let config_path = match &state.config_source {
crate::cli::ConfigSource::File(p) => p,
crate::cli::ConfigSource::Url(_) => {
return Err(AppError::ParseError(
return Err(RequestError::BadRequest(
"Cannot save config: loaded from remote URL (read-only)".to_string(),
));
}
Expand All @@ -132,15 +132,15 @@ pub(crate) async fn update_config_json(
// Read current config and merge the incoming JSON updates into it.
let config_str = tokio::fs::read_to_string(config_path)
.await
.map_err(|e| AppError::ParseError(format!("Failed to read config: {e}")))?;
.map_err(|e| RequestError::Internal(anyhow::anyhow!("Failed to read config: {e}")))?;

let mut config: toml::Value = toml::from_str(&config_str)
.map_err(|e| AppError::ParseError(format!("Failed to parse config: {e}")))?;
.map_err(|e| RequestError::ParseError(format!("Failed to parse config: {e}")))?;

// Update providers section
if let Some(providers) = new_config.get("providers") {
let providers_toml: toml::Value = serde_json::from_str(&providers.to_string())
.map_err(|e| AppError::ParseError(format!("Failed to convert providers: {e}")))?;
.map_err(|e| RequestError::ParseError(format!("Failed to convert providers: {e}")))?;

if let Some(table) = config.as_table_mut() {
table.insert("providers".to_string(), providers_toml);
Expand All @@ -150,7 +150,7 @@ pub(crate) async fn update_config_json(
// Update models section
if let Some(models) = new_config.get("models") {
let models_toml: toml::Value = serde_json::from_str(&models.to_string())
.map_err(|e| AppError::ParseError(format!("Failed to convert models: {e}")))?;
.map_err(|e| RequestError::ParseError(format!("Failed to convert models: {e}")))?;

if let Some(table) = config.as_table_mut() {
table.insert("models".to_string(), models_toml);
Expand Down Expand Up @@ -192,9 +192,9 @@ pub(crate) async fn update_config_json(

// Deserialise the merged TOML into AppConfig so we can validate and reload.
let merged_toml_str = toml::to_string_pretty(&config)
.map_err(|e| AppError::ParseError(format!("Failed to serialize config: {e}")))?;
.map_err(|e| RequestError::Internal(anyhow::anyhow!("Failed to serialize config: {e}")))?;
let merged_config: crate::models::config::AppConfig = toml::from_str(&merged_toml_str)
.map_err(|e| AppError::ParseError(format!("Invalid config after merge: {e}")))?;
.map_err(|e| RequestError::ParseError(format!("Invalid config after merge: {e}")))?;

// Backup, write, and hot-reload via the shared pipeline.
super::config_guard::persist_and_reload(&state, &merged_config).await?;
Expand Down
Loading
Loading