mfs-server 0.4.0__py3-none-any.whl
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.
- mfs_server/__init__.py +0 -0
- mfs_server/api/__init__.py +0 -0
- mfs_server/api/app.py +645 -0
- mfs_server/api/models.py +224 -0
- mfs_server/common/__init__.py +0 -0
- mfs_server/common/accel.py +125 -0
- mfs_server/common/converter.py +60 -0
- mfs_server/common/embedding.py +132 -0
- mfs_server/common/embeddings/__init__.py +85 -0
- mfs_server/common/embeddings/gemini.py +79 -0
- mfs_server/common/embeddings/local.py +79 -0
- mfs_server/common/embeddings/ollama.py +47 -0
- mfs_server/common/embeddings/onnx.py +196 -0
- mfs_server/common/embeddings/openai.py +85 -0
- mfs_server/common/embeddings/utils.py +33 -0
- mfs_server/common/embeddings/voyage.py +69 -0
- mfs_server/common/llm/__init__.py +77 -0
- mfs_server/common/llm/anthropic.py +72 -0
- mfs_server/common/llm/gemini.py +62 -0
- mfs_server/common/llm/openai.py +64 -0
- mfs_server/common/retrieval.py +125 -0
- mfs_server/common/summary.py +93 -0
- mfs_server/common/vlm.py +91 -0
- mfs_server/config.py +530 -0
- mfs_server/connectors/__init__.py +0 -0
- mfs_server/connectors/base.py +575 -0
- mfs_server/connectors/bigquery/__init__.py +4 -0
- mfs_server/connectors/bigquery/plugin.py +239 -0
- mfs_server/connectors/discord/__init__.py +4 -0
- mfs_server/connectors/discord/plugin.py +255 -0
- mfs_server/connectors/feishu/__init__.py +4 -0
- mfs_server/connectors/feishu/auth_login.py +132 -0
- mfs_server/connectors/feishu/oauth.py +207 -0
- mfs_server/connectors/feishu/plugin.py +659 -0
- mfs_server/connectors/file/__init__.py +4 -0
- mfs_server/connectors/file/plugin.py +589 -0
- mfs_server/connectors/gdrive/__init__.py +4 -0
- mfs_server/connectors/gdrive/plugin.py +251 -0
- mfs_server/connectors/github/__init__.py +4 -0
- mfs_server/connectors/github/plugin.py +327 -0
- mfs_server/connectors/gmail/__init__.py +4 -0
- mfs_server/connectors/gmail/plugin.py +214 -0
- mfs_server/connectors/hubspot/__init__.py +4 -0
- mfs_server/connectors/hubspot/plugin.py +181 -0
- mfs_server/connectors/jira/__init__.py +4 -0
- mfs_server/connectors/jira/plugin.py +210 -0
- mfs_server/connectors/linear/__init__.py +4 -0
- mfs_server/connectors/linear/plugin.py +196 -0
- mfs_server/connectors/mongo/__init__.py +4 -0
- mfs_server/connectors/mongo/plugin.py +217 -0
- mfs_server/connectors/mysql/__init__.py +4 -0
- mfs_server/connectors/mysql/plugin.py +276 -0
- mfs_server/connectors/notion/__init__.py +4 -0
- mfs_server/connectors/notion/plugin.py +239 -0
- mfs_server/connectors/postgres/__init__.py +4 -0
- mfs_server/connectors/postgres/plugin.py +303 -0
- mfs_server/connectors/registry.py +56 -0
- mfs_server/connectors/s3/__init__.py +4 -0
- mfs_server/connectors/s3/plugin.py +155 -0
- mfs_server/connectors/slack/__init__.py +4 -0
- mfs_server/connectors/slack/plugin.py +229 -0
- mfs_server/connectors/snowflake/__init__.py +4 -0
- mfs_server/connectors/snowflake/plugin.py +356 -0
- mfs_server/connectors/web/__init__.py +4 -0
- mfs_server/connectors/web/plugin.py +239 -0
- mfs_server/connectors/zendesk/__init__.py +4 -0
- mfs_server/connectors/zendesk/plugin.py +200 -0
- mfs_server/engine/__init__.py +0 -0
- mfs_server/engine/adapters.py +135 -0
- mfs_server/engine/engine.py +2984 -0
- mfs_server/engine/job_lane/__init__.py +318 -0
- mfs_server/engine/job_lane/queue.py +53 -0
- mfs_server/engine/job_lane/tree.py +105 -0
- mfs_server/engine/job_lane/worker.py +116 -0
- mfs_server/engine/job_watcher.py +120 -0
- mfs_server/engine/pipeline.py +409 -0
- mfs_server/engine/producers/__init__.py +79 -0
- mfs_server/engine/producers/base.py +195 -0
- mfs_server/engine/producers/image.py +52 -0
- mfs_server/engine/producers/message_stream.py +132 -0
- mfs_server/engine/producers/record_collection.py +125 -0
- mfs_server/engine/producers/render.py +156 -0
- mfs_server/engine/producers/table_schema.py +70 -0
- mfs_server/engine/producers/text.py +140 -0
- mfs_server/engine/state.py +79 -0
- mfs_server/processors/__init__.py +0 -0
- mfs_server/processors/text.py +75 -0
- mfs_server/server/__init__.py +0 -0
- mfs_server/server/__main__.py +155 -0
- mfs_server/server/connector_schemas.py +436 -0
- mfs_server/server/connector_wizard.py +802 -0
- mfs_server/server/setup_wizard.py +682 -0
- mfs_server/server/wizard_ui.py +223 -0
- mfs_server/storage/__init__.py +0 -0
- mfs_server/storage/artifact_cache.py +79 -0
- mfs_server/storage/file_state.py +103 -0
- mfs_server/storage/ids.py +64 -0
- mfs_server/storage/metadata/__init__.py +44 -0
- mfs_server/storage/metadata/base.py +234 -0
- mfs_server/storage/metadata/postgres.py +70 -0
- mfs_server/storage/metadata/sqlite.py +72 -0
- mfs_server/storage/milvus.py +468 -0
- mfs_server/storage/transformation_cache/__init__.py +45 -0
- mfs_server/storage/transformation_cache/base.py +137 -0
- mfs_server/storage/transformation_cache/postgres.py +86 -0
- mfs_server/storage/transformation_cache/sqlite.py +80 -0
- mfs_server-0.4.0.dist-info/METADATA +159 -0
- mfs_server-0.4.0.dist-info/RECORD +110 -0
- mfs_server-0.4.0.dist-info/WHEEL +4 -0
- mfs_server-0.4.0.dist-info/entry_points.txt +2 -0
mfs_server/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
mfs_server/api/app.py
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
"""FastAPI /v1 control plane. Thin HTTP wrappers over Engine.
|
|
2
|
+
Typed request/response models (api/models.py) make the generated OpenAPI rich enough
|
|
3
|
+
for the multi-language SDKs. `add` indexes inline by default (returns job_id when done)
|
|
4
|
+
or enqueues for the standalone worker when process=false.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
14
|
+
from fastapi.exceptions import RequestValidationError
|
|
15
|
+
from fastapi.openapi.utils import get_openapi
|
|
16
|
+
from fastapi.responses import JSONResponse
|
|
17
|
+
from starlette.datastructures import Headers
|
|
18
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
19
|
+
from starlette.requests import ClientDisconnect
|
|
20
|
+
|
|
21
|
+
from ..config import ServerConfig, load_server_config
|
|
22
|
+
from ..engine.engine import Engine
|
|
23
|
+
from .models import (
|
|
24
|
+
AddRequest,
|
|
25
|
+
AddResponse,
|
|
26
|
+
CancelResponse,
|
|
27
|
+
CatMeta,
|
|
28
|
+
CatResponse,
|
|
29
|
+
EstimateResponse,
|
|
30
|
+
ErrorResponse,
|
|
31
|
+
GrepResponse,
|
|
32
|
+
JobResponse,
|
|
33
|
+
LsResponse,
|
|
34
|
+
ManifestRequest,
|
|
35
|
+
ManifestResponse,
|
|
36
|
+
ProbeRequest,
|
|
37
|
+
ProbeResponse,
|
|
38
|
+
RemoveResponse,
|
|
39
|
+
SearchResponse,
|
|
40
|
+
ServerInfo,
|
|
41
|
+
StatusResponse,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Canonical error codes -> suggested next actions. The endpoints
|
|
45
|
+
# raise HTTPException with the canonical code as `detail` for these cases; the handler
|
|
46
|
+
# below turns that into the stable {code, detail, suggestions} envelope SDKs switch on.
|
|
47
|
+
_CODE_SUGGESTIONS = {
|
|
48
|
+
"object_too_large_for_cat": ["head", "cat --range", "export"],
|
|
49
|
+
"is_directory": ["ls", "tree"],
|
|
50
|
+
"range_unsupported": ["cat --meta", "export"],
|
|
51
|
+
"density_unsupported": ["head", "cat --range"],
|
|
52
|
+
"tail_unsupported": ["head", "cat --range"],
|
|
53
|
+
"locator_not_found": ["re-search; the record may have changed"],
|
|
54
|
+
"since_unsupported": ["drop --since"],
|
|
55
|
+
"sync_already_running": ["mfs job list", "mfs job cancel JOB_ID"],
|
|
56
|
+
"connector_removing": ["wait for removal to finish, then retry"],
|
|
57
|
+
"remove_requires_connector_root": [
|
|
58
|
+
"pass the registered connector root from `mfs connector list` or `mfs connector inspect`"
|
|
59
|
+
],
|
|
60
|
+
"connector_unhealthy": ["check credentials/connectivity"],
|
|
61
|
+
"embedding_auth_failed": ["fix the embedding provider API key, then `mfs add` again"],
|
|
62
|
+
"embedding_quota_exceeded": [
|
|
63
|
+
"top up the embedding provider quota/billing, then `mfs add` again"
|
|
64
|
+
],
|
|
65
|
+
"field_missing": [
|
|
66
|
+
"fix the connector `[[objects]]` text_fields — a configured field is absent from the records"
|
|
67
|
+
],
|
|
68
|
+
"not_found": ["check the URI"],
|
|
69
|
+
"not_available": ["the connector may require an optional dependency; install its extra"],
|
|
70
|
+
"top_k_too_large": [
|
|
71
|
+
"lower --top-k: it exceeds the vector store's result limit (hybrid mode over-fetches, so its effective limit is higher than top_k)"
|
|
72
|
+
],
|
|
73
|
+
"embedding_dim_mismatch": [
|
|
74
|
+
"the embedding dimension doesn't match this collection's vectors (the collection name encodes its dim)",
|
|
75
|
+
"re-run `mfs-server setup --section embedding` to set the correct dim, or re-index into a fresh collection",
|
|
76
|
+
],
|
|
77
|
+
"validation_error": ["fix request shape"],
|
|
78
|
+
}
|
|
79
|
+
# HTTP status -> code when `detail` isn't already a canonical code (human strings).
|
|
80
|
+
_STATUS_CODE = {
|
|
81
|
+
400: "bad_request",
|
|
82
|
+
404: "not_found",
|
|
83
|
+
405: "method_not_allowed",
|
|
84
|
+
409: "conflict",
|
|
85
|
+
422: "validation_error",
|
|
86
|
+
499: "client_closed_request",
|
|
87
|
+
501: "not_available",
|
|
88
|
+
502: "connector_unhealthy",
|
|
89
|
+
}
|
|
90
|
+
_OPENAPI_ERROR_RESPONSES = {
|
|
91
|
+
"400": "Bad Request",
|
|
92
|
+
"401": "Unauthorized",
|
|
93
|
+
"404": "Not Found",
|
|
94
|
+
"405": "Method Not Allowed",
|
|
95
|
+
"422": "Validation Error",
|
|
96
|
+
"500": "Internal Server Error",
|
|
97
|
+
}
|
|
98
|
+
_OPENAPI_METHODS = {"get", "post", "put", "patch", "delete", "options", "head"}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _error_response_ref(description: str) -> dict:
|
|
102
|
+
return {
|
|
103
|
+
"description": description,
|
|
104
|
+
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _install_openapi_contract(app: FastAPI, cfg: ServerConfig) -> None:
|
|
109
|
+
"""Keep generated OpenAPI aligned with auth middleware and error handlers."""
|
|
110
|
+
|
|
111
|
+
def custom_openapi() -> dict:
|
|
112
|
+
if app.openapi_schema:
|
|
113
|
+
return app.openapi_schema
|
|
114
|
+
|
|
115
|
+
schema = get_openapi(
|
|
116
|
+
title=app.title,
|
|
117
|
+
version=app.version,
|
|
118
|
+
description=app.description,
|
|
119
|
+
routes=app.routes,
|
|
120
|
+
)
|
|
121
|
+
components = schema.setdefault("components", {})
|
|
122
|
+
schemas = components.setdefault("schemas", {})
|
|
123
|
+
schemas["ErrorResponse"] = ErrorResponse.model_json_schema(
|
|
124
|
+
ref_template="#/components/schemas/{model}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
auth_enabled = bool(cfg.auth_token)
|
|
128
|
+
if auth_enabled:
|
|
129
|
+
components.setdefault("securitySchemes", {})["BearerAuth"] = {
|
|
130
|
+
"type": "http",
|
|
131
|
+
"scheme": "bearer",
|
|
132
|
+
"bearerFormat": "opaque",
|
|
133
|
+
}
|
|
134
|
+
schema["security"] = [{"BearerAuth": []}]
|
|
135
|
+
|
|
136
|
+
for path, path_item in schema.get("paths", {}).items():
|
|
137
|
+
for method, operation in path_item.items():
|
|
138
|
+
if method not in _OPENAPI_METHODS:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
if auth_enabled:
|
|
142
|
+
operation["security"] = [] if path == "/healthz" else [{"BearerAuth": []}]
|
|
143
|
+
|
|
144
|
+
if path == "/healthz":
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
responses = operation.setdefault("responses", {})
|
|
148
|
+
for status, description in _OPENAPI_ERROR_RESPONSES.items():
|
|
149
|
+
if status == "401" and not auth_enabled:
|
|
150
|
+
continue
|
|
151
|
+
responses[status] = _error_response_ref(description)
|
|
152
|
+
|
|
153
|
+
schemas.pop("HTTPValidationError", None)
|
|
154
|
+
schemas.pop("ValidationError", None)
|
|
155
|
+
|
|
156
|
+
app.openapi_schema = schema
|
|
157
|
+
return app.openapi_schema
|
|
158
|
+
|
|
159
|
+
app.openapi = custom_openapi
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _auth_failure(headers: Headers, expected_token: str) -> tuple[int, dict] | None:
|
|
163
|
+
values = headers.getlist("authorization")
|
|
164
|
+
if len(values) > 1:
|
|
165
|
+
return (
|
|
166
|
+
400,
|
|
167
|
+
{
|
|
168
|
+
"code": "bad_request",
|
|
169
|
+
"detail": "duplicate Authorization header",
|
|
170
|
+
"suggestions": ["send exactly one Authorization: Bearer <token> header"],
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
if len(values) != 1:
|
|
174
|
+
return _unauthorized()
|
|
175
|
+
|
|
176
|
+
scheme, sep, token = values[0].partition(" ")
|
|
177
|
+
if sep != " " or scheme.lower() != "bearer" or token != expected_token:
|
|
178
|
+
return _unauthorized()
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _unauthorized() -> tuple[int, dict]:
|
|
183
|
+
return (
|
|
184
|
+
401,
|
|
185
|
+
{
|
|
186
|
+
"code": "unauthorized",
|
|
187
|
+
"detail": "missing or invalid bearer token",
|
|
188
|
+
"suggestions": ["set a profile token (Authorization: Bearer <token>)"],
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _reject_unknown_query_params(request: Request, allowed: set[str]) -> None:
|
|
194
|
+
unknown = sorted({key for key, _ in request.query_params.multi_items()} - allowed)
|
|
195
|
+
if unknown:
|
|
196
|
+
joined = ", ".join(unknown)
|
|
197
|
+
raise HTTPException(422, f"unknown query parameter(s): {joined}")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def create_app(cfg: ServerConfig | None = None, *, preload_local_models: bool = False) -> FastAPI:
|
|
201
|
+
cfg = cfg or load_server_config()
|
|
202
|
+
|
|
203
|
+
@asynccontextmanager
|
|
204
|
+
async def lifespan(app: FastAPI):
|
|
205
|
+
eng = Engine(cfg)
|
|
206
|
+
await eng.startup(preload_local_models=preload_local_models)
|
|
207
|
+
app.state.engine = eng
|
|
208
|
+
# AIO (sqlite/single-binary): there is no separate worker process, so an enqueued
|
|
209
|
+
# (--no-process) job would sit 'queued' forever. Drain it with one in-process worker.
|
|
210
|
+
# CS (postgres) deployments run a dedicated `mfs-server worker`; skip there unless
|
|
211
|
+
# explicitly turned on, so API replicas don't also do indexing work.
|
|
212
|
+
worker_task = None
|
|
213
|
+
if cfg.server.in_process_jobrunner and eng.meta.backend == "sqlite":
|
|
214
|
+
worker_task = asyncio.create_task(eng.run_worker_forever(concurrency=1))
|
|
215
|
+
try:
|
|
216
|
+
yield
|
|
217
|
+
finally:
|
|
218
|
+
if worker_task is not None:
|
|
219
|
+
worker_task.cancel()
|
|
220
|
+
try:
|
|
221
|
+
await worker_task
|
|
222
|
+
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
223
|
+
pass
|
|
224
|
+
await eng.shutdown()
|
|
225
|
+
|
|
226
|
+
app = FastAPI(
|
|
227
|
+
title="MFS",
|
|
228
|
+
version="0.4.0",
|
|
229
|
+
lifespan=lifespan,
|
|
230
|
+
description="Multi-source File-like Search — HTTP /v1 control plane.",
|
|
231
|
+
)
|
|
232
|
+
_install_openapi_contract(app, cfg)
|
|
233
|
+
|
|
234
|
+
if cfg.auth_token:
|
|
235
|
+
|
|
236
|
+
@app.middleware("http")
|
|
237
|
+
async def _auth(request: Request, call_next):
|
|
238
|
+
"""Bearer-token gate: when auth_token is configured,
|
|
239
|
+
every request — loopback included — must carry Authorization: Bearer <token>.
|
|
240
|
+
/healthz is exempt so k8s/compose liveness probes don't need the token (it
|
|
241
|
+
returns no data) — see deployments/."""
|
|
242
|
+
if request.url.path == "/healthz":
|
|
243
|
+
return await call_next(request)
|
|
244
|
+
if failure := _auth_failure(request.headers, cfg.auth_token):
|
|
245
|
+
status_code, content = failure
|
|
246
|
+
return JSONResponse(status_code=status_code, content=content)
|
|
247
|
+
return await call_next(request)
|
|
248
|
+
|
|
249
|
+
@app.exception_handler(StarletteHTTPException)
|
|
250
|
+
async def _http_exc(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
|
251
|
+
"""Wrap HTTPException into the {code, detail, suggestions} envelope.
|
|
252
|
+
When `detail` is already a canonical code, surface it as `code`; otherwise derive
|
|
253
|
+
`code` from the HTTP status and keep the human string as `detail`."""
|
|
254
|
+
detail = exc.detail if isinstance(exc.detail, str) else "error"
|
|
255
|
+
code = detail if detail in _CODE_SUGGESTIONS else _STATUS_CODE.get(exc.status_code, "error")
|
|
256
|
+
return JSONResponse(
|
|
257
|
+
status_code=exc.status_code,
|
|
258
|
+
content={
|
|
259
|
+
"code": code,
|
|
260
|
+
"detail": detail,
|
|
261
|
+
"suggestions": _CODE_SUGGESTIONS.get(code, []),
|
|
262
|
+
},
|
|
263
|
+
headers=getattr(exc, "headers", None),
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
@app.exception_handler(RequestValidationError)
|
|
267
|
+
async def _val_exc(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
268
|
+
# Build the detail from only each error's location + message — deliberately DROP
|
|
269
|
+
# pydantic's `input`/`url`/`ctx` fields. `input` echoes the submitted value (which
|
|
270
|
+
# for a config body can be a live secret) and `url` carries the server source path;
|
|
271
|
+
# `str(exc)` would leak both. Keep `detail` a plain string so the envelope shape is
|
|
272
|
+
# unchanged for SDK consumers.
|
|
273
|
+
parts = []
|
|
274
|
+
for err in exc.errors():
|
|
275
|
+
loc = ".".join(str(p) for p in err.get("loc", ()) if p != "body")
|
|
276
|
+
msg = err.get("msg", "invalid")
|
|
277
|
+
parts.append(f"{loc}: {msg}" if loc else msg)
|
|
278
|
+
return JSONResponse(
|
|
279
|
+
status_code=422,
|
|
280
|
+
content={
|
|
281
|
+
"code": "validation_error",
|
|
282
|
+
"detail": "; ".join(parts) or "validation error",
|
|
283
|
+
"suggestions": ["fix request shape"],
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
@app.exception_handler(NotImplementedError)
|
|
288
|
+
async def _not_impl_exc(_request: Request, exc: NotImplementedError) -> JSONResponse:
|
|
289
|
+
"""A requested connector scheme has no registered plugin — usually because its
|
|
290
|
+
optional extra isn't installed (registry.load_builtin skips connectors whose
|
|
291
|
+
import fails). Return a clean 501 envelope instead of a 500 + traceback, with an
|
|
292
|
+
actionable hint to install the connector's extra."""
|
|
293
|
+
detail = str(exc) or "not implemented"
|
|
294
|
+
# message shape is "no plugin for <scheme>": surface an install hint for that extra.
|
|
295
|
+
# The extra name usually equals the URI scheme, but a few differ because the SDK is
|
|
296
|
+
# shared/renamed: postgres's extra is `pg` (asyncpg), and gdrive/gmail share `google`
|
|
297
|
+
# (google-api-python-client). Map those so the hint names a command that exists
|
|
298
|
+
# (`uv sync --extra postgres` would fail — the real extra is `pg`).
|
|
299
|
+
_SCHEME_TO_EXTRA = {"postgres": "pg", "gdrive": "google", "gmail": "google"}
|
|
300
|
+
scheme = detail.rsplit(" ", 1)[-1] if detail.startswith("no plugin for ") else None
|
|
301
|
+
extra = _SCHEME_TO_EXTRA.get(scheme, scheme) if scheme else None
|
|
302
|
+
suggestions = (
|
|
303
|
+
[f"install the connector extra: uv sync --extra {extra}"]
|
|
304
|
+
if scheme
|
|
305
|
+
else _CODE_SUGGESTIONS["not_available"]
|
|
306
|
+
)
|
|
307
|
+
return JSONResponse(
|
|
308
|
+
status_code=501,
|
|
309
|
+
content={"code": "not_available", "detail": detail, "suggestions": suggestions},
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
@app.exception_handler(Exception)
|
|
313
|
+
async def _unhandled_exc(_request: Request, exc: Exception) -> JSONResponse:
|
|
314
|
+
"""Any uncaught error still returns the stable envelope so SDKs can switch on
|
|
315
|
+
`code` instead of parsing a raw 500 body."""
|
|
316
|
+
return JSONResponse(
|
|
317
|
+
status_code=500,
|
|
318
|
+
content={"code": "internal_error", "detail": str(exc), "suggestions": []},
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def eng() -> Engine:
|
|
322
|
+
return app.state.engine
|
|
323
|
+
|
|
324
|
+
@app.get(
|
|
325
|
+
"/v1/server/info", response_model=ServerInfo, operation_id="getServerInfo", tags=["server"]
|
|
326
|
+
)
|
|
327
|
+
async def server_info() -> ServerInfo:
|
|
328
|
+
import socket
|
|
329
|
+
|
|
330
|
+
return ServerInfo(version="0.4.0", machine_id=socket.gethostname(), namespace=cfg.namespace)
|
|
331
|
+
|
|
332
|
+
@app.post("/v1/add", response_model=AddResponse, operation_id="addSource", tags=["ingest"])
|
|
333
|
+
async def add(body: AddRequest) -> AddResponse:
|
|
334
|
+
try:
|
|
335
|
+
job_id = await eng().add(
|
|
336
|
+
body.target,
|
|
337
|
+
config=body.config,
|
|
338
|
+
full=body.full,
|
|
339
|
+
since=body.since,
|
|
340
|
+
process=body.process,
|
|
341
|
+
update_config=body.update,
|
|
342
|
+
)
|
|
343
|
+
except ValueError as e:
|
|
344
|
+
code = str(e)
|
|
345
|
+
status = 409 if code in ("sync_already_running", "connector_removing") else 400
|
|
346
|
+
raise HTTPException(status, code) # -> error envelope
|
|
347
|
+
return AddResponse(job_id=job_id)
|
|
348
|
+
|
|
349
|
+
@app.post(
|
|
350
|
+
"/v1/jobs/{job_id}/cancel",
|
|
351
|
+
response_model=CancelResponse,
|
|
352
|
+
operation_id="cancelJob",
|
|
353
|
+
tags=["ingest"],
|
|
354
|
+
)
|
|
355
|
+
async def cancel_job(job_id: str) -> CancelResponse:
|
|
356
|
+
ok = await eng().cancel_job(job_id)
|
|
357
|
+
return CancelResponse(job_id=job_id, cancelled=ok)
|
|
358
|
+
|
|
359
|
+
@app.post(
|
|
360
|
+
"/v1/connectors/probe",
|
|
361
|
+
response_model=ProbeResponse,
|
|
362
|
+
operation_id="probeConnector",
|
|
363
|
+
tags=["connectors"],
|
|
364
|
+
)
|
|
365
|
+
async def probe(body: ProbeRequest) -> ProbeResponse:
|
|
366
|
+
return ProbeResponse(**await eng().probe(body.target, body.config))
|
|
367
|
+
|
|
368
|
+
@app.post(
|
|
369
|
+
"/v1/connectors/estimate",
|
|
370
|
+
response_model=EstimateResponse,
|
|
371
|
+
operation_id="estimateConnector",
|
|
372
|
+
tags=["connectors"],
|
|
373
|
+
)
|
|
374
|
+
async def estimate(body: ProbeRequest) -> EstimateResponse:
|
|
375
|
+
"""Zero-billing pre-flight estimate: object/chunk/token counts via
|
|
376
|
+
metadata + a local chunker/tokenizer dry-run. No embedding API calls."""
|
|
377
|
+
try:
|
|
378
|
+
return EstimateResponse(
|
|
379
|
+
**await eng().estimate(body.target, body.config, since=body.since)
|
|
380
|
+
)
|
|
381
|
+
except ValueError as e:
|
|
382
|
+
# e.g. an unreachable / missing source root surfaces as connector_unhealthy;
|
|
383
|
+
# return the clean envelope instead of a raw 500.
|
|
384
|
+
raise HTTPException(400, str(e))
|
|
385
|
+
|
|
386
|
+
@app.get("/v1/connectors/inspect", operation_id="inspectConnector", tags=["connectors"])
|
|
387
|
+
async def inspect(target: str):
|
|
388
|
+
out = await eng().inspect(target)
|
|
389
|
+
if out is None:
|
|
390
|
+
raise HTTPException(404, "connector not found")
|
|
391
|
+
return out
|
|
392
|
+
|
|
393
|
+
@app.delete(
|
|
394
|
+
"/v1/connectors",
|
|
395
|
+
response_model=RemoveResponse,
|
|
396
|
+
operation_id="removeConnector",
|
|
397
|
+
tags=["connectors"],
|
|
398
|
+
)
|
|
399
|
+
async def remove(target: str) -> RemoveResponse:
|
|
400
|
+
try:
|
|
401
|
+
return RemoveResponse(target=target, removed=await eng().remove_connector(target))
|
|
402
|
+
except ValueError as e:
|
|
403
|
+
raise HTTPException(400, str(e))
|
|
404
|
+
|
|
405
|
+
@app.post(
|
|
406
|
+
"/v1/upload", response_model=AddResponse, operation_id="uploadSource", tags=["ingest"]
|
|
407
|
+
)
|
|
408
|
+
async def upload(request: Request, name: str, process: bool = True) -> AddResponse:
|
|
409
|
+
"""CS upload flow: POST a tar(.gz) of a tree as the raw body (?name=<label>);
|
|
410
|
+
the server stages + indexes it. For client/server without a shared filesystem."""
|
|
411
|
+
try:
|
|
412
|
+
data = await request.body()
|
|
413
|
+
except ClientDisconnect:
|
|
414
|
+
raise HTTPException(499, "client disconnected during upload")
|
|
415
|
+
if not data:
|
|
416
|
+
raise HTTPException(400, "empty upload body")
|
|
417
|
+
try:
|
|
418
|
+
out = await eng().ingest_upload(name, data, process=process)
|
|
419
|
+
except ValueError as e:
|
|
420
|
+
raise HTTPException(400, str(e))
|
|
421
|
+
return AddResponse(job_id=out["job_id"])
|
|
422
|
+
|
|
423
|
+
@app.post(
|
|
424
|
+
"/v1/files/manifest",
|
|
425
|
+
response_model=ManifestResponse,
|
|
426
|
+
operation_id="filesManifest",
|
|
427
|
+
tags=["ingest"],
|
|
428
|
+
)
|
|
429
|
+
async def files_manifest(body: ManifestRequest) -> ManifestResponse:
|
|
430
|
+
"""Manifest-diff upload step ②: stat-only manifest in, need_sha1 + deletion
|
|
431
|
+
candidates out. No bytes transferred here."""
|
|
432
|
+
out = await eng().files_manifest(
|
|
433
|
+
body.client_id, body.root, [f.model_dump() for f in body.files]
|
|
434
|
+
)
|
|
435
|
+
return ManifestResponse(**out)
|
|
436
|
+
|
|
437
|
+
@app.put(
|
|
438
|
+
"/v1/files/upload", response_model=AddResponse, operation_id="filesUpload", tags=["ingest"]
|
|
439
|
+
)
|
|
440
|
+
async def files_upload(
|
|
441
|
+
request: Request, client_id: str, root: str, process: bool = True, full: bool = False
|
|
442
|
+
) -> AddResponse:
|
|
443
|
+
"""Manifest-diff upload step ④: PUT a tar(.gz) carrying a `.mfs-meta.json`
|
|
444
|
+
member (hashes/renames/deletions) + the changed file bytes. The server applies
|
|
445
|
+
it to the staging area and triggers the file-connector sync. full=true
|
|
446
|
+
(--force-index/--force-upload) forces a re-index of the whole staged tree."""
|
|
447
|
+
try:
|
|
448
|
+
data = await request.body()
|
|
449
|
+
except ClientDisconnect:
|
|
450
|
+
raise HTTPException(499, "client disconnected during upload")
|
|
451
|
+
if not data:
|
|
452
|
+
raise HTTPException(400, "empty upload body")
|
|
453
|
+
try:
|
|
454
|
+
out = await eng().files_upload(client_id, root, data, process=process, full=full)
|
|
455
|
+
except ValueError as e:
|
|
456
|
+
raise HTTPException(400, str(e))
|
|
457
|
+
return AddResponse(job_id=out["job_id"])
|
|
458
|
+
|
|
459
|
+
@app.get("/v1/search", response_model=SearchResponse, operation_id="search", tags=["retrieval"])
|
|
460
|
+
async def search(
|
|
461
|
+
request: Request,
|
|
462
|
+
q: str,
|
|
463
|
+
path: str | None = None,
|
|
464
|
+
mode: Literal["hybrid", "semantic", "keyword"] = "hybrid",
|
|
465
|
+
top_k: int = 10,
|
|
466
|
+
collapse: bool = False,
|
|
467
|
+
kind: str | None = None,
|
|
468
|
+
) -> SearchResponse:
|
|
469
|
+
_reject_unknown_query_params(request, {"q", "path", "mode", "top_k", "collapse", "kind"})
|
|
470
|
+
connector_uri = None
|
|
471
|
+
object_prefix = None
|
|
472
|
+
if path:
|
|
473
|
+
connector_uri, object_prefix = await eng().resolve_connector_uri(path)
|
|
474
|
+
# comma-separated chunk_kinds, e.g. ?kind=body,directory_summary
|
|
475
|
+
chunk_kinds = [k.strip() for k in kind.split(",") if k.strip()] if kind else None
|
|
476
|
+
try:
|
|
477
|
+
results = await eng().search(
|
|
478
|
+
q,
|
|
479
|
+
connector_uri=connector_uri,
|
|
480
|
+
object_prefix=object_prefix,
|
|
481
|
+
mode=mode,
|
|
482
|
+
top_k=top_k,
|
|
483
|
+
chunk_kinds=chunk_kinds,
|
|
484
|
+
collapse=collapse,
|
|
485
|
+
)
|
|
486
|
+
except ValueError as e:
|
|
487
|
+
raise HTTPException(400, str(e))
|
|
488
|
+
return SearchResponse(results=results)
|
|
489
|
+
|
|
490
|
+
@app.get("/v1/grep", response_model=GrepResponse, operation_id="grep", tags=["retrieval"])
|
|
491
|
+
async def grep(pattern: str, path: str) -> GrepResponse:
|
|
492
|
+
# A scope path that resolves under no connector raises ValueError ("path not under
|
|
493
|
+
# any registered connector") from _open_path — map it to a clean 404 like ls/cat
|
|
494
|
+
# instead of letting it escape as a raw 500 (search returns [] for the same case;
|
|
495
|
+
# grep follows the browse family's explicit not_found here).
|
|
496
|
+
try:
|
|
497
|
+
return GrepResponse(results=await eng().grep(pattern, path))
|
|
498
|
+
except (FileNotFoundError, NotADirectoryError, ValueError) as e:
|
|
499
|
+
raise HTTPException(404, str(e))
|
|
500
|
+
|
|
501
|
+
@app.get("/v1/ls", response_model=LsResponse, operation_id="ls", tags=["browse"])
|
|
502
|
+
async def ls(path: str) -> LsResponse:
|
|
503
|
+
try:
|
|
504
|
+
return LsResponse(**await eng().ls(path))
|
|
505
|
+
except (FileNotFoundError, NotADirectoryError, ValueError) as e:
|
|
506
|
+
raise HTTPException(404, str(e))
|
|
507
|
+
|
|
508
|
+
@app.get(
|
|
509
|
+
"/v1/cat",
|
|
510
|
+
operation_id="cat",
|
|
511
|
+
tags=["browse"],
|
|
512
|
+
response_model=None,
|
|
513
|
+
responses={200: {"model": CatResponse}},
|
|
514
|
+
)
|
|
515
|
+
async def cat(
|
|
516
|
+
path: str,
|
|
517
|
+
range: str | None = None,
|
|
518
|
+
meta: bool = False,
|
|
519
|
+
density: str | None = None,
|
|
520
|
+
locator: str | None = None,
|
|
521
|
+
):
|
|
522
|
+
import json as _json
|
|
523
|
+
|
|
524
|
+
rg = None
|
|
525
|
+
if range:
|
|
526
|
+
# External --range is 1-based half-open [start, end) — matches
|
|
527
|
+
# locator.lines, head/tail line counts, and how humans cite ranges
|
|
528
|
+
# ("lines 100 to 200"). Require an explicit colon so a bare "100"
|
|
529
|
+
# doesn't silently degrade to a single line or an open end. Convert
|
|
530
|
+
# to 0-based half-open here; engine.cat + plugin.read stay 0-based
|
|
531
|
+
# internally.
|
|
532
|
+
if ":" not in range:
|
|
533
|
+
raise HTTPException(
|
|
534
|
+
400, "invalid range: expected start:end (1-based, end-exclusive)"
|
|
535
|
+
)
|
|
536
|
+
a, _, b = range.partition(":")
|
|
537
|
+
try:
|
|
538
|
+
start_1 = int(a) if a.strip() else 1
|
|
539
|
+
end_1 = int(b) if b.strip() else (2**63 - 1)
|
|
540
|
+
except ValueError:
|
|
541
|
+
raise HTTPException(400, "invalid range")
|
|
542
|
+
if start_1 < 1:
|
|
543
|
+
raise HTTPException(400, "invalid range: start must be >= 1")
|
|
544
|
+
if end_1 < start_1:
|
|
545
|
+
raise HTTPException(400, "invalid range: end must be >= start")
|
|
546
|
+
rg = (start_1 - 1, end_1 - 1)
|
|
547
|
+
loc = None
|
|
548
|
+
if locator:
|
|
549
|
+
try:
|
|
550
|
+
loc = _json.loads(locator)
|
|
551
|
+
except ValueError:
|
|
552
|
+
raise HTTPException(400, "invalid locator JSON")
|
|
553
|
+
try:
|
|
554
|
+
out = await eng().cat(path, range=rg, meta=meta, density=density, locator=loc)
|
|
555
|
+
except IsADirectoryError:
|
|
556
|
+
raise HTTPException(400, "is_directory")
|
|
557
|
+
except ValueError as e:
|
|
558
|
+
code = str(e)
|
|
559
|
+
if code in ("density_unsupported", "range_unsupported", "object_too_large_for_cat"):
|
|
560
|
+
raise HTTPException(400, code)
|
|
561
|
+
if code == "locator_not_found":
|
|
562
|
+
raise HTTPException(404, "locator_not_found")
|
|
563
|
+
raise HTTPException(404, code)
|
|
564
|
+
except FileNotFoundError as e:
|
|
565
|
+
raise HTTPException(404, str(e))
|
|
566
|
+
if meta:
|
|
567
|
+
return CatMeta(**out) if isinstance(out, dict) else out
|
|
568
|
+
if isinstance(out, dict): # locator hit -> {source, locator, content}
|
|
569
|
+
return CatResponse(source=out.get("source", path), content=out.get("content", ""))
|
|
570
|
+
return CatResponse(source=path, content=out)
|
|
571
|
+
|
|
572
|
+
async def _read_op(fn, path: str):
|
|
573
|
+
"""Shared error mapping for head/tail/export."""
|
|
574
|
+
try:
|
|
575
|
+
return await fn(path)
|
|
576
|
+
except IsADirectoryError:
|
|
577
|
+
raise HTTPException(400, "is_directory")
|
|
578
|
+
except FileNotFoundError as e:
|
|
579
|
+
raise HTTPException(404, str(e))
|
|
580
|
+
except ValueError as e:
|
|
581
|
+
raise HTTPException(400, str(e))
|
|
582
|
+
|
|
583
|
+
@app.get("/v1/head", response_model=CatResponse, operation_id="head", tags=["browse"])
|
|
584
|
+
async def head(path: str, n: int = 20) -> CatResponse:
|
|
585
|
+
return CatResponse(source=path, content=await _read_op(lambda p: eng().head(p, n), path))
|
|
586
|
+
|
|
587
|
+
@app.get("/v1/tail", response_model=CatResponse, operation_id="tail", tags=["browse"])
|
|
588
|
+
async def tail(path: str, n: int = 20) -> CatResponse:
|
|
589
|
+
return CatResponse(source=path, content=await _read_op(lambda p: eng().tail(p, n), path))
|
|
590
|
+
|
|
591
|
+
@app.get("/v1/export", response_model=CatResponse, operation_id="export", tags=["browse"])
|
|
592
|
+
async def export(path: str) -> CatResponse:
|
|
593
|
+
"""Full object content for `mfs export`. Honest about completeness:
|
|
594
|
+
each connector's own row cap still applies (postgres `max_read_rows`,
|
|
595
|
+
BigQuery `max_read_rows`, etc.), so structured objects above that
|
|
596
|
+
threshold return `partial=true`. The bare-cat size guard
|
|
597
|
+
(object_too_large_for_cat) does NOT apply — export is the escape
|
|
598
|
+
hatch for that — but true streaming export is still TODO."""
|
|
599
|
+
text, partial = await _read_op(eng().export, path)
|
|
600
|
+
return CatResponse(source=path, content=text, partial=partial)
|
|
601
|
+
|
|
602
|
+
@app.get("/healthz", tags=["server"])
|
|
603
|
+
async def healthz() -> dict:
|
|
604
|
+
"""Unauthenticated liveness/readiness probe (no sensitive data); used by the
|
|
605
|
+
compose healthcheck and Helm probes so they work even with auth enabled."""
|
|
606
|
+
return {"status": "ok"}
|
|
607
|
+
|
|
608
|
+
@app.get("/v1/status", response_model=StatusResponse, operation_id="status", tags=["server"])
|
|
609
|
+
async def status() -> StatusResponse:
|
|
610
|
+
# Per-connector object/chunk counts come from the metadata `objects` table
|
|
611
|
+
# (objects.chunk_count is already maintained per object). One grouped LEFT JOIN —
|
|
612
|
+
# connectors with nothing indexed yet still report 0/0 — so status surfaces store
|
|
613
|
+
# state without a full Milvus scan.
|
|
614
|
+
conns = await eng().meta.fetchall(
|
|
615
|
+
"SELECT c.root_uri AS root_uri, c.type AS type, c.status AS status, "
|
|
616
|
+
" COUNT(o.object_uri) AS object_count, "
|
|
617
|
+
" COALESCE(SUM(o.chunk_count), 0) AS chunk_count "
|
|
618
|
+
"FROM connectors c LEFT JOIN objects o ON o.connector_id = c.id "
|
|
619
|
+
"WHERE c.namespace_id=? GROUP BY c.id, c.root_uri, c.type, c.status",
|
|
620
|
+
(cfg.namespace,),
|
|
621
|
+
)
|
|
622
|
+
jobs = await eng().meta.fetchall(
|
|
623
|
+
"SELECT status, count(*) AS n FROM connector_jobs GROUP BY status"
|
|
624
|
+
)
|
|
625
|
+
return StatusResponse(
|
|
626
|
+
connectors=[dict(c) for c in conns], jobs={j["status"]: j["n"] for j in jobs}
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
@app.get("/v1/jobs", response_model=list[JobResponse], operation_id="listJobs", tags=["ingest"])
|
|
630
|
+
async def list_jobs(limit: int = 20) -> list[JobResponse]:
|
|
631
|
+
rows = await eng().meta.fetchall(
|
|
632
|
+
"SELECT * FROM connector_jobs ORDER BY started_at DESC LIMIT ?", (limit,)
|
|
633
|
+
)
|
|
634
|
+
return [JobResponse(**{k: dict(r).get(k) for k in JobResponse.model_fields}) for r in rows]
|
|
635
|
+
|
|
636
|
+
@app.get(
|
|
637
|
+
"/v1/jobs/{job_id}", response_model=JobResponse, operation_id="getJob", tags=["ingest"]
|
|
638
|
+
)
|
|
639
|
+
async def job(job_id: str) -> JobResponse:
|
|
640
|
+
row = await eng().meta.fetchone("SELECT * FROM connector_jobs WHERE id=?", (job_id,))
|
|
641
|
+
if not row:
|
|
642
|
+
raise HTTPException(404, "job not found")
|
|
643
|
+
return JobResponse(**{k: dict(row).get(k) for k in JobResponse.model_fields})
|
|
644
|
+
|
|
645
|
+
return app
|