aegis-gateway-core 2.0.0a0__tar.gz

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.
Files changed (70) hide show
  1. aegis_gateway_core-2.0.0a0/.gitignore +100 -0
  2. aegis_gateway_core-2.0.0a0/PKG-INFO +26 -0
  3. aegis_gateway_core-2.0.0a0/pyproject.toml +41 -0
  4. aegis_gateway_core-2.0.0a0/src/aegis_core/__init__.py +5 -0
  5. aegis_gateway_core-2.0.0a0/src/aegis_core/config/__init__.py +25 -0
  6. aegis_gateway_core-2.0.0a0/src/aegis_core/config/loader.py +122 -0
  7. aegis_gateway_core-2.0.0a0/src/aegis_core/config/models.py +163 -0
  8. aegis_gateway_core-2.0.0a0/src/aegis_core/errors.py +233 -0
  9. aegis_gateway_core-2.0.0a0/src/aegis_core/guardrails/__init__.py +8 -0
  10. aegis_gateway_core-2.0.0a0/src/aegis_core/guardrails/incremental.py +44 -0
  11. aegis_gateway_core-2.0.0a0/src/aegis_core/guardrails/protocol.py +35 -0
  12. aegis_gateway_core-2.0.0a0/src/aegis_core/guardrails/regex_guard.py +39 -0
  13. aegis_gateway_core-2.0.0a0/src/aegis_core/guardrails/spine.py +93 -0
  14. aegis_gateway_core-2.0.0a0/src/aegis_core/hooks/__init__.py +23 -0
  15. aegis_gateway_core-2.0.0a0/src/aegis_core/hooks/specs.py +75 -0
  16. aegis_gateway_core-2.0.0a0/src/aegis_core/mcp/__init__.py +27 -0
  17. aegis_gateway_core-2.0.0a0/src/aegis_core/mcp/execute_node.py +205 -0
  18. aegis_gateway_core-2.0.0a0/src/aegis_core/mcp/guards.py +96 -0
  19. aegis_gateway_core-2.0.0a0/src/aegis_core/mcp/protocol.py +55 -0
  20. aegis_gateway_core-2.0.0a0/src/aegis_core/mcp/tool_policy.py +34 -0
  21. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/__init__.py +21 -0
  22. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/assembler.py +440 -0
  23. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/checkpointer.py +41 -0
  24. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/executor.py +71 -0
  25. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/nodes.py +31 -0
  26. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/protocol.py +27 -0
  27. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/state.py +64 -0
  28. aegis_gateway_core-2.0.0a0/src/aegis_core/pipeline/verdict.py +70 -0
  29. aegis_gateway_core-2.0.0a0/src/aegis_core/providers/__init__.py +28 -0
  30. aegis_gateway_core-2.0.0a0/src/aegis_core/providers/litellm_provider.py +192 -0
  31. aegis_gateway_core-2.0.0a0/src/aegis_core/providers/models.py +84 -0
  32. aegis_gateway_core-2.0.0a0/src/aegis_core/providers/openai_compatible.py +40 -0
  33. aegis_gateway_core-2.0.0a0/src/aegis_core/providers/profiles.py +169 -0
  34. aegis_gateway_core-2.0.0a0/src/aegis_core/providers/protocol.py +41 -0
  35. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/__init__.py +15 -0
  36. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/adapter.py +63 -0
  37. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/chroma_store.py +44 -0
  38. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/chunker.py +72 -0
  39. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/chunking.py +36 -0
  40. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/pgvector_store.py +41 -0
  41. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/protocol.py +58 -0
  42. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/retrieval_node.py +105 -0
  43. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/stores/__init__.py +1 -0
  44. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/stores/chroma.py +82 -0
  45. aegis_gateway_core-2.0.0a0/src/aegis_core/rag/stores/pgvector.py +84 -0
  46. aegis_gateway_core-2.0.0a0/src/aegis_core/registry/__init__.py +8 -0
  47. aegis_gateway_core-2.0.0a0/src/aegis_core/registry/discovery.py +121 -0
  48. aegis_gateway_core-2.0.0a0/src/aegis_core/registry/models.py +28 -0
  49. aegis_gateway_core-2.0.0a0/src/aegis_core/secrets/__init__.py +17 -0
  50. aegis_gateway_core-2.0.0a0/src/aegis_core/secrets/backends/__init__.py +1 -0
  51. aegis_gateway_core-2.0.0a0/src/aegis_core/secrets/backends/env.py +39 -0
  52. aegis_gateway_core-2.0.0a0/src/aegis_core/secrets/backends/keyring.py +81 -0
  53. aegis_gateway_core-2.0.0a0/src/aegis_core/secrets/protocol.py +27 -0
  54. aegis_gateway_core-2.0.0a0/src/aegis_core/secrets/ref.py +52 -0
  55. aegis_gateway_core-2.0.0a0/src/aegis_core/secrets/resolver.py +66 -0
  56. aegis_gateway_core-2.0.0a0/src/aegis_core/testing/__init__.py +6 -0
  57. aegis_gateway_core-2.0.0a0/src/aegis_core/testing/guardrails.py +89 -0
  58. aegis_gateway_core-2.0.0a0/src/aegis_core/testing/providers.py +204 -0
  59. aegis_gateway_core-2.0.0a0/src/aegis_core/testing/rag.py +107 -0
  60. aegis_gateway_core-2.0.0a0/tests/test_config.py +288 -0
  61. aegis_gateway_core-2.0.0a0/tests/test_guardrails.py +299 -0
  62. aegis_gateway_core-2.0.0a0/tests/test_hooks.py +192 -0
  63. aegis_gateway_core-2.0.0a0/tests/test_mcp.py +503 -0
  64. aegis_gateway_core-2.0.0a0/tests/test_pipeline.py +417 -0
  65. aegis_gateway_core-2.0.0a0/tests/test_placeholder.py +7 -0
  66. aegis_gateway_core-2.0.0a0/tests/test_providers.py +439 -0
  67. aegis_gateway_core-2.0.0a0/tests/test_rag.py +331 -0
  68. aegis_gateway_core-2.0.0a0/tests/test_registry.py +190 -0
  69. aegis_gateway_core-2.0.0a0/tests/test_secrets.py +195 -0
  70. aegis_gateway_core-2.0.0a0/tests/test_streaming.py +202 -0
