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())