Spaces:
Runtime error
Runtime error
File size: 8,949 Bytes
02422e3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 | """
sidecar/gate.py β Concurrent Guard + LLM Fan-Out Gate.
The Gate is the heart of the sidecar. It starts the Prompt Guard check
and the LLM stream simultaneously, then:
β’ If Guard BLOCKS β cancel the LLM stream, yield a BlockEvent, stop.
β’ If Guard PASSES β open the gate; yield all buffered + live tokens.
This means the guard's latency (~50β150ms) is hidden inside the LLM's
time-to-first-token (~300β600ms). On the happy path the user effectively
sees ZERO added latency from the guard.
ββββββββββββββββββββββββββββββββββ
Client ββpromptβββΊβ Gate.run() β
β β
β Task A: guard.screen(prompt) β ~100ms
β Task B: adapter.stream_chat() β ~500ms TTFT
β β
β Guard finishes first? β
β blocked=True β cancel B ββββΊ BlockEvent
β blocked=False β open gate ββββΊ yield buffered tokens
β ββββΊ yield live tokensβ¦
ββββββββββββββββββββββββββββββββββ
"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import AsyncGenerator, List, Optional, Union
log = logging.getLogger(__name__)
# ββ Gate events βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@dataclass
class TokenEvent:
"""A raw token chunk from the LLM β pass through to client."""
text: str
type: str = field(default="token", init=False)
@dataclass
class BlockEvent:
"""Guard blocked the prompt β LLM never delivered output to client."""
reason: str
threat_score: int
flags: List[str]
pg_score: float
guard_ms: float
type: str = field(default="blocked", init=False)
GateEvent = Union[TokenEvent, BlockEvent]
# ββ The Gate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
class ShieldGate:
"""
Orchestrates concurrent Guard check + LLM stream with a buffering gate.
Args:
guard: PromptGuardTextGuard instance.
adapter: LLMAdapter with stream_chat().
guard_timeout_sec: Max seconds to wait for guard before passing anyway.
(Safety valve β guard should always be < 300ms.)
"""
def __init__(self, guard, adapter, guard_timeout_sec: float = 3.0):
self._guard = guard
self._adapter = adapter
self._timeout = guard_timeout_sec
async def run(
self,
prompt: str,
system_prompt: str = "",
trace=None, # Optional[RequestTrace] β for pipeline telemetry
) -> AsyncGenerator[GateEvent, None]:
"""
Async generator β yields GateEvents to the SSE layer.
Yields either a single BlockEvent (if guard fires) or a stream of
TokenEvents followed by nothing (caller handles done/close).
"""
t_start = time.perf_counter()
# ββ Token buffer β accumulates LLM tokens while guard decides βββββ
token_buffer: List[str] = []
buffer_lock = asyncio.Lock()
first_token_seen = False
# ββ Task A: Guard (runs in thread pool β it's synchronous) ββββββββ
async def run_guard() -> dict:
if trace:
trace.on_guard_start()
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, self._guard.screen, prompt)
return result
# ββ Task B: LLM stream β feeds token_buffer until gate opens ββββββ
async def run_stream():
nonlocal first_token_seen
if trace:
trace.on_llm_start()
try:
token_count = 0
total_chars = 0
async for token in self._adapter.stream_chat(prompt, system_prompt or None):
if not first_token_seen:
first_token_seen = True
if trace:
trace.on_first_token()
async with buffer_lock:
token_buffer.append(token)
token_count += 1
total_chars += len(token)
# Emit a token-flow tick every 10 tokens for the dashboard
if trace and token_count % 10 == 0:
trace.on_token_tick(token_count, total_chars)
except asyncio.CancelledError:
pass # Guard blocked β stream cancelled cleanly
# Start both tasks concurrently
guard_task = asyncio.create_task(run_guard(), name="shield-guard")
stream_task = asyncio.create_task(run_stream(), name="llm-stream")
# ββ Wait for guard verdict βββββββββββββββββββββββββββββββββββββββββ
try:
guard_result = await asyncio.wait_for(
asyncio.shield(guard_task),
timeout=self._timeout,
)
except asyncio.TimeoutError:
log.error("[Gate] Guard timed out after %.1fs β defaulting to PASS", self._timeout)
guard_result = {"blocked": False, "threat_score": 0, "flags": [], "checks": {}}
guard_ms = round((time.perf_counter() - t_start) * 1000, 2)
pg_score = guard_result.get("checks", {}).get("prompt_guard", {}).get("malicious_score", 0.0)
# ββ Guard says BLOCK βββββββββββββββββββββββββββββββββββββββββββββββ
if guard_result.get("blocked"):
stream_task.cancel()
try:
await stream_task
except (asyncio.CancelledError, Exception):
pass
log.warning(
"[Gate] Prompt BLOCKED β score=%.3f flags=%s latency=%.0fms",
pg_score, guard_result.get("flags", []), guard_ms,
)
if trace:
trace.on_guard_block(
pg_score = pg_score,
threat_score = guard_result.get("threat_score", 100),
reason = guard_result.get("reason", "GUARD_BLOCKED"),
flags = guard_result.get("flags", []),
)
yield BlockEvent(
reason = guard_result.get("reason", "GUARD_BLOCKED"),
threat_score = guard_result.get("threat_score", 100),
flags = guard_result.get("flags", []),
pg_score = pg_score,
guard_ms = guard_ms,
)
return
# ββ Guard says PASS β drain buffer then yield live tokens ββββββββββ
log.info(
"[Gate] Prompt PASSED β score=%.3f latency=%.0fms β opening gate",
pg_score, guard_ms,
)
if trace:
trace.on_guard_pass(
pg_score = pg_score,
threat_score = guard_result.get("threat_score", 0),
flags = guard_result.get("flags", []),
)
# Drain anything buffered while guard was deciding
async with buffer_lock:
buffered = token_buffer.copy()
token_buffer.clear()
for token in buffered:
yield TokenEvent(text=token)
# Wait for stream task to complete, yielding live tokens as they arrive
# We poll the buffer so we can yield from the async generator context
while not stream_task.done():
await asyncio.sleep(0.005) # 5ms poll β tight enough for streaming
async with buffer_lock:
live = token_buffer.copy()
token_buffer.clear()
for token in live:
yield TokenEvent(text=token)
# Final drain after stream task completes
async with buffer_lock:
for token in token_buffer:
yield TokenEvent(text=token)
# Propagate any stream errors
if not stream_task.cancelled() and stream_task.exception():
log.error("[Gate] LLM stream error: %s", stream_task.exception())
|