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.
Files changed (203) hide show
  1. 10xgraph_api-0.7.0.dist-info/METADATA +618 -0
  2. 10xgraph_api-0.7.0.dist-info/RECORD +203 -0
  3. 10xgraph_api-0.7.0.dist-info/WHEEL +5 -0
  4. 10xgraph_api-0.7.0.dist-info/entry_points.txt +3 -0
  5. 10xgraph_api-0.7.0.dist-info/licenses/LICENSE +21 -0
  6. 10xgraph_api-0.7.0.dist-info/top_level.txt +2 -0
  7. agentflow_cli/__init__.py +69 -0
  8. tenxgraph_api/__init__.py +23 -0
  9. tenxgraph_api/cli/__init__.py +6 -0
  10. tenxgraph_api/cli/capabilities.py +127 -0
  11. tenxgraph_api/cli/commands/__init__.py +49 -0
  12. tenxgraph_api/cli/commands/api.py +262 -0
  13. tenxgraph_api/cli/commands/audit.py +213 -0
  14. tenxgraph_api/cli/commands/build.py +305 -0
  15. tenxgraph_api/cli/commands/config.py +71 -0
  16. tenxgraph_api/cli/commands/demo.py +93 -0
  17. tenxgraph_api/cli/commands/eval.py +909 -0
  18. tenxgraph_api/cli/commands/init.py +531 -0
  19. tenxgraph_api/cli/commands/skills.py +532 -0
  20. tenxgraph_api/cli/commands/test.py +113 -0
  21. tenxgraph_api/cli/commands/version.py +47 -0
  22. tenxgraph_api/cli/config_editor/__init__.py +15 -0
  23. tenxgraph_api/cli/config_editor/schema.py +566 -0
  24. tenxgraph_api/cli/config_editor/server.py +240 -0
  25. tenxgraph_api/cli/config_editor/static/app.css +2 -0
  26. tenxgraph_api/cli/config_editor/static/app.js +3 -0
  27. tenxgraph_api/cli/config_editor/static/favicon.svg +11 -0
  28. tenxgraph_api/cli/config_editor/static/index.html +20 -0
  29. tenxgraph_api/cli/config_editor/store.py +120 -0
  30. tenxgraph_api/cli/config_editor/validation.py +377 -0
  31. tenxgraph_api/cli/constants.py +112 -0
  32. tenxgraph_api/cli/context.py +46 -0
  33. tenxgraph_api/cli/core/__init__.py +0 -0
  34. tenxgraph_api/cli/core/animations.py +550 -0
  35. tenxgraph_api/cli/core/config.py +279 -0
  36. tenxgraph_api/cli/core/output.py +625 -0
  37. tenxgraph_api/cli/core/prompts.py +220 -0
  38. tenxgraph_api/cli/core/screen.py +287 -0
  39. tenxgraph_api/cli/core/steps.py +621 -0
  40. tenxgraph_api/cli/core/theme.py +242 -0
  41. tenxgraph_api/cli/core/validation.py +251 -0
  42. tenxgraph_api/cli/exceptions.py +129 -0
  43. tenxgraph_api/cli/logger.py +84 -0
  44. tenxgraph_api/cli/main.py +936 -0
  45. tenxgraph_api/cli/templates/__init__.py +0 -0
  46. tenxgraph_api/cli/templates/defaults.py +844 -0
  47. tenxgraph_api/cli/templates/dev/.env.example +1 -0
  48. tenxgraph_api/cli/templates/dev/10xgraph.json +16 -0
  49. tenxgraph_api/cli/templates/dev/graph/__init__.py +0 -0
  50. tenxgraph_api/cli/templates/dev/graph/agent.py +63 -0
  51. tenxgraph_api/cli/templates/prod/.env.example +75 -0
  52. tenxgraph_api/cli/templates/prod/.pre-commit-config.yaml +50 -0
  53. tenxgraph_api/cli/templates/prod/.python-version +1 -0
  54. tenxgraph_api/cli/templates/prod/10xgraph.json +42 -0
  55. tenxgraph_api/cli/templates/prod/auth/__init__.py +0 -0
  56. tenxgraph_api/cli/templates/prod/auth/agent_auth.py +46 -0
  57. tenxgraph_api/cli/templates/prod/evals/__init__.py +0 -0
  58. tenxgraph_api/cli/templates/prod/evals/user_simulator_eval.py +76 -0
  59. tenxgraph_api/cli/templates/prod/evals/weather_agents_eval.py +53 -0
  60. tenxgraph_api/cli/templates/prod/graph/__init__.py +0 -0
  61. tenxgraph_api/cli/templates/prod/graph/agent.py +50 -0
  62. tenxgraph_api/cli/templates/prod/graph/state.py +5 -0
  63. tenxgraph_api/cli/templates/prod/graph/thread_name_generator.py +7 -0
  64. tenxgraph_api/cli/templates/prod/graph/tools/__init__.py +0 -0
  65. tenxgraph_api/cli/templates/prod/graph/tools/weather_tool.py +43 -0
  66. tenxgraph_api/cli/templates/prod/graph/validators/__init__.py +0 -0
  67. tenxgraph_api/cli/templates/prod/graph/validators/lifecyle.py +107 -0
  68. tenxgraph_api/cli/templates/prod/graph/validators/manager.py +9 -0
  69. tenxgraph_api/cli/templates/prod/graph/validators/validators.py +21 -0
  70. tenxgraph_api/cli/templates/prod/pyproject.toml +79 -0
  71. tenxgraph_api/cli/templates/prod/tests/__init__.py +0 -0
  72. tenxgraph_api/cli/templates/prod/tests/conftest.py +6 -0
  73. tenxgraph_api/cli/templates/prod/tests/test_agent_eval.py +78 -0
  74. tenxgraph_api/cli/templates/prod/tests/test_catalog_tools.py +47 -0
  75. tenxgraph_api/cli/templates/prod/tests/test_graph_nodes.py +57 -0
  76. tenxgraph_api/cli/templates/skills/10xgraph/SKILL.md +92 -0
  77. tenxgraph_api/cli/templates/skills/10xgraph/references/agents-and-tools.md +389 -0
  78. tenxgraph_api/cli/templates/skills/10xgraph/references/api-client.md +61 -0
  79. tenxgraph_api/cli/templates/skills/10xgraph/references/api-configuration.md +75 -0
  80. tenxgraph_api/cli/templates/skills/10xgraph/references/api-settings-and-middleware.md +89 -0
  81. tenxgraph_api/cli/templates/skills/10xgraph/references/architecture.md +91 -0
  82. tenxgraph_api/cli/templates/skills/10xgraph/references/auth-and-authorization.md +149 -0
  83. tenxgraph_api/cli/templates/skills/10xgraph/references/callbacks-and-command.md +283 -0
  84. tenxgraph_api/cli/templates/skills/10xgraph/references/checkpointing-and-threads.md +63 -0
  85. tenxgraph_api/cli/templates/skills/10xgraph/references/cli-commands.md +73 -0
  86. tenxgraph_api/cli/templates/skills/10xgraph/references/client-auth-and-errors.md +94 -0
  87. tenxgraph_api/cli/templates/skills/10xgraph/references/client-messages-invoke-stream.md +115 -0
  88. tenxgraph_api/cli/templates/skills/10xgraph/references/client-threads-memory-files.md +80 -0
  89. tenxgraph_api/cli/templates/skills/10xgraph/references/context-id-background.md +80 -0
  90. tenxgraph_api/cli/templates/skills/10xgraph/references/dependency-injection.md +58 -0
  91. tenxgraph_api/cli/templates/skills/10xgraph/references/evaluation.md +500 -0
  92. tenxgraph_api/cli/templates/skills/10xgraph/references/id-and-thread-name-generators.md +69 -0
  93. tenxgraph_api/cli/templates/skills/10xgraph/references/media-and-files.md +66 -0
  94. tenxgraph_api/cli/templates/skills/10xgraph/references/memory-and-store.md +70 -0
  95. tenxgraph_api/cli/templates/skills/10xgraph/references/prebuilt-agents-and-tools.md +394 -0
  96. tenxgraph_api/cli/templates/skills/10xgraph/references/production-runtime.md +56 -0
  97. tenxgraph_api/cli/templates/skills/10xgraph/references/providers-and-adapters.md +89 -0
  98. tenxgraph_api/cli/templates/skills/10xgraph/references/publishers-and-runtime-protocols.md +84 -0
  99. tenxgraph_api/cli/templates/skills/10xgraph/references/rate-limiting.md +194 -0
  100. tenxgraph_api/cli/templates/skills/10xgraph/references/realtime.md +172 -0
  101. tenxgraph_api/cli/templates/skills/10xgraph/references/remote-tools.md +55 -0
  102. tenxgraph_api/cli/templates/skills/10xgraph/references/rest-api-and-errors.md +97 -0
  103. tenxgraph_api/cli/templates/skills/10xgraph/references/security-and-validators.md +86 -0
  104. tenxgraph_api/cli/templates/skills/10xgraph/references/state-and-messages.md +68 -0
  105. tenxgraph_api/cli/templates/skills/10xgraph/references/state-graph.md +232 -0
  106. tenxgraph_api/cli/templates/skills/10xgraph/references/stream-emitter.md +144 -0
  107. tenxgraph_api/cli/templates/skills/10xgraph/references/streaming.md +53 -0
  108. tenxgraph_api/cli/templates/skills/10xgraph/references/testing-and-evaluation.md +64 -0
  109. tenxgraph_api/cli/templates/skills/10xgraph/references/unit-testing.md +202 -0
  110. tenxgraph_api/cli/templates/skills/copilot/10xgraph.instructions.md +108 -0
  111. tenxgraph_api/py.typed +0 -0
  112. tenxgraph_api/src/__init__.py +0 -0
  113. tenxgraph_api/src/app/__init__.py +13 -0
  114. tenxgraph_api/src/app/core/__init__.py +15 -0
  115. tenxgraph_api/src/app/core/auth/__init__.py +0 -0
  116. tenxgraph_api/src/app/core/auth/auth_backend.py +38 -0
  117. tenxgraph_api/src/app/core/auth/authorization.py +379 -0
  118. tenxgraph_api/src/app/core/auth/base_auth.py +27 -0
  119. tenxgraph_api/src/app/core/auth/jwt_auth.py +122 -0
  120. tenxgraph_api/src/app/core/auth/ownership_resolver.py +170 -0
  121. tenxgraph_api/src/app/core/auth/permissions.py +354 -0
  122. tenxgraph_api/src/app/core/auth/request_config.py +130 -0
  123. tenxgraph_api/src/app/core/auth/route_guard.py +105 -0
  124. tenxgraph_api/src/app/core/config/__init__.py +0 -0
  125. tenxgraph_api/src/app/core/config/graph_config.py +532 -0
  126. tenxgraph_api/src/app/core/config/media_settings.py +72 -0
  127. tenxgraph_api/src/app/core/config/sentry_config.py +60 -0
  128. tenxgraph_api/src/app/core/config/settings.py +202 -0
  129. tenxgraph_api/src/app/core/config/setup_logs.py +65 -0
  130. tenxgraph_api/src/app/core/config/setup_middleware.py +483 -0
  131. tenxgraph_api/src/app/core/config/worker_middleware.py +99 -0
  132. tenxgraph_api/src/app/core/exceptions/__init__.py +17 -0
  133. tenxgraph_api/src/app/core/exceptions/general_exception.py +58 -0
  134. tenxgraph_api/src/app/core/exceptions/handle_errors.py +363 -0
  135. tenxgraph_api/src/app/core/exceptions/resources_exceptions.py +72 -0
  136. tenxgraph_api/src/app/core/exceptions/user_exception.py +58 -0
  137. tenxgraph_api/src/app/core/middleware/__init__.py +25 -0
  138. tenxgraph_api/src/app/core/middleware/rate_limit/__init__.py +15 -0
  139. tenxgraph_api/src/app/core/middleware/rate_limit/base.py +27 -0
  140. tenxgraph_api/src/app/core/middleware/rate_limit/factory.py +98 -0
  141. tenxgraph_api/src/app/core/middleware/rate_limit/keying.py +133 -0
  142. tenxgraph_api/src/app/core/middleware/rate_limit/memory.py +98 -0
  143. tenxgraph_api/src/app/core/middleware/rate_limit/middleware.py +90 -0
  144. tenxgraph_api/src/app/core/middleware/rate_limit/redis.py +115 -0
  145. tenxgraph_api/src/app/core/middleware/request_limits.py +154 -0
  146. tenxgraph_api/src/app/core/middleware/security_headers.py +239 -0
  147. tenxgraph_api/src/app/core/utils/__init__.py +14 -0
  148. tenxgraph_api/src/app/core/utils/log_sanitizer.py +216 -0
  149. tenxgraph_api/src/app/loader.py +516 -0
  150. tenxgraph_api/src/app/main.py +180 -0
  151. tenxgraph_api/src/app/routers/__init__.py +4 -0
  152. tenxgraph_api/src/app/routers/ag_ui/__init__.py +10 -0
  153. tenxgraph_api/src/app/routers/ag_ui/converter.py +195 -0
  154. tenxgraph_api/src/app/routers/ag_ui/event_mapper.py +353 -0
  155. tenxgraph_api/src/app/routers/ag_ui/router.py +90 -0
  156. tenxgraph_api/src/app/routers/ag_ui/service.py +193 -0
  157. tenxgraph_api/src/app/routers/checkpointer/__init__.py +1 -0
  158. tenxgraph_api/src/app/routers/checkpointer/router.py +482 -0
  159. tenxgraph_api/src/app/routers/checkpointer/schemas/__init__.py +1 -0
  160. tenxgraph_api/src/app/routers/checkpointer/schemas/checkpointer_schemas.py +121 -0
  161. tenxgraph_api/src/app/routers/checkpointer/services/__init__.py +1 -0
  162. tenxgraph_api/src/app/routers/checkpointer/services/checkpointer_service.py +300 -0
  163. tenxgraph_api/src/app/routers/evals/__init__.py +6 -0
  164. tenxgraph_api/src/app/routers/evals/router.py +38 -0
  165. tenxgraph_api/src/app/routers/evals/services/__init__.py +1 -0
  166. tenxgraph_api/src/app/routers/evals/services/eval_report_service.py +354 -0
  167. tenxgraph_api/src/app/routers/graph/__init__.py +6 -0
  168. tenxgraph_api/src/app/routers/graph/realtime_guard.py +161 -0
  169. tenxgraph_api/src/app/routers/graph/router.py +821 -0
  170. tenxgraph_api/src/app/routers/graph/schemas/__init__.py +1 -0
  171. tenxgraph_api/src/app/routers/graph/schemas/graph_schemas.py +386 -0
  172. tenxgraph_api/src/app/routers/graph/services/__init__.py +1 -0
  173. tenxgraph_api/src/app/routers/graph/services/graph_service.py +1219 -0
  174. tenxgraph_api/src/app/routers/graph/services/multimodal_preprocessor.py +164 -0
  175. tenxgraph_api/src/app/routers/media/__init__.py +506 -0
  176. tenxgraph_api/src/app/routers/media/router.py +242 -0
  177. tenxgraph_api/src/app/routers/media/schemas.py +39 -0
  178. tenxgraph_api/src/app/routers/ping/__init__.py +4 -0
  179. tenxgraph_api/src/app/routers/ping/router.py +28 -0
  180. tenxgraph_api/src/app/routers/setup_router.py +60 -0
  181. tenxgraph_api/src/app/routers/store/__init__.py +4 -0
  182. tenxgraph_api/src/app/routers/store/router.py +200 -0
  183. tenxgraph_api/src/app/routers/store/schemas/__init__.py +28 -0
  184. tenxgraph_api/src/app/routers/store/schemas/store_schemas.py +200 -0
  185. tenxgraph_api/src/app/routers/store/services/__init__.py +4 -0
  186. tenxgraph_api/src/app/routers/store/services/store_service.py +203 -0
  187. tenxgraph_api/src/app/tasks/__init__.py +0 -0
  188. tenxgraph_api/src/app/tasks/user_tasks.py +38 -0
  189. tenxgraph_api/src/app/utils/__init__.py +12 -0
  190. tenxgraph_api/src/app/utils/callable_helper.py +26 -0
  191. tenxgraph_api/src/app/utils/media/__init__.py +15 -0
  192. tenxgraph_api/src/app/utils/media/extractor.py +172 -0
  193. tenxgraph_api/src/app/utils/media/pipeline.py +83 -0
  194. tenxgraph_api/src/app/utils/parse_output.py +17 -0
  195. tenxgraph_api/src/app/utils/response_helper.py +129 -0
  196. tenxgraph_api/src/app/utils/schemas/__init__.py +11 -0
  197. tenxgraph_api/src/app/utils/schemas/output_schemas.py +28 -0
  198. tenxgraph_api/src/app/utils/schemas/user_schemas.py +13 -0
  199. tenxgraph_api/src/app/utils/snowflake_id_generator.py +90 -0
  200. tenxgraph_api/src/app/utils/swagger_helper.py +160 -0
  201. tenxgraph_api/src/app/utils/telemetry_store.py +123 -0
  202. tenxgraph_api/src/app/utils/thread_name_generator.py +304 -0
  203. 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)