loopshield 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loopshield/proxy.py ADDED
@@ -0,0 +1,376 @@
1
+ """OpenAI- and Anthropic-compatible proxy that screens agent loops before upstream calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import uuid
9
+ from collections.abc import AsyncIterator, Mapping
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.responses import JSONResponse, StreamingResponse
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from loopshield.mitigator import estimate_tokens, prune_context
18
+ from loopshield.shield import LoopShield
19
+ from loopshield.transcript import content_text as _content_text
20
+ from loopshield.transcript import messages_to_steps as _messages_to_steps
21
+ from loopshield.transcript import nudge_for as _nudge_for
22
+
23
+ _FORWARD_HEADERS = (
24
+ "authorization",
25
+ "x-api-key",
26
+ "anthropic-version",
27
+ "anthropic-beta",
28
+ "openai-organization",
29
+ "openai-project",
30
+ )
31
+
32
+
33
+ class ProxySettings(BaseModel):
34
+ """Runtime configuration loaded from the process environment."""
35
+
36
+ model_config = ConfigDict(frozen=True)
37
+
38
+ upstream_api_base: str = "https://api.openai.com"
39
+ similarity_threshold: float = Field(default=0.92, ge=0.0, le=1.0)
40
+ max_tokens: int = Field(default=8000, ge=1)
41
+ max_cycles: int = Field(default=1, ge=0)
42
+ max_semantic_repeats: int = Field(default=2, ge=1)
43
+ timeout_seconds: float = Field(default=60.0, gt=0)
44
+
45
+ @classmethod
46
+ def from_env(cls) -> ProxySettings:
47
+ """Read the proxy settings from environment variables."""
48
+
49
+ return cls(
50
+ upstream_api_base=os.environ.get("UPSTREAM_API_BASE", "https://api.openai.com"),
51
+ similarity_threshold=float(os.environ.get("SHIELD_SIMILARITY_THRESHOLD", "0.92")),
52
+ max_tokens=int(os.environ.get("SHIELD_MAX_TOKENS", "8000")),
53
+ max_cycles=int(os.environ.get("SHIELD_MAX_CYCLES", "1")),
54
+ max_semantic_repeats=int(os.environ.get("SHIELD_MAX_SEMANTIC_REPEATS", "2")),
55
+ timeout_seconds=float(os.environ.get("SHIELD_UPSTREAM_TIMEOUT", "60")),
56
+ )
57
+
58
+
59
+ def create_app(
60
+ settings: ProxySettings | None = None,
61
+ transport: httpx.BaseTransport | None = None,
62
+ ) -> FastAPI:
63
+ """Build the proxy application.
64
+
65
+ ``transport`` is for tests that need to capture the upstream request.
66
+ Uvicorn serves the module-level ``app`` created from the environment.
67
+ """
68
+
69
+ resolved = settings or ProxySettings.from_env()
70
+ application = FastAPI(title="LoopShield Proxy", version="0.1.0")
71
+ application.state.settings = resolved
72
+ application.state.transport = transport
73
+ application.state.shield = LoopShield(
74
+ similarity_threshold=resolved.similarity_threshold,
75
+ max_cycles=resolved.max_cycles,
76
+ max_semantic_repeats=resolved.max_semantic_repeats,
77
+ max_tokens_estimate=resolved.max_tokens,
78
+ )
79
+
80
+ @application.get("/health")
81
+ async def health() -> dict[str, str]:
82
+ return {"status": "ok"}
83
+
84
+ @application.post("/v1/chat/completions", response_model=None)
85
+ async def chat_completions(request: Request) -> JSONResponse | StreamingResponse:
86
+ return await _handle_completion(request, application)
87
+
88
+ return application
89
+
90
+
91
+ app = create_app()
92
+
93
+
94
+ async def _handle_completion(request: Request, application: FastAPI) -> JSONResponse | StreamingResponse:
95
+ settings: ProxySettings = application.state.settings
96
+ payload = await _read_payload(request)
97
+ if isinstance(payload, JSONResponse):
98
+ return payload
99
+
100
+ style = _api_style(request, payload)
101
+ messages = _normalize_messages(payload, style)
102
+ if isinstance(messages, JSONResponse):
103
+ return messages
104
+
105
+ steps = _messages_to_steps(messages)
106
+ verdict = application.state.shield.inspect(steps)
107
+ if verdict.is_loop:
108
+ nudge = _nudge_for(verdict, steps)
109
+ if bool(payload.get("stream")):
110
+ return StreamingResponse(
111
+ _intercepted_stream(style, str(payload.get("model") or "loopshield"), nudge, verdict.loop_type),
112
+ media_type="text/event-stream",
113
+ )
114
+ body = _intercepted_body(style, str(payload.get("model") or "loopshield"), nudge, verdict.loop_type, messages)
115
+ return JSONResponse(body)
116
+
117
+ screened = _screen_messages(messages, settings.max_tokens)
118
+ outbound = _outbound_payload(payload, screened, style)
119
+ return await _forward(request, settings, application.state.transport, outbound, style)
120
+
121
+
122
+ async def _read_payload(request: Request) -> dict[str, Any] | JSONResponse:
123
+ try:
124
+ payload = await request.json()
125
+ except json.JSONDecodeError:
126
+ return _error("Request body must be valid JSON.", anthropic=False, status_code=400)
127
+ if not isinstance(payload, dict):
128
+ return _error("Request body must be a JSON object.", anthropic=False, status_code=400)
129
+ return payload
130
+
131
+
132
+ def _api_style(request: Request, payload: Mapping[str, Any]) -> str:
133
+ if request.headers.get("anthropic-version"):
134
+ return "anthropic"
135
+ if "system" in payload and "max_tokens" in payload:
136
+ return "anthropic"
137
+ for message in payload.get("messages") or []:
138
+ content = message.get("content") if isinstance(message, Mapping) else None
139
+ if isinstance(content, list) and any(
140
+ isinstance(part, Mapping) and part.get("type") in {"tool_use", "tool_result"}
141
+ for part in content
142
+ ):
143
+ return "anthropic"
144
+ return "openai"
145
+
146
+
147
+ def _normalize_messages(
148
+ payload: Mapping[str, Any],
149
+ style: str,
150
+ ) -> list[dict[str, Any]] | JSONResponse:
151
+ raw_messages = payload.get("messages")
152
+ if not isinstance(raw_messages, list) or not raw_messages:
153
+ return _error("messages must be a non-empty list.", anthropic=style == "anthropic", status_code=400)
154
+ if any(not isinstance(message, dict) for message in raw_messages):
155
+ return _error("every message must be an object.", anthropic=style == "anthropic", status_code=400)
156
+
157
+ messages = [dict(message) for message in raw_messages]
158
+ if style == "anthropic":
159
+ system = payload.get("system")
160
+ if isinstance(system, str) and system.strip():
161
+ messages.insert(0, {"role": "system", "content": system})
162
+ elif isinstance(system, list) and system:
163
+ messages.insert(0, {"role": "system", "content": system})
164
+ if not str(payload.get("model") or "").strip():
165
+ return _error("model is required.", anthropic=style == "anthropic", status_code=400)
166
+ return messages
167
+
168
+
169
+ def _screen_messages(messages: list[dict[str, Any]], max_tokens: int) -> list[dict[str, Any]]:
170
+ if estimate_tokens(messages) <= max_tokens:
171
+ return messages
172
+ return prune_context(messages, max_tokens)
173
+
174
+
175
+ def _outbound_payload(
176
+ payload: Mapping[str, Any],
177
+ messages: list[dict[str, Any]],
178
+ style: str,
179
+ ) -> dict[str, Any]:
180
+ outbound = dict(payload)
181
+ if style == "openai":
182
+ outbound["messages"] = messages
183
+ return outbound
184
+ system, rest = _split_leading_system(messages)
185
+ if system:
186
+ contents = [_content_text(message.get("content")) for message in system]
187
+ outbound["system"] = "\n\n".join(part for part in contents if part)
188
+ else:
189
+ outbound.pop("system", None)
190
+ outbound["messages"] = [_anthropic_message(message) for message in rest]
191
+ return outbound
192
+
193
+
194
+ def _anthropic_message(message: dict[str, Any]) -> dict[str, Any]:
195
+ if str(message.get("role", "")).casefold() != "system":
196
+ return message
197
+ summary = _content_text(message.get("content"))
198
+ return {"role": "user", "content": f"LoopShield context summary: {summary}"}
199
+
200
+
201
+ def _split_leading_system(
202
+ messages: list[dict[str, Any]],
203
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
204
+ index = 0
205
+ while index < len(messages) and str(messages[index].get("role", "")).casefold() == "system":
206
+ index += 1
207
+ return messages[:index], messages[index:]
208
+
209
+
210
+ async def _forward(
211
+ request: Request,
212
+ settings: ProxySettings,
213
+ transport: httpx.BaseTransport | None,
214
+ payload: dict[str, Any],
215
+ style: str,
216
+ ) -> JSONResponse | StreamingResponse:
217
+ path = "/v1/messages" if style == "anthropic" else "/v1/chat/completions"
218
+ url = _upstream_url(settings.upstream_api_base, path)
219
+ headers = _forward_headers(request)
220
+ if payload.get("stream") is True:
221
+ return StreamingResponse(
222
+ _stream_upstream(url, payload, headers, settings, transport),
223
+ media_type="text/event-stream",
224
+ )
225
+ try:
226
+ async with httpx.AsyncClient(transport=transport, timeout=settings.timeout_seconds) as client:
227
+ response = await client.post(url, json=payload, headers=headers)
228
+ except httpx.HTTPError as exc:
229
+ return _error(f"Upstream request failed: {exc}", anthropic=style == "anthropic", status_code=502)
230
+ return _upstream_json(response, style)
231
+
232
+
233
+ def _upstream_json(response: httpx.Response, style: str) -> JSONResponse:
234
+ try:
235
+ body = response.json()
236
+ except json.JSONDecodeError:
237
+ body = {"error": {"message": response.text, "type": "upstream_error"}}
238
+ return JSONResponse(body, status_code=response.status_code)
239
+
240
+
241
+ async def _stream_upstream(
242
+ url: str,
243
+ payload: dict[str, Any],
244
+ headers: dict[str, str],
245
+ settings: ProxySettings,
246
+ transport: httpx.BaseTransport | None,
247
+ ) -> AsyncIterator[bytes]:
248
+ client = httpx.AsyncClient(transport=transport, timeout=settings.timeout_seconds)
249
+ try:
250
+ async with client.stream("POST", url, json=payload, headers=headers) as response:
251
+ async for chunk in response.aiter_raw():
252
+ yield chunk
253
+ except httpx.HTTPError as exc:
254
+ message = json.dumps(_error_body(f"Upstream request failed: {exc}", anthropic=False))
255
+ yield f"data: {message}\n\n".encode()
256
+ finally:
257
+ await client.aclose()
258
+
259
+
260
+ def _forward_headers(request: Request) -> dict[str, str]:
261
+ headers: dict[str, str] = {}
262
+ for name in _FORWARD_HEADERS:
263
+ value = request.headers.get(name)
264
+ if value:
265
+ headers[name] = value
266
+ return headers
267
+
268
+
269
+ def _upstream_url(base: str, path: str) -> str:
270
+ root = base.strip().rstrip("/")
271
+ if not root:
272
+ raise ValueError("UPSTREAM_API_BASE is required")
273
+ if root.endswith("/v1") and path.startswith("/v1/"):
274
+ return f"{root}{path[len('/v1'):]}"
275
+ return f"{root}{path}"
276
+
277
+
278
+ def _intercepted_body(
279
+ style: str,
280
+ model: str,
281
+ nudge: str,
282
+ loop_type: str | None,
283
+ messages: list[dict[str, Any]],
284
+ ) -> dict[str, Any]:
285
+ prompt_tokens = estimate_tokens(messages)
286
+ completion_tokens = max(1, (len(nudge) + 3) // 4)
287
+ marker = {
288
+ "intercepted": True,
289
+ "loop_type": loop_type,
290
+ "intervention_prompt": nudge,
291
+ }
292
+ if style == "anthropic":
293
+ return {
294
+ "id": f"msg_loopshield_{uuid.uuid4().hex[:24]}",
295
+ "type": "message",
296
+ "role": "assistant",
297
+ "model": model,
298
+ "content": [{"type": "text", "text": nudge}],
299
+ "stop_reason": "end_turn",
300
+ "stop_sequence": None,
301
+ "usage": {"input_tokens": prompt_tokens, "output_tokens": completion_tokens},
302
+ "loopshield": marker,
303
+ }
304
+ return {
305
+ "id": f"chatcmpl-loopshield-{uuid.uuid4().hex[:24]}",
306
+ "object": "chat.completion",
307
+ "created": int(time.time()),
308
+ "model": model,
309
+ "choices": [
310
+ {
311
+ "index": 0,
312
+ "message": {"role": "assistant", "content": nudge},
313
+ "finish_reason": "stop",
314
+ }
315
+ ],
316
+ "usage": {
317
+ "prompt_tokens": prompt_tokens,
318
+ "completion_tokens": completion_tokens,
319
+ "total_tokens": prompt_tokens + completion_tokens,
320
+ },
321
+ "loopshield": marker,
322
+ }
323
+
324
+
325
+ async def _intercepted_stream(
326
+ style: str,
327
+ model: str,
328
+ nudge: str,
329
+ loop_type: str | None,
330
+ ) -> AsyncIterator[bytes]:
331
+ if style == "anthropic":
332
+ events = [
333
+ {"type": "message_start", "message": {"id": "msg_loopshield", "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "usage": {"input_tokens": 0, "output_tokens": 0}}},
334
+ {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
335
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": nudge}},
336
+ {"type": "content_block_stop", "index": 0},
337
+ {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": max(1, (len(nudge) + 3) // 4)}},
338
+ {"type": "message_stop"},
339
+ ]
340
+ for event in events:
341
+ name = str(event["type"])
342
+ yield f"event: {name}\ndata: {json.dumps(event)}\n\n".encode()
343
+ return
344
+
345
+ created = int(time.time())
346
+ completion_id = f"chatcmpl-loopshield-{uuid.uuid4().hex[:24]}"
347
+ chunks = [
348
+ {
349
+ "id": completion_id,
350
+ "object": "chat.completion.chunk",
351
+ "created": created,
352
+ "model": model,
353
+ "choices": [{"index": 0, "delta": {"role": "assistant", "content": nudge}, "finish_reason": None}],
354
+ "loopshield": {"intercepted": True, "loop_type": loop_type, "intervention_prompt": nudge},
355
+ },
356
+ {
357
+ "id": completion_id,
358
+ "object": "chat.completion.chunk",
359
+ "created": created,
360
+ "model": model,
361
+ "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
362
+ },
363
+ ]
364
+ for chunk in chunks:
365
+ yield f"data: {json.dumps(chunk)}\n\n".encode()
366
+ yield b"data: [DONE]\n\n"
367
+
368
+
369
+ def _error(message: str, *, anthropic: bool, status_code: int) -> JSONResponse:
370
+ return JSONResponse(_error_body(message, anthropic=anthropic), status_code=status_code)
371
+
372
+
373
+ def _error_body(message: str, *, anthropic: bool) -> dict[str, Any]:
374
+ if anthropic:
375
+ return {"type": "error", "error": {"type": "invalid_request_error", "message": message}}
376
+ return {"error": {"message": message, "type": "invalid_request_error", "code": "invalid_request_error"}}
loopshield/py.typed ADDED
File without changes
@@ -0,0 +1,142 @@
1
+ """Cosine-similarity detection of repeating agent thoughts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ from collections import Counter
8
+ from collections.abc import Callable, Sequence
9
+
10
+ import numpy as np
11
+
12
+ from loopshield.models import AgentStep
13
+
14
+ EmbedFn = Callable[[str], list[float]]
15
+
16
+ _TOKEN = re.compile(r"[a-z0-9]+")
17
+
18
+
19
+ def tokenize(text: str) -> list[str]:
20
+ """Split text into lowercase word tokens."""
21
+
22
+ return _TOKEN.findall(text.casefold())
23
+
24
+
25
+ def tfidf_embed(texts: Sequence[str]) -> list[list[float]]:
26
+ """Embed texts with a shared bag-of-words TF-IDF vocabulary.
27
+
28
+ This is the lightweight fallback used when no embedding model is injected.
29
+ Identical thoughts land on the same vector. A text with no tokens becomes
30
+ a one-dimensional zero vector.
31
+ """
32
+
33
+ documents = [tokenize(text) for text in texts]
34
+ vocabulary: dict[str, int] = {}
35
+ for tokens in documents:
36
+ for token in tokens:
37
+ if token not in vocabulary:
38
+ vocabulary[token] = len(vocabulary)
39
+ if not vocabulary:
40
+ return [[0.0] for _ in texts]
41
+
42
+ document_frequency = [0] * len(vocabulary)
43
+ for tokens in documents:
44
+ for token in set(tokens):
45
+ document_frequency[vocabulary[token]] += 1
46
+
47
+ document_count = len(documents)
48
+ vectors: list[list[float]] = []
49
+ for tokens in documents:
50
+ counts = Counter(tokens)
51
+ length = len(tokens) or 1
52
+ vector = [0.0] * len(vocabulary)
53
+ for token, count in counts.items():
54
+ term_frequency = count / length
55
+ inverse_document_frequency = math.log(
56
+ (1.0 + document_count) / (1.0 + document_frequency[vocabulary[token]])
57
+ ) + 1.0
58
+ vector[vocabulary[token]] = term_frequency * inverse_document_frequency
59
+ vectors.append(vector)
60
+ return vectors
61
+
62
+
63
+ def cosine_similarity(left: Sequence[float], right: Sequence[float]) -> float:
64
+ """Return the cosine similarity of two vectors, clipped to [-1, 1]."""
65
+
66
+ left_vector = np.asarray(left, dtype=np.float64).reshape(-1)
67
+ right_vector = np.asarray(right, dtype=np.float64).reshape(-1)
68
+ if left_vector.size == 0 or right_vector.size == 0:
69
+ raise ValueError("Embedding must contain at least one dimension.")
70
+ if left_vector.shape != right_vector.shape:
71
+ raise ValueError(
72
+ "Embedding dimensions differ: "
73
+ f"{left_vector.shape[0]} and {right_vector.shape[0]}"
74
+ )
75
+ denominator = float(np.linalg.norm(left_vector) * np.linalg.norm(right_vector))
76
+ if denominator == 0.0:
77
+ return 0.0
78
+ score = float(np.dot(left_vector, right_vector) / denominator)
79
+ if not math.isfinite(score):
80
+ return 0.0
81
+ return float(np.clip(score, -1.0, 1.0))
82
+
83
+
84
+ class SemanticAnalyzer:
85
+ """Flag a thought that keeps matching the consecutive thoughts before it.
86
+
87
+ Pass ``embed_fn`` to plug in any model that maps text to a vector. When it
88
+ is omitted, thoughts in the call are embedded together with TF-IDF
89
+ bag-of-words.
90
+
91
+ Walking backward from the current thought, each recent thought whose cosine
92
+ similarity exceeds ``similarity_threshold`` extends a streak. The streak
93
+ stops at the first thought that does not exceed it. A semantic loop is
94
+ flagged when that streak is at least ``max_semantic_repeats`` long.
95
+ """
96
+
97
+ def __init__(
98
+ self,
99
+ embed_fn: EmbedFn | None = None,
100
+ *,
101
+ similarity_threshold: float = 0.92,
102
+ max_semantic_repeats: int = 2,
103
+ ) -> None:
104
+ if not 0.0 <= similarity_threshold <= 1.0:
105
+ raise ValueError("similarity_threshold must be between 0 and 1")
106
+ if max_semantic_repeats < 1:
107
+ raise ValueError("max_semantic_repeats must be at least 1")
108
+ self.embed_fn = embed_fn
109
+ self.similarity_threshold = similarity_threshold
110
+ self.max_semantic_repeats = max_semantic_repeats
111
+
112
+ def similarity_streak(self, steps: Sequence[AgentStep]) -> int:
113
+ """Return how many consecutive thoughts before the latest one exceed the threshold.
114
+
115
+ Comparison starts at the current thought and walks backward. The first
116
+ recent thought that does not exceed the threshold ends the streak.
117
+ """
118
+
119
+ if len(steps) < 2:
120
+ return 0
121
+ vectors = self._embed([step.thought for step in steps])
122
+ streak = 0
123
+ current = vectors[-1]
124
+ for previous in range(len(vectors) - 2, -1, -1):
125
+ similarity = cosine_similarity(current, vectors[previous])
126
+ if similarity > self.similarity_threshold:
127
+ streak += 1
128
+ else:
129
+ break
130
+ return streak
131
+
132
+ def detect(self, steps: Sequence[AgentStep]) -> bool:
133
+ """Return True when a similar streak reaches ``max_semantic_repeats``."""
134
+
135
+ return self.similarity_streak(steps) >= self.max_semantic_repeats
136
+
137
+ def _embed(self, thoughts: Sequence[str]) -> list[np.ndarray]:
138
+ if self.embed_fn is None:
139
+ embedded = tfidf_embed(thoughts)
140
+ else:
141
+ embedded = [self.embed_fn(thought) for thought in thoughts]
142
+ return [np.asarray(vector, dtype=np.float64).reshape(-1) for vector in embedded]