quantlix 0.1.4__tar.gz → 0.2.0__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.
- {quantlix-0.1.4 → quantlix-0.2.0}/PKG-INFO +15 -9
- {quantlix-0.1.4 → quantlix-0.2.0}/README.md +8 -8
- quantlix-0.2.0/api/audit_signing.py +127 -0
- quantlix-0.2.0/api/budget_hierarchy.py +165 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/config.py +33 -1
- quantlix-0.2.0/api/cost_estimation.py +54 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/email.py +8 -5
- quantlix-0.2.0/api/enforcement_events.py +118 -0
- quantlix-0.2.0/api/enforcement_otel.py +76 -0
- quantlix-0.2.0/api/enforcement_packs.py +221 -0
- quantlix-0.2.0/api/enforcement_webhook.py +67 -0
- quantlix-0.2.0/api/entitlement_enforcement.py +248 -0
- quantlix-0.2.0/api/entitlements.py +168 -0
- quantlix-0.2.0/api/fleet_overview.py +274 -0
- quantlix-0.2.0/api/guardrails/__init__.py +5 -0
- quantlix-0.2.0/api/guardrails/base.py +29 -0
- quantlix-0.2.0/api/guardrails/block_rate.py +80 -0
- quantlix-0.2.0/api/guardrails/config.py +16 -0
- quantlix-0.2.0/api/guardrails/metrics.py +21 -0
- quantlix-0.2.0/api/guardrails/rules.py +125 -0
- quantlix-0.2.0/api/guardrails/runner.py +122 -0
- quantlix-0.2.0/api/logging_config.py +20 -0
- quantlix-0.2.0/api/main.py +306 -0
- quantlix-0.2.0/api/metrics.py +77 -0
- quantlix-0.2.0/api/models.py +294 -0
- quantlix-0.2.0/api/pipeline_lock.py +485 -0
- quantlix-0.2.0/api/policies/__init__.py +4 -0
- quantlix-0.2.0/api/policies/policy.py +39 -0
- quantlix-0.2.0/api/policy_engine/__init__.py +6 -0
- quantlix-0.2.0/api/policy_engine/budget.py +165 -0
- quantlix-0.2.0/api/policy_engine/engine.py +276 -0
- quantlix-0.2.0/api/policy_engine/models.py +54 -0
- quantlix-0.2.0/api/policy_resolver.py +63 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/rate_limit.py +18 -0
- quantlix-0.2.0/api/redact.py +48 -0
- quantlix-0.2.0/api/retention_exports.py +283 -0
- quantlix-0.2.0/api/routes/audit.py +40 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/auth.py +6 -3
- quantlix-0.2.0/api/routes/contract.py +35 -0
- quantlix-0.2.0/api/routes/demo.py +45 -0
- quantlix-0.2.0/api/routes/deploy.py +161 -0
- quantlix-0.2.0/api/routes/deployments.py +1020 -0
- quantlix-0.2.0/api/routes/enforcement_events.py +331 -0
- quantlix-0.2.0/api/routes/enforcement_packs.py +24 -0
- quantlix-0.2.0/api/routes/entitlements.py +61 -0
- quantlix-0.2.0/api/routes/jobs.py +169 -0
- quantlix-0.2.0/api/routes/orgs.py +462 -0
- quantlix-0.2.0/api/routes/run.py +339 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/status.py +6 -0
- quantlix-0.2.0/api/routes/usage.py +340 -0
- quantlix-0.2.0/api/schemas.py +667 -0
- quantlix-0.2.0/api/scoring/__init__.py +4 -0
- quantlix-0.2.0/api/scoring/scorer.py +24 -0
- quantlix-0.2.0/api/security_middleware.py +103 -0
- quantlix-0.2.0/api/trace_context.py +103 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/usage_service.py +13 -0
- quantlix-0.2.0/api/value_dashboard.py +412 -0
- quantlix-0.2.0/api/violations/__init__.py +6 -0
- quantlix-0.2.0/api/violations/codes.py +53 -0
- quantlix-0.2.0/api/violations/factory.py +71 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/cli/main.py +393 -12
- {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/config.py +3 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/k8s.py +4 -1
- quantlix-0.2.0/orchestrator/worker.py +360 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/pyproject.toml +20 -1
- {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/PKG-INFO +15 -9
- quantlix-0.2.0/quantlix.egg-info/SOURCES.txt +95 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/requires.txt +7 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/sdk/quantlix/client.py +130 -0
- quantlix-0.2.0/tests/test_audit_signing.py +63 -0
- quantlix-0.2.0/tests/test_cost_amplification.py +75 -0
- quantlix-0.2.0/tests/test_failure_modes.py +101 -0
- quantlix-0.2.0/tests/test_fallback.py +337 -0
- quantlix-0.2.0/tests/test_multi_tenancy.py +335 -0
- quantlix-0.2.0/tests/test_pipeline_lock_ship_checklist.py +301 -0
- quantlix-0.2.0/tests/test_plan_enforcement.py +236 -0
- quantlix-0.2.0/tests/test_violations.py +51 -0
- quantlix-0.1.4/api/main.py +0 -152
- quantlix-0.1.4/api/metrics.py +0 -35
- quantlix-0.1.4/api/models.py +0 -162
- quantlix-0.1.4/api/routes/deploy.py +0 -78
- quantlix-0.1.4/api/routes/deployments.py +0 -129
- quantlix-0.1.4/api/routes/jobs.py +0 -43
- quantlix-0.1.4/api/routes/run.py +0 -76
- quantlix-0.1.4/api/routes/usage.py +0 -143
- quantlix-0.1.4/api/schemas.py +0 -287
- quantlix-0.1.4/orchestrator/worker.py +0 -192
- quantlix-0.1.4/quantlix.egg-info/SOURCES.txt +0 -44
- {quantlix-0.1.4 → quantlix-0.2.0}/LICENSE +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/__init__.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/auth.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/db.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/disposable_domains.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/queue.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/__init__.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/billing.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/health.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/cli/__init__.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/__init__.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/inference_client.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/main.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/dependency_links.txt +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/entry_points.txt +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/top_level.txt +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/sdk/__init__.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/sdk/python/__init__.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/sdk/quantlix/__init__.py +0 -0
- {quantlix-0.1.4 → quantlix-0.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: quantlix
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Quantlix — AI-focused GPU inference platform
|
|
5
5
|
Author: Quantlix
|
|
6
6
|
License: MIT
|
|
@@ -20,6 +20,9 @@ Requires-Dist: typer[all]>=0.9.0
|
|
|
20
20
|
Requires-Dist: python-dotenv>=1.0.0
|
|
21
21
|
Requires-Dist: httpx>=0.26.0
|
|
22
22
|
Requires-Dist: rich>=13.0.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
23
26
|
Provides-Extra: full
|
|
24
27
|
Requires-Dist: fastapi>=0.109.0; extra == "full"
|
|
25
28
|
Requires-Dist: uvicorn[standard]>=0.27.0; extra == "full"
|
|
@@ -35,19 +38,22 @@ Requires-Dist: pydantic-settings>=2.1.0; extra == "full"
|
|
|
35
38
|
Requires-Dist: prometheus-client>=0.19.0; extra == "full"
|
|
36
39
|
Requires-Dist: aiosmtplib>=3.0.0; extra == "full"
|
|
37
40
|
Requires-Dist: stripe>=8.0.0; extra == "full"
|
|
41
|
+
Requires-Dist: opentelemetry-api>=1.22.0; extra == "full"
|
|
42
|
+
Requires-Dist: opentelemetry-sdk>=1.22.0; extra == "full"
|
|
43
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.22.0; extra == "full"
|
|
38
44
|
Dynamic: license-file
|
|
39
45
|
|
|
40
46
|
# Quantlix
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
The AI Runtime Control Plane. Govern ML & AI workloads at execution time. Enforce contracts. Apply policies. Control cost. Every request becomes deterministic.
|
|
43
49
|
|
|
44
50
|
## Features
|
|
45
51
|
|
|
46
|
-
- **
|
|
47
|
-
- **
|
|
48
|
-
- **
|
|
49
|
-
- **Customer portal** —
|
|
50
|
-
- **CLI** — `quantlix deploy`, `quantlix run`
|
|
52
|
+
- **Runtime governance** — Contract enforcement, policy engine, budget controls
|
|
53
|
+
- **Enforcement is strict by default** — Schema validation, feature contracts, drift prevention
|
|
54
|
+
- **REST API** — Deploy, run, status, enforcement events, audit exports
|
|
55
|
+
- **Customer portal** — Dashboard, enforcement visibility, usage
|
|
56
|
+
- **CLI** — `quantlix deploy`, `quantlix run` — AI runtime control plane
|
|
51
57
|
|
|
52
58
|
## Quick start
|
|
53
59
|
|
|
@@ -68,11 +74,11 @@ export QUANTLIX_API_KEY="qxl_xxx" # from login output
|
|
|
68
74
|
|
|
69
75
|
# 3. Deploy and run
|
|
70
76
|
quantlix deploy qx-example
|
|
71
|
-
# Run inference
|
|
77
|
+
# Run inference — request passes through enforcement before execution
|
|
72
78
|
quantlix run <deployment_id> -i '{"prompt": "Hello!"}'
|
|
73
79
|
```
|
|
74
80
|
|
|
75
|
-
See [docs/CLI_GUIDE.md](docs/CLI_GUIDE.md) for detailed setup.
|
|
81
|
+
See [docs/CLI_GUIDE.md](docs/CLI_GUIDE.md) for detailed setup. To test Pipeline Lock locally, see [docs/TESTING_PIPELINE_LOCK.md](docs/TESTING_PIPELINE_LOCK.md). For a full pre-production test checklist, see [docs/LOCAL_TESTING_GUIDE.md](docs/LOCAL_TESTING_GUIDE.md).
|
|
76
82
|
|
|
77
83
|
## Architecture
|
|
78
84
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# Quantlix
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The AI Runtime Control Plane. Govern ML & AI workloads at execution time. Enforce contracts. Apply policies. Control cost. Every request becomes deterministic.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
8
|
-
- **
|
|
9
|
-
- **
|
|
10
|
-
- **Customer portal** —
|
|
11
|
-
- **CLI** — `quantlix deploy`, `quantlix run`
|
|
7
|
+
- **Runtime governance** — Contract enforcement, policy engine, budget controls
|
|
8
|
+
- **Enforcement is strict by default** — Schema validation, feature contracts, drift prevention
|
|
9
|
+
- **REST API** — Deploy, run, status, enforcement events, audit exports
|
|
10
|
+
- **Customer portal** — Dashboard, enforcement visibility, usage
|
|
11
|
+
- **CLI** — `quantlix deploy`, `quantlix run` — AI runtime control plane
|
|
12
12
|
|
|
13
13
|
## Quick start
|
|
14
14
|
|
|
@@ -29,11 +29,11 @@ export QUANTLIX_API_KEY="qxl_xxx" # from login output
|
|
|
29
29
|
|
|
30
30
|
# 3. Deploy and run
|
|
31
31
|
quantlix deploy qx-example
|
|
32
|
-
# Run inference
|
|
32
|
+
# Run inference — request passes through enforcement before execution
|
|
33
33
|
quantlix run <deployment_id> -i '{"prompt": "Hello!"}'
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
See [docs/CLI_GUIDE.md](docs/CLI_GUIDE.md) for detailed setup.
|
|
36
|
+
See [docs/CLI_GUIDE.md](docs/CLI_GUIDE.md) for detailed setup. To test Pipeline Lock locally, see [docs/TESTING_PIPELINE_LOCK.md](docs/TESTING_PIPELINE_LOCK.md). For a full pre-production test checklist, see [docs/LOCAL_TESTING_GUIDE.md](docs/LOCAL_TESTING_GUIDE.md).
|
|
37
37
|
|
|
38
38
|
## Architecture
|
|
39
39
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Signed audit exports — hash chain and HMAC signature for tamper-evident JSONL."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, AsyncIterator
|
|
7
|
+
|
|
8
|
+
from api.config import settings
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
CHAIN_SEED = b"quantlix-audit-v1"
|
|
13
|
+
SIGNATURE_ALGORITHM = "HMAC-SHA256"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _get_signing_key() -> bytes | None:
|
|
17
|
+
"""Audit signing key from config. Required for signed exports."""
|
|
18
|
+
key = getattr(settings, "audit_signing_key", "") or ""
|
|
19
|
+
return key.encode() if key else None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _hash_line(line: str, prev_hash: str) -> str:
|
|
23
|
+
"""Compute chain hash: SHA256(prev_hash + line)."""
|
|
24
|
+
payload = f"{prev_hash}\n{line}".encode()
|
|
25
|
+
return hashlib.sha256(payload).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _sign_payload(payload: str, key: bytes) -> str:
|
|
29
|
+
"""HMAC-SHA256 signature of payload."""
|
|
30
|
+
return hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_audit_signing_key_id() -> str:
|
|
34
|
+
"""Key ID for rotation (first 8 chars of key hash)."""
|
|
35
|
+
key = _get_signing_key()
|
|
36
|
+
if not key:
|
|
37
|
+
return "none"
|
|
38
|
+
return hashlib.sha256(key).hexdigest()[:8]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def stream_signed_jsonl(
|
|
42
|
+
lines: AsyncIterator[str],
|
|
43
|
+
) -> AsyncIterator[str]:
|
|
44
|
+
"""
|
|
45
|
+
Wrap JSONL stream with hash chain and trailing signature block.
|
|
46
|
+
Each line gets _prev_hash, _hash. Final line: {"_signature": "...", "_key_id": "...", "_algorithm": "..."}.
|
|
47
|
+
"""
|
|
48
|
+
key = _get_signing_key()
|
|
49
|
+
if not key:
|
|
50
|
+
raise ValueError("AUDIT_SIGNING_KEY required for signed exports")
|
|
51
|
+
|
|
52
|
+
prev_hash = hashlib.sha256(CHAIN_SEED).hexdigest()
|
|
53
|
+
chain_payloads: list[str] = []
|
|
54
|
+
|
|
55
|
+
async for line in lines:
|
|
56
|
+
line = line.rstrip("\n")
|
|
57
|
+
if not line:
|
|
58
|
+
continue
|
|
59
|
+
try:
|
|
60
|
+
obj = json.loads(line)
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
obj = {"_raw": line}
|
|
63
|
+
canonical = json.dumps(obj, sort_keys=True, default=str)
|
|
64
|
+
current_hash = _hash_line(canonical, prev_hash)
|
|
65
|
+
chain_payloads.append(f"{prev_hash}\n{canonical}")
|
|
66
|
+
|
|
67
|
+
obj["_prev_hash"] = prev_hash
|
|
68
|
+
obj["_hash"] = current_hash
|
|
69
|
+
yield json.dumps(obj, default=str) + "\n"
|
|
70
|
+
prev_hash = current_hash
|
|
71
|
+
|
|
72
|
+
# Trailing signature block
|
|
73
|
+
full_chain = "\n".join(chain_payloads) if chain_payloads else prev_hash
|
|
74
|
+
signature = _sign_payload(full_chain, key)
|
|
75
|
+
sig_block = {
|
|
76
|
+
"_signature": signature,
|
|
77
|
+
"_key_id": get_audit_signing_key_id(),
|
|
78
|
+
"_algorithm": SIGNATURE_ALGORITHM,
|
|
79
|
+
"_chain_tail": prev_hash,
|
|
80
|
+
}
|
|
81
|
+
yield json.dumps(sig_block) + "\n"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def verify_signed_export(lines: list[str]) -> tuple[bool, str | None]:
|
|
85
|
+
"""
|
|
86
|
+
Verify a signed export. Returns (valid, error_message).
|
|
87
|
+
"""
|
|
88
|
+
if not lines:
|
|
89
|
+
return False, "Empty export"
|
|
90
|
+
key = _get_signing_key()
|
|
91
|
+
if not key:
|
|
92
|
+
return False, "No signing key configured"
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
sig_block = json.loads(lines[-1])
|
|
96
|
+
if not sig_block.get("_signature"):
|
|
97
|
+
return False, "Missing signature block"
|
|
98
|
+
expected_sig = sig_block["_signature"]
|
|
99
|
+
chain_tail = sig_block.get("_chain_tail", "")
|
|
100
|
+
|
|
101
|
+
prev_hash = hashlib.sha256(CHAIN_SEED).hexdigest()
|
|
102
|
+
chain_parts: list[str] = []
|
|
103
|
+
for i, line in enumerate(lines[:-1]):
|
|
104
|
+
line = line.rstrip("\n")
|
|
105
|
+
obj = json.loads(line)
|
|
106
|
+
prev = obj.pop("_prev_hash", "")
|
|
107
|
+
curr = obj.pop("_hash", "")
|
|
108
|
+
if prev != prev_hash:
|
|
109
|
+
return False, f"Chain broken at line {i+1}: prev_hash mismatch"
|
|
110
|
+
canonical = json.dumps(obj, sort_keys=True, default=str)
|
|
111
|
+
current_hash = _hash_line(canonical, prev_hash)
|
|
112
|
+
if curr != current_hash:
|
|
113
|
+
return False, f"Chain broken at line {i+1}: hash mismatch"
|
|
114
|
+
chain_parts.append(f"{prev_hash}\n{canonical}")
|
|
115
|
+
prev_hash = current_hash
|
|
116
|
+
|
|
117
|
+
if prev_hash != chain_tail:
|
|
118
|
+
return False, "Chain tail mismatch"
|
|
119
|
+
|
|
120
|
+
full_chain = "\n".join(chain_parts)
|
|
121
|
+
actual_sig = _sign_payload(full_chain, key)
|
|
122
|
+
if not hmac.compare_digest(actual_sig, expected_sig):
|
|
123
|
+
return False, "Signature invalid"
|
|
124
|
+
|
|
125
|
+
return True, None
|
|
126
|
+
except (json.JSONDecodeError, KeyError) as e:
|
|
127
|
+
return False, str(e)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Budget hierarchy: org → project → deployment. Multi-level resource governance."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import func, select
|
|
8
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
9
|
+
|
|
10
|
+
from api.models import Deployment, EnforcementEvent, Job, UsageRecord
|
|
11
|
+
from api.usage_service import get_limits_for_user, get_current_period_usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_budget_config(cfg: dict | None) -> dict:
|
|
15
|
+
if not cfg:
|
|
16
|
+
return {}
|
|
17
|
+
budget = cfg.get("budget_policies") or {}
|
|
18
|
+
pl = cfg.get("pipeline_lock") or {}
|
|
19
|
+
pl_budget = (pl.get("policies") or {}).get("budget") or {}
|
|
20
|
+
return {**pl_budget, **budget}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def get_budget_hierarchy(db: AsyncSession, user_id: str) -> dict[str, Any]:
|
|
24
|
+
"""
|
|
25
|
+
Budget hierarchy for user. Structure: account (org-level) → deployments.
|
|
26
|
+
Returns org summary, tree nodes, and recent budget events.
|
|
27
|
+
"""
|
|
28
|
+
start_of_month = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
|
29
|
+
cutoff_7d = datetime.now(timezone.utc) - timedelta(days=7)
|
|
30
|
+
|
|
31
|
+
# User-level limits and usage
|
|
32
|
+
token_limit, cpu_limit, gpu_limit = await get_limits_for_user(db, user_id)
|
|
33
|
+
tokens_used, cpu_used, gpu_used = await get_current_period_usage(db, user_id)
|
|
34
|
+
|
|
35
|
+
compute_pct = (cpu_used / cpu_limit * 100) if cpu_limit > 0 else 0
|
|
36
|
+
token_pct = (tokens_used / token_limit * 100) if token_limit > 0 else 0
|
|
37
|
+
gpu_pct = (gpu_used / gpu_limit * 100) if gpu_limit > 0 else 0
|
|
38
|
+
|
|
39
|
+
# Deployments with budget config and usage
|
|
40
|
+
dep_result = await db.execute(
|
|
41
|
+
select(Deployment).where(Deployment.user_id == user_id).order_by(Deployment.updated_at.desc())
|
|
42
|
+
)
|
|
43
|
+
deployments = dep_result.scalars().all()
|
|
44
|
+
|
|
45
|
+
# Per-deployment usage (this month)
|
|
46
|
+
dep_usages: dict[str, dict[str, float]] = {}
|
|
47
|
+
if deployments:
|
|
48
|
+
dep_ids = [d.id for d in deployments]
|
|
49
|
+
usage_result = await db.execute(
|
|
50
|
+
select(
|
|
51
|
+
Job.deployment_id,
|
|
52
|
+
func.coalesce(func.sum(UsageRecord.tokens_used), 0).label("tokens"),
|
|
53
|
+
func.coalesce(func.sum(UsageRecord.compute_seconds), 0).label("cpu"),
|
|
54
|
+
func.coalesce(func.sum(UsageRecord.gpu_seconds), 0).label("gpu"),
|
|
55
|
+
)
|
|
56
|
+
.select_from(Job)
|
|
57
|
+
.outerjoin(UsageRecord, Job.id == UsageRecord.job_id)
|
|
58
|
+
.where(
|
|
59
|
+
Job.user_id == user_id,
|
|
60
|
+
Job.deployment_id.in_(dep_ids),
|
|
61
|
+
Job.created_at >= start_of_month,
|
|
62
|
+
)
|
|
63
|
+
.group_by(Job.deployment_id)
|
|
64
|
+
)
|
|
65
|
+
for row in usage_result.all():
|
|
66
|
+
dep_usages[row.deployment_id] = {
|
|
67
|
+
"tokens": int(row.tokens),
|
|
68
|
+
"cpu": float(row.cpu),
|
|
69
|
+
"gpu": float(row.gpu),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Build tree nodes
|
|
73
|
+
nodes: list[dict[str, Any]] = []
|
|
74
|
+
|
|
75
|
+
# Account (top-level)
|
|
76
|
+
nodes.append({
|
|
77
|
+
"id": "account",
|
|
78
|
+
"name": "Account",
|
|
79
|
+
"level": "org",
|
|
80
|
+
"parent_id": None,
|
|
81
|
+
"limit_tokens": token_limit if token_limit > 0 else None,
|
|
82
|
+
"limit_compute": cpu_limit if cpu_limit > 0 else None,
|
|
83
|
+
"limit_gpu": gpu_limit if gpu_limit > 0 else None,
|
|
84
|
+
"used_tokens": tokens_used,
|
|
85
|
+
"used_compute": cpu_used,
|
|
86
|
+
"used_gpu": gpu_used,
|
|
87
|
+
"usage_pct": max(compute_pct, token_pct, gpu_pct),
|
|
88
|
+
"inherited": False,
|
|
89
|
+
"override": False,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
# Deployments as children
|
|
93
|
+
for d in deployments:
|
|
94
|
+
budget = _get_budget_config(d.config)
|
|
95
|
+
req_limit = int(budget.get("request_rate_per_minute") or 0)
|
|
96
|
+
compute_limit = float(budget.get("max_compute_per_request_seconds") or 0)
|
|
97
|
+
retry_ceiling = float(budget.get("retry_cost_multiplier_ceiling") or 0)
|
|
98
|
+
|
|
99
|
+
usage = dep_usages.get(d.id, {})
|
|
100
|
+
dep_cpu = usage.get("cpu", 0)
|
|
101
|
+
dep_tokens = usage.get("tokens", 0)
|
|
102
|
+
|
|
103
|
+
# Usage % for deployment: share of org compute budget
|
|
104
|
+
usage_pct = (dep_cpu / cpu_limit * 100) if cpu_limit > 0 and dep_cpu > 0 else 0.0
|
|
105
|
+
|
|
106
|
+
nodes.append({
|
|
107
|
+
"id": d.id,
|
|
108
|
+
"name": d.model_id,
|
|
109
|
+
"level": "deployment",
|
|
110
|
+
"parent_id": "account",
|
|
111
|
+
"deployment_id": d.id,
|
|
112
|
+
"limit_request_rate": req_limit if req_limit > 0 else None,
|
|
113
|
+
"limit_compute_per_request": compute_limit if compute_limit > 0 else None,
|
|
114
|
+
"retry_ceiling": retry_ceiling if retry_ceiling > 0 else None,
|
|
115
|
+
"used_tokens": dep_tokens,
|
|
116
|
+
"used_compute": dep_cpu,
|
|
117
|
+
"usage_pct": usage_pct,
|
|
118
|
+
"inherited": req_limit == 0 and compute_limit == 0,
|
|
119
|
+
"override": bool(budget),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
# Recent budget events (enforcement events with budget_json)
|
|
123
|
+
dep_ids = [d.id for d in deployments]
|
|
124
|
+
events: list[dict[str, Any]] = []
|
|
125
|
+
if dep_ids:
|
|
126
|
+
events_result = await db.execute(
|
|
127
|
+
select(
|
|
128
|
+
EnforcementEvent.event_id,
|
|
129
|
+
EnforcementEvent.deployment_id,
|
|
130
|
+
EnforcementEvent.action_taken,
|
|
131
|
+
EnforcementEvent.budget_json,
|
|
132
|
+
EnforcementEvent.created_at,
|
|
133
|
+
)
|
|
134
|
+
.where(
|
|
135
|
+
EnforcementEvent.deployment_id.in_(dep_ids),
|
|
136
|
+
EnforcementEvent.created_at >= cutoff_7d,
|
|
137
|
+
EnforcementEvent.budget_json.isnot(None),
|
|
138
|
+
)
|
|
139
|
+
.order_by(EnforcementEvent.created_at.desc())
|
|
140
|
+
.limit(50)
|
|
141
|
+
)
|
|
142
|
+
for row in events_result.all():
|
|
143
|
+
events.append({
|
|
144
|
+
"event_id": row.event_id,
|
|
145
|
+
"deployment_id": row.deployment_id,
|
|
146
|
+
"action": row.action_taken,
|
|
147
|
+
"budget_json": row.budget_json,
|
|
148
|
+
"created_at": row.created_at.isoformat() if row.created_at else "",
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
"org_summary": {
|
|
153
|
+
"total_tokens_limit": token_limit if token_limit > 0 else None,
|
|
154
|
+
"total_compute_limit": cpu_limit if cpu_limit > 0 else None,
|
|
155
|
+
"total_gpu_limit": gpu_limit if gpu_limit > 0 else None,
|
|
156
|
+
"used_tokens": tokens_used,
|
|
157
|
+
"used_compute": cpu_used,
|
|
158
|
+
"used_gpu": gpu_used,
|
|
159
|
+
"compute_pct": round(compute_pct, 1),
|
|
160
|
+
"token_pct": round(token_pct, 1),
|
|
161
|
+
"gpu_pct": round(gpu_pct, 1),
|
|
162
|
+
},
|
|
163
|
+
"nodes": nodes,
|
|
164
|
+
"events": events,
|
|
165
|
+
}
|
|
@@ -27,7 +27,7 @@ class Settings(BaseSettings):
|
|
|
27
27
|
# Otherwise use SMTP with smtp_user/smtp_password.
|
|
28
28
|
email_enabled: bool = True
|
|
29
29
|
sweego_api_key: str = "" # When set, use Sweego HTTP API instead of SMTP
|
|
30
|
-
sweego_auth_type: str = "
|
|
30
|
+
sweego_auth_type: str = "api_key" # "api_key" (Api-Key, Sweego default), "api_token" (Api-Token), or "bearer" (Authorization: Bearer)
|
|
31
31
|
smtp_host: str = "smtp.sweego.io"
|
|
32
32
|
smtp_port: int = 587
|
|
33
33
|
smtp_user: str = ""
|
|
@@ -45,6 +45,38 @@ class Settings(BaseSettings):
|
|
|
45
45
|
# CORS (comma-separated extra origins, e.g. for Vercel: https://quantlix.vercel.app)
|
|
46
46
|
cors_origins: str = ""
|
|
47
47
|
|
|
48
|
+
# Local dev: no orchestrator/K8s — mark deployments ready immediately
|
|
49
|
+
mock_k8s: bool = False
|
|
50
|
+
|
|
51
|
+
# Logging (DEBUG, INFO, WARNING, ERROR)
|
|
52
|
+
log_level: str = "INFO"
|
|
53
|
+
|
|
54
|
+
# Guardrails
|
|
55
|
+
guardrail_timeout_seconds: float = 5.0
|
|
56
|
+
guardrail_fail_open: bool = True # Allow on error; False = block on error
|
|
57
|
+
guardrail_block_max_per_window: int = 5
|
|
58
|
+
guardrail_block_window_seconds: int = 300
|
|
59
|
+
|
|
60
|
+
# Enforcement event integrations (never block request path)
|
|
61
|
+
otel_enabled: bool = False
|
|
62
|
+
otel_endpoint: str = "http://localhost:4318/v1/traces" # OTLP HTTP
|
|
63
|
+
enforcement_webhook_url: str = "" # Global webhook for blocked/warned events
|
|
64
|
+
integration_timeout_seconds: float = 3.0 # Webhook/OTel timeout; fire-and-forget
|
|
65
|
+
|
|
66
|
+
# Audit signing (signed exports)
|
|
67
|
+
audit_signing_key: str = "" # HMAC key for signed audit exports; rotate via env
|
|
68
|
+
|
|
69
|
+
# Retention defaults (for tenants without org config; 0 = unlimited)
|
|
70
|
+
retention_days_jobs: int = 90
|
|
71
|
+
retention_days_enforcement_events: int = 90
|
|
72
|
+
export_bucket: str = "exports" # S3-compatible bucket for scheduled exports
|
|
73
|
+
|
|
74
|
+
# Feature flags (optional security)
|
|
75
|
+
feature_request_signing: bool = False # Require X-Signature on /run when enabled
|
|
76
|
+
feature_ip_allowlist: bool = False # Restrict /run to allowed IPs when enabled
|
|
77
|
+
request_signing_secret: str = "" # Shared secret for X-Signature (HMAC-SHA256)
|
|
78
|
+
allowed_ips: str = "" # Comma-separated IPs/CIDRs (e.g. 10.0.0.0/8, 192.168.1.1)
|
|
79
|
+
|
|
48
80
|
# Stripe
|
|
49
81
|
stripe_secret_key: str = ""
|
|
50
82
|
stripe_webhook_secret: str = ""
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Cost estimation for inference jobs. Used for Cost Amplification Score in Decision Trace."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
# Rates in EUR (configurable via env in future). Pro GPU overage: €0.50/hr
|
|
5
|
+
TOKEN_COST_PER_1M = 10.0 # €10 per 1M tokens (typical LLM)
|
|
6
|
+
CPU_COST_PER_HOUR = 0.36 # ~€0.36/hr cloud CPU
|
|
7
|
+
GPU_COST_PER_HOUR = 0.50 # €0.50/hr Pro overage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def estimate_cost_eur(tokens: int, compute_s: float, gpu_s: float) -> float:
|
|
11
|
+
"""Estimate cost in EUR from usage. Returns 0 if all zero."""
|
|
12
|
+
if tokens == 0 and compute_s == 0 and gpu_s == 0:
|
|
13
|
+
return 0.0
|
|
14
|
+
token_cost = (tokens / 1_000_000) * TOKEN_COST_PER_1M
|
|
15
|
+
cpu_cost = (compute_s / 3600) * CPU_COST_PER_HOUR
|
|
16
|
+
gpu_cost = (gpu_s / 3600) * GPU_COST_PER_HOUR
|
|
17
|
+
return round(token_cost + cpu_cost + gpu_cost, 4)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_cost_amplification(
|
|
21
|
+
*,
|
|
22
|
+
tokens: int,
|
|
23
|
+
compute_s: float,
|
|
24
|
+
gpu_s: float,
|
|
25
|
+
retry_count: int,
|
|
26
|
+
fallback_triggered: bool,
|
|
27
|
+
) -> dict:
|
|
28
|
+
"""
|
|
29
|
+
Compute Cost Amplification Score breakdown.
|
|
30
|
+
|
|
31
|
+
Total inference runs = 1 (base) + retry_count + (1 if fallback else 0).
|
|
32
|
+
We charge for the final successful run only; failed attempts still consume compute.
|
|
33
|
+
Cost per run = total_cost / total_runs. Base = 1 run, retry = retry_count runs, fallback = 1 run.
|
|
34
|
+
"""
|
|
35
|
+
total_cost_eur = estimate_cost_eur(tokens, compute_s, gpu_s)
|
|
36
|
+
total_runs = 1 + retry_count + (1 if fallback_triggered else 0)
|
|
37
|
+
if total_runs <= 0:
|
|
38
|
+
total_runs = 1
|
|
39
|
+
cost_per_run = total_cost_eur / total_runs
|
|
40
|
+
|
|
41
|
+
base_cost = round(cost_per_run, 4)
|
|
42
|
+
retry_cost = round(retry_count * cost_per_run, 4)
|
|
43
|
+
fallback_cost = round((1 if fallback_triggered else 0) * cost_per_run, 4)
|
|
44
|
+
total_cost = round(base_cost + retry_cost + fallback_cost, 4)
|
|
45
|
+
|
|
46
|
+
amplification = round(total_runs, 2) if total_runs > 1 else 1.0
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
"base_cost_eur": base_cost,
|
|
50
|
+
"retry_cost_eur": retry_cost,
|
|
51
|
+
"fallback_cost_eur": fallback_cost,
|
|
52
|
+
"total_cost_eur": total_cost,
|
|
53
|
+
"amplification_factor": amplification,
|
|
54
|
+
}
|
|
@@ -32,10 +32,10 @@ async def _send_via_sweego_api(to_email: str, subject: str, body: str) -> None:
|
|
|
32
32
|
}
|
|
33
33
|
if settings.sweego_auth_type == "bearer":
|
|
34
34
|
auth_header = ("Authorization", f"Bearer {settings.sweego_api_key}")
|
|
35
|
-
elif settings.sweego_auth_type == "
|
|
36
|
-
auth_header = ("Api-
|
|
35
|
+
elif settings.sweego_auth_type == "api_token":
|
|
36
|
+
auth_header = ("Api-Token", settings.sweego_api_key)
|
|
37
37
|
else:
|
|
38
|
-
auth_header = ("Api-
|
|
38
|
+
auth_header = ("Api-Key", settings.sweego_api_key) # default: api_key (Sweego docs)
|
|
39
39
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
40
40
|
resp = await client.post(
|
|
41
41
|
SWEEGO_API_URL,
|
|
@@ -53,7 +53,10 @@ async def _send_via_sweego_api(to_email: str, subject: str, body: str) -> None:
|
|
|
53
53
|
resp.status_code,
|
|
54
54
|
body_preview,
|
|
55
55
|
)
|
|
56
|
-
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"Sweego API error {resp.status_code}: {body_preview}"
|
|
58
|
+
) from None
|
|
59
|
+
return
|
|
57
60
|
|
|
58
61
|
|
|
59
62
|
async def _send_email(to_email: str, subject: str, body: str) -> None:
|
|
@@ -84,7 +87,7 @@ async def send_verification_email(to_email: str, token: str) -> bool:
|
|
|
84
87
|
logger.warning("Email not configured; skipping verification email to %s", to_email)
|
|
85
88
|
return False
|
|
86
89
|
|
|
87
|
-
verify_url = f"{settings.
|
|
90
|
+
verify_url = f"{settings.portal_base_url.rstrip('/')}/verify?token={token}"
|
|
88
91
|
subject = "Verify your Quantlix account"
|
|
89
92
|
body = f"""Hello,
|
|
90
93
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Enforcement event recording — audit spine. Internal, not exposed in UI.
|
|
2
|
+
Integrations (OTel, webhook) run fire-and-forget with timeouts; never block request path."""
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from api.config import settings
|
|
8
|
+
from api.db import async_session_maker
|
|
9
|
+
from api.enforcement_webhook import notify_webhook
|
|
10
|
+
from api.models import EnforcementEvent
|
|
11
|
+
from api.policy_engine.models import PolicyDecision
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
CONTRACT_VERSION = "1.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _action_taken(decision: PolicyDecision) -> str:
|
|
19
|
+
"""Map PolicyDecision to action_taken."""
|
|
20
|
+
if not decision.allowed:
|
|
21
|
+
return "blocked"
|
|
22
|
+
if decision.severity == "warn":
|
|
23
|
+
return "warned"
|
|
24
|
+
return "allowed"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_webhook_url(deployment_config: dict[str, Any] | None) -> str:
|
|
28
|
+
"""Resolve webhook URL: per-deployment overrides global."""
|
|
29
|
+
if deployment_config:
|
|
30
|
+
integrations = deployment_config.get("integrations") or {}
|
|
31
|
+
url = integrations.get("webhook_url") or ""
|
|
32
|
+
if url:
|
|
33
|
+
return url
|
|
34
|
+
return settings.enforcement_webhook_url or ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def record_enforcement_event(
|
|
38
|
+
request_id: str,
|
|
39
|
+
deployment_id: str,
|
|
40
|
+
decision: PolicyDecision,
|
|
41
|
+
deployment_config: dict[str, Any] | None = None,
|
|
42
|
+
trace_context: dict[str, Any] | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Store every enforcement decision in enforcement_events.
|
|
46
|
+
Uses own session so events are persisted even when main request raises (blocked).
|
|
47
|
+
Fire-and-forget: log on error but don't fail the request.
|
|
48
|
+
Triggers OTel export and webhook when configured.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
budget_json = None
|
|
52
|
+
if decision.budget_status:
|
|
53
|
+
budget_json = decision.budget_status
|
|
54
|
+
elif decision.error_code and decision.error_code.startswith("budget_"):
|
|
55
|
+
budget_json = {"error_code": decision.error_code}
|
|
56
|
+
|
|
57
|
+
action_taken = _action_taken(decision)
|
|
58
|
+
violations = decision.violations if decision.violations else None
|
|
59
|
+
|
|
60
|
+
event = EnforcementEvent(
|
|
61
|
+
request_id=request_id,
|
|
62
|
+
deployment_id=deployment_id,
|
|
63
|
+
contract_version=CONTRACT_VERSION,
|
|
64
|
+
policy_version=decision.policy_version,
|
|
65
|
+
violations_json=violations,
|
|
66
|
+
action_taken=action_taken,
|
|
67
|
+
budget_json=budget_json,
|
|
68
|
+
trace_context=trace_context,
|
|
69
|
+
)
|
|
70
|
+
async with async_session_maker() as session:
|
|
71
|
+
session.add(event)
|
|
72
|
+
await session.flush()
|
|
73
|
+
event_id = event.event_id
|
|
74
|
+
await session.commit()
|
|
75
|
+
|
|
76
|
+
# OpenTelemetry trace export (fire-and-forget, never blocks)
|
|
77
|
+
def _do_otel():
|
|
78
|
+
from api.enforcement_otel import export_enforcement_span
|
|
79
|
+
export_enforcement_span(
|
|
80
|
+
request_id=request_id,
|
|
81
|
+
deployment_id=deployment_id,
|
|
82
|
+
event_id=event_id,
|
|
83
|
+
action_taken=action_taken,
|
|
84
|
+
contract_version=CONTRACT_VERSION,
|
|
85
|
+
policy_version=decision.policy_version,
|
|
86
|
+
violations=violations,
|
|
87
|
+
budget_json=budget_json,
|
|
88
|
+
trace_context=trace_context,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
async def _otel_with_timeout():
|
|
92
|
+
try:
|
|
93
|
+
await asyncio.wait_for(
|
|
94
|
+
asyncio.to_thread(_do_otel),
|
|
95
|
+
timeout=settings.integration_timeout_seconds,
|
|
96
|
+
)
|
|
97
|
+
except asyncio.TimeoutError:
|
|
98
|
+
logger.warning("OTel export timed out")
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.warning("OTel export failed: %s", e)
|
|
101
|
+
|
|
102
|
+
asyncio.create_task(_otel_with_timeout())
|
|
103
|
+
|
|
104
|
+
# Webhook for violations (blocked/warned)
|
|
105
|
+
webhook_url = _resolve_webhook_url(deployment_config)
|
|
106
|
+
notify_webhook(
|
|
107
|
+
webhook_url=webhook_url,
|
|
108
|
+
request_id=request_id,
|
|
109
|
+
deployment_id=deployment_id,
|
|
110
|
+
event_id=event_id,
|
|
111
|
+
action_taken=action_taken,
|
|
112
|
+
contract_version=CONTRACT_VERSION,
|
|
113
|
+
policy_version=decision.policy_version,
|
|
114
|
+
violations=violations,
|
|
115
|
+
budget_json=budget_json,
|
|
116
|
+
)
|
|
117
|
+
except Exception as e:
|
|
118
|
+
logger.warning("Failed to record enforcement event: %s", e)
|