File size: 10,051 Bytes
a55ea57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a29f872
a55ea57
 
 
 
 
2f1e7ff
89e57b7
2f1e7ff
a55ea57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a611e4
a55ea57
3a611e4
a55ea57
3a611e4
a55ea57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2f1e7ff
 
a55ea57
 
 
2f1e7ff
 
 
 
 
 
 
 
a55ea57
 
 
 
 
 
 
 
 
03c130e
a55ea57
03c130e
a55ea57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89e57b7
 
a55ea57
 
 
 
 
 
 
 
 
 
 
2f1e7ff
 
a55ea57
 
 
 
 
89e57b7
a55ea57
 
a29f872
a55ea57
 
 
 
 
 
 
 
03c130e
53469d7
 
a55ea57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
03c130e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
import logging
import os
import re
from hashlib import sha1
from contextlib import asynccontextmanager
from pathlib import Path

import dotenv

_project_root = Path(__file__).resolve().parent.parent.parent.parent
dotenv.load_dotenv(_project_root / ".env")

from fastapi import FastAPI, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.routing import APIRoute
from uvicorn import run

from src.constants.data import BRAIN_VERSION
from src.services.api.console_static import SPAStaticFiles
from src.services.api.connection_docs import connection_docs_router
from src.services.api.errors import install_error_handlers
from src.services.api.openapi import install_openapi_contract

from src.services.api.middlewares.auth import BrainPATMiddleware
from src.services.api.middlewares.brains import BrainMiddleware
from src.services.api.routes.auth import auth_router
from src.services.api.routes.backup import backup_router
from src.services.api.routes.access_tokens import access_token_router
from src.services.api.routes.ingest import ingest_router
from src.services.api.routes.meta import meta_router
from src.services.api.routes.model import model_router
from src.services.api.routes.public import public_router
from src.services.api.routes.retrieve import retrieve_router
from src.services.api.routes.system import system_router
from src.services.api.routes.tasks import tasks_router
from src.services.api.routes.workspaces import workspace_router
from src.services.api.routes.workspace_api import workspace_api_router
from src.services.api.routes.agent import agent_router
from src.lib.tracing.middleware import TraceMiddleware
from src.lib.tracing.runtime import start_runtime_monitoring, stop_runtime_monitoring

logger = logging.getLogger("brainapi.plugins")

PLUGINS_DIR = Path(os.getenv("PLUGINS_DIR", str(_project_root / "plugins")))
CONSOLE_DIST = _project_root / "console" / "dist"


def _console_enabled() -> bool:
    return os.getenv("CONSOLE_ENABLED", "true").strip().lower() != "false"


def _production_mode() -> bool:
    return os.getenv("ENV", "production").strip().lower() != "development"


def _cors_allowed_origins() -> list[str]:
    configured = [
        origin.strip()
        for origin in os.getenv("CORS_ALLOWED_ORIGINS", "").split(",")
        if origin.strip()
    ]
    if not configured and not _production_mode():
        return ["*"]
    if "*" in configured and _production_mode():
        raise RuntimeError(
            "CORS_ALLOWED_ORIGINS='*' is allowed only when ENV=development"
        )
    return configured


def _enforce_plugin_results(results: dict[str, bool]) -> None:
    failed = sorted(name for name, loaded in results.items() if not loaded)
    if not failed:
        return
    default_policy = "fail" if _production_mode() else "warn"
    policy = os.getenv("PLUGIN_FAILURE_POLICY", default_policy).strip().lower()
    if policy not in {"fail", "warn"}:
        raise RuntimeError("PLUGIN_FAILURE_POLICY must be 'fail' or 'warn'")
    if policy == "fail":
        raise RuntimeError(f"Required plugins failed to load: {', '.join(failed)}")


def stable_operation_id(route: APIRoute) -> str:
    """Generate function-calling-safe IDs that are stable across schema exports."""

    tag = route.tags[0] if route.tags else "api"
    method = sorted(route.methods or {"get"})[0].lower()
    raw = f"{tag}_{route.name}_{method}".lower()
    normalized = re.sub(r"[^a-z0-9_]+", "_", raw).strip("_")
    if len(normalized) <= 64:
        return normalized
    digest = sha1(normalized.encode("utf-8")).hexdigest()[:8]
    return f"{normalized[:55].rstrip('_')}_{digest}"


@asynccontextmanager
async def lifespan(app: FastAPI):
    from src.services.brain_backup.state import require_startup_ready
    require_startup_ready()
    from src.core.plugins.context import PluginContext
    from src.core.plugins.loader import PluginLoader

    from src.services.persistence import enforce_persistence
    enforce_persistence()
    from src.services.auth_config import resolve_system_pat
    resolve_system_pat()
    from src.config import config
    if config.data_db == "postgresql":
        from src.services.credentials import token_repository
        token_repository.initialize()
    start_runtime_monitoring("brainapi-api")
    ctx = PluginContext.from_app(app)
    loader = PluginLoader(plugins_dir=PLUGINS_DIR, context=ctx)
    results = loader.load_all()

    try:
        from src.services.data.main import data_adapter
        from src.services.workspaces import WorkspaceService

        WorkspaceService(data_adapter).bootstrap()
    except Exception:
        logger.exception("Workspace bootstrap deferred; persistence is unavailable")

    _log_plugin_banner(loader, results)
    _enforce_plugin_results(results)

    for event_name, handlers in ctx._event_handlers.items():
        if event_name == "startup":
            for handler in handlers:
                await handler() if _is_coroutine(handler) else handler()

    try:
        yield
    finally:
        stop_runtime_monitoring("brainapi-api")

    for event_name, handlers in ctx._event_handlers.items():
        if event_name == "shutdown":
            for handler in handlers:
                await handler() if _is_coroutine(handler) else handler()


