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,120 @@
1
+ """Read and atomically write the ``10xgraph.json`` file being edited."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ class ConfigConflictError(Exception):
16
+ """The file changed on disk after the editor loaded it."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class LoadedConfig:
21
+ config: dict[str, Any] | None
22
+ version: str | None # sha256 of the file bytes, None when the file does not exist
23
+ parse_error: str | None = None
24
+
25
+
26
+ class ConfigFileStore:
27
+ """Owns one config file: loads it, detects concurrent edits, and saves it safely.
28
+
29
+ ``seed_path`` is read only while ``path`` does not exist: the editor starts from its
30
+ contents, and the first save creates ``path``. This is how a project that still has the
31
+ legacy ``agentflow.json`` moves to ``10xgraph.json`` without the old file being touched.
32
+ """
33
+
34
+ def __init__(self, path: Path, seed_path: Path | None = None) -> None:
35
+ self.path = path
36
+ self.seed_path = seed_path
37
+
38
+ @property
39
+ def backup_path(self) -> Path:
40
+ return self.path.with_name(f"{self.path.name}.bak")
41
+
42
+ @property
43
+ def seeded(self) -> bool:
44
+ """True while the editor starts from ``seed_path`` because ``path`` is missing."""
45
+ return (
46
+ not self.path.exists()
47
+ and self.seed_path is not None
48
+ and _read(self.seed_path).config is not None
49
+ )
50
+
51
+ def load(self) -> LoadedConfig:
52
+ loaded = _read(self.path)
53
+ if loaded.version is None and self.seed_path is not None:
54
+ seed = _read(self.seed_path)
55
+ if seed.config is not None:
56
+ # Version stays None: the target does not exist yet, so a save creates it.
57
+ return LoadedConfig(config=seed.config, version=None)
58
+ return loaded
59
+
60
+ def save(self, config: dict[str, Any], expected_version: str | None) -> str:
61
+ """Write ``config`` and return the new version.
62
+
63
+ Raises:
64
+ ConfigConflictError: The file on disk no longer matches ``expected_version``.
65
+ """
66
+ current = _read(self.path)
67
+ if current.version != expected_version:
68
+ raise ConfigConflictError(
69
+ f"{self.path.name} changed on disk after it was loaded. Reload to see the "
70
+ "latest version before saving."
71
+ )
72
+
73
+ # A new file keeps the key order of the file it was seeded from.
74
+ ordered = _preserve_key_order(config, current.config or self.load().config or {})
75
+ payload = (json.dumps(ordered, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
76
+
77
+ self.path.parent.mkdir(parents=True, exist_ok=True)
78
+ if self.path.exists():
79
+ shutil.copy2(self.path, self.backup_path)
80
+ fd, temporary_name = tempfile.mkstemp(
81
+ prefix=f".{self.path.name}.", suffix=".tmp", dir=self.path.parent
82
+ )
83
+ temporary_path = Path(temporary_name)
84
+ try:
85
+ with os.fdopen(fd, "wb") as handle:
86
+ handle.write(payload)
87
+ handle.flush()
88
+ os.fsync(handle.fileno())
89
+ temporary_path.replace(self.path)
90
+ finally:
91
+ if temporary_path.exists():
92
+ temporary_path.unlink()
93
+ return _digest(payload)
94
+
95
+
96
+ def _read(path: Path) -> LoadedConfig:
97
+ if not path.exists():
98
+ return LoadedConfig(config=None, version=None)
99
+ raw = path.read_bytes()
100
+ version = _digest(raw)
101
+ try:
102
+ data = json.loads(raw.decode("utf-8"))
103
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
104
+ return LoadedConfig(config=None, version=version, parse_error=str(exc))
105
+ if not isinstance(data, dict):
106
+ return LoadedConfig(
107
+ config=None, version=version, parse_error="The file is not a JSON object."
108
+ )
109
+ return LoadedConfig(config=data, version=version)
110
+
111
+
112
+ def _preserve_key_order(new: dict[str, Any], old: dict[str, Any]) -> dict[str, Any]:
113
+ """Keep the file's existing top-level key order; append new keys at the end."""
114
+ ordered = {key: new[key] for key in old if key in new}
115
+ ordered.update({key: value for key, value in new.items() if key not in ordered})
116
+ return ordered
117
+
118
+
119
+ def _digest(raw: bytes) -> str:
120
+ return hashlib.sha256(raw).hexdigest()
@@ -0,0 +1,377 @@
1
+ """Validate an ``10xgraph.json`` document the same way the CLI and API server read it.
2
+
3
+ Errors block a save because the server would refuse to start (or silently misbehave).
4
+ Warnings describe things that are valid but probably not what the user meant, such as
5
+ an import path whose module file cannot be found next to the config.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import os
12
+ import re
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from tenxgraph_api.cli.config_editor.schema import (
18
+ BUILTIN_AUTHORIZATION,
19
+ KNOWN_TOP_LEVEL_KEYS,
20
+ section_for_key,
21
+ )
22
+
23
+
24
+ Issue = dict[str, str]
25
+
26
+ _IMPORT_PATH = re.compile(r"^[A-Za-z_][\w.]*:[A-Za-z_][\w.]*$")
27
+ _OBSERVABILITY_LEVELS = ("spans", "standard", "full")
28
+ _MAX_PERCENT = 100
29
+
30
+
31
+ class _Collector:
32
+ def __init__(self) -> None:
33
+ self.issues: list[Issue] = []
34
+
35
+ def add(self, level: str, path: str, message: str) -> None:
36
+ top = path.split(".", 1)[0].split("[", 1)[0]
37
+ self.issues.append(
38
+ {
39
+ "level": level,
40
+ "section": section_for_key(top) if path else "core",
41
+ "path": path,
42
+ "message": message,
43
+ }
44
+ )
45
+
46
+ def error(self, path: str, message: str) -> None:
47
+ self.add("error", path, message)
48
+
49
+ def warning(self, path: str, message: str) -> None:
50
+ self.add("warning", path, message)
51
+
52
+
53
+ def validate_config(config: Any, base_dir: Path | None = None) -> list[Issue]:
54
+ """Return every error and warning for ``config``.
55
+
56
+ Args:
57
+ config: Parsed ``10xgraph.json`` content.
58
+ base_dir: Directory that holds the config file. Enables checks that
59
+ referenced modules and files exist.
60
+ """
61
+ out = _Collector()
62
+ if not isinstance(config, dict):
63
+ out.error("", "The configuration must be a JSON object.")
64
+ return out.issues
65
+
66
+ _check_agent(config, out, base_dir)
67
+ _check_env(config, out, base_dir)
68
+ _check_auth(config, out, base_dir)
69
+ _check_authorization(config, out, base_dir)
70
+ _check_rate_limit(config, out)
71
+ _check_websocket(config, out)
72
+ _check_ag_ui(config, out)
73
+ _check_observability(config, out)
74
+ _check_plugins(config, out, base_dir)
75
+ _check_remote_tools(config, out)
76
+ _check_test(config, out)
77
+ _check_evaluation(config, out)
78
+
79
+ for key in config:
80
+ if key not in KNOWN_TOP_LEVEL_KEYS:
81
+ out.warning(key, f"'{key}' is not read by 10xGraph. It is kept as-is.")
82
+ return out.issues
83
+
84
+
85
+ def has_errors(issues: list[Issue]) -> bool:
86
+ return any(issue["level"] == "error" for issue in issues)
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Individual sections
91
+ # ---------------------------------------------------------------------------
92
+
93
+
94
+ def _check_agent(config: dict, out: _Collector, base_dir: Path | None) -> None:
95
+ agent = config.get("agent")
96
+ if agent is None or agent == "":
97
+ out.error("agent", "agent is required, for example 'graph.react:app'.")
98
+ return
99
+ _check_import_path("agent", agent, out, base_dir)
100
+
101
+
102
+ def _check_env(config: dict, out: _Collector, base_dir: Path | None) -> None:
103
+ env = config.get("env")
104
+ if env is None:
105
+ return
106
+ if not isinstance(env, str):
107
+ out.error("env", "env must be a file path string or null.")
108
+ return
109
+ if env and base_dir is not None and not (base_dir / env).exists():
110
+ out.warning("env", f"Env file '{env}' does not exist yet.")
111
+
112
+
113
+ def _check_auth(config: dict, out: _Collector, base_dir: Path | None) -> None:
114
+ auth = config.get("auth")
115
+ if not auth:
116
+ return
117
+ if isinstance(auth, str):
118
+ if "jwt" not in auth:
119
+ out.error("auth", f"Unsupported auth '{auth}'. Use 'jwt' or a custom object.")
120
+ return
121
+ if isinstance(auth, dict):
122
+ method, path = auth.get("method"), auth.get("path")
123
+ if method != "custom":
124
+ out.error("auth.method", 'auth.method must be \'custom\'. For JWT use "auth": "jwt".')
125
+ if not path:
126
+ out.error("auth.path", "auth.path is required for custom auth.")
127
+ else:
128
+ _check_import_path("auth.path", path, out, base_dir)
129
+ return
130
+ out.error("auth", 'auth must be null, \'jwt\', or {"method": "custom", "path": ...}.')
131
+
132
+
133
+ def _check_authorization(config: dict, out: _Collector, base_dir: Path | None) -> None:
134
+ value = config.get("authorization")
135
+ if value is None:
136
+ return
137
+ if isinstance(value, str):
138
+ if ":" in value:
139
+ _check_import_path("authorization", value, out, base_dir)
140
+ elif value.strip().lower() not in BUILTIN_AUTHORIZATION:
141
+ out.error(
142
+ "authorization",
143
+ f"Unknown authorization '{value}'. Use one of "
144
+ f"{', '.join(BUILTIN_AUTHORIZATION)} or a module:attribute path.",
145
+ )
146
+ return
147
+ if not isinstance(value, dict):
148
+ out.error("authorization", "authorization must be a string, an RBAC object, or null.")
149
+ return
150
+ _check_rbac(value, out)
151
+
152
+
153
+ def _check_rbac(value: dict, out: _Collector) -> None:
154
+ backend = str(value.get("backend") or value.get("type") or "").lower()
155
+ if backend not in ("rbac", "role_based", "roles"):
156
+ out.error("authorization.backend", "authorization.backend must be 'rbac'.")
157
+ roles = value.get("roles") if "roles" in value else value.get("role_scopes")
158
+ if roles is not None:
159
+ if not isinstance(roles, dict):
160
+ out.error("authorization.roles", "roles must map role names to scope lists.")
161
+ else:
162
+ for role, scopes in roles.items():
163
+ if not _is_str_list(scopes):
164
+ out.error(f"authorization.roles.{role}", "Scopes must be a list of strings.")
165
+ if not roles:
166
+ out.warning("authorization.roles", "No roles defined. Only default scopes apply.")
167
+ default_scopes = value.get("default_scopes")
168
+ if default_scopes is not None and not _is_str_list(default_scopes):
169
+ out.error("authorization.default_scopes", "default_scopes must be a list of strings.")
170
+ isolation = value.get("isolation", "owner")
171
+ if isolation not in ("owner", "none"):
172
+ out.error("authorization.isolation", "isolation must be 'owner' or 'none'.")
173
+
174
+
175
+ def _check_rate_limit(config: dict, out: _Collector) -> None:
176
+ data = config.get("rate_limit")
177
+ if data is None:
178
+ return
179
+ if not isinstance(data, dict):
180
+ out.error("rate_limit", "rate_limit must be an object.")
181
+ return
182
+
183
+ from tenxgraph_api.src.app.core.config.graph_config import RateLimitConfig
184
+
185
+ probe = dict(data)
186
+ redis_value = probe.get("redis")
187
+ redis_url = redis_value.get("url") if isinstance(redis_value, dict) else redis_value
188
+ if isinstance(redis_url, str) and _has_unresolved_env(redis_url):
189
+ # The server expands $VAR at startup; it is fine for it to be unset while editing.
190
+ out.warning(
191
+ "rate_limit.redis.url",
192
+ f"{redis_url} is not set in this shell. Make sure it is set when the API runs.",
193
+ )
194
+ placeholder = "redis://placeholder"
195
+ probe["redis"] = (
196
+ {**redis_value, "url": placeholder} if isinstance(redis_value, dict) else placeholder
197
+ )
198
+
199
+ config_obj = _quietly(lambda: RateLimitConfig.from_dict(probe), out, "rate_limit")
200
+ if config_obj is None:
201
+ return
202
+ if config_obj.backend == "redis" and not redis_url:
203
+ out.warning(
204
+ "rate_limit.redis.url",
205
+ "The redis backend needs a URL unless a Redis client is bound in InjectQ.",
206
+ )
207
+ if config_obj.enabled and config_obj.backend == "memory":
208
+ out.warning(
209
+ "rate_limit.backend",
210
+ "The memory backend counts per process. Use redis with more than one worker.",
211
+ )
212
+
213
+
214
+ def _check_websocket(config: dict, out: _Collector) -> None:
215
+ data = config.get("websocket")
216
+ if data is None:
217
+ return
218
+ from tenxgraph_api.src.app.core.config.graph_config import WebSocketConfig
219
+
220
+ _quietly(lambda: WebSocketConfig.from_dict(data), out, "websocket")
221
+
222
+
223
+ def _check_ag_ui(config: dict, out: _Collector) -> None:
224
+ data = config.get("ag_ui")
225
+ if data is None:
226
+ return
227
+ from tenxgraph_api.src.app.core.config.graph_config import AgUiConfig
228
+
229
+ _quietly(lambda: AgUiConfig.from_dict(data), out, "ag_ui")
230
+
231
+
232
+ def _check_observability(config: dict, out: _Collector) -> None:
233
+ data = config.get("observability")
234
+ if data is None:
235
+ return
236
+ if not isinstance(data, dict):
237
+ out.error("observability", "observability must be an object.")
238
+ return
239
+ level = data.get("level", "standard")
240
+ if level not in _OBSERVABILITY_LEVELS:
241
+ out.error(
242
+ "observability.level", f"level must be one of {', '.join(_OBSERVABILITY_LEVELS)}."
243
+ )
244
+ any_enabled = False
245
+ for backend in ("logfire", "langsmith"):
246
+ block = data.get(backend)
247
+ if block is None:
248
+ continue
249
+ if not isinstance(block, dict):
250
+ out.error(f"observability.{backend}", f"{backend} must be an object.")
251
+ continue
252
+ enabled = block.get("enabled", False)
253
+ if not isinstance(enabled, bool):
254
+ out.error(f"observability.{backend}.enabled", "enabled must be true or false.")
255
+ any_enabled = any_enabled or enabled is True
256
+ if not any_enabled:
257
+ out.warning(
258
+ "observability", "Neither Logfire nor LangSmith is enabled, so nothing is traced."
259
+ )
260
+
261
+
262
+ def _check_plugins(config: dict, out: _Collector, base_dir: Path | None) -> None:
263
+ for key in ("injectq", "store", "checkpointer", "thread_name_generator"):
264
+ value = config.get(key)
265
+ if value is None or value == "":
266
+ continue
267
+ _check_import_path(key, value, out, base_dir)
268
+ if config.get("checkpointer"):
269
+ out.warning(
270
+ "checkpointer",
271
+ "The API server does not apply this key yet. Pass the checkpointer to "
272
+ "graph.compile() instead.",
273
+ )
274
+
275
+ redis = config.get("redis")
276
+ if redis is None or redis == "":
277
+ return
278
+ if not isinstance(redis, str):
279
+ out.error("redis", "redis must be a URL string.")
280
+ elif "$" in redis:
281
+ out.warning("redis", "Top-level redis is used as-is; $VAR is not expanded.")
282
+
283
+
284
+ def _check_remote_tools(config: dict, out: _Collector) -> None:
285
+ if "remote_tools" not in config or config["remote_tools"] is None:
286
+ return
287
+ from tenxgraph_api.src.app.core.config.graph_config import validate_remote_tools
288
+
289
+ _quietly(lambda: validate_remote_tools(config["remote_tools"]), out, "remote_tools")
290
+
291
+
292
+ def _check_test(config: dict, out: _Collector) -> None:
293
+ data = config.get("test")
294
+ if data is None:
295
+ return
296
+ if not isinstance(data, dict):
297
+ out.error("test", "test must be an object.")
298
+ return
299
+ if "path" in data and not isinstance(data["path"], str):
300
+ out.error("test.path", "path must be a string.")
301
+ if "coverage" in data and not isinstance(data["coverage"], bool):
302
+ out.error("test.coverage", "coverage must be true or false.")
303
+ threshold = data.get("coverage_threshold")
304
+ if threshold is not None and not (_is_int(threshold) and 0 <= threshold <= _MAX_PERCENT):
305
+ out.error("test.coverage_threshold", "coverage_threshold must be a whole number 0-100.")
306
+
307
+
308
+ def _check_evaluation(config: dict, out: _Collector) -> None:
309
+ data = config.get("evaluation")
310
+ if data is None:
311
+ return
312
+ if not isinstance(data, dict):
313
+ out.error("evaluation", "evaluation must be an object.")
314
+ return
315
+ for key in ("directory", "output_dir"):
316
+ if key in data and not isinstance(data[key], str):
317
+ out.error(f"evaluation.{key}", f"{key} must be a string.")
318
+ threshold = data.get("threshold")
319
+ if threshold is not None and not (_is_number(threshold) and 0 <= threshold <= 1):
320
+ out.error("evaluation.threshold", "threshold must be a number between 0 and 1.")
321
+ if "parallel" in data and not isinstance(data["parallel"], bool):
322
+ out.error("evaluation.parallel", "parallel must be true or false.")
323
+ concurrency = data.get("max_concurrency")
324
+ if concurrency is not None and not (_is_int(concurrency) and concurrency >= 1):
325
+ out.error("evaluation.max_concurrency", "max_concurrency must be a whole number >= 1.")
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Helpers
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ def _check_import_path(path: str, value: Any, out: _Collector, base_dir: Path | None) -> None:
334
+ if not isinstance(value, str) or not _IMPORT_PATH.match(value):
335
+ out.error(path, f"Expected module:attribute, got {value!r}.")
336
+ return
337
+ if base_dir is None:
338
+ return
339
+ module = value.split(":", 1)[0]
340
+ candidate = base_dir.joinpath(*module.split("."))
341
+ if not (candidate.with_suffix(".py").exists() or (candidate / "__init__.py").exists()):
342
+ out.warning(path, f"Module '{module}' was not found next to 10xgraph.json.")
343
+
344
+
345
+ def _quietly(parse: Callable[[], Any], out: _Collector, path: str) -> Any:
346
+ """Run a server-side parser, turning its exceptions into an error issue.
347
+
348
+ The parsers log operational warnings (for example about the memory rate-limit
349
+ backend); those are reported as issues here, so the logger is muted meanwhile.
350
+ """
351
+ api_logger = logging.getLogger("tenxgraph_api")
352
+ previous = api_logger.disabled
353
+ api_logger.disabled = True
354
+ try:
355
+ return parse()
356
+ except (TypeError, ValueError) as exc:
357
+ out.error(path, str(exc))
358
+ return None
359
+ finally:
360
+ api_logger.disabled = previous
361
+
362
+
363
+ def _has_unresolved_env(value: str) -> bool:
364
+ # Same rule as graph_config._expand_env, which raises for these at startup.
365
+ return os.path.expandvars(value) == value and (value.startswith("$") or "${" in value)
366
+
367
+
368
+ def _is_str_list(value: Any) -> bool:
369
+ return isinstance(value, list) and all(isinstance(item, str) for item in value)
370
+
371
+
372
+ def _is_int(value: Any) -> bool:
373
+ return isinstance(value, int) and not isinstance(value, bool)
374
+
375
+
376
+ def _is_number(value: Any) -> bool:
377
+ return isinstance(value, int | float) and not isinstance(value, bool)
@@ -0,0 +1,112 @@
1
+ """CLI constants and configuration values."""
2
+
3
+ import tomllib
4
+ from importlib.metadata import PackageNotFoundError
5
+ from importlib.metadata import version as _pkg_version
6
+ from pathlib import Path
7
+ from typing import Final
8
+
9
+
10
+ _PACKAGE_NAME: Final[str] = "10xgraph-api"
11
+
12
+
13
+ def _resolve_version() -> str:
14
+ """Resolve the package version from installed metadata, falling back to pyproject.toml.
15
+
16
+ Single source of truth: the installed distribution's version. When running from a
17
+ source checkout that is not installed, read ``pyproject.toml`` instead.
18
+ """
19
+ try:
20
+ return _pkg_version(_PACKAGE_NAME)
21
+ except PackageNotFoundError:
22
+ pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
23
+ try:
24
+ with pyproject.open("rb") as f:
25
+ return tomllib.load(f).get("project", {}).get("version", "unknown")
26
+ except (OSError, tomllib.TOMLDecodeError):
27
+ return "unknown"
28
+
29
+
30
+ # Version information (single-sourced from package metadata / pyproject.toml)
31
+ CLI_VERSION: Final[str] = _resolve_version()
32
+
33
+ # Default configuration values
34
+ DEFAULT_HOST: Final[str] = "127.0.0.1"
35
+ DEFAULT_PORT: Final[int] = 8000
36
+ DEFAULT_CONFIG_FILE: Final[str] = "10xgraph.json"
37
+ # Pre-rename config file name. Still read when no 10xgraph.json sits beside it.
38
+ LEGACY_CONFIG_FILE: Final[str] = "agentflow.json"
39
+ DEFAULT_PLAYGROUND_URL: Final[str] = "https://playground-463bd.web.app"
40
+ DEFAULT_PYTHON_VERSION: Final[str] = "3.13"
41
+ DEFAULT_SERVICE_NAME: Final[str] = "10xgraph-api"
42
+
43
+ # File paths and names
44
+ # Discovery order within one directory: the first match wins.
45
+ CONFIG_FILENAMES: Final[list[str]] = [
46
+ DEFAULT_CONFIG_FILE,
47
+ LEGACY_CONFIG_FILE,
48
+ ".agentflow.json",
49
+ "agentflow.config.json",
50
+ ]
51
+
52
+ REQUIREMENTS_PATHS: Final[list[str]] = [
53
+ "requirements.txt",
54
+ "requirements/requirements.txt",
55
+ "requirements/base.txt",
56
+ "requirements/production.txt",
57
+ ]
58
+
59
+ # Docker and container configuration
60
+ DOCKERFILE_NAME: Final[str] = "Dockerfile"
61
+ DOCKER_COMPOSE_NAME: Final[str] = "docker-compose.yml"
62
+ HEALTH_CHECK_ENDPOINT: Final[str] = "/ping"
63
+
64
+ # Logging configuration
65
+ LOG_FORMAT: Final[str] = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
66
+ LOG_DATE_FORMAT: Final[str] = "%Y-%m-%d %H:%M:%S"
67
+
68
+ # Environment variables
69
+ ENV_GRAPH_PATH: Final[str] = "GRAPH_PATH"
70
+ ENV_PYTHONPATH: Final[str] = "PYTHONPATH"
71
+ ENV_PYTHONDONTWRITEBYTECODE: Final[str] = "PYTHONDONTWRITEBYTECODE"
72
+ ENV_PYTHONUNBUFFERED: Final[str] = "PYTHONUNBUFFERED"
73
+
74
+ # Exit codes
75
+ EXIT_SUCCESS: Final[int] = 0
76
+ EXIT_FAILURE: Final[int] = 1
77
+ EXIT_CONFIG_ERROR: Final[int] = 2
78
+ EXIT_VALIDATION_ERROR: Final[int] = 3
79
+
80
+
81
+ # Output styling
82
+ class Colors:
83
+ """ANSI color codes for terminal output."""
84
+
85
+ RESET: Final[str] = "\033[0m"
86
+ RED: Final[str] = "\033[91m"
87
+ GREEN: Final[str] = "\033[92m"
88
+ YELLOW: Final[str] = "\033[93m"
89
+ BLUE: Final[str] = "\033[94m"
90
+ MAGENTA: Final[str] = "\033[95m"
91
+ CYAN: Final[str] = "\033[96m"
92
+ WHITE: Final[str] = "\033[97m"
93
+
94
+ @classmethod
95
+ def colorize(cls, text: str, color: str) -> str:
96
+ """Apply color to text."""
97
+ color_code = getattr(cls, color.upper(), cls.RESET)
98
+ return f"{color_code}{text}{cls.RESET}"
99
+
100
+
101
+ # Emoji and symbols for output
102
+ EMOJI_SUCCESS: Final[str] = "✅"
103
+ EMOJI_ERROR: Final[str] = "⚠️"
104
+ EMOJI_INFO: Final[str] = "📋"
105
+ EMOJI_SPARKLE: Final[str] = "✨"
106
+ EMOJI_ROCKET: Final[str] = "🚀"
107
+ EMOJI_PACKAGE: Final[str] = "📦"
108
+
109
+ # Project structure
110
+ PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
111
+ CLI_ROOT: Final[Path] = Path(__file__).parent
112
+ TEMPLATES_DIR: Final[Path] = CLI_ROOT / "templates"
@@ -0,0 +1,46 @@
1
+ """Shared runtime context for 10xGraph CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ from tenxgraph_api.cli.capabilities import TerminalCapabilities
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class CLIContext:
14
+ """Invocation-scoped CLI state passed through Typer's context object."""
15
+
16
+ capabilities: TerminalCapabilities
17
+ cwd: Path
18
+ verbosity: int = 0
19
+ quiet: bool = False
20
+ debug: bool = False
21
+ yes: bool = False
22
+ non_interactive: bool = False
23
+ invocation_id: str = ""
24
+
25
+ @classmethod
26
+ def create(
27
+ cls,
28
+ *,
29
+ capabilities: TerminalCapabilities,
30
+ cwd: Path,
31
+ verbosity: int = 0,
32
+ quiet: bool = False,
33
+ debug: bool = False,
34
+ yes: bool = False,
35
+ non_interactive: bool = False,
36
+ ) -> CLIContext:
37
+ return cls(
38
+ capabilities=capabilities,
39
+ cwd=cwd,
40
+ verbosity=verbosity,
41
+ quiet=quiet,
42
+ debug=debug,
43
+ yes=yes,
44
+ non_interactive=non_interactive,
45
+ invocation_id=uuid4().hex[:12],
46
+ )
File without changes