potato-pzy commited on
Commit
02422e3
Β·
1 Parent(s): 0d599d9

feat: remove powered-by line and integrate sidecar with sentence-level streaming

Browse files
gemini_adapter.py CHANGED
@@ -1,25 +1,29 @@
1
  """
2
  gemini_adapter.py β€” Concrete LLMAdapter for Google Gemini.
3
 
4
- V2: Reads API key from GEMINI_API_KEY environment variable.
5
  """
6
 
7
  import os
 
 
8
  import google.generativeai as genai
9
- from typing import Optional
10
  from llm_adapter import LLMAdapter
11
 
12
 
13
  class GeminiAdapter(LLMAdapter):
14
  """
15
  Wraps Google Gemini API as an LLMAdapter.
 
 
16
  """
17
 
18
  def __init__(
19
  self,
20
- api_key: Optional[str] = None,
21
- model_name: str = "gemini-3.1-flash-lite",
22
- system_prompt: Optional[str] = None
23
  ):
24
  self.api_key = api_key or os.getenv("GEMINI_API_KEY")
25
  if not self.api_key:
@@ -28,27 +32,63 @@ class GeminiAdapter(LLMAdapter):
28
  "or pass api_key directly."
29
  )
30
 
31
- self.model_name = model_name
32
  self.system_prompt = system_prompt or "You are a helpful AI assistant."
33
 
34
  genai.configure(api_key=self.api_key)
35
- self.model = genai.GenerativeModel(
36
  model_name=self.model_name,
37
- system_instruction=self.system_prompt
38
  )
39
 
 
 
 
 
40
  def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
41
- # If a different system prompt is provided at runtime, we recreate the model
42
- # (Gemini system instructions are set at model instantiation)
43
- current_model = self.model
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  if system_prompt and system_prompt != self.system_prompt:
45
- current_model = genai.GenerativeModel(
46
  model_name=self.model_name,
47
- system_instruction=system_prompt
48
  )
49
-
50
- response = current_model.generate_content(prompt)
51
- return response.text
52
 
53
  def get_model_name(self) -> str:
54
  return self.model_name
 
1
  """
2
  gemini_adapter.py β€” Concrete LLMAdapter for Google Gemini.
3
 
4
+ V3 (Sidecar): adds stream_chat() for sentence-level streaming.
5
  """
6
 
7
  import os
8
+ from typing import AsyncGenerator, Optional
9
+
10
  import google.generativeai as genai
11
+
12
  from llm_adapter import LLMAdapter
13
 
14
 
15
  class GeminiAdapter(LLMAdapter):
16
  """
17
  Wraps Google Gemini API as an LLMAdapter.
18
+
19
+ Supports both blocking `chat()` and streaming `stream_chat()`.
20
  """
21
 
22
  def __init__(
23
  self,
24
+ api_key: Optional[str] = None,
25
+ model_name: str = "gemini-2.0-flash-lite",
26
+ system_prompt: Optional[str] = None,
27
  ):
28
  self.api_key = api_key or os.getenv("GEMINI_API_KEY")
29
  if not self.api_key:
 
32
  "or pass api_key directly."
33
  )
34
 
35
+ self.model_name = model_name
36
  self.system_prompt = system_prompt or "You are a helpful AI assistant."
37
 
38
  genai.configure(api_key=self.api_key)
39
+ self._model = genai.GenerativeModel(
40
  model_name=self.model_name,
41
+ system_instruction=self.system_prompt,
42
  )
43
 
44
+ # ------------------------------------------------------------------
45
+ # Blocking interface (existing β€” unchanged)
46
+ # ------------------------------------------------------------------
47
+
48
  def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
49
+ """Send prompt, return full response text (blocking)."""
50
+ model = self._get_model(system_prompt)
51
+ response = model.generate_content(prompt)
52
+ return response.text
53
+
54
+ # ------------------------------------------------------------------
55
+ # Streaming interface (NEW)
56
+ # ------------------------------------------------------------------
57
+
58
+ async def stream_chat(
59
+ self,
60
+ prompt: str,
61
+ system_prompt: Optional[str] = None,
62
+ ) -> AsyncGenerator[str, None]:
63
+ """
64
+ Yield token chunks from Gemini as they arrive.
65
+
66
+ This is a synchronous SDK call wrapped in an async generator β€”
67
+ Gemini's Python SDK streams synchronously, so we iterate the
68
+ response object directly and yield each text chunk.
69
+ """
70
+ model = self._get_model(system_prompt)
71
+
72
+ # generate_content with stream=True returns a synchronous iterator
73
+ response = model.generate_content(prompt, stream=True)
74
+
75
+ for chunk in response:
76
+ text = getattr(chunk, "text", None)
77
+ if text:
78
+ yield text
79
+
80
+ # ------------------------------------------------------------------
81
+ # Helpers
82
+ # ------------------------------------------------------------------
83
+
84
+ def _get_model(self, system_prompt: Optional[str]):
85
+ """Return model instance, recreating if system prompt differs."""
86
  if system_prompt and system_prompt != self.system_prompt:
87
+ return genai.GenerativeModel(
88
  model_name=self.model_name,
89
+ system_instruction=system_prompt,
90
  )
91
+ return self._model
 
 
92
 
93
  def get_model_name(self) -> str:
94
  return self.model_name
genai_app.py CHANGED
@@ -19,6 +19,7 @@ import os
19
  import json
20
  import time
21
  import queue
 
22
  from flask import Flask, request, jsonify, render_template, Response, stream_with_context
23
  from flask_cors import CORS
24
 
@@ -76,6 +77,16 @@ def broadcast(event_type: str, data: dict):
76
  def index():
77
  return render_template("genai.html", model=ADAPTER.get_model_name())
78
 
 
 
 
 
 
 
 
 
 
 
79
  @app.route("/genai-monitoring")
80
  def monitoring():
81
  return render_template("genai_monitoring.html", model=ADAPTER.get_model_name())
@@ -240,9 +251,35 @@ def guard_stats():
240
  return jsonify(PG_ENGINE.stats())
241
 
242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
  if __name__ == "__main__":
244
  port = int(os.getenv("GENAI_PORT", 5001))
245
  print(f"GenAI Shield V2 starting on port {port}")
246
  print(f"LLM Model: {ADAPTER.get_model_name()}")
247
  print(f"Guard: Llama-Prompt-Guard-2-86M")
248
- app.run(host="0.0.0.0", port=port, debug=True)
 
 
 
 
 
 
19
  import json
20
  import time
21
  import queue
22
+ import threading
23
  from flask import Flask, request, jsonify, render_template, Response, stream_with_context
24
  from flask_cors import CORS
25
 
 
77
  def index():
78
  return render_template("genai.html", model=ADAPTER.get_model_name())
79
 
80
+ @app.route("/sidecar")
81
+ def sidecar_ui():
82
+ """Sidecar streaming chat UI."""
83
+ return render_template("sidecar.html")
84
+
85
+ @app.route("/dataflow")
86
+ def dataflow_ui():
87
+ """Real-time data flow visualization dashboard."""
88
+ return render_template("dataflow.html")
89
+
90
  @app.route("/genai-monitoring")
91
  def monitoring():
92
  return render_template("genai_monitoring.html", model=ADAPTER.get_model_name())
 
251
  return jsonify(PG_ENGINE.stats())
252
 
253
 
254
+ def _start_sidecar_subprocess():
255
+ """
256
+ Optionally launch the sidecar as a sub-process so both UIs run together.
257
+ Controlled via LAUNCH_SIDECAR=true env var.
258
+ """
259
+ import subprocess, sys
260
+ sidecar_port = int(os.getenv("SIDECAR_PORT", 5050))
261
+ print(f"[GenAI Shield] Launching sidecar on :{sidecar_port}...")
262
+ proc = subprocess.Popen(
263
+ [sys.executable, "-m", "uvicorn", "sidecar.app:app",
264
+ "--host", "0.0.0.0", "--port", str(sidecar_port),
265
+ "--log-level", "warning"],
266
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
267
+ )
268
+ def _pipe():
269
+ for line in proc.stdout:
270
+ print("[sidecar]", line.decode(errors='replace').rstrip())
271
+ threading.Thread(target=_pipe, daemon=True).start()
272
+ return proc
273
+
274
+
275
  if __name__ == "__main__":
276
  port = int(os.getenv("GENAI_PORT", 5001))
277
  print(f"GenAI Shield V2 starting on port {port}")
278
  print(f"LLM Model: {ADAPTER.get_model_name()}")
279
  print(f"Guard: Llama-Prompt-Guard-2-86M")
280
+ print(f"Sidecar UI: http://localhost:{port}/sidecar")
281
+
282
+ if os.getenv("LAUNCH_SIDECAR", "").lower() in ("1", "true", "yes"):
283
+ _start_sidecar_subprocess()
284
+
285
+ app.run(host="0.0.0.0", port=port, debug=False)
llm_adapter.py CHANGED
@@ -1,40 +1,47 @@
1
  """
2
- llm_adapter.py β€” Abstract base class for LLM adapters.
3
 
4
- Any LLM (OpenAI, OpenRouter, Ollama, Anthropic, local) can be plugged into
5
- the TextMonitor by subclassing LLMAdapter and implementing its two methods.
6
  """
7
 
8
  from abc import ABC, abstractmethod
9
- from typing import Optional
10
 
11
 
12
  class LLMAdapter(ABC):
13
  """
14
- Abstract interface that TextMonitor depends on.
15
 
16
- Subclass this for any LLM you want to monitor.
 
 
 
 
 
 
 
 
 
17
  """
18
 
19
  @abstractmethod
20
- def chat(
 
 
 
 
 
21
  self,
22
- prompt: str,
23
  system_prompt: Optional[str] = None,
24
- ) -> str:
25
- """
26
- Send a prompt to the LLM and return the response text.
27
-
28
- Args:
29
- prompt: The user's message.
30
- system_prompt: Optional system prompt to prepend.
31
-
32
- Returns:
33
- The model's response as a plain string.
34
- """
35
  ...
 
 
 
36
 
37
  @abstractmethod
38
  def get_model_name(self) -> str:
39
- """Return a human-readable name for the model being used."""
40
  ...
 
1
  """
2
+ llm_adapter.py β€” Abstract base class for all LLM adapters.
3
 
4
+ V3 (Sidecar): adds stream_chat() abstract method.
 
5
  """
6
 
7
  from abc import ABC, abstractmethod
8
+ from typing import AsyncGenerator, Optional
9
 
10
 
11
  class LLMAdapter(ABC):
12
  """
13
+ Minimal interface that all LLM adapters must implement.
14
 
15
+ Methods
16
+ -------
17
+ chat(prompt, system_prompt) -> str
18
+ Blocking single-turn call. Returns full response text.
19
+
20
+ stream_chat(prompt, system_prompt) -> AsyncGenerator[str, None]
21
+ Async streaming call. Yields token chunks as they arrive.
22
+
23
+ get_model_name() -> str
24
+ Returns the model identifier string.
25
  """
26
 
27
  @abstractmethod
28
+ def chat(self, prompt: str, system_prompt: Optional[str] = None) -> str:
29
+ """Blocking call β€” return full response as a string."""
30
+ ...
31
+
32
+ @abstractmethod
33
+ async def stream_chat(
34
  self,
35
+ prompt: str,
36
  system_prompt: Optional[str] = None,
37
+ ) -> AsyncGenerator[str, None]:
38
+ """Async generator β€” yield token chunks as they arrive."""
 
 
 
 
 
 
 
 
 
39
  ...
40
+ # Make this an async generator (required by ABC)
41
+ # Concrete classes must use `yield` in their implementation
42
+ yield # type: ignore[misc]
43
 
44
  @abstractmethod
45
  def get_model_name(self) -> str:
46
+ """Return the model identifier (e.g. 'gemini-2.0-flash-lite')."""
47
  ...
openai_adapter.py CHANGED
@@ -1,16 +1,19 @@
1
  """
2
  openai_adapter.py β€” Concrete LLMAdapter for OpenAI / OpenRouter / Ollama.
3
 
4
- Works with any OpenAI-compatible API. Configure via environment variables:
5
 
 
6
  OPENAI_API_KEY=sk-...
7
- OPENAI_BASE_URL=https://openrouter.ai/api/v1 (or https://api.openai.com/v1)
8
- OPENAI_MODEL=openai/gpt-4o-mini (or gpt-4o, llama3, etc.)
9
  """
10
 
11
  import os
12
- from typing import Optional
13
- from openai import OpenAI
 
 
14
  from llm_adapter import LLMAdapter
15
 
16
 
@@ -20,10 +23,8 @@ class OpenAIAdapter(LLMAdapter):
20
 
21
  Args:
22
  api_key: API key. Defaults to OPENAI_API_KEY env var.
23
- base_url: API base URL. Defaults to OPENAI_BASE_URL env var,
24
- or https://api.openai.com/v1 if not set.
25
- model: Model name. Defaults to OPENAI_MODEL env var,
26
- or gpt-4o-mini if not set.
27
  system_prompt: Default system prompt for all calls.
28
  temperature: Sampling temperature (0 = deterministic).
29
  max_tokens: Max tokens in response.
@@ -38,8 +39,8 @@ class OpenAIAdapter(LLMAdapter):
38
  base_url: Optional[str] = None,
39
  model: Optional[str] = None,
40
  system_prompt: Optional[str] = None,
41
- temperature: float = 0.7,
42
- max_tokens: int = 1024,
43
  ):
44
  self._api_key = api_key or os.getenv("OPENAI_API_KEY", "")
45
  self._base_url = base_url or os.getenv("OPENAI_BASE_URL", self.DEFAULT_BASE_URL)
@@ -51,13 +52,14 @@ class OpenAIAdapter(LLMAdapter):
51
  self._temperature = temperature
52
  self._max_tokens = max_tokens
53
 
54
- self._client = OpenAI(
55
- api_key = self._api_key,
56
- base_url = self._base_url,
57
- )
 
58
 
59
  # ------------------------------------------------------------------
60
- # LLMAdapter interface
61
  # ------------------------------------------------------------------
62
 
63
  def chat(
@@ -65,19 +67,48 @@ class OpenAIAdapter(LLMAdapter):
65
  prompt: str,
66
  system_prompt: Optional[str] = None,
67
  ) -> str:
68
- """Send prompt to LLM and return response text."""
69
- sys_msg = system_prompt or self._system_prompt
70
-
71
  response = self._client.chat.completions.create(
72
- model = self._model,
73
- messages = [
74
- {"role": "system", "content": sys_msg},
75
- {"role": "user", "content": prompt},
76
  ],
77
  temperature = self._temperature,
78
  max_tokens = self._max_tokens,
79
  )
80
  return response.choices[0].message.content or ""
81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  def get_model_name(self) -> str:
83
  return self._model
 
1
  """
2
  openai_adapter.py β€” Concrete LLMAdapter for OpenAI / OpenRouter / Ollama.
3
 
4
+ V3 (Sidecar): adds stream_chat() for sentence-level streaming.
5
 
6
+ Works with any OpenAI-compatible API. Configure via environment variables:
7
  OPENAI_API_KEY=sk-...
8
+ OPENAI_BASE_URL=https://openrouter.ai/api/v1
9
+ OPENAI_MODEL=gpt-4o-mini
10
  """
11
 
12
  import os
13
+ from typing import AsyncGenerator, Optional
14
+
15
+ from openai import AsyncOpenAI, OpenAI
16
+
17
  from llm_adapter import LLMAdapter
