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,516 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import inspect
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from injectq import InjectQ
|
|
8
|
+
from tenxgraph.core import CompiledGraph
|
|
9
|
+
from tenxgraph.storage.checkpointer import BaseCheckpointer
|
|
10
|
+
from tenxgraph.storage.store import BaseStore
|
|
11
|
+
|
|
12
|
+
from tenxgraph_api import BaseAuth
|
|
13
|
+
from tenxgraph_api.src.app.core.auth.authorization import (
|
|
14
|
+
AuthorizationBackend,
|
|
15
|
+
DefaultAuthorizationBackend,
|
|
16
|
+
OwnershipAuthorizationBackend,
|
|
17
|
+
RoleBasedAuthorizationBackend,
|
|
18
|
+
)
|
|
19
|
+
from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
|
|
20
|
+
from tenxgraph_api.src.app.utils.thread_name_generator import ThreadNameGenerator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("tenxgraph_api.loader")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def load_graph(path: str) -> CompiledGraph | None:
|
|
27
|
+
if ":" not in path:
|
|
28
|
+
raise ValueError(f"Invalid graph path format '{path}'. Expected 'module:attribute'.")
|
|
29
|
+
|
|
30
|
+
module_name_importable, function_name = path.split(":")
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
module = importlib.import_module(module_name_importable)
|
|
34
|
+
entry_point_obj = getattr(module, function_name)
|
|
35
|
+
|
|
36
|
+
if callable(entry_point_obj):
|
|
37
|
+
if inspect.iscoroutinefunction(entry_point_obj):
|
|
38
|
+
app = await entry_point_obj()
|
|
39
|
+
else:
|
|
40
|
+
app = entry_point_obj()
|
|
41
|
+
else:
|
|
42
|
+
app = entry_point_obj
|
|
43
|
+
|
|
44
|
+
if app is None:
|
|
45
|
+
raise RuntimeError(f"Failed to obtain a runnable graph from {path}.")
|
|
46
|
+
|
|
47
|
+
if isinstance(app, CompiledGraph):
|
|
48
|
+
logger.info(f"Successfully loaded graph '{function_name}' from {path}.")
|
|
49
|
+
else:
|
|
50
|
+
raise TypeError("Loaded object is not a CompiledGraph.")
|
|
51
|
+
|
|
52
|
+
except ModuleNotFoundError as e:
|
|
53
|
+
logger.error(f"Module not found when loading graph from {path}: {e}")
|
|
54
|
+
raise ModuleNotFoundError(f"Module not found for graph path '{path}': {e}")
|
|
55
|
+
except AttributeError as e:
|
|
56
|
+
logger.error(f"Attribute not found when loading graph from {path}: {e}")
|
|
57
|
+
raise AttributeError(f"Attribute not found for graph path '{path}': {e}")
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.error(f"Error loading graph from {path}: {e}")
|
|
60
|
+
raise Exception(f"Failed to load graph from {path}: {e}")
|
|
61
|
+
|
|
62
|
+
return app
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_checkpointer(path: str | None) -> BaseCheckpointer | None:
|
|
66
|
+
if not path:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
if ":" not in path:
|
|
70
|
+
raise ValueError(f"Invalid checkpointer path format '{path}'. Expected 'module:attribute'.")
|
|
71
|
+
|
|
72
|
+
module_name_importable, function_name = path.split(":")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
module = importlib.import_module(module_name_importable)
|
|
76
|
+
entry_point_obj = getattr(module, function_name)
|
|
77
|
+
checkpointer = entry_point_obj
|
|
78
|
+
|
|
79
|
+
if checkpointer is None:
|
|
80
|
+
raise RuntimeError(f"Failed to obtain a BaseCheckpointer graph from {path}.")
|
|
81
|
+
|
|
82
|
+
if isinstance(checkpointer, BaseCheckpointer):
|
|
83
|
+
logger.info(f"Successfully loaded BaseCheckpointer '{function_name}' from {path}.")
|
|
84
|
+
else:
|
|
85
|
+
raise TypeError("Loaded object is not a BaseCheckpointer.")
|
|
86
|
+
except ModuleNotFoundError as e:
|
|
87
|
+
logger.error(f"Module not found when loading BaseCheckpointer from {path}: {e}")
|
|
88
|
+
raise ModuleNotFoundError(f"Module not found for checkpointer path '{path}': {e}")
|
|
89
|
+
except AttributeError as e:
|
|
90
|
+
logger.error(f"Attribute not found when loading BaseCheckpointer from {path}: {e}")
|
|
91
|
+
raise AttributeError(f"Attribute not found for checkpointer path '{path}': {e}")
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.error(f"Error loading BaseCheckpointer from {path}: {e}")
|
|
94
|
+
raise Exception(f"Failed to load BaseCheckpointer from {path}: {e}")
|
|
95
|
+
|
|
96
|
+
return checkpointer
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_store(path: str | None) -> BaseStore | None:
|
|
100
|
+
if not path:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
if ":" not in path:
|
|
104
|
+
raise ValueError(f"Invalid store path format '{path}'. Expected 'module:attribute'.")
|
|
105
|
+
|
|
106
|
+
module_name_importable, function_name = path.split(":")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
module = importlib.import_module(module_name_importable)
|
|
110
|
+
entry_point_obj = getattr(module, function_name)
|
|
111
|
+
store = entry_point_obj
|
|
112
|
+
|
|
113
|
+
if store is None:
|
|
114
|
+
raise RuntimeError(f"Failed to obtain a BaseStore from {path}.")
|
|
115
|
+
|
|
116
|
+
if isinstance(store, BaseStore):
|
|
117
|
+
logger.info(f"Successfully loaded graph '{function_name}' from {path}.")
|
|
118
|
+
else:
|
|
119
|
+
raise TypeError("Loaded object is not a BaseStore.")
|
|
120
|
+
except ModuleNotFoundError as e:
|
|
121
|
+
logger.error(f"Module not found when loading BaseStore from {path}: {e}")
|
|
122
|
+
raise ModuleNotFoundError(f"Module not found for store path '{path}': {e}")
|
|
123
|
+
except AttributeError as e:
|
|
124
|
+
logger.error(f"Attribute not found when loading BaseStore from {path}: {e}")
|
|
125
|
+
raise AttributeError(f"Attribute not found for store path '{path}': {e}")
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error(f"Error loading BaseStore from {path}: {e}")
|
|
128
|
+
raise Exception(f"Failed to load BaseStore from {path}: {e}")
|
|
129
|
+
|
|
130
|
+
return store
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load_container(path: str | None) -> InjectQ | None:
|
|
134
|
+
if not path:
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
module_name_importable, function_name = path.split(":")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
module = importlib.import_module(module_name_importable)
|
|
141
|
+
entry_point_obj = getattr(module, function_name)
|
|
142
|
+
container = entry_point_obj
|
|
143
|
+
|
|
144
|
+
if container is None:
|
|
145
|
+
raise RuntimeError(f"Failed to obtain a InjectQ from {path}.")
|
|
146
|
+
|
|
147
|
+
if isinstance(container, InjectQ):
|
|
148
|
+
logger.info(f"Successfully loaded InjectQ '{function_name}' from {path}.")
|
|
149
|
+
else:
|
|
150
|
+
raise TypeError("Loaded object is not a InjectQ.")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.error(f"Error loading InjectQ from {path}: {e}")
|
|
153
|
+
raise Exception(f"Failed to load InjectQ from {path}: {e}")
|
|
154
|
+
|
|
155
|
+
# if we have container, set it as the global instance
|
|
156
|
+
if container:
|
|
157
|
+
container.activate()
|
|
158
|
+
|
|
159
|
+
return container
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def load_auth(path: str | None) -> BaseAuth | None:
|
|
163
|
+
if not path:
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
if ":" not in path:
|
|
167
|
+
raise ValueError(f"Invalid auth path format '{path}'. Expected 'module:attribute'.")
|
|
168
|
+
|
|
169
|
+
module_name_importable, function_name = path.split(":")
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
module = importlib.import_module(module_name_importable)
|
|
173
|
+
entry_point_obj = getattr(module, function_name)
|
|
174
|
+
|
|
175
|
+
if inspect.isclass(entry_point_obj) and issubclass(entry_point_obj, BaseAuth):
|
|
176
|
+
auth = entry_point_obj()
|
|
177
|
+
elif isinstance(entry_point_obj, BaseAuth):
|
|
178
|
+
auth = entry_point_obj
|
|
179
|
+
else:
|
|
180
|
+
raise TypeError("Loaded object is not a subclass or instance of BaseAuth.")
|
|
181
|
+
|
|
182
|
+
logger.info(f"Successfully loaded BaseAuth '{function_name}' from {path}.")
|
|
183
|
+
except ModuleNotFoundError as e:
|
|
184
|
+
logger.error(f"Module not found when loading BaseAuth from {path}: {e}")
|
|
185
|
+
raise ModuleNotFoundError(f"Module not found for auth path '{path}': {e}")
|
|
186
|
+
except AttributeError as e:
|
|
187
|
+
logger.error(f"Attribute not found when loading BaseAuth from {path}: {e}")
|
|
188
|
+
raise AttributeError(f"Attribute not found for auth path '{path}': {e}")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.error(f"Error loading BaseAuth from {path}: {e}")
|
|
191
|
+
raise Exception(f"Failed to load BaseAuth from {path}: {e}")
|
|
192
|
+
|
|
193
|
+
return auth
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def load_authorization(path: str | None) -> AuthorizationBackend | None:
|
|
197
|
+
"""
|
|
198
|
+
Load authorization backend from the specified path.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
path: Module path in format 'module:attribute' or None
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
AuthorizationBackend instance or None if path is not provided
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
Exception: If loading fails or object is not AuthorizationBackend
|
|
208
|
+
"""
|
|
209
|
+
if not path:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
module_name_importable, function_name = path.split(":")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
module = importlib.import_module(module_name_importable)
|
|
216
|
+
entry_point_obj = getattr(module, function_name)
|
|
217
|
+
|
|
218
|
+
# If it's a class, instantiate it; if it's an instance, use as is
|
|
219
|
+
if inspect.isclass(entry_point_obj) and issubclass(entry_point_obj, AuthorizationBackend):
|
|
220
|
+
authorization = entry_point_obj()
|
|
221
|
+
elif isinstance(entry_point_obj, AuthorizationBackend):
|
|
222
|
+
authorization = entry_point_obj
|
|
223
|
+
else:
|
|
224
|
+
raise TypeError("Loaded object is not a subclass or instance of AuthorizationBackend.")
|
|
225
|
+
|
|
226
|
+
logger.info(f"Successfully loaded AuthorizationBackend '{function_name}' from {path}.")
|
|
227
|
+
except Exception as e:
|
|
228
|
+
logger.error(f"Error loading AuthorizationBackend from {path}: {e}")
|
|
229
|
+
raise Exception(f"Failed to load AuthorizationBackend from {path}: {e}")
|
|
230
|
+
|
|
231
|
+
return authorization
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def load_thread_name_generator(path: str | None) -> ThreadNameGenerator | None:
|
|
235
|
+
if not path:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
module_name_importable, function_name = path.split(":")
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
module = importlib.import_module(module_name_importable)
|
|
242
|
+
entry_point_obj = getattr(module, function_name)
|
|
243
|
+
|
|
244
|
+
# If it's a class, instantiate it; if it's an instance, use as is
|
|
245
|
+
if inspect.isclass(entry_point_obj) and issubclass(entry_point_obj, ThreadNameGenerator):
|
|
246
|
+
thread_name_generator = entry_point_obj()
|
|
247
|
+
elif isinstance(entry_point_obj, ThreadNameGenerator):
|
|
248
|
+
thread_name_generator = entry_point_obj
|
|
249
|
+
else:
|
|
250
|
+
raise TypeError("Loaded object is not a subclass or instance of ThreadNameGenerator.")
|
|
251
|
+
|
|
252
|
+
logger.info(f"Successfully loaded ThreadNameGenerator '{function_name}' from {path}.")
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.error(f"Error loading ThreadNameGenerator from {path}: {e}")
|
|
255
|
+
raise Exception(f"Failed to load ThreadNameGenerator from {path}: {e}")
|
|
256
|
+
|
|
257
|
+
return thread_name_generator
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def load_and_bind_auth(container: InjectQ, auth_config: dict) -> None:
|
|
261
|
+
"""Bind the authentication backend described by ``GraphConfig.auth_config()``.
|
|
262
|
+
|
|
263
|
+
``jwt`` needs no path; ``custom`` needs a ``path`` to a ``BaseAuth``. Any other method
|
|
264
|
+
is a configuration error and fails at startup rather than silently binding no backend.
|
|
265
|
+
"""
|
|
266
|
+
from tenxgraph_api.src.app.core.auth.jwt_auth import JwtAuth, check_jwt_settings
|
|
267
|
+
from tenxgraph_api.src.app.core.config.settings import get_settings
|
|
268
|
+
|
|
269
|
+
method = auth_config.get("method")
|
|
270
|
+
if method == "jwt":
|
|
271
|
+
check_jwt_settings(get_settings())
|
|
272
|
+
auth_backend: BaseAuth | None = JwtAuth()
|
|
273
|
+
elif method == "none":
|
|
274
|
+
auth_backend = None
|
|
275
|
+
elif method == "custom":
|
|
276
|
+
auth_backend = _load_custom_auth(auth_config.get("path"))
|
|
277
|
+
else:
|
|
278
|
+
raise ValueError(f"Unsupported auth method: {method!r}. Use 'jwt' or 'custom'.")
|
|
279
|
+
|
|
280
|
+
container.bind_instance(BaseAuth, auth_backend, allow_none=True)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _load_custom_auth(path: str | None) -> BaseAuth:
|
|
284
|
+
if not path:
|
|
285
|
+
raise ValueError("Custom auth requires a 'path' in auth_config.")
|
|
286
|
+
|
|
287
|
+
# Extract file path before the ':' for existence check
|
|
288
|
+
module_or_path = path.split(":", 1)[0] if ":" in path else path
|
|
289
|
+
|
|
290
|
+
# Simple handling: if it appears to be a filesystem path, use it; otherwise
|
|
291
|
+
# convert dotted module path to a file path like src/auth/custom_auth.py
|
|
292
|
+
if os.path.sep in module_or_path or module_or_path.endswith(".py"):
|
|
293
|
+
file_path = Path(module_or_path)
|
|
294
|
+
elif "." in module_or_path and os.path.sep not in module_or_path:
|
|
295
|
+
file_path = Path(module_or_path.replace(".", os.path.sep) + ".py")
|
|
296
|
+
else:
|
|
297
|
+
file_path = Path(module_or_path)
|
|
298
|
+
|
|
299
|
+
if not file_path.exists():
|
|
300
|
+
raise ValueError(f"Custom auth path does not exist: {module_or_path}")
|
|
301
|
+
|
|
302
|
+
auth = load_auth(path)
|
|
303
|
+
if auth is None: # load_auth only returns None for an empty path, checked above
|
|
304
|
+
raise ValueError("Custom auth requires a 'path' in auth_config.")
|
|
305
|
+
return auth
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# Built-in authorization backends selectable by name in ``10xgraph.json``.
|
|
309
|
+
_BUILTIN_AUTHORIZATION = {
|
|
310
|
+
"ownership": OwnershipAuthorizationBackend, # object-level: owner-only thread access
|
|
311
|
+
"allow_all": DefaultAuthorizationBackend, # authenticated == authorized
|
|
312
|
+
"default": DefaultAuthorizationBackend, # alias for allow_all
|
|
313
|
+
"none": DefaultAuthorizationBackend, # alias for allow_all
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _build_ownership_redis(redis_url: str | None) -> object | None:
|
|
318
|
+
"""Build an async Redis client for the ownership L2 cache, or None.
|
|
319
|
+
|
|
320
|
+
Returns None (L1-only) when no URL is configured or the ``redis`` package is absent;
|
|
321
|
+
the resolver degrades gracefully either way.
|
|
322
|
+
"""
|
|
323
|
+
if not redis_url:
|
|
324
|
+
return None
|
|
325
|
+
try:
|
|
326
|
+
from redis.asyncio import Redis as AsyncRedis # type: ignore[import]
|
|
327
|
+
except ImportError:
|
|
328
|
+
logger.warning(
|
|
329
|
+
"REDIS configured but the 'redis' package is not installed; ownership cache "
|
|
330
|
+
"runs in-process only (L1). Install the redis extra to share it across workers."
|
|
331
|
+
)
|
|
332
|
+
return None
|
|
333
|
+
try:
|
|
334
|
+
client = AsyncRedis.from_url(redis_url, decode_responses=False)
|
|
335
|
+
logger.info("Ownership authorization L2 cache enabled (shared Redis).")
|
|
336
|
+
return client
|
|
337
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
338
|
+
logger.warning("Failed to build ownership Redis client: %s; using L1 cache only.", exc)
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _resolve_authorization_backend(
|
|
343
|
+
authorization: str | dict | None, redis_url: str | None = None
|
|
344
|
+
) -> AuthorizationBackend:
|
|
345
|
+
"""Pick the authorization backend from config, defaulting by run mode.
|
|
346
|
+
|
|
347
|
+
Resolution order (developer choice always wins):
|
|
348
|
+
|
|
349
|
+
1. A dict ``{"backend": "rbac", "roles": {...}, ...}`` -> role-based access control.
|
|
350
|
+
2. ``"module:attr"`` -> the developer's custom :class:`AuthorizationBackend`.
|
|
351
|
+
3. A built-in name (``"ownership"``, ``"allow_all"``/``"default"``/``"none"``) ->
|
|
352
|
+
that backend, regardless of mode.
|
|
353
|
+
4. Not configured (``null``) -> secure-by-default in production
|
|
354
|
+
(:class:`OwnershipAuthorizationBackend`), permissive in development
|
|
355
|
+
(:class:`DefaultAuthorizationBackend`). Either can be overridden via (1)/(2)/(3).
|
|
356
|
+
|
|
357
|
+
``redis_url`` (when set) backs the ownership cache's shared L2 tier.
|
|
358
|
+
"""
|
|
359
|
+
# 1. Config-driven RBAC: {"backend": "rbac", "roles": {...}, "default_scopes": [...]}
|
|
360
|
+
if isinstance(authorization, dict):
|
|
361
|
+
backend_name = (authorization.get("backend") or authorization.get("type") or "").lower()
|
|
362
|
+
if backend_name in ("rbac", "role_based", "roles"):
|
|
363
|
+
logger.info("Using RoleBasedAuthorizationBackend (config-driven roles).")
|
|
364
|
+
return RoleBasedAuthorizationBackend(
|
|
365
|
+
role_scopes=authorization.get("roles") or authorization.get("role_scopes") or {},
|
|
366
|
+
default_scopes=authorization.get("default_scopes") or (),
|
|
367
|
+
isolation=authorization.get("isolation", "owner"),
|
|
368
|
+
redis=_build_ownership_redis(redis_url),
|
|
369
|
+
)
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"Unknown authorization backend config: {authorization!r}. For RBAC use "
|
|
372
|
+
'{"backend": "rbac", "roles": {...}}.'
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if authorization and ":" in authorization:
|
|
376
|
+
backend = load_authorization(authorization)
|
|
377
|
+
logger.info("Using custom AuthorizationBackend from '%s'.", authorization)
|
|
378
|
+
return backend # type: ignore[return-value]
|
|
379
|
+
|
|
380
|
+
if authorization:
|
|
381
|
+
key = authorization.strip().lower()
|
|
382
|
+
builtin = _BUILTIN_AUTHORIZATION.get(key)
|
|
383
|
+
if builtin is None:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
f"Unknown authorization backend '{authorization}'. Use a 'module:attr' "
|
|
386
|
+
f"path or one of: {', '.join(sorted(_BUILTIN_AUTHORIZATION))}."
|
|
387
|
+
)
|
|
388
|
+
logger.info("Using built-in '%s' authorization backend.", key)
|
|
389
|
+
if builtin is OwnershipAuthorizationBackend:
|
|
390
|
+
return OwnershipAuthorizationBackend(redis=_build_ownership_redis(redis_url))
|
|
391
|
+
return builtin()
|
|
392
|
+
|
|
393
|
+
# Not configured: default by mode.
|
|
394
|
+
from tenxgraph_api.src.app.core.config.settings import get_settings
|
|
395
|
+
|
|
396
|
+
if get_settings().MODE == "production":
|
|
397
|
+
logger.info(
|
|
398
|
+
"No authorization configured; defaulting to OwnershipAuthorizationBackend "
|
|
399
|
+
"(owner-only thread access) because MODE=production. Set "
|
|
400
|
+
'"authorization": "allow_all" to opt out.'
|
|
401
|
+
)
|
|
402
|
+
return OwnershipAuthorizationBackend(redis=_build_ownership_redis(redis_url))
|
|
403
|
+
|
|
404
|
+
logger.info(
|
|
405
|
+
"No authorization configured; defaulting to DefaultAuthorizationBackend "
|
|
406
|
+
"(allows all authenticated users) in development. Set "
|
|
407
|
+
'"authorization": "ownership" to enforce owner-only access here too.'
|
|
408
|
+
)
|
|
409
|
+
return DefaultAuthorizationBackend()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def load_and_bind_authorization(
|
|
413
|
+
container: InjectQ,
|
|
414
|
+
authorization_path: str | None,
|
|
415
|
+
redis_url: str | None = None,
|
|
416
|
+
) -> None:
|
|
417
|
+
backend = _resolve_authorization_backend(authorization_path, redis_url)
|
|
418
|
+
container.bind_instance(AuthorizationBackend, backend)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
async def attach_all_modules(
|
|
422
|
+
config: GraphConfig,
|
|
423
|
+
container: InjectQ,
|
|
424
|
+
) -> CompiledGraph | None:
|
|
425
|
+
remote_tools = config.remote_tools
|
|
426
|
+
graph = await load_graph(config.graph_path)
|
|
427
|
+
|
|
428
|
+
if remote_tools:
|
|
429
|
+
if graph is None:
|
|
430
|
+
raise RuntimeError("Cannot attach remote tools because the graph failed to load")
|
|
431
|
+
grouped: dict[str, list[dict]] = {}
|
|
432
|
+
for tool in remote_tools:
|
|
433
|
+
grouped.setdefault(tool.node_name, []).append(tool.to_tool_schema())
|
|
434
|
+
for node_name, schemas in grouped.items():
|
|
435
|
+
graph.attach_remote_tools(schemas, node_name)
|
|
436
|
+
logger.info("Attached %d configured remote tools", len(remote_tools))
|
|
437
|
+
|
|
438
|
+
logger.info("All modules attached successfully")
|
|
439
|
+
|
|
440
|
+
# This binding we have done already in the library
|
|
441
|
+
# # Bind checkpointer instance if configured
|
|
442
|
+
# checkpointer = load_checkpointer(config.checkpointer_path)
|
|
443
|
+
# container.bind_instance(BaseCheckpointer, checkpointer, allow_none=True)
|
|
444
|
+
|
|
445
|
+
# Bind the store instance (if configured) so the /v1/store endpoints — which
|
|
446
|
+
# inject BaseStore via StoreService — can serve it. Without this the store
|
|
447
|
+
# router reports "Store is not configured" even when 10xgraph.json sets one.
|
|
448
|
+
store = load_store(config.store_path)
|
|
449
|
+
container.bind_instance(BaseStore, store, allow_none=True)
|
|
450
|
+
|
|
451
|
+
# Bind the in-memory telemetry store so the graph service can record run
|
|
452
|
+
# events and the /v1/observability endpoints can reconstruct traces.
|
|
453
|
+
#
|
|
454
|
+
# This is a dev-only convenience: it lets the playground show a trace without
|
|
455
|
+
# any external observability backend. It is NOT durable and NOT meant for
|
|
456
|
+
# production (use OTEL / a publisher there instead). So we only bind it
|
|
457
|
+
# outside production; in production the store stays unbound and the
|
|
458
|
+
# /v1/observability endpoints return a clean, empty "disabled" response.
|
|
459
|
+
from tenxgraph_api.src.app.core.config.settings import get_settings
|
|
460
|
+
from tenxgraph_api.src.app.utils.telemetry_store import TelemetryStore
|
|
461
|
+
|
|
462
|
+
if get_settings().MODE != "production":
|
|
463
|
+
container.bind_instance(TelemetryStore, TelemetryStore())
|
|
464
|
+
logger.info("Local in-memory telemetry store bound (dev mode)")
|
|
465
|
+
else:
|
|
466
|
+
logger.info("Production mode: local telemetry store disabled (use OTEL/publisher)")
|
|
467
|
+
|
|
468
|
+
# load auth backend
|
|
469
|
+
auth_config = config.auth_config()
|
|
470
|
+
if auth_config:
|
|
471
|
+
load_and_bind_auth(container, auth_config)
|
|
472
|
+
else:
|
|
473
|
+
# Auth disabled is a legitimate choice (single-user / trusted network), but it
|
|
474
|
+
# must not be silent -- an operator who simply forgot to set `auth` should see
|
|
475
|
+
# it at startup. This warns; it does not force auth on.
|
|
476
|
+
logger.warning(
|
|
477
|
+
"⚠️ Authentication is DISABLED (no `auth` configured). Every request runs "
|
|
478
|
+
"as `anonymous` with no access control. This is fine for single-user or "
|
|
479
|
+
"trusted-network deployments; set `auth` in 10xgraph.json to enable it."
|
|
480
|
+
)
|
|
481
|
+
# bind None
|
|
482
|
+
container.bind_instance(BaseAuth, None, allow_none=True)
|
|
483
|
+
|
|
484
|
+
# load thread name generator
|
|
485
|
+
thread_name_generator_path = config.thread_name_generator_path
|
|
486
|
+
if thread_name_generator_path:
|
|
487
|
+
thread_name_generator = load_thread_name_generator(thread_name_generator_path)
|
|
488
|
+
container.bind_instance(ThreadNameGenerator, thread_name_generator)
|
|
489
|
+
else:
|
|
490
|
+
# bind None if not configured
|
|
491
|
+
container.bind_instance(ThreadNameGenerator, None, allow_none=True)
|
|
492
|
+
|
|
493
|
+
# load authorization backend. The ownership cache's shared L2 tier reuses the
|
|
494
|
+
# configured Redis URL (config `redis`, else settings.REDIS_URL); absent -> L1 only.
|
|
495
|
+
from tenxgraph_api.src.app.core.config.settings import get_settings
|
|
496
|
+
|
|
497
|
+
authorization_path = config.authorization_path
|
|
498
|
+
ownership_redis_url = config.redis_url or get_settings().REDIS_URL
|
|
499
|
+
load_and_bind_authorization(container, authorization_path, ownership_redis_url)
|
|
500
|
+
|
|
501
|
+
# --- Media service wiring ---
|
|
502
|
+
from tenxgraph_api.src.app.core.config.media_settings import (
|
|
503
|
+
MediaSettings,
|
|
504
|
+
get_media_settings,
|
|
505
|
+
)
|
|
506
|
+
from tenxgraph_api.src.app.routers.media import MediaService
|
|
507
|
+
|
|
508
|
+
media_settings = get_media_settings()
|
|
509
|
+
container.bind_instance(MediaSettings, media_settings)
|
|
510
|
+
media_service = MediaService(settings=media_settings)
|
|
511
|
+
container.bind_instance(MediaService, media_service)
|
|
512
|
+
|
|
513
|
+
logger.info("Container loaded successfully")
|
|
514
|
+
logger.debug(f"Container dependency graph: {container.get_dependency_graph()}")
|
|
515
|
+
|
|
516
|
+
return graph
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
from fastapi.concurrency import asynccontextmanager
|
|
6
|
+
from fastapi.responses import ORJSONResponse
|
|
7
|
+
from injectq import InjectQ
|
|
8
|
+
from injectq.integrations.fastapi import setup_fastapi
|
|
9
|
+
from tenxgraph.core.graph import CompiledGraph
|
|
10
|
+
from tenxgraph.utils.background_task_manager import BackgroundTaskManager
|
|
11
|
+
|
|
12
|
+
# # Prometheus Instrumentator import
|
|
13
|
+
# from prometheus_fastapi_instrumentator import Instrumentator
|
|
14
|
+
# from tortoise import Tortoise
|
|
15
|
+
from tenxgraph_api.src.app.core import (
|
|
16
|
+
get_settings,
|
|
17
|
+
init_errors_handler,
|
|
18
|
+
init_logger,
|
|
19
|
+
setup_middleware,
|
|
20
|
+
)
|
|
21
|
+
from tenxgraph_api.src.app.core.auth.route_guard import assert_all_routes_protected
|
|
22
|
+
from tenxgraph_api.src.app.core.config.graph_config import GraphConfig, default_config_path
|
|
23
|
+
from tenxgraph_api.src.app.loader import attach_all_modules, load_container
|
|
24
|
+
from tenxgraph_api.src.app.routers import init_routes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("tenxgraph_api")
|
|
28
|
+
|
|
29
|
+
# How long shutdown waits for in-flight background work (event publishing,
|
|
30
|
+
# checkpoint writes) before giving up. Bounded on purpose: a wedged sink must not
|
|
31
|
+
# hold the pod open until the kubelet SIGKILLs it, which would lose more than
|
|
32
|
+
# draining for a fixed window and then moving on.
|
|
33
|
+
SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 30.0
|
|
34
|
+
|
|
35
|
+
# GraphConfig loads the ``env`` file named in 10xgraph.json, so it must run before Settings
|
|
36
|
+
# is first built. `10xgraph api` loads that file itself, but plain `gunicorn ...main:app`
|
|
37
|
+
# (the generated Dockerfile) does not, and Settings is cached after the first read.
|
|
38
|
+
graph_path = os.environ.get("GRAPH_PATH") or default_config_path()
|
|
39
|
+
graph_config = GraphConfig(graph_path)
|
|
40
|
+
|
|
41
|
+
settings = get_settings()
|
|
42
|
+
# Load the container
|
|
43
|
+
container: InjectQ = load_container(graph_config.injectq_path) or InjectQ.get_instance()
|
|
44
|
+
|
|
45
|
+
# Save config instance
|
|
46
|
+
container.bind_instance(GraphConfig, graph_config)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def _cleanup_temp_media_cache() -> None:
|
|
50
|
+
"""Run best-effort cleanup of expired temporary media cache entries."""
|
|
51
|
+
try:
|
|
52
|
+
from tenxgraph.storage.media.temp_cache import TemporaryMediaCache
|
|
53
|
+
|
|
54
|
+
checkpointer = container.try_get("checkpointer") or container.try_get("BaseCheckpointer")
|
|
55
|
+
media_store = container.try_get("media_store") or container.try_get("BaseMediaStore")
|
|
56
|
+
|
|
57
|
+
if checkpointer is None:
|
|
58
|
+
logger.debug("No checkpointer available, skipping temp media cache cleanup")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
cache = TemporaryMediaCache()
|
|
62
|
+
cleaned = await cache.cleanup(checkpointer, media_store)
|
|
63
|
+
if cleaned:
|
|
64
|
+
logger.info("Cleaned up %d expired temporary media cache entries on startup", cleaned)
|
|
65
|
+
else:
|
|
66
|
+
logger.debug("No expired temporary media cache entries to clean up")
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.warning("Failed to clean up temporary media cache on startup: %s", e)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@asynccontextmanager
|
|
72
|
+
async def lifespan(app: FastAPI):
|
|
73
|
+
# Load the cache
|
|
74
|
+
# RedisCacheBackend(settings.REDIS_URL)
|
|
75
|
+
|
|
76
|
+
graph: CompiledGraph | None = await attach_all_modules(
|
|
77
|
+
graph_config,
|
|
78
|
+
container=container,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Clean up expired temporary media cache on startup
|
|
82
|
+
await _cleanup_temp_media_cache()
|
|
83
|
+
|
|
84
|
+
# load Store
|
|
85
|
+
# store = load_store(graph_config.store_path)
|
|
86
|
+
# injector.binder.bind(BaseStore, store)
|
|
87
|
+
|
|
88
|
+
yield
|
|
89
|
+
|
|
90
|
+
# Shutdown. Order matters: drain in-flight work BEFORE tearing down the
|
|
91
|
+
# resources that work depends on.
|
|
92
|
+
#
|
|
93
|
+
# Previously this went straight to `graph.aclose()`, which released the graph's
|
|
94
|
+
# resources while background work (publisher emits, checkpoint writes) could
|
|
95
|
+
# still be in flight -- so a rolling deploy could drop the tail end of a run's
|
|
96
|
+
# events and its final checkpoint.
|
|
97
|
+
await _drain_background_tasks()
|
|
98
|
+
|
|
99
|
+
if graph:
|
|
100
|
+
# release all the resources
|
|
101
|
+
await graph.aclose()
|
|
102
|
+
|
|
103
|
+
# Close rate-limit backend (e.g. Redis connection pool)
|
|
104
|
+
backend = getattr(app.state, "rate_limit_backend", None)
|
|
105
|
+
if backend is not None:
|
|
106
|
+
await backend.close()
|
|
107
|
+
|
|
108
|
+
# Close the authorization backend's L2 Redis client, if it created one.
|
|
109
|
+
try:
|
|
110
|
+
from tenxgraph_api.src.app.core.auth.authorization import AuthorizationBackend
|
|
111
|
+
|
|
112
|
+
authz = container.try_get(AuthorizationBackend)
|
|
113
|
+
if authz is not None and hasattr(authz, "aclose"):
|
|
114
|
+
await authz.aclose()
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
logger.debug("Authorization backend close failed: %s", exc)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
async def _drain_background_tasks(timeout: float = SHUTDOWN_DRAIN_TIMEOUT_SECONDS) -> None:
|
|
120
|
+
"""Wait for in-flight background tasks (event publishing, checkpoint writes).
|
|
121
|
+
|
|
122
|
+
Bounded: if the sink is wedged we still shut down, rather than hanging the pod
|
|
123
|
+
until the kubelet SIGKILLs it -- which would lose strictly more than draining
|
|
124
|
+
for a bounded window and then giving up.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
task_manager = InjectQ.get_instance().try_get(BackgroundTaskManager)
|
|
128
|
+
except Exception: # container not configured; nothing to drain
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
if task_manager is None:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
pending = getattr(task_manager, "pending_count", 0)
|
|
135
|
+
if not pending:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
logger.info("Draining %d in-flight background tasks before shutdown", pending)
|
|
139
|
+
try:
|
|
140
|
+
await task_manager.wait_for_all(timeout=timeout)
|
|
141
|
+
logger.info("Background tasks drained")
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.warning("Background task drain did not finish cleanly: %s", e)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
app = FastAPI(
|
|
147
|
+
title=settings.APP_NAME,
|
|
148
|
+
version=settings.APP_VERSION,
|
|
149
|
+
debug=settings.MODE == "DEVELOPMENT",
|
|
150
|
+
summary=settings.SUMMARY,
|
|
151
|
+
docs_url=settings.DOCS_PATH if settings.DOCS_PATH else None,
|
|
152
|
+
redoc_url=settings.REDOCS_PATH if settings.REDOCS_PATH else None,
|
|
153
|
+
# The schema lists every route and model; serve it only alongside the docs.
|
|
154
|
+
openapi_url="/openapi.json" if settings.DOCS_PATH or settings.REDOCS_PATH else None,
|
|
155
|
+
default_response_class=ORJSONResponse,
|
|
156
|
+
lifespan=lifespan,
|
|
157
|
+
root_path=settings.ROOT_PATH,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
setup_middleware(app, graph_config=graph_config, container=container)
|
|
161
|
+
|
|
162
|
+
# attach_injector(app, injector=injector)
|
|
163
|
+
setup_fastapi(container=container, app=app)
|
|
164
|
+
|
|
165
|
+
init_logger(settings.LOG_LEVEL)
|
|
166
|
+
|
|
167
|
+
# init error handler
|
|
168
|
+
init_errors_handler(app)
|
|
169
|
+
|
|
170
|
+
# init routes
|
|
171
|
+
# The evals viewer reads local report files and has no auth, so it is a development tool only.
|
|
172
|
+
# The AG-UI endpoint is opt-in through 10xgraph.json.
|
|
173
|
+
init_routes(app, evals=settings.MODE != "production", ag_ui=graph_config.ag_ui.enabled)
|
|
174
|
+
|
|
175
|
+
# Secure by construction: refuse to boot if any non-public route forgot its
|
|
176
|
+
# RequirePermission guard (a forgotten guard would otherwise ship an open endpoint).
|
|
177
|
+
assert_all_routes_protected(app)
|
|
178
|
+
|
|
179
|
+
# instrumentator = Instrumentator().instrument(app) # Instrument first
|
|
180
|
+
# instrumentator.expose(app) # Then expose
|