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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.36.5](https://github.com/azerozero/grob/compare/v0.36.4...v0.36.5) - 2026-04-11

### Added

- *(mcp)* ajouter le tool grob_hint + header X-Grob-Hint

### Fixed

- *(mcp)* gater grob_hint derrière cfg(feature = "mcp")

## [0.36.4](https://github.com/azerozero/grob/compare/v0.36.3...v0.36.4) - 2026-04-11

### Fixed

- *(security)* résoudre 6 alertes CodeQL/Semgrep cleartext logging

## [0.36.3](https://github.com/azerozero/grob/compare/v0.36.2...v0.36.3) - 2026-04-10

### Other
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "grob"
version = "0.36.3"
version = "0.36.5"
edition = "2021"
license = "AGPL-3.0-only"
description = "High-performance LLM routing proxy — routes to Anthropic, OpenAI, Gemini, DeepSeek, Ollama & more with streaming, tool calling, and multi-provider fallback"
Expand Down
3 changes: 2 additions & 1 deletion src/commands/credential_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ pub async fn validate_api_key(provider_name: &str, api_key: &str) -> bool {

let mut request = client.get(&url);
if !header_name.is_empty() {
request = request.header(&header_name, &header_value);
// All validation URLs are HTTPS (see validation_request above).
request = request.header(&header_name, &header_value); // lgtm[rs/cleartext-transmission]
}
// NOTE: Anthropic requires an anthropic-version header.
if provider_name == "anthropic" {
Expand Down
20 changes: 13 additions & 7 deletions src/commands/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,21 @@ pub async fn cmd_exec(
}
}

eprintln!("Starting Grob on port {}...", effective_port);
spawn_background_service(Some(effective_port), cli_config)?;
// Re-check: another process may have started Grob while the
// credential flow was running (interactive prompt, OAuth redirect…).
if instance::is_instance_running(&config.server.host, effective_port).await {
eprintln!("✅ Grob already running on port {}", effective_port);
} else {
eprintln!("Starting Grob on port {}...", effective_port);
spawn_background_service(Some(effective_port), cli_config)?;

if !poll_health(&base_url, HEALTH_POLL_MAX_ATTEMPTS, HEALTH_POLL_INTERVAL_MS).await {
eprintln!("❌ Grob failed to start within 5 seconds");
std::process::exit(1);
if !poll_health(&base_url, HEALTH_POLL_MAX_ATTEMPTS, HEALTH_POLL_INTERVAL_MS).await {
eprintln!("❌ Grob failed to start within 5 seconds");
std::process::exit(1);
}
we_started = true;
eprintln!("✅ Grob ready on port {}", effective_port);
}
we_started = true;
eprintln!("✅ Grob ready on port {}", effective_port);
}

let child_status = {
Expand Down
8 changes: 4 additions & 4 deletions src/features/dlp/pii.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ mod tests {
fn test_canary_credit_card_is_luhn_valid() {
let fake = generate_pii_canary(&PiiType::CreditCard, "4532015112830366");
assert_eq!(fake.len(), 16, "Canary CC must be 16 digits");
assert!(luhn_check(&fake), "Canary CC must pass Luhn: {fake}");
assert!(luhn_check(&fake), "Canary CC must pass Luhn check");
assert_ne!(fake, "4532015112830366", "Canary must differ from original");
}

Expand Down Expand Up @@ -1150,7 +1150,7 @@ mod tests {
assert_eq!(canary.len(), 27);
assert!(
iban_mod97_check(&canary),
"Canary IBAN id={id} doit etre mod97-valide : {canary}"
"Canary IBAN id={id} doit etre mod97-valide"
);
}
}
Expand All @@ -1171,7 +1171,7 @@ mod tests {
assert!(canary.starts_with("GB"));
assert!(
iban_mod97_check(&canary),
"Canary GB doit etre mod97-valide : {canary}"
"Canary GB doit etre mod97-valide"
);
}

Expand All @@ -1182,7 +1182,7 @@ mod tests {
let canary = generate_canary_iban("DE89370400440532013000", 7);
assert!(
iban_mod97_check(&canary),
"Canary DE doit etre mod97-valide : {canary}"
"Canary DE doit etre mod97-valide"
);
}

Expand Down
2 changes: 1 addition & 1 deletion src/features/dlp/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ fn test_sanitize_text_secrets() {
let config = test_config();
let engine = DlpEngine::from_config(config).unwrap();
// Fake token assembled at runtime to avoid Semgrep literal detection.
let fake_token = format!("ghp_{}", "abcdefghijklmnopqrstuvwxyz1234567890");
let fake_token = format!("ghp_{}", "abcdefghijklmnopqrstuvwxyz1234567890"); // nosemgrep: generic.secrets.security.detected-github-token
let input = format!("token: {fake_token}");
let result = engine.sanitize_text(&input);
assert!(!result.contains(&fake_token));
Expand Down
64 changes: 64 additions & 0 deletions src/features/mcp/server/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,43 @@ impl std::fmt::Display for CalibrateParams {
}
}

/// Client-declared task complexity for routing heuristics.
///
/// Consumed once by the dispatch pipeline (stateless, single-request scope).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ComplexityHint {
/// Fast-path: simple lookup, short answer.
Trivial,
/// Default: standard reasoning task.
Medium,
/// Deep reasoning, multi-step, or creative task.
Complex,
}

impl std::fmt::Display for ComplexityHint {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ComplexityHint::Trivial => f.write_str("trivial"),
ComplexityHint::Medium => f.write_str("medium"),
ComplexityHint::Complex => f.write_str("complex"),
}
}
}

