union-py-app 1.0.0

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 (67) hide show
  1. package/app/__init__.py +1 -0
  2. package/app/agent/__init__.py +1 -0
  3. package/app/agent/capabilities.py +387 -0
  4. package/app/agent/coordinator/__init__.py +1 -0
  5. package/app/agent/coordinator/definition.py +50 -0
  6. package/app/agent/coordinator/output_guard.py +29 -0
  7. package/app/agent/graph.py +95 -0
  8. package/app/agent/guardrails.py +30 -0
  9. package/app/agent/routing.py +81 -0
  10. package/app/agent/runtime/__init__.py +1 -0
  11. package/app/agent/runtime/activity.py +393 -0
  12. package/app/agent/runtime/delegation.py +80 -0
  13. package/app/agent/runtime/deps.py +34 -0
  14. package/app/agent/runtime/execution.py +381 -0
  15. package/app/agent/runtime/model.py +47 -0
  16. package/app/agent/runtime/model_errors.py +40 -0
  17. package/app/agent/runtime/session.py +156 -0
  18. package/app/agent/specialists/__init__.py +1 -0
  19. package/app/agent/specialists/behavior_risk/__init__.py +1 -0
  20. package/app/agent/specialists/behavior_risk/definition.py +54 -0
  21. package/app/agent/specialists/build.py +94 -0
  22. package/app/agent/specialists/knowledge/__init__.py +1 -0
  23. package/app/agent/specialists/knowledge/definition.py +38 -0
  24. package/app/agent/specialists/personal_memory/__init__.py +1 -0
  25. package/app/agent/specialists/personal_memory/definition.py +35 -0
  26. package/app/agent/specialists/personal_memory/output_guard.py +55 -0
  27. package/app/agent/specialists/running_analysis/__init__.py +1 -0
  28. package/app/agent/specialists/running_analysis/definition.py +46 -0
  29. package/app/agent/specialists/running_analysis/output_guard.py +38 -0
  30. package/app/agent/specialists/scheduled_task_draft/__init__.py +8 -0
  31. package/app/agent/specialists/scheduled_task_draft/definition.py +142 -0
  32. package/app/agent/specialists/scheduled_task_draft/output_guard.py +24 -0
  33. package/app/asgi.py +148 -0
  34. package/app/config/__init__.py +1 -0
  35. package/app/config/settings.py +67 -0
  36. package/app/memory/__init__.py +1 -0
  37. package/app/memory/store.py +154 -0
  38. package/app/service/rag_service.py +365 -0
  39. package/app/skills/full-chain-quality-analysis/SKILL.md +22 -0
  40. package/app/tools/__init__.py +1 -0
  41. package/app/tools/business.py +183 -0
  42. package/app/utils/__init__.py +1 -0
  43. package/app/utils/api_client.py +108 -0
  44. package/app/utils/control_auth.py +50 -0
  45. package/app/utils/request_logging.py +63 -0
  46. package/app/utils/state_client.py +68 -0
  47. package/app/views/__init__.py +1 -0
  48. package/app/views/auth.py +208 -0
  49. package/app/views/errors.py +29 -0
  50. package/app/views/routes.py +25 -0
  51. package/app/views/run_context.py +33 -0
  52. package/app/views/streaming_runs.py +350 -0
  53. package/app/views/sync_runs.py +180 -0
  54. package/deploy/autoconf/templates/env.j2 +23 -0
  55. package/deploy/autoconf.yml +15 -0
  56. package/deploy/scripts/healthcheck.sh +12 -0
  57. package/deploy/scripts/start.sh +80 -0
  58. package/deploy/scripts/stop.sh +35 -0
  59. package/knowledge/000036-scenario-offline-function-call-mock-v1.md +134 -0
  60. package/package.json +21 -0
  61. package/requirements.txt +10 -0
  62. package/scripts/healthcheck.sh +4 -0
  63. package/scripts/start-BJ11.sh +1 -0
  64. package/scripts/start-BJ12.sh +1 -0
  65. package/scripts/start-SH20.sh +1 -0
  66. package/scripts/start-SZ31.sh +1 -0
  67. package/scripts/stop.sh +4 -0