18
 
19
 
 
23
 
24
  Args:
25
  api_key: API key. Defaults to OPENAI_API_KEY env var.
26
+ base_url: API base URL. Defaults to OPENAI_BASE_URL env var.
27
+ model: Model name. Defaults to OPENAI_MODEL env var.
 
 
28
  system_prompt: Default system prompt for all calls.
29
  temperature: Sampling temperature (0 = deterministic).
30
  max_tokens: Max tokens in response.
 
39
  base_url: Optional[str] = None,
40
  model: Optional[str] = None,
41
  system_prompt: Optional[str] = None,
42
+ temperature: float = 0.7,
43
+ max_tokens: int = 1024,
44
  ):
45
  self._api_key = api_key or os.getenv("OPENAI_API_KEY", "")
46
  self._base_url = base_url or os.getenv("OPENAI_BASE_URL", self.DEFAULT_BASE_URL)
 
52
  self._temperature = temperature
53
  self._max_tokens = max_tokens
54
 
55
+ # Synchronous client (existing blocking interface)
56
+ self._client = OpenAI(api_key=self._api_key, base_url=self._base_url)
57
+
58
+ # Async client (streaming interface)
59
+ self._async_client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)
60
 
61
  # ------------------------------------------------------------------
62
+ # Blocking interface (existing β€” unchanged)
63
  # ------------------------------------------------------------------
64
 
65
  def chat(
 
67
  prompt: str,
68
  system_prompt: Optional[str] = None,
69
  ) -> str:
70
+ """Send prompt to LLM and return response text (blocking)."""
71
+ sys_msg = system_prompt or self._system_prompt
 
72
  response = self._client.chat.completions.create(
73
+ model = self._model,
74
+ messages = [
75
+ {"role": "system", "content": sys_msg},
76
+ {"role": "user", "content": prompt},
77
  ],
78
  temperature = self._temperature,
79
  max_tokens = self._max_tokens,
80
  )
81
  return response.choices[0].message.content or ""
82
 
83
+ # ------------------------------------------------------------------
84
+ # Streaming interface (NEW)
85
+ # ------------------------------------------------------------------
86
+
87
+ async def stream_chat(
88
+ self,
89
+ prompt: str,
90
+ system_prompt: Optional[str] = None,
91
+ ) -> AsyncGenerator[str, None]:
92
+ """
93
+ Yield token chunks from OpenAI as they arrive (true async streaming).
94
+ """
95
+ sys_msg = system_prompt or self._system_prompt
96
+
97
+ stream = await self._async_client.chat.completions.create(
98
+ model = self._model,
99
+ messages = [
100
+ {"role": "system", "content": sys_msg},
101
+ {"role": "user", "content": prompt},
102
+ ],
103
+ temperature = self._temperature,
104
+ max_tokens = self._max_tokens,
105
+ stream = True,
106
+ )
107
+
108
+ async for chunk in stream:
109
+ delta = chunk.choices[0].delta
110
+ if delta and delta.content:
111
+ yield delta.content
112
+
113
  def get_model_name(self) -> str:
114
  return self._model
requirements.txt CHANGED
@@ -5,3 +5,7 @@ transformers
5
  google-generativeai
6
  openai
7
  huggingface-hub
 
 
 
 
 
5
  google-generativeai
6
  openai
7
  huggingface-hub
