@bhooai/nexus-cli 0.1.0
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.
- package/PLAN.md +141 -0
- package/README.md +34 -0
- package/package.json +25 -0
- package/src/commands/cluster.ts +133 -0
- package/src/commands/dev.ts +133 -0
- package/src/commands/doctor.ts +199 -0
- package/src/commands/init.ts +960 -0
- package/src/commands/node.ts +101 -0
- package/src/commands/pysetup.ts +136 -0
- package/src/commands/sync.ts +116 -0
- package/src/commands/uninstall.ts +287 -0
- package/src/config-sync.ts +384 -0
- package/src/dotenv.ts +39 -0
- package/src/index.ts +94 -0
- package/src/supervisor.ts +384 -0
- package/src/util.ts +123 -0
- package/src/wizard.ts +149 -0
- package/templates/Dockerfile +60 -0
- package/templates/README.md +69 -0
- package/templates/apps/admin/index.html +12 -0
- package/templates/apps/admin/package.json +24 -0
- package/templates/apps/admin/postcss.config.js +6 -0
- package/templates/apps/admin/src/main.tsx +10 -0
- package/templates/apps/admin/src/vite-env.d.ts +18 -0
- package/templates/apps/admin/tailwind.config.js +9 -0
- package/templates/apps/admin/tsconfig.json +17 -0
- package/templates/apps/admin/vite.config.ts +64 -0
- package/templates/apps/ai-server/main.py +43 -0
- package/templates/apps/ai-server/providers/__init__.py +3 -0
- package/templates/apps/ai-server/providers/base.py +111 -0
- package/templates/apps/ai-server/requirements.txt +3 -0
- package/templates/apps/ai-server/routers/__init__.py +3 -0
- package/templates/apps/ai-server/routers/chat.py +47 -0
- package/templates/apps/ai-server/routers/embeddings.py +30 -0
- package/templates/apps/ai-server/routers/lint.py +167 -0
- package/templates/apps/ai-server/routers/models.py +23 -0
- package/templates/apps/ai-server/routers/preflight.py +169 -0
- package/templates/apps/ai-server/settings.py +48 -0
- package/templates/apps/backend/package.json +33 -0
- package/templates/apps/backend/src/main.ts +375 -0
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
- package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
- package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
- package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
- package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
- package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
- package/templates/apps/backend/tsconfig.json +14 -0
- package/templates/apps/frontend/index.html +12 -0
- package/templates/apps/frontend/package.json +19 -0
- package/templates/apps/frontend/src/main.tsx +64 -0
- package/templates/apps/frontend/vite.config.ts +63 -0
- package/templates/bin/nexus.js +35 -0
- package/templates/bin/serve-all.mjs +45 -0
- package/templates/dockerignore +15 -0
- package/templates/gitignore +12 -0
- package/templates/nexus.config.ts +69 -0
- package/templates/package.json +47 -0
- package/templates/tsconfig.json +17 -0
- package/templates/uploads/.gitkeep +0 -0
- package/tests/cli.test.ts +45 -0
- package/tests/config-sync.test.ts +201 -0
- package/tests/dotenv.test.ts +51 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +9 -0
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +13 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Env + config linter router.
|
|
2
|
+
|
|
3
|
+
Node sends the raw `.env` text (and the raw `nexus.runtime.json` text) and
|
|
4
|
+
Python computes a structured report. Python never echoes secret *values* — only
|
|
5
|
+
keys + issues. This keeps the lint logic in Python while Node owns auth + file
|
|
6
|
+
access. There is no `.env.example` anymore: secrets live directly in `.env`.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
router = APIRouter()
|
|
19
|
+
|
|
20
|
+
ALLOWED_LOG_LEVELS = {"silent", "fatal", "error", "warn", "info", "debug", "trace"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class LintEnvRequest(BaseModel):
|
|
24
|
+
envText: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LintConfigRequest(BaseModel):
|
|
28
|
+
content: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---- env parsing helpers ----
|
|
32
|
+
|
|
33
|
+
def _parse_env(text: str) -> tuple[list[dict[str, Any]], list[str]]:
|
|
34
|
+
"""Parse dotenv-ish text. Returns (exports, malformed_lines)."""
|
|
35
|
+
exports: list[dict[str, Any]] = []
|
|
36
|
+
malformed: list[str] = []
|
|
37
|
+
for lineno, raw in enumerate(text.splitlines(), 1):
|
|
38
|
+
line = raw.strip()
|
|
39
|
+
if not line or line.startswith("#"):
|
|
40
|
+
continue
|
|
41
|
+
if "=" not in line:
|
|
42
|
+
malformed.append(f"line {lineno}: no '=' found")
|
|
43
|
+
continue
|
|
44
|
+
key, _, value = line.partition("=")
|
|
45
|
+
key = key.strip()
|
|
46
|
+
value = value.strip()
|
|
47
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
48
|
+
value = value[1:-1]
|
|
49
|
+
exports.append({"key": key, "value": value})
|
|
50
|
+
return exports, malformed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_PLACEHOLDER_PATTERNS = [
|
|
54
|
+
r"^(change|changeme|change-me|your|example|exampl|replace|dummy|sample)",
|
|
55
|
+
r"^xxxx+$",
|
|
56
|
+
r"^xxxx+",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_placeholder(value: str) -> bool:
|
|
61
|
+
if len(value.strip()) < 4:
|
|
62
|
+
return True
|
|
63
|
+
lowered = value.strip().lower()
|
|
64
|
+
return any(re.search(p, lowered) for p in _PLACEHOLDER_PATTERNS)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- env checks ----
|
|
68
|
+
|
|
69
|
+
def _check_env(exports: list[dict[str, Any]], malformed: list[str]) -> list[dict[str, Any]]:
|
|
70
|
+
checks: list[dict[str, Any]] = []
|
|
71
|
+
seen: dict[str, int] = {}
|
|
72
|
+
for e in exports:
|
|
73
|
+
k = e["key"]
|
|
74
|
+
count = seen.get(k, 0) + 1
|
|
75
|
+
seen[k] = count
|
|
76
|
+
if count > 1:
|
|
77
|
+
checks.append({"key": k, "severity": "error", "kind": "duplicate", "message": "duplicate key"})
|
|
78
|
+
if e["value"] == "":
|
|
79
|
+
checks.append({"key": k, "severity": "warning", "kind": "empty", "message": "has an empty value"})
|
|
80
|
+
elif _is_placeholder(e["value"]):
|
|
81
|
+
checks.append({"key": k, "severity": "warning", "kind": "placeholder", "message": "value still looks like a placeholder"})
|
|
82
|
+
for line in malformed:
|
|
83
|
+
checks.append({"key": "(syntax)", "severity": "error", "kind": "malformed", "message": line})
|
|
84
|
+
env = {f["key"] for f in exports}
|
|
85
|
+
if "NEXUS_AUTH_JWT_SECRET" not in env:
|
|
86
|
+
checks.append({"key": "NEXUS_AUTH_JWT_SECRET", "severity": "warning", "kind": "missing", "message": "set this in .env — there is no default JWT secret"})
|
|
87
|
+
return checks
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ---- config checks ----
|
|
91
|
+
|
|
92
|
+
def _config_checks(data: dict[str, Any]) -> list[dict[str, Any]]:
|
|
93
|
+
checks: list[dict[str, Any]] = []
|
|
94
|
+
port = _get(data, "server.port")
|
|
95
|
+
if port is not None and not (isinstance(port, (int, float)) and 1 <= int(port) <= 65535):
|
|
96
|
+
checks.append({"key": "server.port", "severity": "error", "kind": "type", "message": "must be an integer port 1–65535"})
|
|
97
|
+
body = _get(data, "server.bodyLimit")
|
|
98
|
+
if body is not None and not isinstance(body, (int, float)):
|
|
99
|
+
checks.append({"key": "server.bodyLimit", "severity": "warning", "kind": "type", "message": "must be a number (bytes)"})
|
|
100
|
+
lvl = _get(data, "logging.level")
|
|
101
|
+
if lvl is not None and lvl not in ALLOWED_LOG_LEVELS:
|
|
102
|
+
checks.append({"key": "logging.level", "severity": "warning", "kind": "value", "message": f"unexpected level '{lvl}' (expected one of {sorted(ALLOWED_LOG_LEVELS)})"})
|
|
103
|
+
uri = _get(data, "db.uri")
|
|
104
|
+
if uri is not None and not (isinstance(uri, str) and uri.startswith(("mongodb://", "mongodb+srv://"))):
|
|
105
|
+
checks.append({"key": "db.uri", "severity": "error", "kind": "url", "message": "must be a mongodb:// or mongodb+srv:// URL"})
|
|
106
|
+
auto = _get(data, "db.autoIndex")
|
|
107
|
+
if auto is not None and not isinstance(auto, bool):
|
|
108
|
+
checks.append({"key": "db.autoIndex", "severity": "warning", "kind": "type", "message": "must be a boolean"})
|
|
109
|
+
intro = _get(data, "graphql.introspection")
|
|
110
|
+
if intro is not None and not isinstance(intro, bool):
|
|
111
|
+
checks.append({"key": "graphql.introspection", "severity": "warning", "kind": "type", "message": "must be a boolean"})
|
|
112
|
+
return checks
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _get(doc: dict[str, Any], path: str) -> Any:
|
|
116
|
+
node: Any = doc
|
|
117
|
+
for part in path.split("."):
|
|
118
|
+
if isinstance(node, dict) and part in node:
|
|
119
|
+
node = node[part]
|
|
120
|
+
else:
|
|
121
|
+
return None
|
|
122
|
+
return node
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---- report ----
|
|
126
|
+
|
|
127
|
+
def _report(checks: list[dict[str, Any]]) -> dict[str, Any]:
|
|
128
|
+
summary = {"error": 0, "warning": 0, "info": 0, "ok": 0}
|
|
129
|
+
for c in checks:
|
|
130
|
+
sev = c["severity"] if c["severity"] in summary else "info"
|
|
131
|
+
summary[sev] += 1
|
|
132
|
+
if not checks:
|
|
133
|
+
summary["ok"] = 1
|
|
134
|
+
order = {"error": 0, "warning": 1, "info": 2, "ok": 9}
|
|
135
|
+
checks.sort(key=lambda c: (order.get(c["severity"], 9), c["key"]))
|
|
136
|
+
return {"ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "summary": summary, "checks": checks}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---- routes ----
|
|
140
|
+
|
|
141
|
+
@router.post("/lint/env")
|
|
142
|
+
async def lint_env(req: LintEnvRequest):
|
|
143
|
+
exports, malformed = _parse_env(req.envText)
|
|
144
|
+
checks = _check_env(exports, malformed)
|
|
145
|
+
return _report(checks)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("/lint/config")
|
|
149
|
+
async def lint_config(req: LintConfigRequest):
|
|
150
|
+
try:
|
|
151
|
+
doc = json.loads(req.content)
|
|
152
|
+
except json.JSONDecodeError as e:
|
|
153
|
+
return {
|
|
154
|
+
"ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
155
|
+
"summary": {"error": 1, "warning": 0, "info": 0, "ok": 0},
|
|
156
|
+
"checks": [
|
|
157
|
+
{"key": "(syntax)", "severity": "error", "kind": "malformed",
|
|
158
|
+
"message": f"not valid JSON: {e} at line {e.lineno} col {e.colno}"}
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
if not isinstance(doc, dict):
|
|
162
|
+
return {
|
|
163
|
+
"ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
164
|
+
"summary": {"error": 1, "warning": 0, "info": 0, "ok": 0},
|
|
165
|
+
"checks": [{"key": "(root)", "severity": "error", "kind": "type", "message": "config root must be a JSON object"}],
|
|
166
|
+
}
|
|
167
|
+
return _report(_config_checks(doc))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Models router — lists available upstream models."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter, HTTPException
|
|
5
|
+
from fastapi.params import Query
|
|
6
|
+
|
|
7
|
+
from providers import get_provider
|
|
8
|
+
|
|
9
|
+
router = APIRouter()
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get("/models")
|
|
13
|
+
async def list_models(provider: str | None = Query(default=None)):
|
|
14
|
+
try:
|
|
15
|
+
p = get_provider(provider)
|
|
16
|
+
except ValueError as e:
|
|
17
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
18
|
+
try:
|
|
19
|
+
return await p.list_models()
|
|
20
|
+
except HTTPException:
|
|
21
|
+
raise
|
|
22
|
+
except Exception as e:
|
|
23
|
+
raise HTTPException(status_code=502, detail=f"upstream error: {e}")
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Preflight diagnostics router — connectivity + latency checks.
|
|
2
|
+
|
|
3
|
+
The Node backend composes the targets it wants checked (backend health, AI
|
|
4
|
+
server health, Mongo/Redis TCP reachability, service ports) and posts them here.
|
|
5
|
+
Python performs the checks concurrently and returns a normalized report. Only
|
|
6
|
+
stdlib (`socket`) + httpx are used, so this router adds no new dependencies.
|
|
7
|
+
|
|
8
|
+
Failed checks carry an `errorCategory` (refused/timeout/dns/ssl/http/other) plus
|
|
9
|
+
a short human-readable `error` message so the admin console never has to surface
|
|
10
|
+
raw transport exceptions like httpx's "All connection attempts failed...".
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import socket
|
|
16
|
+
import ssl
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
from urllib.parse import urlparse
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
from fastapi import APIRouter, HTTPException
|
|
23
|
+
from pydantic import BaseModel
|
|
24
|
+
|
|
25
|
+
router = APIRouter()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Target(BaseModel):
|
|
29
|
+
name: str
|
|
30
|
+
kind: Literal["http", "tcp"] = "tcp"
|
|
31
|
+
url: str | None = None
|
|
32
|
+
host: str | None = None
|
|
33
|
+
port: int | None = None
|
|
34
|
+
timeout: float | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PreflightRequest(BaseModel):
|
|
38
|
+
targets: list[Target] = []
|
|
39
|
+
timeout: float = 2.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _classify_error(e: Exception, host: str = "", port: int | None = None) -> tuple[str, str]:
|
|
43
|
+
"""Map an exception to a (category, friendly message) pair.
|
|
44
|
+
|
|
45
|
+
httpx wraps low-level socket errors, so when the top-level exception is the
|
|
46
|
+
generic "All connection attempts failed..." the underlying cause is unwrapped.
|
|
47
|
+
"""
|
|
48
|
+
endpoint = f"{host}:{port}" if host else "target"
|
|
49
|
+
|
|
50
|
+
if isinstance(e, (httpx.ConnectTimeout, httpx.ConnectError, asyncio.TimeoutError, socket.timeout)):
|
|
51
|
+
try:
|
|
52
|
+
cause = e.__cause__
|
|
53
|
+
except AttributeError:
|
|
54
|
+
cause = None
|
|
55
|
+
if isinstance(cause, socket.gaierror):
|
|
56
|
+
return "dns", f"host not found: {host or 'unknown'}"
|
|
57
|
+
if isinstance(cause, (ConnectionRefusedError, OSError)) and getattr(cause, "errno", None) == getattr(socket, "ECONNREFUSED", 111):
|
|
58
|
+
return "refused", f"connection refused on {endpoint} — is the service running?"
|
|
59
|
+
return "timeout", "timed out — the service may be filtering traffic or is down"
|
|
60
|
+
|
|
61
|
+
if isinstance(e, socket.gaierror):
|
|
62
|
+
return "dns", f"host not found: {host or 'unknown'}"
|
|
63
|
+
if isinstance(e, (ConnectionRefusedError, OSError)) and getattr(e, "errno", None) == getattr(socket, "ECONNREFUSED", 111):
|
|
64
|
+
return "refused", f"connection refused on {endpoint} — is the service running?"
|
|
65
|
+
if isinstance(e, ssl.SSLError):
|
|
66
|
+
return "ssl", "TLS handshake failed"
|
|
67
|
+
return "other", str(e) or type(e).__name__
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _check_http(target: Target, timeout: float) -> dict[str, Any]:
|
|
71
|
+
url = target.url or ""
|
|
72
|
+
start = time.perf_counter()
|
|
73
|
+
try:
|
|
74
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(timeout, connect=1.0, read=timeout, write=timeout, pool=timeout), follow_redirects=True) as client:
|
|
75
|
+
resp = await client.get(url)
|
|
76
|
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
77
|
+
ok = resp.status_code < 400
|
|
78
|
+
category: str | None = None
|
|
79
|
+
error: str | None = None
|
|
80
|
+
if not ok:
|
|
81
|
+
category = "http"
|
|
82
|
+
error = f"HTTP {resp.status_code}"
|
|
83
|
+
return {
|
|
84
|
+
"name": target.name,
|
|
85
|
+
"kind": "http",
|
|
86
|
+
"url": url,
|
|
87
|
+
"ok": ok,
|
|
88
|
+
"status": resp.status_code,
|
|
89
|
+
"latencyMs": latency_ms,
|
|
90
|
+
"errorCategory": category,
|
|
91
|
+
"error": error,
|
|
92
|
+
}
|
|
93
|
+
except Exception as e: # noqa: BLE001 - classify connectivity errors
|
|
94
|
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
95
|
+
category, message = _classify_error(e, urlparse(url).hostname or "", urlparse(url).port)
|
|
96
|
+
return {
|
|
97
|
+
"name": target.name,
|
|
98
|
+
"kind": "http",
|
|
99
|
+
"url": url,
|
|
100
|
+
"ok": False,
|
|
101
|
+
"status": None,
|
|
102
|
+
"latencyMs": latency_ms,
|
|
103
|
+
"errorCategory": category,
|
|
104
|
+
"error": message,
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _check_tcp(target: Target, timeout: float) -> dict[str, Any]:
|
|
109
|
+
host = target.host or ""
|
|
110
|
+
port = target.port or 0
|
|
111
|
+
start = time.perf_counter()
|
|
112
|
+
try:
|
|
113
|
+
reader, writer = await asyncio.wait_for(
|
|
114
|
+
asyncio.open_connection(host, port, limit=256),
|
|
115
|
+
timeout,
|
|
116
|
+
)
|
|
117
|
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
118
|
+
writer.close()
|
|
119
|
+
try:
|
|
120
|
+
await writer.wait_closed()
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
pass
|
|
123
|
+
return {
|
|
124
|
+
"name": target.name,
|
|
125
|
+
"kind": "tcp",
|
|
126
|
+
"host": host,
|
|
127
|
+
"port": port,
|
|
128
|
+
"ok": True,
|
|
129
|
+
"latencyMs": latency_ms,
|
|
130
|
+
"errorCategory": None,
|
|
131
|
+
"error": None,
|
|
132
|
+
}
|
|
133
|
+
except Exception as e: # noqa: BLE001 - classify connectivity errors
|
|
134
|
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
|
135
|
+
category, message = _classify_error(e, host, port)
|
|
136
|
+
return {
|
|
137
|
+
"name": target.name,
|
|
138
|
+
"kind": "tcp",
|
|
139
|
+
"host": host,
|
|
140
|
+
"port": port,
|
|
141
|
+
"ok": False,
|
|
142
|
+
"latencyMs": latency_ms,
|
|
143
|
+
"errorCategory": category,
|
|
144
|
+
"error": message,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("/preflight")
|
|
149
|
+
async def preflight(req: PreflightRequest):
|
|
150
|
+
if not req.targets:
|
|
151
|
+
raise HTTPException(status_code=400, detail="at least one target is required")
|
|
152
|
+
started = time.perf_counter()
|
|
153
|
+
results = await asyncio.gather(*(_check_http(t, t.timeout or req.timeout) if t.kind == "http" else _check_tcp(t, t.timeout or req.timeout) for t in req.targets))
|
|
154
|
+
duration_ms = round((time.perf_counter() - started) * 1000, 1)
|
|
155
|
+
|
|
156
|
+
failed = [c for c in results if not c["ok"]]
|
|
157
|
+
passed = [c for c in results if c["ok"]]
|
|
158
|
+
# failed then warnings (slow) then the rest, preserving stable order within groups.
|
|
159
|
+
slow = [c for c in passed if isinstance(c.get("latencyMs"), (int, float)) and c["latencyMs"] > 800]
|
|
160
|
+
ok = [c for c in passed if c not in slow]
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"ranAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
164
|
+
"durationMs": duration_ms,
|
|
165
|
+
"passed": len(passed),
|
|
166
|
+
"warnings": len(slow),
|
|
167
|
+
"failed": len(failed),
|
|
168
|
+
"checks": failed + slow + ok,
|
|
169
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Configuration for the AI server (env-driven)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _env(key: str, default: str) -> str:
|
|
9
|
+
return os.environ.get(key, default)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Settings:
|
|
14
|
+
# OpenAI-compatible upstreams. Both OpenAI and Ollama speak the same shape;
|
|
15
|
+
# only the base URL + auth header differ.
|
|
16
|
+
openai_base_url: str = field(default_factory=lambda: _env("AI_OPENAI_BASE_URL", "https://api.openai.com/v1"))
|
|
17
|
+
# Fall back to the generic OPENAI_API_KEY / OLLAMA_HOST names so project
|
|
18
|
+
# .env keys work with the AI server as well as the AI_* names.
|
|
19
|
+
openai_api_key: str = field(
|
|
20
|
+
default_factory=lambda: _env("AI_OPENAI_API_KEY", _env("OPENAI_API_KEY", ""))
|
|
21
|
+
)
|
|
22
|
+
ollama_base_url: str = field(
|
|
23
|
+
default_factory=lambda: _env(
|
|
24
|
+
"AI_OLLAMA_BASE_URL",
|
|
25
|
+
(_env("OLLAMA_HOST", "http://localhost:11434") + "/v1"),
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
ollama_api_key: str = field(default_factory=lambda: _env("AI_OLLAMA_API_KEY", ""))
|
|
29
|
+
|
|
30
|
+
default_provider: str = field(default_factory=lambda: _env("AI_DEFAULT_PROVIDER", "auto"))
|
|
31
|
+
request_timeout_s: float = field(default_factory=lambda: float(_env("AI_TIMEOUT_S", "120")))
|
|
32
|
+
server_port: int = field(default_factory=lambda: int(_env("AI_PORT", "8000")))
|
|
33
|
+
|
|
34
|
+
# CORS: the Node backend proxies, but allow dev tools + the admin to call directly.
|
|
35
|
+
cors_origins: list[str] = field(default_factory=lambda: _env("AI_CORS_ORIGINS", "*").split(","))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
settings = Settings()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve_provider(requested: str | None) -> str:
|
|
42
|
+
"""Resolve 'auto' to a concrete provider. auto → openai if a key is set, else ollama."""
|
|
43
|
+
provider = (requested or settings.default_provider or "auto").lower()
|
|
44
|
+
if provider == "auto":
|
|
45
|
+
provider = "openai" if settings.openai_api_key else "ollama"
|
|
46
|
+
if provider not in ("openai", "ollama"):
|
|
47
|
+
raise ValueError(f"unknown provider: {provider}")
|
|
48
|
+
return provider
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/app-backend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/main.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "tsx watch src/main.ts",
|
|
9
|
+
"start": "tsx src/main.ts",
|
|
10
|
+
"test": "vitest run"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@bhooai/nexus-core": "*",
|
|
14
|
+
"@bhooai/nexus-auth": "*",
|
|
15
|
+
"@bhooai/nexus-telemetry": "*",
|
|
16
|
+
"@bhooai/nexus-data": "*",
|
|
17
|
+
"@bhooai/nexus-realtime": "*",
|
|
18
|
+
"@bhooai/nexus-graphql": "*",
|
|
19
|
+
"@bhooai/nexus-cache": "*",
|
|
20
|
+
"@bhooai/nexus-email": "*",
|
|
21
|
+
"@bhooai/nexus-payments": "*",
|
|
22
|
+
"@bhooai/nexus-crypto": "*",
|
|
23
|
+
"@bhooai/nexus-ads": "*",
|
|
24
|
+
"@bhooai/nexus-plugins": "*",
|
|
25
|
+
"@bhooai/nexus-ai-client": "*"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^22.5.0",
|
|
29
|
+
"tsx": "^4.19.0",
|
|
30
|
+
"typescript": "^5.6.2",
|
|
31
|
+
"vitest": "^2.1.1"
|
|
32
|
+
}
|
|
33
|
+
}
|