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.
Files changed (108) hide show
  1. {quantlix-0.1.4 → quantlix-0.2.0}/PKG-INFO +15 -9
  2. {quantlix-0.1.4 → quantlix-0.2.0}/README.md +8 -8
  3. quantlix-0.2.0/api/audit_signing.py +127 -0
  4. quantlix-0.2.0/api/budget_hierarchy.py +165 -0
  5. {quantlix-0.1.4 → quantlix-0.2.0}/api/config.py +33 -1
  6. quantlix-0.2.0/api/cost_estimation.py +54 -0
  7. {quantlix-0.1.4 → quantlix-0.2.0}/api/email.py +8 -5
  8. quantlix-0.2.0/api/enforcement_events.py +118 -0
  9. quantlix-0.2.0/api/enforcement_otel.py +76 -0
  10. quantlix-0.2.0/api/enforcement_packs.py +221 -0
  11. quantlix-0.2.0/api/enforcement_webhook.py +67 -0
  12. quantlix-0.2.0/api/entitlement_enforcement.py +248 -0
  13. quantlix-0.2.0/api/entitlements.py +168 -0
  14. quantlix-0.2.0/api/fleet_overview.py +274 -0
  15. quantlix-0.2.0/api/guardrails/__init__.py +5 -0
  16. quantlix-0.2.0/api/guardrails/base.py +29 -0
  17. quantlix-0.2.0/api/guardrails/block_rate.py +80 -0
  18. quantlix-0.2.0/api/guardrails/config.py +16 -0
  19. quantlix-0.2.0/api/guardrails/metrics.py +21 -0
  20. quantlix-0.2.0/api/guardrails/rules.py +125 -0
  21. quantlix-0.2.0/api/guardrails/runner.py +122 -0
  22. quantlix-0.2.0/api/logging_config.py +20 -0
  23. quantlix-0.2.0/api/main.py +306 -0
  24. quantlix-0.2.0/api/metrics.py +77 -0
  25. quantlix-0.2.0/api/models.py +294 -0
  26. quantlix-0.2.0/api/pipeline_lock.py +485 -0
  27. quantlix-0.2.0/api/policies/__init__.py +4 -0
  28. quantlix-0.2.0/api/policies/policy.py +39 -0
  29. quantlix-0.2.0/api/policy_engine/__init__.py +6 -0
  30. quantlix-0.2.0/api/policy_engine/budget.py +165 -0
  31. quantlix-0.2.0/api/policy_engine/engine.py +276 -0
  32. quantlix-0.2.0/api/policy_engine/models.py +54 -0
  33. quantlix-0.2.0/api/policy_resolver.py +63 -0
  34. {quantlix-0.1.4 → quantlix-0.2.0}/api/rate_limit.py +18 -0
  35. quantlix-0.2.0/api/redact.py +48 -0
  36. quantlix-0.2.0/api/retention_exports.py +283 -0
  37. quantlix-0.2.0/api/routes/audit.py +40 -0
  38. {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/auth.py +6 -3
  39. quantlix-0.2.0/api/routes/contract.py +35 -0
  40. quantlix-0.2.0/api/routes/demo.py +45 -0
  41. quantlix-0.2.0/api/routes/deploy.py +161 -0
  42. quantlix-0.2.0/api/routes/deployments.py +1020 -0
  43. quantlix-0.2.0/api/routes/enforcement_events.py +331 -0
  44. quantlix-0.2.0/api/routes/enforcement_packs.py +24 -0
  45. quantlix-0.2.0/api/routes/entitlements.py +61 -0
  46. quantlix-0.2.0/api/routes/jobs.py +169 -0
  47. quantlix-0.2.0/api/routes/orgs.py +462 -0
  48. quantlix-0.2.0/api/routes/run.py +339 -0
  49. {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/status.py +6 -0
  50. quantlix-0.2.0/api/routes/usage.py +340 -0
  51. quantlix-0.2.0/api/schemas.py +667 -0
  52. quantlix-0.2.0/api/scoring/__init__.py +4 -0
  53. quantlix-0.2.0/api/scoring/scorer.py +24 -0
  54. quantlix-0.2.0/api/security_middleware.py +103 -0
  55. quantlix-0.2.0/api/trace_context.py +103 -0
  56. {quantlix-0.1.4 → quantlix-0.2.0}/api/usage_service.py +13 -0
  57. quantlix-0.2.0/api/value_dashboard.py +412 -0
  58. quantlix-0.2.0/api/violations/__init__.py +6 -0
  59. quantlix-0.2.0/api/violations/codes.py +53 -0
  60. quantlix-0.2.0/api/violations/factory.py +71 -0
  61. {quantlix-0.1.4 → quantlix-0.2.0}/cli/main.py +393 -12
  62. {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/config.py +3 -0
  63. {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/k8s.py +4 -1
  64. quantlix-0.2.0/orchestrator/worker.py +360 -0
  65. {quantlix-0.1.4 → quantlix-0.2.0}/pyproject.toml +20 -1
  66. {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/PKG-INFO +15 -9
  67. quantlix-0.2.0/quantlix.egg-info/SOURCES.txt +95 -0
  68. {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/requires.txt +7 -0
  69. {quantlix-0.1.4 → quantlix-0.2.0}/sdk/quantlix/client.py +130 -0
  70. quantlix-0.2.0/tests/test_audit_signing.py +63 -0
  71. quantlix-0.2.0/tests/test_cost_amplification.py +75 -0
  72. quantlix-0.2.0/tests/test_failure_modes.py +101 -0
  73. quantlix-0.2.0/tests/test_fallback.py +337 -0
  74. quantlix-0.2.0/tests/test_multi_tenancy.py +335 -0
  75. quantlix-0.2.0/tests/test_pipeline_lock_ship_checklist.py +301 -0
  76. quantlix-0.2.0/tests/test_plan_enforcement.py +236 -0
  77. quantlix-0.2.0/tests/test_violations.py +51 -0
  78. quantlix-0.1.4/api/main.py +0 -152
  79. quantlix-0.1.4/api/metrics.py +0 -35
  80. quantlix-0.1.4/api/models.py +0 -162
  81. quantlix-0.1.4/api/routes/deploy.py +0 -78
  82. quantlix-0.1.4/api/routes/deployments.py +0 -129
  83. quantlix-0.1.4/api/routes/jobs.py +0 -43
  84. quantlix-0.1.4/api/routes/run.py +0 -76
  85. quantlix-0.1.4/api/routes/usage.py +0 -143
  86. quantlix-0.1.4/api/schemas.py +0 -287
  87. quantlix-0.1.4/orchestrator/worker.py +0 -192
  88. quantlix-0.1.4/quantlix.egg-info/SOURCES.txt +0 -44
  89. {quantlix-0.1.4 → quantlix-0.2.0}/LICENSE +0 -0
  90. {quantlix-0.1.4 → quantlix-0.2.0}/api/__init__.py +0 -0
  91. {quantlix-0.1.4 → quantlix-0.2.0}/api/auth.py +0 -0
  92. {quantlix-0.1.4 → quantlix-0.2.0}/api/db.py +0 -0
  93. {quantlix-0.1.4 → quantlix-0.2.0}/api/disposable_domains.py +0 -0
  94. {quantlix-0.1.4 → quantlix-0.2.0}/api/queue.py +0 -0
  95. {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/__init__.py +0 -0
  96. {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/billing.py +0 -0
  97. {quantlix-0.1.4 → quantlix-0.2.0}/api/routes/health.py +0 -0
  98. {quantlix-0.1.4 → quantlix-0.2.0}/cli/__init__.py +0 -0
  99. {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/__init__.py +0 -0
  100. {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/inference_client.py +0 -0
  101. {quantlix-0.1.4 → quantlix-0.2.0}/orchestrator/main.py +0 -0
  102. {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/dependency_links.txt +0 -0
  103. {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/entry_points.txt +0 -0
  104. {quantlix-0.1.4 → quantlix-0.2.0}/quantlix.egg-info/top_level.txt +0 -0
  105. {quantlix-0.1.4 → quantlix-0.2.0}/sdk/__init__.py +0 -0
  106. {quantlix-0.1.4 → quantlix-0.2.0}/sdk/python/__init__.py +0 -0
  107. {quantlix-0.1.4 → quantlix-0.2.0}/sdk/quantlix/__init__.py +0 -0
  108. {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.1.4
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
- Deploy AI models in seconds. A simple inference platform with REST API, usage-based billing, and Kubernetes orchestration.
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
- - **REST API** — Deploy models, run inference, check status
47
- - **Usage-based billing** — Free, Starter (€9/mo), Pro (€19/mo) tiers with Stripe
48
- - **Queue & orchestration** — Redis queue, K8s job scheduling
49
- - **Customer portal** — Next.js dashboard, usage graphs, real-time logs
50
- - **CLI** — `quantlix deploy`, `quantlix run` for local dev
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 to activate (first run moves deployment from pending to ready)
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
- Deploy AI models in seconds. A simple inference platform with REST API, usage-based billing, and Kubernetes orchestration.
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
- - **REST API** — Deploy models, run inference, check status
8
- - **Usage-based billing** — Free, Starter (€9/mo), Pro (€19/mo) tiers with Stripe
9
- - **Queue & orchestration** — Redis queue, K8s job scheduling
10
- - **Customer portal** — Next.js dashboard, usage graphs, real-time logs
11
- - **CLI** — `quantlix deploy`, `quantlix run` for local dev
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 to activate (first run moves deployment from pending to ready)
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 = "api_token" # "api_token" (Api-Token), "api_key" (Api-Key), or "bearer" (Authorization: Bearer)
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 == "api_key":
36
- auth_header = ("Api-Key", settings.sweego_api_key)
35
+ elif settings.sweego_auth_type == "api_token":
36
+ auth_header = ("Api-Token", settings.sweego_api_key)
37
37
  else:
38
- auth_header = ("Api-Token", settings.sweego_api_key) # default: api_token
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
- resp.raise_for_status()
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.app_base_url.rstrip('/')}/auth/verify?token={token}"
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)