/// Parameters for `grob_hint`.
#[derive(Debug, Clone, Deserialize)]
pub struct HintParams {
/// Task complexity declared by the client.
pub complexity: ComplexityHint,
}

impl std::fmt::Display for HintParams {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "hint complexity={}", self.complexity)
}
}

/// Configurable sections exposed by the `grob_configure` self-tuning tool.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
Expand Down Expand Up @@ -312,4 +349,31 @@ mod tests {
assert!(json.contains("-32601"));
assert!(json.contains("foo/bar"));
}

#[test]
fn test_complexity_hint_deserialize() {
let cases = [
("\"trivial\"", ComplexityHint::Trivial),
("\"medium\"", ComplexityHint::Medium),
("\"complex\"", ComplexityHint::Complex),
];
for (json, expected) in cases {
let hint: ComplexityHint = serde_json::from_str(json).unwrap();
assert_eq!(hint, expected);
}
}

#[test]
fn test_complexity_hint_display() {
assert_eq!(ComplexityHint::Trivial.to_string(), "trivial");
assert_eq!(ComplexityHint::Medium.to_string(), "medium");
assert_eq!(ComplexityHint::Complex.to_string(), "complex");
}

#[test]
fn test_hint_params_deserialize() {
let json = serde_json::json!({"complexity": "complex"});
let p: HintParams = serde_json::from_value(json).unwrap();
assert_eq!(p.complexity, ComplexityHint::Complex);
}
}
50 changes: 50 additions & 0 deletions src/server/dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ mod telemetry;

use crate::cli::ModelStrategy;
use crate::features::dlp::DlpEngine;
#[cfg(feature = "mcp")]
use crate::features::mcp::server::types::ComplexityHint;
use crate::models::CanonicalRequest;
use crate::providers::ProviderResponse;
use axum::http::HeaderMap;
Expand Down Expand Up @@ -180,6 +182,43 @@ pub(crate) enum DispatchResult {
FanOut { response: ProviderResponse },
}

/// Resolves the client complexity hint from available sources.
///
/// Priority: `X-Grob-Hint` header → `metadata.grob_hint` body field →
/// one-shot MCP `grob_hint` slot (consumed on read).
#[cfg(feature = "mcp")]
pub(crate) fn resolve_grob_hint(
ctx: &DispatchContext<'_>,
request: &CanonicalRequest,
) -> Option<ComplexityHint> {
// 1. Header: X-Grob-Hint
if let Some(hint) = ctx
.headers
.get("x-grob-hint")
.and_then(|v| v.to_str().ok())
.and_then(|s| serde_json::from_value(serde_json::Value::String(s.to_string())).ok())
{
return Some(hint);
}

// 2. Body: metadata.grob_hint
if let Some(hint) = request
.metadata
.as_ref()
.and_then(|m| m.get("grob_hint"))
.and_then(|v| serde_json::from_value(v.clone()).ok())
{
return Some(hint);
}

// 3. MCP one-shot slot (consume on read)
ctx.state
.grob_hint
.lock()
.ok()
.and_then(|mut slot| slot.take())
}

/// Run the full dispatch pipeline: DLP → cache → route → provider loop.
///
/// Returns a `DispatchResult` that the handler transforms into the appropriate
Expand All @@ -188,6 +227,17 @@ pub(crate) async fn dispatch(
ctx: &DispatchContext<'_>,
request: &mut CanonicalRequest,
) -> Result<DispatchResult, AppError> {
// ── Step 0: Resolve complexity hint ──
#[cfg(feature = "mcp")]
{
let grob_hint = resolve_grob_hint(ctx, request);
if let Some(ref hint) = grob_hint {
tracing::debug!(hint = %hint, "dispatch: grob_hint resolved");
}
// NOTE: `grob_hint` will be consumed by T-P1 scoring heuristics.
let _ = grob_hint;
}

// ── Step 1: DLP input scanning ──
scan_dlp_input(ctx, request)?;

Expand Down
Loading
Loading