8
+ fastapi>=0.111.0
9
+ uvicorn[standard]>=0.29.0
10
+ httpx>=0.27.0
11
+ python-multipart
sidecar/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # GenAI Shield V2 β€” Sidecar Package
sidecar/app.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sidecar/app.py β€” GenAI Shield V2 Sidecar Proxy (FastAPI).
3
+
4
+ A language-agnostic sidecar that sits in front of any LLM API and provides:
5
+ β€’ Pre-inference guard (Prompt Guard model + regex) β€” runs in parallel with LLM
6
+ β€’ Sentence-level streaming β€” users see output word-by-word
7
+ β€’ Post-inference monitoring β€” each sentence checked concurrently in background
8
+ β€’ Block signal mid-stream β€” if output turns harmful, client is notified instantly
9
+
10
+ Endpoints
11
+ ---------
12
+ POST /v1/chat β†’ streaming or blocking chat with full shield
13
+ GET /v1/health β†’ liveness probe
14
+ GET /v1/stats β†’ guard model statistics
15
+ GET /v1/metrics β†’ last-request latency breakdown
16
+
17
+ SSE Event Schema (stream=true)
18
+ -------------------------------
19
+ { "type": "chunk", "text": "..." }
20
+ { "type": "sentence", "text": "...", "sentence_id": 1 }
21
+ { "type": "block_signal", "sentence_id": 3, "reason": "...", "threat_score": 85, "flags": [...] }
22
+ { "type": "done", "threat_score": 5, "flags": [], "latency_ms": 420,
23
+ "guard_ms": 98, "sentences": 4 }
24
+ { "type": "blocked", "reason": "...", "threat_score": 100, "flags": [...],
25
+ "pg_score": 0.97, "guard_ms": 102 }
26
+
27
+ Configure via environment variables (see sidecar/config.py).
28
+ """
29
+
30
+ import json
31
+ import logging
32
+ import os
33
+ import sys
34
+ import time
35
+ from pathlib import Path
36
+ from typing import AsyncGenerator, Optional
37
+
38
+ import uvicorn
39
+ from fastapi import FastAPI, HTTPException, Request
40
+ from fastapi.middleware.cors import CORSMiddleware
41
+ from fastapi.responses import FileResponse, StreamingResponse
42
+ from fastapi.staticfiles import StaticFiles
43
+ from pydantic import BaseModel
44
+
45
+ # ── Path fix so imports work from project root ────────────────────────────────
46
+ _ROOT = Path(__file__).parent.parent
47
+ if str(_ROOT) not in sys.path:
48
+ sys.path.insert(0, str(_ROOT))
49
+
50
+ from sidecar.config import (
51
+ GATE_GUARD_TIMEOUT_SEC,
52
+ GEMINI_API_KEY,
53
+ GEMINI_MODEL,
54
+ LLM_BACKEND,
55
+ LOG_LEVEL,
56
+ MONITOR_BLOCK_THRESHOLD,
57
+ MONITOR_WORKERS,
58
+ OPENAI_API_KEY,
59
+ OPENAI_BASE_URL,
60
+ OPENAI_MODEL,
61
+ PROMPT_GUARD_MODEL_DIR,
62
+ SENTENCE_MIN_CHARS,
63
+ SIDECAR_HOST,
64
+ SIDECAR_PORT,
65
+ SYSTEM_PROMPT,
66
+ )
67
+ from sidecar.gate import BlockEvent, ShieldGate, TokenEvent
68
+ from sidecar.sentence_splitter import SentenceEvent, SentenceSplitter
69
+ from sidecar.stream_monitor import BlockSignal, StreamMonitor
70
+ from sidecar.pipeline_events import RequestTrace, subscribe, unsubscribe
71
+
72
+ # Existing shield modules
73
+ from prompt_guard_engine import PromptGuardEngine
74
+ from prompt_guard_text_guard import PromptGuardTextGuard
75
+ from text_monitor import TextMonitor
76
+
77
+ # ── Logging ───────────────────────────────────────────────────────────────────
78
+ logging.basicConfig(
79
+ level = getattr(logging, LOG_LEVEL.upper(), logging.INFO),
80
+ format = "[%(asctime)s] %(levelname)-8s %(name)s β€” %(message)s",
81
+ datefmt = "%H:%M:%S",
82
+ )
83
+ log = logging.getLogger("sidecar")
84
+
85
+ # ── FastAPI app ───────────────────────────────────────────────────────────────
86
+ app = FastAPI(
87
+ title = "GenAI Shield Sidecar",
88
+ description = "Transparent LLM proxy with pre/post-inference security screening",
89
+ version = "2.0.0",
90
+ )
91
+ app.add_middleware(
92
+ CORSMiddleware,
93
+ allow_origins = ["*"],
94
+ allow_methods = ["*"],
95
+ allow_headers = ["*"],
96
+ )
97
+
98
+ # Serve static files if the sidecar runs standalone
99
+ _STATIC_DIR = _ROOT / "static"
100
+ _TEMPLATES_DIR = _ROOT / "templates"
101
+ if _STATIC_DIR.exists():
102
+ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
103
+
104
+ # ── Initialise shield components ──────────────────────────────────────────────
105
+ log.info("Loading Prompt Guard engine...")
106
+ _PG_ENGINE = PromptGuardEngine(model_path=Path(PROMPT_GUARD_MODEL_DIR)).load()
107
+ _GUARD = PromptGuardTextGuard(_PG_ENGINE)
108
+ log.info("Prompt Guard ready.")
109
+
110
+ # ── Initialise LLM adapter ────────────────────────────────────────────────────
111
+ if LLM_BACKEND == "openai":
112
+ from openai_adapter import OpenAIAdapter
113
+ _ADAPTER = OpenAIAdapter(
114
+ api_key = OPENAI_API_KEY,
115
+ base_url = OPENAI_BASE_URL,
116
+ model = OPENAI_MODEL,
117
+ system_prompt = SYSTEM_PROMPT,
118
+ )
119
+ else:
120
+ from gemini_adapter import GeminiAdapter
121
+ _ADAPTER = GeminiAdapter(
122
+ api_key = GEMINI_API_KEY,
123
+ model_name = GEMINI_MODEL,
124
+ system_prompt = SYSTEM_PROMPT,
125
+ )
126
+
127
+ log.info("LLM adapter: %s (%s)", LLM_BACKEND, _ADAPTER.get_model_name())
128
+
129
+ # ── Shared monitor (stateful β€” tracks behavioural drift across requests) ──────
130
+ _TEXT_MONITOR = TextMonitor(_ADAPTER, system_prompt=SYSTEM_PROMPT)
131
+ _STREAM_MONITOR = StreamMonitor(_TEXT_MONITOR, block_threshold=MONITOR_BLOCK_THRESHOLD, max_workers=MONITOR_WORKERS)
132
+
133
+ # ── Last-request metrics (lightweight, single-threaded access via asyncio) ────
134
+ _LAST_METRICS: dict = {}
135
+
136
+
137
+ # ── Request schema ────────────────────────────────────────────────────────────
138
+
139
+ class ChatRequest(BaseModel):
140
+ prompt: str
141
+ stream: bool = True
142
+ system_prompt: Optional[str] = None
143
+ source: Optional[str] = "sidecar"
144
+
145
+
146
+ # ── Routes ────────────────────────────────────────────────────────────────────
147
+
148
+ @app.get("/")
149
+ async def root():
150
+ """Serve the sidecar streaming UI (standalone mode)."""
151
+ ui_file = _TEMPLATES_DIR / "sidecar.html"
152
+ if ui_file.exists():
153
+ return FileResponse(str(ui_file), media_type="text/html")
154
+ return {"message": "GenAI Shield Sidecar", "docs": "/docs"}
155
+
156
+
157
+ @app.get("/dataflow")
158
+ async def dataflow_ui():
159
+ """Serve the real-time data flow visualization dashboard."""
160
+ ui_file = _TEMPLATES_DIR / "dataflow.html"
161
+ if ui_file.exists():
162
+ return FileResponse(str(ui_file), media_type="text/html")
163
+ return {"message": "dataflow.html not found"}
164
+
165
+
166
+ @app.get("/v1/pipeline-stream")
167
+ async def pipeline_stream():
168
+ """
169
+ SSE stream of structured pipeline telemetry events.
170
+ The data flow dashboard subscribes here to get real-time stage data.
171
+ """
172
+ async def _gen():
173
+ import asyncio
174
+ q = subscribe()
175
+ try:
176
+ while True:
177
+ try:
178
+ # Poll queue with a short timeout so we can yield keepalives
179
+ payload = q.get_nowait()
180
+ yield f"data: {json.dumps(payload)}\n\n"
181
+ except Exception:
182
+ # No event β€” send keepalive comment
183
+ yield ": keepalive\n\n"
184
+ await asyncio.sleep(0.5)
185
+ except asyncio.CancelledError:
186
+ pass
187
+ finally:
188
+ unsubscribe(q)
189
+
190
+ return StreamingResponse(
191
+ _gen(),
192
+ media_type = "text/event-stream",
193
+ headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
194
+ )
195
+
196
+
197
+ @app.get("/v1/health")
198
+ async def health():
199
+ return {
200
+ "status": "ok",
201
+ "guard_ready": _PG_ENGINE.ready,
202
+ "model": _ADAPTER.get_model_name(),
203
+ "backend": LLM_BACKEND,
204
+ }
205
+
206
+
207
+ @app.get("/v1/stats")
208
+ async def stats():
209
+ return _PG_ENGINE.stats()
210
+
211
+
212
+ @app.get("/v1/metrics")
213
+ async def metrics():
214
+ return _LAST_METRICS or {"message": "No requests processed yet"}
215
+
216
+
217
+ @app.post("/v1/chat")
218
+ async def chat(req: ChatRequest):
219
+ """
220
+ Main chat endpoint.
221
+
222
+ - stream=true β†’ Server-Sent Events (SSE) with sentence-level output
223
+ - stream=false β†’ Blocking JSON response (legacy-compatible)
224
+ """
225
+ if not req.prompt.strip():
226
+ raise HTTPException(status_code=400, detail="Empty prompt")
227
+
228
+ sys_prompt = req.system_prompt or SYSTEM_PROMPT
229
+
230
+ if req.stream:
231
+ return StreamingResponse(
232
+ _stream_handler(req.prompt, sys_prompt, req.source or "sidecar"),
233
+ media_type = "text/event-stream",
234
+ headers = {
235
+ "Cache-Control": "no-cache",
236
+ "X-Accel-Buffering": "no", # disable nginx buffering
237
+ },
238
+ )
239
+ else:
240
+ return await _blocking_handler(req.prompt, sys_prompt, req.source or "sidecar")
241
+
242
+
243
+ # ── Streaming handler ─────────────────────────────────────────────────────────
244
+
245
+ async def _stream_handler(
246
+ prompt: str,
247
+ sys_prompt: str,
248
+ source: str,
249
+ ) -> AsyncGenerator[str, None]:
250
+ """
251
+ Full streaming pipeline:
252
+ Gate (guard βˆ₯ LLM) β†’ SentenceSplitter β†’ StreamMonitor (background)
253
+ """
254
+ t_total = time.perf_counter()
255
+ gate = ShieldGate(_GUARD, _ADAPTER, guard_timeout_sec=GATE_GUARD_TIMEOUT_SEC)
256
+ splitter = SentenceSplitter(min_chars=SENTENCE_MIN_CHARS)
257
+ _STREAM_MONITOR.reset()
258
+
259
+ # ── Telemetry trace for this request ──────────────────────────────────
260
+ trace = RequestTrace()
261
+ trace.on_request_in(prompt)
262
+
263
+ guard_ms_ref = 0.0
264
+ all_flags: list = []
265
+ threat_score = 0
266
+ block_fired = False
267
+ sentences_sent = 0
268
+ total_tokens = 0
269
+
270
+ def sse(event_dict: dict) -> str:
271
+ """Format a dict as an SSE data line."""
272
+ return f"data: {json.dumps(event_dict)}\n\n"
273
+
274
+ async for gate_event in gate.run(prompt, sys_prompt, trace=trace):
275
+
276
+ # ── Guard blocked the prompt ───────────────────────────────────────
277
+ if isinstance(gate_event, BlockEvent):
278
+ all_flags = gate_event.flags
279
+ threat_score = gate_event.threat_score
280
+ guard_ms_ref = gate_event.guard_ms
281
+
282
+ yield sse({
283
+ "type": "blocked",
284
+ "reason": gate_event.reason,
285
+ "threat_score": gate_event.threat_score,
286
+ "flags": gate_event.flags,
287
+ "pg_score": gate_event.pg_score,
288
+ "guard_ms": gate_event.guard_ms,
289
+ })
290
+ block_fired = True
291
+ break
292
+
293
+ # ── LLM token received ─────────────────────────────────────────────
294
+ if isinstance(gate_event, TokenEvent):
295
+ total_tokens += 1
296
+ splitter_events = splitter.feed(gate_event.text)
297
+
298
+ for ev in splitter_events:
299
+ if isinstance(ev, type(ev)) and ev.type == "chunk":
300
+ yield sse({"type": "chunk", "text": ev.text})
301
+
302
+ elif ev.type == "sentence":
303
+ sentences_sent += 1
304
+ trace.on_sentence_ready(ev.sentence_id, ev.text)
305
+ yield sse({
306
+ "type": "sentence",
307
+ "text": ev.text,
308
+ "sentence_id": ev.sentence_id,
309
+ })
310
+ # Submit to background monitor (non-blocking)
311
+ trace.on_monitor_start(ev.sentence_id)
312
+ await _STREAM_MONITOR.submit(ev.sentence_id, ev.text, prompt)
313
+
314
+ if block_fired:
315
+ total_ms = round((time.perf_counter() - t_total) * 1000, 2)
316
+ trace.on_request_done(threat_score, all_flags, blocked=True)
317
+ _update_metrics(threat_score, all_flags, guard_ms_ref, 0, 0, total_ms)
318
+ return
319
+
320
+ # ── Stream ended β€” flush remaining buffer ──────────────────────────────
321
+ for ev in splitter.flush():
322
+ sentences_sent += 1
323
+ trace.on_sentence_ready(ev.sentence_id, ev.text)
324
+ yield sse({"type": "sentence", "text": ev.text, "sentence_id": ev.sentence_id})
325
+ trace.on_monitor_start(ev.sentence_id)
326
+ await _STREAM_MONITOR.submit(ev.sentence_id, ev.text, prompt)
327
+
328
+ trace.on_stream_done(total_tokens, sentences_sent)
329
+
330
+ # ── Collect background monitor results ─────────────────────────────────
331
+ signals = await _STREAM_MONITOR.collect(timeout=1.5)
332
+
333
+ for sig in signals:
334
+ threat_score = max(threat_score, sig.threat_score)
335
+ all_flags.extend(sig.flags)
336
+ trace.on_monitor_result(sig.sentence_id, sig.threat_score, sig.flags, blocked=True)
337
+ yield sse({
338
+ "type": "block_signal",
339
+ "sentence_id": sig.sentence_id,
340
+ "reason": sig.reason,
341
+ "threat_score": sig.threat_score,
342
+ "flags": sig.flags,
343
+ })
344
+
345
+ total_ms = round((time.perf_counter() - t_total) * 1000, 2)
346
+ trace.on_request_done(threat_score, list(set(all_flags)), blocked=False)
347
+
348
+ yield sse({
349
+ "type": "done",
350
+ "threat_score": threat_score,
351
+ "flags": list(set(all_flags)),
352
+ "latency_ms": total_ms,
353
+ "sentences": sentences_sent,
354
+ })
355
+
356
+ _update_metrics(threat_score, all_flags, 0, 0, total_ms, total_ms)
357
+
358
+
359
+ # ── Blocking handler (non-streaming, backward-compatible) ─────────────────────
360
+
361
+ async def _blocking_handler(prompt: str, sys_prompt: str, source: str) -> dict:
362
+ """
363
+ Non-streaming path β€” guard first, then full LLM call, then monitor.
364
+ Compatible with existing /genai-chat behaviour.
365
+ """
366
+ import asyncio
367
+
368
+ t_start = time.perf_counter()
369
+
370
+ # Guard (in thread β€” synchronous)
371
+ loop = asyncio.get_event_loop()
372
+ guard_result = await loop.run_in_executor(None, _GUARD.screen, prompt)
373
+ guard_ms = round((time.perf_counter() - t_start) * 1000, 2)
374
+
375
+ pg_score = guard_result.get("checks", {}).get("prompt_guard", {}).get("malicious_score", 0.0)
376
+
377
+ if guard_result["blocked"]:
378
+ return {
379
+ "blocked": True,
380
+ "response": None,
381
+ "reason": guard_result["reason"],
382
+ "threat_score": guard_result["threat_score"],
383
+ "flags": guard_result["flags"],
384
+ "pg_score": pg_score,
385
+ "latency_breakdown": {"guard_ms": guard_ms, "model_ms": 0, "monitor_ms": 0},
386
+ }
387
+
388
+ # LLM call (blocking adapter)
389
+ t_model = time.perf_counter()
390
+ response = await loop.run_in_executor(None, _ADAPTER.chat, prompt, sys_prompt)
391
+ model_ms = round((time.perf_counter() - t_model) * 1000, 2)
392
+
393
+ # Post-monitor
394
+ t_monitor = time.perf_counter()
395
+ mon_result = await loop.run_in_executor(None, _TEXT_MONITOR.analyze, prompt, response)
396
+ monitor_ms = round((time.perf_counter() - t_monitor) * 1000, 2)
397
+
398
+ total_ms = round(guard_ms + model_ms + monitor_ms, 2)
399
+ threat_score = max(guard_result["threat_score"], mon_result["threat_score"])
400
+ all_flags = guard_result["flags"] + mon_result["flags"]
401
+
402
+ _update_metrics(threat_score, all_flags, guard_ms, model_ms, monitor_ms, total_ms)
403
+
404
+ return {
405
+ "blocked": False,
406
+ "response": response,
407
+ "threat_score": threat_score,
408
+ "flags": all_flags,
409
+ "pg_score": pg_score,
410
+ "latency_ms": total_ms,
411
+ "model": _ADAPTER.get_model_name(),
412
+ "latency_breakdown": {
413
+ "guard_ms": guard_ms,
414
+ "model_ms": model_ms,
415
+ "monitor_ms": monitor_ms,
416
+ },
417
+ }
418
+
419
+
420
+ # ── Metrics helper ─────────────────────────────────────────────────────────────
421
+
422
+ def _update_metrics(threat_score, flags, guard_ms, model_ms, monitor_ms, total_ms):
423
+ global _LAST_METRICS
424
+ _LAST_METRICS = {
425
+ "threat_score": threat_score,
426
+ "flags": flags,
427
+ "guard_ms": guard_ms,
428
+ "model_ms": model_ms,
429
+ "monitor_ms": monitor_ms,
430
+ "total_ms": total_ms,
431
+ "model": _ADAPTER.get_model_name(),
432
+ "timestamp": time.strftime("%H:%M:%S"),
433
+ }
434
+
435
+
436
+ # ── Entry point ───────────────────────────────────────────────────────────────
437
+
438
+ if __name__ == "__main__":
439
+ log.info("Starting GenAI Shield Sidecar on %s:%d", SIDECAR_HOST, SIDECAR_PORT)
440
+ uvicorn.run(
441
+ "sidecar.app:app",
442
+ host = SIDECAR_HOST,
443
+ port = SIDECAR_PORT,
444
+ log_level = LOG_LEVEL.lower(),
445
+ reload = False,
446
+ )
sidecar/config.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sidecar/config.py β€” Centralised configuration for the GenAI Shield Sidecar.
3
+
4
+ All runtime behaviour is controlled via environment variables.
5
+ Defaults are safe for local development.
6
+ """
7
+
8
+ import os
9
+
10
+ # ── Sidecar server ────────────────────────────────────────────────────────────
11
+ SIDECAR_HOST = os.getenv("SIDECAR_HOST", "0.0.0.0")
12
+ SIDECAR_PORT = int(os.getenv("SIDECAR_PORT", "5050"))
13
+
14
+ # ── LLM backend ───────────────────────────────────────────────────────────────
15
+ LLM_BACKEND = os.getenv("LLM_BACKEND", "gemini") # "gemini" | "openai"
16
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
17
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.0-flash-lite")
18
+
19
+ OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
20
+ OPENAI_BASE_URL = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
21
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
22
+
23
+ # ── System prompt ─────────────────────────────────────────────────────────────
24
+ SYSTEM_PROMPT = os.getenv(
25
+ "GENAI_SYSTEM_PROMPT",
26
+ "You are a helpful AI assistant. Be concise, accurate, and professional.",
27
+ )
28
+
29
+ # ── Shield thresholds ─────────────────────────────────────────────────────────
30
+ GUARD_BLOCK_THRESHOLD = float(os.getenv("GUARD_BLOCK_THRESHOLD", "0.85"))
31
+ GUARD_FLAG_THRESHOLD = float(os.getenv("GUARD_FLAG_THRESHOLD", "0.50"))
32
+ MONITOR_BLOCK_THRESHOLD = int(os.getenv("MONITOR_BLOCK_THRESHOLD", "40"))
33
+
34
+ # ── Gate behaviour ────────────────────────────────────────────────────────────
35
+ # Max ms to wait for guard verdict before letting buffered tokens through anyway
36
+ # (safety valve β€” guard should always be <300ms in practice)
37
+ GATE_GUARD_TIMEOUT_SEC = float(os.getenv("GATE_GUARD_TIMEOUT_SEC", "3.0"))
38
+
39
+ # ── Post-monitor thread pool ──────────────────────────────────────────────────
40
+ MONITOR_WORKERS = int(os.getenv("MONITOR_WORKERS", "4"))
41
+
42
+ # ── Sentence splitter ─────────────────────────────────────────────────────────
43
+ # Minimum characters before a sentence boundary is declared
44
+ SENTENCE_MIN_CHARS = int(os.getenv("SENTENCE_MIN_CHARS", "20"))
45
+
46
+ # ── Model path ────────────────────────────────────────────────────────────────
47
+ PROMPT_GUARD_MODEL_DIR = os.getenv(
48
+ "PROMPT_GUARD_MODEL_DIR",
49
+ "models/Llama-Prompt-Guard-2-86M",
50
+ )
51
+
52
+ # ── Logging ───────────────────────────────────────────────────────────────────
53
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
sidecar/gate.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sidecar/gate.py β€” Concurrent Guard + LLM Fan-Out Gate.
3
+
4
+ The Gate is the heart of the sidecar. It starts the Prompt Guard check
5
+ and the LLM stream simultaneously, then:
6
+
7
+ β€’ If Guard BLOCKS β†’ cancel the LLM stream, yield a BlockEvent, stop.
8
+ β€’ If Guard PASSES β†’ open the gate; yield all buffered + live tokens.
9
+
10
+ This means the guard's latency (~50–150ms) is hidden inside the LLM's
11
+ time-to-first-token (~300–600ms). On the happy path the user effectively
12
+ sees ZERO added latency from the guard.
13
+
14
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
15
+ Client ──prompt──►│ Gate.run() β”‚
16
+ β”‚ β”‚
17
+ β”‚ Task A: guard.screen(prompt) β”‚ ~100ms
18
+ β”‚ Task B: adapter.stream_chat() β”‚ ~500ms TTFT
19
+ β”‚ β”‚
20
+ β”‚ Guard finishes first? β”‚
21
+ β”‚ blocked=True β†’ cancel B │──► BlockEvent
22
+ β”‚ blocked=False β†’ open gate │──► yield buffered tokens
23
+ β”‚ │──► yield live tokens…
24
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
25
+ """
26
+
27
+ import asyncio
28
+ import logging
29
+ import time
30
+ from dataclasses import dataclass, field
31
+ from typing import AsyncGenerator, List, Optional, Union
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+
36
+ # ── Gate events ───────────────────────────────────────────────────────────────
37
+
38
+ @dataclass
39
+ class TokenEvent:
40
+ """A raw token chunk from the LLM β€” pass through to client."""
41
+ text: str
42
+ type: str = field(default="token", init=False)
43
+
44
+
45
+ @dataclass
46
+ class BlockEvent:
47
+ """Guard blocked the prompt β€” LLM never delivered output to client."""
48
+ reason: str
49
+ threat_score: int
50
+ flags: List[str]
51
+ pg_score: float
52
+ guard_ms: float
53
+ type: str = field(default="blocked", init=False)
54
+
55
+
56
+ GateEvent = Union[TokenEvent, BlockEvent]
57
+
58
+
59
+ # ── The Gate ──────────────────────────────────────────────────────────────────
60
+
61
+ class ShieldGate:
62
+ """
63
+ Orchestrates concurrent Guard check + LLM stream with a buffering gate.
64
+
65
+ Args:
66
+ guard: PromptGuardTextGuard instance.
67
+ adapter: LLMAdapter with stream_chat().
68
+ guard_timeout_sec: Max seconds to wait for guard before passing anyway.
69
+ (Safety valve β€” guard should always be < 300ms.)
70
+ """
71
+
72
+ def __init__(self, guard, adapter, guard_timeout_sec: float = 3.0):
73
+ self._guard = guard
74
+ self._adapter = adapter
75
+ self._timeout = guard_timeout_sec
76
+
77
+ async def run(
78
+ self,
79
+ prompt: str,
80
+ system_prompt: str = "",
81
+ trace=None, # Optional[RequestTrace] β€” for pipeline telemetry
82
+ ) -> AsyncGenerator[GateEvent, None]:
83
+ """
84
+ Async generator β€” yields GateEvents to the SSE layer.
85
+
86
+ Yields either a single BlockEvent (if guard fires) or a stream of
87
+ TokenEvents followed by nothing (caller handles done/close).
88
+ """
89
+ t_start = time.perf_counter()
90
+
91
+ # ── Token buffer β€” accumulates LLM tokens while guard decides ─────
92
+ token_buffer: List[str] = []
93
+ buffer_lock = asyncio.Lock()
94
+ first_token_seen = False
95
+
96
+ # ── Task A: Guard (runs in thread pool β€” it's synchronous) ────────
97
+ async def run_guard() -> dict:
98
+ if trace:
99
+ trace.on_guard_start()
100
+ loop = asyncio.get_event_loop()
101
+ result = await loop.run_in_executor(None, self._guard.screen, prompt)
102
+ return result
103
+
104
+ # ── Task B: LLM stream β€” feeds token_buffer until gate opens ──────
105
+ async def run_stream():
106
+ nonlocal first_token_seen
107
+ if trace:
108
+ trace.on_llm_start()
109
+ try:
110
+ token_count = 0
111
+ total_chars = 0
112
+ async for token in self._adapter.stream_chat(prompt, system_prompt or None):
113
+ if not first_token_seen:
114
+ first_token_seen = True
115
+ if trace:
116
+ trace.on_first_token()
117
+ async with buffer_lock:
118
+ token_buffer.append(token)
119
+ token_count += 1
120
+ total_chars += len(token)
121
+ # Emit a token-flow tick every 10 tokens for the dashboard
122
+ if trace and token_count % 10 == 0:
123
+ trace.on_token_tick(token_count, total_chars)
124
+ except asyncio.CancelledError:
125
+ pass # Guard blocked β€” stream cancelled cleanly
126
+
127
+ # Start both tasks concurrently
128
+ guard_task = asyncio.create_task(run_guard(), name="shield-guard")
129
+ stream_task = asyncio.create_task(run_stream(), name="llm-stream")
130
+
131
+ # ── Wait for guard verdict ─────────────────────────────────────────
132
+ try:
133
+ guard_result = await asyncio.wait_for(
134
+ asyncio.shield(guard_task),
135
+ timeout=self._timeout,
136
+ )
137
+ except asyncio.TimeoutError:
138
+ log.error("[Gate] Guard timed out after %.1fs β€” defaulting to PASS", self._timeout)
139
+ guard_result = {"blocked": False, "threat_score": 0, "flags": [], "checks": {}}
140
+
141
+ guard_ms = round((time.perf_counter() - t_start) * 1000, 2)
142
+ pg_score = guard_result.get("checks", {}).get("prompt_guard", {}).get("malicious_score", 0.0)
143
+
144
+ # ── Guard says BLOCK ───────────────────────────────────────────────
145
+ if guard_result.get("blocked"):
146
+ stream_task.cancel()
147
+ try:
148
+ await stream_task
149
+ except (asyncio.CancelledError, Exception):
150
+ pass
151
+
152
+ log.warning(
153
+ "[Gate] Prompt BLOCKED β€” score=%.3f flags=%s latency=%.0fms",
154
+ pg_score, guard_result.get("flags", []), guard_ms,
155
+ )
156
+ if trace:
157
+ trace.on_guard_block(
158
+ pg_score = pg_score,
159
+ threat_score = guard_result.get("threat_score", 100),
160
+ reason = guard_result.get("reason", "GUARD_BLOCKED"),
161
+ flags = guard_result.get("flags", []),
162
+ )
163
+ yield BlockEvent(
164
+ reason = guard_result.get("reason", "GUARD_BLOCKED"),
165
+ threat_score = guard_result.get("threat_score", 100),
166
+ flags = guard_result.get("flags", []),
167
+ pg_score = pg_score,
168
+ guard_ms = guard_ms,
169
+ )
170
+ return
171
+
172
+ # ── Guard says PASS β€” drain buffer then yield live tokens ──────────
173
+ log.info(
174
+ "[Gate] Prompt PASSED β€” score=%.3f latency=%.0fms β€” opening gate",
175
+ pg_score, guard_ms,
176
+ )
177
+ if trace:
178
+ trace.on_guard_pass(
179
+ pg_score = pg_score,
180
+ threat_score = guard_result.get("threat_score", 0),
181
+ flags = guard_result.get("flags", []),
182
+ )
183
+
184
+ # Drain anything buffered while guard was deciding
185
+ async with buffer_lock:
186
+ buffered = token_buffer.copy()
187
+ token_buffer.clear()
188
+
189
+ for token in buffered:
190
+ yield TokenEvent(text=token)
191
+
192
+ # Wait for stream task to complete, yielding live tokens as they arrive
193
+ # We poll the buffer so we can yield from the async generator context
194
+ while not stream_task.done():
195
+ await asyncio.sleep(0.005) # 5ms poll β€” tight enough for streaming
196
+ async with buffer_lock:
197
+ live = token_buffer.copy()
198
+ token_buffer.clear()
199
+ for token in live:
200
+ yield TokenEvent(text=token)
201
+
202
+ # Final drain after stream task completes
203
+ async with buffer_lock:
204
+ for token in token_buffer:
205
+ yield TokenEvent(text=token)
206
+
207
+ # Propagate any stream errors
208
+ if not stream_task.cancelled() and stream_task.exception():
209
+ log.error("[Gate] LLM stream error: %s", stream_task.exception())
sidecar/pipeline_events.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sidecar/pipeline_events.py β€” Structured event bus for real-time data flow telemetry.
3
+
4
+ Every stage of the sidecar pipeline emits a PipelineEvent into a shared
5
+ in-process broadcast queue. The /v1/pipeline-stream SSE endpoint fans these
6
+ out to all connected dashboard clients in real time.
7
+
8
+ Event stages (in chronological order for a single request):
9
+ REQUEST_IN β€” client prompt received
10
+ GUARD_START β€” Prompt Guard model begins screening
11
+ LLM_START β€” LLM stream begins (parallel to GUARD_START)
12
+ GUARD_PASS β€” guard cleared the prompt
13
+ GUARD_BLOCK β€” guard rejected the prompt
14
+ TOKEN_FLOW β€” LLM tokens flowing (summary ticks, not every token)
15
+ SENTENCE_READY β€” a complete sentence was emitted
16
+ MONITOR_START β€” sentence submitted to background monitor
17
+ MONITOR_RESULT β€” monitor returned a verdict for a sentence
18
+ STREAM_DONE β€” LLM stream finished, all sentences emitted
19
+ REQUEST_DONE β€” full request completed, final metrics available
20
+ """
21
+
22
+ import queue
23
+ import time
24
+ import threading
25
+ import uuid
26
+ from dataclasses import dataclass, field, asdict
27
+ from typing import Any, Dict, List, Optional
28
+
29
+
30
+ # ── Event types ───────────────────────────────────────────────────────────────
31
+
32
+ @dataclass
33
+ class PipelineEvent:
34
+ stage: str # one of the constants above
35
+ request_id: str
36
+ ts: float = field(default_factory=time.perf_counter) # perf_counter seconds
37
+ wall: str = field(default_factory=lambda: time.strftime("%H:%M:%S"))
38
+ data: Dict = field(default_factory=dict)
39
+
40
+ def to_dict(self) -> Dict:
41
+ return {
42
+ "stage": self.stage,
43
+ "request_id": self.request_id,
44
+ "ts": round(self.ts * 1000, 2), # ms since process start
45
+ "wall": self.wall,
46
+ **self.data,
47
+ }
48
+
49
+
50
+ # ── Global broadcast queue list ───────────────────────────────────────────────
51
+
52
+ _subscribers: List[queue.Queue] = []
53
+ _lock = threading.Lock()
54
+
55
+
56
+ def subscribe() -> queue.Queue:
57
+ """Register a new SSE client. Returns its dedicated queue."""
58
+ q = queue.Queue(maxsize=200)
59
+ with _lock:
60
+ _subscribers.append(q)
61
+ return q
62
+
63
+
64
+ def unsubscribe(q: queue.Queue) -> None:
65
+ """Remove an SSE client queue."""
66
+ with _lock:
67
+ try:
68
+ _subscribers.remove(q)
69
+ except ValueError:
70
+ pass
71
+
72
+
73
+ def emit(event: PipelineEvent) -> None:
74
+ """Broadcast an event to all connected SSE clients."""
75
+ payload = event.to_dict()
76
+ with _lock:
77
+ dead = []
78
+ for q in _subscribers:
79
+ try:
80
+ q.put_nowait(payload)
81
+ except queue.Full:
82
+ dead.append(q)
83
+ for q in dead:
84
+ _subscribers.remove(q)
85
+
86
+
87
+ # ── Request context helper ────────────────────────────────────────────────────
88
+
89
+ class RequestTrace:
90
+ """
91
+ Tracks timing for a single request and emits PipelineEvents at each stage.
92
+ Used as a context object threaded through the pipeline.
93
+ """
94
+
95
+ def __init__(self):
96
+ self.request_id: str = str(uuid.uuid4())[:8]
97
+ self._t0: float = time.perf_counter()
98
+ self._stage_start: Dict[str, float] = {}
99
+
100
+ def elapsed_ms(self) -> float:
101
+ return round((time.perf_counter() - self._t0) * 1000, 2)
102
+
103
+ def _since_stage(self, stage: str) -> float:
104
+ t = self._stage_start.get(stage)
105
+ if t is None:
106
+ return 0.0
107
+ return round((time.perf_counter() - t) * 1000, 2)
108
+
109
+ def _emit(self, stage: str, **kwargs):
110
+ self._stage_start.setdefault(stage, time.perf_counter())
111
+ emit(PipelineEvent(
112
+ stage = stage,
113
+ request_id = self.request_id,
114
+ data = {"elapsed_ms": self.elapsed_ms(), **kwargs},
115
+ ))
116
+
117
+ # ── Stage helpers ─────────────────────────────────────────────────────────
118
+
119
+ def on_request_in(self, prompt_preview: str):
120
+ self._emit("REQUEST_IN", prompt_preview=prompt_preview[:80])
121
+
122
+ def on_guard_start(self):
123
+ self._stage_start["guard"] = time.perf_counter()
124
+ self._emit("GUARD_START")
125
+
126
+ def on_llm_start(self):
127
+ self._stage_start["llm"] = time.perf_counter()
128
+ self._emit("LLM_START")
129
+
130
+ def on_guard_pass(self, pg_score: float, threat_score: int, flags: list):
131
+ guard_ms = self._since_stage("guard")
132
+ self._emit("GUARD_PASS",
133
+ guard_ms=guard_ms, pg_score=pg_score,
134
+ threat_score=threat_score, flags=flags)
135
+
136
+ def on_guard_block(self, pg_score: float, threat_score: int, reason: str, flags: list):
137
+ guard_ms = self._since_stage("guard")
138
+ self._emit("GUARD_BLOCK",
139
+ guard_ms=guard_ms, pg_score=pg_score,
140
+ threat_score=threat_score, reason=reason, flags=flags)
141
+
142
+ def on_first_token(self):
143
+ ttft = self._since_stage("llm")
144
+ self._emit("FIRST_TOKEN", ttft_ms=ttft)
145
+
146
+ def on_token_tick(self, token_count: int, chars: int):
147
+ self._emit("TOKEN_FLOW", token_count=token_count, chars=chars)
148
+
149
+ def on_sentence_ready(self, sentence_id: int, sentence_preview: str):
150
+ self._emit("SENTENCE_READY",
151
+ sentence_id=sentence_id,
152
+ sentence_preview=sentence_preview[:60])
153
+
154
+ def on_monitor_start(self, sentence_id: int):
155
+ self._emit("MONITOR_START", sentence_id=sentence_id)
156
+
157
+ def on_monitor_result(self, sentence_id: int, threat_score: int, flags: list, blocked: bool):
158
+ self._emit("MONITOR_RESULT",
159
+ sentence_id=sentence_id, threat_score=threat_score,
160
+ flags=flags, blocked=blocked)
161
+
162
+ def on_stream_done(self, total_tokens: int, sentence_count: int):
163
+ stream_ms = self._since_stage("llm")
164
+ self._emit("STREAM_DONE",
165
+ stream_ms=stream_ms, total_tokens=total_tokens,
166
+ sentence_count=sentence_count)
167
+
168
+ def on_request_done(self, threat_score: int, flags: list, blocked: bool):
169
+ self._emit("REQUEST_DONE",
170
+ total_ms=self.elapsed_ms(),
171
+ threat_score=threat_score, flags=flags, blocked=blocked)
sidecar/sentence_splitter.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sidecar/sentence_splitter.py β€” Streaming token accumulator with sentence boundary detection.
3
+
4
+ Accumulates raw token chunks from the LLM stream and detects sentence
5
+ boundaries. On each token it returns:
6
+ - A CHUNK event always (for immediate client rendering)
7
+ - A SENTENCE event when a sentence boundary is crossed (for shield monitoring)
8
+
9
+ Design notes:
10
+ - Boundaries are detected on sentence-ending punctuation followed by
11
+ whitespace, or on double newlines (paragraph breaks).
12
+ - A minimum character threshold prevents very short "sentences" from
13
+ creating excessive monitor submissions (e.g. "OK." or "Yes.").
14
+ - The final flush() call drains any remaining buffer as a sentence.
15
+ """
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import List
20
+
21
+
22
+ # ── Event types ───────────────────────────────────────────────────────────────
23
+
24
+ @dataclass
25
+ class ChunkEvent:
26
+ """A raw token chunk β€” emitted immediately to the client."""
27
+ text: str
28
+ type: str = field(default="chunk", init=False)
29
+
30
+
31
+ @dataclass
32
+ class SentenceEvent:
33
+ """A complete sentence β€” emitted to the client AND submitted to the monitor."""
34
+ text: str
35
+ sentence_id: int
36
+ type: str = field(default="sentence", init=False)
37
+
38
+
39
+ # ── Sentence boundary pattern ──────────────────────────────────────────────────
40
+ #
41
+ # Matches end-of-sentence punctuation (. ! ?) followed by:
42
+ # a) whitespace (space / newline)
43
+ # b) end of string
44
+ # Also matches double newlines (paragraph breaks).
45
+ #
46
+ _SENTENCE_END_RE = re.compile(r'(?<=[.!?])\s+|(?<=[.!?])$|\n\n')
47
+
48
+
49
+ class SentenceSplitter:
50
+ """
51
+ Accumulates streaming tokens and fires sentence events at boundaries.
52
+
53
+ Args:
54
+ min_chars: Minimum buffer length before a boundary is declared.
55
+ Prevents "Yes.", "OK." from triggering monitor overhead.
56
+ """
57
+
58
+ def __init__(self, min_chars: int = 20):
59
+ self._min_chars = min_chars
60
+ self._buffer: str = ""
61
+ self._sentence_id: int = 0
62
+
63
+ # ------------------------------------------------------------------
64
+ # Public API
65
+ # ------------------------------------------------------------------
66
+
67
+ def feed(self, token: str) -> List[ChunkEvent | SentenceEvent]:
68
+ """
69
+ Feed a new token chunk. Returns a list of events to emit.
70
+
71
+ Always contains at least one ChunkEvent.
72
+ May contain a SentenceEvent if a boundary was crossed.
73
+ """
74
+ self._buffer += token
75
+ events: List[ChunkEvent | SentenceEvent] = [ChunkEvent(text=token)]
76
+
77
+ # Scan for sentence boundaries in the current buffer
78
+ while True:
79
+ match = _SENTENCE_END_RE.search(self._buffer)
80
+ if not match:
81
+ break
82
+
83
+ end_pos = match.end()
84
+ sentence_text = self._buffer[:end_pos].strip()
85
+
86
+ # Only declare a sentence if it's long enough
87
+ if len(sentence_text) >= self._min_chars:
88
+ self._sentence_id += 1
89
+ events.append(SentenceEvent(
90
+ text=sentence_text,
91
+ sentence_id=self._sentence_id,
92
+ ))
93
+ self._buffer = self._buffer[end_pos:]
94
+ else:
95
+ # Too short β€” don't split here, keep accumulating
96
+ break
97
+
98
+ return events
99
+
100
+ def flush(self) -> List[SentenceEvent]:
101
+ """
102
+ Flush any remaining buffer as a final sentence.
103
+ Call this after the stream ends.
104
+ """
105
+ events = []
106
+ remainder = self._buffer.strip()
107
+ if remainder:
108
+ self._sentence_id += 1
109
+ events.append(SentenceEvent(
110
+ text=remainder,
111
+ sentence_id=self._sentence_id,
112
+ ))
113
+ self._buffer = ""
114
+ return events
115
+
116
+ @property
117
+ def sentence_count(self) -> int:
118
+ return self._sentence_id
sidecar/stream_monitor.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ sidecar/stream_monitor.py β€” Async sentence-level post-inference monitor.
3
+
4
+ Wraps the synchronous TextMonitor in an async ThreadPoolExecutor so that
5
+ per-sentence analysis runs concurrently in the background while the LLM
6
+ stream continues flowing to the client.
7
+
8
+ When a sentence crosses the threat threshold, a BlockSignal is returned
9
+ so the SSE layer can push a block event to the client immediately.
10
+ """
11
+
12
+ import asyncio
13
+ import logging
14
+ from concurrent.futures import ThreadPoolExecutor
15
+ from dataclasses import dataclass, field
16
+ from typing import List, Optional
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ # ── Event types ───────────────────────────────────────────────────────────────
22
+
23
+ @dataclass
24
+ class BlockSignal:
25
+ """Emitted when the post-monitor flags a sentence as harmful."""
26
+ sentence_id: int
27
+ reason: str
28
+ threat_score: int
29
+ flags: List[str]
30
+ type: str = field(default="block_signal", init=False)
31
+
32
+
33
+ # ── Stream Monitor ─────────────────────────────────────────────────────────────
34
+
35
+ class StreamMonitor:
36
+ """
37
+ Async wrapper around TextMonitor for sentence-level background screening.
38
+
39
+ Usage
40
+ -----
41
+ monitor = StreamMonitor(text_monitor_instance, block_threshold=40)
42
+
43
+ # Submit sentences as they complete (non-blocking):
44
+ task = await monitor.submit(sentence_id=1, sentence="...", prompt="...")
45
+
46
+ # At stream end β€” collect any pending block signals:
47
+ signals = await monitor.collect(timeout=1.0)
48
+
49
+ Args:
50
+ text_monitor: Existing TextMonitor instance.
51
+ block_threshold: threat_score at or above which a BlockSignal is raised.
52
+ max_workers: Thread pool size for concurrent sentence analysis.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ text_monitor,
58
+ block_threshold: int = 40,
59
+ max_workers: int = 4,
60
+ ):
61
+ self._monitor = text_monitor
62
+ self._threshold = block_threshold
63
+ self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="shield-monitor")
64
+ self._tasks: List[asyncio.Future] = []
65
+
66
+ # ------------------------------------------------------------------
67
+ # Public API
68
+ # ------------------------------------------------------------------
69
+
70
+ async def submit(self, sentence_id: int, sentence: str, prompt: str) -> None:
71
+ """
72
+ Non-blocking: submit a sentence for background analysis.
73
+ The result is stored internally; call collect() to retrieve signals.
74
+ """
75
+ loop = asyncio.get_event_loop()
76
+ future = loop.run_in_executor(
77
+ self._executor,
78
+ self._analyze_sync,
79
+ sentence_id, sentence, prompt,
80
+ )
81
+ self._tasks.append(future)
82
+
83
+ async def collect(self, timeout: float = 1.5) -> List[BlockSignal]:
84
+ """
85
+ Wait for all pending monitor tasks (up to timeout) and return
86
+ any BlockSignals found.
87
+
88
+ Called at stream end to finalise the threat assessment.
89
+ """
90
+ if not self._tasks:
91
+ return []
92
+
93
+ signals: List[BlockSignal] = []
94
+ try:
95
+ results = await asyncio.wait_for(
96
+ asyncio.gather(*self._tasks, return_exceptions=True),
97
+ timeout=timeout,
98
+ )
99
+ for result in results:
100
+ if isinstance(result, BlockSignal):
101
+ signals.append(result)
102
+ elif isinstance(result, Exception):
103
+ log.warning("[StreamMonitor] Task error: %s", result)
104
+ except asyncio.TimeoutError:
105
+ log.warning("[StreamMonitor] collect() timed out after %.1fs β€” some sentences unscreened", timeout)
106
+
107
+ self._tasks.clear()
108
+ return signals
109
+
110
+ def reset(self) -> None:
111
+ """Clear pending tasks (e.g. between requests)."""
112
+ self._tasks.clear()
113
+
114
+ def shutdown(self) -> None:
115
+ """Gracefully shut down the thread pool."""
116
+ self._executor.shutdown(wait=False)
117
+
118
+ # ------------------------------------------------------------------
119
+ # Internal β€” runs in thread pool
120
+ # ------------------------------------------------------------------
121
+
122
+ def _analyze_sync(self, sentence_id: int, sentence: str, prompt: str) -> Optional[BlockSignal]:
123
+ """
124
+ Synchronous analysis β€” called inside ThreadPoolExecutor.
125
+ Returns a BlockSignal if the sentence is flagged, else None.
126
+ """
127
+ try:
128
+ result = self._monitor.analyze(prompt=prompt, response=sentence)
129
+ if result["threat_score"] >= self._threshold:
130
+ log.warning(
131
+ "[StreamMonitor] Sentence %d flagged: score=%d flags=%s",
132
+ sentence_id, result["threat_score"], result["flags"],
133
+ )
134
+ return BlockSignal(
135
+ sentence_id = sentence_id,
136
+ reason = result["reason"],
137
+ threat_score = result["threat_score"],
138
+ flags = result["flags"],
139
+ )
140
+ except Exception as exc:
141
+ log.error("[StreamMonitor] Analysis error on sentence %d: %s", sentence_id, exc)
142
+ return None
start_sidecar.sh ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # start_sidecar.sh β€” Launch the GenAI Shield Sidecar standalone
3
+ #
4
+ # Usage:
5
+ # ./start_sidecar.sh # default port 5050
6
+ # SIDECAR_PORT=8080 ./start_sidecar.sh # custom port
7
+ # GEMINI_API_KEY=... ./start_sidecar.sh # with API key inline
8
+
9
+ set -e
10
+
11
+ SIDECAR_PORT="${SIDECAR_PORT:-5050}"
12
+ LOG_LEVEL="${LOG_LEVEL:-info}"
13
+
14
+ echo "╔══════════════════════════════════════════════╗"
15
+ echo "β•‘ GenAI Shield Sidecar β€” Starting up β•‘"
16
+ echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•"
17
+ echo ""
18
+ echo " Port: $SIDECAR_PORT"
19
+ echo " Backend: ${LLM_BACKEND:-gemini}"
20
+ echo " UI: http://localhost:$SIDECAR_PORT"
21
+ echo " API Docs: http://localhost:$SIDECAR_PORT/docs"
22
+ echo ""
23
+
24
+ # Run from project root so imports resolve correctly
25
+ cd "$(dirname "$0")"
26
+
27
+ python -m uvicorn sidecar.app:app \
28
+ --host 0.0.0.0 \
29
+ --port "$SIDECAR_PORT" \
30
+ --log-level "$LOG_LEVEL" \
31
+ --no-access-log
templates/dataflow.html ADDED
@@ -0,0 +1,539 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <title>GenAI Shield β€” Data Flow</title>
6
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
7
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
8
+ <style>
9
+ *{box-sizing:border-box;margin:0;padding:0}
10
+ :root{
11
+ --bg:#07070f;--s1:#0f0f1a;--s2:#161625;--b:#1f1f35;
12
+ --acc:#6c63ff;--grn:#00d4aa;--red:#ff4757;--ylw:#ffa502;
13
+ --txt:#e0e0f0;--muted:#5a5a7a;
14
+ --mono:'JetBrains Mono',monospace;--sans:'Inter',sans-serif;
15
+ }
16
+ body{font-family:var(--sans);background:var(--bg);color:var(--txt);height:100vh;display:flex;flex-direction:column;overflow:hidden}
17
+
18
+ /* Header */
19
+ .hdr{padding:.75rem 1.5rem;border-bottom:1px solid var(--b);background:var(--s1);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
20
+ .hdr-l{display:flex;align-items:center;gap:.75rem}
21
+ .hdr-icon{width:34px;height:34px;border-radius:8px;background:linear-gradient(135deg,var(--acc),var(--grn));display:flex;align-items:center;justify-content:center;font-size:1rem}
22
+ .hdr h1{font-size:.95rem;font-weight:700}
23
+ .hdr-sub{font-family:var(--mono);font-size:.58rem;color:var(--muted);text-transform:uppercase}
24
+ .hdr-r{display:flex;align-items:center;gap:.75rem}
25
+ .dot{width:8px;height:8px;border-radius:50%;background:var(--grn);box-shadow:0 0 6px var(--grn);animation:pulse 2s ease-in-out infinite}
26
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
27
+ .nav-link{font-family:var(--mono);font-size:.6rem;padding:4px 10px;border:1px solid var(--b);border-radius:4px;color:var(--muted);text-decoration:none;transition:color .2s,border-color .2s}
28
+ .nav-link:hover{color:var(--acc);border-color:var(--acc)}
29
+
30
+ /* Layout */
31
+ .layout{flex:1;display:grid;grid-template-columns:1fr 320px;overflow:hidden}
32
+
33
+ /* Pipeline canvas area */
34
+ .canvas-wrap{padding:1.5rem;display:flex;flex-direction:column;gap:1rem;overflow:hidden}
35
+ .section-title{font-family:var(--mono);font-size:.6rem;text-transform:uppercase;color:var(--muted);letter-spacing:.08em;margin-bottom:.5rem}
36
+
37
+ /* Pipeline diagram */
38
+ .pipeline{background:var(--s1);border:1px solid var(--b);border-radius:12px;padding:1.5rem;flex-shrink:0}
39
+ .nodes{display:flex;align-items:center;gap:0;position:relative}
40
+
41
+ .node{display:flex;flex-direction:column;align-items:center;gap:.5rem;flex:1;position:relative;z-index:2}
42
+ .node-box{width:80px;height:80px;border-radius:12px;border:2px solid var(--b);background:var(--s2);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.25rem;cursor:default;transition:border-color .3s,box-shadow .3s;position:relative}
43
+ .node-icon{font-size:1.4rem}
44
+ .node-lbl{font-family:var(--mono);font-size:.55rem;text-align:center;color:var(--muted);text-transform:uppercase;line-height:1.3}
45
+ .node-timer{font-family:var(--mono);font-size:.65rem;font-weight:700;color:var(--grn);min-height:1em;text-align:center}
46
+
47
+ /* Node state classes */
48
+ .node-box.active{border-color:var(--acc);box-shadow:0 0 16px rgba(108,99,255,.4)}
49
+ .node-box.done{border-color:var(--grn);box-shadow:0 0 12px rgba(0,212,170,.25)}
50
+ .node-box.blocked{border-color:var(--red);box-shadow:0 0 16px rgba(255,71,87,.4)}
51
+ .node-box.warn{border-color:var(--ylw)}
52
+
53
+ /* Connectors */
54
+ .connector{flex:1;height:2px;background:var(--b);position:relative;z-index:1;align-self:center;margin-top:-40px}
55
+ .connector-inner{height:100%;width:0;background:linear-gradient(90deg,var(--acc),var(--grn));transition:width .4s ease}
56
+ .connector.active .connector-inner{width:100%}
57
+ .connector.blocked .connector-inner{background:var(--red)}
58
+
59
+ /* Packet animation */
60
+ .packet{position:absolute;top:50%;transform:translateY(-50%);width:10px;height:10px;border-radius:50%;background:var(--acc);box-shadow:0 0 8px var(--acc);animation:travel .6s ease forwards;opacity:0}
61
+ @keyframes travel{0%{left:0;opacity:1}100%{left:100%;opacity:0}}
62
+ .packet.red{background:var(--red);box-shadow:0 0 8px var(--red)}
63
+ .packet.grn{background:var(--grn);box-shadow:0 0 8px var(--grn)}
64
+
65
+ /* Parallel row label */
66
+ .parallel-note{font-family:var(--mono);font-size:.58rem;color:var(--acc);text-align:center;margin-top:.75rem}
67
+
68
+ /* Metrics grid */
69
+ .metrics-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem}
70
+ .metric-card{background:var(--s1);border:1px solid var(--b);border-radius:10px;padding:.875rem;display:flex;flex-direction:column;gap:.25rem}
71
+ .metric-card.acc{border-color:rgba(108,99,255,.4)}
72
+ .metric-card.grn{border-color:rgba(0,212,170,.4)}
73
+ .metric-card.red{border-color:rgba(255,71,87,.4)}
74
+ .metric-card.ylw{border-color:rgba(255,165,2,.4)}
75
+ .metric-lbl{font-family:var(--mono);font-size:.55rem;text-transform:uppercase;color:var(--muted)}
76
+ .metric-val{font-family:var(--mono);font-size:1.5rem;font-weight:700;line-height:1}
77
+ .metric-sub{font-family:var(--mono);font-size:.58rem;color:var(--muted)}
78
+ .metric-card.acc .metric-val{color:var(--acc)}
79
+ .metric-card.grn .metric-val{color:var(--grn)}
80
+ .metric-card.red .metric-val{color:var(--red)}
81
+ .metric-card.ylw .metric-val{color:var(--ylw)}
82
+
83
+ /* Charts row */
84
+ .charts-row{display:grid;grid-template-columns:1fr 1fr;gap:.75rem;flex:1;min-height:0}
85
+ .chart-card{background:var(--s1);border:1px solid var(--b);border-radius:10px;padding:1rem;display:flex;flex-direction:column;min-height:0}
86
+ .chart-card canvas{flex:1;min-height:0}
87
+
88
+ /* Right panel */
89
+ .right-panel{border-left:1px solid var(--b);display:flex;flex-direction:column;overflow:hidden}
90
+ .rp-header{padding:.875rem 1rem;border-bottom:1px solid var(--b);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
91
+ .rp-title{font-family:var(--mono);font-size:.6rem;text-transform:uppercase;color:var(--muted)}
92
+ .rp-count{font-family:var(--mono);font-size:.6rem;color:var(--acc)}
93
+ .event-log{flex:1;overflow-y:auto;padding:.5rem}
94
+ .event-log::-webkit-scrollbar{width:3px}
95
+ .event-log::-webkit-scrollbar-thumb{background:var(--b);border-radius:2px}
96
+
97
+ .evt{padding:.5rem .75rem;border-left:2px solid var(--b);margin-bottom:.375rem;border-radius:0 6px 6px 0;background:var(--s2);cursor:pointer;transition:border-color .2s}
98
+ .evt:hover{border-left-color:var(--acc)}
99
+ .evt-stage{font-family:var(--mono);font-size:.58rem;font-weight:700;text-transform:uppercase}
100
+ .evt-meta{font-family:var(--mono);font-size:.55rem;color:var(--muted);margin-top:2px}
101
+ .evt-detail{font-size:.7rem;color:var(--txt);margin-top:3px;word-break:break-all}
102
+
103
+ .evt.REQUEST_IN {border-left-color:#6c63ff} .evt.REQUEST_IN .evt-stage{color:#6c63ff}
104
+ .evt.GUARD_START {border-left-color:#a78bfa} .evt.GUARD_START .evt-stage{color:#a78bfa}
105
+ .evt.LLM_START {border-left-color:#38bdf8} .evt.LLM_START .evt-stage{color:#38bdf8}
106
+ .evt.GUARD_PASS {border-left-color:#00d4aa} .evt.GUARD_PASS .evt-stage{color:#00d4aa}
107
+ .evt.GUARD_BLOCK {border-left-color:#ff4757} .evt.GUARD_BLOCK .evt-stage{color:#ff4757}
108
+ .evt.FIRST_TOKEN {border-left-color:#00d4aa} .evt.FIRST_TOKEN .evt-stage{color:#00d4aa}
109
+ .evt.TOKEN_FLOW {border-left-color:#22d3ee} .evt.TOKEN_FLOW .evt-stage{color:#22d3ee}
110
+ .evt.SENTENCE_READY{border-left-color:#a3e635} .evt.SENTENCE_READY .evt-stage{color:#a3e635}
111
+ .evt.MONITOR_START {border-left-color:#fbbf24} .evt.MONITOR_START .evt-stage{color:#fbbf24}
112
+ .evt.MONITOR_RESULT{border-left-color:#fb923c} .evt.MONITOR_RESULT .evt-stage{color:#fb923c}
113
+ .evt.STREAM_DONE {border-left-color:#00d4aa} .evt.STREAM_DONE .evt-stage{color:#00d4aa}
114
+ .evt.REQUEST_DONE {border-left-color:#6c63ff} .evt.REQUEST_DONE .evt-stage{color:#6c63ff}
115
+
116
+ /* Threat badge */
117
+ .tbadge{font-family:var(--mono);font-size:.52rem;font-weight:700;padding:1px 5px;border-radius:2px;display:inline-block}
118
+ .clean{background:rgba(0,212,170,.12);color:var(--grn);border:1px solid rgba(0,212,170,.3)}
119
+ .medium{background:rgba(255,165,2,.12);color:var(--ylw);border:1px solid rgba(255,165,2,.3)}
120
+ .high{background:rgba(255,71,87,.12);color:var(--red);border:1px solid rgba(255,71,87,.3)}
121
+
122
+ /* Empty state */
123
+ .empty-state{padding:2rem;text-align:center;color:var(--muted);font-family:var(--mono);font-size:.7rem}
124
+ </style>
125
+ </head>
126
+ <body>
127
+
128
+ <header class="hdr">
129
+ <div class="hdr-l">
130
+ <div class="hdr-icon">🌊</div>
131
+ <div>
132
+ <h1>Data Flow <span style="color:var(--acc)">Monitor</span></h1>
133
+ <div class="hdr-sub">Real-time pipeline telemetry Β· Sidecar v2</div>
134
+ </div>
135
+ </div>
136
+ <div class="hdr-r">
137
+ <a class="nav-link" href="/sidecar">← Chat</a>
138
+ <a class="nav-link" href="/genai-monitoring">SIEM</a>
139
+ <div class="dot" id="conn-dot" title="Connecting..."></div>
140
+ </div>
141
+ </header>
142
+
143
+ <div class="layout">
144
+
145
+ <!-- Left: pipeline + metrics -->
146
+ <div class="canvas-wrap">
147
+
148
+ <!-- Pipeline diagram -->
149
+ <div class="pipeline">
150
+ <div class="section-title">Request Pipeline Β· Live</div>
151
+ <div class="nodes" id="nodes">
152
+
153
+ <!-- Client -->
154
+ <div class="node">
155
+ <div class="node-box" id="n-client">
156
+ <div class="node-icon">πŸ‘€</div>
157
+ <div class="node-lbl">Client</div>
158
+ </div>
159
+ <div class="node-timer" id="t-client">β€”</div>
160
+ </div>
161
+
162
+ <div class="connector" id="c-gate"><div class="connector-inner"></div></div>
163
+
164
+ <!-- Shield Gate -->
165
+ <div class="node">
166
+ <div class="node-box" id="n-gate">
167
+ <div class="node-icon">πŸšͺ</div>
168
+ <div class="node-lbl">Shield Gate</div>
169
+ </div>
170
+ <div class="node-timer" id="t-gate">β€”</div>
171
+ </div>
172
+
173
+ <!-- Fork: guard + LLM (shown as two sub-connectors) -->
174
+ <div style="display:flex;flex-direction:column;gap:.25rem;flex:1.4">
175
+ <div style="display:flex;align-items:center;gap:0">
176
+ <div class="connector" id="c-guard" style="flex:1"><div class="connector-inner"></div></div>
177
+ <div class="node">
178
+ <div class="node-box" id="n-guard" style="width:68px;height:68px">
179
+ <div class="node-icon" style="font-size:1.1rem">πŸ›‘οΈ</div>
180
+ <div class="node-lbl">Prompt Guard</div>
181
+ </div>
182
+ <div class="node-timer" id="t-guard">β€”</div>
183
+ </div>
184
+ </div>
185
+ <div style="display:flex;align-items:center;gap:0">
186
+ <div class="connector" id="c-llm" style="flex:1"><div class="connector-inner"></div></div>
187
+ <div class="node">
188
+ <div class="node-box" id="n-llm" style="width:68px;height:68px">
189
+ <div class="node-icon" style="font-size:1.1rem">πŸ€–</div>
190
+ <div class="node-lbl">LLM API</div>
191
+ </div>
192
+ <div class="node-timer" id="t-llm">β€”</div>
193
+ </div>
194
+ </div>
195
+ <div class="parallel-note">⚑ parallel</div>
196
+ </div>
197
+
198
+ <div class="connector" id="c-monitor"><div class="connector-inner"></div></div>
199
+
200
+ <!-- Stream Monitor -->
201
+ <div class="node">
202
+ <div class="node-box" id="n-monitor">
203
+ <div class="node-icon">πŸ”</div>
204
+ <div class="node-lbl">Monitor</div>
205
+ </div>
206
+ <div class="node-timer" id="t-monitor">β€”</div>
207
+ </div>
208
+
209
+ <div class="connector" id="c-out"><div class="connector-inner"></div></div>
210
+
211
+ <!-- Output -->
212
+ <div class="node">
213
+ <div class="node-box" id="n-out">
214
+ <div class="node-icon">βœ…</div>
215
+ <div class="node-lbl">Output</div>
216
+ </div>
217
+ <div class="node-timer" id="t-out">β€”</div>
218
+ </div>
219
+
220
+ </div>
221
+ </div>
222
+
223
+ <!-- Metrics -->
224
+ <div>
225
+ <div class="section-title">Session Metrics</div>
226
+ <div class="metrics-grid">
227
+ <div class="metric-card acc">
228
+ <div class="metric-lbl">Total Requests</div>
229
+ <div class="metric-val" id="m-total">0</div>
230
+ <div class="metric-sub">since page load</div>
231
+ </div>
232
+ <div class="metric-card grn">
233
+ <div class="metric-lbl">Avg Guard ms</div>
234
+ <div class="metric-val" id="m-guard-avg">β€”</div>
235
+ <div class="metric-sub">Prompt Guard latency</div>
236
+ </div>
237
+ <div class="metric-card ylw">
238
+ <div class="metric-lbl">Avg TTFT ms</div>
239
+ <div class="metric-val" id="m-ttft-avg">β€”</div>
240
+ <div class="metric-sub">time to first token</div>
241
+ </div>
242
+ <div class="metric-card red">
243
+ <div class="metric-lbl">Blocked</div>
244
+ <div class="metric-val" id="m-blocked">0</div>
245
+ <div class="metric-sub">pre/post inference</div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+
250
+ <!-- Charts -->
251
+ <div class="charts-row">
252
+ <div class="chart-card">
253
+ <div class="section-title">Guard Latency (ms) Β· last 20</div>
254
+ <canvas id="chart-guard"></canvas>
255
+ </div>
256
+ <div class="chart-card">
257
+ <div class="section-title">Total Request Time (ms) Β· last 20</div>
258
+ <canvas id="chart-total"></canvas>
259
+ </div>
260
+ </div>
261
+
262
+ </div>
263
+
264
+ <!-- Right: event log -->
265
+ <div class="right-panel">
266
+ <div class="rp-header">
267
+ <span class="rp-title">Pipeline Event Log</span>
268
+ <span class="rp-count" id="evt-count">0 events</span>
269
+ </div>
270
+ <div class="event-log" id="event-log">
271
+ <div class="empty-state" id="empty-msg">Waiting for requests…</div>
272
+ </div>
273
+ </div>
274
+
275
+ </div>
276
+
277
+ <script>
278
+ // ── State ──────────────────────────────────────────────────────────────────────
279
+ const state = {
280
+ total: 0, blocked: 0,
281
+ guardTimes: [], ttftTimes: [], totalTimes: [],
282
+ reqStart: {}, guardStart: {}, llmStart: {},
283
+ };
284
+
285
+ // ── Chart setup ────────────────────────────────────────────────────────────────
286
+ const chartOpts = (color) => ({
287
+ type: 'line',
288
+ data: {
289
+ labels: Array(20).fill(''),
290
+ datasets: [{
291
+ data: Array(20).fill(null),
292
+ borderColor: color,
293
+ backgroundColor: color + '18',
294
+ borderWidth: 1.5,
295
+ fill: true,
296
+ tension: 0.4,
297
+ pointRadius: 2,
298
+ pointBackgroundColor: color,
299
+ }]
300
+ },
301
+ options: {
302
+ responsive: true, maintainAspectRatio: false, animation: false,
303
+ scales: {
304
+ x: { display: false },
305
+ y: { min: 0, grid: { color: '#1f1f35' }, ticks: { color: '#5a5a7a', font: { family: 'JetBrains Mono', size: 9 } } }
306
+ },
307
+ plugins: { legend: { display: false } }
308
+ }
309
+ });
310
+
311
+ const cGuard = new Chart(document.getElementById('chart-guard').getContext('2d'), chartOpts('#6c63ff'));
312
+ const cTotal = new Chart(document.getElementById('chart-total').getContext('2d'), chartOpts('#00d4aa'));
313
+
314
+ function pushChart(chart, val) {
315
+ chart.data.datasets[0].data.push(val);
316
+ chart.data.datasets[0].data.shift();
317
+ chart.data.labels.push('');
318
+ chart.data.labels.shift();
319
+ chart.update('none');
320
+ }
321
+
322
+ // ── Node helpers ────────────���──────────────────────────────────────────────────
323
+ function nodeState(id, cls) {
324
+ const el = document.getElementById('n-' + id);
325
+ if (!el) return;
326
+ el.className = 'node-box ' + (cls || '');
327
+ }
328
+ function nodeTime(id, ms) {
329
+ const el = document.getElementById('t-' + id);
330
+ if (el) el.textContent = ms != null ? ms + 'ms' : 'β€”';
331
+ }
332
+ function connActive(id, cls) {
333
+ const el = document.getElementById('c-' + id);
334
+ if (el) el.className = 'connector active ' + (cls || '');
335
+ }
336
+ function firePacket(connId, cls) {
337
+ const conn = document.getElementById('c-' + connId);
338
+ if (!conn) return;
339
+ const p = document.createElement('div');
340
+ p.className = 'packet ' + (cls || '');
341
+ conn.style.position = 'relative';
342
+ conn.appendChild(p);
343
+ setTimeout(() => p.remove(), 700);
344
+ }
345
+ function resetPipeline() {
346
+ ['client','gate','guard','llm','monitor','out'].forEach(id => nodeState(id, ''));
347
+ ['client','gate','guard','llm','monitor','out'].forEach(id => nodeTime(id, null));
348
+ ['gate','guard','llm','monitor','out'].forEach(id => {
349
+ const el = document.getElementById('c-' + id);
350
+ if (el) el.className = 'connector';
351
+ });
352
+ document.getElementById('n-out').querySelector('.node-icon').textContent = 'βœ…';
353
+ }
354
+
355
+ // ── Metrics update ─────────────────────────────────────────────────────────────
356
+ function updateMetrics() {
357
+ document.getElementById('m-total').textContent = state.total;
358
+ document.getElementById('m-blocked').textContent = state.blocked;
359
+ const avg = arr => arr.length ? Math.round(arr.slice(-20).reduce((a,b)=>a+b,0)/Math.min(arr.length,20)) : 'β€”';
360
+ document.getElementById('m-guard-avg').textContent = avg(state.guardTimes);
361
+ document.getElementById('m-ttft-avg').textContent = avg(state.ttftTimes);
362
+ }
363
+
364
+ // ── Event log ──────────────────────────────────────────────────────────────────
365
+ let evtCount = 0;
366
+ function logEvent(ev) {
367
+ const log = document.getElementById('event-log');
368
+ const empty = document.getElementById('empty-msg');
369
+ if (empty) empty.remove();
370
+
371
+ evtCount++;
372
+ document.getElementById('evt-count').textContent = evtCount + ' events';
373
+
374
+ const d = document.createElement('div');
375
+ d.className = 'evt ' + ev.stage;
376
+
377
+ let detail = '';
378
+ if (ev.prompt_preview) detail = `"${ev.prompt_preview.slice(0,50)}"`;
379
+ else if (ev.guard_ms) detail = `Guard: ${ev.guard_ms}ms Β· PG: ${(ev.pg_score||0).toFixed(3)}`;
380
+ else if (ev.ttft_ms) detail = `TTFT: ${ev.ttft_ms}ms`;
381
+ else if (ev.stream_ms) detail = `Stream: ${ev.stream_ms}ms Β· ${ev.total_tokens||0} tokens`;
382
+ else if (ev.total_ms) detail = `Total: ${ev.total_ms}ms`;
383
+ else if (ev.sentence_preview) detail = `"${ev.sentence_preview.slice(0,40)}"`;
384
+ else if (ev.token_count) detail = `${ev.token_count} tokens`;
385
+
386
+ const score = ev.threat_score;
387
+ const badge = score >= 60 ? `<span class="tbadge high">HIGH ${score}</span>`
388
+ : score >= 30 ? `<span class="tbadge medium">WARN ${score}</span>`
389
+ : score > 0 ? `<span class="tbadge clean">CLEAN ${score}</span>` : '';
390
+
391
+ d.innerHTML = `<div class="evt-stage">${ev.stage.replace(/_/g,' ')}</div>
392
+ <div class="evt-meta">${ev.wall} Β· req ${ev.request_id} Β· +${ev.elapsed_ms}ms ${badge}</div>
393
+ ${detail ? `<div class="evt-detail">${detail}</div>` : ''}`;
394
+
395
+ log.prepend(d);
396
+ if (log.children.length > 80) log.lastChild.remove();
397
+ }
398
+
399
+ // ── Pipeline stage handler ─────────────────────────────────────────────────────
400
+ function handleStage(ev) {
401
+ logEvent(ev);
402
+ const rid = ev.request_id;
403
+
404
+ switch(ev.stage) {
405
+ case 'REQUEST_IN':
406
+ resetPipeline();
407
+ state.reqStart[rid] = ev.elapsed_ms;
408
+ nodeState('client', 'active');
409
+ connActive('gate');
410
+ firePacket('gate');
411
+ nodeState('gate', 'active');
412
+ break;
413
+
414
+ case 'GUARD_START':
415
+ state.guardStart[rid] = ev.elapsed_ms;
416
+ nodeState('guard', 'active');
417
+ connActive('guard');
418
+ firePacket('guard');
419
+ break;
420
+
421
+ case 'LLM_START':
422
+ state.llmStart[rid] = ev.elapsed_ms;
423
+ nodeState('llm', 'active');
424
+ connActive('llm');
425
+ firePacket('llm');
426
+ break;
427
+
428
+ case 'GUARD_PASS':
429
+ nodeState('guard', 'done');
430
+ nodeTime('guard', ev.guard_ms);
431
+ state.guardTimes.push(ev.guard_ms);
432
+ pushChart(cGuard, ev.guard_ms);
433
+ updateMetrics();
434
+ break;
435
+
436
+ case 'GUARD_BLOCK':
437
+ state.total++;
438
+ state.blocked++;
439
+ nodeState('guard', 'blocked');
440
+ nodeTime('guard', ev.guard_ms);
441
+ nodeState('gate', 'blocked');
442
+ nodeState('llm', 'blocked');
443
+ nodeState('out', 'blocked');
444
+ document.getElementById('n-out').querySelector('.node-icon').textContent = '🚫';
445
+ state.guardTimes.push(ev.guard_ms);
446
+ pushChart(cGuard, ev.guard_ms);
447
+ updateMetrics();
448
+ break;
449
+
450
+ case 'FIRST_TOKEN':
451
+ nodeState('llm', 'done');
452
+ nodeTime('llm', ev.ttft_ms);
453
+ state.ttftTimes.push(ev.ttft_ms);
454
+ updateMetrics();
455
+ break;
456
+
457
+ case 'TOKEN_FLOW':
458
+ firePacket('monitor', 'grn');
459
+ break;
460
+
461
+ case 'SENTENCE_READY':
462
+ connActive('monitor');
463
+ firePacket('monitor', 'grn');
464
+ nodeState('monitor', 'active');
465
+ break;
466
+
467
+ case 'MONITOR_START':
468
+ nodeState('monitor', 'active');
469
+ break;
470
+
471
+ case 'MONITOR_RESULT':
472
+ if (ev.blocked) {
473
+ nodeState('monitor', 'blocked');
474
+ nodeState('out', 'blocked');
475
+ document.getElementById('n-out').querySelector('.node-icon').textContent = '🚫';
476
+ } else {
477
+ nodeState('monitor', 'done');
478
+ }
479
+ break;
480
+
481
+ case 'STREAM_DONE':
482
+ nodeState('llm', 'done');
483
+ nodeTime('llm', ev.stream_ms);
484
+ break;
485
+
486
+ case 'REQUEST_DONE':
487
+ state.total++;
488
+ if (ev.blocked) state.blocked++;
489
+ nodeState('client', 'done');
490
+ nodeState('gate', 'done');
491
+ if (!ev.blocked) {
492
+ nodeState('monitor', 'done');
493
+ nodeState('out', 'done');
494
+ connActive('out', 'grn');
495
+ firePacket('out', 'grn');
496
+ }
497
+ nodeTime('out', ev.total_ms);
498
+ nodeTime('client', ev.total_ms);
499
+ state.totalTimes.push(ev.total_ms);
500
+ pushChart(cTotal, ev.total_ms);
501
+ updateMetrics();
502
+ break;
503
+ }
504
+ }
505
+
506
+ // ── SSE connection ─────────────────────────────────────────────────────────────
507
+ function connect() {
508
+ const dot = document.getElementById('conn-dot');
509
+ dot.style.background = 'var(--ylw)';
510
+ dot.style.boxShadow = '0 0 6px var(--ylw)';
511
+
512
+ // Try sidecar direct first, fall back to current host
513
+ const url = '/v1/pipeline-stream';
514
+ const es = new EventSource(url);
515
+
516
+ es.onopen = () => {
517
+ dot.style.background = 'var(--grn)';
518
+ dot.style.boxShadow = '0 0 6px var(--grn)';
519
+ dot.title = 'Connected';
520
+ };
521
+
522
+ es.onmessage = e => {
523
+ if (!e.data || e.data.startsWith(':')) return;
524
+ try { handleStage(JSON.parse(e.data)); } catch {}
525
+ };
526
+
527
+ es.onerror = () => {
528
+ dot.style.background = 'var(--red)';
529
+ dot.style.boxShadow = '0 0 6px var(--red)';
530
+ dot.title = 'Disconnected β€” retrying';
531
+ es.close();
532
+ setTimeout(connect, 3000);
533
+ };
534
+ }
535
+
536
+ connect();
537
+ </script>
538
+ </body>
539
+ </html>
templates/genai.html CHANGED
@@ -33,7 +33,6 @@
33
  <div class="container" style="padding: 0; max-width: none; display: flex; justify-content: space-between; align-items: center;">
34
  <div>
35
  <h1 style="font-size: 1.5rem; margin: 0;">Risknox <span style="text-decoration: underline;">GenAI Shield V2</span></h1>
36
- <p style="font-size: 0.65rem; font-weight: 800; text-transform: uppercase; color: var(--muted);">Powered by Llama Prompt Guard 2 Β· {{ model }}</p>
37
  </div>
38
  <a href="/genai-monitoring" class="btn secondary" style="font-size: 0.75rem;">Dashboard β†’</a>
39
  </div>
 
33
  <div class="container" style="padding: 0; max-width: none; display: flex; justify-content: space-between; align-items: center;">
34
  <div>
35
  <h1 style="font-size: 1.5rem; margin: 0;">Risknox <span style="text-decoration: underline;">GenAI Shield V2</span></h1>
 
36
  </div>
37
  <a href="/genai-monitoring" class="btn secondary" style="font-size: 0.75rem;">Dashboard β†’</a>
38
  </div>
templates/sidecar.html ADDED
@@ -0,0 +1,676 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Risknox | GenAI Shield β€” Sidecar</title>
7
+ <meta name="description" content="GenAI Shield sidecar proxy with real-time streaming and sentence-level threat detection">
8
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
9
+ <style>
10
+ :root {
11
+ --bg: #0a0a0f;
12
+ --surface: #12121a;
13
+ --surface2: #1a1a26;
14
+ --border: #2a2a3d;
15
+ --accent: #6c63ff;
16
+ --accent2: #00d4aa;
17
+ --danger: #ff4757;
18
+ --warn: #ffa502;
19
+ --text: #e8e8f0;
20
+ --muted: #6b6b8a;
21
+ --mono: 'JetBrains Mono', monospace;
22
+ --sans: 'Inter', system-ui, sans-serif;
23
+ }
24
+
25
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
26
+
27
+ body {
28
+ font-family: var(--sans);
29
+ background: var(--bg);
30
+ color: var(--text);
31
+ height: 100vh;
32
+ display: flex;
33
+ flex-direction: column;
34
+ overflow: hidden;
35
+ }
36
+
37
+ /* ── Header ───────────────────────────────────────────────── */
38
+ .header {
39
+ padding: 0.875rem 1.5rem;
40
+ border-bottom: 1px solid var(--border);
41
+ background: var(--surface);
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ flex-shrink: 0;
46
+ }
47
+ .header-brand { display: flex; align-items: center; gap: 0.75rem; }
48
+ .shield-icon {
49
+ width: 36px; height: 36px;
50
+ background: linear-gradient(135deg, var(--accent), var(--accent2));
51
+ border-radius: 8px;
52
+ display: flex; align-items: center; justify-content: center;
53
+ font-size: 1.1rem;
54
+ }
55
+ .header h1 { font-size: 1rem; font-weight: 700; }
56
+ .header-sub {
57
+ font-family: var(--mono);
58
+ font-size: 0.6rem;
59
+ color: var(--muted);
60
+ text-transform: uppercase;
61
+ letter-spacing: 0.08em;
62
+ }
63
+ .header-actions { display: flex; align-items: center; gap: 0.75rem; }
64
+ .mode-badge {
65
+ font-family: var(--mono);
66
+ font-size: 0.6rem;
67
+ font-weight: 700;
68
+ padding: 3px 8px;
69
+ border-radius: 3px;
70
+ background: rgba(108, 99, 255, 0.15);
71
+ border: 1px solid rgba(108, 99, 255, 0.4);
72
+ color: var(--accent);
73
+ text-transform: uppercase;
74
+ }
75
+ .status-dot {
76
+ width: 8px; height: 8px;
77
+ border-radius: 50%;
78
+ background: var(--accent2);
79
+ box-shadow: 0 0 6px var(--accent2);
80
+ animation: pulse 2s ease-in-out infinite;
81
+ }
82
+ @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
83
+
84
+ /* ── Chat area ────────────────────────────────────────────── */
85
+ .chat-area {
86
+ flex: 1;
87
+ overflow-y: auto;
88
+ padding: 1.5rem;
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 1.5rem;
92
+ }
93
+ .chat-area::-webkit-scrollbar { width: 4px; }
94
+ .chat-area::-webkit-scrollbar-track { background: transparent; }
95
+ .chat-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
96
+
97
+ /* ── Messages ─────────────────────────────────────────────── */
98
+ .msg { display: flex; gap: 0.75rem; max-width: 760px; width: 100%; }
99
+ .msg.user { align-self: flex-end; flex-direction: row-reverse; }
100
+ .msg.assistant { align-self: flex-start; }
101
+
102
+ .avatar {
103
+ width: 32px; height: 32px;
104
+ border-radius: 8px;
105
+ display: flex; align-items: center; justify-content: center;
106
+ font-weight: 700; font-size: 0.65rem;
107
+ flex-shrink: 0;
108
+ font-family: var(--mono);
109
+ }
110
+ .msg.user .avatar { background: var(--accent); color: #fff; }
111
+ .msg.assistant .avatar { background: var(--surface2); color: var(--accent2); border: 1px solid var(--border); }
112
+
113
+ .bubble {
114
+ padding: 0.875rem 1.125rem;
115
+ border-radius: 12px;
116
+ line-height: 1.65;
117
+ font-size: 0.9375rem;
118
+ max-width: calc(100% - 44px);
119
+ }
120
+ .msg.user .bubble {
121
+ background: var(--accent);
122
+ color: #fff;
123
+ border-bottom-right-radius: 3px;
124
+ }
125
+ .msg.assistant .bubble {
126
+ background: var(--surface);
127
+ border: 1px solid var(--border);
128
+ border-bottom-left-radius: 3px;
129
+ color: var(--text);
130
+ position: relative;
131
+ }
132
+
133
+ /* ── Streaming cursor ─────────────────────────────────────── */
134
+ .stream-cursor {
135
+ display: inline-block;
136
+ width: 2px;
137
+ height: 1em;
138
+ background: var(--accent2);
139
+ margin-left: 2px;
140
+ vertical-align: text-bottom;
141
+ border-radius: 1px;
142
+ animation: blink 0.8s step-end infinite;
143
+ }
144
+ @keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } }
145
+
146
+ /* ── Shield monitoring bar ────────────────────────────────── */
147
+ .shield-bar {
148
+ display: flex;
149
+ align-items: center;
150
+ gap: 0.5rem;
151
+ margin-top: 0.625rem;
152
+ font-family: var(--mono);
153
+ font-size: 0.6rem;
154
+ color: var(--muted);
155
+ }
156
+ .shield-bar .spinner {
157
+ width: 10px; height: 10px;
158
+ border: 1.5px solid var(--accent2);
159
+ border-top-color: transparent;
160
+ border-radius: 50%;
161
+ animation: spin 0.8s linear infinite;
162
+ }
163
+ @keyframes spin { to { transform: rotate(360deg); } }
164
+
165
+ /* ── Threat meta row ──────────────────────────────────────── */
166
+ .msg-meta {
167
+ display: flex;
168
+ align-items: center;
169
+ gap: 0.5rem;
170
+ margin-top: 0.5rem;
171
+ flex-wrap: wrap;
172
+ }
173
+ .threat-badge {
174
+ font-family: var(--mono);
175
+ font-size: 0.6rem;
176
+ font-weight: 700;
177
+ padding: 2px 7px;
178
+ border-radius: 3px;
179
+ text-transform: uppercase;
180
+ }
181
+ .threat-clean { background: rgba(0,212,170,0.12); color: var(--accent2); border: 1px solid rgba(0,212,170,0.3); }
182
+ .threat-medium { background: rgba(255,165,2,0.12); color: var(--warn); border: 1px solid rgba(255,165,2,0.3); }
183
+ .threat-high { background: rgba(255,71,87,0.12); color: var(--danger); border: 1px solid rgba(255,71,87,0.3); }
184
+ .meta-chip {
185
+ font-family: var(--mono);
186
+ font-size: 0.57rem;
187
+ color: var(--muted);
188
+ }
189
+ .flag-chip {
190
+ font-family: var(--mono);
191
+ font-size: 0.57rem;
192
+ padding: 1px 5px;
193
+ border: 1px solid rgba(255,71,87,0.4);
194
+ color: var(--danger);
195
+ border-radius: 2px;
196
+ }
197
+
198
+ /* ── Block signal overlay ─────────────────────────────────── */
199
+ .block-signal-overlay {
200
+ margin-top: 0.625rem;
201
+ padding: 0.75rem 1rem;
202
+ background: rgba(255,71,87,0.08);
203
+ border: 1px solid rgba(255,71,87,0.4);
204
+ border-radius: 8px;
205
+ font-size: 0.8rem;
206
+ }
207
+ .block-signal-overlay strong { color: var(--danger); font-family: var(--mono); font-size: 0.7rem; }
208
+
209
+ /* Tainted text fade */
210
+ .tainted { opacity: 0.3; text-decoration: line-through; color: var(--danger); }
211
+
212
+ /* ── Blocked message ──────────────────────────────────────── */
213
+ .blocked-card {
214
+ max-width: 760px;
215
+ padding: 1rem 1.25rem;
216
+ background: rgba(255,71,87,0.07);
217
+ border: 1px solid rgba(255,71,87,0.4);
218
+ border-radius: 12px;
219
+ align-self: flex-end;
220
+ }
221
+ .blocked-card-title {
222
+ font-weight: 700;
223
+ font-size: 0.875rem;
224
+ color: var(--danger);
225
+ margin-bottom: 0.375rem;
226
+ display: flex; align-items: center; gap: 0.5rem;
227
+ }
228
+ .blocked-card-reason {
229
+ font-family: var(--mono);
230
+ font-size: 0.7rem;
231
+ color: var(--muted);
232
+ margin-bottom: 0.5rem;
233
+ }
234
+
235
+ /* ── Input area ───────────────────────────────────────────── */
236
+ .input-area {
237
+ padding: 1rem 1.5rem;
238
+ border-top: 1px solid var(--border);
239
+ background: var(--surface);
240
+ flex-shrink: 0;
241
+ }
242
+ .input-row {
243
+ max-width: 760px;
244
+ margin: 0 auto;
245
+ display: flex;
246
+ gap: 0.75rem;
247
+ align-items: flex-end;
248
+ }
249
+ .input-wrap {
250
+ flex: 1;
251
+ background: var(--surface2);
252
+ border: 1px solid var(--border);
253
+ border-radius: 10px;
254
+ padding: 0.75rem 1rem;
255
+ transition: border-color 0.2s;
256
+ }
257
+ .input-wrap:focus-within { border-color: var(--accent); }
258
+ textarea {
259
+ width: 100%;
260
+ background: transparent;
261
+ border: none;
262
+ outline: none;
263
+ font-family: var(--sans);
264
+ font-size: 0.9375rem;
265
+ color: var(--text);
266
+ resize: none;
267
+ min-height: 24px;
268
+ max-height: 160px;
269
+ line-height: 1.5;
270
+ }
271
+ textarea::placeholder { color: var(--muted); }
272
+ .send-btn {
273
+ width: 44px; height: 44px;
274
+ border-radius: 10px;
275
+ border: none;
276
+ background: var(--accent);
277
+ color: #fff;
278
+ cursor: pointer;
279
+ display: flex; align-items: center; justify-content: center;
280
+ transition: background 0.2s, transform 0.1s;
281
+ flex-shrink: 0;
282
+ }
283
+ .send-btn:hover { background: #7c73ff; }
284
+ .send-btn:active { transform: scale(0.94); }
285
+ .send-btn:disabled { background: var(--border); cursor: not-allowed; }
286
+
287
+ /* ── Latency bar (below input) ────────────────────────────── */
288
+ .latency-row {
289
+ max-width: 760px;
290
+ margin: 0.5rem auto 0;
291
+ display: flex;
292
+ gap: 1rem;
293
+ font-family: var(--mono);
294
+ font-size: 0.58rem;
295
+ color: var(--muted);
296
+ }
297
+ .latency-seg { display: flex; align-items: center; gap: 0.3rem; }
298
+ .latency-dot { width: 6px; height: 6px; border-radius: 50%; }
299
+ .dot-guard { background: var(--accent); }
300
+ .dot-model { background: var(--accent2); }
301
+ .dot-monitor { background: var(--warn); }
302
+
303
+ /* ── Welcome state ────────────────────────────────────────── */
304
+ .welcome {
305
+ flex: 1;
306
+ display: flex;
307
+ flex-direction: column;
308
+ align-items: center;
309
+ justify-content: center;
310
+ text-align: center;
311
+ gap: 1rem;
312
+ color: var(--muted);
313
+ padding: 2rem;
314
+ }
315
+ .welcome-icon { font-size: 3rem; }
316
+ .welcome h2 { font-size: 1.25rem; color: var(--text); }
317
+ .welcome p { font-size: 0.875rem; max-width: 400px; line-height: 1.6; }
318
+ .pill-row { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; }
319
+ .pill {
320
+ font-family: var(--mono);
321
+ font-size: 0.65rem;
322
+ padding: 4px 10px;
323
+ border-radius: 99px;
324
+ background: var(--surface2);
325
+ border: 1px solid var(--border);
326
+ }
327
+ </style>
328
+ </head>
329
+ <body>
330
+
331
+ <header class="header">
332
+ <div class="header-brand">
333
+ <div class="shield-icon">πŸ›‘οΈ</div>
334
+ <div>
335
+ <h1>GenAI Shield <span style="color:var(--accent)">Sidecar</span></h1>
336
+ <div class="header-sub">Parallel Guard Β· Sentence Streaming Β· Background Monitor</div>
337
+ </div>
338
+ </div>
339
+ <div class="header-actions">
340
+ <div class="mode-badge">Sidecar v2</div>
341
+ <div class="status-dot" id="status-dot" title="Guard ready"></div>
342
+ </div>
343
+ </header>
344
+
345
+ <div class="chat-area" id="chat-area">
346
+ <div class="welcome" id="welcome-screen">
347
+ <div class="welcome-icon">πŸ›‘οΈ</div>
348
+ <h2>Sidecar Proxy Active</h2>
349
+ <p>Your prompt is guarded <em>before</em> the LLM sees it, and every sentence of the response is monitored <em>as it streams</em> β€” with zero added latency on the happy path.</p>
350
+ <div class="pill-row">
351
+ <span class="pill">⚑ Parallel guard + LLM</span>
352
+ <span class="pill">πŸ“‘ Sentence-level SSE</span>
353
+ <span class="pill">πŸ” Background monitor</span>
354
+ <span class="pill">🚫 Mid-stream block signal</span>
355
+ </div>
356
+ </div>
357
+ </div>
358
+
359
+ <div class="input-area">
360
+ <div class="input-row">
361
+ <div class="input-wrap">
362
+ <textarea
363
+ id="prompt-input"
364
+ rows="1"
365
+ placeholder="Enter a prompt β€” the shield and the LLM start together..."
366
+ onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}"
367
+ oninput="autoResize(this)"
368
+ ></textarea>
369
+ </div>
370
+ <button class="send-btn" id="send-btn" onclick="sendMessage()" title="Send">
371
+ <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor">
372
+ <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
373
+ </svg>
374
+ </button>
375
+ </div>
376
+ <div class="latency-row" id="latency-row" style="display:none">
377
+ <span class="latency-seg"><span class="latency-dot dot-guard"></span><span id="lat-guard">Guard: β€”</span></span>
378
+ <span class="latency-seg"><span class="latency-dot dot-model"></span><span id="lat-model">Stream: β€”</span></span>
379
+ <span class="latency-seg"><span class="latency-dot dot-monitor"></span><span id="lat-monitor">Monitor: background</span></span>
380
+ </div>
381
+ </div>
382
+
383
+ <script>
384
+ // ── Config ────────────────────────────────────────────────────────────────────
385
+ const SIDECAR_URL = '/v1/chat'; // proxied through Flask or direct to :5050
386
+
387
+ // ── Helpers ───────────────────────────────────────────────────────────────────
388
+ const chatArea = document.getElementById('chat-area');
389
+ const input = document.getElementById('prompt-input');
390
+ const sendBtn = document.getElementById('send-btn');
391
+ const latRow = document.getElementById('latency-row');
392
+
393
+ function esc(s) {
394
+ return String(s)
395
+ .replace(/&/g,'&amp;')
396
+ .replace(/</g,'&lt;')
397
+ .replace(/>/g,'&gt;')
398
+ .replace(/\n/g,'<br>');
399
+ }
400
+
401
+ function autoResize(el) {
402
+ el.style.height = 'auto';
403
+ el.style.height = Math.min(el.scrollHeight, 160) + 'px';
404
+ }
405
+
406
+ function scrollBottom() {
407
+ chatArea.scrollTop = chatArea.scrollHeight;
408
+ }
409
+
410
+ function hideWelcome() {
411
+ const w = document.getElementById('welcome-screen');
412
+ if (w) w.remove();
413
+ }
414
+
415
+ function setLatency(guard, total) {
416
+ latRow.style.display = 'flex';
417
+ document.getElementById('lat-guard').textContent = `Guard: ${guard || 'β€”'}ms`;
418
+ document.getElementById('lat-model').textContent = `Total: ${total || 'β€”'}ms`;
419
+ }
420
+
421
+ // ── Append user bubble ─────────────────────────────────────────────────────────
422
+ function appendUser(text) {
423
+ const div = document.createElement('div');
424
+ div.className = 'msg user';
425
+ div.innerHTML = `
426
+ <div class="avatar">U</div>
427
+ <div class="bubble">${esc(text)}</div>
428
+ `;
429
+ chatArea.appendChild(div);
430
+ scrollBottom();
431
+ }
432
+
433
+ // ── Append assistant streaming bubble ─────────────────────────────────────────
434
+ function createAssistantBubble() {
435
+ const div = document.createElement('div');
436
+ div.className = 'msg assistant';
437
+
438
+ const bubbleId = 'bubble-' + Date.now();
439
+ const metaId = 'meta-' + Date.now();
440
+ const shieldId = 'shield-' + Date.now();
441
+
442
+ div.innerHTML = `
443
+ <div class="avatar">AI</div>
444
+ <div>
445
+ <div class="bubble" id="${bubbleId}">
446
+ <span class="stream-cursor"></span>
447
+ </div>
448
+ <div class="shield-bar" id="${shieldId}">
449
+ <div class="spinner"></div>
450
+ <span>Shield monitoring output…</span>
451
+ </div>
452
+ <div class="msg-meta" id="${metaId}" style="display:none"></div>
453
+ </div>
454
+ `;
455
+ chatArea.appendChild(div);
456
+ scrollBottom();
457
+
458
+ return {
459
+ getBubble: () => document.getElementById(bubbleId),
460
+ getMeta: () => document.getElementById(metaId),
461
+ getShield: () => document.getElementById(shieldId),
462
+ bubbleId,
463
+ metaId,
464
+ shieldId,
465
+
466
+ // Sentence tracking for block signals
467
+ sentences: {}, // sentence_id β†’ { start, end } char positions not tracked easily,
468
+ // so we track spans instead
469
+ fullText: '',
470
+ };
471
+ }
472
+
473
+ // ── Block signal: taint sentences from sentence_id onward ─────────────────────
474
+ function applyBlockSignal(bubbleEl, bubbleState, signalData) {
475
+ const sid = signalData.sentence_id;
476
+
477
+ // Mark all sentence spans from sid onward as tainted
478
+ const spans = bubbleEl.querySelectorAll(`[data-sid]`);
479
+ spans.forEach(span => {
480
+ const spanSid = parseInt(span.dataset.sid, 10);
481
+ if (spanSid >= sid) span.classList.add('tainted');
482
+ });
483
+
484
+ // Append block signal banner
485
+ const overlay = document.createElement('div');
486
+ overlay.className = 'block-signal-overlay';
487
+ overlay.innerHTML = `
488
+ <strong>🚫 OUTPUT BLOCKED AT SENTENCE ${sid}</strong>
489
+ <div style="margin-top:4px;font-size:0.75rem;color:var(--muted)">${esc(signalData.reason)}</div>
490
+ <div style="margin-top:6px">${(signalData.flags||[]).map(f => `<span class="flag-chip">${f}</span>`).join(' ')}</div>
491
+ `;
492
+ bubbleEl.parentElement.insertAdjacentElement('beforeend', overlay);
493
+ }
494
+
495
+ // ── Render done meta ──────────────────────────────────────────────────────────
496
+ function renderDone(metaEl, shieldEl, doneData) {
497
+ // Hide shield spinner
498
+ shieldEl.style.display = 'none';
499
+
500
+ const score = doneData.threat_score || 0;
501
+ const cls = score >= 60 ? 'threat-high' : score >= 30 ? 'threat-medium' : 'threat-clean';
502
+ const label = score >= 60 ? 'HIGH RISK' : score >= 30 ? 'SUSPICIOUS' : 'CLEAN';
503
+
504
+ let html = `<span class="threat-badge ${cls}">${label} Β· ${score}</span>`;
505
+ html += `<span class="meta-chip">${doneData.latency_ms || 0}ms total Β· ${doneData.sentences || 0} sentences</span>`;
506
+ (doneData.flags || []).forEach(f => { html += `<span class="flag-chip">${f}</span>`; });
507
+
508
+ metaEl.innerHTML = html;
509
+ metaEl.style.display = 'flex';
510
+ }
511
+
512
+ // ── Append blocked card ───────────────────────────────────────────────────────
513
+ function appendBlocked(data) {
514
+ const div = document.createElement('div');
515
+ div.className = 'blocked-card';
516
+ div.innerHTML = `
517
+ <div class="blocked-card-title">🚫 Input Rejected by Prompt Guard</div>
518
+ <div class="blocked-card-reason">${esc(data.reason || 'GUARD_BLOCKED')}</div>
519
+ <div style="margin-top:4px">
520
+ ${(data.flags||[]).map(f => `<span class="flag-chip">${f}</span>`).join(' ')}
521
+ <span class="meta-chip" style="margin-left:8px">PG score: ${(data.pg_score||0).toFixed(3)} Β· Guard: ${data.guard_ms||0}ms</span>
522
+ </div>
523
+ `;
524
+ chatArea.appendChild(div);
525
+ scrollBottom();
526
+ }
527
+
528
+ // ── Main send logic ───────────────────────────────────────────────────────────
529
+ async function sendMessage() {
530
+ const prompt = input.value.trim();
531
+ if (!prompt) return;
532
+
533
+ hideWelcome();
534
+ input.value = '';
535
+ input.style.height = 'auto';
536
+ sendBtn.disabled = true;
537
+
538
+ appendUser(prompt);
539
+
540
+ const state = createAssistantBubble();
541
+ const bubble = state.getBubble();
542
+ const meta = state.getMeta();
543
+ const shield = state.getShield();
544
+
545
+ const tStart = performance.now();
546
+ let currentSentenceId = 0;
547
+ let blocked = false;
548
+ let guardMs = 0;
549
+
550
+ try {
551
+ const res = await fetch(SIDECAR_URL, {
552
+ method: 'POST',
553
+ headers: { 'Content-Type': 'application/json' },
554
+ body: JSON.stringify({ prompt, stream: true }),
555
+ });
556
+
557
+ if (!res.ok) {
558
+ const err = await res.json().catch(() => ({}));
559
+ shield.style.display = 'none';
560
+ bubble.innerHTML = `<span style="color:var(--danger)">Error: ${esc(err.detail || res.statusText)}</span>`;
561
+ sendBtn.disabled = false;
562
+ return;
563
+ }
564
+
565
+ const reader = res.body.getReader();
566
+ const decoder = new TextDecoder();
567
+ let rawBuf = '';
568
+
569
+ // Remove blinking cursor initially placed
570
+ bubble.innerHTML = '';
571
+
572
+ while (true) {
573
+ const { done, value } = await reader.read();
574
+ if (done) break;
575
+
576
+ rawBuf += decoder.decode(value, { stream: true });
577
+
578
+ // Parse SSE lines
579
+ const lines = rawBuf.split('\n');
580
+ rawBuf = lines.pop(); // keep incomplete line
581
+
582
+ for (const line of lines) {
583
+ if (!line.startsWith('data: ')) continue;
584
+ let ev;
585
+ try { ev = JSON.parse(line.slice(6)); } catch { continue; }
586
+
587
+ switch (ev.type) {
588
+
589
+ // ── Raw token chunk β†’ append immediately ──────────────
590
+ case 'chunk': {
591
+ // We actually render sentence spans, not raw chunks
592
+ // Chunks are for the browser but we track sentences for guard
593
+ // Simple approach: append text nodes directly
594
+ bubble.appendChild(document.createTextNode(ev.text));
595
+ scrollBottom();
596
+ break;
597
+ }
598
+
599
+ // ── Complete sentence β†’ wrap in span with data-sid ────
600
+ case 'sentence': {
601
+ // Replace plain text accumulated in bubble with a tracked span
602
+ // Strategy: clear bubble's text nodes, re-render as spans
603
+ currentSentenceId = ev.sentence_id;
604
+
605
+ // Find last text node and replace with span
606
+ const lastChild = bubble.lastChild;
607
+ if (lastChild && lastChild.nodeType === Node.TEXT_NODE) {
608
+ bubble.removeChild(lastChild);
609
+ }
610
+ const span = document.createElement('span');
611
+ span.dataset.sid = ev.sentence_id;
612
+ span.textContent = ev.text + ' ';
613
+ bubble.appendChild(span);
614
+ scrollBottom();
615
+ break;
616
+ }
617
+
618
+ // ── Guard blocked the prompt (pre-inference) ───���──────
619
+ case 'blocked': {
620
+ blocked = true;
621
+ guardMs = ev.guard_ms || 0;
622
+
623
+ // Remove the assistant bubble we created
624
+ bubble.closest('.msg').remove();
625
+ appendBlocked(ev);
626
+ setLatency(ev.guard_ms, ev.guard_ms);
627
+ break;
628
+ }
629
+
630
+ // ── Mid-stream block signal (post-monitor) ────────────
631
+ case 'block_signal': {
632
+ applyBlockSignal(bubble, state, ev);
633
+ break;
634
+ }
635
+
636
+ // ── Stream done ───────────────────────────────────────
637
+ case 'done': {
638
+ // Remove trailing cursor if present
639
+ const cursor = bubble.querySelector('.stream-cursor');
640
+ if (cursor) cursor.remove();
641
+
642
+ renderDone(meta, shield, ev);
643
+ setLatency(null, ev.latency_ms);
644
+ break;
645
+ }
646
+ }
647
+ }
648
+ }
649
+
650
+ } catch (err) {
651
+ shield.style.display = 'none';
652
+ bubble.innerHTML = `<span style="color:var(--danger)">Connection error: ${esc(err.message)}</span>`;
653
+ }
654
+
655
+ sendBtn.disabled = false;
656
+ input.focus();
657
+ }
658
+
659
+ // ── Health check on load ──────────────────────────────────────────────────────
660
+ (async () => {
661
+ try {
662
+ const r = await fetch('/v1/health');
663
+ const d = await r.json();
664
+ if (!d.guard_ready) {
665
+ document.getElementById('status-dot').style.background = 'var(--warn)';
666
+ document.getElementById('status-dot').style.boxShadow = '0 0 6px var(--warn)';
667
+ document.getElementById('status-dot').title = 'Guard not ready';
668
+ }
669
+ } catch {
670
+ document.getElementById('status-dot').style.background = 'var(--danger)';
671
+ document.getElementById('status-dot').title = 'Sidecar unreachable';
672
+ }
673
+ })();
674
+ </script>
675
+ </body>
676
+ </html>