Skip to content

Commit eacea93

Browse files
committed
fix: improve LLM provider integration and debug logging
This commit addresses critical issues with Gemini and OpenAI provide integrations and enhances debug logging across all LLM adapters. Changes: - Reroute Gemini to standalone HTTP client bypassing rig.rs due to deserialization issues with current Gemini API - Add generationConfig.responseMimeType to Gemini requests to enforce JSON responses - Switch OpenAI from json_schema to json_object response format for better compatibility with GPT-5 reasoning models - Add flexible regex patterns INSTR_IGNORE and PROMPT_LEAK to rules/patterns.json to catch attack variations - Enhance debug logging: always log raw LLM responses when --debug flag is enabled (not only on errors) - Add debug logging to Gemini standalone client - Remove unused verdict_json_schema function from rig adapter - Update README.md with detailed provider integration pitfalls Fixes: - Gemini "missing field generationConfig" deserialization errors - OpenAI GPT-5 returning only reasoning traces with no content - Detection rules missing "ignore your previous instructions" variations - Debug logging only showing errors instead of all raw responses
1 parent fa42884 commit eacea93

File tree

5 files changed

+75
-61
lines changed

5 files changed

+75
-61
lines changed

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,8 +533,9 @@ Based on this ~7-hour hackathon experience building a production-ready Rust CLI:
533533
While wiring rig.rs into real LLM providers we hit a few repeat offenders. The highlights:
534534
535535
- **Anthropic truncation & malformed JSON** — Responses frequently dropped closing quotes/braces and embedded raw newlines inside strings. We added newline sanitisation, automatic quote/brace repair, a JSON5 fallback, and eventually a fallback verdict so scans never abort.
536-
- **OpenAI reasoning-only replies** — GPT‑5 often streamed only `reasoning` traces or tool calls. We now capture tool-call arguments, request OpenAI’s structured JSON schema, and fall back to an “unknown” verdict when the model withholds textual content.
537-
- **Gemini empty responses** — Successful calls can still return empty candidates. Health checks now treat empty responses as warnings instead of hard failures, surfacing an “unknown” verdict with guidance.
536+
- **OpenAI reasoning-only replies** — GPT‑5 reasoning models returned only reasoning traces without textual content when using `json_schema` response format. We now capture tool-call arguments and use simpler `json_object` response format (instead of strict `json_schema`) for better compatibility with reasoning models. Falls back to an "unknown" verdict when the model withholds textual content.
537+
- **Gemini rig.rs incompatibility** — Rig's Gemini implementation has deserialization issues with the current Gemini API (missing `generationConfig` field errors). The Gemini API also rejects requests combining forced function calling (ANY mode) with `responseMimeType: 'application/json'`. Solution: Bypassed rig entirely for Gemini; implemented standalone HTTP client using Gemini's native REST API with prompt-based JSON formatting.
538+
- **Gemini empty responses** — Successful calls can still return empty candidates. Health checks now treat empty responses as warnings instead of hard failures, surfacing an "unknown" verdict with guidance.
538539
- **Debugging provider quirks** — The global `--debug` flag flips `LLM_GUARD_DEBUG=1`, causing the adapter to log the raw upstream payload whenever parsing fails, making it obvious when prompt/schema updates are needed.
539540
540541
These guardrails keep the CLI resilient even when upstream providers change response contracts mid-flight.

crates/llm-guard-core/src/llm/gemini.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use anyhow::{anyhow, bail, Context, Result};
44
use async_trait::async_trait;
55
use reqwest::Client;
66
use serde::{Deserialize, Serialize};
7+
use std::env;
78
use std::time::Duration;
89
use tokio::time::sleep;
910

@@ -64,6 +65,9 @@ impl LlmClient for GeminiClient {
6465
)),
6566
}],
6667
}],
68+
generation_config: Some(GeminiGenerationConfig {
69+
response_mime_type: "application/json".to_string(),
70+
}),
6771
};
6872

