@bhooai/nexus-cli 0.1.5 → 2.0.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/package.json +8 -12
- package/src/commands/add.ts +274 -0
- package/src/commands/build.ts +91 -0
- package/src/commands/db.ts +241 -0
- package/src/commands/dev.ts +149 -186
- package/src/commands/doctor.ts +102 -184
- package/src/commands/init.ts +204 -958
- package/src/commands/maintenance-commands.ts +40 -0
- package/src/commands/plugin.ts +233 -0
- package/src/commands/queue.ts +145 -0
- package/src/commands/test.ts +34 -0
- package/src/dispatcher.ts +98 -0
- package/src/generators.ts +102 -0
- package/src/index.ts +32 -90
- package/src/make/generators.ts +431 -0
- package/src/ports.ts +85 -0
- package/src/prompts.ts +68 -0
- package/src/templating/render.ts +109 -0
- package/src/util.ts +74 -112
- package/templates/base/Dockerfile.ejs +68 -0
- package/templates/base/README.md.ejs +62 -0
- package/templates/base/apps/admin/index.html.ejs +12 -0
- package/templates/base/apps/admin/nginx.conf.ejs +16 -0
- package/templates/base/apps/admin/package.json.ejs +15 -0
- package/templates/base/apps/admin/src/main.tsx.ejs +7 -0
- package/templates/base/apps/admin/vite.config.ts.ejs +22 -0
- package/templates/base/apps/ai-server/main.py.ejs +30 -0
- package/templates/base/apps/ai-server/requirements.txt +4 -0
- package/templates/base/apps/backend/src/main.ts +14 -0
- package/templates/base/apps/backend/src/routes/index.ts.ejs +11 -0
- package/templates/base/apps/frontend/index.html.ejs +12 -0
- package/templates/base/apps/frontend/nginx.conf.ejs +33 -0
- package/templates/base/apps/frontend/package.json.ejs +11 -0
- package/templates/base/apps/frontend/src/main.tsx.ejs +16 -0
- package/templates/base/apps/frontend/vite.config.ts.ejs +22 -0
- package/templates/base/docker-compose.yml.ejs +93 -0
- package/templates/base/dockerignore +21 -0
- package/templates/base/gitignore +43 -0
- package/templates/base/nexus.config.ts.ejs +97 -0
- package/templates/base/package.json.ejs +40 -0
- package/templates/{tsconfig.json → base/tsconfig.json} +2 -3
- package/tsconfig.json +5 -4
- package/PLAN.md +0 -141
- package/README.md +0 -34
- package/src/commands/cluster.ts +0 -133
- package/src/commands/node.ts +0 -106
- package/src/commands/pysetup.ts +0 -142
- package/src/commands/sync.ts +0 -116
- package/src/commands/uninstall.ts +0 -287
- package/src/config-sync.ts +0 -384
- package/src/dotenv.ts +0 -39
- package/src/supervisor.ts +0 -394
- package/src/wizard.ts +0 -149
- package/templates/Dockerfile +0 -60
- package/templates/README.md +0 -69
- package/templates/apps/admin/index.html +0 -12
- package/templates/apps/admin/package.json +0 -22
- package/templates/apps/admin/postcss.config.js +0 -3
- package/templates/apps/admin/src/main.tsx +0 -10
- package/templates/apps/admin/src/vite-env.d.ts +0 -18
- package/templates/apps/admin/tailwind.config.js +0 -9
- package/templates/apps/admin/tsconfig.json +0 -17
- package/templates/apps/admin/vite.config.ts +0 -64
- package/templates/apps/ai-server/main.py +0 -43
- package/templates/apps/ai-server/providers/__init__.py +0 -3
- package/templates/apps/ai-server/providers/base.py +0 -111
- package/templates/apps/ai-server/requirements.txt +0 -3
- package/templates/apps/ai-server/routers/__init__.py +0 -3
- package/templates/apps/ai-server/routers/chat.py +0 -47
- package/templates/apps/ai-server/routers/embeddings.py +0 -30
- package/templates/apps/ai-server/routers/lint.py +0 -167
- package/templates/apps/ai-server/routers/models.py +0 -23
- package/templates/apps/ai-server/routers/preflight.py +0 -169
- package/templates/apps/ai-server/settings.py +0 -48
- package/templates/apps/backend/package.json +0 -34
- package/templates/apps/backend/src/main.ts +0 -375
- package/templates/apps/backend/src/modules/admin/adminRoutes.ts +0 -732
- package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +0 -391
- package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +0 -161
- package/templates/apps/backend/src/modules/admin/lintProxy.ts +0 -89
- package/templates/apps/backend/src/modules/admin/preflightProxy.ts +0 -242
- package/templates/apps/backend/src/modules/admin/roleCatalog.ts +0 -78
- package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +0 -449
- package/templates/apps/backend/src/modules/ai/aiProxy.ts +0 -265
- package/templates/apps/backend/src/modules/auth/authRoutes.ts +0 -220
- package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +0 -100
- package/templates/apps/backend/src/modules/payments/paymentStore.ts +0 -172
- package/templates/apps/backend/src/modules/requests/requestLog.ts +0 -175
- package/templates/apps/backend/src/modules/users/userGraph.ts +0 -83
- package/templates/apps/backend/src/modules/users/userModel.ts +0 -88
- package/templates/apps/backend/src/plugins/CronScheduler.ts +0 -69
- package/templates/apps/backend/src/plugins/loadPlugins.ts +0 -107
- package/templates/apps/backend/tsconfig.json +0 -14
- package/templates/apps/frontend/index.html +0 -12
- package/templates/apps/frontend/package.json +0 -20
- package/templates/apps/frontend/postcss.config.js +0 -3
- package/templates/apps/frontend/src/index.css +0 -44
- package/templates/apps/frontend/src/main.tsx +0 -65
- package/templates/apps/frontend/tailwind.config.js +0 -9
- package/templates/apps/frontend/vite.config.ts +0 -72
- package/templates/bin/nexus.js +0 -35
- package/templates/bin/serve-all.mjs +0 -45
- package/templates/dockerignore +0 -15
- package/templates/gitignore +0 -12
- package/templates/nexus.config.ts +0 -69
- package/templates/package.json +0 -47
- package/tests/cli.test.ts +0 -52
- package/tests/config-sync.test.ts +0 -201
- package/tests/dotenv.test.ts +0 -51
- package/vitest.config.ts +0 -9
- package/vitest.config.ts.timestamp-1786095205351-444061f6ff5c58.mjs +0 -13
- /package/templates/{uploads → base/apps/backend/storage}/.gitkeep +0 -0
package/templates/README.md
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# My Nexus App
|
|
2
|
-
|
|
3
|
-
Scaffolded by `nexus init` — a [BhooAI Nexus](https://github.com/bhooai/nexus) full-stack app
|
|
4
|
-
with four runnable terminals: backend, frontend, Python AI server, and admin.
|
|
5
|
-
|
|
6
|
-
## Quick start
|
|
7
|
-
|
|
8
|
-
```bash
|
|
9
|
-
npm install
|
|
10
|
-
npm run doctor # verify node, python, mongo, redis
|
|
11
|
-
npm run dev # start all four terminals under the supervisor
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
`nexus init` installs these dependencies automatically. Run `npm install` manually only when this
|
|
15
|
-
directory was copied without running `nexus init`, or after changing dependency versions.
|
|
16
|
-
|
|
17
|
-
Then visit:
|
|
18
|
-
|
|
19
|
-
- Backend: http://localhost:4000/health
|
|
20
|
-
- Frontend: http://localhost:3000
|
|
21
|
-
- Admin: http://localhost:3300
|
|
22
|
-
- AI server: http://localhost:8000
|
|
23
|
-
|
|
24
|
-
## What you got
|
|
25
|
-
|
|
26
|
-
- `apps/backend/` — Node backend on the Nexus framework (custom HTTP server, CSRF/CORS,
|
|
27
|
-
GraphQL, websockets, ODM, OAuth, payments, and multipart uploads under `/uploads`.
|
|
28
|
-
Uploaded files are stored in the project-local `uploads/` directory. See `src/main.ts`.
|
|
29
|
-
- `apps/frontend/` — React + Vite + Tailwind (placeholder; add components in `src/`).
|
|
30
|
-
- `apps/ai-server/` — Python FastAPI (OpenAI + Ollama). `pip install -r requirements.txt`.
|
|
31
|
-
- `apps/admin/` — React + Vite admin console (config, processes, plugins, users, monitoring).
|
|
32
|
-
|
|
33
|
-
## Config
|
|
34
|
-
|
|
35
|
-
Edit `nexus.config.ts` for app settings. Secrets go directly in `.env` (no `.env.example`).
|
|
36
|
-
Admin overrides are written to `nexus.runtime.json` (gitignored).
|
|
37
|
-
|
|
38
|
-
## Uploads
|
|
39
|
-
|
|
40
|
-
The generated backend accepts a `multipart/form-data` `POST /uploads` request and returns generated
|
|
41
|
-
file URLs. The default limit is 10 MiB per file and 20 files per request. The request must include the
|
|
42
|
-
CSRF token returned by `GET /csrf-token`:
|
|
43
|
-
|
|
44
|
-
```js
|
|
45
|
-
const csrf = await fetch('/csrf-token', { credentials: 'include' }).then((r) => r.json());
|
|
46
|
-
const form = new FormData();
|
|
47
|
-
form.append('file', input.files[0]);
|
|
48
|
-
const response = await fetch('/uploads', {
|
|
49
|
-
method: 'POST',
|
|
50
|
-
body: form,
|
|
51
|
-
credentials: 'include',
|
|
52
|
-
headers: { 'x-csrf-token': csrf.token },
|
|
53
|
-
});
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Configure `uploads.dir`, `uploads.path`, `uploads.maxFileSize`, `uploads.maxFiles`, and
|
|
57
|
-
`uploads.allowedTypes` in `nexus.config.ts`. Uploaded files are ignored by Git.
|
|
58
|
-
|
|
59
|
-
> Generated projects depend on the published `bhooai-nexus` package. Publish the framework package
|
|
60
|
-
> from the `bhooai-nexus/` workspace root with `npm publish` before installing it from the public registry.
|
|
61
|
-
|
|
62
|
-
## Scripts
|
|
63
|
-
|
|
64
|
-
| command | what it does |
|
|
65
|
-
| --- | --- |
|
|
66
|
-
| `npm run dev` | start backend + frontend + AI + admin under the supervisor |
|
|
67
|
-
| `npm run doctor` | check node/python versions and mongo/redis reachability |
|
|
68
|
-
| `npm run build` | build all TypeScript workspaces |
|
|
69
|
-
| `npm test` | run the test suites |
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
-
<title>BhooAI Nexus Admin</title>
|
|
7
|
-
</head>
|
|
8
|
-
<body class="bg-mesh text-slate-100">
|
|
9
|
-
<div id="root"></div>
|
|
10
|
-
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
-
</body>
|
|
12
|
-
</html>
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "my-nexus-admin",
|
|
3
|
-
"private": true,
|
|
4
|
-
"type": "module",
|
|
5
|
-
"scripts": {
|
|
6
|
-
"dev": "vite --port 3300",
|
|
7
|
-
"build": "tsc -b && vite build",
|
|
8
|
-
"preview": "vite preview"
|
|
9
|
-
},
|
|
10
|
-
"dependencies": {
|
|
11
|
-
"react": "^18.3.0",
|
|
12
|
-
"react-dom": "^18.3.0"
|
|
13
|
-
},
|
|
14
|
-
"devDependencies": {
|
|
15
|
-
"@bhooai/nexus-postcss": "*",
|
|
16
|
-
"@types/react": "^18.3.0",
|
|
17
|
-
"@types/react-dom": "^18.3.0",
|
|
18
|
-
"@vitejs/plugin-react": "^4.3.0",
|
|
19
|
-
"typescript": "^5.6.0",
|
|
20
|
-
"vite": "^5.4.0"
|
|
21
|
-
}
|
|
22
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { createRoot } from 'react-dom/client';
|
|
3
|
-
import { App } from '@bhooai/admin';
|
|
4
|
-
import '@bhooai/admin/style.css';
|
|
5
|
-
|
|
6
|
-
createRoot(document.getElementById('root')!).render(
|
|
7
|
-
<React.StrictMode>
|
|
8
|
-
<App />
|
|
9
|
-
</React.StrictMode>,
|
|
10
|
-
);
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
// Ambient declarations for the admin host app. Self-contained: does not depend
|
|
2
|
-
// on `vite/client` types being resolvable from the host workspace, so `@bhooai/admin`
|
|
3
|
-
// imports (import.meta.env, *.svg) type-check consistently in every project.
|
|
4
|
-
interface ImportMetaEnv {
|
|
5
|
-
readonly VITE_SUPERVISOR_URL?: string;
|
|
6
|
-
readonly [key: string]: string | undefined;
|
|
7
|
-
}
|
|
8
|
-
interface ImportMeta {
|
|
9
|
-
readonly env: ImportMetaEnv;
|
|
10
|
-
}
|
|
11
|
-
declare module '*.svg' {
|
|
12
|
-
const src: string;
|
|
13
|
-
export default src;
|
|
14
|
-
}
|
|
15
|
-
declare module '*.png' {
|
|
16
|
-
const src: string;
|
|
17
|
-
export default src;
|
|
18
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
-
"module": "ESNext",
|
|
6
|
-
"moduleResolution": "Bundler",
|
|
7
|
-
"jsx": "react-jsx",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"noEmit": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"esModuleInterop": true,
|
|
12
|
-
"resolveJsonModule": true,
|
|
13
|
-
"isolatedModules": true,
|
|
14
|
-
"allowImportingTsExtensions": true
|
|
15
|
-
},
|
|
16
|
-
"include": ["src"]
|
|
17
|
-
}
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'vite';
|
|
2
|
-
import react from '@vitejs/plugin-react';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
4
|
-
import { dirname, join, resolve } from 'node:path';
|
|
5
|
-
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
-
|
|
7
|
-
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
8
|
-
|
|
9
|
-
// Read the merged host/port values from the project's single nexus config file
|
|
10
|
-
// (ts/js/mjs/cjs, discovery order matches the backend loader) plus NEXUS_*
|
|
11
|
-
// env overrides, so this Vite dev server and its proxy target stay in one
|
|
12
|
-
// source of truth: nexus.config.js.
|
|
13
|
-
async function loadConfig() {
|
|
14
|
-
const exts = ['ts', 'js', 'mjs', 'cjs'];
|
|
15
|
-
let user: Record<string, any> = {};
|
|
16
|
-
for (const ext of exts) {
|
|
17
|
-
const file = join(PROJECT_ROOT, `nexus.config.${ext}`);
|
|
18
|
-
if (existsSync(file)) {
|
|
19
|
-
const mod = (await import(pathToFileURL(file).href + `?v=${Date.now()}`)) as Record<string, any>;
|
|
20
|
-
user = mod.default ?? mod.config ?? {};
|
|
21
|
-
break;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
const num = (v: string | undefined, fallback: number) => (v !== undefined && Number.isFinite(Number(v)) ? Number(v) : fallback);
|
|
25
|
-
const str = (v: string | undefined, fallback: string) => (v !== undefined && v !== '' ? v : fallback);
|
|
26
|
-
|
|
27
|
-
return {
|
|
28
|
-
server: {
|
|
29
|
-
host: str(process.env.NEXUS_SERVER_HOST, user.server?.host ?? '0.0.0.0'),
|
|
30
|
-
port: num(process.env.NEXUS_SERVER_PORT, user.server?.port ?? 4000),
|
|
31
|
-
},
|
|
32
|
-
admin: {
|
|
33
|
-
host: str(process.env.NEXUS_ADMIN_HOST, user.admin?.host ?? 'localhost'),
|
|
34
|
-
port: num(process.env.NEXUS_ADMIN_PORT, user.admin?.port ?? 3300),
|
|
35
|
-
enabled: user.admin?.enabled ?? true,
|
|
36
|
-
},
|
|
37
|
-
frontend: {
|
|
38
|
-
host: str(process.env.NEXUS_FRONTEND_HOST, user.frontend?.host ?? 'localhost'),
|
|
39
|
-
port: num(process.env.NEXUS_FRONTEND_PORT, user.frontend?.port ?? 3000),
|
|
40
|
-
enabled: user.frontend?.enabled ?? true,
|
|
41
|
-
},
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export default defineConfig(async () => {
|
|
46
|
-
const cfg = await loadConfig();
|
|
47
|
-
const backendHost = cfg.server.host === '0.0.0.0' || cfg.server.host === '::' ? '127.0.0.1' : cfg.server.host;
|
|
48
|
-
const target = `http://${backendHost}:${cfg.server.port}`;
|
|
49
|
-
|
|
50
|
-
return {
|
|
51
|
-
plugins: [react()],
|
|
52
|
-
server: {
|
|
53
|
-
port: cfg.admin.port,
|
|
54
|
-
host: cfg.admin.host,
|
|
55
|
-
proxy: {
|
|
56
|
-
'/admin': { target, changeOrigin: true },
|
|
57
|
-
'/auth': { target, changeOrigin: true },
|
|
58
|
-
'/payments': { target, changeOrigin: true },
|
|
59
|
-
'/csrf-token': { target, changeOrigin: true },
|
|
60
|
-
'/ai': { target, changeOrigin: true },
|
|
61
|
-
},
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
});
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
"""BhooAI Nexus AI server (FastAPI).
|
|
2
|
-
|
|
3
|
-
OpenAI-compatible gateway fronting OpenAI and Ollama. The Node backend proxies
|
|
4
|
-
requests here (and re-emits SSE); the browser never calls this server directly.
|
|
5
|
-
|
|
6
|
-
Run: uvicorn main:app --reload --port 8000 (from apps/ai-server)
|
|
7
|
-
"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
from fastapi import FastAPI
|
|
11
|
-
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
-
|
|
13
|
-
from settings import settings
|
|
14
|
-
from providers import available_providers
|
|
15
|
-
from routers import chat, embeddings, models, preflight, lint
|
|
16
|
-
|
|
17
|
-
app = FastAPI(title="BhooAI Nexus AI", version="1.0.0")
|
|
18
|
-
|
|
19
|
-
app.add_middleware(
|
|
20
|
-
CORSMiddleware,
|
|
21
|
-
allow_origins=settings.cors_origins,
|
|
22
|
-
allow_credentials=True,
|
|
23
|
-
allow_methods=["*"],
|
|
24
|
-
allow_headers=["*"],
|
|
25
|
-
)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@app.get("/health")
|
|
29
|
-
async def health():
|
|
30
|
-
return {"status": "ok", "providers": available_providers()}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
app.include_router(chat.router)
|
|
34
|
-
app.include_router(embeddings.router)
|
|
35
|
-
app.include_router(models.router)
|
|
36
|
-
app.include_router(preflight.router)
|
|
37
|
-
app.include_router(lint.router)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if __name__ == "__main__":
|
|
41
|
-
import uvicorn
|
|
42
|
-
|
|
43
|
-
uvicorn.run("main:app", host="0.0.0.0", port=settings.server_port, reload=False)
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
"""Provider abstraction. Both OpenAI and Ollama expose OpenAI-compatible
|
|
2
|
-
endpoints, so a single OpenAICompatibleProvider parameterised by base URL +
|
|
3
|
-
auth handles both. Streaming uses SSE (upstream → parsed → re-emitted with the
|
|
4
|
-
provider field injected)."""
|
|
5
|
-
from __future__ import annotations
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
from typing import Any, AsyncIterator
|
|
9
|
-
|
|
10
|
-
import httpx
|
|
11
|
-
|
|
12
|
-
from settings import Settings, settings, resolve_provider
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class Provider:
|
|
16
|
-
name = "base"
|
|
17
|
-
|
|
18
|
-
def __init__(self, base_url: str, api_key: str, timeout: float, transport: Any | None = None):
|
|
19
|
-
self.base_url = base_url.rstrip("/")
|
|
20
|
-
self.api_key = api_key
|
|
21
|
-
self.timeout = timeout
|
|
22
|
-
self.transport = transport
|
|
23
|
-
|
|
24
|
-
def _headers(self) -> dict[str, str]:
|
|
25
|
-
h = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
26
|
-
if self.api_key:
|
|
27
|
-
h["Authorization"] = f"Bearer {self.api_key}"
|
|
28
|
-
return h
|
|
29
|
-
|
|
30
|
-
def _clean(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
31
|
-
# Strip our routing field before forwarding upstream.
|
|
32
|
-
return {k: v for k, v in body.items() if k != "provider"}
|
|
33
|
-
|
|
34
|
-
async def chat(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
35
|
-
async with httpx.AsyncClient(timeout=self.timeout, transport=self.transport) as client:
|
|
36
|
-
resp = await client.post(f"{self.base_url}/chat/completions", json=self._clean(body), headers=self._headers())
|
|
37
|
-
resp.raise_for_status()
|
|
38
|
-
data = resp.json()
|
|
39
|
-
data["provider"] = self.name
|
|
40
|
-
return data
|
|
41
|
-
|
|
42
|
-
async def chat_stream(self, body: dict[str, Any]) -> AsyncIterator[str]:
|
|
43
|
-
"""Yield SSE-formatted `data: ...` lines (already ending in \\n\\n)."""
|
|
44
|
-
payload = self._clean(body)
|
|
45
|
-
payload["stream"] = True
|
|
46
|
-
async with httpx.AsyncClient(timeout=self.timeout, transport=self.transport) as client:
|
|
47
|
-
async with client.stream("POST", f"{self.base_url}/chat/completions", json=payload, headers=self._headers()) as resp:
|
|
48
|
-
resp.raise_for_status()
|
|
49
|
-
async for line in resp.aiter_lines():
|
|
50
|
-
if not line or not line.startswith("data:"):
|
|
51
|
-
continue
|
|
52
|
-
raw = line[5:].strip()
|
|
53
|
-
if raw == "[DONE]":
|
|
54
|
-
yield "data: [DONE]\n\n"
|
|
55
|
-
return
|
|
56
|
-
try:
|
|
57
|
-
chunk = json.loads(raw)
|
|
58
|
-
except json.JSONDecodeError:
|
|
59
|
-
continue
|
|
60
|
-
chunk["provider"] = self.name
|
|
61
|
-
yield f"data: {json.dumps(chunk)}\n\n"
|
|
62
|
-
# If the upstream didn't send [DONE], close the stream cleanly.
|
|
63
|
-
yield "data: [DONE]\n\n"
|
|
64
|
-
|
|
65
|
-
async def embed(self, body: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
-
async with httpx.AsyncClient(timeout=self.timeout, transport=self.transport) as client:
|
|
67
|
-
resp = await client.post(f"{self.base_url}/embeddings", json=self._clean(body), headers=self._headers())
|
|
68
|
-
resp.raise_for_status()
|
|
69
|
-
data = resp.json()
|
|
70
|
-
data["provider"] = self.name
|
|
71
|
-
return data
|
|
72
|
-
|
|
73
|
-
async def list_models(self) -> dict[str, Any]:
|
|
74
|
-
async with httpx.AsyncClient(timeout=self.timeout, transport=self.transport) as client:
|
|
75
|
-
resp = await client.get(f"{self.base_url}/models", headers=self._headers())
|
|
76
|
-
resp.raise_for_status()
|
|
77
|
-
data = resp.json()
|
|
78
|
-
data["provider"] = self.name
|
|
79
|
-
return data
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
class OpenAIProvider(Provider):
|
|
83
|
-
name = "openai"
|
|
84
|
-
|
|
85
|
-
@classmethod
|
|
86
|
-
def from_settings(cls, s: Settings) -> "OpenAIProvider":
|
|
87
|
-
return cls(s.openai_base_url, s.openai_api_key, s.request_timeout_s)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
class OllamaProvider(Provider):
|
|
91
|
-
name = "ollama"
|
|
92
|
-
|
|
93
|
-
@classmethod
|
|
94
|
-
def from_settings(cls, s: Settings) -> "OllamaProvider":
|
|
95
|
-
return cls(s.ollama_base_url, s.ollama_api_key, s.request_timeout_s)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def get_provider(requested: str | None) -> Provider:
|
|
99
|
-
"""Resolve and instantiate the provider for a request."""
|
|
100
|
-
name = resolve_provider(requested)
|
|
101
|
-
if name == "openai":
|
|
102
|
-
return OpenAIProvider.from_settings(settings)
|
|
103
|
-
return OllamaProvider.from_settings(settings)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def available_providers() -> list[str]:
|
|
107
|
-
avail = []
|
|
108
|
-
if settings.openai_api_key:
|
|
109
|
-
avail.append("openai")
|
|
110
|
-
avail.append("ollama")
|
|
111
|
-
return avail
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
"""Chat completions router — OpenAI-compatible, supports SSE streaming."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from fastapi import APIRouter, HTTPException
|
|
5
|
-
from fastapi.responses import StreamingResponse
|
|
6
|
-
from pydantic import BaseModel, Field
|
|
7
|
-
|
|
8
|
-
from providers import get_provider
|
|
9
|
-
|
|
10
|
-
router = APIRouter()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class Message(BaseModel):
|
|
14
|
-
role: str
|
|
15
|
-
content: str
|
|
16
|
-
name: str | None = None
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
class ChatCompletionRequest(BaseModel):
|
|
20
|
-
model: str
|
|
21
|
-
messages: list[Message]
|
|
22
|
-
stream: bool = False
|
|
23
|
-
temperature: float | None = None
|
|
24
|
-
max_tokens: int | None = None
|
|
25
|
-
provider: str | None = None
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@router.post("/chat/completions")
|
|
29
|
-
async def chat_completions(req: ChatCompletionRequest):
|
|
30
|
-
body = req.model_dump(exclude_none=True)
|
|
31
|
-
try:
|
|
32
|
-
provider = get_provider(body.get("provider"))
|
|
33
|
-
except ValueError as e:
|
|
34
|
-
raise HTTPException(status_code=400, detail=str(e))
|
|
35
|
-
|
|
36
|
-
if body.get("stream"):
|
|
37
|
-
async def gen():
|
|
38
|
-
async for chunk in provider.chat_stream(body):
|
|
39
|
-
yield chunk
|
|
40
|
-
return StreamingResponse(gen(), media_type="text/event-stream")
|
|
41
|
-
|
|
42
|
-
try:
|
|
43
|
-
return await provider.chat(body)
|
|
44
|
-
except HTTPException:
|
|
45
|
-
raise
|
|
46
|
-
except Exception as e: # upstream error
|
|
47
|
-
raise HTTPException(status_code=502, detail=f"upstream error: {e}")
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
"""Embeddings router — OpenAI-compatible."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
from fastapi import APIRouter, HTTPException
|
|
5
|
-
from pydantic import BaseModel
|
|
6
|
-
|
|
7
|
-
from providers import get_provider
|
|
8
|
-
|
|
9
|
-
router = APIRouter()
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class EmbeddingRequest(BaseModel):
|
|
13
|
-
model: str
|
|
14
|
-
input: str | list[str]
|
|
15
|
-
provider: str | None = None
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@router.post("/embeddings")
|
|
19
|
-
async def create_embeddings(req: EmbeddingRequest):
|
|
20
|
-
body = req.model_dump(exclude_none=True)
|
|
21
|
-
try:
|
|
22
|
-
provider = get_provider(body.get("provider"))
|
|
23
|
-
except ValueError as e:
|
|
24
|
-
raise HTTPException(status_code=400, detail=str(e))
|
|
25
|
-
try:
|
|
26
|
-
return await provider.embed(body)
|
|
27
|
-
except HTTPException:
|
|
28
|
-
raise
|
|
29
|
-
except Exception as e:
|
|
30
|
-
raise HTTPException(status_code=502, detail=f"upstream error: {e}")
|
|
@@ -1,167 +0,0 @@
|
|
|
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))
|
|
@@ -1,23 +0,0 @@
|
|
|
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}")
|