@@ -0,0 +1,183 @@
1
+ """定义知识检索和运行分析 Agent 可调用的业务工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Annotated
7
+ from zoneinfo import ZoneInfo
8
+
9
+ import anyio
10
+ from pydantic import Field
11
+ from pydantic_ai import RunContext, Tool
12
+ from pydantic_ai.toolsets import FunctionToolset
13
+
14
+ from app.agent.runtime.deps import RunDeps
15
+ from app.utils.api_client import ToolError, ToolResult
16
+
17
+ QueryDate = Annotated[str, Field(pattern=r"^\d{8}$")]
18
+ _BIGDATA_REQUEST_TIMEOUT_SECONDS = 180
19
+ _BIGDATA_TIMEOUT_ATTEMPTS = 3
20
+ _BIGDATA_TOOL_TIMEOUT_SECONDS = 600
21
+
22
+
23
+ async def knowledge_search(
24
+ ctx: RunContext[RunDeps],
25
+ query: Annotated[str, Field(min_length=1, max_length=2000)],
26
+ top_k: Annotated[int | None, Field(ge=1, le=20)] = None,
27
+ ) -> ToolResult:
28
+ """检索共享知识库中的制度、SOP、机制和名词解释证据。"""
29
+ if ctx.deps.rag_service is None:
30
+ raise ToolError("知识库当前不可用。")
31
+ try:
32
+ data, status = await anyio.to_thread.run_sync(
33
+ ctx.deps.rag_service.knowledge_search,
34
+ query,
35
+ top_k,
36
+ abandon_on_cancel=True,
37
+ )
38
+ except Exception as exc:
39
+ raise ToolError("知识库检索失败。") from exc
40
+ if status != "success":
41
+ raise ToolError("知识库检索失败。")
42
+ return ToolResult(status="success", data=data)
43
+
44
+
45
+ async def running_analysis_resolve_member_org(
46
+ ctx: RunContext[RunDeps],
47
+ org_name: Annotated[str, Field(min_length=1, max_length=128)],
48
+ ) -> ToolResult:
49
+ """根据机构名称、简称或别名解析可信 orgCode。"""
50
+ return await ctx.deps.api_client.call(
51
+ path="/agent/getOrgInfo",
52
+ payload={"orgName": org_name},
53
+ timeout=10,
54
+ )
55
+
56
+
57
+ async def running_analysis_query_member_metrics(
58
+ ctx: RunContext[RunDeps],
59
+ start_date: QueryDate,
60
+ end_date: QueryDate,
61
+ org_code_list: Annotated[list[str], Field(min_length=1, max_length=100)],
62
+ ) -> ToolResult:
63
+ """查询指定成员机构在日期范围内的每日运行指标。"""
64
+ _validate_metric_end_date(end_date)
65
+ return await ctx.deps.api_client.call(
66
+ path="/agent/queryBigData",
67
+ payload={
68
+ "interfaceName": "runing_cnt.bank",
69
+ "params": {
70
+ "startDate": start_date,
71
+ "endDate": end_date,
72
+ "orgCodeList": org_code_list,
73
+ },
74
+ },
75
+ timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
76
+ timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
77
+ )
78
+
79
+
80
+ async def running_analysis_query_all_member_metrics(
81
+ ctx: RunContext[RunDeps],
82
+ start_date: QueryDate,
83
+ end_date: QueryDate,
84
+ ) -> ToolResult:
85
+ """查询全部成员机构在日期范围内的每日运行指标。"""
86
+ _validate_metric_end_date(end_date)
87
+ return await ctx.deps.api_client.call(
88
+ path="/agent/queryBigData",
89
+ payload={
90
+ "interfaceName": "runing_cnt.bank",
91
+ "params": {"startDate": start_date, "endDate": end_date},
92
+ },
93
+ timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
94
+ timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
95
+ )
96
+
97
+
98
+ async def running_analysis_query_chain_metrics(
99
+ ctx: RunContext[RunDeps],
100
+ start_date: QueryDate,
101
+ end_date: QueryDate,
102
+ ) -> ToolResult:
103
+ """查询全链路在日期范围内的每日运行指标。"""
104
+ _validate_metric_end_date(end_date)
105
+ return await ctx.deps.api_client.call(
106
+ path="/agent/queryBigData",
107
+ payload={
108
+ "interfaceName": "runing_cnt.full_link",
109
+ "params": {"startDate": start_date, "endDate": end_date},
110
+ },
111
+ timeout=_BIGDATA_REQUEST_TIMEOUT_SECONDS,
112
+ timeout_attempts=_BIGDATA_TIMEOUT_ATTEMPTS,
113
+ )
114
+
115
+
116
+ async def running_analysis_query_changes(
117
+ ctx: RunContext[RunDeps],
118
+ org_code: Annotated[str, Field(min_length=1, max_length=64)],
119
+ start_date: QueryDate,
120
+ end_date: QueryDate,
121
+ ) -> ToolResult:
122
+ """查询机构在日期范围内的变更通知、状态、评价和影响范围。"""
123
+ return await ctx.deps.api_client.call(
124
+ path="/agent/announceList",
125
+ payload={
126
+ "org_code": org_code,
127
+ "planned_start_time": start_date,
128
+ "planned_start_time_end": end_date,
129
+ },
130
+ timeout=10,
131
+ )
132
+
133
+
134
+ async def running_analysis_query_faults(
135
+ ctx: RunContext[RunDeps],
136
+ org_code: Annotated[str, Field(min_length=1, max_length=64)],
137
+ start_date: QueryDate,
138
+ end_date: QueryDate,
139
+ ) -> ToolResult:
140
+ """查询机构在日期范围内的 Jira 故障、影响、原因及状态。"""
141
+ return await ctx.deps.api_client.call(
142
+ path="/agent/getJiraInfo",
143
+ payload={"orgCode": org_code, "startDate": start_date, "endDate": end_date},
144
+ timeout=10,
145
+ )
146
+
147
+
148
+ def _validate_metric_end_date(end_date: str) -> None:
149
+ today = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d")
150
+ if end_date > today:
151
+ raise ToolError(f"运行指标结束日期不得晚于当前业务日期 {today}。")
152
+
153
+
154
+ def knowledge_toolset(timeout: float) -> FunctionToolset[RunDeps]:
155
+ return FunctionToolset[RunDeps](
156
+ [knowledge_search],
157
+ timeout=timeout,
158
+ max_retries=2,
159
+ )
160
+
161
+
162
+ def running_analysis_toolset(timeout: float) -> FunctionToolset[RunDeps]:
163
+ return FunctionToolset[RunDeps](
164
+ [
165
+ running_analysis_resolve_member_org,
166
+ Tool(
167
+ running_analysis_query_member_metrics,
168
+ timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
169
+ ),
170
+ Tool(
171
+ running_analysis_query_all_member_metrics,
172
+ timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
173
+ ),
174
+ Tool(
175
+ running_analysis_query_chain_metrics,
176
+ timeout=_BIGDATA_TOOL_TIMEOUT_SECONDS,
177
+ ),
178
+ running_analysis_query_changes,
179
+ running_analysis_query_faults,
180
+ ],
181
+ timeout=timeout,
182
+ max_retries=2,
183
+ )
@@ -0,0 +1 @@
1
+ """存放不依赖 Agent 定义的通用业务 API 辅助实现。"""
@@ -0,0 +1,108 @@
1
+ """封装工具调用所需的认证 HTTP 请求、结果校验与数据边界。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from time import monotonic
6
+ from typing import Any, Literal
7
+
8
+ import httpx
9
+ from pydantic import BaseModel, ConfigDict
10
+ from pydantic_ai import ModelRetry, ToolFailed
11
+
12
+ from app.utils.control_auth import ControlAuth
13
+ from app.utils.request_logging import get_logger
14
+
15
+ logger = get_logger(__name__)
16
+
17
+
18
+ class ToolResult(BaseModel):
19
+ model_config = ConfigDict(extra="forbid")
20
+
21
+ status: Literal["success"]
22
+ data: Any
23
+
24
+
25
+ class ToolError(ModelRetry):
26
+ pass
27
+
28
+
29
+ class ApiClient:
30
+ def __init__(
31
+ self,
32
+ *,
33
+ http: httpx.AsyncClient,
34
+ base_url: str,
35
+ auth: ControlAuth,
36
+ ) -> None:
37
+ self._http = http
38
+ self._base_url = base_url.rstrip("/")
39
+ self._auth = auth
40
+
41
+ async def call(
42
+ self,
43
+ *,
44
+ path: str,
45
+ payload: dict[str, Any],
46
+ timeout: float,
47
+ timeout_attempts: int = 1,
48
+ ) -> ToolResult:
49
+ if timeout_attempts < 1:
50
+ raise ValueError("timeout_attempts must be positive")
51
+ started = monotonic()
52
+ logger.info("Tool request started path=%r", path)
53
+ for attempt in range(1, timeout_attempts + 1):
54
+ try:
55
+ response = await self._http.post(
56
+ f"{self._base_url}{path}",
57
+ json=payload,
58
+ headers=self._auth.headers(),
59
+ timeout=timeout,
60
+ )
61
+ break
62
+ except httpx.TimeoutException as exc:
63
+ logger.warning(
64
+ "Tool request timeout path=%r attempt=%s/%s timeout_seconds=%s",
65
+ path,
66
+ attempt,
67
+ timeout_attempts,
68
+ timeout,
69
+ )
70
+ if attempt == timeout_attempts:
71
+ raise ToolFailed(
72
+ f"上游查询连续 {timeout_attempts} 次超时,请稍后重试。"
73
+ ) from exc
74
+ except httpx.HTTPError as exc:
75
+ logger.error("Tool request failed path=%r error_type=%s", path, type(exc).__name__)
76
+ raise
77
+ try:
78
+ result = _tool_result(response)
79
+ except ToolError as exc:
80
+ logger.warning(
81
+ "Tool request rejected path=%r status=%s reason=%s",
82
+ path,
83
+ response.status_code,
84
+ exc,
85
+ )
86
+ raise
87
+ logger.info(
88
+ "Tool request completed path=%r status=%s elapsed_ms=%.0f",
89
+ path,
90
+ response.status_code,
91
+ (monotonic() - started) * 1000,
92
+ )
93
+ return result
94
+
95
+
96
+ def _tool_result(response: httpx.Response) -> ToolResult:
97
+ if response.status_code >= 400:
98
+ raise ToolError(f"上游查询失败,HTTP {response.status_code}。")
99
+ try:
100
+ body = response.json()
101
+ except ValueError as exc:
102
+ raise ToolError("上游没有返回有效 JSON。") from exc
103
+ data = body.get("data") if isinstance(body, dict) else None
104
+ if isinstance(data, dict) and "data" in data:
105
+ data = data["data"]
106
+ if data is None:
107
+ raise ToolError("上游没有返回可用数据。")
108
+ return ToolResult(status="success", data=data)
@@ -0,0 +1,50 @@
1
+ """封装访问 Control 时互斥且不可回显的运行认证凭证。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class ControlAuth:
11
+ """A server-created CAS or scheduled credential, never both."""
12
+
13
+ authentication_type: Literal["CAS", "SCHEDULED"]
14
+ _cas_session_id: str | None = field(default=None, repr=False)
15
+ _scheduled_token: str | None = field(default=None, repr=False)
16
+ _trusted_context: str | None = field(default=None, repr=False)
17
+
18
+ def __post_init__(self) -> None:
19
+ credentials = sum(
20
+ bool(value)
21
+ for value in (
22
+ self._cas_session_id,
23
+ self._scheduled_token,
24
+ self._trusted_context,
25
+ )
26
+ )
27
+ if credentials != 1:
28
+ raise ValueError("exactly one Control credential is required")
29
+ expected = "CAS" if self._cas_session_id else "SCHEDULED"
30
+ if self.authentication_type != expected:
31
+ raise ValueError("authentication type does not match credential")
32
+
33
+ @classmethod
34
+ def cas(cls, session_id: str) -> ControlAuth:
35
+ return cls("CAS", _cas_session_id=session_id)
36
+
37
+ @classmethod
38
+ def scheduled(cls, token: str) -> ControlAuth:
39
+ return cls("SCHEDULED", _scheduled_token=token)
40
+
41
+ @classmethod
42
+ def trusted_context(cls, context: str) -> ControlAuth:
43
+ return cls("SCHEDULED", _trusted_context=context)
44
+
45
+ def headers(self) -> dict[str, str]:
46
+ if self.authentication_type == "CAS":
47
+ return {"Cookie": f"CASSESSIONID={self._cas_session_id}"}
48
+ if self._trusted_context:
49
+ return {"X-Agent-Trusted-Context": self._trusted_context}
50
+ return {"Authorization": f"Scheduled {self._scheduled_token}"}
@@ -0,0 +1,63 @@
1
+ """关联请求日志并记录 HTTP 生命周期,不记录请求或响应正文。"""
2
+
3
+ import logging
4
+ from contextvars import ContextVar
5
+ from time import monotonic
6
+ from uuid import uuid4
7
+
8
+ request_id = ContextVar("request_id", default="-")
9
+
10
+
11
+ class RequestLogger(logging.LoggerAdapter):
12
+ def process(self, msg, kwargs):
13
+ return f"request_id={request_id.get()} {msg}", kwargs
14
+
15
+
16
+ def get_logger(name: str) -> RequestLogger:
17
+ return RequestLogger(logging.getLogger(name), {})
18
+
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ class RequestLoggingMiddleware:
24
+ def __init__(self, app):
25
+ self.app = app
26
+
27
+ async def __call__(self, scope, receive, send):
28
+ if scope["type"] != "http" or scope["path"] == "/healthcheck.html":
29
+ return await self.app(scope, receive, send)
30
+ token = request_id.set(uuid4().hex)
31
+ started = monotonic()
32
+ status = None
33
+ outcome = "interrupted"
34
+
35
+ async def send_logged(message):
36
+ nonlocal status
37
+ if message["type"] == "http.response.start":
38
+ status = message["status"]
39
+ message = dict(message)
40
+ message["headers"] = [
41
+ *message.get("headers", []),
42
+ (b"x-request-id", request_id.get().encode()),
43
+ ]
44
+ await send(message)
45
+
46
+ logger.info("Request received method=%s path=%r", scope["method"], scope["path"])
47
+ try:
48
+ await self.app(scope, receive, send_logged)
49
+ outcome = "finished"
50
+ except Exception as exc:
51
+ outcome = "failed"
52
+ if status is None:
53
+ status = 500
54
+ logger.error("Request failed error_type=%s", type(exc).__name__)
55
+ raise
56
+ finally:
57
+ successful = outcome == "finished" and status is not None and status < 400
58
+ logger.log(
59
+ logging.INFO if successful else logging.WARNING,
60
+ "Request ended status=%s outcome=%s elapsed_ms=%.0f",
61
+ status, outcome, (monotonic() - started) * 1000,
62
+ )
63
+ request_id.reset(token)
@@ -0,0 +1,68 @@
1
+ """封装 Agent 会话、运行结果与记忆状态的远端操作。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ from app.utils.control_auth import ControlAuth
10
+ from app.utils.request_logging import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+
15
+ class AgentStateError(RuntimeError):
16
+ pass
17
+
18
+
19
+ class AgentStateClient:
20
+ """访问远端 Agent 状态接口的认证客户端。"""
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ http: httpx.AsyncClient,
26
+ base_url: str,
27
+ auth: ControlAuth,
28
+ ) -> None:
29
+ self._http = http
30
+ self._base_url = base_url.rstrip("/")
31
+ self._auth = auth
32
+
33
+ @property
34
+ def headers(self) -> dict[str, str]:
35
+ return self._auth.headers()
36
+
37
+ async def post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
38
+ try:
39
+ response = await self._http.post(
40
+ f"{self._base_url}{path}",
41
+ json=payload,
42
+ headers=self.headers,
43
+ timeout=15,
44
+ )
45
+ except httpx.HTTPError as exc:
46
+ logger.warning("State request failed path=%r error_type=%s", path, type(exc).__name__)
47
+ raise
48
+ if response.status_code >= 400:
49
+ logger.warning("State request rejected path=%r status=%s", path, response.status_code)
50
+ raise AgentStateError(f"state request failed: {response.status_code}")
51
+ body = response.json()
52
+ if not isinstance(body, dict):
53
+ raise AgentStateError("state service returned a non-object response")
54
+ return body
55
+
56
+ async def load_messages(self, conversation_id: str) -> list[dict[str, Any]]:
57
+ body = await self.post(
58
+ "/agent/getConversationMessages",
59
+ {"conversationId": conversation_id},
60
+ )
61
+ messages = body.get("messages", [])
62
+ return messages if isinstance(messages, list) else []
63
+
64
+ async def complete_run(
65
+ self,
66
+ payload: dict[str, Any],
67
+ ) -> None:
68
+ await self.post("/agent/completeRun", payload)
@@ -0,0 +1 @@
1
+ """存放 ASGI 应用的 HTTP 路由与请求边界处理。"""
@@ -0,0 +1,208 @@
1
+ """校验 CAS 与定时任务身份并构造可信运行身份。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from base64 import b64decode
8
+ from binascii import Error as Base64Error
9
+ from datetime import datetime, timezone
10
+ from http.cookies import CookieError, SimpleCookie
11
+ from zoneinfo import ZoneInfo
12
+
13
+ import httpx
14
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
15
+ from starlette.requests import Request
16
+
17
+ from app.utils.control_auth import ControlAuth
18
+ from app.utils.request_logging import get_logger
19
+
20
+ from app.views.errors import RouteError
21
+
22
+ logger = get_logger(__name__)
23
+
24
+ _COOKIE_VALUE = re.compile(r"^[A-Za-z0-9._:@+-]{1,256}$")
25
+ _SCHEDULED_TOKEN = re.compile(r"^[A-Za-z0-9_-]{43}$")
26
+ _FORBIDDEN_SCHEDULED_HEADERS = {
27
+ "x-agent-scheduled-run-id",
28
+ "x-agent-user-id",
29
+ "x-agent-org-code",
30
+ "x-agent-effective-at",
31
+ "x-agent-effective-timezone",
32
+ }
33
+
34
+
35
+ class ScheduledIdentity(BaseModel):
36
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
37
+
38
+ authentication_type: str = Field(alias="authenticationType")
39
+ run_id: int = Field(alias="runId", strict=True, gt=0)
40
+ task_id: int = Field(alias="taskId", strict=True, gt=0)
41
+ user_id: str = Field(alias="userId", min_length=1, max_length=256)
42
+ org_code: str = Field(alias="orgCode", min_length=1, max_length=64)
43
+ role_id: str = Field(alias="roleId", min_length=1, max_length=64)
44
+ prompt: str = Field(min_length=1, max_length=65536)
45
+ scheduled_at: datetime = Field(alias="scheduledAt")
46
+ timezone: str = Field(min_length=1, max_length=128)
47
+ expires_at: datetime = Field(alias="expiresAt")
48
+ trusted_context: str = Field(alias="trustedContext", min_length=1, max_length=256)
49
+
50
+
51
+ class _ScheduledEnvelope(BaseModel):
52
+ model_config = ConfigDict(extra="forbid")
53
+
54
+ success: bool
55
+ data: ScheduledIdentity
56
+
57
+
58
+ async def authenticate_user(request: Request, runtime):
59
+ session_id = _cookies(request.headers.get("cookie", "")).get("CASSESSIONID")
60
+ if not session_id:
61
+ logger.warning("Authentication rejected reason=missing_session")
62
+ raise RouteError(401, "auth error")
63
+ try:
64
+ response = await runtime.http.get(
65
+ f"{runtime.settings.union_base_url}/common/getUserInfo",
66
+ headers={"Cookie": f"CASSESSIONID={session_id}"},
67
+ timeout=10,
68
+ )
69
+ body = response.json()
70
+ data = body.get("data", {}) if response.status_code < 400 else {}
71
+ except (httpx.HTTPError, ValueError) as exc:
72
+ logger.warning(
73
+ "Authentication failed reason=identity_lookup error_type=%s",
74
+ type(exc).__name__,
75
+ )
76
+ raise RouteError(401, "auth error")
77
+ if (
78
+ not isinstance(data, dict)
79
+ or not isinstance(data.get("userId"), str)
80
+ or not data["userId"]
81
+ ):
82
+ logger.warning(
83
+ "Authentication rejected reason=invalid_identity upstream_status=%s",
84
+ response.status_code,
85
+ )
86
+ raise RouteError(401, "auth error")
87
+ permissions = data.get("permissions") or []
88
+ if isinstance(permissions, str):
89
+ permissions = [permissions]
90
+ required = runtime.settings.required_permission
91
+ if required and required not in permissions:
92
+ logger.warning("Authentication rejected reason=missing_permission")
93
+ raise RouteError(403, "auth error")
94
+ logger.info("Authentication succeeded type=CAS")
95
+ return {
96
+ "authentication_type": "CAS",
97
+ "user_id": data["userId"],
98
+ "org_code": data.get("orgCode", ""),
99
+ "permissions": tuple(permissions),
100
+ "control_auth": ControlAuth.cas(session_id),
101
+ }
102
+
103
+
104
+ def reject_scheduled_on_cas_endpoint(request: Request) -> None:
105
+ authorization = request.headers.get("authorization")
106
+ if not authorization or not authorization.lower().startswith("scheduled "):
107
+ return
108
+ if request.headers.get("cookie") is not None:
109
+ raise RouteError(400, "mixed_authentication")
110
+ raise RouteError(401, "auth error")
111
+
112
+
113
+ def scheduled_token(request: Request) -> str:
114
+ authorizations = request.headers.getlist("authorization")
115
+ if len(authorizations) != 1:
116
+ raise RouteError(401, "scheduled_auth_error")
117
+ authorization = authorizations[0]
118
+ cookie_present = request.headers.get("cookie") is not None
119
+ is_scheduled = bool(
120
+ authorization and authorization.lower().startswith("scheduled ")
121
+ )
122
+ if cookie_present and is_scheduled:
123
+ raise RouteError(400, "mixed_authentication")
124
+ if cookie_present or not authorization:
125
+ raise RouteError(401, "scheduled_auth_error")
126
+ scheme, separator, token = authorization.partition(" ")
127
+ if separator != " " or scheme.lower() != "scheduled" or not _valid_token(token):
128
+ raise RouteError(401, "scheduled_auth_error")
129
+ return token
130
+
131
+
132
+ async def strict_scheduled_body(request: Request) -> None:
133
+ if _FORBIDDEN_SCHEDULED_HEADERS.intersection(request.headers.keys()):
134
+ raise RouteError(422, "invalid_scheduled_request")
135
+ try:
136
+ payload = json.loads(await request.body())
137
+ except (json.JSONDecodeError, UnicodeDecodeError):
138
+ raise RouteError(422, "invalid_scheduled_request")
139
+ if not isinstance(payload, dict) or payload:
140
+ raise RouteError(422, "invalid_scheduled_request")
141
+
142
+
143
+ async def authenticate_scheduled_run(runtime, token: str) -> ScheduledIdentity:
144
+ try:
145
+ response = await runtime.http.post(
146
+ f"{runtime.settings.union_base_url}/agent/scheduledTaskAuthorize",
147
+ headers=ControlAuth.scheduled(token).headers(),
148
+ json={},
149
+ timeout=10,
150
+ )
151
+ except (httpx.TimeoutException, httpx.NetworkError) as exc:
152
+ raise RouteError(503, "scheduled_identity_unavailable") from exc
153
+ if response.status_code == 401:
154
+ raise RouteError(401, "scheduled_auth_error")
155
+ if response.status_code == 403:
156
+ raise RouteError(403, "forbidden")
157
+ if response.status_code >= 500:
158
+ raise RouteError(503, "scheduled_identity_unavailable")
159
+ if response.status_code >= 400:
160
+ raise RouteError(502, "scheduled_identity_unavailable")
161
+ try:
162
+ envelope = _ScheduledEnvelope.model_validate(response.json())
163
+ identity = envelope.data
164
+ if not envelope.success or identity.authentication_type != "SCHEDULED":
165
+ raise ValueError
166
+ if identity.scheduled_at.tzinfo is None or identity.expires_at.tzinfo is None:
167
+ raise ValueError
168
+ if identity.expires_at <= datetime.now(timezone.utc):
169
+ raise ValueError
170
+ if identity.trusted_context != f"Scheduled {token}":
171
+ raise ValueError
172
+ ZoneInfo(identity.timezone)
173
+ if any(
174
+ not value.strip()
175
+ for value in (
176
+ identity.user_id,
177
+ identity.org_code,
178
+ identity.role_id,
179
+ identity.prompt,
180
+ )
181
+ ):
182
+ raise ValueError
183
+ except (ValidationError, ValueError, KeyError, json.JSONDecodeError):
184
+ raise RouteError(502, "scheduled_identity_unavailable")
185
+ logger.info("Authentication succeeded type=SCHEDULED")
186
+ return identity
187
+
188
+
189
+ def _valid_token(token: str) -> bool:
190
+ if not _SCHEDULED_TOKEN.fullmatch(token):
191
+ return False
192
+ try:
193
+ return len(b64decode(token + "=", altchars=b"-_", validate=True)) == 32
194
+ except (Base64Error, ValueError):
195
+ return False
196
+
197
+
198
+ def _cookies(header: str) -> dict[str, str]:
199
+ try:
200
+ cookie = SimpleCookie()
201
+ cookie.load(header)
202
+ except CookieError:
203
+ return {}
204
+ return {
205
+ name: morsel.value
206
+ for name, morsel in cookie.items()
207
+ if _COOKIE_VALUE.fullmatch(morsel.value)
208
+ }