Skip to content
Merged
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
40 changes: 40 additions & 0 deletions .config/nextest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Nextest configuration — https://nexte.st/book/configuration
# Run with: cargo nextest run

[store]
dir = "target/nextest"

# Default profile: local dev and CI
[profile.default]
# Retry flaky tests once before failing
retries = 1
# Per-test timeout (catch infinite loops / deadlocks)
slow-timeout = { period = "30s", terminate-after = 2 }
# Fail fast: stop on first failure in CI (overridden in dev profile)
fail-fast = true
# Status output level
status-level = "pass"
final-status-level = "flaky"

# Local development: more lenient
[profile.dev]
retries = 0
fail-fast = false
slow-timeout = { period = "60s", terminate-after = 2 }

# CI profile: strict, with JUnit output for test reporting
[profile.ci]
retries = 2
fail-fast = true
slow-timeout = { period = "30s", terminate-after = 2 }
status-level = "retry"
final-status-level = "slow"

# Test groups — isolate heavy tests that can't run in parallel
[test-groups.serial-integration]
max-threads = 1

# Assign integration tests to serial group (they bind ports / use shared state)
[[profile.default.overrides]]
filter = "test(/^integration/)"
test-group = "serial-integration"
24 changes: 23 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,29 @@ jobs:
with:
shared-key: test-${{ matrix.os }}
save-if: ${{ github.ref == 'refs/heads/develop' }}
- run: cargo test
- uses: taiki-e/install-action@nextest
- run: cargo nextest run --profile ci
- run: cargo test --doc

coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: llvm-tools-preview
- uses: Swatinem/rust-cache@v2
with:
shared-key: coverage
save-if: ${{ github.ref == 'refs/heads/develop' }}
- uses: taiki-e/install-action@cargo-llvm-cov
- run: cargo llvm-cov --lcov --output-path lcov.info
- uses: codecov/codecov-action@v5
with:
files: lcov.info
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}

audit:
name: Security Audit
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.9.0](https://github.com/azerozero/grob/compare/v0.1.3...v0.9.0) - 2026-02-26

### Added

- *(dx)* add nextest, insta, tracing-test, coverage, cargo-chef

### Fixed

- remove invalid release_branch field from release-plz.toml

### Other

- add develop branch workflow and auto-merge release PRs
- enable auto-merge for release-plz PRs

### Added

- **Budget enforcement**: global, per-provider, and per-model monthly spend limits (`[budget]`, `budget_usd`)
Expand Down
64 changes: 63 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ anyhow = "1"
thiserror = "1"

# Utilities
once_cell = "1" # Lazy statics
async-trait = "0.1" # Async trait support
async-trait = "0.1" # Async trait support (needed for dyn Provider dispatch)
dirs = "5" # User directories
regex = "1" # Regular expressions
uuid = { version = "1.0", features = ["v4", "serde"] } # UUID generation for streaming
Expand Down Expand Up @@ -112,6 +111,8 @@ tokio-test = "0.4"
mockito = "1"
criterion = "0.5" # Benchmarking
tempfile = "3" # Temporary directory testing
insta = { version = "1", features = ["json", "yaml"] } # Snapshot testing
tracing-test = "0.2" # Capture tracing logs in tests

# Property Testing
proptest = "1"
Expand All @@ -128,7 +129,9 @@ tls = ["axum-server", "rustls", "rustls-pemfile"]
# ecdsa: trait crate required for p256::ecdsa::signature::Signer bounds
# tower: required by tower-http for Service/Layer trait impls
# rustls/rustls-pemfile: behind optional "tls" feature flag
ignored = ["ecdsa", "tower", "rustls", "rustls-pemfile"]
# insta: snapshot testing — used incrementally as tests are converted
# tracing-test: used via #[traced_test] attribute on individual tests
ignored = ["ecdsa", "tower", "rustls", "rustls-pemfile", "insta", "tracing-test"]

[profile.release]
opt-level = 3
Expand Down
30 changes: 15 additions & 15 deletions Containerfile
Original file line number Diff line number Diff line change
@@ -1,32 +1,32 @@
# Grob - LLM Routing Proxy
# Containerfile for Podman/Docker (rootless compatible)
# Multi-stage build for minimal attack surface
# Multi-stage build with cargo-chef for fast rebuilds

# Stage 1: Build environment
FROM rust:1.85-alpine AS builder

# Install build dependencies
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static

# Create app directory
# Stage 1: Chef planner — compute dependency recipe
FROM rust:1.85-alpine AS chef
RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static && \
cargo install cargo-chef --locked
WORKDIR /usr/src/grob

# Copy manifest files first (cache dependencies)
COPY Cargo.toml Cargo.lock deny.toml ./
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

# Copy source code
COPY src ./src
COPY benches ./benches
# Stage 2: Build dependencies (cached unless Cargo.toml/lock change)
FROM chef AS builder
COPY --from=planner /usr/src/grob/recipe.json recipe.json
RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json

# Build static binary with musl
# Copy source and build final binary (only this layer invalidates on code changes)
COPY . .
ENV RUSTFLAGS="-C target-feature=+crt-static -C link-self-contained=yes"
RUN cargo update time@0.3.47 --precise 0.3.35 && \
cargo build --release --target x86_64-unknown-linux-musl

# Strip symbols for smaller binary
RUN strip target/x86_64-unknown-linux-musl/release/grob

# Stage 2: Runtime (scratch - empty base)
# Stage 3: Runtime (scratch - empty base)
FROM scratch

# Metadata
Expand Down
2 changes: 0 additions & 2 deletions release-plz.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ git_tag_name = "v{{ version }}"
git_tag_enable = true
changelog_update = true
pr_labels = ["release"]
# Release PRs target main from develop
release_branch = "main"

[[package]]
name = "grob"
Expand Down
10 changes: 5 additions & 5 deletions src/router/mod.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
use crate::cli::AppConfig;
use crate::models::{AnthropicRequest, MessageContent, RouteDecision, RouteType, SystemPrompt};
use anyhow::Result;
use once_cell::sync::Lazy;
use regex::Regex;
use std::sync::LazyLock;
use tracing::{debug, info};

/// Regex to detect capture group references ($1, $name, ${1}, ${name})
static CAPTURE_REF_PATTERN: Lazy<Regex> =
Lazy::new(|| Regex::new(r"\$(?:\d+|[a-zA-Z_]\w*|\{[^}]+\})").unwrap());
static CAPTURE_REF_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\$(?:\d+|[a-zA-Z_]\w*|\{[^}]+\})").unwrap());

/// Pre-compiled regex for subagent model tag extraction (avoids per-request compilation)
static SUBAGENT_TAG_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"<GROB-SUBAGENT-MODEL>(.*?)</GROB-SUBAGENT-MODEL>").unwrap());
static SUBAGENT_TAG_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<GROB-SUBAGENT-MODEL>(.*?)</GROB-SUBAGENT-MODEL>").unwrap());

/// Check if a string contains capture group references
fn contains_capture_reference(s: &str) -> bool {
Expand Down
Loading