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,532 @@
|
|
|
1
|
+
import ipaddress
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError, field_validator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("tenxgraph_api")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _empty_parameters() -> dict[str, Any]:
|
|
17
|
+
return {"type": "object", "properties": {}, "required": []}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RemoteToolConfig(BaseModel):
|
|
21
|
+
"""Trusted model-facing schema for a tool executed by the client."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
24
|
+
|
|
25
|
+
node_name: str = Field(alias="node", min_length=1)
|
|
26
|
+
name: str = Field(min_length=1)
|
|
27
|
+
description: str = Field(min_length=1)
|
|
28
|
+
parameters: dict[str, Any] = Field(default_factory=_empty_parameters)
|
|
29
|
+
|
|
30
|
+
@field_validator("node_name", "name", "description")
|
|
31
|
+
@classmethod
|
|
32
|
+
def _must_not_be_blank(cls, value: str) -> str:
|
|
33
|
+
value = value.strip()
|
|
34
|
+
if not value:
|
|
35
|
+
raise ValueError("must not be blank")
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
@field_validator("parameters")
|
|
39
|
+
@classmethod
|
|
40
|
+
def _validate_parameters(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
41
|
+
parameters = dict(value)
|
|
42
|
+
parameters.setdefault("type", "object")
|
|
43
|
+
parameters.setdefault("properties", {})
|
|
44
|
+
parameters.setdefault("required", [])
|
|
45
|
+
if parameters["type"] != "object":
|
|
46
|
+
raise ValueError("remote tool parameters.type must be 'object'")
|
|
47
|
+
if not isinstance(parameters["properties"], dict):
|
|
48
|
+
raise ValueError("remote tool parameters.properties must be an object")
|
|
49
|
+
if not isinstance(parameters["required"], list) or not all(
|
|
50
|
+
isinstance(item, str) for item in parameters["required"]
|
|
51
|
+
):
|
|
52
|
+
raise ValueError("remote tool parameters.required must be a list of strings")
|
|
53
|
+
return parameters
|
|
54
|
+
|
|
55
|
+
def to_tool_schema(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"type": "function",
|
|
58
|
+
"function": {
|
|
59
|
+
"name": self.name,
|
|
60
|
+
"description": self.description,
|
|
61
|
+
"parameters": self.parameters,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_REMOTE_TOOLS_ADAPTER = TypeAdapter(list[RemoteToolConfig])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_remote_tools(raw: object) -> list[RemoteToolConfig]:
|
|
70
|
+
"""Validate remote-tool declarations for both CLI audit and API startup."""
|
|
71
|
+
if not isinstance(raw, list):
|
|
72
|
+
raise ValueError("remote_tools must be a list")
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
tools = _REMOTE_TOOLS_ADAPTER.validate_python(raw)
|
|
76
|
+
except ValidationError as exc:
|
|
77
|
+
raise ValueError(f"Invalid remote_tools configuration: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
seen: set[str] = set()
|
|
80
|
+
for tool in tools:
|
|
81
|
+
if tool.name in seen:
|
|
82
|
+
raise ValueError(f"Duplicate remote tool name '{tool.name}'")
|
|
83
|
+
seen.add(tool.name)
|
|
84
|
+
return tools
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_bool(value: object, *, field: str) -> bool:
|
|
88
|
+
if isinstance(value, bool):
|
|
89
|
+
return value
|
|
90
|
+
if isinstance(value, str):
|
|
91
|
+
normalized = value.strip().lower()
|
|
92
|
+
if normalized in {"1", "true", "yes", "on"}:
|
|
93
|
+
return True
|
|
94
|
+
if normalized in {"0", "false", "no", "off"}:
|
|
95
|
+
return False
|
|
96
|
+
raise ValueError(f"{field} must be a boolean")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _expand_env(value: str | None) -> str | None:
|
|
100
|
+
if value is None:
|
|
101
|
+
return None
|
|
102
|
+
expanded = os.path.expandvars(value)
|
|
103
|
+
if expanded == value and (value.startswith("$") or "${" in value):
|
|
104
|
+
raise ValueError(f"Unresolved environment variable in value: {value}")
|
|
105
|
+
return expanded
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_networks(raw: object) -> tuple[str, ...]:
|
|
109
|
+
if not isinstance(raw, list | tuple):
|
|
110
|
+
raise ValueError("rate_limit.trusted_proxies must be a list of IPs or CIDR ranges")
|
|
111
|
+
networks = []
|
|
112
|
+
for item in raw:
|
|
113
|
+
try:
|
|
114
|
+
networks.append(str(ipaddress.ip_network(str(item).strip(), strict=False)))
|
|
115
|
+
except ValueError as exc:
|
|
116
|
+
raise ValueError(f"rate_limit.trusted_proxies: invalid network {item!r}") from exc
|
|
117
|
+
return tuple(networks)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class RateLimitConfig:
|
|
122
|
+
"""Rate limit configuration parsed from 10xgraph.json.
|
|
123
|
+
|
|
124
|
+
Example (memory backend, default)::
|
|
125
|
+
|
|
126
|
+
"rate_limit": {
|
|
127
|
+
"enabled": true,
|
|
128
|
+
"backend": "memory",
|
|
129
|
+
"requests": 100,
|
|
130
|
+
"window": 60,
|
|
131
|
+
"by": "ip",
|
|
132
|
+
"trusted_proxy_headers": false,
|
|
133
|
+
"exclude_paths": ["/ping", "/docs", "/redoc", "/openapi.json"]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
Example (Redis backend)::
|
|
137
|
+
|
|
138
|
+
"rate_limit": {
|
|
139
|
+
"enabled": true,
|
|
140
|
+
"backend": "redis",
|
|
141
|
+
"requests": 100,
|
|
142
|
+
"window": 60,
|
|
143
|
+
"by": "ip",
|
|
144
|
+
"trusted_proxy_headers": true,
|
|
145
|
+
"exclude_paths": ["/ping"],
|
|
146
|
+
"redis": {
|
|
147
|
+
"url": "redis://localhost:6379/0",
|
|
148
|
+
"prefix": "10xgraph:rate-limit"
|
|
149
|
+
},
|
|
150
|
+
"fail_open": true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
Example (custom backend)::
|
|
154
|
+
|
|
155
|
+
"rate_limit": {
|
|
156
|
+
"enabled": true,
|
|
157
|
+
"backend": "custom",
|
|
158
|
+
"requests": 100,
|
|
159
|
+
"window": 60,
|
|
160
|
+
"by": "ip"
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
For custom backends, bind a ``BaseRateLimitBackend`` instance in InjectQ.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
enabled: bool
|
|
167
|
+
requests: int
|
|
168
|
+
window: int
|
|
169
|
+
by: str # "ip" | "user" | "global"
|
|
170
|
+
backend: str # "memory" | "redis" | "custom"
|
|
171
|
+
redis_url: str | None
|
|
172
|
+
redis_prefix: str
|
|
173
|
+
exclude_paths: tuple[str, ...]
|
|
174
|
+
trusted_proxy_headers: bool # honour X-Forwarded-For only when True
|
|
175
|
+
# Number of proxies you control in front of the app. Only this many entries,
|
|
176
|
+
# counted from the RIGHT of X-Forwarded-For, were appended by your own
|
|
177
|
+
# infrastructure; anything further left came from the caller and is forgeable.
|
|
178
|
+
trusted_proxy_hops: int = 1
|
|
179
|
+
fail_open: bool = True # on backend error: True=allow, False=deny
|
|
180
|
+
# Networks your proxies connect from. When set, X-Forwarded-For is honoured only for
|
|
181
|
+
# requests whose peer address is in one of them, so a client that reaches the app
|
|
182
|
+
# directly (bypassing the proxy) cannot pick its own address.
|
|
183
|
+
trusted_proxies: tuple[str, ...] = ()
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_dict(cls, data: dict) -> "RateLimitConfig":
|
|
187
|
+
if not isinstance(data, dict):
|
|
188
|
+
raise ValueError("rate_limit must be an object")
|
|
189
|
+
|
|
190
|
+
enabled = _parse_bool(data.get("enabled", True), field="rate_limit.enabled")
|
|
191
|
+
requests = int(data.get("requests", 100))
|
|
192
|
+
window = int(data.get("window", 60))
|
|
193
|
+
by = data.get("by", "ip")
|
|
194
|
+
backend = data.get("backend", "memory")
|
|
195
|
+
trusted_proxy_headers = _parse_bool(
|
|
196
|
+
data.get("trusted_proxy_headers", False),
|
|
197
|
+
field="rate_limit.trusted_proxy_headers",
|
|
198
|
+
)
|
|
199
|
+
exclude_paths_raw = data.get("exclude_paths", [])
|
|
200
|
+
if not isinstance(exclude_paths_raw, list | tuple):
|
|
201
|
+
raise ValueError("rate_limit.exclude_paths must be a list of paths")
|
|
202
|
+
exclude_paths = tuple(str(path) for path in exclude_paths_raw)
|
|
203
|
+
fail_open = _parse_bool(data.get("fail_open", True), field="rate_limit.fail_open")
|
|
204
|
+
|
|
205
|
+
# Redis sub-object: {"url": "...", "prefix": "..."}
|
|
206
|
+
redis_obj = data.get("redis") or {}
|
|
207
|
+
if isinstance(redis_obj, str):
|
|
208
|
+
# Allow shorthand: "redis": "redis://..."
|
|
209
|
+
redis_url: str | None = _expand_env(redis_obj)
|
|
210
|
+
redis_prefix = "10xgraph:rate-limit"
|
|
211
|
+
elif isinstance(redis_obj, dict):
|
|
212
|
+
redis_url = _expand_env(redis_obj.get("url") or None)
|
|
213
|
+
redis_prefix = str(redis_obj.get("prefix", "10xgraph:rate-limit"))
|
|
214
|
+
else:
|
|
215
|
+
raise ValueError("rate_limit.redis must be an object or Redis URL string")
|
|
216
|
+
|
|
217
|
+
# How many proxies of YOUR OWN sit in front of the app and append to
|
|
218
|
+
# X-Forwarded-For. Only that many entries, counted from the right, were
|
|
219
|
+
# written by infrastructure you control; everything to the left of them came
|
|
220
|
+
# from the caller and is forgeable. See keying._client_ip.
|
|
221
|
+
trusted_proxy_hops = int(data.get("trusted_proxy_hops", 1))
|
|
222
|
+
trusted_proxies = _parse_networks(data.get("trusted_proxies", []))
|
|
223
|
+
|
|
224
|
+
# Validation
|
|
225
|
+
if by not in ("ip", "global", "user"):
|
|
226
|
+
raise ValueError(f"rate_limit.by must be 'ip', 'user', or 'global', got '{by}'")
|
|
227
|
+
if backend not in ("memory", "redis", "custom"):
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"rate_limit.backend must be 'memory', 'redis', or 'custom', got '{backend}'"
|
|
230
|
+
)
|
|
231
|
+
if requests <= 0:
|
|
232
|
+
raise ValueError("rate_limit.requests must be a positive integer")
|
|
233
|
+
if window <= 0:
|
|
234
|
+
raise ValueError("rate_limit.window must be a positive integer")
|
|
235
|
+
if trusted_proxy_hops < 1:
|
|
236
|
+
raise ValueError("rate_limit.trusted_proxy_hops must be >= 1")
|
|
237
|
+
|
|
238
|
+
# The memory backend counts per PROCESS. Under gunicorn with N workers the
|
|
239
|
+
# effective limit therefore becomes requests x N, and it resets whenever a
|
|
240
|
+
# worker restarts -- so it does not really limit anything in production.
|
|
241
|
+
if enabled and backend == "memory":
|
|
242
|
+
logger.warning(
|
|
243
|
+
"Rate limiting uses the in-memory backend. It counts per process, so "
|
|
244
|
+
"with N workers the real limit is requests x N and it resets on every "
|
|
245
|
+
"worker restart. Use the 'redis' backend for any multi-worker deployment."
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return cls(
|
|
249
|
+
enabled=enabled,
|
|
250
|
+
requests=requests,
|
|
251
|
+
window=window,
|
|
252
|
+
by=by,
|
|
253
|
+
backend=backend,
|
|
254
|
+
redis_url=redis_url,
|
|
255
|
+
redis_prefix=redis_prefix,
|
|
256
|
+
exclude_paths=exclude_paths,
|
|
257
|
+
trusted_proxy_headers=trusted_proxy_headers,
|
|
258
|
+
trusted_proxy_hops=trusted_proxy_hops,
|
|
259
|
+
fail_open=fail_open,
|
|
260
|
+
trusted_proxies=trusted_proxies,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# Finite defaults so an unconfigured server cannot be held open by one client.
|
|
265
|
+
DEFAULT_WS_MAX_CONNECTIONS = 1000
|
|
266
|
+
DEFAULT_WS_MAX_CONNECTIONS_PER_USER = 10
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@dataclass
|
|
270
|
+
class WebSocketConfig:
|
|
271
|
+
"""WebSocket connection limits parsed from 10xgraph.json.
|
|
272
|
+
|
|
273
|
+
Example::
|
|
274
|
+
|
|
275
|
+
"websocket": {
|
|
276
|
+
"max_connections": 1000,
|
|
277
|
+
"max_connections_per_user": 10,
|
|
278
|
+
"realtime_models": ["gemini-2.5-flash-live"]
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
``max_connections`` caps the concurrent WebSocket connections this server *process*
|
|
282
|
+
accepts (realtime ``/v1/graph/live`` + streaming ``/v1/graph/ws``).
|
|
283
|
+
``max_connections_per_user`` caps how many of those one verified user may hold, so a
|
|
284
|
+
single account cannot take every slot. Both are per process, like the in-memory
|
|
285
|
+
rate-limit backend; size them per worker.
|
|
286
|
+
|
|
287
|
+
A missing key gets a finite default (1000 and 10). Set a key to ``0`` or ``null`` to make
|
|
288
|
+
it unlimited. WebSocket handshakes, and every graph run started over ``/v1/graph/ws``, also
|
|
289
|
+
count against the global ``rate_limit`` bucket shared with REST requests.
|
|
290
|
+
|
|
291
|
+
``realtime_models`` lists the models a ``/v1/graph/live`` client may ask for in its init
|
|
292
|
+
frame. Any other requested model is ignored and the agent's own model is used. Empty (the
|
|
293
|
+
default) means clients cannot choose the model.
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
max_connections: int | None
|
|
297
|
+
max_connections_per_user: int | None = DEFAULT_WS_MAX_CONNECTIONS_PER_USER
|
|
298
|
+
realtime_models: tuple[str, ...] = ()
|
|
299
|
+
|
|
300
|
+
@classmethod
|
|
301
|
+
def from_dict(cls, data: dict) -> "WebSocketConfig":
|
|
302
|
+
if not isinstance(data, dict):
|
|
303
|
+
raise ValueError("websocket must be an object")
|
|
304
|
+
return cls(
|
|
305
|
+
max_connections=_connection_limit(data, "max_connections", DEFAULT_WS_MAX_CONNECTIONS),
|
|
306
|
+
max_connections_per_user=_connection_limit(
|
|
307
|
+
data, "max_connections_per_user", DEFAULT_WS_MAX_CONNECTIONS_PER_USER
|
|
308
|
+
),
|
|
309
|
+
realtime_models=_string_list(data, "realtime_models"),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _string_list(data: dict, key: str) -> tuple[str, ...]:
|
|
314
|
+
raw = data.get(key)
|
|
315
|
+
if raw is None:
|
|
316
|
+
return ()
|
|
317
|
+
if not isinstance(raw, list) or not all(isinstance(item, str) for item in raw):
|
|
318
|
+
raise ValueError(f"websocket.{key} must be a list of strings")
|
|
319
|
+
return tuple(item.strip() for item in raw if item.strip())
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _connection_limit(data: dict, key: str, default: int) -> int | None:
|
|
323
|
+
"""A missing key gets ``default``; an explicit ``0`` or ``null`` means unlimited."""
|
|
324
|
+
if key not in data:
|
|
325
|
+
return default
|
|
326
|
+
raw = data[key]
|
|
327
|
+
if raw in (None, 0):
|
|
328
|
+
return None
|
|
329
|
+
value = int(raw)
|
|
330
|
+
if value < 0:
|
|
331
|
+
raise ValueError(f"websocket.{key} must be a non-negative integer")
|
|
332
|
+
return value
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@dataclass
|
|
336
|
+
class AgUiConfig:
|
|
337
|
+
"""AG-UI protocol endpoint settings parsed from 10xgraph.json.
|
|
338
|
+
|
|
339
|
+
Example::
|
|
340
|
+
|
|
341
|
+
"ag_ui": {
|
|
342
|
+
"enabled": true,
|
|
343
|
+
"allow_client_tools": true
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
When enabled, ``POST /v1/ag-ui`` streams the graph as AG-UI events, so AG-UI clients
|
|
347
|
+
such as CopilotKit can use the agent. It needs the ``ag-ui`` extra
|
|
348
|
+
(``pip install "10xgraph-api[ag-ui]"``). Off unless explicitly enabled.
|
|
349
|
+
|
|
350
|
+
``allow_client_tools`` (default ``true``) offers the tools an AG-UI client sends with each
|
|
351
|
+
run to the model for that run. They run in the caller's own browser, cannot take the name of
|
|
352
|
+
a server tool, and must pass size limits. Set it to ``false`` to accept only the client tools
|
|
353
|
+
declared under ``remote_tools``.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
enabled: bool = False
|
|
357
|
+
allow_client_tools: bool = True
|
|
358
|
+
|
|
359
|
+
@classmethod
|
|
360
|
+
def from_dict(cls, data: dict) -> "AgUiConfig":
|
|
361
|
+
if not isinstance(data, dict):
|
|
362
|
+
raise ValueError("ag_ui must be an object")
|
|
363
|
+
return cls(
|
|
364
|
+
enabled=_parse_bool(data.get("enabled", False), field="ag_ui.enabled"),
|
|
365
|
+
allow_client_tools=_parse_bool(
|
|
366
|
+
data.get("allow_client_tools", True), field="ag_ui.allow_client_tools"
|
|
367
|
+
),
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
CONFIG_FILE = "10xgraph.json"
|
|
372
|
+
# Pre-rename name, read only when no 10xgraph.json exists. Removed in 2.0.
|
|
373
|
+
LEGACY_CONFIG_FILE = "agentflow.json"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def default_config_path() -> str:
|
|
377
|
+
"""``10xgraph.json`` in the cwd, or ``agentflow.json`` when only the legacy file exists."""
|
|
378
|
+
if not Path(CONFIG_FILE).exists() and Path(LEGACY_CONFIG_FILE).exists():
|
|
379
|
+
return LEGACY_CONFIG_FILE
|
|
380
|
+
return CONFIG_FILE
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
class GraphConfig:
|
|
384
|
+
def __init__(self, path: str | None = None):
|
|
385
|
+
with Path(path or default_config_path()).open() as f:
|
|
386
|
+
self.data: dict = json.load(f)
|
|
387
|
+
|
|
388
|
+
# load .env file
|
|
389
|
+
env_file = self.data.get("env")
|
|
390
|
+
if env_file and Path(env_file).exists():
|
|
391
|
+
load_dotenv(env_file)
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def graph_path(self) -> str:
|
|
395
|
+
agent = self.data.get("agent")
|
|
396
|
+
if agent:
|
|
397
|
+
return agent
|
|
398
|
+
|
|
399
|
+
raise ValueError("Agent graph not found")
|
|
400
|
+
|
|
401
|
+
@property
|
|
402
|
+
def checkpointer_path(self) -> str | None:
|
|
403
|
+
return self.data.get("checkpointer", None)
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def injectq_path(self) -> str | None:
|
|
407
|
+
return self.data.get("injectq", None)
|
|
408
|
+
|
|
409
|
+
@property
|
|
410
|
+
def store_path(self) -> str | None:
|
|
411
|
+
return self.data.get("store", None)
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def redis_url(self) -> str | None:
|
|
415
|
+
return self.data.get("redis", None)
|
|
416
|
+
|
|
417
|
+
@property
|
|
418
|
+
def thread_name_generator_path(self) -> str | None:
|
|
419
|
+
return self.data.get("thread_name_generator", None)
|
|
420
|
+
|
|
421
|
+
@property
|
|
422
|
+
def remote_tools(self) -> list[RemoteToolConfig]:
|
|
423
|
+
"""Validated client-executed tool schemas configured at startup."""
|
|
424
|
+
return validate_remote_tools(self.data.get("remote_tools", []))
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def observability(self) -> dict | None:
|
|
428
|
+
"""The declarative ``observability`` block (Logfire / LangSmith).
|
|
429
|
+
|
|
430
|
+
Shape mirrors ``tenxgraph.runtime.publisher.setup_observability``::
|
|
431
|
+
|
|
432
|
+
{
|
|
433
|
+
"level": "standard",
|
|
434
|
+
"logfire": {"enabled": true, "service_name": "my-agent", ...},
|
|
435
|
+
"langsmith": {"enabled": true, "project": "my-agent", "endpoint": null}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
Secrets (``LOGFIRE_TOKEN``, ``LANGSMITH_API_KEY``) stay in the
|
|
439
|
+
environment and are never read from here.
|
|
440
|
+
"""
|
|
441
|
+
return self.data.get("observability", None)
|
|
442
|
+
|
|
443
|
+
@property
|
|
444
|
+
def authorization_path(self) -> str | None:
|
|
445
|
+
"""
|
|
446
|
+
Get the authorization backend selector from configuration.
|
|
447
|
+
|
|
448
|
+
Accepts, in order of precedence:
|
|
449
|
+
- ``"module:attribute"`` -- a custom ``AuthorizationBackend`` to load.
|
|
450
|
+
- a built-in name: ``"ownership"`` (owner-only thread access) or
|
|
451
|
+
``"allow_all"``/``"default"``/``"none"`` (any authenticated user).
|
|
452
|
+
- ``None`` (not configured) -- defaults by run mode: ``ownership`` in
|
|
453
|
+
production, ``allow_all`` in development.
|
|
454
|
+
|
|
455
|
+
Returns:
|
|
456
|
+
str | None: The configured selector, or None to use the mode default.
|
|
457
|
+
"""
|
|
458
|
+
return self.data.get("authorization", None)
|
|
459
|
+
|
|
460
|
+
def auth_config(self) -> dict | None:
|
|
461
|
+
res = self.data.get("auth", None)
|
|
462
|
+
if not res:
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
if isinstance(res, str) and "jwt" in res:
|
|
466
|
+
# Now check jwt secret and algorithm available in env
|
|
467
|
+
secret = os.environ.get("JWT_SECRET_KEY", None)
|
|
468
|
+
algorithm = os.environ.get("JWT_ALGORITHM", None)
|
|
469
|
+
if not secret or not algorithm:
|
|
470
|
+
raise ValueError(
|
|
471
|
+
"JWT_SECRET_KEY and JWT_ALGORITHM must be set in environment variables",
|
|
472
|
+
)
|
|
473
|
+
return {
|
|
474
|
+
"method": "jwt",
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if isinstance(res, dict):
|
|
478
|
+
method = res.get("method", None)
|
|
479
|
+
path: str | None = res.get("path", None)
|
|
480
|
+
if not path or not method:
|
|
481
|
+
raise ValueError("Both method and path must be provided in auth config")
|
|
482
|
+
|
|
483
|
+
if method == "custom" and path:
|
|
484
|
+
return {
|
|
485
|
+
"method": "custom",
|
|
486
|
+
"path": path,
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
raise ValueError(f"Unsupported auth method: {res}")
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def rate_limit(self) -> RateLimitConfig | None:
|
|
493
|
+
"""
|
|
494
|
+
Get rate limit configuration from 10xgraph.json.
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
RateLimitConfig if 'rate_limit' key is present and enabled, else None.
|
|
498
|
+
|
|
499
|
+
Example 10xgraph.json entry::
|
|
500
|
+
|
|
501
|
+
"rate_limit": {
|
|
502
|
+
"enabled": true,
|
|
503
|
+
"requests": 100,
|
|
504
|
+
"window": 60,
|
|
505
|
+
"by": "ip"
|
|
506
|
+
}
|
|
507
|
+
"""
|
|
508
|
+
data = self.data.get("rate_limit", None)
|
|
509
|
+
if data is None:
|
|
510
|
+
return None
|
|
511
|
+
config = RateLimitConfig.from_dict(data)
|
|
512
|
+
if not config.enabled:
|
|
513
|
+
return None
|
|
514
|
+
return config
|
|
515
|
+
|
|
516
|
+
@property
|
|
517
|
+
def websocket(self) -> "WebSocketConfig":
|
|
518
|
+
"""WebSocket connection limits from 10xgraph.json (``websocket`` key).
|
|
519
|
+
|
|
520
|
+
Returns the default limits when the key is absent.
|
|
521
|
+
"""
|
|
522
|
+
data = self.data.get("websocket", None)
|
|
523
|
+
return WebSocketConfig.from_dict({} if data is None else data)
|
|
524
|
+
|
|
525
|
+
@property
|
|
526
|
+
def ag_ui(self) -> AgUiConfig:
|
|
527
|
+
"""AG-UI endpoint settings from 10xgraph.json (``ag_ui`` key).
|
|
528
|
+
|
|
529
|
+
Returns a disabled config when the key is absent.
|
|
530
|
+
"""
|
|
531
|
+
data = self.data.get("ag_ui", None)
|
|
532
|
+
return AgUiConfig.from_dict({} if data is None else data)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Media / multimodal configuration loaded from environment variables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from functools import lru_cache
|
|
8
|
+
|
|
9
|
+
from pydantic import ConfigDict
|
|
10
|
+
from pydantic_settings import BaseSettings
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("tenxgraph_api.media")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MediaStorageType(StrEnum):
|
|
17
|
+
MEMORY = "memory"
|
|
18
|
+
LOCAL = "local"
|
|
19
|
+
CLOUD = "cloud"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MediaSettings(BaseSettings):
|
|
23
|
+
"""Settings loaded from env vars (prefix-free, matches plan)."""
|
|
24
|
+
|
|
25
|
+
MEDIA_STORAGE_TYPE: MediaStorageType = MediaStorageType.LOCAL
|
|
26
|
+
MEDIA_STORAGE_PATH: str = "./uploads"
|
|
27
|
+
MEDIA_MAX_SIZE_MB: float = 25.0
|
|
28
|
+
DOCUMENT_HANDLING: str = "extract_text" # extract_text | pass_raw | skip
|
|
29
|
+
|
|
30
|
+
# Comma-separated content-type allowlist for uploads. Empty (the default) means
|
|
31
|
+
# accept any type -- the developer's call. Set e.g.
|
|
32
|
+
# ``MEDIA_ALLOWED_CONTENT_TYPES=image/*,application/pdf`` to restrict. Entries may
|
|
33
|
+
# be exact (``image/png``) or wildcard subtype (``image/*``).
|
|
34
|
+
MEDIA_ALLOWED_CONTENT_TYPES: str = ""
|
|
35
|
+
|
|
36
|
+
# Deny files with no recorded owner (uploaded before ownership tracking, or while auth
|
|
37
|
+
# was off) instead of letting any authenticated user read them.
|
|
38
|
+
MEDIA_REQUIRE_OWNER: bool = False
|
|
39
|
+
|
|
40
|
+
def allowed_content_types(self) -> list[str]:
|
|
41
|
+
"""Parsed, normalized allowlist. Empty list == allow all."""
|
|
42
|
+
return [t.strip().lower() for t in self.MEDIA_ALLOWED_CONTENT_TYPES.split(",") if t.strip()]
|
|
43
|
+
|
|
44
|
+
def is_content_type_allowed(self, mime: str) -> bool:
|
|
45
|
+
allow = self.allowed_content_types()
|
|
46
|
+
if not allow:
|
|
47
|
+
return True
|
|
48
|
+
mime = mime.split(";", 1)[0].strip().lower()
|
|
49
|
+
top = mime.split("/", 1)[0]
|
|
50
|
+
return mime in allow or f"{top}/*" in allow
|
|
51
|
+
|
|
52
|
+
# Cloud storage (S3/GCS) — only used when MEDIA_STORAGE_TYPE=cloud
|
|
53
|
+
MEDIA_CLOUD_PROVIDER: str = "aws" # aws | gcp
|
|
54
|
+
MEDIA_CLOUD_BUCKET: str = ""
|
|
55
|
+
MEDIA_CLOUD_REGION: str = "us-east-1"
|
|
56
|
+
# The default prefix also reads objects stored under the pre-rename "agentflow-media".
|
|
57
|
+
MEDIA_CLOUD_PREFIX: str = "10xgraph-media"
|
|
58
|
+
MEDIA_CLOUD_ACCESS_KEY_ID: str | None = None
|
|
59
|
+
MEDIA_CLOUD_SECRET_ACCESS_KEY: str | None = None
|
|
60
|
+
MEDIA_CLOUD_SESSION_TOKEN: str | None = None
|
|
61
|
+
MEDIA_CLOUD_PROJECT_ID: str | None = None
|
|
62
|
+
MEDIA_CLOUD_CREDENTIALS_JSON: str | None = None
|
|
63
|
+
|
|
64
|
+
MEDIA_SIGNED_URL_TTL_SECONDS: int = 3600
|
|
65
|
+
MEDIA_SIGNED_URL_REFRESH_BUFFER_SECONDS: int = 60
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="allow")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@lru_cache
|
|
71
|
+
def get_media_settings() -> MediaSettings:
|
|
72
|
+
return MediaSettings() # type: ignore[call-arg]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from fastapi import Depends
|
|
2
|
+
|
|
3
|
+
from tenxgraph_api.src.app.core import Settings, get_settings, logger
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Only server faults are Sentry events. A 403 is authorization doing its job; reporting
|
|
7
|
+
# every one lets any client flood the project and bury real errors.
|
|
8
|
+
SENTRY_FAILED_STATUS_CODES = frozenset(range(500, 600))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def init_sentry(settings: Settings = Depends(get_settings)) -> None:
|
|
12
|
+
"""Initialize Sentry for error tracking and performance monitoring.
|
|
13
|
+
|
|
14
|
+
The initialization is best-effort: if ``sentry_sdk`` isn't installed or any
|
|
15
|
+
unexpected error occurs, the application continues to run and a warning is
|
|
16
|
+
logged instead of failing hard.
|
|
17
|
+
"""
|
|
18
|
+
environment = settings.MODE.upper() if settings.MODE else ""
|
|
19
|
+
|
|
20
|
+
if not settings.SENTRY_DSN:
|
|
21
|
+
logger.warning(
|
|
22
|
+
"Sentry is not configured. Sentry DSN is not set or running in local environment."
|
|
23
|
+
)
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
allowed_environments = ["PRODUCTION", "STAGING", "DEVELOPMENT"]
|
|
27
|
+
if environment not in allowed_environments:
|
|
28
|
+
logger.warning(
|
|
29
|
+
f"Sentry is not configured for this environment: {environment}. "
|
|
30
|
+
f"Allowed environments are: {allowed_environments}"
|
|
31
|
+
)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
logger.info(f"Sentry is configured for environment: {environment}")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
import sentry_sdk
|
|
38
|
+
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
|
39
|
+
from sentry_sdk.integrations.starlette import StarletteIntegration
|
|
40
|
+
|
|
41
|
+
sentry_sdk.init(
|
|
42
|
+
dsn=settings.SENTRY_DSN,
|
|
43
|
+
integrations=[
|
|
44
|
+
FastApiIntegration(
|
|
45
|
+
transaction_style="endpoint",
|
|
46
|
+
failed_request_status_codes=SENTRY_FAILED_STATUS_CODES,
|
|
47
|
+
),
|
|
48
|
+
StarletteIntegration(
|
|
49
|
+
transaction_style="endpoint",
|
|
50
|
+
failed_request_status_codes=SENTRY_FAILED_STATUS_CODES,
|
|
51
|
+
),
|
|
52
|
+
],
|
|
53
|
+
traces_sample_rate=settings.SENTRY_TRACES_SAMPLE_RATE,
|
|
54
|
+
profiles_sample_rate=settings.SENTRY_PROFILES_SAMPLE_RATE,
|
|
55
|
+
)
|
|
56
|
+
logger.debug("Sentry initialized")
|
|
57
|
+
except ImportError:
|
|
58
|
+
logger.warning("sentry_sdk not installed; install '10xgraph-api[sentry]' to enable Sentry")
|
|
59
|
+
except Exception as exc: # intentionally broad: init must not crash app
|
|
60
|
+
logger.warning("Error initializing Sentry: %s", exc)
|