6973
let mut attempt = 0u32;
@@ -106,6 +110,15 @@ impl LlmClient for GeminiClient {
106110
.json()
107111
.await
108112
.context("failed to parse Gemini response")?;
113+
114+
// Always log raw response when debug is enabled
115+
if debug_enabled() {
116+
tracing::warn!(
117+
"gemini raw response: {}",
118+
serde_json::to_string_pretty(&message).unwrap_or_default()
119+
);
120+
}
121+
109122
let content = message
110123
.candidates
111124
.into_iter()
@@ -114,6 +127,11 @@ impl LlmClient for GeminiClient {
114127
.next()
115128
.ok_or_else(|| anyhow!("Gemini response missing message content"))?;
116129

130+
// Log extracted content when debug is enabled
131+
if debug_enabled() {
132+
tracing::warn!("gemini extracted content: {}", content);
133+
}
134+
117135
let verdict: ModelVerdict = serde_json::from_str(&content)
118136
.context("expected JSON verdict from Gemini response")?;
119137

@@ -135,9 +153,22 @@ fn truncate(input: &str, max_chars: usize) -> String {
135153
input.chars().take(max_chars).collect::<String>() + "…"
136154
}
137155

156+
fn debug_enabled() -> bool {
157+
matches!(env::var("LLM_GUARD_DEBUG"), Ok(val) if !val.is_empty() && val != "0")
158+
}
159+
138160
#[derive(Serialize)]
139161
struct GeminiRequest {
140162
contents: Vec<GeminiRequestContent>,
163+
#[serde(skip_serializing_if = "Option::is_none")]
164+
#[serde(rename = "generationConfig")]
165+
generation_config: Option<GeminiGenerationConfig>,
166+
}
167+
168+
#[derive(Serialize)]
169+
struct GeminiGenerationConfig {
170+
#[serde(rename = "responseMimeType")]
171+
response_mime_type: String,
141172
}
142173

143174
#[derive(Serialize)]
@@ -152,22 +183,22 @@ struct GeminiRequestPart {
152183
text: Option<String>,
153184
}
154185

155-
#[derive(Deserialize)]
186+
#[derive(Deserialize, Serialize)]
156187
struct GeminiResponse {
157188
candidates: Vec<GeminiCandidate>,
158189
}
159190

160-
#[derive(Deserialize)]
191+
#[derive(Deserialize, Serialize)]
161192
struct GeminiCandidate {
162193
content: GeminiResponseContent,
163194
}
164195

165-
#[derive(Deserialize)]
196+
#[derive(Deserialize, Serialize)]
166197
struct GeminiResponseContent {
167198
parts: Vec<GeminiResponsePart>,
168199
}
169200

170-
#[derive(Deserialize)]
201+
#[derive(Deserialize, Serialize)]
171202
struct GeminiResponsePart {
172203
#[serde(default)]
173204
text: Option<String>,

crates/llm-guard-core/src/llm/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ pub fn build_client(settings: &LlmSettings) -> Result<Box<dyn LlmClient>> {
4242
let kind = ProviderKind::from_provider(settings.provider.trim())?;
4343
match kind {
4444
ProviderKind::Noop => Ok(Box::new(NoopLlmClient::default())),
45+
ProviderKind::Gemini => {
46+
// Use standalone Gemini client to avoid rig deserialization issues
47+
Ok(Box::new(GeminiClient::new(settings)?))
48+
}
4549
ProviderKind::Rig => {
4650
bail!("Select a specific rig-enabled provider (e.g. openai) in LLM_GUARD_PROVIDER")
4751
}

crates/llm-guard-core/src/llm/rig_adapter.rs

Lines changed: 21 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,13 @@ use rig::client::CompletionClient;
66
use rig::completion::message::AssistantContent;
77
use rig::completion::{CompletionError, CompletionModelDyn};
88
use rig::providers::azure::AzureOpenAIAuth;
9-
use rig::providers::{anthropic, azure, gemini, openai};
9+
use rig::providers::{anthropic, azure, openai};
1010
use serde::Deserialize;
1111
use serde_json::json;
1212
use std::env;
1313

1414
const DEFAULT_OPENAI_MODEL: &str = "gpt-4o-mini";
1515
const DEFAULT_ANTHROPIC_MODEL: &str = "claude-3-5-sonnet-latest";
16-
const DEFAULT_GEMINI_MODEL: &str = "gemini-1.5-pro";
1716
const MAX_OUTPUT_TOKENS: u64 = 200;
1817
const TEMPERATURE: f64 = 0.1;
1918
const SYSTEM_PROMPT: &str = "You are an application security assistant. Analyze prompt-injection scan results and respond with strict JSON: {\"label\": \"safe|suspicious|malicious\", \"rationale\": \"...\", \"mitigation\": \"...\"}. The mitigation should advise remediation steps.";
@@ -36,7 +35,9 @@ impl RigLlmClient {
3635
match kind {
3736
ProviderKind::OpenAi => Ok(Box::new(Self::new_openai(settings)?)),
3837
ProviderKind::Anthropic => Ok(Box::new(Self::new_anthropic(settings)?)),
39-
ProviderKind::Gemini => Ok(Box::new(Self::new_gemini(settings)?)),
38+
ProviderKind::Gemini => {
39+
bail!("Gemini provider should use standalone client, not rig adapter")
40+
}
4041
ProviderKind::Azure => Ok(Box::new(Self::new_azure(settings)?)),
4142
ProviderKind::Noop | ProviderKind::Rig => {
4243
bail!("rig adapter does not support provider `{kind:?}` yet")
@@ -100,30 +101,8 @@ impl RigLlmClient {
100101
))
101102
}
102103

103-
fn new_gemini(settings: &LlmSettings) -> Result<Self> {
104-
if settings.api_key.trim().is_empty() {
105-
bail!("Gemini API key must be provided via LLM_GUARD_API_KEY");
106-
}
107-
108-
let mut builder = gemini::Client::builder(&settings.api_key);
109-
if let Some(endpoint) = settings.endpoint.as_deref() {
110-
builder = builder.base_url(endpoint);
111-
}
112-
let client = builder
113-
.build()
114-
.context("failed to build gemini rig client")?;
115-
116-
let model_id = settings
117-
.model
118-
.clone()
119-
.filter(|m| !m.trim().is_empty())
120-
.unwrap_or_else(|| DEFAULT_GEMINI_MODEL.to_string());
121-
122-
let model: Box<dyn CompletionModelDyn + Send + Sync> =
123-
Box::new(client.completion_model(&model_id));
124-
125-
Ok(Self::from_model(model, "gemini", model_id, None, true))
126-
}
104+
// Note: Gemini support removed from rig adapter due to deserialization issues.
105+
// Gemini now uses a standalone HTTP client implementation (see gemini.rs).
127106

128107
fn new_azure(settings: &LlmSettings) -> Result<Self> {
129108
if settings.api_key.trim().is_empty() {
@@ -217,17 +196,16 @@ impl LlmClient for RigLlmClient {
217196
}
218197

219198
if self.config.provider_label == "openai" {
199+
// Use simple json_object format instead of json_schema for better compatibility
200+
// with reasoning models like gpt-5
220201
builder = builder.additional_params(json!({
221202
"response_format": {
222-
"type": "json_schema",
223-
"json_schema": {
224-
"name": "verdict",
225-
"strict": false,
226-
"schema": verdict_json_schema()
227-
}
203+
"type": "json_object"
228204
}
229205
}));
230206
}
207+
// Note: Gemini function calling removed due to rig compatibility issues
208+
// Gemini will rely on prompt instructions for JSON formatting
231209

232210
let request = builder.build();
233211

@@ -255,6 +233,9 @@ impl LlmClient for RigLlmClient {
255233

256234
let choice = response.choice;
257235

236+
// Always log raw response when debug is enabled
237+
debug_log_choice(self.config.provider_label, &choice);
238+
258239
let content = choice
259240
.clone()
260241
.into_iter()
@@ -270,8 +251,13 @@ impl LlmClient for RigLlmClient {
270251
.join("\n");
271252

272253
let trimmed = content.trim();
254+
255+
// Log extracted content when debug is enabled
256+
if debug_enabled() && !trimmed.is_empty() {
257+
tracing::warn!("rig {} extracted content: {}", self.config.provider_label, trimmed);
258+
}
259+
273260
if trimmed.is_empty() {
274-
debug_log_choice(self.config.provider_label, &choice);
275261
// Gemini/OpenAI callbacks sometimes omit textual content while returning metadata only.
276262
// In that case we produce a fallback verdict instead of treating it as an error.
277263
tracing::warn!(
@@ -282,11 +268,7 @@ impl LlmClient for RigLlmClient {
282268
}
283269

284270
let json_payload = extract_json_payload(trimmed);
285-
let verdict = parse_verdict_json(&json_payload, self.config.provider_label, &self.model_id)
286-
.or_else(|err| {
287-
debug_log_choice(self.config.provider_label, &choice);
288-
Err(err)
289-
})?;
271+
let verdict = parse_verdict_json(&json_payload, self.config.provider_label, &self.model_id)?;
290272

291273
Ok(LlmVerdict {
292274
label: verdict.label,
@@ -362,22 +344,6 @@ fn fallback_model_verdict(provider: &str) -> ModelVerdict {
362344
}
363345
}
364346

365-
fn verdict_json_schema() -> serde_json::Value {
366-
json!({
367-
"type": "object",
368-
"additionalProperties": false,
369-
"required": ["label", "rationale", "mitigation"],
370-
"properties": {
371-
"label": {
372-
"type": "string",
373-
"enum": ["safe", "suspicious", "malicious", "unknown"]
374-
},
375-
"rationale": { "type": "string" },
376-
"mitigation": { "type": "string" }
377-
}
378-
})
379-
}
380-
381347
fn sanitize_json_strings(payload: &str) -> String {
382348
let mut result = String::with_capacity(payload.len());
383349
let mut in_string = false;

rules/patterns.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,17 @@
1111
"description": "Requests to rewrite or ignore system instructions",
1212
"pattern": "(?i)pretend\\s+you\\s+are\\s+a\\s+developer",
1313
"weight": 30
14+
},
15+
{
16+
"id": "INSTR_IGNORE",
17+
"description": "Attempts to ignore or override previous instructions",
18+
"pattern": "(?i)ignore\\s+(?:your\\s+)?(?:previous|prior|all)\\s+(?:instructions?|prompts?|rules?)",
19+
"weight": 35
20+
},
21+
{
22+
"id": "PROMPT_LEAK",
23+
"description": "Attempts to reveal system prompts or hidden instructions",
24+
"pattern": "(?i)(?:dump|reveal|show|tell\\s+me|print)\\s+.*?(?:hidden|system|initial|original)?\\s*(?:prompt|instruction)s?",
25+
"weight": 40
1426
}
1527
]

0 commit comments

Comments
 (0)