ks-app-sdk 0.1.0__tar.gz
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.
- ks_app_sdk-0.1.0/PKG-INFO +71 -0
- ks_app_sdk-0.1.0/README.md +50 -0
- ks_app_sdk-0.1.0/pyproject.toml +29 -0
- ks_app_sdk-0.1.0/setup.cfg +4 -0
- ks_app_sdk-0.1.0/src/ks_app/__init__.py +14 -0
- ks_app_sdk-0.1.0/src/ks_app/app.py +111 -0
- ks_app_sdk-0.1.0/src/ks_app/config.py +9 -0
- ks_app_sdk-0.1.0/src/ks_app/context.py +107 -0
- ks_app_sdk-0.1.0/src/ks_app/health.py +51 -0
- ks_app_sdk-0.1.0/src/ks_app/llm.py +104 -0
- ks_app_sdk-0.1.0/src/ks_app/mcp_handler.py +173 -0
- ks_app_sdk-0.1.0/src/ks_app/schema.py +88 -0
- ks_app_sdk-0.1.0/src/ks_app/tool.py +15 -0
- ks_app_sdk-0.1.0/src/ks_app_sdk.egg-info/PKG-INFO +71 -0
- ks_app_sdk-0.1.0/src/ks_app_sdk.egg-info/SOURCES.txt +26 -0
- ks_app_sdk-0.1.0/src/ks_app_sdk.egg-info/dependency_links.txt +1 -0
- ks_app_sdk-0.1.0/src/ks_app_sdk.egg-info/requires.txt +8 -0
- ks_app_sdk-0.1.0/src/ks_app_sdk.egg-info/top_level.txt +1 -0
- ks_app_sdk-0.1.0/tests/test_app.py +36 -0
- ks_app_sdk-0.1.0/tests/test_config.py +24 -0
- ks_app_sdk-0.1.0/tests/test_context.py +98 -0
- ks_app_sdk-0.1.0/tests/test_extensions.py +186 -0
- ks_app_sdk-0.1.0/tests/test_health.py +15 -0
- ks_app_sdk-0.1.0/tests/test_http.py +106 -0
- ks_app_sdk-0.1.0/tests/test_llm.py +225 -0
- ks_app_sdk-0.1.0/tests/test_mcp_handler.py +403 -0
- ks_app_sdk-0.1.0/tests/test_schema.py +122 -0
- ks_app_sdk-0.1.0/tests/test_tool.py +27 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ks-app-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keystone 平台 MCP Service SDK(Python)
|
|
5
|
+
Author: Keystone Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Framework :: AsyncIO
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: uvicorn>=0.30
|
|
15
|
+
Requires-Dist: starlette>=0.37
|
|
16
|
+
Requires-Dist: pyyaml>=6.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
20
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
21
|
+
|
|
22
|
+
# ks-app-sdk
|
|
23
|
+
|
|
24
|
+
Keystone MCP Service SDK for Python.
|
|
25
|
+
|
|
26
|
+
## 安装
|
|
27
|
+
|
|
28
|
+
pip install ks-app-sdk
|
|
29
|
+
|
|
30
|
+
## 使用
|
|
31
|
+
|
|
32
|
+
from ks_app import App
|
|
33
|
+
|
|
34
|
+
app = App("my-app")
|
|
35
|
+
|
|
36
|
+
@app.tool("greet", "打招呼")
|
|
37
|
+
async def greet(name: str = "world"):
|
|
38
|
+
return {"message": f"Hello, {name}!"}
|
|
39
|
+
|
|
40
|
+
app.run() # 监听 0.0.0.0:8080
|
|
41
|
+
|
|
42
|
+
## API
|
|
43
|
+
|
|
44
|
+
### App(app_id: str)
|
|
45
|
+
|
|
46
|
+
创建应用实例。
|
|
47
|
+
|
|
48
|
+
### @app.tool(name: str, description: str)
|
|
49
|
+
|
|
50
|
+
注册工具(装饰器)。handler 必须是 async 函数。
|
|
51
|
+
|
|
52
|
+
### app.run()
|
|
53
|
+
|
|
54
|
+
启动 uvicorn 服务器。
|
|
55
|
+
|
|
56
|
+
## 端点
|
|
57
|
+
|
|
58
|
+
| 路径 | 方法 | 说明 |
|
|
59
|
+
|------|------|------|
|
|
60
|
+
| /healthz | GET | 存活探针 |
|
|
61
|
+
| /readyz | GET | 就绪探针 |
|
|
62
|
+
| /meta | GET | 应用元信息 + 工具列表 |
|
|
63
|
+
| /mcp/tools/list | GET | 已注册工具列表 |
|
|
64
|
+
| /mcp/tools/call | POST | 调用工具 |
|
|
65
|
+
|
|
66
|
+
## 配置
|
|
67
|
+
|
|
68
|
+
| 环境变量 | 默认值 | 说明 |
|
|
69
|
+
|----------|--------|------|
|
|
70
|
+
| KS_APP_PORT | 8080 | 监听端口 |
|
|
71
|
+
| KS_APP_HOST | 0.0.0.0 | 监听地址 |
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# ks-app-sdk
|
|
2
|
+
|
|
3
|
+
Keystone MCP Service SDK for Python.
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
pip install ks-app-sdk
|
|
8
|
+
|
|
9
|
+
## 使用
|
|
10
|
+
|
|
11
|
+
from ks_app import App
|
|
12
|
+
|
|
13
|
+
app = App("my-app")
|
|
14
|
+
|
|
15
|
+
@app.tool("greet", "打招呼")
|
|
16
|
+
async def greet(name: str = "world"):
|
|
17
|
+
return {"message": f"Hello, {name}!"}
|
|
18
|
+
|
|
19
|
+
app.run() # 监听 0.0.0.0:8080
|
|
20
|
+
|
|
21
|
+
## API
|
|
22
|
+
|
|
23
|
+
### App(app_id: str)
|
|
24
|
+
|
|
25
|
+
创建应用实例。
|
|
26
|
+
|
|
27
|
+
### @app.tool(name: str, description: str)
|
|
28
|
+
|
|
29
|
+
注册工具(装饰器)。handler 必须是 async 函数。
|
|
30
|
+
|
|
31
|
+
### app.run()
|
|
32
|
+
|
|
33
|
+
启动 uvicorn 服务器。
|
|
34
|
+
|
|
35
|
+
## 端点
|
|
36
|
+
|
|
37
|
+
| 路径 | 方法 | 说明 |
|
|
38
|
+
|------|------|------|
|
|
39
|
+
| /healthz | GET | 存活探针 |
|
|
40
|
+
| /readyz | GET | 就绪探针 |
|
|
41
|
+
| /meta | GET | 应用元信息 + 工具列表 |
|
|
42
|
+
| /mcp/tools/list | GET | 已注册工具列表 |
|
|
43
|
+
| /mcp/tools/call | POST | 调用工具 |
|
|
44
|
+
|
|
45
|
+
## 配置
|
|
46
|
+
|
|
47
|
+
| 环境变量 | 默认值 | 说明 |
|
|
48
|
+
|----------|--------|------|
|
|
49
|
+
| KS_APP_PORT | 8080 | 监听端口 |
|
|
50
|
+
| KS_APP_HOST | 0.0.0.0 | 监听地址 |
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ks-app-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Keystone 平台 MCP Service SDK(Python)"
|
|
9
|
+
authors = [{name = "Keystone Team"}]
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
dependencies = ["uvicorn>=0.30", "starlette>=0.37", "pyyaml>=6.0"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Framework :: AsyncIO",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27"]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
package-dir = {"" = "src"}
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from .app import App
|
|
2
|
+
from .context import ToolContext, get_context
|
|
3
|
+
from .llm import ChatResponse, LLMClient
|
|
4
|
+
from .tool import Param, tool
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"App",
|
|
8
|
+
"tool",
|
|
9
|
+
"Param",
|
|
10
|
+
"get_context",
|
|
11
|
+
"ToolContext",
|
|
12
|
+
"LLMClient",
|
|
13
|
+
"ChatResponse",
|
|
14
|
+
]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
from starlette.applications import Starlette
|
|
5
|
+
from starlette.responses import JSONResponse
|
|
6
|
+
from starlette.routing import Route
|
|
7
|
+
from .health import health_routes
|
|
8
|
+
from .config import load_config
|
|
9
|
+
from .llm import LLMClient
|
|
10
|
+
from .mcp_handler import mcp_route
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class App:
|
|
14
|
+
def __init__(self, app_id: str):
|
|
15
|
+
self.app_id = app_id
|
|
16
|
+
self._tools: dict[str, dict] = {}
|
|
17
|
+
self._config = load_config()
|
|
18
|
+
self._llm = LLMClient()
|
|
19
|
+
self._health_checks: list[tuple[str, callable]] = []
|
|
20
|
+
self._middlewares: list = []
|
|
21
|
+
self._custom_routes: list[Route] = []
|
|
22
|
+
|
|
23
|
+
def llm(self) -> LLMClient:
|
|
24
|
+
"""返回 Keystone LLM Relay 客户端(已在 __init__ 中预初始化)。
|
|
25
|
+
|
|
26
|
+
要求 manifest 中 llm_mode 为 keystone_relay,Keystone 会注入
|
|
27
|
+
KS_RELAY_TOKEN 环境变量。本地开发时开发者需自行设置 KS_RELAY_TOKEN。
|
|
28
|
+
"""
|
|
29
|
+
return self._llm
|
|
30
|
+
|
|
31
|
+
def tool(self, name: str, description: str):
|
|
32
|
+
def decorator(func):
|
|
33
|
+
if not inspect.iscoroutinefunction(func):
|
|
34
|
+
raise TypeError(
|
|
35
|
+
f"tool {name!r} 的 handler 必须是 async 函数,"
|
|
36
|
+
f"收到的是同步函数 {func.__qualname__}"
|
|
37
|
+
)
|
|
38
|
+
if name in self._tools:
|
|
39
|
+
raise ValueError(f"tool {name!r} 已经注册过了,禁止重复注册")
|
|
40
|
+
self._tools[name] = {"handler": func, "description": description}
|
|
41
|
+
return func
|
|
42
|
+
return decorator
|
|
43
|
+
|
|
44
|
+
def handle(self, path: str, endpoint, methods: list[str] | None = None):
|
|
45
|
+
"""注册自定义路由(如 REST API、WebSocket 等)。返回 self 支持链式调用。"""
|
|
46
|
+
self._custom_routes.append(Route(path, endpoint, methods=methods))
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def use(self, middleware_cls, **kwargs):
|
|
50
|
+
"""注册 Starlette 中间件(类形式)。返回 self 支持链式调用。
|
|
51
|
+
|
|
52
|
+
用��: app.use(CORSMiddleware, allow_origins=["*"])
|
|
53
|
+
"""
|
|
54
|
+
self._middlewares.append((middleware_cls, kwargs))
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
def health_check(self, name: str, check_fn):
|
|
58
|
+
"""注册自定义健康检查项。check_fn() 无异��表示健康,抛异常表示不健康。
|
|
59
|
+
|
|
60
|
+
/healthz 端点��聚合所有检查项,任一失败返回 503。
|
|
61
|
+
返回 self 支持链式调用。
|
|
62
|
+
"""
|
|
63
|
+
self._health_checks.append((name, check_fn))
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def create_app(self) -> Starlette:
|
|
67
|
+
"""构建并返回 Starlette 实例,供需要自行管理生命周期的高级��景使用。"""
|
|
68
|
+
routes = health_routes(self.app_id, self._tools, self._health_checks) + [
|
|
69
|
+
Route("/mcp/tools/call", self._handle_tool_call, methods=["POST"]),
|
|
70
|
+
Route("/mcp/tools/list", self._handle_tools_list, methods=["GET"]),
|
|
71
|
+
mcp_route(self.app_id, "0.1.0", self._tools),
|
|
72
|
+
] + self._custom_routes
|
|
73
|
+
|
|
74
|
+
app = Starlette(routes=routes)
|
|
75
|
+
|
|
76
|
+
for middleware_cls, kwargs in self._middlewares:
|
|
77
|
+
app.add_middleware(middleware_cls, **kwargs)
|
|
78
|
+
|
|
79
|
+
return app
|
|
80
|
+
|
|
81
|
+
def run(self):
|
|
82
|
+
"""启动 HTTP 服务器,阻塞直到进程收到信号。
|
|
83
|
+
|
|
84
|
+
如需自行管理生命周期(如后台任务协调),使用 create_app() 获取 Starlette 实例
|
|
85
|
+
后自行调用 uvicorn.run() 或其他 ASGI 服务器。
|
|
86
|
+
"""
|
|
87
|
+
app = self.create_app()
|
|
88
|
+
uvicorn.run(app, host=self._config["host"], port=self._config["port"])
|
|
89
|
+
|
|
90
|
+
async def _handle_tool_call(self, request):
|
|
91
|
+
try:
|
|
92
|
+
body = await request.json()
|
|
93
|
+
except Exception as e:
|
|
94
|
+
return JSONResponse({"error": f"invalid json: {e}"}, status_code=400)
|
|
95
|
+
name = body.get("name")
|
|
96
|
+
params = body.get("params", {})
|
|
97
|
+
tool = self._tools.get(name)
|
|
98
|
+
if not tool:
|
|
99
|
+
return JSONResponse({"error": f"tool {name} not found"}, status_code=404)
|
|
100
|
+
try:
|
|
101
|
+
result = await tool["handler"](**params)
|
|
102
|
+
return JSONResponse({"result": result})
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
105
|
+
|
|
106
|
+
async def _handle_tools_list(self, request):
|
|
107
|
+
tools = [
|
|
108
|
+
{"name": name, "description": info["description"]}
|
|
109
|
+
for name, info in self._tools.items()
|
|
110
|
+
]
|
|
111
|
+
return JSONResponse({"tools": tools})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Keystone 运行时上下文注入。
|
|
2
|
+
|
|
3
|
+
通过 contextvars 在 MCP tools/call 调用期间为 handler 注入运行时元信息
|
|
4
|
+
(resource_scope / execution_id 等),开发者通过 get_context() 非侵入式
|
|
5
|
+
获取,handler 函数签名无需变更。
|
|
6
|
+
|
|
7
|
+
设计要点:
|
|
8
|
+
- ContextVar 默认值统一为空字符串 "",避免开发者写 None 检查
|
|
9
|
+
- _set_meta 将 _meta 中的非 string 值通过 str() 强制转成 string(MCP 协议允许
|
|
10
|
+
任意 JSON 值,但 SDK 内部统一按 string 暴露)。注意这与 Go SDK 的行为 **不同**:
|
|
11
|
+
Go 用 type assertion `v.(string)` 对非 string 值静默 drop;Python 则 coerce,
|
|
12
|
+
例如 int 42 会变成 "42"、bool True 会变成 "True"。Python 选择 coerce 的理由
|
|
13
|
+
是更实用 —— 工具 handler 有时合法地使用 int/bool(例如数值型租户 ID),
|
|
14
|
+
coerce 后的 string 形式仍可用。如未来需要严格匹配 Go 行为,可改为
|
|
15
|
+
`if isinstance(meta[key], str)` 拦截。
|
|
16
|
+
- _reset_meta 必须在 tools/call 的 finally 中调用,防止上下文泄漏到下一个请求
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from contextvars import ContextVar
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# 模块级 ContextVar,每个字段独立存放,未注入时返回空字符串。
|
|
23
|
+
_ks_resource_scope: ContextVar[str] = ContextVar("ks_resource_scope", default="")
|
|
24
|
+
_ks_execution_id: ContextVar[str] = ContextVar("ks_execution_id", default="")
|
|
25
|
+
_ks_task_id: ContextVar[str] = ContextVar("ks_task_id", default="")
|
|
26
|
+
_ks_task_name: ContextVar[str] = ContextVar("ks_task_name", default="")
|
|
27
|
+
_ks_trigger_type: ContextVar[str] = ContextVar("ks_trigger_type", default="")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ToolContext:
|
|
31
|
+
"""tools/call 调用期间可获取的 Keystone 运行时上下文。
|
|
32
|
+
|
|
33
|
+
所有字段均为只读 string;当 MCP 客户端未注入对应 _meta 字段时,对应属性
|
|
34
|
+
返回空字符串而非 None,以简化 handler 端的判空逻辑。
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def resource_scope(self) -> str:
|
|
39
|
+
"""资源作用域。多租户隔离的关键字段,不同实例调用同一工具时通过它区分数据边界。"""
|
|
40
|
+
return _ks_resource_scope.get()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def execution_id(self) -> str:
|
|
44
|
+
"""当前执行 ID。"""
|
|
45
|
+
return _ks_execution_id.get()
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def task_id(self) -> str:
|
|
49
|
+
"""当前任务 ID。"""
|
|
50
|
+
return _ks_task_id.get()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def task_name(self) -> str:
|
|
54
|
+
"""当前任务名称。"""
|
|
55
|
+
return _ks_task_name.get()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def trigger_type(self) -> str:
|
|
59
|
+
"""触发类型(manual / cron / webhook / event 等)。"""
|
|
60
|
+
return _ks_trigger_type.get()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_context() -> ToolContext:
|
|
64
|
+
"""获取当前 tools/call 调用的 Keystone 运行时上下文。
|
|
65
|
+
|
|
66
|
+
在 handler 函数体内调用,返回的 ToolContext 反映 MCP 请求 _meta 字段
|
|
67
|
+
中注入的值。在非 tools/call 路径下调用,所有字段均为空字符串。
|
|
68
|
+
"""
|
|
69
|
+
return ToolContext()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _set_meta(meta: dict | None) -> None:
|
|
73
|
+
"""将 MCP _meta 字段写入 ContextVars。
|
|
74
|
+
|
|
75
|
+
None 或空 dict 时为 no-op;对每个字段通过 str() 强制 coerce 成 string,
|
|
76
|
+
使 int/bool/None 等非 string 值也能以 string 形式暴露给 handler。
|
|
77
|
+
|
|
78
|
+
与 Go SDK 的差异:Go `withMeta` 用 type assertion `v.(string)` 对非 string
|
|
79
|
+
值静默 drop(完全忽略该字段),Python 则 coerce(`42` -> `"42"`、
|
|
80
|
+
`True` -> `"True"`、`None` -> `"None"`)。这是刻意的分歧 —— Python 侧
|
|
81
|
+
更宽松,保留 int/bool 租户 ID 等合法场景的可用性。
|
|
82
|
+
"""
|
|
83
|
+
if not meta:
|
|
84
|
+
return
|
|
85
|
+
if "ks_resource_scope" in meta:
|
|
86
|
+
_ks_resource_scope.set(str(meta["ks_resource_scope"]))
|
|
87
|
+
if "ks_execution_id" in meta:
|
|
88
|
+
_ks_execution_id.set(str(meta["ks_execution_id"]))
|
|
89
|
+
if "ks_task_id" in meta:
|
|
90
|
+
_ks_task_id.set(str(meta["ks_task_id"]))
|
|
91
|
+
if "ks_task_name" in meta:
|
|
92
|
+
_ks_task_name.set(str(meta["ks_task_name"]))
|
|
93
|
+
if "ks_trigger_type" in meta:
|
|
94
|
+
_ks_trigger_type.set(str(meta["ks_trigger_type"]))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _reset_meta() -> None:
|
|
98
|
+
"""清空所有 ContextVar,必须在 tools/call 的 finally 中调用。
|
|
99
|
+
|
|
100
|
+
避免 handler 异常或正常返回后遗留的上下文被下一个请求读取(在共享
|
|
101
|
+
event loop 的 ASGI 服务下,ContextVar 不会自动重置)。
|
|
102
|
+
"""
|
|
103
|
+
_ks_resource_scope.set("")
|
|
104
|
+
_ks_execution_id.set("")
|
|
105
|
+
_ks_task_id.set("")
|
|
106
|
+
_ks_task_name.set("")
|
|
107
|
+
_ks_trigger_type.set("")
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
from starlette.responses import JSONResponse
|
|
4
|
+
from starlette.routing import Route
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def health_routes(app_id: str, tools: dict, health_checks: list | None = None) -> list:
|
|
10
|
+
"""返回 /healthz、/readyz、/meta 三个路由。
|
|
11
|
+
|
|
12
|
+
health_checks: [(name, check_fn), ...] 列表。check_fn 返回 None 表示健康,
|
|
13
|
+
抛异常表示不健康。
|
|
14
|
+
"""
|
|
15
|
+
checks = health_checks or []
|
|
16
|
+
|
|
17
|
+
async def healthz(request):
|
|
18
|
+
if not checks:
|
|
19
|
+
return JSONResponse({"status": "ok"})
|
|
20
|
+
|
|
21
|
+
failures = {}
|
|
22
|
+
for name, check_fn in checks:
|
|
23
|
+
try:
|
|
24
|
+
check_fn()
|
|
25
|
+
except Exception as e:
|
|
26
|
+
failures[name] = str(e)
|
|
27
|
+
|
|
28
|
+
if failures:
|
|
29
|
+
return JSONResponse(
|
|
30
|
+
{"status": "unhealthy", "checks": failures},
|
|
31
|
+
status_code=503,
|
|
32
|
+
)
|
|
33
|
+
return JSONResponse({"status": "ok"})
|
|
34
|
+
|
|
35
|
+
async def readyz(request):
|
|
36
|
+
return JSONResponse({"status": "ok"})
|
|
37
|
+
|
|
38
|
+
async def meta(request):
|
|
39
|
+
return JSONResponse({
|
|
40
|
+
"app_id": app_id,
|
|
41
|
+
"tools": [
|
|
42
|
+
{"name": name, "description": info["description"]}
|
|
43
|
+
for name, info in tools.items()
|
|
44
|
+
],
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
return [
|
|
48
|
+
Route("/healthz", healthz, methods=["GET"]),
|
|
49
|
+
Route("/readyz", readyz, methods=["GET"]),
|
|
50
|
+
Route("/meta", meta, methods=["GET"]),
|
|
51
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Keystone LLM Relay 客户端。
|
|
2
|
+
|
|
3
|
+
通过 Keystone 网关调用大模型,无需自行管理 API key。
|
|
4
|
+
要求 manifest 中 llm_mode 为 keystone_relay,Keystone 安装应用时会注入
|
|
5
|
+
KS_RELAY_TOKEN 环境变量。
|
|
6
|
+
|
|
7
|
+
注意:chat 抛出的 RuntimeError 错误消息可能包含网关响应 body 原文(用于本地调试)。
|
|
8
|
+
如果 tool handler 需要把错误信息透传给 MCP 客户端,应先脱敏或仅返回固定消息,
|
|
9
|
+
与 Task 3 建立的 handler 错误处理约定一致。
|
|
10
|
+
"""
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from urllib.error import HTTPError
|
|
16
|
+
from urllib.request import Request, urlopen
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ChatResponse:
|
|
21
|
+
"""聊天响应。"""
|
|
22
|
+
id: str = ""
|
|
23
|
+
model: str = ""
|
|
24
|
+
choices: list = field(default_factory=list)
|
|
25
|
+
usage: dict = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LLMClient:
|
|
29
|
+
"""Keystone LLM Relay 客户端。
|
|
30
|
+
|
|
31
|
+
使用示例:
|
|
32
|
+
client = app.llm()
|
|
33
|
+
resp = await client.chat([{"role": "user", "content": "你好"}])
|
|
34
|
+
print(resp.choices[0]["message"]["content"])
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
self._gateway_url = os.environ.get("KS_GATEWAY_URL", "http://localhost:9988")
|
|
39
|
+
self._relay_token = os.environ.get("KS_RELAY_TOKEN", "")
|
|
40
|
+
|
|
41
|
+
async def chat(
|
|
42
|
+
self,
|
|
43
|
+
messages: list[dict],
|
|
44
|
+
*,
|
|
45
|
+
model: str = "",
|
|
46
|
+
temperature: float | None = None,
|
|
47
|
+
max_tokens: int = 0,
|
|
48
|
+
) -> ChatResponse:
|
|
49
|
+
"""发送聊天请求到 Keystone LLM 网关。
|
|
50
|
+
|
|
51
|
+
messages: OpenAI 风格的消息列表,每条消息是带 role/content 的 dict。
|
|
52
|
+
model: 可选指定模型名;留空时由网关按 manifest 策略选择。
|
|
53
|
+
temperature/max_tokens: 可选生成参数。
|
|
54
|
+
|
|
55
|
+
未设置 KS_RELAY_TOKEN 时直接抛 RuntimeError。
|
|
56
|
+
"""
|
|
57
|
+
if not self._relay_token:
|
|
58
|
+
raise RuntimeError(
|
|
59
|
+
"KS_RELAY_TOKEN 未设置,请确认 manifest 中 llm_mode 为 keystone_relay"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
body: dict = {"messages": messages}
|
|
63
|
+
if model:
|
|
64
|
+
body["model"] = model
|
|
65
|
+
if temperature is not None:
|
|
66
|
+
body["temperature"] = temperature
|
|
67
|
+
if max_tokens > 0:
|
|
68
|
+
body["max_tokens"] = max_tokens
|
|
69
|
+
|
|
70
|
+
url = f"{self._gateway_url}/v1/gateway/relay/chat"
|
|
71
|
+
data = json.dumps(body).encode()
|
|
72
|
+
req = Request(url, data=data, method="POST")
|
|
73
|
+
req.add_header("Content-Type", "application/json")
|
|
74
|
+
req.add_header("Authorization", f"Bearer {self._relay_token}")
|
|
75
|
+
|
|
76
|
+
# urllib 是同步 API,直接在 async 函数里调用会阻塞事件循环。
|
|
77
|
+
# asyncio.to_thread 把同步调用放到线程池执行,保持异步语义。
|
|
78
|
+
# 60s 超时与 Go 侧对齐,防止 hung gateway 长时间阻塞 tool handler。
|
|
79
|
+
def _do_request() -> dict:
|
|
80
|
+
try:
|
|
81
|
+
with urlopen(req, timeout=60) as resp:
|
|
82
|
+
raw = resp.read()
|
|
83
|
+
except HTTPError as e:
|
|
84
|
+
# 读取 body 时可能因连接已关闭而失败,做防御处理
|
|
85
|
+
try:
|
|
86
|
+
body = e.read().decode(errors="replace")
|
|
87
|
+
except Exception:
|
|
88
|
+
body = ""
|
|
89
|
+
raise RuntimeError(
|
|
90
|
+
f"LLM 网关返回 {e.code}: {body}"
|
|
91
|
+
) from e
|
|
92
|
+
try:
|
|
93
|
+
return json.loads(raw)
|
|
94
|
+
except json.JSONDecodeError as e:
|
|
95
|
+
raise RuntimeError(f"解析响应失败: {e}") from e
|
|
96
|
+
|
|
97
|
+
resp_data = await asyncio.to_thread(_do_request)
|
|
98
|
+
|
|
99
|
+
return ChatResponse(
|
|
100
|
+
id=resp_data.get("id", ""),
|
|
101
|
+
model=resp_data.get("model", ""),
|
|
102
|
+
choices=resp_data.get("choices", []),
|
|
103
|
+
usage=resp_data.get("usage", {}),
|
|
104
|
+
)
|