datacules-agent-identity 0.3.3__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.
- datacules_agent_identity-0.3.3/PKG-INFO +89 -0
- datacules_agent_identity-0.3.3/README.md +74 -0
- datacules_agent_identity-0.3.3/agent_identity/__init__.py +213 -0
- datacules_agent_identity-0.3.3/datacules_agent_identity.egg-info/PKG-INFO +89 -0
- datacules_agent_identity-0.3.3/datacules_agent_identity.egg-info/SOURCES.txt +9 -0
- datacules_agent_identity-0.3.3/datacules_agent_identity.egg-info/dependency_links.txt +1 -0
- datacules_agent_identity-0.3.3/datacules_agent_identity.egg-info/requires.txt +4 -0
- datacules_agent_identity-0.3.3/datacules_agent_identity.egg-info/top_level.txt +1 -0
- datacules_agent_identity-0.3.3/pyproject.toml +26 -0
- datacules_agent_identity-0.3.3/setup.cfg +4 -0
- datacules_agent_identity-0.3.3/tests/test_client.py +261 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datacules-agent-identity
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: Python SDK for the agent-identity credential routing sidecar — by Datacules LLC
|
|
5
|
+
Author-email: Datacules LLC <harshalrasal792@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hvrcharon1/agent-identity
|
|
8
|
+
Project-URL: Repository, https://github.com/hvrcharon1/agent-identity
|
|
9
|
+
Project-URL: Documentation, https://github.com/hvrcharon1/agent-identity/tree/main/packages/python-sdk
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# agent-identity Python SDK
|
|
17
|
+
|
|
18
|
+
Pure-Python client for the `agent-identity` sidecar. No Node.js required.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install agent-identity
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from agent_identity import AgentIdentityClient
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
|
|
32
|
+
client = AgentIdentityClient(base_url="http://localhost:3001")
|
|
33
|
+
|
|
34
|
+
# Resolve a credential for a single agent request
|
|
35
|
+
resolved = client.resolve({
|
|
36
|
+
"userId": "user-abc",
|
|
37
|
+
"resourceId": "crm-db",
|
|
38
|
+
"resourceKind": "shared",
|
|
39
|
+
"provider": "anthropic",
|
|
40
|
+
"model": "claude-sonnet-4-20250514",
|
|
41
|
+
"action": "read",
|
|
42
|
+
"traceId": "trace-xyz",
|
|
43
|
+
"requestedAt": datetime.now(timezone.utc).isoformat(),
|
|
44
|
+
})
|
|
45
|
+
print(resolved["resolvedFor"]) # 'service' or userId
|
|
46
|
+
|
|
47
|
+
# Resolve source + target credentials for a migration phase
|
|
48
|
+
pair = client.resolve_migration({
|
|
49
|
+
"migrationId": "migration-2026-q2",
|
|
50
|
+
"phase": "load",
|
|
51
|
+
"sourceResourceId": "crm-postgres-prod",
|
|
52
|
+
"targetResourceId": "crm-postgres-v2",
|
|
53
|
+
"userId": "svc-migration-bot",
|
|
54
|
+
"provider": "anthropic",
|
|
55
|
+
"model": "claude-sonnet-4-20250514",
|
|
56
|
+
"traceId": "trace-abc123",
|
|
57
|
+
"dryRun": False,
|
|
58
|
+
})
|
|
59
|
+
print(pair["expiresAt"]) # ISO 8601 or None
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## LangChain integration
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from langchain_anthropic import ChatAnthropic
|
|
66
|
+
from agent_identity import AgentIdentityClient
|
|
67
|
+
|
|
68
|
+
client = AgentIdentityClient()
|
|
69
|
+
resolved = client.resolve({...})
|
|
70
|
+
|
|
71
|
+
# resolved["resolvedFor"] is safe to log; the raw API key stays on the server
|
|
72
|
+
llm = ChatAnthropic(model="claude-sonnet-4-20250514")
|
|
73
|
+
# The sidecar injects the API key server-side when you call /api/resolve
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Error handling
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from agent_identity import AgentIdentityClient, NoCredentialError, ValidationError
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
result = client.resolve(ctx)
|
|
83
|
+
except NoCredentialError:
|
|
84
|
+
# 403 — no routing rule matched this context
|
|
85
|
+
...
|
|
86
|
+
except ValidationError as e:
|
|
87
|
+
# 400 — bad request body
|
|
88
|
+
print(e)
|
|
89
|
+
```
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# agent-identity Python SDK
|
|
2
|
+
|
|
3
|
+
Pure-Python client for the `agent-identity` sidecar. No Node.js required.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install agent-identity
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from agent_identity import AgentIdentityClient
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
client = AgentIdentityClient(base_url="http://localhost:3001")
|
|
18
|
+
|
|
19
|
+
# Resolve a credential for a single agent request
|
|
20
|
+
resolved = client.resolve({
|
|
21
|
+
"userId": "user-abc",
|
|
22
|
+
"resourceId": "crm-db",
|
|
23
|
+
"resourceKind": "shared",
|
|
24
|
+
"provider": "anthropic",
|
|
25
|
+
"model": "claude-sonnet-4-20250514",
|
|
26
|
+
"action": "read",
|
|
27
|
+
"traceId": "trace-xyz",
|
|
28
|
+
"requestedAt": datetime.now(timezone.utc).isoformat(),
|
|
29
|
+
})
|
|
30
|
+
print(resolved["resolvedFor"]) # 'service' or userId
|
|
31
|
+
|
|
32
|
+
# Resolve source + target credentials for a migration phase
|
|
33
|
+
pair = client.resolve_migration({
|
|
34
|
+
"migrationId": "migration-2026-q2",
|
|
35
|
+
"phase": "load",
|
|
36
|
+
"sourceResourceId": "crm-postgres-prod",
|
|
37
|
+
"targetResourceId": "crm-postgres-v2",
|
|
38
|
+
"userId": "svc-migration-bot",
|
|
39
|
+
"provider": "anthropic",
|
|
40
|
+
"model": "claude-sonnet-4-20250514",
|
|
41
|
+
"traceId": "trace-abc123",
|
|
42
|
+
"dryRun": False,
|
|
43
|
+
})
|
|
44
|
+
print(pair["expiresAt"]) # ISO 8601 or None
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## LangChain integration
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from langchain_anthropic import ChatAnthropic
|
|
51
|
+
from agent_identity import AgentIdentityClient
|
|
52
|
+
|
|
53
|
+
client = AgentIdentityClient()
|
|
54
|
+
resolved = client.resolve({...})
|
|
55
|
+
|
|
56
|
+
# resolved["resolvedFor"] is safe to log; the raw API key stays on the server
|
|
57
|
+
llm = ChatAnthropic(model="claude-sonnet-4-20250514")
|
|
58
|
+
# The sidecar injects the API key server-side when you call /api/resolve
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Error handling
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from agent_identity import AgentIdentityClient, NoCredentialError, ValidationError
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
result = client.resolve(ctx)
|
|
68
|
+
except NoCredentialError:
|
|
69
|
+
# 403 — no routing rule matched this context
|
|
70
|
+
...
|
|
71
|
+
except ValidationError as e:
|
|
72
|
+
# 400 — bad request body
|
|
73
|
+
print(e)
|
|
74
|
+
```
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent_identity — Python SDK for the agent-identity credential routing sidecar
|
|
3
|
+
|
|
4
|
+
Installation:
|
|
5
|
+
pip install datacules-agent-identity
|
|
6
|
+
# or directly:
|
|
7
|
+
pip install git+https://github.com/hvrcharon1/agent-identity.git#subdirectory=packages/python-sdk
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
from agent_identity import AgentIdentityClient
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
client = AgentIdentityClient(base_url="http://localhost:3001")
|
|
14
|
+
|
|
15
|
+
resolved = client.resolve({
|
|
16
|
+
"userId": "user-abc",
|
|
17
|
+
"resourceId": "crm-db",
|
|
18
|
+
"resourceKind": "shared",
|
|
19
|
+
"provider": "anthropic",
|
|
20
|
+
"model": "claude-sonnet-4-20250514",
|
|
21
|
+
"action": "read",
|
|
22
|
+
"traceId": "trace-xyz",
|
|
23
|
+
"requestedAt": datetime.now(timezone.utc).isoformat(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
pair = client.resolve_migration({
|
|
27
|
+
"migrationId": "migration-2026-q2",
|
|
28
|
+
"phase": "load",
|
|
29
|
+
"sourceResourceId": "crm-postgres-prod",
|
|
30
|
+
"targetResourceId": "crm-postgres-v2",
|
|
31
|
+
"userId": "svc-migration-bot",
|
|
32
|
+
"provider": "anthropic",
|
|
33
|
+
"model": "claude-sonnet-4-20250514",
|
|
34
|
+
"traceId": "trace-abc123",
|
|
35
|
+
"dryRun": False,
|
|
36
|
+
})
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from typing import Any, Dict, Literal, Optional, TypedDict
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
import urllib.request as _urllib
|
|
47
|
+
except ImportError: # pragma: no cover
|
|
48
|
+
raise RuntimeError("agent-identity requires Python 3.8+")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ─── Type aliases ────────────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
ResourceKind = Literal["shared", "personal"]
|
|
54
|
+
SupportedProvider = Literal["openai", "anthropic", "gemini", "mistral", "local"]
|
|
55
|
+
MigrationPhase = Literal["dry-run", "extract", "transform", "load", "verify", "rollback"]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AgentRequestContext(TypedDict, total=False):
|
|
59
|
+
userId: str # required
|
|
60
|
+
resourceId: str # required
|
|
61
|
+
resourceKind: ResourceKind # required
|
|
62
|
+
provider: SupportedProvider # required
|
|
63
|
+
model: str # required
|
|
64
|
+
action: str # required
|
|
65
|
+
traceId: str # required
|
|
66
|
+
requestedAt: str # required (ISO 8601)
|
|
67
|
+
sessionId: str # optional
|
|
68
|
+
parentTraceId: str # optional
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MigrateResolveRequest(TypedDict, total=False):
|
|
72
|
+
migrationId: str # required
|
|
73
|
+
phase: MigrationPhase # required
|
|
74
|
+
sourceResourceId: str # required
|
|
75
|
+
targetResourceId: str # required
|
|
76
|
+
userId: str # required
|
|
77
|
+
provider: SupportedProvider # required
|
|
78
|
+
model: str # required
|
|
79
|
+
traceId: str # required
|
|
80
|
+
dryRun: bool # optional, default False
|
|
81
|
+
batchIndex: int # optional
|
|
82
|
+
totalBatches: int # optional
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ResolveResponse(TypedDict):
|
|
86
|
+
ok: bool
|
|
87
|
+
resolvedFor: str
|
|
88
|
+
expiresAt: Optional[str]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class MigrateResolveResponse(TypedDict):
|
|
92
|
+
migrationId: str
|
|
93
|
+
phase: MigrationPhase
|
|
94
|
+
sourceResolvedFor: str
|
|
95
|
+
targetResolvedFor: str
|
|
96
|
+
dryRun: bool
|
|
97
|
+
expiresAt: Optional[str]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ─── Exceptions ───────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
class AgentIdentityError(Exception):
|
|
103
|
+
"""Base exception for all agent-identity SDK errors."""
|
|
104
|
+
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
|
105
|
+
super().__init__(message)
|
|
106
|
+
self.status_code = status_code
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NoCredentialError(AgentIdentityError):
|
|
110
|
+
"""Raised when the server returns 403 — no routing rule matched."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ValidationError(AgentIdentityError):
|
|
114
|
+
"""Raised when the server returns 400 — invalid request body."""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ─── Client ────────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
class AgentIdentityClient:
|
|
120
|
+
"""
|
|
121
|
+
Thin HTTP client for the agent-identity sidecar.
|
|
122
|
+
No Node.js required — pure Python 3.8+ stdlib.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
base_url: URL of the running sidecar, e.g. 'http://localhost:3001'
|
|
126
|
+
timeout: Request timeout in seconds (default 10)
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self, base_url: str = "http://localhost:3001", timeout: int = 10) -> None:
|
|
130
|
+
self.base_url = base_url.rstrip("/")
|
|
131
|
+
self.timeout = timeout
|
|
132
|
+
|
|
133
|
+
# ─── Public API ────────────────────────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
def resolve(self, ctx: AgentRequestContext) -> ResolveResponse:
|
|
136
|
+
"""
|
|
137
|
+
Resolve the best credential for a single agent request.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
ctx: AgentRequestContext dict — all required fields must be present.
|
|
141
|
+
If 'requestedAt' is omitted, the current UTC time is used.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
ResolveResponse with resolvedFor and optional expiresAt.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValidationError: Server returned 400 (missing/invalid field).
|
|
148
|
+
NoCredentialError: Server returned 403 (no rule matched).
|
|
149
|
+
AgentIdentityError: Any other HTTP error.
|
|
150
|
+
"""
|
|
151
|
+
if "requestedAt" not in ctx:
|
|
152
|
+
ctx = {**ctx, "requestedAt": datetime.now(timezone.utc).isoformat()} # type: ignore[assignment]
|
|
153
|
+
return self._post("/api/resolve", ctx) # type: ignore[return-value]
|
|
154
|
+
|
|
155
|
+
def resolve_migration(
|
|
156
|
+
self, request: MigrateResolveRequest
|
|
157
|
+
) -> MigrateResolveResponse:
|
|
158
|
+
"""
|
|
159
|
+
Resolve source + target credentials for a migration phase.
|
|
160
|
+
|
|
161
|
+
Call once at the start of each phase. The returned expiresAt lets
|
|
162
|
+
the agent decide when to re-call before the batch loop ends.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
request: MigrateResolveRequest dict.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
MigrateResolveResponse with sourceResolvedFor, targetResolvedFor,
|
|
169
|
+
and optional expiresAt (earliest of the two credentials).
|
|
170
|
+
"""
|
|
171
|
+
if "dryRun" not in request:
|
|
172
|
+
request = {**request, "dryRun": False} # type: ignore[assignment]
|
|
173
|
+
return self._post("/api/migrate/resolve", request) # type: ignore[return-value]
|
|
174
|
+
|
|
175
|
+
def health(self) -> bool:
|
|
176
|
+
"""Returns True if the sidecar is reachable and healthy."""
|
|
177
|
+
try:
|
|
178
|
+
self._post("/api/health", {}, method="GET")
|
|
179
|
+
return True
|
|
180
|
+
except AgentIdentityError:
|
|
181
|
+
return False
|
|
182
|
+
except Exception: # noqa: BLE001
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
# ─── Internal ──────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
def _post(self, path: str, body: Dict[str, Any], method: str = "POST") -> Any:
|
|
188
|
+
url = f"{self.base_url}{path}"
|
|
189
|
+
data = json.dumps(body).encode("utf-8")
|
|
190
|
+
req = _urllib.Request(
|
|
191
|
+
url,
|
|
192
|
+
data=data if method == "POST" else None,
|
|
193
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|
194
|
+
method=method,
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
with _urllib.urlopen(req, timeout=self.timeout) as resp:
|
|
198
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
199
|
+
except _urllib.HTTPError as e:
|
|
200
|
+
status = e.code
|
|
201
|
+
try:
|
|
202
|
+
error_body = json.loads(e.read().decode("utf-8"))
|
|
203
|
+
message = error_body.get("error", str(e))
|
|
204
|
+
except Exception: # noqa: BLE001
|
|
205
|
+
message = str(e)
|
|
206
|
+
|
|
207
|
+
if status == 400:
|
|
208
|
+
raise ValidationError(message, status_code=status) from e
|
|
209
|
+
if status == 403:
|
|
210
|
+
raise NoCredentialError(message, status_code=status) from e
|
|
211
|
+
raise AgentIdentityError(message, status_code=status) from e
|
|
212
|
+
except Exception as e:
|
|
213
|
+
raise AgentIdentityError(f"Request failed: {e}") from e
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: datacules-agent-identity
|
|
3
|
+
Version: 0.3.3
|
|
4
|
+
Summary: Python SDK for the agent-identity credential routing sidecar — by Datacules LLC
|
|
5
|
+
Author-email: Datacules LLC <harshalrasal792@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/hvrcharon1/agent-identity
|
|
8
|
+
Project-URL: Repository, https://github.com/hvrcharon1/agent-identity
|
|
9
|
+
Project-URL: Documentation, https://github.com/hvrcharon1/agent-identity/tree/main/packages/python-sdk
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
15
|
+
|
|
16
|
+
# agent-identity Python SDK
|
|
17
|
+
|
|
18
|
+
Pure-Python client for the `agent-identity` sidecar. No Node.js required.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install agent-identity
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from agent_identity import AgentIdentityClient
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
|
|
32
|
+
client = AgentIdentityClient(base_url="http://localhost:3001")
|
|
33
|
+
|
|
34
|
+
# Resolve a credential for a single agent request
|
|
35
|
+
resolved = client.resolve({
|
|
36
|
+
"userId": "user-abc",
|
|
37
|
+
"resourceId": "crm-db",
|
|
38
|
+
"resourceKind": "shared",
|
|
39
|
+
"provider": "anthropic",
|
|
40
|
+
"model": "claude-sonnet-4-20250514",
|
|
41
|
+
"action": "read",
|
|
42
|
+
"traceId": "trace-xyz",
|
|
43
|
+
"requestedAt": datetime.now(timezone.utc).isoformat(),
|
|
44
|
+
})
|
|
45
|
+
print(resolved["resolvedFor"]) # 'service' or userId
|
|
46
|
+
|
|
47
|
+
# Resolve source + target credentials for a migration phase
|
|
48
|
+
pair = client.resolve_migration({
|
|
49
|
+
"migrationId": "migration-2026-q2",
|
|
50
|
+
"phase": "load",
|
|
51
|
+
"sourceResourceId": "crm-postgres-prod",
|
|
52
|
+
"targetResourceId": "crm-postgres-v2",
|
|
53
|
+
"userId": "svc-migration-bot",
|
|
54
|
+
"provider": "anthropic",
|
|
55
|
+
"model": "claude-sonnet-4-20250514",
|
|
56
|
+
"traceId": "trace-abc123",
|
|
57
|
+
"dryRun": False,
|
|
58
|
+
})
|
|
59
|
+
print(pair["expiresAt"]) # ISO 8601 or None
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## LangChain integration
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from langchain_anthropic import ChatAnthropic
|
|
66
|
+
from agent_identity import AgentIdentityClient
|
|
67
|
+
|
|
68
|
+
client = AgentIdentityClient()
|
|
69
|
+
resolved = client.resolve({...})
|
|
70
|
+
|
|
71
|
+
# resolved["resolvedFor"] is safe to log; the raw API key stays on the server
|
|
72
|
+
llm = ChatAnthropic(model="claude-sonnet-4-20250514")
|
|
73
|
+
# The sidecar injects the API key server-side when you call /api/resolve
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Error handling
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from agent_identity import AgentIdentityClient, NoCredentialError, ValidationError
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
result = client.resolve(ctx)
|
|
83
|
+
except NoCredentialError:
|
|
84
|
+
# 403 — no routing rule matched this context
|
|
85
|
+
...
|
|
86
|
+
except ValidationError as e:
|
|
87
|
+
# 400 — bad request body
|
|
88
|
+
print(e)
|
|
89
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
agent_identity/__init__.py
|
|
4
|
+
datacules_agent_identity.egg-info/PKG-INFO
|
|
5
|
+
datacules_agent_identity.egg-info/SOURCES.txt
|
|
6
|
+
datacules_agent_identity.egg-info/dependency_links.txt
|
|
7
|
+
datacules_agent_identity.egg-info/requires.txt
|
|
8
|
+
datacules_agent_identity.egg-info/top_level.txt
|
|
9
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
agent_identity
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "datacules-agent-identity"
|
|
7
|
+
version = "0.3.3"
|
|
8
|
+
description = "Python SDK for the agent-identity credential routing sidecar — by Datacules LLC"
|
|
9
|
+
authors = [{name = "Datacules LLC", email = "harshalrasal792@gmail.com"}]
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.8"
|
|
13
|
+
# Zero runtime dependencies — pure Python stdlib
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest", "pytest-cov"]
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/hvrcharon1/agent-identity"
|
|
21
|
+
Repository = "https://github.com/hvrcharon1/agent-identity"
|
|
22
|
+
Documentation = "https://github.com/hvrcharon1/agent-identity/tree/main/packages/python-sdk"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["."]
|
|
26
|
+
include = ["agent_identity*"]
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pytest suite for agent_identity.AgentIdentityClient.
|
|
3
|
+
|
|
4
|
+
All HTTP calls are intercepted via unittest.mock so no live server is needed.
|
|
5
|
+
The SDK imports urllib.request as _urllib (a module-level alias), so the
|
|
6
|
+
correct patch target is urllib.request.urlopen — patching the real module
|
|
7
|
+
in-place so every call through _urllib is intercepted regardless of alias.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import unittest
|
|
14
|
+
from io import BytesIO
|
|
15
|
+
from unittest.mock import MagicMock, patch
|
|
16
|
+
|
|
17
|
+
from agent_identity import (
|
|
18
|
+
AgentIdentityClient,
|
|
19
|
+
AgentIdentityError,
|
|
20
|
+
NoCredentialError,
|
|
21
|
+
ValidationError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
def _make_response(body: dict, status: int = 200) -> MagicMock:
|
|
28
|
+
"""Build a fake urllib response context-manager."""
|
|
29
|
+
raw = json.dumps(body).encode()
|
|
30
|
+
mock_resp = MagicMock()
|
|
31
|
+
mock_resp.read.return_value = raw
|
|
32
|
+
mock_resp.status = status
|
|
33
|
+
mock_resp.__enter__ = lambda s: s
|
|
34
|
+
mock_resp.__exit__ = MagicMock(return_value=False)
|
|
35
|
+
return mock_resp
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _make_http_error(body: dict, code: int):
|
|
39
|
+
"""Build a fake urllib.error.HTTPError."""
|
|
40
|
+
import urllib.error
|
|
41
|
+
raw = json.dumps(body).encode()
|
|
42
|
+
err = urllib.error.HTTPError(
|
|
43
|
+
url="http://localhost:3001/api/resolve",
|
|
44
|
+
code=code,
|
|
45
|
+
msg="Error",
|
|
46
|
+
hdrs=None, # type: ignore[arg-type]
|
|
47
|
+
fp=BytesIO(raw),
|
|
48
|
+
)
|
|
49
|
+
return err
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
MINIMAL_CTX = {
|
|
53
|
+
"userId": "user-1",
|
|
54
|
+
"resourceId": "kb-1",
|
|
55
|
+
"resourceKind": "personal",
|
|
56
|
+
"provider": "anthropic",
|
|
57
|
+
"model": "claude-sonnet-4-20250514",
|
|
58
|
+
"action": "read",
|
|
59
|
+
"traceId": "trace-001",
|
|
60
|
+
"requestedAt": "2026-05-27T00:00:00+00:00",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
MINIMAL_MIGRATION = {
|
|
64
|
+
"migrationId": "mig-001",
|
|
65
|
+
"phase": "load",
|
|
66
|
+
"sourceResourceId": "pg-v1",
|
|
67
|
+
"targetResourceId": "pg-v2",
|
|
68
|
+
"userId": "svc-bot",
|
|
69
|
+
"provider": "anthropic",
|
|
70
|
+
"model": "claude-sonnet-4-20250514",
|
|
71
|
+
"traceId": "trace-mig-001",
|
|
72
|
+
"dryRun": False,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ─── Tests ───────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
class TestAgentIdentityClientInit(unittest.TestCase):
|
|
79
|
+
def test_trailing_slash_stripped(self):
|
|
80
|
+
client = AgentIdentityClient(base_url="http://localhost:3001/")
|
|
81
|
+
self.assertEqual(client.base_url, "http://localhost:3001")
|
|
82
|
+
|
|
83
|
+
def test_default_base_url(self):
|
|
84
|
+
client = AgentIdentityClient()
|
|
85
|
+
self.assertEqual(client.base_url, "http://localhost:3001")
|
|
86
|
+
|
|
87
|
+
def test_custom_timeout(self):
|
|
88
|
+
client = AgentIdentityClient(timeout=30)
|
|
89
|
+
self.assertEqual(client.timeout, 30)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestResolve(unittest.TestCase):
|
|
93
|
+
def setUp(self):
|
|
94
|
+
self.client = AgentIdentityClient()
|
|
95
|
+
|
|
96
|
+
@patch("urllib.request.urlopen")
|
|
97
|
+
def test_resolve_success(self, mock_urlopen):
|
|
98
|
+
expected = {"ok": True, "resolvedFor": "user-1", "expiresAt": None}
|
|
99
|
+
mock_urlopen.return_value = _make_response(expected)
|
|
100
|
+
|
|
101
|
+
result = self.client.resolve(MINIMAL_CTX)
|
|
102
|
+
|
|
103
|
+
self.assertEqual(result["resolvedFor"], "user-1")
|
|
104
|
+
self.assertTrue(result["ok"])
|
|
105
|
+
mock_urlopen.assert_called_once()
|
|
106
|
+
|
|
107
|
+
@patch("urllib.request.urlopen")
|
|
108
|
+
def test_resolve_auto_injects_requested_at(self, mock_urlopen):
|
|
109
|
+
"""resolve() must add requestedAt when the caller omits it."""
|
|
110
|
+
ctx_without_ts = {k: v for k, v in MINIMAL_CTX.items() if k != "requestedAt"}
|
|
111
|
+
expected = {"ok": True, "resolvedFor": "user-1", "expiresAt": None}
|
|
112
|
+
mock_urlopen.return_value = _make_response(expected)
|
|
113
|
+
|
|
114
|
+
self.client.resolve(ctx_without_ts) # type: ignore[arg-type]
|
|
115
|
+
|
|
116
|
+
call_args = mock_urlopen.call_args
|
|
117
|
+
request_obj = call_args[0][0]
|
|
118
|
+
sent_body = json.loads(request_obj.data.decode())
|
|
119
|
+
self.assertIn("requestedAt", sent_body)
|
|
120
|
+
|
|
121
|
+
@patch("urllib.request.urlopen")
|
|
122
|
+
def test_resolve_no_credential_raises(self, mock_urlopen):
|
|
123
|
+
mock_urlopen.side_effect = _make_http_error({"error": "No credential resolved"}, 403)
|
|
124
|
+
|
|
125
|
+
with self.assertRaises(NoCredentialError) as cm:
|
|
126
|
+
self.client.resolve(MINIMAL_CTX)
|
|
127
|
+
|
|
128
|
+
self.assertEqual(cm.exception.status_code, 403)
|
|
129
|
+
self.assertIn("No credential resolved", str(cm.exception))
|
|
130
|
+
|
|
131
|
+
@patch("urllib.request.urlopen")
|
|
132
|
+
def test_resolve_validation_error_raises(self, mock_urlopen):
|
|
133
|
+
mock_urlopen.side_effect = _make_http_error({"error": "Missing required field: userId"}, 400)
|
|
134
|
+
|
|
135
|
+
with self.assertRaises(ValidationError) as cm:
|
|
136
|
+
self.client.resolve(MINIMAL_CTX)
|
|
137
|
+
|
|
138
|
+
self.assertEqual(cm.exception.status_code, 400)
|
|
139
|
+
|
|
140
|
+
@patch("urllib.request.urlopen")
|
|
141
|
+
def test_resolve_server_error_raises(self, mock_urlopen):
|
|
142
|
+
mock_urlopen.side_effect = _make_http_error({"error": "Internal server error"}, 500)
|
|
143
|
+
|
|
144
|
+
with self.assertRaises(AgentIdentityError) as cm:
|
|
145
|
+
self.client.resolve(MINIMAL_CTX)
|
|
146
|
+
|
|
147
|
+
self.assertEqual(cm.exception.status_code, 500)
|
|
148
|
+
|
|
149
|
+
@patch("urllib.request.urlopen")
|
|
150
|
+
def test_resolve_network_failure_raises(self, mock_urlopen):
|
|
151
|
+
import urllib.error
|
|
152
|
+
mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
|
|
153
|
+
|
|
154
|
+
with self.assertRaises(AgentIdentityError) as cm:
|
|
155
|
+
self.client.resolve(MINIMAL_CTX)
|
|
156
|
+
|
|
157
|
+
self.assertIsNone(cm.exception.status_code)
|
|
158
|
+
|
|
159
|
+
@patch("urllib.request.urlopen")
|
|
160
|
+
def test_resolve_returns_expires_at(self, mock_urlopen):
|
|
161
|
+
expected = {
|
|
162
|
+
"ok": True,
|
|
163
|
+
"resolvedFor": "user-1",
|
|
164
|
+
"expiresAt": "2026-05-27T01:00:00+00:00",
|
|
165
|
+
}
|
|
166
|
+
mock_urlopen.return_value = _make_response(expected)
|
|
167
|
+
|
|
168
|
+
result = self.client.resolve(MINIMAL_CTX)
|
|
169
|
+
|
|
170
|
+
self.assertEqual(result["expiresAt"], "2026-05-27T01:00:00+00:00")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class TestResolveMigration(unittest.TestCase):
|
|
174
|
+
def setUp(self):
|
|
175
|
+
self.client = AgentIdentityClient()
|
|
176
|
+
|
|
177
|
+
@patch("urllib.request.urlopen")
|
|
178
|
+
def test_resolve_migration_success(self, mock_urlopen):
|
|
179
|
+
expected = {
|
|
180
|
+
"migrationId": "mig-001",
|
|
181
|
+
"phase": "load",
|
|
182
|
+
"sourceResolvedFor": "service",
|
|
183
|
+
"targetResolvedFor": "service",
|
|
184
|
+
"dryRun": False,
|
|
185
|
+
"expiresAt": None,
|
|
186
|
+
}
|
|
187
|
+
mock_urlopen.return_value = _make_response(expected)
|
|
188
|
+
|
|
189
|
+
result = self.client.resolve_migration(MINIMAL_MIGRATION)
|
|
190
|
+
|
|
191
|
+
self.assertEqual(result["migrationId"], "mig-001")
|
|
192
|
+
self.assertEqual(result["sourceResolvedFor"], "service")
|
|
193
|
+
mock_urlopen.assert_called_once()
|
|
194
|
+
|
|
195
|
+
@patch("urllib.request.urlopen")
|
|
196
|
+
def test_resolve_migration_injects_dry_run_false(self, mock_urlopen):
|
|
197
|
+
req_without_dry_run = {k: v for k, v in MINIMAL_MIGRATION.items() if k != "dryRun"}
|
|
198
|
+
expected = {
|
|
199
|
+
"migrationId": "mig-001",
|
|
200
|
+
"phase": "load",
|
|
201
|
+
"sourceResolvedFor": "service",
|
|
202
|
+
"targetResolvedFor": "service",
|
|
203
|
+
"dryRun": False,
|
|
204
|
+
"expiresAt": None,
|
|
205
|
+
}
|
|
206
|
+
mock_urlopen.return_value = _make_response(expected)
|
|
207
|
+
|
|
208
|
+
self.client.resolve_migration(req_without_dry_run) # type: ignore[arg-type]
|
|
209
|
+
|
|
210
|
+
call_args = mock_urlopen.call_args
|
|
211
|
+
request_obj = call_args[0][0]
|
|
212
|
+
sent_body = json.loads(request_obj.data.decode())
|
|
213
|
+
self.assertFalse(sent_body["dryRun"])
|
|
214
|
+
|
|
215
|
+
@patch("urllib.request.urlopen")
|
|
216
|
+
def test_resolve_migration_no_credential_raises(self, mock_urlopen):
|
|
217
|
+
mock_urlopen.side_effect = _make_http_error({"error": "No credential resolved"}, 403)
|
|
218
|
+
|
|
219
|
+
with self.assertRaises(NoCredentialError):
|
|
220
|
+
self.client.resolve_migration(MINIMAL_MIGRATION)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TestHealth(unittest.TestCase):
|
|
224
|
+
def setUp(self):
|
|
225
|
+
self.client = AgentIdentityClient()
|
|
226
|
+
|
|
227
|
+
@patch("urllib.request.urlopen")
|
|
228
|
+
def test_health_returns_true_on_200(self, mock_urlopen):
|
|
229
|
+
mock_urlopen.return_value = _make_response({"ok": True})
|
|
230
|
+
self.assertTrue(self.client.health())
|
|
231
|
+
|
|
232
|
+
@patch("urllib.request.urlopen")
|
|
233
|
+
def test_health_returns_false_on_connection_error(self, mock_urlopen):
|
|
234
|
+
import urllib.error
|
|
235
|
+
mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
|
|
236
|
+
self.assertFalse(self.client.health())
|
|
237
|
+
|
|
238
|
+
@patch("urllib.request.urlopen")
|
|
239
|
+
def test_health_returns_false_on_500(self, mock_urlopen):
|
|
240
|
+
mock_urlopen.side_effect = _make_http_error({"error": "Internal error"}, 500)
|
|
241
|
+
self.assertFalse(self.client.health())
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class TestExceptions(unittest.TestCase):
|
|
245
|
+
def test_no_credential_error_is_agent_identity_error(self):
|
|
246
|
+
err = NoCredentialError("No match", status_code=403)
|
|
247
|
+
self.assertIsInstance(err, AgentIdentityError)
|
|
248
|
+
self.assertEqual(err.status_code, 403)
|
|
249
|
+
|
|
250
|
+
def test_validation_error_is_agent_identity_error(self):
|
|
251
|
+
err = ValidationError("Bad input", status_code=400)
|
|
252
|
+
self.assertIsInstance(err, AgentIdentityError)
|
|
253
|
+
self.assertEqual(err.status_code, 400)
|
|
254
|
+
|
|
255
|
+
def test_base_error_status_code_none(self):
|
|
256
|
+
err = AgentIdentityError("Network failure")
|
|
257
|
+
self.assertIsNone(err.status_code)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
if __name__ == "__main__":
|
|
261
|
+
unittest.main()
|