aiperture 0.3.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.
- aiperture/__init__.py +3 -0
- aiperture/api/__init__.py +5 -0
- aiperture/api/app.py +44 -0
- aiperture/api/auth.py +46 -0
- aiperture/api/routes/__init__.py +0 -0
- aiperture/api/routes/artifacts.py +181 -0
- aiperture/api/routes/audit.py +137 -0
- aiperture/api/routes/config.py +36 -0
- aiperture/api/routes/health.py +57 -0
- aiperture/api/routes/intelligence.py +41 -0
- aiperture/api/routes/metrics.py +13 -0
- aiperture/api/routes/permissions.py +240 -0
- aiperture/cli.py +179 -0
- aiperture/config.py +220 -0
- aiperture/db/__init__.py +5 -0
- aiperture/db/engine.py +75 -0
- aiperture/mcp_server.py +767 -0
- aiperture/metrics.py +80 -0
- aiperture/models/__init__.py +45 -0
- aiperture/models/artifact.py +68 -0
- aiperture/models/audit.py +38 -0
- aiperture/models/intelligence.py +24 -0
- aiperture/models/permission.py +83 -0
- aiperture/models/verdict.py +175 -0
- aiperture/permissions/__init__.py +47 -0
- aiperture/permissions/challenge.py +214 -0
- aiperture/permissions/crowd.py +162 -0
- aiperture/permissions/engine.py +915 -0
- aiperture/permissions/explainer.py +114 -0
- aiperture/permissions/intelligence.py +242 -0
- aiperture/permissions/learning.py +292 -0
- aiperture/permissions/presets.py +167 -0
- aiperture/permissions/resource.py +105 -0
- aiperture/permissions/risk.py +572 -0
- aiperture/permissions/scope_normalize.py +232 -0
- aiperture/permissions/similarity.py +281 -0
- aiperture/plugins.py +157 -0
- aiperture/stores/__init__.py +6 -0
- aiperture/stores/artifact_store.py +209 -0
- aiperture/stores/audit_store.py +233 -0
- aiperture-0.3.0.dist-info/METADATA +570 -0
- aiperture-0.3.0.dist-info/RECORD +45 -0
- aiperture-0.3.0.dist-info/WHEEL +4 -0
- aiperture-0.3.0.dist-info/entry_points.txt +2 -0
- aiperture-0.3.0.dist-info/licenses/LICENSE +190 -0
aiperture/__init__.py
ADDED
aiperture/api/app.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends, FastAPI
|
|
7
|
+
|
|
8
|
+
from aiperture import plugins
|
|
9
|
+
from aiperture.api.auth import require_api_key
|
|
10
|
+
from aiperture.api.routes import artifacts, audit, config, health, intelligence, metrics, permissions
|
|
11
|
+
from aiperture.db import init_db
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@asynccontextmanager
|
|
15
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
16
|
+
plugins.load_all()
|
|
17
|
+
init_db()
|
|
18
|
+
yield
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def create_app() -> FastAPI:
|
|
22
|
+
app = FastAPI(
|
|
23
|
+
title="AIperture",
|
|
24
|
+
description="The permission layer for AI agents. Controls what passes through.",
|
|
25
|
+
version="0.3.0",
|
|
26
|
+
lifespan=lifespan,
|
|
27
|
+
dependencies=[Depends(require_api_key)],
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
app.include_router(permissions.router, prefix="/permissions", tags=["permissions"])
|
|
31
|
+
app.include_router(artifacts.router, prefix="/artifacts", tags=["artifacts"])
|
|
32
|
+
app.include_router(audit.router, prefix="/audit", tags=["audit"])
|
|
33
|
+
app.include_router(intelligence.router, prefix="/intelligence", tags=["intelligence"])
|
|
34
|
+
app.include_router(config.router, prefix="/config", tags=["config"])
|
|
35
|
+
app.include_router(health.router, tags=["health"])
|
|
36
|
+
app.include_router(metrics.router, tags=["metrics"])
|
|
37
|
+
|
|
38
|
+
# Register plugin routers if any
|
|
39
|
+
plugin_router = plugins.get("router")
|
|
40
|
+
if plugin_router is not None:
|
|
41
|
+
for router_info in plugin_router.get_routers():
|
|
42
|
+
app.include_router(router_info)
|
|
43
|
+
|
|
44
|
+
return app
|
aiperture/api/auth.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Bearer token authentication for the AIperture HTTP API.
|
|
2
|
+
|
|
3
|
+
If AIPERTURE_API_KEY is empty or unset, all requests are allowed (local dev mode).
|
|
4
|
+
If set, every request must include an ``Authorization: Bearer <key>`` header
|
|
5
|
+
whose value matches the configured key exactly.
|
|
6
|
+
|
|
7
|
+
A plugin ``auth_backend`` can replace or extend the default bearer-token check.
|
|
8
|
+
|
|
9
|
+
The MCP server (stdio transport) is unaffected — this dependency is only wired
|
|
10
|
+
into the FastAPI application.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from fastapi import Depends, HTTPException, Request, status
|
|
16
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
17
|
+
|
|
18
|
+
import aiperture.config
|
|
19
|
+
from aiperture import plugins
|
|
20
|
+
|
|
21
|
+
# optional=True so the scheme does not return 403 when the header is absent;
|
|
22
|
+
# we handle the missing-header case ourselves with a clear 401.
|
|
23
|
+
_bearer_scheme = HTTPBearer(auto_error=False)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def require_api_key(
|
|
27
|
+
request: Request,
|
|
28
|
+
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""FastAPI dependency that enforces Bearer token auth when an API key is configured."""
|
|
31
|
+
# Delegate to plugin auth backend if present
|
|
32
|
+
auth_backend = plugins.get("auth_backend")
|
|
33
|
+
if auth_backend is not None:
|
|
34
|
+
await auth_backend.authenticate(request)
|
|
35
|
+
return
|
|
36
|
+
|
|
37
|
+
configured_key = aiperture.config.settings.api_key
|
|
38
|
+
if not configured_key:
|
|
39
|
+
# No key configured — open access (local dev mode).
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
if credentials is None or credentials.credentials != configured_key:
|
|
43
|
+
raise HTTPException(
|
|
44
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
45
|
+
detail="Invalid or missing API key",
|
|
46
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Artifact API — store, retrieve, verify, and track costs.
|
|
2
|
+
|
|
3
|
+
External runtimes call these endpoints to:
|
|
4
|
+
1. Store every output as a verified artifact
|
|
5
|
+
2. Retrieve artifacts for audit or review
|
|
6
|
+
3. Re-verify artifact integrity
|
|
7
|
+
4. Get cost summaries across runtimes/models
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, HTTPException
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from aiperture.stores.artifact_store import ArtifactStore
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
store = ArtifactStore()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- Request/Response schemas ---
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StoreRequest(BaseModel):
|
|
24
|
+
content: str
|
|
25
|
+
artifact_type: str = "custom"
|
|
26
|
+
organization_id: str = "default"
|
|
27
|
+
session_id: str = ""
|
|
28
|
+
task_id: str = ""
|
|
29
|
+
runtime_id: str = ""
|
|
30
|
+
tool_name: str = ""
|
|
31
|
+
tool_args: dict | None = None
|
|
32
|
+
summary: str = ""
|
|
33
|
+
extra: dict | None = None
|
|
34
|
+
tokens_input: int = 0
|
|
35
|
+
tokens_output: int = 0
|
|
36
|
+
cost_usd: float = 0.0
|
|
37
|
+
model_used: str = ""
|
|
38
|
+
provider_used: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ArtifactResponse(BaseModel):
|
|
42
|
+
artifact_id: str
|
|
43
|
+
type: str
|
|
44
|
+
content_hash: str
|
|
45
|
+
verification_status: str
|
|
46
|
+
tool_name: str
|
|
47
|
+
summary: str
|
|
48
|
+
tokens_input: int
|
|
49
|
+
tokens_output: int
|
|
50
|
+
cost_usd: float
|
|
51
|
+
model_used: str
|
|
52
|
+
provider_used: str
|
|
53
|
+
created_at: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- Endpoints ---
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@router.post("/store", response_model=ArtifactResponse)
|
|
60
|
+
def store_artifact(req: StoreRequest):
|
|
61
|
+
"""Store an artifact with automatic SHA-256 verification.
|
|
62
|
+
|
|
63
|
+
Every tool call, every LLM response, every output from any
|
|
64
|
+
external runtime gets persisted here as the canonical record.
|
|
65
|
+
"""
|
|
66
|
+
artifact = store.store(
|
|
67
|
+
content=req.content,
|
|
68
|
+
artifact_type=req.artifact_type,
|
|
69
|
+
organization_id=req.organization_id,
|
|
70
|
+
session_id=req.session_id,
|
|
71
|
+
task_id=req.task_id,
|
|
72
|
+
runtime_id=req.runtime_id,
|
|
73
|
+
tool_name=req.tool_name,
|
|
74
|
+
tool_args=req.tool_args,
|
|
75
|
+
summary=req.summary,
|
|
76
|
+
extra=req.extra,
|
|
77
|
+
tokens_input=req.tokens_input,
|
|
78
|
+
tokens_output=req.tokens_output,
|
|
79
|
+
cost_usd=req.cost_usd,
|
|
80
|
+
model_used=req.model_used,
|
|
81
|
+
provider_used=req.provider_used,
|
|
82
|
+
)
|
|
83
|
+
return ArtifactResponse(
|
|
84
|
+
artifact_id=artifact.artifact_id,
|
|
85
|
+
type=artifact.type,
|
|
86
|
+
content_hash=artifact.content_hash,
|
|
87
|
+
verification_status=artifact.verification_status,
|
|
88
|
+
tool_name=artifact.tool_name,
|
|
89
|
+
summary=artifact.summary,
|
|
90
|
+
tokens_input=artifact.tokens_input,
|
|
91
|
+
tokens_output=artifact.tokens_output,
|
|
92
|
+
cost_usd=artifact.cost_usd,
|
|
93
|
+
model_used=artifact.model_used,
|
|
94
|
+
provider_used=artifact.provider_used,
|
|
95
|
+
created_at=artifact.created_at.isoformat(),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@router.get("/costs/summary")
|
|
100
|
+
def cost_summary(
|
|
101
|
+
organization_id: str = "default",
|
|
102
|
+
task_id: str | None = None,
|
|
103
|
+
runtime_id: str | None = None,
|
|
104
|
+
):
|
|
105
|
+
"""Get cost summary across artifacts.
|
|
106
|
+
|
|
107
|
+
Breaks down by provider and model. Shows total tokens and cost.
|
|
108
|
+
"""
|
|
109
|
+
return store.get_cost_summary(
|
|
110
|
+
organization_id=organization_id,
|
|
111
|
+
task_id=task_id,
|
|
112
|
+
runtime_id=runtime_id,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@router.get("/task/{task_id}")
|
|
117
|
+
def list_by_task(task_id: str, organization_id: str = "default"):
|
|
118
|
+
"""List all artifacts for a task."""
|
|
119
|
+
artifacts = store.list_by_task(task_id, organization_id)
|
|
120
|
+
return {
|
|
121
|
+
"task_id": task_id,
|
|
122
|
+
"count": len(artifacts),
|
|
123
|
+
"artifacts": [
|
|
124
|
+
{
|
|
125
|
+
"artifact_id": a.artifact_id,
|
|
126
|
+
"type": a.type,
|
|
127
|
+
"content_hash": a.content_hash,
|
|
128
|
+
"verification_status": a.verification_status,
|
|
129
|
+
"tool_name": a.tool_name,
|
|
130
|
+
"summary": a.summary,
|
|
131
|
+
"cost_usd": a.cost_usd,
|
|
132
|
+
"created_at": a.created_at.isoformat(),
|
|
133
|
+
}
|
|
134
|
+
for a in artifacts
|
|
135
|
+
],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@router.get("/{artifact_id}")
|
|
140
|
+
def get_artifact(artifact_id: str):
|
|
141
|
+
"""Retrieve a stored artifact."""
|
|
142
|
+
artifact = store.get(artifact_id)
|
|
143
|
+
if not artifact:
|
|
144
|
+
raise HTTPException(status_code=404, detail="Artifact not found")
|
|
145
|
+
return {
|
|
146
|
+
"artifact_id": artifact.artifact_id,
|
|
147
|
+
"type": artifact.type,
|
|
148
|
+
"content_hash": artifact.content_hash,
|
|
149
|
+
"verification_status": artifact.verification_status,
|
|
150
|
+
"content": artifact.content if artifact.storage_backend == "inline" else None,
|
|
151
|
+
"tool_name": artifact.tool_name,
|
|
152
|
+
"tool_args": artifact.tool_args,
|
|
153
|
+
"summary": artifact.summary,
|
|
154
|
+
"extra": artifact.extra,
|
|
155
|
+
"tokens_input": artifact.tokens_input,
|
|
156
|
+
"tokens_output": artifact.tokens_output,
|
|
157
|
+
"cost_usd": artifact.cost_usd,
|
|
158
|
+
"model_used": artifact.model_used,
|
|
159
|
+
"provider_used": artifact.provider_used,
|
|
160
|
+
"runtime_id": artifact.runtime_id,
|
|
161
|
+
"created_at": artifact.created_at.isoformat(),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@router.post("/{artifact_id}/verify")
|
|
166
|
+
def verify_artifact(artifact_id: str):
|
|
167
|
+
"""Re-verify an artifact's integrity.
|
|
168
|
+
|
|
169
|
+
Recomputes SHA-256 hash and compares to stored hash.
|
|
170
|
+
Returns current verification status.
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
artifact = store.verify(artifact_id)
|
|
174
|
+
except ValueError as e:
|
|
175
|
+
raise HTTPException(status_code=404, detail=str(e)) from e
|
|
176
|
+
return {
|
|
177
|
+
"artifact_id": artifact.artifact_id,
|
|
178
|
+
"verification_status": artifact.verification_status,
|
|
179
|
+
"content_hash": artifact.content_hash,
|
|
180
|
+
"verified_at": artifact.verified_at.isoformat() if artifact.verified_at else None,
|
|
181
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Audit API — immutable event trail.
|
|
2
|
+
|
|
3
|
+
Everything that happens in Aperture is logged here.
|
|
4
|
+
This is the compliance backbone.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
|
|
11
|
+
from aiperture.stores.audit_store import AuditStore
|
|
12
|
+
|
|
13
|
+
router = APIRouter()
|
|
14
|
+
store = AuditStore()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/events")
|
|
18
|
+
def list_events(
|
|
19
|
+
organization_id: str = "default",
|
|
20
|
+
event_type: str | None = None,
|
|
21
|
+
entity_type: str | None = None,
|
|
22
|
+
entity_id: str | None = None,
|
|
23
|
+
actor_id: str | None = None,
|
|
24
|
+
runtime_id: str | None = None,
|
|
25
|
+
since: str | None = None,
|
|
26
|
+
until: str | None = None,
|
|
27
|
+
limit: int = 100,
|
|
28
|
+
offset: int = 0,
|
|
29
|
+
):
|
|
30
|
+
"""Query audit events with filters."""
|
|
31
|
+
limit = min(limit, 1000)
|
|
32
|
+
try:
|
|
33
|
+
since_dt = datetime.fromisoformat(since) if since else None
|
|
34
|
+
except ValueError:
|
|
35
|
+
raise HTTPException(status_code=422, detail=f"Invalid 'since' date format: {since!r}")
|
|
36
|
+
try:
|
|
37
|
+
until_dt = datetime.fromisoformat(until) if until else None
|
|
38
|
+
except ValueError:
|
|
39
|
+
raise HTTPException(status_code=422, detail=f"Invalid 'until' date format: {until!r}")
|
|
40
|
+
|
|
41
|
+
events = store.list_events(
|
|
42
|
+
organization_id=organization_id,
|
|
43
|
+
event_type=event_type,
|
|
44
|
+
entity_type=entity_type,
|
|
45
|
+
entity_id=entity_id,
|
|
46
|
+
actor_id=actor_id,
|
|
47
|
+
runtime_id=runtime_id,
|
|
48
|
+
since=since_dt,
|
|
49
|
+
until=until_dt,
|
|
50
|
+
limit=limit,
|
|
51
|
+
offset=offset,
|
|
52
|
+
)
|
|
53
|
+
return {
|
|
54
|
+
"count": len(events),
|
|
55
|
+
"events": [
|
|
56
|
+
{
|
|
57
|
+
"event_id": e.event_id,
|
|
58
|
+
"event_type": e.event_type,
|
|
59
|
+
"entity_type": e.entity_type,
|
|
60
|
+
"entity_id": e.entity_id,
|
|
61
|
+
"summary": e.summary,
|
|
62
|
+
"actor_id": e.actor_id,
|
|
63
|
+
"actor_type": e.actor_type,
|
|
64
|
+
"runtime_id": e.runtime_id,
|
|
65
|
+
"details": e.details,
|
|
66
|
+
"created_at": e.created_at.isoformat(),
|
|
67
|
+
}
|
|
68
|
+
for e in events
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get("/events/{event_id}")
|
|
74
|
+
def get_event(event_id: str):
|
|
75
|
+
"""Get a single audit event with full details."""
|
|
76
|
+
event = store.get_event(event_id)
|
|
77
|
+
if not event:
|
|
78
|
+
raise HTTPException(status_code=404, detail="Audit event not found")
|
|
79
|
+
return {
|
|
80
|
+
"event_id": event.event_id,
|
|
81
|
+
"event_type": event.event_type,
|
|
82
|
+
"entity_type": event.entity_type,
|
|
83
|
+
"entity_id": event.entity_id,
|
|
84
|
+
"summary": event.summary,
|
|
85
|
+
"actor_id": event.actor_id,
|
|
86
|
+
"actor_type": event.actor_type,
|
|
87
|
+
"runtime_id": event.runtime_id,
|
|
88
|
+
"details": event.details,
|
|
89
|
+
"previous_state": event.previous_state,
|
|
90
|
+
"new_state": event.new_state,
|
|
91
|
+
"created_at": event.created_at.isoformat(),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@router.get("/entity/{entity_type}/{entity_id}")
|
|
96
|
+
def entity_history(
|
|
97
|
+
entity_type: str,
|
|
98
|
+
entity_id: str,
|
|
99
|
+
organization_id: str = "default",
|
|
100
|
+
limit: int = 50,
|
|
101
|
+
):
|
|
102
|
+
"""Full audit history of a specific entity."""
|
|
103
|
+
limit = min(limit, 1000)
|
|
104
|
+
events = store.get_entity_history(entity_type, entity_id, organization_id, limit)
|
|
105
|
+
return {
|
|
106
|
+
"entity_type": entity_type,
|
|
107
|
+
"entity_id": entity_id,
|
|
108
|
+
"count": len(events),
|
|
109
|
+
"events": [
|
|
110
|
+
{
|
|
111
|
+
"event_id": e.event_id,
|
|
112
|
+
"event_type": e.event_type,
|
|
113
|
+
"summary": e.summary,
|
|
114
|
+
"actor_id": e.actor_id,
|
|
115
|
+
"details": e.details,
|
|
116
|
+
"created_at": e.created_at.isoformat(),
|
|
117
|
+
}
|
|
118
|
+
for e in events
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@router.get("/count")
|
|
124
|
+
def event_count(organization_id: str = "default"):
|
|
125
|
+
"""Total audit event count."""
|
|
126
|
+
return {"count": store.count(organization_id)}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@router.get("/verify-chain")
|
|
130
|
+
def verify_chain(organization_id: str = "default"):
|
|
131
|
+
"""Verify the hash chain integrity of the audit trail.
|
|
132
|
+
|
|
133
|
+
Walks the entire chain and checks that each event's hash matches
|
|
134
|
+
its computed value and links to the previous event's hash.
|
|
135
|
+
Any deletion, reordering, or tampering breaks the chain.
|
|
136
|
+
"""
|
|
137
|
+
return store.verify_chain(organization_id)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Configuration API — GET /config and PATCH /config for tunable settings."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
import aiperture.config
|
|
9
|
+
|
|
10
|
+
router = APIRouter()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigPatchRequest(BaseModel):
|
|
14
|
+
settings: dict[str, Any]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("")
|
|
18
|
+
def get_config():
|
|
19
|
+
"""Return current tunable settings and their descriptions."""
|
|
20
|
+
return {
|
|
21
|
+
"settings": aiperture.config.get_tunable_config(),
|
|
22
|
+
"descriptions": dict(aiperture.config.Settings.TUNABLE_DESCRIPTIONS),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.patch("")
|
|
27
|
+
def patch_config(body: ConfigPatchRequest):
|
|
28
|
+
"""Update tunable settings at runtime. Persists to .aiperture.env."""
|
|
29
|
+
try:
|
|
30
|
+
aiperture.config.update_settings(body.settings)
|
|
31
|
+
except ValueError as e:
|
|
32
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
33
|
+
return {
|
|
34
|
+
"updated": True,
|
|
35
|
+
"settings": aiperture.config.get_tunable_config(),
|
|
36
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Health check endpoint — database connectivity probe."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter
|
|
6
|
+
from sqlalchemy import text
|
|
7
|
+
from sqlmodel import Session
|
|
8
|
+
|
|
9
|
+
from aiperture import plugins
|
|
10
|
+
from aiperture.db import get_engine
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
router = APIRouter()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/health")
|
|
18
|
+
def health_check():
|
|
19
|
+
"""Check service health including database connectivity.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
200 with {"status": "healthy", "database": "connected"} when DB is reachable.
|
|
23
|
+
200 with {"status": "degraded", "database": "error", "detail": "..."} when DB is unreachable.
|
|
24
|
+
|
|
25
|
+
Always returns 200 so load balancers can distinguish between
|
|
26
|
+
"service is up but degraded" vs "service is down".
|
|
27
|
+
"""
|
|
28
|
+
result = {
|
|
29
|
+
"service": "aiperture",
|
|
30
|
+
"version": "0.2.0",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
with Session(get_engine()) as session:
|
|
35
|
+
session.execute(text("SELECT 1"))
|
|
36
|
+
result["status"] = "healthy"
|
|
37
|
+
result["database"] = "connected"
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
detail = str(exc)
|
|
40
|
+
logger.warning("Health check: database unreachable — %s", detail)
|
|
41
|
+
result["status"] = "degraded"
|
|
42
|
+
result["database"] = "error"
|
|
43
|
+
result["detail"] = detail
|
|
44
|
+
|
|
45
|
+
# Run plugin health checkers
|
|
46
|
+
health_checker = plugins.get("health_checker")
|
|
47
|
+
if health_checker is not None:
|
|
48
|
+
try:
|
|
49
|
+
plugin_result = health_checker.check()
|
|
50
|
+
result[health_checker.name] = plugin_result
|
|
51
|
+
if plugin_result.get("status") != "healthy":
|
|
52
|
+
result["status"] = "degraded"
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
result[health_checker.name] = {"status": "error", "detail": str(exc)}
|
|
55
|
+
result["status"] = "degraded"
|
|
56
|
+
|
|
57
|
+
return result
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Intelligence API — cross-org anonymized permission signals."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
|
|
5
|
+
from aiperture.permissions.intelligence import IntelligenceEngine
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/global-signal")
|
|
11
|
+
def get_global_signal(
|
|
12
|
+
tool: str,
|
|
13
|
+
action: str,
|
|
14
|
+
scope: str,
|
|
15
|
+
):
|
|
16
|
+
"""Get cross-organization permission signal for a pattern.
|
|
17
|
+
|
|
18
|
+
Returns DP-protected aggregate statistics. Only available when
|
|
19
|
+
enough organizations have contributed data (min_orgs threshold).
|
|
20
|
+
"""
|
|
21
|
+
import aiperture.config
|
|
22
|
+
|
|
23
|
+
intel = IntelligenceEngine(
|
|
24
|
+
min_orgs=aiperture.config.settings.intelligence_min_orgs,
|
|
25
|
+
default_epsilon=aiperture.config.settings.intelligence_epsilon,
|
|
26
|
+
)
|
|
27
|
+
signal = intel.get_global_signal(tool, action, scope)
|
|
28
|
+
|
|
29
|
+
if signal is None:
|
|
30
|
+
return {"available": False, "reason": "Insufficient cross-org data"}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"available": True,
|
|
34
|
+
"total_orgs": signal.total_orgs,
|
|
35
|
+
"estimated_allow_rate": round(signal.estimated_allow_rate, 3),
|
|
36
|
+
"confidence_interval": [
|
|
37
|
+
round(signal.confidence_interval[0], 3),
|
|
38
|
+
round(signal.confidence_interval[1], 3),
|
|
39
|
+
],
|
|
40
|
+
"sample_size": signal.sample_size,
|
|
41
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Prometheus metrics endpoint."""
|
|
2
|
+
|
|
3
|
+
from fastapi import APIRouter
|
|
4
|
+
from fastapi.responses import PlainTextResponse
|
|
5
|
+
from prometheus_client import generate_latest
|
|
6
|
+
|
|
7
|
+
router = APIRouter()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@router.get("/metrics", response_class=PlainTextResponse)
|
|
11
|
+
def metrics():
|
|
12
|
+
"""Prometheus-compatible metrics endpoint."""
|
|
13
|
+
return generate_latest()
|