@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.
Files changed (75) hide show
  1. package/PLAN.md +141 -0
  2. package/README.md +34 -0
  3. package/package.json +25 -0
  4. package/src/commands/cluster.ts +133 -0
  5. package/src/commands/dev.ts +133 -0
  6. package/src/commands/doctor.ts +199 -0
  7. package/src/commands/init.ts +960 -0
  8. package/src/commands/node.ts +101 -0
  9. package/src/commands/pysetup.ts +136 -0
  10. package/src/commands/sync.ts +116 -0
  11. package/src/commands/uninstall.ts +287 -0
  12. package/src/config-sync.ts +384 -0
  13. package/src/dotenv.ts +39 -0
  14. package/src/index.ts +94 -0
  15. package/src/supervisor.ts +384 -0
  16. package/src/util.ts +123 -0
  17. package/src/wizard.ts +149 -0
  18. package/templates/Dockerfile +60 -0
  19. package/templates/README.md +69 -0
  20. package/templates/apps/admin/index.html +12 -0
  21. package/templates/apps/admin/package.json +24 -0
  22. package/templates/apps/admin/postcss.config.js +6 -0
  23. package/templates/apps/admin/src/main.tsx +10 -0
  24. package/templates/apps/admin/src/vite-env.d.ts +18 -0
  25. package/templates/apps/admin/tailwind.config.js +9 -0
  26. package/templates/apps/admin/tsconfig.json +17 -0
  27. package/templates/apps/admin/vite.config.ts +64 -0
  28. package/templates/apps/ai-server/main.py +43 -0
  29. package/templates/apps/ai-server/providers/__init__.py +3 -0
  30. package/templates/apps/ai-server/providers/base.py +111 -0
  31. package/templates/apps/ai-server/requirements.txt +3 -0
  32. package/templates/apps/ai-server/routers/__init__.py +3 -0
  33. package/templates/apps/ai-server/routers/chat.py +47 -0
  34. package/templates/apps/ai-server/routers/embeddings.py +30 -0
  35. package/templates/apps/ai-server/routers/lint.py +167 -0
  36. package/templates/apps/ai-server/routers/models.py +23 -0
  37. package/templates/apps/ai-server/routers/preflight.py +169 -0
  38. package/templates/apps/ai-server/settings.py +48 -0
  39. package/templates/apps/backend/package.json +33 -0
  40. package/templates/apps/backend/src/main.ts +375 -0
  41. package/templates/apps/backend/src/modules/admin/adminRoutes.ts +732 -0
  42. package/templates/apps/backend/src/modules/admin/clusterRoutes.ts +391 -0
  43. package/templates/apps/backend/src/modules/admin/databaseRoutes.ts +161 -0
  44. package/templates/apps/backend/src/modules/admin/lintProxy.ts +89 -0
  45. package/templates/apps/backend/src/modules/admin/preflightProxy.ts +242 -0
  46. package/templates/apps/backend/src/modules/admin/roleCatalog.ts +78 -0
  47. package/templates/apps/backend/src/modules/admin/schemaRoutes.ts +449 -0
  48. package/templates/apps/backend/src/modules/ai/aiProxy.ts +265 -0
  49. package/templates/apps/backend/src/modules/auth/authRoutes.ts +220 -0
  50. package/templates/apps/backend/src/modules/payments/paymentRoutes.ts +100 -0
  51. package/templates/apps/backend/src/modules/payments/paymentStore.ts +172 -0
  52. package/templates/apps/backend/src/modules/requests/requestLog.ts +175 -0
  53. package/templates/apps/backend/src/modules/users/userGraph.ts +83 -0
  54. package/templates/apps/backend/src/modules/users/userModel.ts +88 -0
  55. package/templates/apps/backend/src/plugins/CronScheduler.ts +69 -0
  56. package/templates/apps/backend/src/plugins/loadPlugins.ts +107 -0
  57. package/templates/apps/backend/tsconfig.json +14 -0
  58. package/templates/apps/frontend/index.html +12 -0
  59. package/templates/apps/frontend/package.json +19 -0
  60. package/templates/apps/frontend/src/main.tsx +64 -0
  61. package/templates/apps/frontend/vite.config.ts +63 -0
  62. package/templates/bin/nexus.js +35 -0
  63. package/templates/bin/serve-all.mjs +45 -0
  64. package/templates/dockerignore +15 -0
  65. package/templates/gitignore +12 -0
  66. package/templates/nexus.config.ts +69 -0
  67. package/templates/package.json +47 -0
  68. package/templates/tsconfig.json +17 -0
  69. package/templates/uploads/.gitkeep +0 -0
  70. package/tests/cli.test.ts +45 -0
  71. package/tests/config-sync.test.ts +201 -0
  72. package/tests/dotenv.test.ts +51 -0
  73. package/tsconfig.json +9 -0
  74. package/vitest.config.ts +9 -0
  75. 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,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
@@ -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,9 @@
1
+ export default {
2
+ content: [
3
+ './index.html',
4
+ './src/**/*.{ts,tsx}',
5
+ '../../bhooai-nexus/packages/nexus-admin/src/**/*.{ts,tsx}',
6
+ ],
7
+ theme: { extend: {} },
8
+ plugins: [],
9
+ };
@@ -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,3 @@
1
+ from .base import Provider, OpenAIProvider, OllamaProvider, get_provider, available_providers
2
+
3
+ __all__ = ["Provider", "OpenAIProvider", "OllamaProvider", "get_provider", "available_providers"]
@@ -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,3 @@
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.30.0
3
+ httpx>=0.27.0
@@ -0,0 +1,3 @@
1
+ from . import chat, embeddings, models
2
+
3
+ __all__ = ["chat", "embeddings", "models"]
@@ -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}")