Skip to content

Commit ae5f13b

Browse files
authored
refactor(aiguard): move AI Guard to top-level ddtrace.aiguard package (#18754)
## Description Moves the AI Guard SDK out of the AppSec tree (`ddtrace/appsec/ai_guard` + `ddtrace/appsec/_ai_guard`) into a new top-level **`ddtrace/aiguard`** package, so the Python import path matches the other tracers — Node (`dd-trace/aiguard`) and Java (`datadog.trace.api.aiguard`) — and is no longer conflated with AAP/AppSec. - New package `ddtrace/aiguard/`: cross-provider core (`_listener`, `_context`, `_common`, `_streaming`, `messages`, `_api_client`, `_initialization`) at top level; provider listeners/plugins under `ddtrace/aiguard/integrations/`. - `init_ai_guard` is now `load_ai_guard` in `ddtrace/aiguard/_initialization.py`; the AppSec product loader imports it from there. - **Backwards compatibility:** `ddtrace.appsec.ai_guard` remains as a lazy re-export shim that emits `ddtrace.DDTraceDeprecationWarning` on access and forwards to `ddtrace.aiguard`. Scheduled for removal in **5.0.0**. - Tests moved to `tests/aiguard/` with their own `tests/aiguard/suitespec.yml` (split out of `tests/appsec/suitespec.yml`); `riotfile.py` test paths updated. JIRA: [APPSEC-67628](https://datadoghq.atlassian.net/browse/APPSEC-67628) ## Testing - New `tests/aiguard/api/test_compat_imports.py` asserts every public symbol is importable from the old `ddtrace.appsec.ai_guard` path, resolves to the same object as `ddtrace.aiguard`, and emits `DDTraceDeprecationWarning`. - Ran `ai_guard_api` (88 passed) and `ai_guard_strands` (130 passed) locally — the latter includes the `TestLazyImport` Strands regression test, confirming the lazy-load contract survives the move. Other suites (openai/anthropic/langchain/litellm) are unchanged mechanically; their optional SDKs aren't installed in the local base env. - `scripts/lint` `fmt` / `suitespec-check` / `error-log-check` / `riot` all pass. ## Risks - Low. Pure relocation + import rewrites behind a backwards-compatible shim; no public API contract changes. The deprecated path keeps working until 5.0.0. - Provider abort-error `__module__` identities now point to `ddtrace.aiguard.integrations.{openai,anthropic}` (used for span `error.type`). ## Additional Notes - Release note added under `deprecations`. - `docs/upgrading.rst` gains an "Upgrade to 5.0" entry; `.cursor/rules/ai-guard.mdc`, `AGENTS.md`, and the `.sg` rule updated to the new layout. [APPSEC-67628]: https://datadoghq.atlassian.net/browse/APPSEC-67628?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Co-authored-by: alberto.vara <alberto.vara@datadoghq.com>
1 parent 9f6f4ab commit ae5f13b

65 files changed

Lines changed: 1842 additions & 1652 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cursor/rules/ai-guard.mdc

Lines changed: 36 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
---
22
description: AI Guard - LLM/agentic workflow safety evaluation - how it works and development guidelines
33
globs:
4-
- "**/appsec/_ai_guard/**"
4+
- "**/aiguard/**"
55
- "**/appsec/ai_guard/**"
6-
- "**/tests/appsec/ai_guard/**"
6+
- "**/tests/aiguard/**"
77
---
88

99
# AI Guard Development Guide
@@ -27,7 +27,7 @@ and acting on its verdict.
2727
### Evaluation outcomes
2828

2929
The service returns one of three actions (see `ACTIONS` in
30-
`ddtrace/appsec/ai_guard/_api_client.py`):
30+
`ddtrace/aiguard/_api_client.py`):
3131

3232
| Action | Meaning |
3333
|--------|---------|
@@ -42,44 +42,49 @@ user code cannot accidentally swallow a block decision.
4242

4343
## Package layout
4444

45-
AI Guard lives in two sibling packages under `ddtrace/appsec/`:
45+
AI Guard lives in the top-level `ddtrace/aiguard/` package (moved out of
46+
`ddtrace/appsec/` to align with Node `dd-trace/aiguard` and Java
47+
`datadog.trace.api.aiguard`):
4648