def _is_coroutine(fn):
    import asyncio
    return asyncio.iscoroutinefunction(fn)


def _log_plugin_banner(loader, results: dict[str, bool]):
    loaded = loader.loaded_plugins
    total = len(results)
    ok = sum(1 for v in results.values() if v)
    failed = total - ok

    lines = [
        "",
        "\033[36m ╔══════════════════════════════════════════════════════╗\033[0m",
        "\033[36m β•‘\033[0m             \033[1;36m⚑  BrainAPI Plugin System  ⚑\033[0m           \033[36mβ•‘\033[0m",
        "\033[36m ╠══════════════════════════════════════════════════════╣\033[0m",
    ]

    if total == 0:
        lines.append(
            "\033[36m β•‘\033[0m  \033[2mNo plugins installed\033[0m                                \033[36mβ•‘\033[0m"
        )
    else:
        for name, success in results.items():
            manifest = loaded.get(name)
            if success and manifest:
                ver = f"v{manifest.version}"
                status = "\033[32mβœ” loaded\033[0m"
                label = f"{manifest.name} ({ver})"
            else:
                status = "\033[31m✘ failed\033[0m"
                label = name
            padded = f"  {status}  {label}"
            visible_len = len(f"  βœ” loaded  {label}")
            pad = 54 - visible_len
            lines.append(f"\033[36m β•‘\033[0m{padded}{' ' * max(pad, 1)}\033[36mβ•‘\033[0m")

    lines.append("\033[36m ╠══════════════════════════════════════════════════════╣\033[0m")

    summary_parts = [f"\033[1;32m{ok} loaded\033[0m"]
    if failed:
        summary_parts.append(f"\033[1;31m{failed} failed\033[0m")
    summary_text = f"  {' Β· '.join(summary_parts)}"
    visible_summary_len = len(f"  {ok} loaded" + (f" Β· {failed} failed" if failed else ""))
    summary_pad = 54 - visible_summary_len
    lines.append(f"\033[36m β•‘\033[0m{summary_text}{' ' * max(summary_pad, 1)}\033[36mβ•‘\033[0m")

    lines.append("\033[36m β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•\033[0m")
    lines.append("")

    print("\n".join(lines))


app = FastAPI(
    title="BrainAPI",
    version=BRAIN_VERSION,
    description="Knowledge, memory, search, recommendation, and agent API.",
    debug=os.getenv("ENV") == "development",
    lifespan=lifespan,
    redirect_slashes=False,
    generate_unique_id_function=stable_operation_id,
)
install_error_handlers(app)

from src.services.brain_backup.middleware import ContentAdmissionMiddleware
app.add_middleware(ContentAdmissionMiddleware)
app.add_middleware(BrainPATMiddleware)
app.add_middleware(BrainMiddleware)
app.add_middleware(
    CORSMiddleware,
    allow_origins=_cors_allowed_origins(),
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)
app.add_middleware(TraceMiddleware, service_name="brainapi-api")

app.include_router(auth_router)
app.include_router(access_token_router)
app.include_router(ingest_router)
app.include_router(retrieve_router)
app.include_router(meta_router)
app.include_router(model_router)
app.include_router(system_router)
app.include_router(backup_router)
app.include_router(tasks_router)
app.include_router(public_router)
app.include_router(connection_docs_router)
app.include_router(workspace_router)
app.include_router(workspace_api_router)
app.include_router(agent_router)
install_openapi_contract(app)


@app.get("/", include_in_schema=False)
async def root():
    return RedirectResponse(url="/console/", status_code=307)


if _console_enabled():

    @app.get("/console", include_in_schema=False)
    async def console_redirect():
        return RedirectResponse(url="/console/", status_code=307)

    if CONSOLE_DIST.is_dir():
        app.mount(
            "/console",
            SPAStaticFiles(directory=str(CONSOLE_DIST), html=True),
            name="console",
        )
        logger.info("Local console mounted at /console")
    else:
        logger.warning(
            "Console enabled but %s not found β€” run: make build-console",
            CONSOLE_DIST,
        )
elif os.getenv("CONSOLE_ENABLED", "").strip().lower() == "true":
    logger.warning(
        "CONSOLE_ENABLED=true but console is disabled β€” set CONSOLE_ENABLED=false to silence",
    )


if __name__ == "__main__":
    run(app, host="0.0.0.0", port=8000)