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.
Files changed (110) hide show
  1. mfs_server/__init__.py +0 -0
  2. mfs_server/api/__init__.py +0 -0
  3. mfs_server/api/app.py +645 -0
  4. mfs_server/api/models.py +224 -0
  5. mfs_server/common/__init__.py +0 -0
  6. mfs_server/common/accel.py +125 -0
  7. mfs_server/common/converter.py +60 -0
  8. mfs_server/common/embedding.py +132 -0
  9. mfs_server/common/embeddings/__init__.py +85 -0
  10. mfs_server/common/embeddings/gemini.py +79 -0
  11. mfs_server/common/embeddings/local.py +79 -0
  12. mfs_server/common/embeddings/ollama.py +47 -0
  13. mfs_server/common/embeddings/onnx.py +196 -0
  14. mfs_server/common/embeddings/openai.py +85 -0
  15. mfs_server/common/embeddings/utils.py +33 -0
  16. mfs_server/common/embeddings/voyage.py +69 -0
  17. mfs_server/common/llm/__init__.py +77 -0
  18. mfs_server/common/llm/anthropic.py +72 -0
  19. mfs_server/common/llm/gemini.py +62 -0
  20. mfs_server/common/llm/openai.py +64 -0
  21. mfs_server/common/retrieval.py +125 -0
  22. mfs_server/common/summary.py +93 -0
  23. mfs_server/common/vlm.py +91 -0
  24. mfs_server/config.py +530 -0
  25. mfs_server/connectors/__init__.py +0 -0
  26. mfs_server/connectors/base.py +575 -0
  27. mfs_server/connectors/bigquery/__init__.py +4 -0
  28. mfs_server/connectors/bigquery/plugin.py +239 -0
  29. mfs_server/connectors/discord/__init__.py +4 -0
  30. mfs_server/connectors/discord/plugin.py +255 -0
  31. mfs_server/connectors/feishu/__init__.py +4 -0
  32. mfs_server/connectors/feishu/auth_login.py +132 -0
  33. mfs_server/connectors/feishu/oauth.py +207 -0
  34. mfs_server/connectors/feishu/plugin.py +659 -0
  35. mfs_server/connectors/file/__init__.py +4 -0
  36. mfs_server/connectors/file/plugin.py +589 -0
  37. mfs_server/connectors/gdrive/__init__.py +4 -0
  38. mfs_server/connectors/gdrive/plugin.py +251 -0
  39. mfs_server/connectors/github/__init__.py +4 -0
  40. mfs_server/connectors/github/plugin.py +327 -0
  41. mfs_server/connectors/gmail/__init__.py +4 -0
  42. mfs_server/connectors/gmail/plugin.py +214 -0
  43. mfs_server/connectors/hubspot/__init__.py +4 -0
  44. mfs_server/connectors/hubspot/plugin.py +181 -0
  45. mfs_server/connectors/jira/__init__.py +4 -0
  46. mfs_server/connectors/jira/plugin.py +210 -0
  47. mfs_server/connectors/linear/__init__.py +4 -0
  48. mfs_server/connectors/linear/plugin.py +196 -0
  49. mfs_server/connectors/mongo/__init__.py +4 -0
  50. mfs_server/connectors/mongo/plugin.py +217 -0
  51. mfs_server/connectors/mysql/__init__.py +4 -0
  52. mfs_server/connectors/mysql/plugin.py +276 -0
  53. mfs_server/connectors/notion/__init__.py +4 -0
  54. mfs_server/connectors/notion/plugin.py +239 -0
  55. mfs_server/connectors/postgres/__init__.py +4 -0
  56. mfs_server/connectors/postgres/plugin.py +303 -0
  57. mfs_server/connectors/registry.py +56 -0
  58. mfs_server/connectors/s3/__init__.py +4 -0
  59. mfs_server/connectors/s3/plugin.py +155 -0
  60. mfs_server/connectors/slack/__init__.py +4 -0
  61. mfs_server/connectors/slack/plugin.py +229 -0
  62. mfs_server/connectors/snowflake/__init__.py +4 -0
  63. mfs_server/connectors/snowflake/plugin.py +356 -0
  64. mfs_server/connectors/web/__init__.py +4 -0
  65. mfs_server/connectors/web/plugin.py +239 -0
  66. mfs_server/connectors/zendesk/__init__.py +4 -0
  67. mfs_server/connectors/zendesk/plugin.py +200 -0
  68. mfs_server/engine/__init__.py +0 -0
  69. mfs_server/engine/adapters.py +135 -0
  70. mfs_server/engine/engine.py +2984 -0
  71. mfs_server/engine/job_lane/__init__.py +318 -0
  72. mfs_server/engine/job_lane/queue.py +53 -0
  73. mfs_server/engine/job_lane/tree.py +105 -0
  74. mfs_server/engine/job_lane/worker.py +116 -0
  75. mfs_server/engine/job_watcher.py +120 -0
  76. mfs_server/engine/pipeline.py +409 -0
  77. mfs_server/engine/producers/__init__.py +79 -0
  78. mfs_server/engine/producers/base.py +195 -0
  79. mfs_server/engine/producers/image.py +52 -0
  80. mfs_server/engine/producers/message_stream.py +132 -0
  81. mfs_server/engine/producers/record_collection.py +125 -0
  82. mfs_server/engine/producers/render.py +156 -0
  83. mfs_server/engine/producers/table_schema.py +70 -0
  84. mfs_server/engine/producers/text.py +140 -0
  85. mfs_server/engine/state.py +79 -0
  86. mfs_server/processors/__init__.py +0 -0
  87. mfs_server/processors/text.py +75 -0
  88. mfs_server/server/__init__.py +0 -0
  89. mfs_server/server/__main__.py +155 -0
  90. mfs_server/server/connector_schemas.py +436 -0
  91. mfs_server/server/connector_wizard.py +802 -0
  92. mfs_server/server/setup_wizard.py +682 -0
  93. mfs_server/server/wizard_ui.py +223 -0
  94. mfs_server/storage/__init__.py +0 -0
  95. mfs_server/storage/artifact_cache.py +79 -0
  96. mfs_server/storage/file_state.py +103 -0
  97. mfs_server/storage/ids.py +64 -0
  98. mfs_server/storage/metadata/__init__.py +44 -0
  99. mfs_server/storage/metadata/base.py +234 -0
  100. mfs_server/storage/metadata/postgres.py +70 -0
  101. mfs_server/storage/metadata/sqlite.py +72 -0
  102. mfs_server/storage/milvus.py +468 -0
  103. mfs_server/storage/transformation_cache/__init__.py +45 -0
  104. mfs_server/storage/transformation_cache/base.py +137 -0
  105. mfs_server/storage/transformation_cache/postgres.py +86 -0
  106. mfs_server/storage/transformation_cache/sqlite.py +80 -0
  107. mfs_server-0.4.0.dist-info/METADATA +159 -0
  108. mfs_server-0.4.0.dist-info/RECORD +110 -0
  109. mfs_server-0.4.0.dist-info/WHEEL +4 -0
  110. 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