@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,60 @@
|
|
|
1
|
+
# BhooAI Nexus image - backend + built frontend + built admin in one container.
|
|
2
|
+
#
|
|
3
|
+
# Build:
|
|
4
|
+
# docker build -t node-1 .
|
|
5
|
+
# Run (pass project secrets; real env vars always win over .env):
|
|
6
|
+
# docker run --rm -p 4000:4000 -p 3000:3000 -p 3001:3001 --env-file .env node-1
|
|
7
|
+
# # or override the server port / cluster node agent port:
|
|
8
|
+
# docker run --rm -p 4000:4000 -p 3000:3000 -p 3001:3001 -e NEXUS_SERVER_PORT=4000 -e NEXUS_NODE_PORT=7575 node-1
|
|
9
|
+
#
|
|
10
|
+
# Access:
|
|
11
|
+
# frontend http://localhost:3000 (SPA; /health /graphql /auth /ai /ws proxied to the backend)
|
|
12
|
+
# admin http://localhost:3001 (SPA; /admin /auth proxied to the backend)
|
|
13
|
+
# backend http://localhost:4000 (API + /health)
|
|
14
|
+
# node http://localhost:7575 (cluster node agent — link it from a root: `nexus cluster link http://<host>:7575`)
|
|
15
|
+
#
|
|
16
|
+
# The Python AI server, MongoDB, and Redis remain separate services and must
|
|
17
|
+
# already be reachable (see nexus.config.*).
|
|
18
|
+
|
|
19
|
+
ARG NODE_VERSION=24
|
|
20
|
+
|
|
21
|
+
FROM node:${NODE_VERSION}-alpine AS base
|
|
22
|
+
|
|
23
|
+
WORKDIR /app
|
|
24
|
+
|
|
25
|
+
# npm workspaces: install deps for packages/*, apps/backend, admin, apps/frontend.
|
|
26
|
+
# The dev deps (vite, typescript, tsx) are needed both to build the SPAs and at runtime.
|
|
27
|
+
COPY package.json package-lock.json* ./
|
|
28
|
+
COPY packages packages
|
|
29
|
+
COPY apps apps
|
|
30
|
+
COPY apps/admin apps/admin
|
|
31
|
+
RUN npm install --no-audit --no-fund
|
|
32
|
+
|
|
33
|
+
# Build the frontend + admin SPA bundles (static dist/ folders).
|
|
34
|
+
RUN npm --prefix apps/frontend run build \
|
|
35
|
+
&& npm --prefix apps/admin run build
|
|
36
|
+
|
|
37
|
+
# Full source tree (config, bin, uploads dir, tsconfig).
|
|
38
|
+
COPY . .
|
|
39
|
+
|
|
40
|
+
# The uploads dir is written at runtime (uploads / certs).
|
|
41
|
+
RUN mkdir -p uploads && chown -R node:node /app
|
|
42
|
+
|
|
43
|
+
# Default ports are informational; the real values come from nexus.config.* /
|
|
44
|
+
# NEXUS_* env overrides at runtime.
|
|
45
|
+
EXPOSE 3000 3001 4000 7575
|
|
46
|
+
|
|
47
|
+
ENV NODE_ENV=production
|
|
48
|
+
|
|
49
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
50
|
+
CMD wget -qO- http://127.0.0.1:${NEXUS_SERVER_PORT:-4000}/health >/dev/null || exit 1
|
|
51
|
+
|
|
52
|
+
USER node
|
|
53
|
+
|
|
54
|
+
# Runs the stack: cluster node agent (:7575) + backend role service via
|
|
55
|
+
# `nexus node serve --role=backend --port=7575`, plus frontend (:3000) and
|
|
56
|
+
# admin (:3001) previews.
|
|
57
|
+
COPY --chown=node:node bin/serve-all.mjs bin/serve-all.mjs
|
|
58
|
+
COPY --chown=node:node package.json package.json
|
|
59
|
+
|
|
60
|
+
CMD ["node", "bin/serve-all.mjs"]
|
|
@@ -0,0 +1,69 @@
|
|
|
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:3001
|
|
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 |
|
|
@@ -0,0 +1,12 @@
|
|
|
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>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-nexus-admin",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "vite --port 3001",
|
|
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
|
+
"@types/react": "^18.3.0",
|
|
16
|
+
"@types/react-dom": "^18.3.0",
|
|
17
|
+
"@vitejs/plugin-react": "^4.3.0",
|
|
18
|
+
"autoprefixer": "^10.4.20",
|
|
19
|
+
"postcss": "^8.4.47",
|
|
20
|
+
"tailwindcss": "^3.4.13",
|
|
21
|
+
"typescript": "^5.6.0",
|
|
22
|
+
"vite": "^5.4.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
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
|
+
);
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
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 ?? 3001),
|
|
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
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
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)
|
|
@@ -0,0 +1,111 @@
|
|
|
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
|
|
@@ -0,0 +1,47 @@
|
|
|
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}")
|
|
@@ -0,0 +1,30 @@
|
|
|
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}")
|