4749
```
48-
ddtrace/appsec/
49-
├── ai_guard/ # PUBLIC SDK (user-facing, stable API)
50-
├── __init__.py # re-exports: new_ai_guard_client, AIGuardClient,
51-
# AIGuardClientError, AIGuardAbortError, Message,
52-
# Evaluation, Options, ToolCall, Function, …
53-
├── _api_client.py # AIGuardClient.evaluate() + HTTP transport + types
54-
│ └── integrations/ # framework SDK plugins resolved lazily
55-
├── litellm.py
56-
└── strands.py # AIGuardStrandsPlugin / AIGuardStrandsHookProvider
57-
58-
── _ai_guard/ # PRIVATE internals (listeners + converters)
59-
├── __init__.py # init_ai_guard(): lazy, gated on DD_AI_GUARD_ENABLED
60-
├── _listener.py # ai_guard_listen(): registers all core.on(...) hooks
61-
├── _context.py # contextvar depth counter to avoid double-scanning
62-
├── _common.py # _get(), wrap_abort_error() shared helpers
63-
├── _openai.py # OpenAI common helpers
50+
ddtrace/aiguard/
51+
├── __init__.py # PUBLIC SDK: re-exports new_ai_guard_client,
52+
# AIGuardClient, AIGuardClientError,
53+
# AIGuardAbortError, Message, Evaluation, Options,
54+
# ToolCall, Function, … (Strands plugins lazily)
55+
├── _api_client.py # AIGuardClient.evaluate() + HTTP transport + types
56+
── _initialization.py # load_ai_guard(): lazy, gated on DD_AI_GUARD_ENABLED
57+
├── _listener.py # ai_guard_listen(): registers all core.on(...) hooks
58+
├── _context.py # contextvar depth counter to avoid double-scanning
59+
├── _common.py # _get(), wrap_abort_error() shared helpers
60+
── _streaming.py # streaming response evaluation
61+
├── messages.py # message formatting helpers
62+
└── integrations/ # provider listeners/converters + framework plugins
63+
├── litellm.py
64+
├── strands.py # AIGuardStrandsPlugin / AIGuardStrandsHookProvider
65+
├── openai.py # OpenAI common helpers
6466
├── _openai_chat.py # Chat Completions API converters + listeners
6567
├── _openai_responses.py# Responses API converters + listeners
6668
├── _openai_errors.py # OpenAI-compatible abort error class
67-
├── _anthropic.py # Anthropic Messages API converters + listeners
68-
├── _anthropic_errors.py# Anthropic-compatible abort error class
69-
├── _langchain.py # LangChain agent/chatmodel/llm listeners
70-
├── _streaming.py # streaming response evaluation
71-
└── messages.py # message formatting helpers
69+
├── anthropic.py # Anthropic Messages API converters + listeners
70+
├── _anthropic_streaming.py # Anthropic stream reconstruction
71+
├── _anthropic_errors.py # Anthropic-compatible abort error class
72+
└── langchain.py # LangChain agent/chatmodel/llm listeners
7273
```
7374

75+
The old import path `ddtrace.appsec.ai_guard` remains as a deprecated re-export
76+
shim (`ddtrace/appsec/ai_guard/__init__.py`) that emits
77+
`DDTraceDeprecationWarning` and will be removed in 5.0.0.
78+
7479
**Rule of thumb:**
75-
- `ai_guard/` (no underscore) = **public**, stable contract. Don't break it.
76-
- `_ai_guard/` (underscore) = **private**, listeners and provider converters.
80+
- `ddtrace/aiguard/` top level + `__init__.py` = **public**, stable contract. Don't break it.
81+
- `_`-prefixed modules and `integrations/` provider listeners = **private** internals.
7782

7883
## How AI Guard Works: High-Level Architecture
7984

8085
### 1. Lazy, opt-in initialization
8186

82-
`init_ai_guard()` (in `ddtrace/appsec/_ai_guard/__init__.py`) is a one-shot
87+
`load_ai_guard()` (in `ddtrace/aiguard/_initialization.py`) is a one-shot
8388
loader gated on `DD_AI_GUARD_ENABLED`. When enabled, it imports
8489
`ai_guard_listen()` from `_listener.py`, which constructs a single
8590
`AIGuardClient` and registers all event listeners. When disabled, nothing is
@@ -108,7 +113,7 @@ so a `DENY`/`ABORT` decision actually stops the SDK call.
108113
**Consumer side** — `_listener.py` registers listeners with `core.on(...)`:
109114

110115
```python
111-
# ddtrace/appsec/_ai_guard/_listener.py
116+
# ddtrace/aiguard/_listener.py
112117
core.on("openai.chat.completions.create.before",
113118
partial(_openai_chat_completion_before, client))
114119
core.on("openai.chat.completions.create.after",
@@ -174,7 +179,7 @@ All configuration is via environment variables, parsed in
174179
## Public SDK usage
175180

176181
```python
177-
from ddtrace.appsec.ai_guard import new_ai_guard_client, AIGuardAbortError
182+
from ddtrace.aiguard import new_ai_guard_client, AIGuardAbortError
178183

