jdcloud-agentgrid 0.1.3__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.
agentgrid/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """AgentGrid Python SDK 顶层导出。
2
+
3
+ 根据 OpenSpec `sdk-packaging-and-entrypoint` 能力约束,v1 必须稳定支持:
4
+ `from agentgrid import AgentGridProfile`。
5
+ """
6
+
7
+ from .profile import AgentGridProfile
8
+ from .session import code_session, runtime_session
9
+ from .version import __version__
10
+
11
+ __all__ = ["AgentGridProfile", "runtime_session", "code_session", "__version__"]
@@ -0,0 +1,5 @@
1
+ """AgentGrid 应用层导出。"""
2
+
3
+ from .base_app import BaseAgentGridApp
4
+
5
+ __all__ = ["BaseAgentGridApp"]
@@ -0,0 +1,13 @@
1
+ """AgentGrid 应用抽象基类。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class BaseAgentGridApp(ABC):
9
+ """应用层统一运行接口。"""
10
+
11
+ @abstractmethod
12
+ def run(self, host: str | None = None, port: int = 8000) -> None:
13
+ """启动应用服务。"""
@@ -0,0 +1,5 @@
1
+ """AgentGrid simple app 导出。"""
2
+
3
+ from .simple_app import AgentGridSimpleApp
4
+
5
+ __all__ = ["AgentGridSimpleApp"]
@@ -0,0 +1,63 @@
1
+ """AgentGrid simple app 实现。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from typing import Any, Callable
7
+
8
+ import uvicorn
9
+ from starlette.applications import Starlette
10
+ from starlette.routing import Route
11
+
12
+ from agentgrid.apps.base_app import BaseAgentGridApp
13
+ from agentgrid.apps.simple_app.simple_app_handlers import (
14
+ AsyncTaskHandler,
15
+ InvokeHandler,
16
+ PingHandler,
17
+ )
18
+
19
+
20
+ class AgentGridSimpleApp(BaseAgentGridApp, Starlette):
21
+ """AgentGrid simple app。"""
22
+
23
+ def __init__(self) -> None:
24
+ self.ping_handler = PingHandler()
25
+ self.invoke_handler = InvokeHandler()
26
+ self.async_task_handler = AsyncTaskHandler()
27
+
28
+ routes = [
29
+ Route("/ping", self.ping_handler.handle, methods=["GET"]),
30
+ Route("/health", self.ping_handler.health_check, methods=["GET"]),
31
+ Route("/readiness", self.ping_handler.readiness, methods=["GET"]),
32
+ Route("/liveness", self.ping_handler.liveness, methods=["GET"]),
33
+ Route("/invoke", self.invoke_handler.handle, methods=["POST"]),
34
+ Route("/invocations", self.invoke_handler.handle, methods=["POST"]),
35
+ Route(
36
+ "/v1/regions/{region}/runtimes/{runtime_id}:invoke",
37
+ self.invoke_handler.handle,
38
+ methods=["POST"],
39
+ ),
40
+ ]
41
+
42
+ super().__init__(routes=routes)
43
+
44
+ def entrypoint(self, func: Callable[..., Any]) -> Callable[..., Any]:
45
+ """注册 `/invoke` 处理函数。"""
46
+
47
+ self.invoke_handler.func = func
48
+ return func
49
+
50
+ def ping(self, func: Callable[..., Any]) -> Callable[..., Any]:
51
+ """注册 `/ping` 检查函数。"""
52
+
53
+ assert len(list(inspect.signature(func).parameters.keys())) == 0, (
54
+ f"Health check function `{func.__name__}` should not receive any arguments."
55
+ )
56
+ self.ping_handler.func = func
57
+ return func
58
+
59
+ def run(self, host: str | None = None, port: int = 8080) -> None:
60
+ """启动 Web 应用。"""
61
+
62
+ resolved_host = host if host else "0.0.0.0"
63
+ uvicorn.run(self, host=resolved_host, port=port)
@@ -0,0 +1,252 @@
1
+ """AgentGrid simple app 请求处理器。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import threading
10
+ import time
11
+ import uuid
12
+ from abc import ABC, abstractmethod
13
+ from typing import Any, Callable
14
+
15
+ from starlette.requests import Request
16
+ from starlette.responses import JSONResponse, Response, StreamingResponse
17
+
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _safe_serialize_to_json_string(value: Any) -> str:
23
+ """将任意对象安全序列化为 JSON 字符串。"""
24
+
25
+ try:
26
+ return json.dumps(value, ensure_ascii=False, default=str)
27
+ except TypeError:
28
+ return json.dumps(str(value), ensure_ascii=False)
29
+
30
+
31
+ class BaseHandler(ABC):
32
+ """处理器基类。"""
33
+
34
+ def __init__(self, func: Callable[..., Any] | None = None) -> None:
35
+ self.func = func
36
+
37
+ @abstractmethod
38
+ async def handle(self, request: Request) -> Response:
39
+ """处理请求。"""
40
+
41
+
42
+ class InvokeHandler(BaseHandler):
43
+ """`/invoke` 处理器。"""
44
+
45
+ async def handle(self, request: Request) -> Response | StreamingResponse:
46
+ if not self.func:
47
+ logger.error("Invoke handler function is not set")
48
+ return Response(status_code=404)
49
+
50
+ payload, headers, result = await self._process_invoke(request)
51
+ logger.debug(
52
+ "Invoke payload keys=%s headers_count=%s", payload.keys(), len(headers)
53
+ )
54
+
55
+ if inspect.isgenerator(result):
56
+ return StreamingResponse(
57
+ self._sync_stream_with_error_handling(result),
58
+ media_type="text/event-stream",
59
+ )
60
+
61
+ if inspect.isasyncgen(result):
62
+ return StreamingResponse(
63
+ self._stream_with_error_handling(result),
64
+ media_type="text/event-stream",
65
+ )
66
+
67
+ safe_json_string = _safe_serialize_to_json_string(result)
68
+ return Response(safe_json_string, media_type="application/json")
69
+
70
+ async def _process_invoke(
71
+ self, request: Request
72
+ ) -> tuple[dict[str, Any], dict[str, str], Any]:
73
+ """按 agentkit 兼容规则分发 entrypoint 参数。"""
74
+
75
+ if not self.func:
76
+ logger.error("Invoke handler function is not set")
77
+ return {}, {}, {"message": "Invoke handler function is not set."}
78
+
79
+ payload: dict[str, Any] = await request.json()
80
+ headers = dict(request.headers)
81
+
82
+ param_names = list(inspect.signature(self.func).parameters.keys())
83
+ num_params = len(param_names)
84
+
85
+ if num_params == 0:
86
+ args: tuple[Any, ...] = ()
87
+ elif num_params == 1:
88
+ if param_names[0].lower() == "request":
89
+ args = (request,)
90
+ else:
91
+ args = (payload,)
92
+ else:
93
+ args = (payload, headers)
94
+
95
+ if asyncio.iscoroutinefunction(self.func):
96
+ return payload, headers, await self.func(*args)
97
+ return payload, headers, self.func(*args)
98
+
99
+ def _convert_to_sse(self, obj: Any) -> bytes:
100
+ """将对象编码为 SSE 数据帧。"""
101
+
102
+ if isinstance(obj, str):
103
+ payload = obj
104
+ else:
105
+ payload = _safe_serialize_to_json_string(obj)
106
+ return f"data: {payload}\n\n".encode("utf-8")
107
+
108
+ def _sync_stream_with_error_handling(self, generator: Any):
109
+ """包装同步生成器,保证异常时输出错误事件。"""
110
+
111
+ try:
112
+ for value in generator:
113
+ yield self._convert_to_sse(value)
114
+ except Exception as exc: # pragma: no cover - 通过集成用例覆盖
115
+ logger.exception("Error in sync streaming")
116
+ yield self._convert_to_sse(
117
+ {
118
+ "error": str(exc),
119
+ "error_type": type(exc).__name__,
120
+ "message": "An error occurred during streaming",
121
+ }
122
+ )
123
+
124
+ async def _stream_with_error_handling(self, generator: Any):
125
+ """包装异步生成器,保证异常时输出错误事件。"""
126
+
127
+ try:
128
+ async for value in generator:
129
+ yield self._convert_to_sse(value)
130
+ except Exception as exc: # pragma: no cover - 通过集成用例覆盖
131
+ logger.exception("Error in async streaming")
132
+ yield self._convert_to_sse(
133
+ {
134
+ "error": str(exc),
135
+ "error_type": type(exc).__name__,
136
+ "message": "An error occurred during streaming",
137
+ }
138
+ )
139
+
140
+
141
+ class PingHandler(BaseHandler):
142
+ """`/ping` 与健康检查处理器。"""
143
+
144
+ async def handle(self, request: Request) -> Response:
145
+ del request
146
+
147
+ if not self.func:
148
+ logger.error("Ping handler function is not set")
149
+ return Response(status_code=404)
150
+
151
+ if asyncio.iscoroutinefunction(self.func):
152
+ result = await self.func()
153
+ else:
154
+ result = self.func()
155
+
156
+ return JSONResponse(content={"status": result}, media_type="application/json")
157
+
158
+ async def health_check(self, request: Request) -> Response:
159
+ del request
160
+ return JSONResponse(
161
+ content={
162
+ "status": "healthy",
163
+ "timestamp": time.time(),
164
+ "service": "agent-service",
165
+ },
166
+ media_type="application/json",
167
+ )
168
+
169
+ async def readiness(self, request: Request) -> Response:
170
+ del request
171
+ return JSONResponse(
172
+ content={
173
+ "status": "success",
174
+ "timestamp": time.time(),
175
+ "service": "agent-service",
176
+ },
177
+ media_type="application/json",
178
+ )
179
+
180
+ async def liveness(self, request: Request) -> Response:
181
+ del request
182
+ return JSONResponse(
183
+ content={
184
+ "status": "success",
185
+ "timestamp": time.time(),
186
+ "service": "agent-service",
187
+ },
188
+ media_type="application/json",
189
+ )
190
+
191
+
192
+ class AsyncTaskHandler(BaseHandler):
193
+ """异步任务跟踪处理器。"""
194
+
195
+ def __init__(self, func: Callable[..., Any] | None = None) -> None:
196
+ super().__init__(func=func)
197
+ self._active_tasks: dict[int, dict[str, Any]] = {}
198
+ self._task_counter_lock = threading.Lock()
199
+
200
+ async def handle(self, request: Request) -> Response:
201
+ del request
202
+ return Response()
203
+
204
+ def get_async_task_info(self) -> dict[str, Any]:
205
+ """获取当前活跃异步任务信息。"""
206
+
207
+ running_jobs: list[dict[str, Any]] = []
208
+ for task in self._active_tasks.values():
209
+ try:
210
+ running_jobs.append(
211
+ {
212
+ "name": task.get("name", "unknown"),
213
+ "duration": time.time() - task.get("start_time", time.time()),
214
+ }
215
+ )
216
+ except Exception as exc: # pragma: no cover - 防御性分支
217
+ logger.warning("Caught exception, continuing...: %s", exc)
218
+ continue
219
+
220
+ return {"active_count": len(self._active_tasks), "running_jobs": running_jobs}
221
+
222
+ def add_async_task(self, name: str, metadata: dict[str, Any] | None = None) -> int:
223
+ """注册异步任务并返回任务 ID。"""
224
+
225
+ with self._task_counter_lock:
226
+ task_id = hash(str(uuid.uuid4()))
227
+ task_info: dict[str, Any] = {"name": name, "start_time": time.time()}
228
+ if metadata:
229
+ task_info["metadata"] = metadata
230
+ self._active_tasks[task_id] = task_info
231
+
232
+ logger.info("Async task started: %s (ID: %s)", name, task_id)
233
+ return task_id
234
+
235
+ def complete_async_task(self, task_id: int) -> bool:
236
+ """完成异步任务。"""
237
+
238
+ with self._task_counter_lock:
239
+ task_info = self._active_tasks.pop(task_id, None)
240
+ if not task_info:
241
+ logger.warning("Attempted to complete unknown task ID: %s", task_id)
242
+ return False
243
+
244
+ task_name = task_info.get("name", "unknown")
245
+ duration = time.time() - task_info.get("start_time", time.time())
246
+ logger.info(
247
+ "Async task completed: %s (ID: %s, Duration: %.2fs)",
248
+ task_name,
249
+ task_id,
250
+ duration,
251
+ )
252
+ return True
@@ -0,0 +1,36 @@
1
+ """认证模块预留。
2
+
3
+ v1 目标认证方式:
4
+ - API Key
5
+ - 京东云 AK/SK(复用底层签名能力)
6
+ """
7
+
8
+ from .providers import ApiKeyAuthProvider, AuthProvider, JdcloudAkskAuthProvider
9
+ from .credential_chain import (
10
+ DEFAULT_CREDENTIALS_PATH,
11
+ ENV_ACCESS_KEY,
12
+ ENV_SECRET_KEY,
13
+ InvalidCredentialSourceError,
14
+ resolve_aksk_credentials,
15
+ )
16
+ from .providers import (
17
+ UnsupportedAuthProviderError,
18
+ apply_authentication,
19
+ ensure_supported_profile_auth,
20
+ resolve_auth_provider,
21
+ )
22
+
23
+ __all__ = [
24
+ "AuthProvider",
25
+ "ApiKeyAuthProvider",
26
+ "JdcloudAkskAuthProvider",
27
+ "ENV_ACCESS_KEY",
28
+ "ENV_SECRET_KEY",
29
+ "DEFAULT_CREDENTIALS_PATH",
30
+ "InvalidCredentialSourceError",
31
+ "resolve_aksk_credentials",
32
+ "UnsupportedAuthProviderError",
33
+ "ensure_supported_profile_auth",
34
+ "resolve_auth_provider",
35
+ "apply_authentication",
36
+ ]
@@ -0,0 +1,129 @@
1
+ """AK/SK 默认凭证链解析。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from configparser import ConfigParser
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Mapping
9
+ import os
10
+
11
+
12
+ ENV_ACCESS_KEY = "JDCLOUD_ACCESS_KEY"
13
+ ENV_SECRET_KEY = "JDCLOUD_SECRET_KEY"
14
+ DEFAULT_CREDENTIALS_PATH = Path("~/.jdcloud/credentials")
15
+
16
+
17
+ class InvalidCredentialSourceError(ValueError):
18
+ """当某个凭证来源出现半配置时抛出。"""
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class AkskCredential:
23
+ """解析后的 AK/SK 凭证。"""
24
+
25
+ access_key: str
26
+ secret_key: str
27
+ source: str
28
+
29
+
30
+ def resolve_aksk_credentials(
31
+ *,
32
+ explicit_access_key: str | None,
33
+ explicit_secret_key: str | None,
34
+ environ: Mapping[str, str] | None = None,
35
+ credentials_path: Path | None = None,
36
+ ) -> AkskCredential | None:
37
+ """按优先级解析 AK/SK:显式参数 > 环境变量 > 默认文件。"""
38
+
39
+ explicit = _resolve_source(
40
+ source="explicit",
41
+ access_key=explicit_access_key,
42
+ secret_key=explicit_secret_key,
43
+ diagnostics=("显式 AK/SK 参数半配置:请同时提供 access_key 与 secret_key"),
44
+ )
45
+ if explicit is not None:
46
+ return explicit
47
+
48
+ env = _resolve_from_env(environ=environ)
49
+ if env is not None:
50
+ return env
51
+
52
+ return _resolve_from_credentials_file(credentials_path=credentials_path)
53
+
54
+
55
+ def _resolve_from_env(
56
+ *,
57
+ environ: Mapping[str, str] | None,
58
+ ) -> AkskCredential | None:
59
+ source = environ if environ is not None else os.environ
60
+ access_key = source.get(ENV_ACCESS_KEY)
61
+ secret_key = source.get(ENV_SECRET_KEY)
62
+ return _resolve_source(
63
+ source="env",
64
+ access_key=access_key,
65
+ secret_key=secret_key,
66
+ diagnostics=(
67
+ f"环境变量半配置:请同时设置 {ENV_ACCESS_KEY} 与 {ENV_SECRET_KEY}"
68
+ ),
69
+ )
70
+
71
+
72
+ def _resolve_from_credentials_file(
73
+ *,
74
+ credentials_path: Path | None,
75
+ ) -> AkskCredential | None:
76
+ path = (credentials_path or DEFAULT_CREDENTIALS_PATH).expanduser()
77
+ if not path.exists():
78
+ return None
79
+
80
+ parser = ConfigParser()
81
+ try:
82
+ parser.read(path, encoding="utf-8")
83
+ except Exception as exc: # pragma: no cover
84
+ raise InvalidCredentialSourceError(
85
+ f"默认凭证文件解析失败:{path},{exc}"
86
+ ) from exc
87
+
88
+ if not parser.has_section("default"):
89
+ return None
90
+
91
+ access_key = _normalize(parser.get("default", "access_key_id", fallback=None))
92
+ secret_key = _normalize(
93
+ parser.get("default", "secret_access_key", fallback=None)
94
+ )
95
+ return _resolve_source(
96
+ source="file",
97
+ access_key=access_key,
98
+ secret_key=secret_key,
99
+ diagnostics=(
100
+ "默认凭证文件半配置:"
101
+ f"{path} 的 [default] 必须同时包含 access_key_id 与 secret_access_key"
102
+ ),
103
+ )
104
+
105
+
106
+ def _resolve_source(
107
+ *,
108
+ source: str,
109
+ access_key: str | None,
110
+ secret_key: str | None,
111
+ diagnostics: str,
112
+ ) -> AkskCredential | None:
113
+ access = _normalize(access_key)
114
+ secret = _normalize(secret_key)
115
+
116
+ if access and secret:
117
+ return AkskCredential(access_key=access, secret_key=secret, source=source)
118
+ if access or secret:
119
+ raise InvalidCredentialSourceError(diagnostics)
120
+ return None
121
+
122
+
123
+ def _normalize(value: str | None) -> str | None:
124
+ if value is None:
125
+ return None
126
+ normalized = value.strip()
127
+ if not normalized:
128
+ return None
129
+ return normalized
@@ -0,0 +1,122 @@
1
+ """认证 Provider 抽象与 v1 实现。
2
+
3
+ 对应 OpenSpec `auth-provider-framework`:
4
+ - v1 仅支持 API Key 与 AK/SK 两种认证。
5
+ - 认证注入必须走统一调用点,避免业务 client 分散拼接 header。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Callable, Optional, Protocol
12
+
13
+
14
+ class UnsupportedAuthProviderError(ValueError):
15
+ """当配置了 v1 不支持的认证方式时抛出。"""
16
+
17
+
18
+ # 历史兼容字段:早期版本允许注入 signer。
19
+ # 真实链路接入后,AK/SK 签名由 jdcloud SDK `JDCloudClient.send()` 完成,
20
+ # AgentGrid 不再在本层执行本地 signer。
21
+ AkskSigner = Callable[[dict[str, str], str, str, Optional[Any]], dict[str, str]]
22
+
23
+
24
+ class AuthProvider(Protocol):
25
+ """统一认证协议。
26
+
27
+ 后续在请求发送前由 transport 层统一调用,避免业务 client 分散注入。
28
+ """
29
+
30
+ def apply(
31
+ self, headers: dict[str, str], *, request: Any | None = None
32
+ ) -> dict[str, str]:
33
+ """将认证信息注入请求头并返回新 headers。"""
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class ApiKeyAuthProvider:
38
+ """API Key 认证实现。"""
39
+
40
+ api_key: str
41
+
42
+ def apply(
43
+ self, headers: dict[str, str], *, request: Any | None = None
44
+ ) -> dict[str, str]:
45
+ merged = dict(headers)
46
+ merged["x-api-key"] = self.api_key
47
+ return merged
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class JdcloudAkskAuthProvider:
52
+ """京东云 AK/SK 认证 Provider。
53
+
54
+ 注意:真实签名发生在 jdcloud SDK 底层 `client.send()`,
55
+ 本 provider 仅用于表达身份类型与携带 AK/SK。
56
+ """
57
+
58
+ access_key: str
59
+ secret_key: str
60
+
61
+ def apply(
62
+ self, headers: dict[str, str], *, request: Any | None = None
63
+ ) -> dict[str, str]:
64
+ # AK/SK 不在本层拼接 Authorization。
65
+ # 真实签名由 JDCloudClient.send() 使用 Credential 自动完成。
66
+ _ = request
67
+ return dict(headers)
68
+
69
+
70
+ def ensure_supported_profile_auth(auth: Any) -> None:
71
+ """校验 Profile 级认证是否在 v1 支持范围内。"""
72
+
73
+ if auth is None:
74
+ return
75
+ if isinstance(auth, (ApiKeyAuthProvider, JdcloudAkskAuthProvider)):
76
+ return
77
+ raise UnsupportedAuthProviderError(
78
+ "Unsupported auth provider in v1. Supported auth: ApiKeyAuthProvider, "
79
+ "JdcloudAkskAuthProvider"
80
+ )
81
+
82
+
83
+ def resolve_auth_provider(
84
+ profile_auth: Any, *, api_key: str | None
85
+ ) -> AuthProvider | None:
86
+ """解析当前请求最终生效的认证 Provider。
87
+
88
+ 规则:请求级 api_key 优先于 Profile 默认认证。
89
+ """
90
+
91
+ if api_key is not None:
92
+ return ApiKeyAuthProvider(api_key=api_key)
93
+
94
+ if profile_auth is None:
95
+ return None
96
+
97
+ if isinstance(profile_auth, (ApiKeyAuthProvider, JdcloudAkskAuthProvider)):
98
+ return profile_auth
99
+
100
+ raise UnsupportedAuthProviderError(
101
+ "Unsupported auth provider in v1. Supported auth: ApiKeyAuthProvider, "
102
+ "JdcloudAkskAuthProvider"
103
+ )
104
+
105
+
106
+ def apply_authentication(
107
+ headers: dict[str, str],
108
+ *,
109
+ profile_auth: Any,
110
+ api_key: str | None,
111
+ request: Any | None = None,
112
+ ) -> dict[str, str]:
113
+ """统一认证注入调用点。
114
+
115
+ Runtime 等业务 client 必须通过该函数进行认证处理,
116
+ 以满足 OpenSpec 对“统一抽象层注入”的约束。
117
+ """
118
+
119
+ provider = resolve_auth_provider(profile_auth=profile_auth, api_key=api_key)
120
+ if provider is None:
121
+ return dict(headers)
122
+ return provider.apply(dict(headers), request=request)