overwing 0.1.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.
overwing/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Overwing: guardrails for LLM output. https://overwing.ai"""
2
+
3
+ from ._client import AsyncOverwing, Overwing
4
+ from ._errors import OverwingError
5
+ from ._types import BatchResult, Evaluation, RuleResult, Verdict
6
+
7
+ __all__ = ["AsyncOverwing", "BatchResult", "Evaluation", "Overwing", "OverwingError", "RuleResult", "Verdict"]
8
+ __version__ = "0.1.0"
overwing/_client.py ADDED
@@ -0,0 +1,181 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import time
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from ._errors import OverwingError
10
+ from ._types import BatchResult, Evaluation
11
+
12
+ DEFAULT_BASE_URL = "https://overwing.ai"
13
+ _USER_AGENT = "overwing-python/0.1.0"
14
+
15
+
16
+ def _resolve(api_key: str | None, base_url: str | None) -> tuple[str, str]:
17
+ key = api_key or os.environ.get("OVERWING_API_KEY")
18
+ if not key:
19
+ raise OverwingError("Overwing API key missing. Pass api_key= or set OVERWING_API_KEY. Get one at https://overwing.ai/login")
20
+ return key, (base_url or os.environ.get("OVERWING_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
21
+
22
+
23
+ def _error_from(res: httpx.Response) -> OverwingError:
24
+ try:
25
+ message = str(res.json().get("error", f"HTTP {res.status_code}"))
26
+ except ValueError:
27
+ message = f"HTTP {res.status_code}"
28
+ ra = res.headers.get("retry-after")
29
+ return OverwingError(message, res.status_code, float(ra) if ra else None)
30
+
31
+
32
+ def _retryable(res: httpx.Response) -> bool:
33
+ if res.status_code == 429:
34
+ ra = res.headers.get("retry-after")
35
+ return ra is None or float(ra) <= 5
36
+ return res.status_code >= 500
37
+
38
+
39
+ class _Base:
40
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None, timeout: float = 15.0, max_retries: int = 2) -> None:
41
+ self._api_key, self.base_url = _resolve(api_key, base_url)
42
+ self._timeout = timeout
43
+ self._max_retries = max_retries
44
+
45
+ def _headers(self, idempotency_key: str | None = None, json_body: bool = False) -> dict[str, str]:
46
+ h = {"Authorization": f"Bearer {self._api_key}", "Accept": "application/json", "User-Agent": _USER_AGENT}
47
+ if json_body:
48
+ h["Content-Type"] = "application/json"
49
+ if idempotency_key:
50
+ h["Idempotency-Key"] = idempotency_key
51
+ return h
52
+
53
+
54
+ class Overwing(_Base):
55
+ """Synchronous client for the Overwing API."""
56
+
57
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None, timeout: float = 15.0, max_retries: int = 2, transport: httpx.BaseTransport | None = None) -> None:
58
+ super().__init__(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
59
+ self._http = httpx.Client(base_url=self.base_url, timeout=timeout, transport=transport)
60
+
61
+ def close(self) -> None:
62
+ self._http.close()
63
+
64
+ def __enter__(self) -> "Overwing":
65
+ return self
66
+
67
+ def __exit__(self, *exc: object) -> None:
68
+ self.close()
69
+
70
+ def _request(self, method: str, path: str, *, json: Any = None, idempotency_key: str | None = None, accept: tuple[int, ...] = ()) -> Any:
71
+ attempt = 0
72
+ while True:
73
+ try:
74
+ res = self._http.request(method, path, json=json, headers=self._headers(idempotency_key, json is not None))
75
+ except httpx.HTTPError as e:
76
+ if attempt < self._max_retries:
77
+ attempt += 1
78
+ time.sleep(0.25 * attempt)
79
+ continue
80
+ raise OverwingError(f"Overwing API unreachable: {e}") from e
81
+ if res.status_code in accept:
82
+ return res.json()
83
+ if _retryable(res) and attempt < self._max_retries:
84
+ attempt += 1
85
+ ra = res.headers.get("retry-after")
86
+ time.sleep(float(ra) if ra else 0.3 * attempt)
87
+ continue
88
+ if res.is_error:
89
+ raise _error_from(res)
90
+ return res.json() if res.content else None
91
+
92
+ def evaluate(self, text: str, *, rule_set: str = "content-safety", metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> Evaluation:
93
+ """Score one text. Raises OverwingError on any non-2xx."""
94
+ return Evaluation.from_dict(self._request("POST", "/api/v1/evaluate", json={"input": text, "rule_set": rule_set, "metadata": metadata}, idempotency_key=idempotency_key))
95
+
96
+ def evaluate_batch(self, items: list[dict[str, Any]], *, rule_set: str = "content-safety", idempotency_key: str | None = None) -> BatchResult:
97
+ """Score up to 50 texts. Each item: {"input": str, "id"?: str, "metadata"?: dict}."""
98
+ return BatchResult.from_dict(self._request("POST", "/api/v1/evaluate/batch", json={"rule_set": rule_set, "items": items}, idempotency_key=idempotency_key, accept=(502,)))
99
+
100
+ def get_evaluation(self, evaluation_id: str) -> dict[str, Any]:
101
+ return self._request("GET", f"/api/v1/evaluations/{evaluation_id}")
102
+
103
+ def list_rule_sets(self, include_inactive: bool = False) -> list[dict[str, Any]]:
104
+ return self._request("GET", "/api/v1/rule-sets" + ("?include_inactive=true" if include_inactive else ""))["rule_sets"]
105
+
106
+ def get_rule_set(self, slug: str) -> dict[str, Any]:
107
+ return self._request("GET", f"/api/v1/rule-sets/{slug}")
108
+
109
+ def create_rule_set(self, *, name: str, slug: str, rules: list[dict[str, Any]], description: str | None = None) -> dict[str, Any]:
110
+ return self._request("POST", "/api/v1/rule-sets", json={"name": name, "slug": slug, "rules": rules, "description": description})
111
+
112
+ def usage(self, days: int | None = None) -> dict[str, Any]:
113
+ return self._request("GET", "/api/v1/usage" + (f"?days={days}" if days else ""))
114
+
115
+ def me(self) -> dict[str, Any]:
116
+ return self._request("GET", "/api/v1/me")
117
+
118
+
119
+ class AsyncOverwing(_Base):
120
+ """Asynchronous client for the Overwing API."""
121
+
122
+ def __init__(self, api_key: str | None = None, *, base_url: str | None = None, timeout: float = 15.0, max_retries: int = 2, transport: httpx.AsyncBaseTransport | None = None) -> None:
123
+ super().__init__(api_key, base_url=base_url, timeout=timeout, max_retries=max_retries)
124
+ self._http = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, transport=transport)
125
+
126
+ async def aclose(self) -> None:
127
+ await self._http.aclose()
128
+
129
+ async def __aenter__(self) -> "AsyncOverwing":
130
+ return self
131
+
132
+ async def __aexit__(self, *exc: object) -> None:
133
+ await self.aclose()
134
+
135
+ async def _request(self, method: str, path: str, *, json: Any = None, idempotency_key: str | None = None, accept: tuple[int, ...] = ()) -> Any:
136
+ import asyncio
137
+
138
+ attempt = 0
139
+ while True:
140
+ try:
141
+ res = await self._http.request(method, path, json=json, headers=self._headers(idempotency_key, json is not None))
142
+ except httpx.HTTPError as e:
143
+ if attempt < self._max_retries:
144
+ attempt += 1
145
+ await asyncio.sleep(0.25 * attempt)
146
+ continue
147
+ raise OverwingError(f"Overwing API unreachable: {e}") from e
148
+ if res.status_code in accept:
149
+ return res.json()
150
+ if _retryable(res) and attempt < self._max_retries:
151
+ attempt += 1
152
+ ra = res.headers.get("retry-after")
153
+ await asyncio.sleep(float(ra) if ra else 0.3 * attempt)
154
+ continue
155
+ if res.is_error:
156
+ raise _error_from(res)
157
+ return res.json() if res.content else None
158
+
159
+ async def evaluate(self, text: str, *, rule_set: str = "content-safety", metadata: dict[str, Any] | None = None, idempotency_key: str | None = None) -> Evaluation:
160
+ return Evaluation.from_dict(await self._request("POST", "/api/v1/evaluate", json={"input": text, "rule_set": rule_set, "metadata": metadata}, idempotency_key=idempotency_key))
161
+
162
+ async def evaluate_batch(self, items: list[dict[str, Any]], *, rule_set: str = "content-safety", idempotency_key: str | None = None) -> BatchResult:
163
+ return BatchResult.from_dict(await self._request("POST", "/api/v1/evaluate/batch", json={"rule_set": rule_set, "items": items}, idempotency_key=idempotency_key, accept=(502,)))
164
+
165
+ async def get_evaluation(self, evaluation_id: str) -> dict[str, Any]:
166
+ return await self._request("GET", f"/api/v1/evaluations/{evaluation_id}")
167
+
168
+ async def list_rule_sets(self, include_inactive: bool = False) -> list[dict[str, Any]]:
169
+ return (await self._request("GET", "/api/v1/rule-sets" + ("?include_inactive=true" if include_inactive else "")))["rule_sets"]
170
+
171
+ async def get_rule_set(self, slug: str) -> dict[str, Any]:
172
+ return await self._request("GET", f"/api/v1/rule-sets/{slug}")
173
+
174
+ async def create_rule_set(self, *, name: str, slug: str, rules: list[dict[str, Any]], description: str | None = None) -> dict[str, Any]:
175
+ return await self._request("POST", "/api/v1/rule-sets", json={"name": name, "slug": slug, "rules": rules, "description": description})
176
+
177
+ async def usage(self, days: int | None = None) -> dict[str, Any]:
178
+ return await self._request("GET", "/api/v1/usage" + (f"?days={days}" if days else ""))
179
+
180
+ async def me(self) -> dict[str, Any]:
181
+ return await self._request("GET", "/api/v1/me")
overwing/_errors.py ADDED
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class OverwingError(Exception):
5
+ """Any non-2xx answer from the Overwing API, or a transport failure."""
6
+
7
+ def __init__(self, message: str, status: int = 0, retry_after_seconds: float | None = None) -> None:
8
+ super().__init__(message)
9
+ self.status = status
10
+ self.retry_after_seconds = retry_after_seconds
overwing/_types.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Literal
5
+
6
+ Verdict = Literal["pass", "fail", "review"]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class RuleResult:
11
+ rule: str
12
+ type: Literal["choice", "score", "noul"]
13
+ answer: str | float | bool
14
+ probability: float
15
+ confidence: float
16
+ verdict: Verdict
17
+
18
+ @classmethod
19
+ def from_dict(cls, d: dict[str, Any]) -> "RuleResult":
20
+ return cls(rule=d["rule"], type=d["type"], answer=d["answer"], probability=float(d["probability"]), confidence=float(d["confidence"]), verdict=d["verdict"])
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Evaluation:
25
+ id: str
26
+ verdict: Verdict
27
+ aggregate_score: float
28
+ confidence: float
29
+ latency_ms: int
30
+ results: list[RuleResult] = field(default_factory=list)
31
+ raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
32
+
33
+ @property
34
+ def failed_rules(self) -> list[str]:
35
+ return [r.rule for r in self.results if r.verdict == "fail"]
36
+
37
+ @property
38
+ def review_rules(self) -> list[str]:
39
+ return [r.rule for r in self.results if r.verdict == "review"]
40
+
41
+ @classmethod
42
+ def from_dict(cls, d: dict[str, Any]) -> "Evaluation":
43
+ return cls(
44
+ id=d["id"],
45
+ verdict=d["verdict"],
46
+ aggregate_score=float(d["aggregate_score"]),
47
+ confidence=float(d["confidence"]),
48
+ latency_ms=int(d["latency_ms"]),
49
+ results=[RuleResult.from_dict(r) for r in d.get("results", [])],
50
+ raw=d,
51
+ )
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class BatchItemResult:
56
+ id: str | None
57
+ index: int
58
+ evaluation: Evaluation | None
59
+ error: str | None
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class BatchResult:
64
+ total: int
65
+ passed: int
66
+ failed: int
67
+ review: int
68
+ errors: int
69
+ results: list[BatchItemResult]
70
+
71
+ @classmethod
72
+ def from_dict(cls, d: dict[str, Any]) -> "BatchResult":
73
+ s = d["summary"]
74
+ return cls(
75
+ total=s["total"], passed=s["pass"], failed=s["fail"], review=s["review"], errors=s["errors"],
76
+ results=[BatchItemResult(id=r.get("id"), index=r["index"], evaluation=Evaluation.from_dict(r["evaluation"]) if r.get("evaluation") else None, error=r.get("error")) for r in d.get("results", [])],
77
+ )
overwing/langchain.py ADDED
@@ -0,0 +1,255 @@
1
+ """Overwing guardrails for LangChain.
2
+
3
+ Two ways to use it:
4
+
5
+ 1. A runnable you pipe after your model. It scores the model's answer and,
6
+ on `fail`, raises or replaces it. This is the one to reach for.
7
+
8
+ from overwing.langchain import overwing_guard
9
+ chain = prompt | llm | overwing_guard(on_fail="replace")
10
+
11
+ 2. A callback handler that scores every LLM output (and optionally every
12
+ prompt) as it happens. Use it to log verdicts across an application or to
13
+ abort a run on `fail` without changing the chain.
14
+
15
+ from overwing.langchain import OverwingCallbackHandler
16
+ llm.invoke("...", config={"callbacks": [OverwingCallbackHandler()]})
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from typing import Any, Callable, Literal
23
+ from uuid import UUID
24
+
25
+ from langchain_core.callbacks import AsyncCallbackHandler, BaseCallbackHandler
26
+ from langchain_core.messages import AIMessage, BaseMessage
27
+ from langchain_core.outputs import LLMResult
28
+ from langchain_core.runnables import RunnableLambda
29
+
30
+ from ._client import AsyncOverwing, Overwing
31
+ from ._errors import OverwingError
32
+ from ._types import Evaluation
33
+
34
+ Action = Literal["raise", "replace", "annotate"]
35
+ DEFAULT_REPLACEMENT = "I can't share that response."
36
+
37
+
38
+ class OverwingGuardrailError(Exception):
39
+ """Raised when a verdict trips the configured action."""
40
+
41
+ def __init__(self, evaluation: Evaluation, phase: str) -> None:
42
+ detail = ", ".join(f"{r.rule}={r.verdict}" for r in evaluation.results if r.verdict != "pass") or "no rule detail"
43
+ super().__init__(f"Overwing {evaluation.verdict.upper()} on {phase} ({detail}) · {evaluation.id}")
44
+ self.evaluation = evaluation
45
+ self.phase = phase
46
+
47
+
48
+ def text_of(value: Any) -> str:
49
+ """Text from a string, a message, a list of messages, or anything else LangChain hands us."""
50
+ if isinstance(value, str):
51
+ return value.strip()
52
+ if isinstance(value, BaseMessage):
53
+ content = value.content
54
+ if isinstance(content, str):
55
+ return content.strip()
56
+ if isinstance(content, list):
57
+ return "\n".join(p.get("text", "") if isinstance(p, dict) else str(p) for p in content).strip()
58
+ return str(content).strip()
59
+ if isinstance(value, list):
60
+ return "\n".join(t for t in (text_of(v) for v in value) if t).strip()
61
+ if value is None:
62
+ return ""
63
+ try:
64
+ return json.dumps(value, default=str)
65
+ except Exception: # noqa: BLE001
66
+ return str(value)
67
+
68
+
69
+ def _decide(evaluation: Evaluation, on_fail: Action, on_review: Action) -> Action | Literal["pass"]:
70
+ if evaluation.verdict == "fail":
71
+ return on_fail
72
+ if evaluation.verdict == "review":
73
+ return on_review
74
+ return "pass"
75
+
76
+
77
+ def _annotate(value: Any, evaluation: Evaluation) -> Any:
78
+ meta = {
79
+ "id": evaluation.id,
80
+ "verdict": evaluation.verdict,
81
+ "aggregate_score": evaluation.aggregate_score,
82
+ "confidence": evaluation.confidence,
83
+ "failed_rules": evaluation.failed_rules,
84
+ "review_rules": evaluation.review_rules,
85
+ }
86
+ if isinstance(value, BaseMessage):
87
+ value.response_metadata = {**(value.response_metadata or {}), "overwing": meta}
88
+ return value
89
+
90
+
91
+ def _replace(value: Any, replacement: str) -> Any:
92
+ if isinstance(value, BaseMessage):
93
+ return AIMessage(content=replacement, response_metadata={**(value.response_metadata or {})})
94
+ return replacement
95
+
96
+
97
+ def overwing_guard(
98
+ *,
99
+ client: Overwing | None = None,
100
+ rule_set: str = "content-safety",
101
+ on_fail: Action = "raise",
102
+ on_review: Action = "annotate",
103
+ replacement: str | Callable[[Evaluation], str] = DEFAULT_REPLACEMENT,
104
+ metadata: dict[str, Any] | None = None,
105
+ on_verdict: Callable[[Evaluation, str], None] | None = None,
106
+ fail_open: bool = False,
107
+ ) -> RunnableLambda:
108
+ """A runnable that scores whatever flows through it (string, AIMessage, or list) and acts on the verdict.
109
+
110
+ - fail -> `on_fail`: "raise" (default) raises OverwingGuardrailError; "replace" swaps the text; "annotate" passes it through with the verdict on `response_metadata["overwing"]`.
111
+ - review -> `on_review`: "annotate" (default) | "raise" | "replace".
112
+ - pass -> passed through, annotated when the value is a message.
113
+ """
114
+ ow = client or Overwing()
115
+
116
+ def run(value: Any) -> Any:
117
+ text = text_of(value)
118
+ if not text:
119
+ return value
120
+ try:
121
+ evaluation = ow.evaluate(text, rule_set=rule_set, metadata={**(metadata or {}), "phase": "output", "source": "langchain"})
122
+ except OverwingError:
123
+ if fail_open:
124
+ return value
125
+ raise
126
+ if on_verdict:
127
+ on_verdict(evaluation, "output")
128
+ action = _decide(evaluation, on_fail, on_review)
129
+ if action == "raise":
130
+ raise OverwingGuardrailError(evaluation, "output")
131
+ if action == "replace":
132
+ return _annotate(_replace(value, replacement(evaluation) if callable(replacement) else replacement), evaluation)
133
+ return _annotate(value, evaluation)
134
+
135
+ return RunnableLambda(run, name="overwing_guard")
136
+
137
+
138
+ class OverwingCallbackHandler(BaseCallbackHandler):
139
+ """Scores every LLM output (and prompts when `check_input=True`).
140
+
141
+ On `fail` the handler raises OverwingGuardrailError, which aborts the run
142
+ because `raise_error` is True. Set `on_fail="log"` to only record verdicts.
143
+ """
144
+
145
+ raise_error = True
146
+
147
+ def __init__(
148
+ self,
149
+ *,
150
+ client: Overwing | None = None,
151
+ rule_set: str = "content-safety",
152
+ on_fail: Literal["raise", "log"] = "raise",
153
+ on_review: Literal["raise", "log"] = "log",
154
+ check_input: bool = False,
155
+ metadata: dict[str, Any] | None = None,
156
+ on_verdict: Callable[[Evaluation, str], None] | None = None,
157
+ fail_open: bool = False,
158
+ ) -> None:
159
+ super().__init__()
160
+ self.client = client or Overwing()
161
+ self.rule_set = rule_set
162
+ self.on_fail = on_fail
163
+ self.on_review = on_review
164
+ self.check_input = check_input
165
+ self.metadata = metadata or {}
166
+ self.on_verdict = on_verdict
167
+ self.fail_open = fail_open
168
+ self.verdicts: list[tuple[str, Evaluation]] = []
169
+
170
+ def _score(self, text: str, phase: str) -> None:
171
+ if not text:
172
+ return
173
+ try:
174
+ evaluation = self.client.evaluate(text, rule_set=self.rule_set, metadata={**self.metadata, "phase": phase, "source": "langchain-callback"})
175
+ except OverwingError:
176
+ if self.fail_open:
177
+ return
178
+ raise
179
+ self.verdicts.append((phase, evaluation))
180
+ if self.on_verdict:
181
+ self.on_verdict(evaluation, phase)
182
+ if (evaluation.verdict == "fail" and self.on_fail == "raise") or (evaluation.verdict == "review" and self.on_review == "raise"):
183
+ raise OverwingGuardrailError(evaluation, phase)
184
+
185
+ def on_llm_start(self, serialized: dict[str, Any], prompts: list[str], *, run_id: UUID, **kwargs: Any) -> None:
186
+ if self.check_input:
187
+ self._score("\n".join(prompts).strip(), "input")
188
+
189
+ def on_chat_model_start(self, serialized: dict[str, Any], messages: list[list[BaseMessage]], *, run_id: UUID, **kwargs: Any) -> None:
190
+ if self.check_input:
191
+ last = [m for batch in messages for m in batch if getattr(m, "type", "") == "human"]
192
+ self._score(text_of(last[-1]) if last else "", "input")
193
+
194
+ def on_llm_end(self, response: LLMResult, *, run_id: UUID, **kwargs: Any) -> None:
195
+ for batch in response.generations:
196
+ for gen in batch:
197
+ self._score(gen.text.strip() if gen.text else text_of(getattr(gen, "message", None)), "output")
198
+
199
+
200
+ class AsyncOverwingCallbackHandler(AsyncCallbackHandler):
201
+ """Async twin of OverwingCallbackHandler."""
202
+
203
+ raise_error = True
204
+
205
+ def __init__(
206
+ self,
207
+ *,
208
+ client: AsyncOverwing | None = None,
209
+ rule_set: str = "content-safety",
210
+ on_fail: Literal["raise", "log"] = "raise",
211
+ on_review: Literal["raise", "log"] = "log",
212
+ check_input: bool = False,
213
+ metadata: dict[str, Any] | None = None,
214
+ on_verdict: Callable[[Evaluation, str], None] | None = None,
215
+ fail_open: bool = False,
216
+ ) -> None:
217
+ super().__init__()
218
+ self.client = client or AsyncOverwing()
219
+ self.rule_set = rule_set
220
+ self.on_fail = on_fail
221
+ self.on_review = on_review
222
+ self.check_input = check_input
223
+ self.metadata = metadata or {}
224
+ self.on_verdict = on_verdict
225
+ self.fail_open = fail_open
226
+ self.verdicts: list[tuple[str, Evaluation]] = []
227
+
228
+ async def _score(self, text: str, phase: str) -> None:
229
+ if not text:
230
+ return
231
+ try:
232
+ evaluation = await self.client.evaluate(text, rule_set=self.rule_set, metadata={**self.metadata, "phase": phase, "source": "langchain-callback"})
233
+ except OverwingError:
234
+ if self.fail_open:
235
+ return
236
+ raise
237
+ self.verdicts.append((phase, evaluation))
238
+ if self.on_verdict:
239
+ self.on_verdict(evaluation, phase)
240
+ if (evaluation.verdict == "fail" and self.on_fail == "raise") or (evaluation.verdict == "review" and self.on_review == "raise"):
241
+ raise OverwingGuardrailError(evaluation, phase)
242
+
243
+ async def on_llm_start(self, serialized: dict[str, Any], prompts: list[str], *, run_id: UUID, **kwargs: Any) -> None:
244
+ if self.check_input:
245
+ await self._score("\n".join(prompts).strip(), "input")
246
+
247
+ async def on_chat_model_start(self, serialized: dict[str, Any], messages: list[list[BaseMessage]], *, run_id: UUID, **kwargs: Any) -> None:
248
+ if self.check_input:
249
+ last = [m for batch in messages for m in batch if getattr(m, "type", "") == "human"]
250
+ await self._score(text_of(last[-1]) if last else "", "input")
251
+
252
+ async def on_llm_end(self, response: LLMResult, *, run_id: UUID, **kwargs: Any) -> None:
253
+ for batch in response.generations:
254
+ for gen in batch:
255
+ await self._score(gen.text.strip() if gen.text else text_of(getattr(gen, "message", None)), "output")
@@ -0,0 +1,137 @@
1
+ """Overwing guardrails for the OpenAI Agents SDK (Python).
2
+
3
+ from agents import Agent, Runner
4
+ from overwing.openai_agents import overwing_input_guardrail, overwing_output_guardrail
5
+
6
+ agent = Agent(
7
+ name="Support",
8
+ instructions="Help the customer.",
9
+ input_guardrails=[overwing_input_guardrail()],
10
+ output_guardrails=[overwing_output_guardrail()],
11
+ )
12
+
13
+ A tripped guardrail makes the SDK raise InputGuardrailTripwireTriggered or
14
+ OutputGuardrailTripwireTriggered; the Overwing evaluation is on
15
+ ``exc.guardrail_result.output.output_info["evaluation"]``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import Any, Callable, Literal
22
+
23
+ from agents import Agent, GuardrailFunctionOutput, InputGuardrail, OutputGuardrail, RunContextWrapper
24
+
25
+ from ._client import AsyncOverwing
26
+ from ._errors import OverwingError
27
+ from ._types import Evaluation
28
+
29
+ TripOn = Literal["fail", "fail-or-review"]
30
+
31
+
32
+ def text_from_input(value: Any) -> str:
33
+ """Pull user-facing text out of the SDK's input shape (a string or a list of input items)."""
34
+ if isinstance(value, str):
35
+ return value.strip()
36
+ if not isinstance(value, list):
37
+ return ""
38
+ texts: list[str] = []
39
+ for item in value:
40
+ if not isinstance(item, dict):
41
+ continue
42
+ role = item.get("role")
43
+ if role is not None and role != "user":
44
+ continue
45
+ content = item.get("content")
46
+ if isinstance(content, str):
47
+ texts.append(content)
48
+ elif isinstance(content, list):
49
+ for part in content:
50
+ if isinstance(part, dict) and isinstance(part.get("text"), str) and part.get("type") in (None, "input_text", "text"):
51
+ texts.append(part["text"])
52
+ elif isinstance(item.get("text"), str):
53
+ texts.append(item["text"])
54
+ return "\n".join(texts).strip()
55
+
56
+
57
+ def text_from_output(value: Any) -> str:
58
+ if isinstance(value, str):
59
+ return value.strip()
60
+ if value is None:
61
+ return ""
62
+ for attr in ("model_dump", "dict"):
63
+ fn = getattr(value, attr, None)
64
+ if callable(fn):
65
+ try:
66
+ return json.dumps(fn(), default=str)
67
+ except Exception: # noqa: BLE001
68
+ break
69
+ try:
70
+ return json.dumps(value, default=str)
71
+ except Exception: # noqa: BLE001
72
+ return str(value)
73
+
74
+
75
+ class _Runner:
76
+ def __init__(self, *, client: AsyncOverwing | None, rule_set: str, trip_on: TripOn, metadata: dict[str, Any] | Callable[[], dict[str, Any]] | None, on_verdict: Callable[[Evaluation, str], None] | None, fail_open: bool) -> None:
77
+ self.client = client or AsyncOverwing()
78
+ self.rule_set = rule_set
79
+ self.trip_on = trip_on
80
+ self.metadata = metadata
81
+ self.on_verdict = on_verdict
82
+ self.fail_open = fail_open
83
+
84
+ async def run(self, text: str, phase: str) -> GuardrailFunctionOutput:
85
+ if not text:
86
+ return GuardrailFunctionOutput(output_info={"evaluation": None, "skipped": "empty"}, tripwire_triggered=False)
87
+ meta = self.metadata() if callable(self.metadata) else dict(self.metadata or {})
88
+ meta.update({"phase": phase, "source": "openai-agents"})
89
+ try:
90
+ evaluation = await self.client.evaluate(text, rule_set=self.rule_set, metadata=meta)
91
+ except OverwingError:
92
+ if self.fail_open:
93
+ return GuardrailFunctionOutput(output_info={"evaluation": None, "skipped": "unreachable"}, tripwire_triggered=False)
94
+ raise
95
+ if self.on_verdict:
96
+ self.on_verdict(evaluation, phase)
97
+ tripped = evaluation.verdict == "fail" or (self.trip_on == "fail-or-review" and evaluation.verdict == "review")
98
+ return GuardrailFunctionOutput(output_info={"evaluation": evaluation}, tripwire_triggered=tripped)
99
+
100
+
101
+ def overwing_input_guardrail(
102
+ *,
103
+ client: AsyncOverwing | None = None,
104
+ rule_set: str = "content-safety",
105
+ trip_on: TripOn = "fail",
106
+ name: str = "overwing-input",
107
+ metadata: dict[str, Any] | Callable[[], dict[str, Any]] | None = None,
108
+ on_verdict: Callable[[Evaluation, str], None] | None = None,
109
+ fail_open: bool = False,
110
+ run_in_parallel: bool = True,
111
+ ) -> InputGuardrail[Any]:
112
+ """Scores the user's input before (or alongside) the agent run."""
113
+ runner = _Runner(client=client, rule_set=rule_set, trip_on=trip_on, metadata=metadata, on_verdict=on_verdict, fail_open=fail_open)
114
+
115
+ async def guardrail(ctx: RunContextWrapper[Any], agent: Agent[Any], input: Any) -> GuardrailFunctionOutput: # noqa: A002
116
+ return await runner.run(text_from_input(input), "input")
117
+
118
+ return InputGuardrail(guardrail_function=guardrail, name=name, run_in_parallel=run_in_parallel)
119
+
120
+
121
+ def overwing_output_guardrail(
122
+ *,
123
+ client: AsyncOverwing | None = None,
124
+ rule_set: str = "content-safety",
125
+ trip_on: TripOn = "fail",
126
+ name: str = "overwing-output",
127
+ metadata: dict[str, Any] | Callable[[], dict[str, Any]] | None = None,
128
+ on_verdict: Callable[[Evaluation, str], None] | None = None,
129
+ fail_open: bool = False,
130
+ ) -> OutputGuardrail[Any]:
131
+ """Scores the agent's final output before it is returned."""
132
+ runner = _Runner(client=client, rule_set=rule_set, trip_on=trip_on, metadata=metadata, on_verdict=on_verdict, fail_open=fail_open)
133
+
134
+ async def guardrail(ctx: RunContextWrapper[Any], agent: Agent[Any], output: Any) -> GuardrailFunctionOutput:
135
+ return await runner.run(text_from_output(output), "output")
136
+
137
+ return OutputGuardrail(guardrail_function=guardrail, name=name)
overwing/py.typed ADDED
File without changes
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: overwing
3
+ Version: 0.1.0
4
+ Summary: Overwing SDK: guardrails for LLM output. Typed client for the Overwing API plus OpenAI Agents SDK guardrails and LangChain runnables/callbacks that check every message before it ships.
5
+ Project-URL: Homepage, https://overwing.ai
6
+ Project-URL: Documentation, https://overwing.ai/docs
7
+ Project-URL: Repository, https://github.com/frod27/overwing-python
8
+ Project-URL: Issues, https://github.com/frod27/overwing-python/issues
9
+ Author-email: Overwing <support@overwing.ai>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,ai-safety,content-moderation,guardrails,langchain,llm,openai-agents,overwing,pii,toxicity
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: httpx>=0.27
25
+ Provides-Extra: agents
26
+ Requires-Dist: openai-agents>=0.1; extra == 'agents'
27
+ Provides-Extra: langchain
28
+ Requires-Dist: langchain-core>=0.3; extra == 'langchain'
29
+ Description-Content-Type: text/markdown
30
+
31
+ <p align="center">
32
+ <a href="https://overwing.ai">
33
+ <picture>
34
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/frod27/overwing-python/main/assets/wordmark-dark.svg">
35
+ <img src="https://raw.githubusercontent.com/frod27/overwing-python/main/assets/wordmark.svg" alt="Overwing" width="220">
36
+ </picture>
37
+ </a>
38
+ </p>
39
+
40
+ <p align="center"><strong>Guardrails for LLM output, in one line.</strong><br>
41
+ OpenAI Agents SDK guardrails, LangChain runnables and callbacks, and a typed client. Every message gets a <code>pass</code> / <code>fail</code> / <code>review</code> verdict with calibrated confidence before it reaches your user.</p>
42
+
43
+ <p align="center">
44
+ <a href="https://pypi.org/project/overwing/"><img alt="PyPI" src="https://img.shields.io/pypi/v/overwing?color=0B1220&label=overwing"></a>
45
+ <a href="https://github.com/frod27/overwing-python/actions"><img alt="CI" src="https://github.com/frod27/overwing-python/actions/workflows/ci.yml/badge.svg"></a>
46
+ <a href="https://overwing.ai/docs"><img alt="API reference" src="https://img.shields.io/badge/API-reference-0B1220"></a>
47
+ <a href="https://overwing.ai"><img alt="agents welcome" src="https://overwing.ai/badge.svg"></a>
48
+ </p>
49
+
50
+ ---
51
+
52
+ ```bash
53
+ pip install "overwing[agents]" # OpenAI Agents SDK guardrails
54
+ pip install "overwing[langchain]" # LangChain guard runnable + callbacks
55
+ ```
56
+
57
+ Get a free API key at [overwing.ai](https://overwing.ai/login) (250 evaluations a day), or let your agent sign itself up with one `POST` to `/api/v1/signup`. Try it first with no key: paste anything into the console at [overwing.ai](https://overwing.ai).
58
+
59
+ ## OpenAI Agents SDK guardrails
60
+
61
+ ```python
62
+ from agents import Agent, Runner, InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered
63
+ from overwing.openai_agents import overwing_input_guardrail, overwing_output_guardrail
64
+
65
+ agent = Agent(
66
+ name="Support",
67
+ instructions="Help the customer.",
68
+ input_guardrails=[overwing_input_guardrail()], # scores the user's message
69
+ output_guardrails=[overwing_output_guardrail()], # scores the agent's final answer
70
+ )
71
+
72
+ try:
73
+ result = await Runner.run(agent, "Reach me at dana@example.com to sort out the refund.")
74
+ except (InputGuardrailTripwireTriggered, OutputGuardrailTripwireTriggered) as exc:
75
+ evaluation = exc.guardrail_result.output.output_info["evaluation"]
76
+ print(evaluation.verdict, evaluation.failed_rules) # "fail" ["pii_detected"]
77
+ ```
78
+
79
+ Both accept `rule_set`, `trip_on="fail" | "fail-or-review"`, `metadata`, `on_verdict`, and `fail_open`. Input guardrails run in parallel with the agent by default; pass `run_in_parallel=False` to block before the model is called. Reads `OVERWING_API_KEY` from the environment, or pass `client=AsyncOverwing(api_key=...)`.
80
+
81
+ ## LangChain
82
+
83
+ Pipe a guard after your model. It scores the answer and acts on the verdict before anything downstream sees it.
84
+
85
+ ```python
86
+ from overwing.langchain import overwing_guard, OverwingGuardrailError
87
+
88
+ chain = prompt | llm | overwing_guard(on_fail="replace") # or on_fail="raise" (default) / "annotate"
89
+ msg = chain.invoke({"question": "..."})
90
+ msg.response_metadata["overwing"] # {"verdict": "pass", "confidence": 0.97, "failed_rules": [], ...}
91
+ ```
92
+
93
+ Or observe every LLM call with a callback handler, which aborts the run on `fail`:
94
+
95
+ ```python
96
+ from overwing.langchain import OverwingCallbackHandler
97
+
98
+ handler = OverwingCallbackHandler(check_input=True) # also scores the user's prompt
99
+ llm.invoke("...", config={"callbacks": [handler]})
100
+ handler.verdicts # [("input", Evaluation), ("output", Evaluation), ...]
101
+ ```
102
+
103
+ Both accept `rule_set`, `metadata`, `on_verdict`, and `fail_open`. There is an `AsyncOverwingCallbackHandler` too.
104
+
105
+ ## Client
106
+
107
+ ```python
108
+ from overwing import Overwing, AsyncOverwing
109
+
110
+ ow = Overwing() # or Overwing(api_key="ow_live_...")
111
+
112
+ e = ow.evaluate("Reach me at dana@example.com to sort out the refund.")
113
+ e.verdict # "fail"
114
+ e.failed_rules # ["pii_detected"]
115
+ e.results[1] # RuleResult(rule="pii_detected", answer=True, confidence=0.98, verdict="fail", ...)
116
+
117
+ batch = ow.evaluate_batch([{"id": "a", "input": "..."}, {"id": "b", "input": "..."}])
118
+ ow.create_rule_set(name="Support tone", slug="support-tone", rules=[...])
119
+ ow.usage()
120
+
121
+ async with AsyncOverwing() as aow:
122
+ e = await aow.evaluate("...")
123
+ ```
124
+
125
+ `OverwingError` carries `status` and `retry_after_seconds`. 429s with a short `Retry-After` and 5xx are retried automatically. Pass `idempotency_key=` to make retries safe. Python 3.10+.
126
+
127
+ ## How verdicts work
128
+
129
+ Each rule has a fail condition, an optional review threshold, and a weight. The prebuilt `content-safety` set checks toxicity, personal data, self-harm, sexual content, and severity. **fail** means a rule matched. **review** means a rule was unsure. **pass** is everything else. Full guide: [overwing.ai/llms.txt](https://overwing.ai/llms.txt). Reference: [overwing.ai/docs](https://overwing.ai/docs).
130
+
131
+ ## Also from Overwing
132
+
133
+ - [`overwing`](https://github.com/frod27/overwing-js) on npm: the same client, a Vercel AI SDK middleware, and Agents SDK guardrails for JavaScript.
134
+ - [`overwing-mcp`](https://github.com/frod27/overwing-mcp): the guardrails as MCP tools for Claude, Cursor, and any MCP client.
135
+
136
+ MIT © Overwing. Verdicts are produced by TypeSafe's Jev System One model; Overwing is not affiliated with TypeSafe.
@@ -0,0 +1,11 @@
1
+ overwing/__init__.py,sha256=yjoApvQWTDbKPu9nSRlqL-BU26Ah_HDvN5zLkPILiqE,343
2
+ overwing/_client.py,sha256=7irHyp_t6V7rQvYmwG9ynZLR9CSnaWYSL-gi8sTjdZA,8870
3
+ overwing/_errors.py,sha256=daQrWoYNGduhq2oSXItUwv4gCjKGKlHGHgMllb20JWE,369
4
+ overwing/_types.py,sha256=pEzHS4eVydUyDfyrt9XgKTMAVQg54ZnrFkLBIKUqTck,2290
5
+ overwing/langchain.py,sha256=XtDFgPJbjuhQmPXZrZMrV4fXCzShHJTTQ8QkOdIRS-I,10262
6
+ overwing/openai_agents.py,sha256=cTu3CJxfiUKgJbxGcrbYLDNZsRjBuwzaOfhmeOggVrs,5596
7
+ overwing/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ overwing-0.1.0.dist-info/METADATA,sha256=WXcgKaR-WnVhfrpWMtKdA-A90iSH8XfZBOVCQ7AzOxk,6785
9
+ overwing-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ overwing-0.1.0.dist-info/licenses/LICENSE,sha256=OrKI5tjJZ3skIwOkce1ypaRMFLKKYH7VfdfHi0NN9oY,1079
11
+ overwing-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Overwing (overwing.ai)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.