10xgraph-api 0.7.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.
- 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
- 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
- 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
- 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
- 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
- 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
- agentflow_cli/__init__.py +69 -0
- tenxgraph_api/__init__.py +23 -0
- tenxgraph_api/cli/__init__.py +6 -0
- tenxgraph_api/cli/capabilities.py +127 -0
- tenxgraph_api/cli/commands/__init__.py +49 -0
- tenxgraph_api/cli/commands/api.py +262 -0
- tenxgraph_api/cli/commands/audit.py +213 -0
- tenxgraph_api/cli/commands/build.py +305 -0
- tenxgraph_api/cli/commands/config.py +71 -0
- tenxgraph_api/cli/commands/demo.py +93 -0
- tenxgraph_api/cli/commands/eval.py +909 -0
- tenxgraph_api/cli/commands/init.py +531 -0
- tenxgraph_api/cli/commands/skills.py +532 -0
- tenxgraph_api/cli/commands/test.py +113 -0
- tenxgraph_api/cli/commands/version.py +47 -0
- tenxgraph_api/cli/config_editor/__init__.py +15 -0
- tenxgraph_api/cli/config_editor/schema.py +566 -0
- tenxgraph_api/cli/config_editor/server.py +240 -0
- tenxgraph_api/cli/config_editor/static/app.css +2 -0
- tenxgraph_api/cli/config_editor/static/app.js +3 -0
- tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
- tenxgraph_api/cli/config_editor/static/index.html +20 -0
- tenxgraph_api/cli/config_editor/store.py +120 -0
- tenxgraph_api/cli/config_editor/validation.py +377 -0
- tenxgraph_api/cli/constants.py +112 -0
- tenxgraph_api/cli/context.py +46 -0
- tenxgraph_api/cli/core/__init__.py +0 -0
- tenxgraph_api/cli/core/animations.py +550 -0
- tenxgraph_api/cli/core/config.py +279 -0
- tenxgraph_api/cli/core/output.py +625 -0
- tenxgraph_api/cli/core/prompts.py +220 -0
- tenxgraph_api/cli/core/screen.py +287 -0
- tenxgraph_api/cli/core/steps.py +621 -0
- tenxgraph_api/cli/core/theme.py +242 -0
- tenxgraph_api/cli/core/validation.py +251 -0
- tenxgraph_api/cli/exceptions.py +129 -0
- tenxgraph_api/cli/logger.py +84 -0
- tenxgraph_api/cli/main.py +936 -0
- tenxgraph_api/cli/templates/__init__.py +0 -0
- tenxgraph_api/cli/templates/defaults.py +844 -0
- tenxgraph_api/cli/templates/dev/.env.example +1 -0
- tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
- tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
- tenxgraph_api/cli/templates/prod/.env.example +75 -0
- tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
- tenxgraph_api/cli/templates/prod/.python-version +1 -0
- tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
- tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
- tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
- tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
- tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
- tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
- tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
- tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
- tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
- tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
- tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
- tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
- tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
- tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
- tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
- tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
- tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
- tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
- tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
- tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
- tenxgraph_api/py.typed +0 -0
- tenxgraph_api/src/__init__.py +0 -0
- tenxgraph_api/src/app/__init__.py +13 -0
- tenxgraph_api/src/app/core/__init__.py +15 -0
- tenxgraph_api/src/app/core/auth/__init__.py +0 -0
- tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
- tenxgraph_api/src/app/core/auth/authorization.py +379 -0
- tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
- tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
- tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
- tenxgraph_api/src/app/core/auth/permissions.py +354 -0
- tenxgraph_api/src/app/core/auth/request_config.py +130 -0
- tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
- tenxgraph_api/src/app/core/config/__init__.py +0 -0
- tenxgraph_api/src/app/core/config/graph_config.py +532 -0
- tenxgraph_api/src/app/core/config/media_settings.py +72 -0
- tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
- tenxgraph_api/src/app/core/config/settings.py +202 -0
- tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
- tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
- tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
- tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
- tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
- tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
- tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
- tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
- tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
- tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
- tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
- tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
- tenxgraph_api/src/app/core/utils/__init__.py +14 -0
- tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
- tenxgraph_api/src/app/loader.py +516 -0
- tenxgraph_api/src/app/main.py +180 -0
- tenxgraph_api/src/app/routers/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
- tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
- tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
- tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
- tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
- tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
- tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
- tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
- tenxgraph_api/src/app/routers/evals/router.py +38 -0
- tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
- tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
- tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
- tenxgraph_api/src/app/routers/graph/router.py +821 -0
- tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
- tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
- tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
- tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
- tenxgraph_api/src/app/routers/media/__init__.py +506 -0
- tenxgraph_api/src/app/routers/media/router.py +242 -0
- tenxgraph_api/src/app/routers/media/schemas.py +39 -0
- tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
- tenxgraph_api/src/app/routers/ping/router.py +28 -0
- tenxgraph_api/src/app/routers/setup_router.py +60 -0
- tenxgraph_api/src/app/routers/store/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/router.py +200 -0
- tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
- tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
- tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
- tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
- tenxgraph_api/src/app/tasks/__init__.py +0 -0
- tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
- tenxgraph_api/src/app/utils/__init__.py +12 -0
- tenxgraph_api/src/app/utils/callable_helper.py +26 -0
- tenxgraph_api/src/app/utils/media/__init__.py +15 -0
- tenxgraph_api/src/app/utils/media/extractor.py +172 -0
- tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
- tenxgraph_api/src/app/utils/parse_output.py +17 -0
- tenxgraph_api/src/app/utils/response_helper.py +129 -0
- tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
- tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
- tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
- tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
- tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
- tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
- tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
- tenxgraph_api/src/app/worker.py +91 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""File upload, retrieval, and multimodal config endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile
|
|
9
|
+
from fastapi.logger import logger
|
|
10
|
+
from fastapi.responses import Response
|
|
11
|
+
from injectq.integrations import InjectAPI
|
|
12
|
+
|
|
13
|
+
from tenxgraph_api.src.app.core.auth.permissions import RequirePermission
|
|
14
|
+
from tenxgraph_api.src.app.core.config.media_settings import get_media_settings
|
|
15
|
+
from tenxgraph_api.src.app.routers.media import MediaService
|
|
16
|
+
from tenxgraph_api.src.app.routers.media.schemas import (
|
|
17
|
+
FileAccessUrlResponse,
|
|
18
|
+
FileInfoResponse,
|
|
19
|
+
FileUploadResponse,
|
|
20
|
+
MultimodalConfigResponse,
|
|
21
|
+
)
|
|
22
|
+
from tenxgraph_api.src.app.utils import success_response
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
router = APIRouter(tags=["Files"])
|
|
26
|
+
|
|
27
|
+
# Read uploads in 1 MiB chunks so a size-cap breach is caught before the whole body
|
|
28
|
+
# is buffered in memory.
|
|
29
|
+
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
# Stored types come from the uploader. Only types a browser displays without running
|
|
32
|
+
# script are served inline; the rest are downloads.
|
|
33
|
+
_INLINE_TYPES = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"image/png",
|
|
36
|
+
"image/jpeg",
|
|
37
|
+
"image/gif",
|
|
38
|
+
"image/webp",
|
|
39
|
+
"image/avif",
|
|
40
|
+
"image/bmp",
|
|
41
|
+
"application/pdf",
|
|
42
|
+
"text/plain",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
_INLINE_PREFIXES = ("audio/", "video/")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_active_type(mime: str) -> bool:
|
|
49
|
+
"""Whether a browser would run this type as a page or script, even from a blob: URL.
|
|
50
|
+
|
|
51
|
+
Such files are served as opaque bytes, so neither a navigation nor ``response.blob()``
|
|
52
|
+
keeps the active type.
|
|
53
|
+
"""
|
|
54
|
+
subtype = mime.partition("/")[2]
|
|
55
|
+
return (
|
|
56
|
+
subtype in {"html", "xml", "xhtml+xml", "svg+xml"}
|
|
57
|
+
or subtype.endswith("+xml")
|
|
58
|
+
or "javascript" in subtype
|
|
59
|
+
or "ecmascript" in subtype
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _download_response(file_id: str, data: bytes, mime_type: str | None) -> Response:
|
|
64
|
+
"""Serve stored bytes so an uploaded HTML or SVG file cannot run script on this origin."""
|
|
65
|
+
mime = (mime_type or "").split(";", 1)[0].strip().lower()
|
|
66
|
+
if mime in _INLINE_TYPES or mime.startswith(_INLINE_PREFIXES):
|
|
67
|
+
disposition = "inline"
|
|
68
|
+
else:
|
|
69
|
+
disposition = f'attachment; filename="{file_id}"'
|
|
70
|
+
if not mime or _is_active_type(mime):
|
|
71
|
+
mime = "application/octet-stream"
|
|
72
|
+
headers = {"Content-Disposition": disposition, "X-Content-Type-Options": "nosniff"}
|
|
73
|
+
if mime != "application/pdf": # browsers will not render a PDF under a sandbox CSP
|
|
74
|
+
headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
|
|
75
|
+
return Response(content=data, media_type=mime or "application/octet-stream", headers=headers)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# 4.1 POST /v1/files/upload
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
@router.post(
|
|
82
|
+
"/v1/files/upload",
|
|
83
|
+
summary="Upload a file (image, audio, document)",
|
|
84
|
+
response_model=FileUploadResponse,
|
|
85
|
+
description=(
|
|
86
|
+
"Accepts a multipart file upload. Stores binary in the configured "
|
|
87
|
+
"MediaStore and optionally extracts text for documents."
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
async def upload_file(
|
|
91
|
+
request: Request,
|
|
92
|
+
file: UploadFile,
|
|
93
|
+
service: MediaService = InjectAPI(MediaService),
|
|
94
|
+
user: dict[str, Any] = Depends(RequirePermission("files", "upload")),
|
|
95
|
+
):
|
|
96
|
+
if file.filename is None:
|
|
97
|
+
raise HTTPException(status_code=400, detail="filename is required")
|
|
98
|
+
|
|
99
|
+
media_settings = get_media_settings()
|
|
100
|
+
mime = file.content_type or mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
|
|
101
|
+
|
|
102
|
+
if not media_settings.is_content_type_allowed(mime):
|
|
103
|
+
raise HTTPException(status_code=415, detail=f"Content type not allowed: {mime}")
|
|
104
|
+
|
|
105
|
+
# Read with a running size cap instead of ``await file.read()``. A chunked upload
|
|
106
|
+
# (no Content-Length) slips past RequestSizeLimitMiddleware, so a naive full read
|
|
107
|
+
# would let a multi-GB body OOM-kill the worker. Stop as soon as we exceed the
|
|
108
|
+
# limit and reject, never materializing more than max_size + one chunk.
|
|
109
|
+
max_bytes = int(media_settings.MEDIA_MAX_SIZE_MB * 1024 * 1024)
|
|
110
|
+
chunks: list[bytes] = []
|
|
111
|
+
total = 0
|
|
112
|
+
while True:
|
|
113
|
+
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
|
|
114
|
+
if not chunk:
|
|
115
|
+
break
|
|
116
|
+
total += len(chunk)
|
|
117
|
+
if total > max_bytes:
|
|
118
|
+
raise HTTPException(
|
|
119
|
+
status_code=413,
|
|
120
|
+
detail=f"File exceeds maximum {media_settings.MEDIA_MAX_SIZE_MB}MB",
|
|
121
|
+
)
|
|
122
|
+
chunks.append(chunk)
|
|
123
|
+
data = b"".join(chunks)
|
|
124
|
+
if not data:
|
|
125
|
+
raise HTTPException(status_code=400, detail="Empty file")
|
|
126
|
+
|
|
127
|
+
logger.info("File upload: %s (%s, %d bytes)", file.filename, mime, len(data))
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
# Record the uploader as the owner, so the file cannot later be read by a
|
|
131
|
+
# different user who knows the id.
|
|
132
|
+
result = await service.upload_file(data, file.filename, mime, user_id=user.get("user_id"))
|
|
133
|
+
except ValueError as exc:
|
|
134
|
+
raise HTTPException(status_code=413, detail=str(exc))
|
|
135
|
+
|
|
136
|
+
return success_response(FileUploadResponse(**result), request)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
# 4.2 GET /v1/files/{file_id} — raw binary download
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
@router.get(
|
|
143
|
+
"/v1/files/{file_id}",
|
|
144
|
+
summary="Retrieve file binary",
|
|
145
|
+
description="Returns the raw binary with the correct Content-Type.",
|
|
146
|
+
)
|
|
147
|
+
async def get_file(
|
|
148
|
+
file_id: str,
|
|
149
|
+
service: MediaService = InjectAPI(MediaService),
|
|
150
|
+
user: dict[str, Any] = Depends(RequirePermission("files", "read")),
|
|
151
|
+
):
|
|
152
|
+
try:
|
|
153
|
+
# Authentication alone is not enough: without this, ANY authenticated user
|
|
154
|
+
# who knew a file_id could download another user's file.
|
|
155
|
+
await service.ensure_can_access(file_id, user.get("user_id"))
|
|
156
|
+
data, mime_type = await service.get_file(file_id, user.get("user_id"))
|
|
157
|
+
except KeyError:
|
|
158
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
159
|
+
except PermissionError:
|
|
160
|
+
# 404, not 403: do not confirm that someone else's file_id exists.
|
|
161
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
162
|
+
|
|
163
|
+
return _download_response(file_id, data, mime_type)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# 4.2 GET /v1/files/{file_id}/info — metadata only
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
@router.get(
|
|
170
|
+
"/v1/files/{file_id}/info",
|
|
171
|
+
summary="Retrieve file metadata",
|
|
172
|
+
response_model=FileInfoResponse,
|
|
173
|
+
)
|
|
174
|
+
async def get_file_info(
|
|
175
|
+
request: Request,
|
|
176
|
+
file_id: str,
|
|
177
|
+
service: MediaService = InjectAPI(MediaService),
|
|
178
|
+
user: dict[str, Any] = Depends(RequirePermission("files", "read")),
|
|
179
|
+
):
|
|
180
|
+
try:
|
|
181
|
+
await service.ensure_can_access(file_id, user.get("user_id"))
|
|
182
|
+
info = await service.get_file_info(file_id, user.get("user_id"))
|
|
183
|
+
except KeyError:
|
|
184
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
185
|
+
except PermissionError:
|
|
186
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
187
|
+
|
|
188
|
+
return success_response(FileInfoResponse(**info), request)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.get(
|
|
192
|
+
"/v1/files/{file_id}/url",
|
|
193
|
+
summary="Retrieve direct access URL",
|
|
194
|
+
response_model=FileAccessUrlResponse,
|
|
195
|
+
)
|
|
196
|
+
async def get_file_access_url(
|
|
197
|
+
request: Request,
|
|
198
|
+
file_id: str,
|
|
199
|
+
service: MediaService = InjectAPI(MediaService),
|
|
200
|
+
user: dict[str, Any] = Depends(RequirePermission("files", "read")),
|
|
201
|
+
):
|
|
202
|
+
try:
|
|
203
|
+
# Especially important here: this hands out a *signed direct URL*, which
|
|
204
|
+
# bypasses the API entirely. Handing one to a non-owner would leak the file
|
|
205
|
+
# even after this check was added elsewhere.
|
|
206
|
+
await service.ensure_can_access(file_id, user.get("user_id"))
|
|
207
|
+
info = await service.get_file_info(file_id)
|
|
208
|
+
except KeyError:
|
|
209
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
210
|
+
except PermissionError:
|
|
211
|
+
raise HTTPException(status_code=404, detail="File not found")
|
|
212
|
+
|
|
213
|
+
payload = FileAccessUrlResponse(
|
|
214
|
+
file_id=file_id,
|
|
215
|
+
url=info["direct_url"] or f"/v1/files/{file_id}",
|
|
216
|
+
expires_at=info.get("direct_url_expires_at"),
|
|
217
|
+
mime_type=info["mime_type"],
|
|
218
|
+
)
|
|
219
|
+
return success_response(payload, request)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ------------------------------------------------------------------
|
|
223
|
+
# 4.4 GET /v1/config/multimodal — read current multimodal config
|
|
224
|
+
# ------------------------------------------------------------------
|
|
225
|
+
@router.get(
|
|
226
|
+
"/v1/config/multimodal",
|
|
227
|
+
summary="Get multimodal configuration",
|
|
228
|
+
response_model=MultimodalConfigResponse,
|
|
229
|
+
)
|
|
230
|
+
async def get_multimodal_config(
|
|
231
|
+
request: Request,
|
|
232
|
+
user: dict[str, Any] = Depends(RequirePermission("config", "read")),
|
|
233
|
+
):
|
|
234
|
+
settings = get_media_settings()
|
|
235
|
+
return success_response(
|
|
236
|
+
MultimodalConfigResponse(
|
|
237
|
+
media_storage_type=settings.MEDIA_STORAGE_TYPE.value,
|
|
238
|
+
media_max_size_mb=settings.MEDIA_MAX_SIZE_MB,
|
|
239
|
+
document_handling=settings.DOCUMENT_HANDLING,
|
|
240
|
+
),
|
|
241
|
+
request,
|
|
242
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Schemas for the media / file upload endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FileUploadResponse(BaseModel):
|
|
9
|
+
file_id: str = Field(..., description="Opaque storage key")
|
|
10
|
+
mime_type: str
|
|
11
|
+
size_bytes: int
|
|
12
|
+
filename: str
|
|
13
|
+
extracted_text: str | None = None
|
|
14
|
+
url: str = Field(..., description="Relative retrieval URL")
|
|
15
|
+
direct_url: str | None = None
|
|
16
|
+
direct_url_expires_at: int | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FileInfoResponse(BaseModel):
|
|
20
|
+
file_id: str
|
|
21
|
+
mime_type: str
|
|
22
|
+
size_bytes: int
|
|
23
|
+
filename: str | None = None
|
|
24
|
+
extracted_text: str | None = None
|
|
25
|
+
direct_url: str | None = None
|
|
26
|
+
direct_url_expires_at: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FileAccessUrlResponse(BaseModel):
|
|
30
|
+
file_id: str
|
|
31
|
+
url: str
|
|
32
|
+
expires_at: int | None = None
|
|
33
|
+
mime_type: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class MultimodalConfigResponse(BaseModel):
|
|
37
|
+
media_storage_type: str
|
|
38
|
+
media_max_size_mb: float
|
|
39
|
+
document_handling: str
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from fastapi import APIRouter, Request
|
|
2
|
+
|
|
3
|
+
from tenxgraph_api.src.app.utils.response_helper import success_response
|
|
4
|
+
from tenxgraph_api.src.app.utils.swagger_helper import generate_swagger_responses
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
router = APIRouter(
|
|
8
|
+
tags=["Ping"],
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@router.get(
|
|
13
|
+
"/ping",
|
|
14
|
+
summary="Ping the server",
|
|
15
|
+
responses=generate_swagger_responses(str), # type: ignore
|
|
16
|
+
description="Check the server's health",
|
|
17
|
+
openapi_extra={},
|
|
18
|
+
)
|
|
19
|
+
async def ping_server(
|
|
20
|
+
request: Request,
|
|
21
|
+
):
|
|
22
|
+
"""
|
|
23
|
+
Invoke the graph with the provided input and return the final result.
|
|
24
|
+
"""
|
|
25
|
+
return success_response(
|
|
26
|
+
"pong",
|
|
27
|
+
request,
|
|
28
|
+
)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from fastapi import APIRouter, FastAPI
|
|
2
|
+
|
|
3
|
+
from .checkpointer.router import router as checkpointer_router
|
|
4
|
+
from .evals import router as evals_router
|
|
5
|
+
from .graph import router as graph_router
|
|
6
|
+
from .media.router import router as media_router
|
|
7
|
+
from .ping.router import router as ping_router
|
|
8
|
+
from .store import router as store_router
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
AG_UI_INSTALL_HINT = 'pip install "10xgraph-api[ag-ui]"'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _import_ag_ui_router() -> APIRouter:
|
|
15
|
+
from .ag_ui import router
|
|
16
|
+
|
|
17
|
+
return router
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ag_ui_router() -> APIRouter:
|
|
21
|
+
"""Import the AG-UI router, which needs the optional ``ag-ui-protocol`` package.
|
|
22
|
+
|
|
23
|
+
Imported only when ``ag_ui.enabled`` is set, so servers that leave it off never need the
|
|
24
|
+
package installed.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
return _import_ag_ui_router()
|
|
28
|
+
except ModuleNotFoundError as exc:
|
|
29
|
+
if exc.name and exc.name.split(".")[0] == "ag_ui":
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
"ag_ui is enabled in 10xgraph.json but the AG-UI SDK is not installed. "
|
|
32
|
+
f"Install it with: {AG_UI_INSTALL_HINT}"
|
|
33
|
+
) from exc
|
|
34
|
+
raise
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def init_routes(app: FastAPI, *, evals: bool = True, ag_ui: bool = False):
|
|
38
|
+
"""
|
|
39
|
+
Initialize the routes for the FastAPI application.
|
|
40
|
+
|
|
41
|
+
This function includes the graph and checkpointer routers for 10xGraph functionality.
|
|
42
|
+
Auth and GraphQL routers are disabled for now.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
app (FastAPI): The FastAPI application instance to which the routes
|
|
46
|
+
will be added.
|
|
47
|
+
evals (bool): Mount the evals report viewer. It has no auth, so production
|
|
48
|
+
leaves it out.
|
|
49
|
+
ag_ui (bool): Mount the AG-UI endpoint (``POST /v1/ag-ui``). Off unless
|
|
50
|
+
``ag_ui.enabled`` is set in 10xgraph.json.
|
|
51
|
+
"""
|
|
52
|
+
app.include_router(graph_router)
|
|
53
|
+
app.include_router(checkpointer_router)
|
|
54
|
+
app.include_router(store_router)
|
|
55
|
+
if evals:
|
|
56
|
+
app.include_router(evals_router)
|
|
57
|
+
if ag_ui:
|
|
58
|
+
app.include_router(_ag_ui_router())
|
|
59
|
+
app.include_router(ping_router)
|
|
60
|
+
app.include_router(media_router)
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Store router module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
|
8
|
+
from injectq.integrations import InjectAPI
|
|
9
|
+
|
|
10
|
+
from tenxgraph_api.src.app.core.auth.permissions import RequirePermission
|
|
11
|
+
from tenxgraph_api.src.app.utils.response_helper import success_response
|
|
12
|
+
from tenxgraph_api.src.app.utils.swagger_helper import generate_swagger_responses
|
|
13
|
+
|
|
14
|
+
from .schemas.store_schemas import (
|
|
15
|
+
DeleteMemorySchema,
|
|
16
|
+
ForgetMemorySchema,
|
|
17
|
+
GetMemorySchema,
|
|
18
|
+
ListMemoriesSchema,
|
|
19
|
+
MemoryCreateResponseSchema,
|
|
20
|
+
MemoryItemResponseSchema,
|
|
21
|
+
MemoryListResponseSchema,
|
|
22
|
+
MemoryOperationResponseSchema,
|
|
23
|
+
MemorySearchResponseSchema,
|
|
24
|
+
SearchMemorySchema,
|
|
25
|
+
StoreMemorySchema,
|
|
26
|
+
UpdateMemorySchema,
|
|
27
|
+
)
|
|
28
|
+
from .services.store_service import StoreService
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
router = APIRouter(tags=["store"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@router.post(
|
|
35
|
+
"/v1/store/memories",
|
|
36
|
+
status_code=status.HTTP_200_OK,
|
|
37
|
+
responses=generate_swagger_responses(MemoryCreateResponseSchema),
|
|
38
|
+
summary="Store a memory",
|
|
39
|
+
description="Persist a memory payload using the configured store backend.",
|
|
40
|
+
)
|
|
41
|
+
async def create_memory(
|
|
42
|
+
request: Request,
|
|
43
|
+
payload: StoreMemorySchema,
|
|
44
|
+
service: StoreService = InjectAPI(StoreService),
|
|
45
|
+
user: dict[str, Any] = Depends(RequirePermission("store", "write")),
|
|
46
|
+
):
|
|
47
|
+
"""Store a memory item using the configured store."""
|
|
48
|
+
|
|
49
|
+
result = await service.store_memory(payload, user)
|
|
50
|
+
return success_response(result, request, message="Memory stored successfully")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.post(
|
|
54
|
+
"/v1/store/search",
|
|
55
|
+
status_code=status.HTTP_200_OK,
|
|
56
|
+
responses=generate_swagger_responses(MemorySearchResponseSchema),
|
|
57
|
+
summary="Search memories",
|
|
58
|
+
description="Search memories stored in the backend based on semantic similarity and filters.",
|
|
59
|
+
)
|
|
60
|
+
async def search_memories(
|
|
61
|
+
request: Request,
|
|
62
|
+
payload: SearchMemorySchema,
|
|
63
|
+
service: StoreService = InjectAPI(StoreService),
|
|
64
|
+
user: dict[str, Any] = Depends(RequirePermission("store", "read")),
|
|
65
|
+
):
|
|
66
|
+
"""Search stored memories."""
|
|
67
|
+
if not payload.query or (isinstance(payload.query, str) and not payload.query.strip()):
|
|
68
|
+
raise HTTPException(status_code=422, detail="query is required and cannot be empty")
|
|
69
|
+
|
|
70
|
+
result = await service.search_memories(payload, user)
|
|
71
|
+
return success_response(result, request)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# NOTE: static sub-paths (/list, /forget) MUST be registered before the
|
|
75
|
+
# /{memory_id} catch-all below — FastAPI matches top-down, so otherwise
|
|
76
|
+
# `POST /v1/store/memories/list` binds to `{memory_id="list"}` and never lists.
|
|
77
|
+
@router.post(
|
|
78
|
+
"/v1/store/memories/list",
|
|
79
|
+
status_code=status.HTTP_200_OK,
|
|
80
|
+
responses=generate_swagger_responses(MemoryListResponseSchema),
|
|
81
|
+
summary="List memories",
|
|
82
|
+
description="List memories from the configured store backend.",
|
|
83
|
+
)
|
|
84
|
+
async def list_memories(
|
|
85
|
+
request: Request,
|
|
86
|
+
payload: ListMemoriesSchema | None = Body(
|
|
87
|
+
default=None,
|
|
88
|
+
description="Optional configuration, limit, and options for listing memories.",
|
|
89
|
+
),
|
|
90
|
+
service: StoreService = InjectAPI(StoreService),
|
|
91
|
+
user: dict[str, Any] = Depends(RequirePermission("store", "read")),
|
|
92
|
+
):
|
|
93
|
+
"""List stored memories."""
|
|
94
|
+
|
|
95
|
+
if payload and payload.limit is not None and payload.limit <= 0:
|
|
96
|
+
raise HTTPException(status_code=422, detail="limit must be > 0")
|
|
97
|
+
|
|
98
|
+
if payload is None:
|
|
99
|
+
payload = ListMemoriesSchema()
|
|
100
|
+
cfg = payload.config or {}
|
|
101
|
+
opts = payload.options
|
|
102
|
+
result = await service.list_memories(cfg, user, limit=payload.limit, options=opts)
|
|
103
|
+
return success_response(result, request)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.put(
|
|
107
|
+
"/v1/store/memories/{memory_id}",
|
|
108
|
+
status_code=status.HTTP_200_OK,
|
|
109
|
+
responses=generate_swagger_responses(MemoryOperationResponseSchema),
|
|
110
|
+
summary="Update a memory",
|
|
111
|
+
description="Update the content or metadata of a stored memory.",
|
|
112
|
+
)
|
|
113
|
+
async def update_memory(
|
|
114
|
+
request: Request,
|
|
115
|
+
memory_id: str,
|
|
116
|
+
payload: UpdateMemorySchema,
|
|
117
|
+
service: StoreService = InjectAPI(StoreService),
|
|
118
|
+
user: dict[str, Any] = Depends(RequirePermission("store", "write")),
|
|
119
|
+
):
|
|
120
|
+
"""Update a stored memory."""
|
|
121
|
+
if not memory_id or not memory_id.strip():
|
|
122
|
+
raise HTTPException(status_code=422, detail="memory_id is required and cannot be empty")
|
|
123
|
+
|
|
124
|
+
result = await service.update_memory(memory_id, payload, user)
|
|
125
|
+
return success_response(result, request, message="Memory updated successfully")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.delete(
|
|
129
|
+
"/v1/store/memories/{memory_id}",
|
|
130
|
+
status_code=status.HTTP_200_OK,
|
|
131
|
+
responses=generate_swagger_responses(MemoryOperationResponseSchema),
|
|
132
|
+
summary="Delete a memory",
|
|
133
|
+
description="Delete a stored memory by its identifier.",
|
|
134
|
+
)
|
|
135
|
+
async def delete_memory(
|
|
136
|
+
request: Request,
|
|
137
|
+
memory_id: str,
|
|
138
|
+
payload: DeleteMemorySchema | None = Body(
|
|
139
|
+
default=None,
|
|
140
|
+
description="Optional configuration overrides forwarded to the store backend.",
|
|
141
|
+
),
|
|
142
|
+
service: StoreService = InjectAPI(StoreService),
|
|
143
|
+
user: dict[str, Any] = Depends(RequirePermission("store", "delete")),
|
|
144
|
+
):
|
|
145
|
+
"""Delete a stored memory."""
|
|
146
|
+
if not memory_id or not memory_id.strip():
|
|
147
|
+
raise HTTPException(status_code=422, detail="memory_id is required and cannot be empty")
|
|
148
|
+
|
|
149
|
+
config_payload = payload.config if payload else {}
|
|
150
|
+
options_payload = payload.options if payload else None
|
|
151
|
+
result = await service.delete_memory(memory_id, config_payload, user, options=options_payload)
|
|
152
|
+
return success_response(result, request, message="Memory deleted successfully")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@router.post(
|
|
156
|
+
"/v1/store/memories/forget",
|
|
157
|
+
status_code=status.HTTP_200_OK,
|
|
158
|
+
responses=generate_swagger_responses(MemoryOperationResponseSchema),
|
|
159
|
+
summary="Forget memories",
|
|
160
|
+
description="Forget memories matching the provided filters from the store backend.",
|
|
161
|
+
)
|
|
162
|
+
async def forget_memory(
|
|
163
|
+
request: Request,
|
|
164
|
+
payload: ForgetMemorySchema,
|
|
165
|
+
service: StoreService = InjectAPI(StoreService),
|
|
166
|
+
user: dict[str, Any] = Depends(RequirePermission("store", "delete")),
|
|
167
|
+
):
|
|
168
|
+
"""Forget memories based on filters."""
|
|
169
|
+
|
|
170
|
+
result = await service.forget_memory(payload, user)
|
|
171
|
+
return success_response(result, request, message="Memories removed successfully")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Registered last so the static /list and /forget paths above take precedence
|
|
175
|
+
# over this {memory_id} catch-all.
|
|
176
|
+
@router.post(
|
|
177
|
+
"/v1/store/memories/{memory_id}",
|
|
178
|
+
status_code=status.HTTP_200_OK,
|
|
179
|
+
responses=generate_swagger_responses(MemoryItemResponseSchema),
|
|
180
|
+
summary="Get a memory",
|
|
181
|
+
description="Retrieve a memory by its identifier from the configured store backend.",
|
|
182
|
+
)
|
|
183
|
+
async def get_memory(
|
|
184
|
+
request: Request,
|
|
185
|
+
memory_id: str,
|
|
186
|
+
payload: GetMemorySchema | None = Body(
|
|
187
|
+
default=None,
|
|
188
|
+
description="Optional configuration and options for retrieving the memory.",
|
|
189
|
+
),
|
|
190
|
+
service: StoreService = InjectAPI(StoreService),
|
|
191
|
+
user: dict[str, Any] = Depends(RequirePermission("store", "read")),
|
|
192
|
+
):
|
|
193
|
+
"""Get a memory by ID."""
|
|
194
|
+
if not memory_id or not memory_id.strip():
|
|
195
|
+
raise HTTPException(status_code=422, detail="memory_id is required and cannot be empty")
|
|
196
|
+
|
|
197
|
+
cfg = payload.config if payload else {}
|
|
198
|
+
opts = payload.options if payload else None
|
|
199
|
+
result = await service.get_memory(memory_id, cfg, user, options=opts)
|
|
200
|
+
return success_response(result, request)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .store_schemas import (
|
|
2
|
+
BaseConfigSchema,
|
|
3
|
+
DeleteMemorySchema,
|
|
4
|
+
ForgetMemorySchema,
|
|
5
|
+
MemoryCreateResponseSchema,
|
|
6
|
+
MemoryItemResponseSchema,
|
|
7
|
+
MemoryListResponseSchema,
|
|
8
|
+
MemoryOperationResponseSchema,
|
|
9
|
+
MemorySearchResponseSchema,
|
|
10
|
+
SearchMemorySchema,
|
|
11
|
+
StoreMemorySchema,
|
|
12
|
+
UpdateMemorySchema,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BaseConfigSchema",
|
|
18
|
+
"DeleteMemorySchema",
|
|
19
|
+
"ForgetMemorySchema",
|
|
20
|
+
"MemoryCreateResponseSchema",
|
|
21
|
+
"MemoryItemResponseSchema",
|
|
22
|
+
"MemoryListResponseSchema",
|
|
23
|
+
"MemoryOperationResponseSchema",
|
|
24
|
+
"MemorySearchResponseSchema",
|
|
25
|
+
"SearchMemorySchema",
|
|
26
|
+
"StoreMemorySchema",
|
|
27
|
+
"UpdateMemorySchema",
|
|
28
|
+
]
|