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,821 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
|
7
|
+
from fastapi.logger import logger
|
|
8
|
+
from fastapi.responses import StreamingResponse
|
|
9
|
+
from injectq import InjectQ
|
|
10
|
+
from injectq.integrations import InjectAPI
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
from tenxgraph.core.realtime.base import ErrorEvent
|
|
13
|
+
from tenxgraph.core.realtime.queue import LiveInputQueue
|
|
14
|
+
from tenxgraph.core.state import StreamChunk, StreamEvent
|
|
15
|
+
|
|
16
|
+
from tenxgraph_api.src.app.core.auth.authorization import AuthorizationBackend
|
|
17
|
+
from tenxgraph_api.src.app.core.auth.permissions import (
|
|
18
|
+
RequirePermission,
|
|
19
|
+
ws_bearer_subprotocol,
|
|
20
|
+
)
|
|
21
|
+
from tenxgraph_api.src.app.core.auth.request_config import normalize_thread_id
|
|
22
|
+
from tenxgraph_api.src.app.routers.graph.realtime_guard import (
|
|
23
|
+
realtime_connection_guard,
|
|
24
|
+
ws_identity_still_valid,
|
|
25
|
+
ws_run_allowed,
|
|
26
|
+
)
|
|
27
|
+
from tenxgraph_api.src.app.routers.graph.schemas.graph_schemas import (
|
|
28
|
+
FixGraphRequestSchema,
|
|
29
|
+
GraphInputSchema,
|
|
30
|
+
GraphInvokeOutputSchema,
|
|
31
|
+
GraphSchema,
|
|
32
|
+
GraphStopSchema,
|
|
33
|
+
GraphToolsSchema,
|
|
34
|
+
ObservabilitySchema,
|
|
35
|
+
WsGraphInputSchema,
|
|
36
|
+
)
|
|
37
|
+
from tenxgraph_api.src.app.routers.graph.services.graph_service import GraphService
|
|
38
|
+
from tenxgraph_api.src.app.utils import success_response
|
|
39
|
+
from tenxgraph_api.src.app.utils.swagger_helper import generate_swagger_responses
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Grace period (seconds) to let the model's final response drain to the client after the
|
|
43
|
+
# client side ends (``close`` control frame or disconnect). Closing the input queue makes
|
|
44
|
+
# the live agent finish its turn and stop once the provider goes idle, so this normally
|
|
45
|
+
# returns well within the window; it only bounds a provider that never goes idle.
|
|
46
|
+
REALTIME_DRAIN_TIMEOUT = 30.0
|
|
47
|
+
|
|
48
|
+
# ``config.thread_id`` value a WebSocket client sends to start a fresh thread.
|
|
49
|
+
WS_NEW_THREAD = "new"
|
|
50
|
+
|
|
51
|
+
# Bound the upstream audio queue. WebSocket frames bypass RequestSizeLimitMiddleware (it is
|
|
52
|
+
# HTTP-only), so the realtime path must guard memory itself. At ~50 input frames/sec a depth
|
|
53
|
+
# of 1000 is ~20s of buffered audio; if the provider send stalls past that the oldest frames
|
|
54
|
+
# are dropped (logged) instead of growing memory without bound -- the audio was already behind.
|
|
55
|
+
REALTIME_INPUT_QUEUE_MAXSIZE = 1000
|
|
56
|
+
|
|
57
|
+
# Hard cap on a single binary (PCM16) input frame. Normal client chunks are tens of KB
|
|
58
|
+
# (16kHz PCM16 = 32KB/s); 1 MiB is ~30s of audio in one frame, far beyond any real chunk.
|
|
59
|
+
# Oversized frames are dropped rather than enqueued (again, the middleware does not see them).
|
|
60
|
+
REALTIME_MAX_FRAME_BYTES = 1024 * 1024
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
router = APIRouter(
|
|
64
|
+
tags=["Graph"],
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _bind_ws_container() -> None:
|
|
69
|
+
"""Set the active InjectQ container for a WebSocket handshake.
|
|
70
|
+
|
|
71
|
+
``setup_fastapi`` installs its container-activation via Starlette middleware,
|
|
72
|
+
which runs for HTTP scopes only — WebSocket handshakes bypass it, so every
|
|
73
|
+
``InjectAPI`` dependency (here and inside ``RequirePermission``) would raise
|
|
74
|
+
"No InjectQ container in current request context". Declaring this dependency
|
|
75
|
+
*before* the ``InjectAPI`` ones makes FastAPI resolve it first, so the
|
|
76
|
+
ContextVar is set by the time the container-backed deps resolve.
|
|
77
|
+
|
|
78
|
+
Idempotent and cheap; harmless if the container is already active.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
from injectq.integrations.fastapi import _request_container
|
|
82
|
+
|
|
83
|
+
_request_container.set(InjectQ.get_instance())
|
|
84
|
+
except Exception: # pragma: no cover - defensive: never block the handshake here
|
|
85
|
+
logger.exception("Failed to bind InjectQ container for WebSocket scope")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.post(
|
|
89
|
+
"/v1/graph/invoke",
|
|
90
|
+
summary="Invoke graph execution",
|
|
91
|
+
responses=generate_swagger_responses(GraphInvokeOutputSchema),
|
|
92
|
+
description="Execute the graph with the provided input and return the final result",
|
|
93
|
+
openapi_extra={},
|
|
94
|
+
)
|
|
95
|
+
async def invoke_graph(
|
|
96
|
+
request: Request,
|
|
97
|
+
graph_input: GraphInputSchema,
|
|
98
|
+
service: GraphService = InjectAPI(GraphService),
|
|
99
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "invoke")),
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
Invoke the graph with the provided input and return the final result.
|
|
103
|
+
"""
|
|
104
|
+
logger.info(f"Graph invoke request received with {len(graph_input.messages)} messages")
|
|
105
|
+
|
|
106
|
+
result: GraphInvokeOutputSchema = await service.invoke_graph(
|
|
107
|
+
graph_input,
|
|
108
|
+
user,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
logger.info("Graph invoke completed successfully")
|
|
112
|
+
|
|
113
|
+
return success_response(
|
|
114
|
+
result,
|
|
115
|
+
request,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@router.post(
|
|
120
|
+
"/v1/graph/stream",
|
|
121
|
+
summary="Stream graph execution",
|
|
122
|
+
description="Execute the graph with streaming output for real-time results",
|
|
123
|
+
responses=generate_swagger_responses(StreamChunk),
|
|
124
|
+
openapi_extra={},
|
|
125
|
+
)
|
|
126
|
+
async def stream_graph(
|
|
127
|
+
graph_input: GraphInputSchema,
|
|
128
|
+
service: GraphService = InjectAPI(GraphService),
|
|
129
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "stream")),
|
|
130
|
+
):
|
|
131
|
+
"""
|
|
132
|
+
Stream the graph execution with real-time output.
|
|
133
|
+
"""
|
|
134
|
+
logger.info(f"Graph stream request received with {len(graph_input.messages)} messages")
|
|
135
|
+
|
|
136
|
+
result = service.stream_graph(
|
|
137
|
+
graph_input,
|
|
138
|
+
user,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return StreamingResponse(
|
|
142
|
+
result,
|
|
143
|
+
media_type="text/event-stream",
|
|
144
|
+
headers={
|
|
145
|
+
"Cache-Control": "no-cache, no-transform",
|
|
146
|
+
"Connection": "keep-alive",
|
|
147
|
+
"X-Accel-Buffering": "no", # Disable nginx buffering
|
|
148
|
+
"Content-Encoding": "identity", # Disable any content encoding (bypasses GZip)
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.get(
|
|
154
|
+
"/v1/graph",
|
|
155
|
+
summary="Invoke graph execution",
|
|
156
|
+
responses=generate_swagger_responses(GraphSchema),
|
|
157
|
+
description="Execute the graph with the provided input and return the final result",
|
|
158
|
+
openapi_extra={},
|
|
159
|
+
)
|
|
160
|
+
async def graph_details(
|
|
161
|
+
request: Request,
|
|
162
|
+
service: GraphService = InjectAPI(GraphService),
|
|
163
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
|
|
164
|
+
):
|
|
165
|
+
"""
|
|
166
|
+
Invoke the graph with the provided input and return the final result.
|
|
167
|
+
"""
|
|
168
|
+
logger.info("Graph getting details")
|
|
169
|
+
|
|
170
|
+
result: GraphSchema = await service.graph_details()
|
|
171
|
+
|
|
172
|
+
logger.info("Graph invoke completed successfully")
|
|
173
|
+
|
|
174
|
+
return success_response(
|
|
175
|
+
result,
|
|
176
|
+
request,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.get(
|
|
181
|
+
"/v1/graph/tools",
|
|
182
|
+
summary="List graph tools",
|
|
183
|
+
responses=generate_swagger_responses(GraphToolsSchema),
|
|
184
|
+
description=(
|
|
185
|
+
"List the tools exposed by every ToolNode in the graph, grouped by node. "
|
|
186
|
+
"Each tool is tagged with its source (local, mcp, or remote)."
|
|
187
|
+
),
|
|
188
|
+
openapi_extra={},
|
|
189
|
+
)
|
|
190
|
+
async def graph_tools(
|
|
191
|
+
request: Request,
|
|
192
|
+
service: GraphService = InjectAPI(GraphService),
|
|
193
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
|
|
194
|
+
):
|
|
195
|
+
"""List the tools exposed by the graph's tool nodes."""
|
|
196
|
+
logger.info("Graph getting tools")
|
|
197
|
+
|
|
198
|
+
result: GraphToolsSchema = await service.get_tools()
|
|
199
|
+
|
|
200
|
+
logger.info("Graph tools listed successfully")
|
|
201
|
+
|
|
202
|
+
return success_response(
|
|
203
|
+
result,
|
|
204
|
+
request,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.get(
|
|
209
|
+
"/v1/observability/{thread_id}",
|
|
210
|
+
summary="Get observability trace for a thread",
|
|
211
|
+
responses=generate_swagger_responses(ObservabilitySchema),
|
|
212
|
+
description=(
|
|
213
|
+
"Reconstruct a run trace (spans, events, token cost) for a thread from the "
|
|
214
|
+
"events its runs emitted. Returns the latest run by default, or ?run_id=..."
|
|
215
|
+
),
|
|
216
|
+
openapi_extra={},
|
|
217
|
+
)
|
|
218
|
+
async def observability(
|
|
219
|
+
request: Request,
|
|
220
|
+
thread_id: str,
|
|
221
|
+
run_id: str | None = None,
|
|
222
|
+
service: GraphService = InjectAPI(GraphService),
|
|
223
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
|
|
224
|
+
):
|
|
225
|
+
"""Return the reconstructed observability trace for a thread."""
|
|
226
|
+
logger.info(f"Observability requested for thread {thread_id}")
|
|
227
|
+
|
|
228
|
+
result: ObservabilitySchema = await service.get_observability(thread_id, run_id)
|
|
229
|
+
|
|
230
|
+
return success_response(
|
|
231
|
+
result,
|
|
232
|
+
request,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@router.get(
|
|
237
|
+
"/v1/graph:StateSchema",
|
|
238
|
+
summary="Invoke graph execution",
|
|
239
|
+
responses=generate_swagger_responses(GraphSchema),
|
|
240
|
+
description="Execute the graph with the provided input and return the final result",
|
|
241
|
+
openapi_extra={},
|
|
242
|
+
)
|
|
243
|
+
async def state_schema(
|
|
244
|
+
request: Request,
|
|
245
|
+
service: GraphService = InjectAPI(GraphService),
|
|
246
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "read")),
|
|
247
|
+
):
|
|
248
|
+
"""
|
|
249
|
+
Invoke the graph with the provided input and return the final result.
|
|
250
|
+
"""
|
|
251
|
+
logger.info("Graph getting details")
|
|
252
|
+
|
|
253
|
+
result: dict = await service.get_state_schema()
|
|
254
|
+
|
|
255
|
+
logger.info("Graph invoke completed successfully")
|
|
256
|
+
|
|
257
|
+
return success_response(
|
|
258
|
+
result,
|
|
259
|
+
request,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@router.post(
|
|
264
|
+
"/v1/graph/stop",
|
|
265
|
+
summary="Stop graph execution",
|
|
266
|
+
description="Stop the currently running graph execution for a specific thread",
|
|
267
|
+
responses=generate_swagger_responses(dict), # type: ignore
|
|
268
|
+
openapi_extra={},
|
|
269
|
+
)
|
|
270
|
+
async def stop_graph(
|
|
271
|
+
request: Request,
|
|
272
|
+
stop_request: GraphStopSchema,
|
|
273
|
+
service: GraphService = InjectAPI(GraphService),
|
|
274
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "stop")),
|
|
275
|
+
):
|
|
276
|
+
"""
|
|
277
|
+
Stop the graph execution for a specific thread.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
stop_request: Request containing thread_id and optional config
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
Status information about the stop operation
|
|
284
|
+
"""
|
|
285
|
+
logger.info(f"Graph stop request received for thread: {stop_request.thread_id}")
|
|
286
|
+
|
|
287
|
+
result = await service.stop_graph(stop_request.thread_id, user, stop_request.config)
|
|
288
|
+
|
|
289
|
+
logger.info(f"Graph stop completed for thread {stop_request.thread_id}")
|
|
290
|
+
|
|
291
|
+
return success_response(
|
|
292
|
+
result,
|
|
293
|
+
request,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@router.post(
|
|
298
|
+
"/v1/graph/fix",
|
|
299
|
+
summary="Fix graph state by removing messages with empty tool calls",
|
|
300
|
+
description=(
|
|
301
|
+
"Fix the graph state by identifying and removing messages that have tool "
|
|
302
|
+
"calls with empty content. This is useful for cleaning up incomplete "
|
|
303
|
+
"tool call messages that may have failed or been interrupted."
|
|
304
|
+
),
|
|
305
|
+
responses=generate_swagger_responses(dict), # type: ignore
|
|
306
|
+
openapi_extra={},
|
|
307
|
+
)
|
|
308
|
+
async def fix_graph(
|
|
309
|
+
request: Request,
|
|
310
|
+
fix_request: FixGraphRequestSchema,
|
|
311
|
+
service: GraphService = InjectAPI(GraphService),
|
|
312
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "fix")),
|
|
313
|
+
):
|
|
314
|
+
"""
|
|
315
|
+
Fix the graph execution state for a specific thread.
|
|
316
|
+
|
|
317
|
+
This endpoint removes messages with empty tool call content from the state.
|
|
318
|
+
Tool calls with empty content typically indicate interrupted or failed tool
|
|
319
|
+
executions that should be cleaned up.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
request: HTTP request object
|
|
323
|
+
fix_request: Request containing thread_id and optional config
|
|
324
|
+
service: Injected GraphService instance
|
|
325
|
+
user: Current authenticated user
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Status information about the fix operation, including:
|
|
329
|
+
- success: Whether the operation was successful
|
|
330
|
+
- message: Descriptive message about the operation
|
|
331
|
+
- removed_count: Number of messages that were removed
|
|
332
|
+
- state: Updated state after fixing (or original if no changes)
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
HTTPException: If the fix operation fails or if no state is found
|
|
336
|
+
for the given thread_id
|
|
337
|
+
"""
|
|
338
|
+
logger.info(f"Graph fix request received for thread: {fix_request.thread_id}")
|
|
339
|
+
|
|
340
|
+
result = await service.fix_graph(
|
|
341
|
+
fix_request.thread_id,
|
|
342
|
+
user,
|
|
343
|
+
fix_request.config,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
logger.info(f"Graph fix completed for thread {fix_request.thread_id}")
|
|
347
|
+
|
|
348
|
+
return success_response(
|
|
349
|
+
result,
|
|
350
|
+
request,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
355
|
+
# WebSocket endpoint
|
|
356
|
+
# ──────────────────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def _ws_run_gate(websocket: WebSocket, user: dict[str, Any]) -> str:
|
|
360
|
+
"""Per-run checks for a WebSocket: ``"run"``, ``"skip"`` (rate limited) or ``"close"``.
|
|
361
|
+
|
|
362
|
+
The handshake guard runs once, so a socket that stays open must be re-checked before each
|
|
363
|
+
run: the token may have expired or been revoked, and every run counts against the limit.
|
|
364
|
+
"""
|
|
365
|
+
if not ws_identity_still_valid(websocket, user):
|
|
366
|
+
logger.info("WebSocket credential no longer valid; closing")
|
|
367
|
+
await websocket.send_text(
|
|
368
|
+
StreamChunk(
|
|
369
|
+
event=StreamEvent.ERROR,
|
|
370
|
+
data={"reason": "Session expired. Reconnect with a valid token."},
|
|
371
|
+
).model_dump_json()
|
|
372
|
+
)
|
|
373
|
+
await websocket.close(code=1008)
|
|
374
|
+
return "close"
|
|
375
|
+
|
|
376
|
+
retry_after = await ws_run_allowed(websocket)
|
|
377
|
+
if retry_after is not None:
|
|
378
|
+
await websocket.send_text(
|
|
379
|
+
StreamChunk(
|
|
380
|
+
event=StreamEvent.ERROR,
|
|
381
|
+
data={
|
|
382
|
+
"reason": f"Rate limit exceeded. Retry after {retry_after}s.",
|
|
383
|
+
"retry_after_seconds": retry_after,
|
|
384
|
+
},
|
|
385
|
+
).model_dump_json()
|
|
386
|
+
)
|
|
387
|
+
return "skip"
|
|
388
|
+
return "run"
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _ws_run_thread_id(ws_input: WsGraphInputSchema) -> str | None:
|
|
392
|
+
"""Resolve the thread a WebSocket run targets, and make the run use exactly that thread.
|
|
393
|
+
|
|
394
|
+
Normalises ``config.thread_id`` the way ``GraphService`` does. ``"new"`` (or a missing
|
|
395
|
+
or blank id) means "start a fresh thread": the key is removed so the service generates
|
|
396
|
+
one, instead of running on a literal thread named ``"new"`` shared by every client.
|
|
397
|
+
"""
|
|
398
|
+
config = dict(ws_input.config or {})
|
|
399
|
+
thread_id = _resolve_ws_thread(config)
|
|
400
|
+
ws_input.config = config
|
|
401
|
+
return thread_id
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _resolve_ws_thread(frame: dict[str, Any]) -> str | None:
|
|
405
|
+
"""Normalise ``frame["thread_id"]`` in place; ``"new"``, blank or missing removes it."""
|
|
406
|
+
thread_id = normalize_thread_id(frame.get("thread_id"))
|
|
407
|
+
if thread_id == WS_NEW_THREAD:
|
|
408
|
+
thread_id = None
|
|
409
|
+
if thread_id is None:
|
|
410
|
+
frame.pop("thread_id", None)
|
|
411
|
+
else:
|
|
412
|
+
frame["thread_id"] = thread_id
|
|
413
|
+
return thread_id
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
async def _ws_thread_authorized(
|
|
417
|
+
authz: AuthorizationBackend,
|
|
418
|
+
user: dict[str, Any],
|
|
419
|
+
thread_id: str | None,
|
|
420
|
+
action: str,
|
|
421
|
+
) -> bool:
|
|
422
|
+
"""Owner-check ``thread_id`` for a WebSocket run.
|
|
423
|
+
|
|
424
|
+
Returns ``True`` when the run may proceed. Ownership is only enforced when auth is
|
|
425
|
+
configured (``GraphConfig.auth_config``); an unauthenticated dev graph and fresh
|
|
426
|
+
(``"new"``/absent) threads always pass. If the config cannot be read, the check runs
|
|
427
|
+
anyway: a lookup failure must not switch authorization off.
|
|
428
|
+
"""
|
|
429
|
+
if not thread_id or thread_id == WS_NEW_THREAD:
|
|
430
|
+
return True
|
|
431
|
+
|
|
432
|
+
from injectq import InjectQ
|
|
433
|
+
|
|
434
|
+
has_auth = True
|
|
435
|
+
try:
|
|
436
|
+
from tenxgraph_api.src.app.core.config.graph_config import GraphConfig
|
|
437
|
+
|
|
438
|
+
cfg = InjectQ.get_instance().get(GraphConfig)
|
|
439
|
+
has_auth = bool(cfg and cfg.auth_config())
|
|
440
|
+
except Exception as exc:
|
|
441
|
+
logger.warning("Could not resolve GraphConfig for WS auth check; enforcing it: %s", exc)
|
|
442
|
+
|
|
443
|
+
if not has_auth:
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
return await authz.authorize(user, "graph", action, resource_id=str(thread_id))
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
@router.websocket("/v1/graph/ws")
|
|
450
|
+
async def websocket_graph(
|
|
451
|
+
websocket: WebSocket,
|
|
452
|
+
_bind: None = Depends(_bind_ws_container),
|
|
453
|
+
_guard: None = Depends(realtime_connection_guard),
|
|
454
|
+
service: GraphService = InjectAPI(GraphService),
|
|
455
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "stream")),
|
|
456
|
+
authz: AuthorizationBackend = InjectAPI(AuthorizationBackend),
|
|
457
|
+
):
|
|
458
|
+
"""
|
|
459
|
+
WebSocket endpoint for streaming graph execution.
|
|
460
|
+
|
|
461
|
+
One connection, one run at a time. The client drives the protocol:
|
|
462
|
+
|
|
463
|
+
Fresh run
|
|
464
|
+
---------
|
|
465
|
+
Client → Server : WsGraphInputSchema { invoke_type:"fresh", messages:[…], config:{} }
|
|
466
|
+
Server → Client : StreamChunk JSON messages (same format as POST /v1/graph/stream)
|
|
467
|
+
Server → Client : StreamChunk { event:"updates", data:{status:"done"} }
|
|
468
|
+
|
|
469
|
+
Resume after remote tool call
|
|
470
|
+
-----------------------------
|
|
471
|
+
(client detects the remote-tool-call chunk in the stream, executes the tool)
|
|
472
|
+
Client → Server : WsGraphInputSchema { invoke_type:"resume", tool_result:[…],
|
|
473
|
+
config:{thread_id:"<id>"} }
|
|
474
|
+
Server → Client : StreamChunk JSON messages (graph resumes from checkpoint)
|
|
475
|
+
Server → Client : StreamChunk { event:"updates", data:{status:"done"} }
|
|
476
|
+
|
|
477
|
+
The server does not inspect chunks for tool-call detection — that is the
|
|
478
|
+
client library's responsibility. invoke_type is only used for validation
|
|
479
|
+
and logging.
|
|
480
|
+
|
|
481
|
+
Authentication
|
|
482
|
+
--------------
|
|
483
|
+
Bearer token via the ``Authorization`` header, the ``10xgraph-bearer``
|
|
484
|
+
Sec-WebSocket-Protocol (browser-safe), or the ``?token=`` query fallback —
|
|
485
|
+
identical to the HTTP stream route. Handshakes are subject to the global rate
|
|
486
|
+
limit and the ``websocket.max_connections`` cap.
|
|
487
|
+
|
|
488
|
+
Every run on the socket counts against the same rate limit (an error chunk with
|
|
489
|
+
``retry_after_seconds`` is sent when over it), and the token is re-verified before each
|
|
490
|
+
run.
|
|
491
|
+
|
|
492
|
+
Close codes
|
|
493
|
+
-----------
|
|
494
|
+
1000 normal closure (client disconnected cleanly)
|
|
495
|
+
1008 rejected: this graph is a live (realtime) agent — use ``/v1/graph/live``;
|
|
496
|
+
or the token expired / was revoked since the socket opened
|
|
497
|
+
1011 unexpected server error
|
|
498
|
+
1013 rejected: rate limit or connection cap exceeded (try again later)
|
|
499
|
+
"""
|
|
500
|
+
await websocket.accept(subprotocol=ws_bearer_subprotocol(websocket))
|
|
501
|
+
logger.info("WebSocket graph connection accepted")
|
|
502
|
+
|
|
503
|
+
if hasattr(authz, "dependency"):
|
|
504
|
+
from injectq import InjectQ
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
authz = InjectQ.get_instance().get(AuthorizationBackend)
|
|
508
|
+
except Exception:
|
|
509
|
+
from tenxgraph_api.src.app.core.auth.authorization import DefaultAuthorizationBackend
|
|
510
|
+
|
|
511
|
+
authz = DefaultAuthorizationBackend()
|
|
512
|
+
|
|
513
|
+
# Wrong agent type for this endpoint: a live (realtime) graph cannot be driven over
|
|
514
|
+
# the turn-based stream socket. Reject up front with a clear error instead of failing
|
|
515
|
+
# mid-run when the graph refuses invoke/stream.
|
|
516
|
+
if service.is_live_agent:
|
|
517
|
+
logger.warning("Rejected /v1/graph/ws connection: graph is a live agent")
|
|
518
|
+
with contextlib.suppress(Exception):
|
|
519
|
+
await websocket.send_text(
|
|
520
|
+
StreamChunk(
|
|
521
|
+
event=StreamEvent.ERROR,
|
|
522
|
+
data={
|
|
523
|
+
"reason": "This graph is a live (realtime) agent; connect to "
|
|
524
|
+
"/v1/graph/live instead of /v1/graph/ws.",
|
|
525
|
+
},
|
|
526
|
+
).model_dump_json()
|
|
527
|
+
)
|
|
528
|
+
with contextlib.suppress(Exception):
|
|
529
|
+
await websocket.close(code=1008)
|
|
530
|
+
return
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
while True:
|
|
534
|
+
try:
|
|
535
|
+
data = await websocket.receive_json()
|
|
536
|
+
except WebSocketDisconnect:
|
|
537
|
+
logger.info("WebSocket client disconnected")
|
|
538
|
+
break
|
|
539
|
+
|
|
540
|
+
try:
|
|
541
|
+
ws_input = WsGraphInputSchema(**data)
|
|
542
|
+
except Exception as e:
|
|
543
|
+
logger.warning("WebSocket received invalid input: %s", e)
|
|
544
|
+
await websocket.send_text(
|
|
545
|
+
StreamChunk(
|
|
546
|
+
event=StreamEvent.ERROR,
|
|
547
|
+
data={"reason": f"Invalid request: {e}"},
|
|
548
|
+
).model_dump_json()
|
|
549
|
+
)
|
|
550
|
+
continue
|
|
551
|
+
|
|
552
|
+
# The handshake checks happen once; these apply to every run on the socket.
|
|
553
|
+
gate = await _ws_run_gate(websocket, user)
|
|
554
|
+
if gate == "close":
|
|
555
|
+
break
|
|
556
|
+
if gate == "skip":
|
|
557
|
+
continue
|
|
558
|
+
|
|
559
|
+
thread_id = _ws_run_thread_id(ws_input)
|
|
560
|
+
if not await _ws_thread_authorized(authz, user, thread_id, "stream"):
|
|
561
|
+
logger.warning(
|
|
562
|
+
f"WebSocket authorization failed for user {user.get('user_id')} "
|
|
563
|
+
f"on thread {thread_id}"
|
|
564
|
+
)
|
|
565
|
+
await websocket.send_text(
|
|
566
|
+
StreamChunk(
|
|
567
|
+
event=StreamEvent.ERROR,
|
|
568
|
+
data={"reason": f"Not authorized to stream thread {thread_id}"},
|
|
569
|
+
).model_dump_json()
|
|
570
|
+
)
|
|
571
|
+
continue
|
|
572
|
+
|
|
573
|
+
logger.info(
|
|
574
|
+
"WebSocket graph run: invoke_type=%s, thread_id=%s",
|
|
575
|
+
ws_input.invoke_type,
|
|
576
|
+
thread_id,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
graph_input = ws_input.to_graph_input()
|
|
580
|
+
async for chunk_json in service.stream_graph(graph_input, user=user):
|
|
581
|
+
await websocket.send_text(chunk_json)
|
|
582
|
+
|
|
583
|
+
await websocket.send_text(
|
|
584
|
+
StreamChunk(
|
|
585
|
+
event=StreamEvent.UPDATES,
|
|
586
|
+
data={"status": "done"},
|
|
587
|
+
).model_dump_json()
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
except WebSocketDisconnect:
|
|
591
|
+
logger.info("WebSocket client disconnected")
|
|
592
|
+
except Exception as e:
|
|
593
|
+
logger.error("WebSocket graph connection error: %s", e)
|
|
594
|
+
with contextlib.suppress(Exception):
|
|
595
|
+
await websocket.close(code=1011)
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _client_error(exc: Exception) -> str:
|
|
599
|
+
"""The error text a client may see: the exception in development, generic in production.
|
|
600
|
+
|
|
601
|
+
A ValueError here can come from anywhere under the session (storage, provider SDK), not
|
|
602
|
+
only from the init frame, and its text may name internal hosts, paths or queries.
|
|
603
|
+
"""
|
|
604
|
+
from tenxgraph_api.src.app.core.config.settings import get_settings
|
|
605
|
+
from tenxgraph_api.src.app.core.exceptions.handle_errors import _sanitize_error_message
|
|
606
|
+
|
|
607
|
+
is_production = get_settings().MODE == "production"
|
|
608
|
+
return _sanitize_error_message(str(exc), "VALIDATION_ERROR", is_production)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _realtime_event_json(event: Any) -> str:
|
|
612
|
+
"""Serialize a non-audio RealtimeEvent to a JSON text frame for the client."""
|
|
613
|
+
try:
|
|
614
|
+
payload = event.model_dump(mode="json")
|
|
615
|
+
except Exception as e:
|
|
616
|
+
logger.warning("Realtime event serialization failed (%s): %s", type(event).__name__, e)
|
|
617
|
+
payload = {"type": getattr(event, "type", "unknown")}
|
|
618
|
+
return json.dumps(payload)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
@router.websocket("/v1/graph/live")
|
|
622
|
+
async def realtime_graph_ws( # noqa: PLR0912, PLR0915
|
|
623
|
+
websocket: WebSocket,
|
|
624
|
+
_bind: None = Depends(_bind_ws_container),
|
|
625
|
+
_guard: None = Depends(realtime_connection_guard),
|
|
626
|
+
service: GraphService = InjectAPI(GraphService),
|
|
627
|
+
user: dict[str, Any] = Depends(RequirePermission("graph", "stream")),
|
|
628
|
+
authz: AuthorizationBackend = InjectAPI(AuthorizationBackend),
|
|
629
|
+
):
|
|
630
|
+
"""Realtime (audio-to-audio) WebSocket bridge over ``CompiledGraph.arealtime``.
|
|
631
|
+
|
|
632
|
+
Protocol (provider-neutral; the client never sees Gemini vs OpenAI)
|
|
633
|
+
------------------------------------------------------------------
|
|
634
|
+
First frame : JSON control ``{model, thread_id?, voice?, modalities?, vad?, ...}``
|
|
635
|
+
Upstream : binary frame = PCM16 input audio
|
|
636
|
+
JSON control = ``{type:"activity_start"|"activity_end"|"text"|"close", ...}``
|
|
637
|
+
Downstream : binary frame = PCM16 model audio (``audio_delta``)
|
|
638
|
+
JSON text frame = every other event (transcripts, turn_complete,
|
|
639
|
+
interrupted, tool_call, session/go_away, error)
|
|
640
|
+
|
|
641
|
+
Auth: ``RequirePermission("graph","stream")`` — bearer via the ``Authorization`` header,
|
|
642
|
+
the ``10xgraph-bearer`` Sec-WebSocket-Protocol (browser-safe), or the ``?token=`` query
|
|
643
|
+
fallback. Handshakes are subject to the global rate limit and the
|
|
644
|
+
``websocket.max_connections`` cap (rejected with close code 1013). A non-live
|
|
645
|
+
(turn-based) graph is rejected up front with close code 1008 — use ``/v1/graph/ws``.
|
|
646
|
+
"""
|
|
647
|
+
await websocket.accept(subprotocol=ws_bearer_subprotocol(websocket))
|
|
648
|
+
logger.info("Realtime WebSocket connection accepted")
|
|
649
|
+
|
|
650
|
+
if hasattr(authz, "dependency"):
|
|
651
|
+
from injectq import InjectQ
|
|
652
|
+
|
|
653
|
+
try:
|
|
654
|
+
authz = InjectQ.get_instance().get(AuthorizationBackend)
|
|
655
|
+
except Exception:
|
|
656
|
+
from tenxgraph_api.src.app.core.auth.authorization import DefaultAuthorizationBackend
|
|
657
|
+
|
|
658
|
+
authz = DefaultAuthorizationBackend()
|
|
659
|
+
|
|
660
|
+
# Wrong agent type for this endpoint: the realtime bridge requires a graph rooted at a
|
|
661
|
+
# LiveAgent. Reject a turn-based graph up front with a normalized fatal error instead
|
|
662
|
+
# of accepting the init frame and failing later inside ``arealtime``.
|
|
663
|
+
if not service.is_live_agent:
|
|
664
|
+
logger.warning("Rejected /v1/graph/live connection: graph is not a live agent")
|
|
665
|
+
with contextlib.suppress(Exception):
|
|
666
|
+
await websocket.send_text(
|
|
667
|
+
_realtime_event_json(
|
|
668
|
+
ErrorEvent(
|
|
669
|
+
code="not_live",
|
|
670
|
+
message="This graph is not a live (realtime) agent; use the "
|
|
671
|
+
"turn-based /v1/graph/ws endpoint instead of /v1/graph/live.",
|
|
672
|
+
fatal=True,
|
|
673
|
+
)
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
with contextlib.suppress(Exception):
|
|
677
|
+
await websocket.close(code=1008)
|
|
678
|
+
return
|
|
679
|
+
|
|
680
|
+
try:
|
|
681
|
+
init = await websocket.receive_json()
|
|
682
|
+
except WebSocketDisconnect:
|
|
683
|
+
logger.info("Realtime client disconnected before init")
|
|
684
|
+
return
|
|
685
|
+
except Exception as e:
|
|
686
|
+
logger.warning("Realtime init frame invalid: %s", e)
|
|
687
|
+
with contextlib.suppress(Exception):
|
|
688
|
+
await websocket.close(code=1003)
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
if isinstance(init, dict):
|
|
692
|
+
# Same rules as /v1/graph/ws: "new" starts a fresh thread, never a shared one.
|
|
693
|
+
thread_id = _resolve_ws_thread(init)
|
|
694
|
+
if not await _ws_thread_authorized(authz, user, thread_id, "stream"):
|
|
695
|
+
logger.warning(
|
|
696
|
+
f"Realtime WebSocket authorization failed for user {user.get('user_id')} "
|
|
697
|
+
f"on thread {thread_id}"
|
|
698
|
+
)
|
|
699
|
+
with contextlib.suppress(Exception):
|
|
700
|
+
await websocket.send_text(
|
|
701
|
+
_realtime_event_json(
|
|
702
|
+
ErrorEvent(
|
|
703
|
+
code="not_authorized",
|
|
704
|
+
message=f"Not authorized to stream thread {thread_id}",
|
|
705
|
+
fatal=True,
|
|
706
|
+
)
|
|
707
|
+
)
|
|
708
|
+
)
|
|
709
|
+
with contextlib.suppress(Exception):
|
|
710
|
+
await websocket.close(code=1008)
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
if not isinstance(init, dict):
|
|
714
|
+
logger.warning("Realtime init frame must be a JSON object, got %s", type(init).__name__)
|
|
715
|
+
with contextlib.suppress(Exception):
|
|
716
|
+
await websocket.close(code=1003)
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
queue = LiveInputQueue(maxsize=REALTIME_INPUT_QUEUE_MAXSIZE)
|
|
720
|
+
|
|
721
|
+
async def upstream() -> None:
|
|
722
|
+
"""Pump client frames into the input queue until close/disconnect."""
|
|
723
|
+
try:
|
|
724
|
+
while True:
|
|
725
|
+
message = await websocket.receive()
|
|
726
|
+
if message.get("type") == "websocket.disconnect":
|
|
727
|
+
break
|
|
728
|
+
data = message.get("bytes")
|
|
729
|
+
if data is not None:
|
|
730
|
+
if len(data) > REALTIME_MAX_FRAME_BYTES:
|
|
731
|
+
logger.warning(
|
|
732
|
+
"Realtime upstream: dropping oversized audio frame (%d bytes > %d)",
|
|
733
|
+
len(data),
|
|
734
|
+
REALTIME_MAX_FRAME_BYTES,
|
|
735
|
+
)
|
|
736
|
+
continue
|
|
737
|
+
queue.send_audio(data)
|
|
738
|
+
continue
|
|
739
|
+
text = message.get("text")
|
|
740
|
+
if text is None:
|
|
741
|
+
continue
|
|
742
|
+
try:
|
|
743
|
+
control = json.loads(text)
|
|
744
|
+
except json.JSONDecodeError:
|
|
745
|
+
logger.warning("Realtime upstream: non-JSON text frame ignored")
|
|
746
|
+
continue
|
|
747
|
+
ctype = control.get("type")
|
|
748
|
+
if ctype == "text":
|
|
749
|
+
queue.send_text(control.get("text", ""))
|
|
750
|
+
elif ctype == "activity_start":
|
|
751
|
+
queue.send_activity_start()
|
|
752
|
+
elif ctype == "activity_end":
|
|
753
|
+
queue.send_activity_end()
|
|
754
|
+
elif ctype == "close":
|
|
755
|
+
break
|
|
756
|
+
except WebSocketDisconnect:
|
|
757
|
+
logger.info("Realtime client disconnected (upstream)")
|
|
758
|
+
finally:
|
|
759
|
+
queue.close()
|
|
760
|
+
|
|
761
|
+
async def downstream() -> None:
|
|
762
|
+
"""Stream normalized events back: audio as binary, everything else as JSON."""
|
|
763
|
+
try:
|
|
764
|
+
async for event in service.realtime_graph(queue, init, user):
|
|
765
|
+
if getattr(event, "type", None) == "audio_delta":
|
|
766
|
+
await websocket.send_bytes(event.data)
|
|
767
|
+
else:
|
|
768
|
+
await websocket.send_text(_realtime_event_json(event))
|
|
769
|
+
except (ValidationError, ValueError) as e:
|
|
770
|
+
# Bad session config from the init frame (e.g. an invalid ``modalities`` value)
|
|
771
|
+
# is a client error, not a server fault. Send a normalized, fatal error event
|
|
772
|
+
# so the client can show why instead of seeing an opaque 1011 close.
|
|
773
|
+
logger.warning("Realtime session config rejected: %s", e)
|
|
774
|
+
with contextlib.suppress(Exception):
|
|
775
|
+
await websocket.send_text(
|
|
776
|
+
_realtime_event_json(
|
|
777
|
+
ErrorEvent(code="invalid_config", message=_client_error(e), fatal=True)
|
|
778
|
+
)
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
up_task = asyncio.create_task(upstream())
|
|
782
|
+
down_task = asyncio.create_task(downstream())
|
|
783
|
+
try:
|
|
784
|
+
_done, pending = await asyncio.wait(
|
|
785
|
+
{up_task, down_task}, return_when=asyncio.FIRST_COMPLETED
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
# If the client side finished first (sent ``close`` or disconnected) while the
|
|
789
|
+
# model is still responding, give downstream a bounded grace period to drain the
|
|
790
|
+
# session's final events instead of cutting them off. Closing the input queue ends
|
|
791
|
+
# the live agent's turn and stops it once the provider goes idle, so this returns
|
|
792
|
+
# promptly; on a real disconnect the next send fails fast and downstream ends.
|
|
793
|
+
if down_task in pending:
|
|
794
|
+
queue.close()
|
|
795
|
+
await asyncio.wait({down_task}, timeout=REALTIME_DRAIN_TIMEOUT)
|
|
796
|
+
|
|
797
|
+
# Cancel whatever is still running: downstream that overran the grace window, or
|
|
798
|
+
# upstream once the session ended.
|
|
799
|
+
for task in (up_task, down_task):
|
|
800
|
+
if not task.done():
|
|
801
|
+
task.cancel()
|
|
802
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
803
|
+
await task
|
|
804
|
+
|
|
805
|
+
# asyncio.wait never re-raises; surface any failure from the finished task(s)
|
|
806
|
+
# (e.g. arealtime rejecting a non-live graph, or a provider/checkpointer error).
|
|
807
|
+
for task in (up_task, down_task):
|
|
808
|
+
if task.done() and not task.cancelled():
|
|
809
|
+
task.result()
|
|
810
|
+
except WebSocketDisconnect:
|
|
811
|
+
# The client going away mid-session is a normal termination, not a server fault;
|
|
812
|
+
# don't log it as an error or attempt a 1011 close on an already-closed socket.
|
|
813
|
+
logger.info("Realtime WebSocket client disconnected")
|
|
814
|
+
except Exception as e:
|
|
815
|
+
logger.error("Realtime WebSocket error: %s", e)
|
|
816
|
+
with contextlib.suppress(Exception):
|
|
817
|
+
await websocket.close(code=1011)
|
|
818
|
+
finally:
|
|
819
|
+
queue.close()
|
|
820
|
+
with contextlib.suppress(Exception):
|
|
821
|
+
await websocket.close()
|