@@ -0,0 +1,100 @@
1
+ # Node/npm local installs
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+ .pnpm-store/
7
+
8
+ # Local environment files
9
+ .env
10
+ .env.*
11
+ !.env.example
12
+
13
+ ## jj
14
+ .jj
15
+
16
+ # Codex local configuration
17
+ .codex/
18
+ AGENTS.md
19
+
20
+ # Claude Flow runtime data
21
+ .claude-flow/data/
22
+ .claude-flow/logs/
23
+ CLAUDE.md
24
+ .claude
25
+ .claude-flow
26
+
27
+ # MCP
28
+ .mcp.json
29
+ ruvector.db
30
+
31
+ # ruflo agents
32
+ .agents
33
+ .swarm
34
+ .agentdb
35
+ agentdb.db
36
+
37
+ # Environment variables
38
+ .env
39
+ .env.local
40
+ .env.*.local
41
+
42
+ # Created by https://www.toptal.com/developers/gitignore/api/visualstudiocode
43
+ # Edit at https://www.toptal.com/developers/gitignore?templates=visualstudiocode
44
+
45
+ ### VisualStudioCode ###
46
+ .vscode/*
47
+ !.vscode/settings.json
48
+ !.vscode/tasks.json
49
+ !.vscode/launch.json
50
+ !.vscode/extensions.json
51
+ !.vscode/*.code-snippets
52
+ .vscode
53
+
54
+ # Local History for Visual Studio Code
55
+ .history/
56
+
57
+ # Built Visual Studio Code Extensions
58
+ *.vsix
59
+
60
+ ### VisualStudioCode Patch ###
61
+ # Ignore all local history of files
62
+ .history
63
+ .ionide
64
+
65
+ ### Misc files
66
+ gemma4
67
+ .postman/
68
+ .openclaude/
69
+ postman/
70
+
71
+ ### OpenClaude
72
+ .openclaude-profile.json
73
+
74
+ # Python
75
+ __pycache__/
76
+ *.py[cod]
77
+ *.egg-info/
78
+ .pytest_cache/
79
+ dist/
80
+ build/
81
+ *.egg
82
+ .venv/
83
+ .uv-cache/
84
+ .tmp/
85
+ .ruff_cache/
86
+
87
+ # Docker
88
+ .docker/
89
+
90
+ ### Build plans
91
+ BUILD_PLAN.md
92
+ CLAUDE.md
93
+ PROJECT_SPEC.md
94
+ AMEND_PLAN.md
95
+ SOURCE_OF_TRUTH.md
96
+ repo-seed/
97
+ .import_linter_cache/
98
+ site/
99
+
100
+ # End of https://www.toptal.com/developers/gitignore/api/visualstudiocode
@@ -0,0 +1,26 @@
1
+ Metadata-Version: 2.4
2
+ Name: aegis-gateway-core
3
+ Version: 2.0.0a0
4
+ Summary: Aegis v2 — core kernel: plugin registry, typed config, pipeline contracts.
5
+ Project-URL: Homepage, https://github.com/e-choness/aegis
6
+ Project-URL: Repository, https://github.com/e-choness/aegis
7
+ Project-URL: Issues, https://github.com/e-choness/aegis/issues
8
+ License: MIT
9
+ Requires-Python: >=3.12
10
+ Requires-Dist: keyring>=25.0
11
+ Requires-Dist: langchain-core>=0.3
12
+ Requires-Dist: langchain-mcp-adapters>=0.1.0
13
+ Requires-Dist: langgraph-checkpoint-postgres>=3.1.0
14
+ Requires-Dist: langgraph-checkpoint-sqlite>=3.1.0
15
+ Requires-Dist: langgraph>=0.2
16
+ Requires-Dist: litellm>=1.0
17
+ Requires-Dist: mcp>=1.0
18
+ Requires-Dist: pluggy>=1.4
19
+ Requires-Dist: pydantic-settings>=2.0
20
+ Requires-Dist: pydantic>=2.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Provides-Extra: rag
23
+ Requires-Dist: chromadb>=0.5; extra == 'rag'
24
+ Requires-Dist: langchain-chroma>=0.1; extra == 'rag'
25
+ Requires-Dist: langchain-postgres>=0.0.12; extra == 'rag'
26
+ Requires-Dist: langchain-text-splitters>=0.3; extra == 'rag'
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "aegis-gateway-core"
3
+ version = "2.0.0a0"
4
+ description = "Aegis v2 — core kernel: plugin registry, typed config, pipeline contracts."
5
+ license = { text = "MIT" }
6
+ requires-python = ">=3.12"
7
+
8
+ dependencies = [
9
+ "pydantic>=2.0",
10
+ "pydantic-settings>=2.0",
11
+ "pluggy>=1.4",
12
+ "pyyaml>=6.0",
13
+ "keyring>=25.0",
14
+ "litellm>=1.0",
15
+ "langgraph>=0.2",
16
+ "langchain-core>=0.3",
17
+ "langgraph-checkpoint-sqlite>=3.1.0",
18
+ "langgraph-checkpoint-postgres>=3.1.0",
19
+ "mcp>=1.0",
20
+ "langchain-mcp-adapters>=0.1.0",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/e-choness/aegis"
25
+ Repository = "https://github.com/e-choness/aegis"
26
+ Issues = "https://github.com/e-choness/aegis/issues"
27
+
28
+ [project.optional-dependencies]
29
+ rag = [
30
+ "chromadb>=0.5",
31
+ "langchain-chroma>=0.1",
32
+ "langchain-postgres>=0.0.12",
33
+ "langchain-text-splitters>=0.3",
34
+ ]
35
+
36
+ [build-system]
37
+ requires = ["hatchling"]
38
+ build-backend = "hatchling.build"
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/aegis_core"]
@@ -0,0 +1,5 @@
1
+ """Aegis v2 — core kernel."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "2.0.0a0"
@@ -0,0 +1,25 @@
1
+ """Aegis config subsystem — typed pydantic models and YAML loader."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from aegis_core.config.loader import load_config
6
+ from aegis_core.config.models import (
7
+ AegisConfig,
8
+ AuthConfig,
9
+ GuardrailConfig,
10
+ PipelineConfig,
11
+ ProviderConfig,
12
+ ResidencyConfig,
13
+ RouteConfig,
14
+ )
15
+
16
+ __all__ = [
17
+ "AegisConfig",
18
+ "AuthConfig",
19
+ "GuardrailConfig",
20
+ "PipelineConfig",
21
+ "ProviderConfig",
22
+ "ResidencyConfig",
23
+ "RouteConfig",
24
+ "load_config",
25
+ ]
@@ -0,0 +1,122 @@
1
+ """Config loader — reads aegis.yaml and applies env overrides.
2
+
3
+ Loading sequence:
4
+ 1. Parse the YAML file into a raw dict.
5
+ 2. Walk the raw dict with ``SecretResolver`` to replace ``secret://`` URIs.
6
+ 3. Apply pydantic-settings env overrides (``AEGIS__<SECTION>__<KEY>=...``).
7
+ 4. Validate with ``AegisConfig``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from aegis_core.config.models import AegisConfig
17
+ from aegis_core.errors import AegisConfigNotFoundError, AegisConfigValidationError
18
+ from aegis_core.secrets.backends.env import EnvSecretProvider
19
+ from aegis_core.secrets.resolver import SecretResolver
20
+
21
+ try:
22
+ import yaml # type: ignore[import-untyped]
23
+ except ModuleNotFoundError as exc: # pragma: no cover
24
+ raise ImportError(
25
+ "PyYAML is required to load aegis.yaml. "
26
+ "Install it with: uv add pyyaml"
27
+ ) from exc
28
+
29
+
30
+ def _build_default_resolver() -> SecretResolver:
31
+ resolver = SecretResolver()
32
+ resolver.register(EnvSecretProvider())
33
+ return resolver
34
+
35
+
36
+ def load_config(
37
+ path: str | Path,
38
+ *,
39
+ resolver: SecretResolver | None = None,
40
+ env_prefix: str = "AEGIS",
41
+ ) -> AegisConfig:
42
+ """Load and validate an ``aegis.yaml`` file.
43
+
44
+ Args:
45
+ path: Path to ``aegis.yaml``.
46
+ resolver: Optional :class:`~aegis_core.secrets.SecretResolver`. If not
47
+ supplied a default one (env-only) is constructed.
48
+ env_prefix: Prefix for environment-variable overrides. Variables of the
49
+ form ``AEGIS__ROUTES__DEFAULT__MODEL`` override nested config keys
50
+ (double-underscore as separator).
51
+
52
+ Returns:
53
+ Validated :class:`~aegis_core.config.models.AegisConfig` instance.
54
+
55
+ Raises:
56
+ AegisConfigNotFoundError: if *path* does not exist.
57
+ AegisConfigValidationError: if the YAML is structurally valid but fails
58
+ Pydantic validation.
59
+ """
60
+ p = Path(path)
61
+ if not p.exists():
62
+ raise AegisConfigNotFoundError(
63
+ f"Configuration file not found: {p}",
64
+ path=str(p),
65
+ )
66
+
67
+ with p.open("rb") as fh:
68
+ raw: Any = yaml.safe_load(fh)
69
+
70
+ if raw is None:
71
+ raw = {}
72
+
73
+ if not isinstance(raw, dict):
74
+ raise AegisConfigValidationError(
75
+ "aegis.yaml must be a YAML mapping at the top level.",
76
+ path=str(p),
77
+ )
78
+
79
+ # Resolve secret:// URIs
80
+ if resolver is None:
81
+ resolver = _build_default_resolver()
82
+ raw = resolver.resolve_dict(raw)
83
+
84
+ # Apply env overrides (double-underscore path separator)
85
+ raw = _apply_env_overrides(raw, prefix=env_prefix)
86
+
87
+ # Validate
88
+ from pydantic import ValidationError
89
+
90
+ try:
91
+ return AegisConfig.model_validate(raw)
92
+ except ValidationError as exc:
93
+ raise AegisConfigValidationError(
94
+ f"Configuration validation failed:\n{exc}",
95
+ path=str(p),
96
+ ) from exc
97
+
98
+
99
+ def _apply_env_overrides(data: dict[str, Any], prefix: str) -> dict[str, Any]:
100
+ """Overlay ``<PREFIX>__KEY1__KEY2=value`` env vars onto *data*.
101
+
102
+ Only variables that start with ``<prefix>__`` (case-insensitive) are
103
+ considered. Double-underscore is the nesting separator.
104
+ """
105
+ prefix_upper = prefix.upper() + "__"
106
+ for key, value in os.environ.items():
107
+ if not key.upper().startswith(prefix_upper):
108
+ continue
109
+ remainder = key[len(prefix_upper):]
110
+ parts = [p.lower() for p in remainder.split("__")]
111
+ _set_nested(data, parts, value)
112
+ return data
113
+
114
+
115
+ def _set_nested(data: dict[str, Any], keys: list[str], value: str) -> None:
116
+ """Set *data*[keys[0]][keys[1]]… = *value*, creating dicts as needed."""
117
+ node = data
118
+ for k in keys[:-1]:
119
+ if k not in node or not isinstance(node[k], dict):
120
+ node[k] = {}
121
+ node = node[k]
122
+ node[keys[-1]] = value
@@ -0,0 +1,163 @@
1
+ """Pydantic v2 models for aegis.yaml configuration.
2
+
3
+ All credentials are stored as ``pydantic.SecretStr`` so that they are
4
+ automatically redacted in ``repr``, logs, and serialized output.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator, model_validator
12
+
13
+
14
+ class _StrictModel(BaseModel):
15
+ """Shared base — forbids extra fields so typos surface as errors."""
16
+
17
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
18
+
19
+
20
+ # ── Residency ────────────────────────────────────────────────────────────────
21
+
22
+
23
+ class ResidencyConfig(_StrictModel):
24
+ region: str
25
+ jurisdiction: str | None = None
26
+ source_url: str | None = None
27
+
28
+
29
+ # ── Providers ────────────────────────────────────────────────────────────────
30
+
31
+
32
+ class ProviderConfig(_StrictModel):
33
+ """A single LLM provider profile."""
34
+
35
+ type: str # e.g. "anthropic", "openai_compatible"
36
+ api_key: SecretStr | None = None
37
+ base_url: str | None = None
38
+ model: str | None = None
39
+ residency: ResidencyConfig | None = None
40
+ # Allow extra provider-specific fields without failing validation
41
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
42
+
43
+ @field_validator("api_key", mode="before")
44
+ @classmethod
45
+ def _coerce_secret(cls, v: Any) -> Any:
46
+ # secret:// URIs are resolved later by SecretResolver; pass through.
47
+ return v
48
+
49
+
50
+ # ── Guardrails ────────────────────────────────────────────────────────────────
51
+
52
+
53
+ class GuardrailConfig(_StrictModel):
54
+ pack: str
55
+ mode: str | None = None
56
+ scanners: list[str] | None = None
57
+ threshold: float | None = None
58
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
59
+
60
+
61
+ # ── Pipeline ──────────────────────────────────────────────────────────────────
62
+
63
+
64
+ class PipelineConfig(_StrictModel):
65
+ ingress: list[str] = Field(default_factory=list)
66
+ tool_call: list[str] = Field(default_factory=list)
67
+ tool_result: list[str] = Field(default_factory=list)
68
+ egress: list[str] = Field(default_factory=list)
69
+
70
+
71
+ # ── Routes ────────────────────────────────────────────────────────────────────
72
+
73
+
74
+ class RouteConfig(_StrictModel):
75
+ provider: str
76
+ model: str | None = None
77
+ pipeline: PipelineConfig | None = None
78
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
79
+
80
+
81
+ # ── Auth ──────────────────────────────────────────────────────────────────────
82
+
83
+
84
+ class AuthConfig(_StrictModel):
85
+ type: Literal["none", "api_key"] = "none"
86
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
87
+
88
+
89
+ # ── Top-level AegisConfig ─────────────────────────────────────────────────────
90
+
91
+
92
+ class AegisConfig(_StrictModel):
93
+ """Root configuration object for an Aegis gateway instance."""
94
+
95
+ providers: dict[str, ProviderConfig] = Field(default_factory=dict)
96
+ guardrails: dict[str, GuardrailConfig] = Field(default_factory=dict)
97
+ pipeline: PipelineConfig = Field(default_factory=PipelineConfig)
98
+ routes: dict[str, RouteConfig] = Field(default_factory=dict)
99
+ auth: AuthConfig = Field(default_factory=AuthConfig)
100
+
101
+ @model_validator(mode="after")
102
+ def _validate_route_providers(self) -> AegisConfig:
103
+ """All routes must reference a declared provider."""
104
+ from aegis_core.errors import AegisConfigValidationError
105
+
106
+ for route_name, route in self.routes.items():
107
+ if route.provider not in self.providers:
108
+ raise AegisConfigValidationError(
109
+ f"Route {route_name!r} references unknown provider "
110
+ f"{route.provider!r}. "
111
+ f"Declared providers: {list(self.providers)}",
112
+ route=route_name,
113
+ provider=route.provider,
114
+ )
115
+ return self
116
+
117
+ @model_validator(mode="after")
118
+ def _validate_pipeline_guardrails(self) -> AegisConfig:
119
+ """Pipeline node references must be declared guardrails or qualified names."""
120
+ from aegis_core.errors import AegisConfigValidationError
121
+
122
+ all_refs: list[str] = (
123
+ self.pipeline.ingress
124
+ + self.pipeline.tool_call
125
+ + self.pipeline.tool_result
126
+ + self.pipeline.egress
127
+ )
128
+ for ref in all_refs:
129
+ # Strip any ".unmask" or similar suffixes for lookup
130
+ base = ref.split(".")[0]
131
+ if base not in self.guardrails:
132
+ raise AegisConfigValidationError(
133
+ f"Pipeline references unknown guardrail {ref!r}. "
134
+ f"Declared guardrails: {list(self.guardrails)}",
135
+ guardrail_ref=ref,
136
+ )
137
+ return self
138
+
139
+ def safe_dict(self) -> dict[str, Any]:
140
+ """Return a JSON-serialisable dict with all secrets redacted.
141
+
142
+ ``SecretStr`` fields are replaced with ``"**REDACTED**"`` so the
143
+ output is safe to display in CLI output or logs.
144
+ """
145
+ raw = self.model_dump(mode="python")
146
+ return _redact(raw)
147
+
148
+
149
+ # ── Helpers ───────────────────────────────────────────────────────────────────
150
+
151
+ _REDACTED = "**REDACTED**"
152
+
153
+
154
+ def _redact(node: Any) -> Any:
155
+ if isinstance(node, SecretStr):
156
+ return _REDACTED
157
+ if isinstance(node, dict):
158
+ return {k: _redact(v) for k, v in node.items()}
159
+ if isinstance(node, list):
160
+ return [_redact(item) for item in node]
161
+ return node
162
+
163
+