179184
client = new_ai_guard_client()
180185
try:
@@ -208,5 +213,5 @@ re-exported lazily from `ai_guard/__init__.py` via a module-level
208213
the provider listener honor `is_aiguard_context_active()`.
209214
- **Preserve `AIDEV-NOTE` anchors** (lazy import timing, stream counter
210215
lifecycle, legacy message translation) — they encode non-obvious invariants.
211-
- **Tests** live under `tests/appsec/ai_guard/`. Use the `run-tests` skill;
216+
- **Tests** live under `tests/aiguard/`. Use the `run-tests` skill;
212217
never run `pytest` directly.

.cursor/rules/isolated-responsibility.mdc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ The contrib layer knows **nothing** about who (if anyone) is listening.
6464
### Consumer — inside the security product
6565

6666
Register listeners with `core.on(...)`. The canonical example is
67-
`ddtrace/appsec/_ai_guard/_listener.py`:
67+
`ddtrace/aiguard/_listener.py`:
6868

6969
```python
7070
from functools import partial
@@ -82,7 +82,7 @@ def ai_guard_listen():
8282
```
8383

8484
Listeners are only registered when the product is **enabled** (e.g. AI Guard's
85-
`init_ai_guard()` is gated on `DD_AI_GUARD_ENABLED`). When disabled, the events
85+
`load_ai_guard()` is gated on `DD_AI_GUARD_ENABLED`). When disabled, the events
8686
are still dispatched but have no subscribers — a cheap no-op.
8787

8888
## Mental model

.github/CODEOWNERS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ benchmarks/base/aspects_benchmarks_generate.py @DataDog/asm-python
142142
benchmarks/bm/iast_fixtures* @DataDog/asm-python
143143
benchmarks/bm/iast_utils* @DataDog/asm-python
144144
# ddtrace files
145+
ddtrace/aiguard/ @DataDog/asm-python
145146
ddtrace/appsec/ @DataDog/asm-python
146147
ddtrace/contrib/internal/flask_login/ @DataDog/asm-python
147148
ddtrace/contrib/internal/subprocess/ @DataDog/asm-python
@@ -153,6 +154,7 @@ ddtrace/internal/iast/ @DataDog/asm-python
153154
ddtrace/internal/sca/ @DataDog/asm-python
154155
ddtrace/internal/settings/asm.py @DataDog/asm-python
155156
# tests files
157+
tests/aiguard/ @DataDog/asm-python
156158
tests/appsec/ @DataDog/asm-python
157159
tests/contrib/subprocess @DataDog/asm-python
158160
tests/snapshots/tests*appsec*.json @DataDog/asm-python

.sg/rules/span-meta-access.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ severity: error
99
language: python
1010
ignores:
1111
- "ddtrace/_trace/context.py"
12-
- "ddtrace/appsec/ai_guard/_api_client.py"
12+
- "ddtrace/aiguard/_api_client.py"
1313
rule:
1414
pattern: $X._meta
1515
constraints:

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Use the Skill tool to invoke these. **Always prefer skills over raw commands.**
7878
|--------|-------|-------|
7979
| Application Security (AppSec) | `.cursor/rules/appsec.mdc` | `ddtrace/appsec/`, `tests/appsec/` |
8080
| IAST | `.cursor/rules/iast.mdc` | `ddtrace/appsec/_iast/`, `tests/appsec/iast*/` |
81-
| AI Guard | `.cursor/rules/ai-guard.mdc` | `ddtrace/appsec/ai_guard/`, `ddtrace/appsec/_ai_guard/`, `tests/appsec/ai_guard/` |
81+
| AI Guard | `.cursor/rules/ai-guard.mdc` | `ddtrace/aiguard/`, `tests/aiguard/` |
8282
| Isolated Responsibility (security vs. shared integrations) | `.cursor/rules/isolated-responsibility.mdc` | `ddtrace/contrib/`, `ddtrace/appsec/` |
8383
| Native Code (C/C++/Rust/Cython) | `.cursor/rules/native-code.mdc` | `*.c`, `*.cc`, `*.cpp`, `*.h`, `*.hh`, `*.hpp`, `*.rs`, `*.pyx`, `*.pxd` |
8484
| Internal module (fork safety, periodic threads, forksafe hooks) | `ddtrace/internal/README.md` | `ddtrace/internal/`, `ddtrace/internal/periodic.py`, `ddtrace/internal/threads.py`, `ddtrace/internal/forksafe.py`, `ddtrace/internal/_threads.cpp` |

ddtrace/aiguard/__init__.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""
2+
AI Guard public SDK
3+
"""
4+
5+
from ._api_client import AIGuardAbortError
6+
from ._api_client import AIGuardClient
7+
from ._api_client import AIGuardClientError
8+
from ._api_client import ContentPart
9+
from ._api_client import Evaluation
10+
from ._api_client import Function
11+
from ._api_client import ImageURL
12+
from ._api_client import Message
13+
from ._api_client import Options
14+
from ._api_client import ToolCall
15+
from ._api_client import new_ai_guard_client
16+
17+
18+
__all__ = [
19+
"new_ai_guard_client",
20+
"AIGuardClient",
21+
"AIGuardClientError",
22+
"AIGuardAbortError",
23+
"ContentPart",
24+
"Evaluation",
25+
"Function",
26+
"ImageURL",
27+
"Message",
28+
"Options",
29+
"ToolCall",
30+
]
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ class Evaluation(TypedDict):
6464
action: Literal["ALLOW", "DENY", "ABORT"]
6565
reason: str
6666
tags: list[str]
67-
sds: list
67+
sds: list[Any]
6868
tag_probs: dict[str, float]
6969

7070

@@ -108,7 +108,7 @@ def __init__(
108108
action: str,
109109
reason: str,
110110
tags: Optional[list[str]] = None,
111-
sds: Optional[list] = None,
111+
sds: Optional[list[Any]] = None,
112112
tag_probs: Optional[dict[str, float]] = None,
113113
):
114114
self.action = action
@@ -267,7 +267,7 @@ def evaluate(self, messages: list[Message], options: Optional[Options] = None) -
267267

268268
try:
269269
response = self._execute_request(f"{self._endpoint}/evaluate", payload)
270-
result = response.get_json() or {}
270+
result = response.get_json() or {} # type: ignore[no-untyped-call]
271271
except Exception as e:
272272
raise AIGuardClientError(message=f"Unexpected error calling AI Guard service: {e}") from e
273273

@@ -362,9 +362,9 @@ def _execute_request(self, url: str, payload: Any) -> Response:
362362
try:
363363
conn = get_connection(url, self._timeout)
364364
json_body = json.dumps(payload, ensure_ascii=True, skipkeys=True, default=str)
365-
conn.request("POST", url, json_body, self._headers)
365+
conn.request("POST", url, json_body, self._headers) # type: ignore[no-untyped-call]
366366
resp = conn.getresponse()
367-
return Response.from_http_response(resp)
367+
return Response.from_http_response(resp) # type: ignore[no-any-return,no-untyped-call]
368368
finally:
369369
conn.close()
370370

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections.abc import Mapping
99
from typing import Any
1010

11-
from ddtrace.appsec.ai_guard._api_client import AIGuardAbortError
11+
from ddtrace.aiguard._api_client import AIGuardAbortError
1212

1313

1414
def _get(obj: Any, key: str, default: Any = None) -> Any:
Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import contextlib
1212
import contextvars
13+
from typing import Iterator
1314
from typing import Optional
1415

1516

@@ -21,7 +22,7 @@ def is_aiguard_context_active() -> bool:
2122
return _AI_GUARD_DEPTH.get() > 0
2223

2324

24-
def set_aiguard_context_active() -> contextvars.Token:
25+
def set_aiguard_context_active() -> contextvars.Token[int]:
2526
"""Mark the current execution context as already under AI Guard evaluation.
2627
2728
Returns an opaque :class:`contextvars.Token` to pair with
@@ -32,7 +33,7 @@ def set_aiguard_context_active() -> contextvars.Token:
3233
return _AI_GUARD_DEPTH.set(_AI_GUARD_DEPTH.get() + 1)
3334

3435

35-
def reset_aiguard_context_active(token: Optional[contextvars.Token]) -> None:
36+
def reset_aiguard_context_active(token: Optional[contextvars.Token[int]]) -> None:
3637
"""Restore the depth counter to its value before the matching ``set``.
3738
3839
A ``None`` token is a defensive no-op (e.g. cleanup paths that may run
@@ -62,7 +63,7 @@ def reset_aiguard_context_active_current() -> None:
6263

6364

6465
@contextlib.contextmanager
65-
def aiguard_context():
66+
def aiguard_context() -> Iterator[None]:
6667
"""Mark the current task as under AI Guard evaluation for the block's duration.
6768
6869
Framework integrations (LangChain, Strands) wrap their dispatch + LLM
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,15 @@
77
_AI_GUARD_TO_BE_LOADED: bool = True
88

99

10-
def init_ai_guard():
10+
def load_ai_guard() -> None:
1111
"""Lazily load the ai_guard module listeners."""
1212
global _AI_GUARD_TO_BE_LOADED
1313
if _AI_GUARD_TO_BE_LOADED:
1414
try:
1515
if not ai_guard_config._ai_guard_enabled:
1616
return
1717

18-
from ddtrace.appsec._ai_guard._listener import ai_guard_listen
18+
from ddtrace.aiguard._listener import ai_guard_listen
1919

2020
ai_guard_listen()
2121

0 commit comments

Comments
 (0)