media-studio-agent-sdk 0.1.0b1__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.
- media_studio_agent_sdk-0.1.0b1/.gitignore +45 -0
- media_studio_agent_sdk-0.1.0b1/LICENSE +21 -0
- media_studio_agent_sdk-0.1.0b1/PKG-INFO +65 -0
- media_studio_agent_sdk-0.1.0b1/README.md +31 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/__init__.py +5 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/__main__.py +370 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/assets.py +421 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/config.py +250 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/provider.py +96 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/relay.py +83 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/runtime.py +412 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/server.py +804 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/store.py +315 -0
- media_studio_agent_sdk-0.1.0b1/dsh_sandbox/trace.py +762 -0
- media_studio_agent_sdk-0.1.0b1/pyproject.toml +63 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.so
|
|
6
|
+
*.egg-info/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg
|
|
10
|
+
.eggs/
|
|
11
|
+
|
|
12
|
+
# Virtual environments
|
|
13
|
+
.venv/
|
|
14
|
+
venv/
|
|
15
|
+
ENV/
|
|
16
|
+
env/
|
|
17
|
+
|
|
18
|
+
# IDE
|
|
19
|
+
.idea/
|
|
20
|
+
.vscode/
|
|
21
|
+
*.swp
|
|
22
|
+
*.swo
|
|
23
|
+
*~
|
|
24
|
+
|
|
25
|
+
# OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
Thumbs.db
|
|
28
|
+
|
|
29
|
+
# Secrets / local overrides(仓库不放任何默认 key)
|
|
30
|
+
.env
|
|
31
|
+
.env.*
|
|
32
|
+
!.env.example
|
|
33
|
+
.claude/settings.local.json
|
|
34
|
+
*.pem
|
|
35
|
+
*.key
|
|
36
|
+
|
|
37
|
+
# Project specific
|
|
38
|
+
*.log
|
|
39
|
+
.dsh-test-tmp/
|
|
40
|
+
.assets-test-tmp/
|
|
41
|
+
.build-stage/
|
|
42
|
+
.dev-install-test-tmp/
|
|
43
|
+
.cli-test-tmp/
|
|
44
|
+
.local/
|
|
45
|
+
.tmp-wheel/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AI302
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: media-studio-agent-sdk
|
|
3
|
+
Version: 0.1.0b1
|
|
4
|
+
Summary: AI302 Media Studio agent runtime SDK — FastAPI / WebSocket / replayable runs
|
|
5
|
+
Project-URL: Homepage, https://github.com/ai302/claude-sandbox-sdk
|
|
6
|
+
Project-URL: Repository, https://github.com/ai302/claude-sandbox-sdk
|
|
7
|
+
Project-URL: Issues, https://github.com/ai302/claude-sandbox-sdk/issues
|
|
8
|
+
Author: AI302
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,fastapi,media,runtime,websocket
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: deepseek-harness-runtime-bin==0.2.1rc2
|
|
22
|
+
Requires-Dist: deepseek-harness-sdk==0.2.1rc2
|
|
23
|
+
Requires-Dist: fastapi>=0.100.0
|
|
24
|
+
Requires-Dist: uvicorn[standard]>=0.23.0
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: hatchling>=1.21; extra == 'dev'
|
|
28
|
+
Requires-Dist: httpx>=0.27.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: mypy>=1.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.1.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# media-studio-agent-sdk
|
|
36
|
+
|
|
37
|
+
AI302 Media Studio 的 agent 运行时 SDK:FastAPI + WebSocket,逐 step 下发原始
|
|
38
|
+
agent 通知,并提供可复查的 run 帧回放。
|
|
39
|
+
|
|
40
|
+
## 安装
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install media-studio-agent-sdk
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## 启动
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
media-studio-agent --host 0.0.0.0 --port 8300
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 接口速览
|
|
53
|
+
|
|
54
|
+
- `WS /ws/chat`:`init` → `message` → 原始运行时通知 + `run_started` /
|
|
55
|
+
`run_finished` 控制帧;每帧带 `run_id` / `seq`。
|
|
56
|
+
- `GET /runs`、`GET /runs/{run_id}/frames?since_seq=`:与 WSS 一致的帧回放。
|
|
57
|
+
- `GET /runs/{run_id}/trace`、`/trace.txt`:根 + 子 agent 的 step 级轨迹。
|
|
58
|
+
- `GET /health`:服务状态 + 活动运行时数。
|
|
59
|
+
|
|
60
|
+
部署配置、完整 WS 协议与已知限制见仓库 `docs/`。
|
|
61
|
+
|
|
62
|
+
## 发版
|
|
63
|
+
|
|
64
|
+
推送 `agent-v*` tag 触发 `.github/workflows/publish-agent.yml`;版本与流程见仓库
|
|
65
|
+
`docs/release.md`。
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# media-studio-agent-sdk
|
|
2
|
+
|
|
3
|
+
AI302 Media Studio 的 agent 运行时 SDK:FastAPI + WebSocket,逐 step 下发原始
|
|
4
|
+
agent 通知,并提供可复查的 run 帧回放。
|
|
5
|
+
|
|
6
|
+
## 安装
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install media-studio-agent-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 启动
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
media-studio-agent --host 0.0.0.0 --port 8300
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 接口速览
|
|
19
|
+
|
|
20
|
+
- `WS /ws/chat`:`init` → `message` → 原始运行时通知 + `run_started` /
|
|
21
|
+
`run_finished` 控制帧;每帧带 `run_id` / `seq`。
|
|
22
|
+
- `GET /runs`、`GET /runs/{run_id}/frames?since_seq=`:与 WSS 一致的帧回放。
|
|
23
|
+
- `GET /runs/{run_id}/trace`、`/trace.txt`:根 + 子 agent 的 step 级轨迹。
|
|
24
|
+
- `GET /health`:服务状态 + 活动运行时数。
|
|
25
|
+
|
|
26
|
+
部署配置、完整 WS 协议与已知限制见仓库 `docs/`。
|
|
27
|
+
|
|
28
|
+
## 发版
|
|
29
|
+
|
|
30
|
+
推送 `agent-v*` tag 触发 `.github/workflows/publish-agent.yml`;版本与流程见仓库
|
|
31
|
+
`docs/release.md`。
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""启动入口:``python -m dsh_sandbox``(开发时 ``PYTHONPATH=app``)。
|
|
2
|
+
|
|
3
|
+
生产镜像里可以直接::
|
|
4
|
+
|
|
5
|
+
uvicorn --factory dsh_sandbox.server:create_app_from_env --host 0.0.0.0 --port 8300
|
|
6
|
+
|
|
7
|
+
本地(Windows/macOS)常用::
|
|
8
|
+
|
|
9
|
+
python -m dsh_sandbox --port 8300 \
|
|
10
|
+
--workspace C:\\work\\agent-workspace \
|
|
11
|
+
--data-dir C:\\work\\.dsh-sandbox
|
|
12
|
+
|
|
13
|
+
想先确认最终生效的路径/模型,加 ``--print-config``(不会创建目录、不启动服务)。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import csv
|
|
20
|
+
import io
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import socket
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import __version__
|
|
30
|
+
from .config import DEFAULT_DATA_DIR, DEFAULT_WORKSPACE, Settings
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="media-studio-agent",
|
|
38
|
+
description="Media Studio agent 服务:WebSocket 逐 step 输出 + HTTP 复查",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--host", default=None, help="监听地址(默认 DSH_SANDBOX_HOST / 0.0.0.0)")
|
|
41
|
+
parser.add_argument("--port", type=int, default=None, help="监听端口(默认 DSH_SANDBOX_PORT / 8300)")
|
|
42
|
+
parser.add_argument("--log-level", default=None, help="DEBUG / INFO / WARNING / ERROR")
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--workspace",
|
|
45
|
+
default=None,
|
|
46
|
+
help=f"agent 工作目录(默认按平台:Linux /home/user/workspace,其它 {DEFAULT_WORKSPACE})",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument("--data-dir", default=None, help=f"run 落盘目录(默认 {DEFAULT_DATA_DIR})")
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--assets-dir",
|
|
51
|
+
default=None,
|
|
52
|
+
help="agent 资产版本仓(默认 <data-dir>/assets);控制中台经 POST /assets 投放",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("--runtime-home", default=None, help="运行时持久化 home(默认 <data-dir>/home)")
|
|
55
|
+
parser.add_argument("--provider", default=None, help="模型 provider id(默认 deepseek-official)")
|
|
56
|
+
parser.add_argument("--model", default=None, help="模型 id(默认 deepseek-v4-flash)")
|
|
57
|
+
parser.add_argument("--effort", default=None, help="reasoning effort(等价 EFFORT 环境变量)")
|
|
58
|
+
parser.add_argument("--max-tokens", type=int, default=None, help="单次输出上限(默认 49152)")
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--context-window",
|
|
61
|
+
type=int,
|
|
62
|
+
default=None,
|
|
63
|
+
help="路由上下文容量(缺失时用于算 run_finished.context 百分比;0=不兜底)",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--patch",
|
|
67
|
+
action="append",
|
|
68
|
+
default=None,
|
|
69
|
+
metavar="FILE",
|
|
70
|
+
help="运行时 profile patch(可重复);第三方网关的 compat 配置从这里投放",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("--ssl-keyfile", default=None, help="TLS 私钥(服务自己终止 wss 时用)")
|
|
73
|
+
parser.add_argument("--ssl-certfile", default=None, help="TLS 证书(服务自己终止 wss 时用)")
|
|
74
|
+
parser.add_argument("--print-config", action="store_true", help="打印生效配置后退出(不建目录、不启动)")
|
|
75
|
+
parser.add_argument("--check-dirs", action="store_true", help="逐个检查/创建启动目录并报告可写性,然后退出")
|
|
76
|
+
parser.add_argument("--version", action="version", version=f"media-studio-agent-sdk {__version__}")
|
|
77
|
+
return parser
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_settings(args: argparse.Namespace) -> Settings:
|
|
81
|
+
settings = Settings()
|
|
82
|
+
if args.host is not None:
|
|
83
|
+
settings.host = args.host
|
|
84
|
+
if args.port is not None:
|
|
85
|
+
settings.port = args.port
|
|
86
|
+
if args.log_level is not None:
|
|
87
|
+
settings.log_level = args.log_level
|
|
88
|
+
if args.workspace is not None:
|
|
89
|
+
settings.workspace = Path(args.workspace).expanduser()
|
|
90
|
+
if args.data_dir is not None:
|
|
91
|
+
settings.data_dir = Path(args.data_dir).expanduser()
|
|
92
|
+
if args.assets_dir is not None:
|
|
93
|
+
settings.assets_dir = Path(args.assets_dir).expanduser()
|
|
94
|
+
if args.runtime_home is not None:
|
|
95
|
+
settings.dsh_home = Path(args.runtime_home).expanduser()
|
|
96
|
+
if args.provider is not None:
|
|
97
|
+
settings.provider = args.provider
|
|
98
|
+
if args.model is not None:
|
|
99
|
+
settings.model = args.model
|
|
100
|
+
if args.effort is not None:
|
|
101
|
+
settings.reasoning_effort = args.effort or None
|
|
102
|
+
if args.max_tokens is not None:
|
|
103
|
+
settings.max_tokens = args.max_tokens
|
|
104
|
+
if args.context_window is not None:
|
|
105
|
+
settings.context_window = args.context_window
|
|
106
|
+
if args.patch:
|
|
107
|
+
settings.provider_patches = tuple(args.patch)
|
|
108
|
+
if args.ssl_keyfile is not None:
|
|
109
|
+
settings.ssl_keyfile = args.ssl_keyfile
|
|
110
|
+
if args.ssl_certfile is not None:
|
|
111
|
+
settings.ssl_certfile = args.ssl_certfile
|
|
112
|
+
return settings
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_config(settings: Settings) -> None:
|
|
116
|
+
"""打印最终生效值(供本地排查路径/模型用)。"""
|
|
117
|
+
rows = [
|
|
118
|
+
("workspace", settings.workspace),
|
|
119
|
+
("data_dir", settings.data_dir),
|
|
120
|
+
("assets_dir", settings.resolved_assets_dir),
|
|
121
|
+
("runtime_home", settings.resolved_dsh_home),
|
|
122
|
+
("host", settings.host),
|
|
123
|
+
("port", settings.port),
|
|
124
|
+
("provider", settings.provider),
|
|
125
|
+
("model", settings.model),
|
|
126
|
+
("reasoning_effort", settings.reasoning_effort or ""),
|
|
127
|
+
("max_tokens", settings.max_tokens),
|
|
128
|
+
("context_window", settings.context_window or "auto"),
|
|
129
|
+
("profile", settings.dsh_profile),
|
|
130
|
+
("patches", list(settings.provider_patches)),
|
|
131
|
+
("run_timeout_sec", settings.run_timeout_sec),
|
|
132
|
+
("ssl_keyfile", settings.ssl_keyfile or ""),
|
|
133
|
+
("ssl_certfile", settings.ssl_certfile or ""),
|
|
134
|
+
]
|
|
135
|
+
width = max(len(name) for name, _ in rows)
|
|
136
|
+
for name, value in rows:
|
|
137
|
+
print(f"{name:<{width}} : {value}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def report_dirs(settings: Settings) -> bool:
|
|
141
|
+
"""打印每个启动目录的可写性;全部 ok 返回 True。"""
|
|
142
|
+
results = settings.directory_checks()
|
|
143
|
+
width = max(len(label) for label, _, _ in results)
|
|
144
|
+
for label, path, status in results:
|
|
145
|
+
mark = "ok " if status == "ok" else "FAIL"
|
|
146
|
+
print(f"[{mark}] {label:<{width}} {path}")
|
|
147
|
+
if status != "ok":
|
|
148
|
+
print(f" {status}")
|
|
149
|
+
return all(status == "ok" for _, _, status in results)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---- 端口占用预检 ------------------------------------------------------
|
|
153
|
+
#
|
|
154
|
+
# 端口被占时 uvicorn 只打一行 ``error while attempting to bind on address``,
|
|
155
|
+
# Windows 上还常被写成 ``WinError 10013``("以一种访问权限不允许的方式做了一个
|
|
156
|
+
# 访问套接字的尝试"),看着像权限问题,实际是 Docker Desktop 之类的进程先占了
|
|
157
|
+
# 8300。这里先探一次,直接说清楚"谁占着、换哪个端口"。
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True)
|
|
161
|
+
class PortProblem:
|
|
162
|
+
"""端口不可用的原因(``owner`` 仅在查得出来时有值)。"""
|
|
163
|
+
|
|
164
|
+
host: str
|
|
165
|
+
port: int
|
|
166
|
+
reason: str
|
|
167
|
+
owner: str | None = None
|
|
168
|
+
occupied: bool = False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def probe_host(host: str) -> str:
|
|
172
|
+
"""把监听地址换成可连接的探测地址。
|
|
173
|
+
|
|
174
|
+
监听在 ``0.0.0.0`` / ``::`` 时没法直接 connect,分别换成回环地址。
|
|
175
|
+
"""
|
|
176
|
+
stripped = (host or "").strip().strip("[]")
|
|
177
|
+
if stripped in {"", "*", "0.0.0.0"}:
|
|
178
|
+
return "127.0.0.1"
|
|
179
|
+
if stripped in {":", "::"}:
|
|
180
|
+
return "::1"
|
|
181
|
+
return stripped
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _is_listening(connect_host: str, port: int, timeout: float = 0.3) -> bool:
|
|
185
|
+
"""能否连通该端口(能连通即有人监听)。"""
|
|
186
|
+
try:
|
|
187
|
+
with socket.create_connection((connect_host, port), timeout=timeout):
|
|
188
|
+
return True
|
|
189
|
+
except OSError:
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _bind_error(host: str, port: int) -> OSError | None:
|
|
194
|
+
"""真实试绑一次,返回失败原因(``None`` 表示绑得上)。
|
|
195
|
+
|
|
196
|
+
socket 选项跟 uvicorn 对齐:Windows 上用独占绑定,POSIX 上用
|
|
197
|
+
``SO_REUSEADDR``(否则 TIME_WAIT 会误报)。只 bind 不 listen,不影响后续启动。
|
|
198
|
+
"""
|
|
199
|
+
family = socket.AF_INET6 if ":" in probe_host(host) else socket.AF_INET
|
|
200
|
+
sock = socket.socket(family, socket.SOCK_STREAM)
|
|
201
|
+
try:
|
|
202
|
+
exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None)
|
|
203
|
+
if os.name == "nt" and exclusive is not None:
|
|
204
|
+
sock.setsockopt(socket.SOL_SOCKET, exclusive, 1)
|
|
205
|
+
else:
|
|
206
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
207
|
+
sock.bind((host if host else "0.0.0.0", port))
|
|
208
|
+
return None
|
|
209
|
+
except OSError as exc:
|
|
210
|
+
return exc
|
|
211
|
+
finally:
|
|
212
|
+
sock.close()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _describe_bind_error(exc: OSError) -> str:
|
|
216
|
+
winerror = getattr(exc, "winerror", None)
|
|
217
|
+
if exc.errno == 13 or winerror == 10013:
|
|
218
|
+
return "系统拒绝监听(端口已被独占,或落在 Windows 保留/排除的端口段)"
|
|
219
|
+
if exc.errno == 98 or winerror in {10048, 10049}:
|
|
220
|
+
return "端口已被占用"
|
|
221
|
+
return f"{type(exc).__name__}: {exc}"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def find_port_owner(port: int) -> str | None:
|
|
225
|
+
"""尽力查出占用端口的进程(仅 Windows,纯标准库,查不到返回 ``None``)。"""
|
|
226
|
+
if os.name != "nt":
|
|
227
|
+
return None
|
|
228
|
+
try:
|
|
229
|
+
listing = subprocess.run(
|
|
230
|
+
["netstat", "-ano", "-p", "tcp"],
|
|
231
|
+
capture_output=True,
|
|
232
|
+
text=True,
|
|
233
|
+
timeout=5,
|
|
234
|
+
check=False,
|
|
235
|
+
).stdout
|
|
236
|
+
except (OSError, subprocess.SubprocessError):
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
pids: list[str] = []
|
|
240
|
+
for line in listing.splitlines():
|
|
241
|
+
parts = line.split()
|
|
242
|
+
if len(parts) < 4 or parts[0].upper() != "TCP":
|
|
243
|
+
continue
|
|
244
|
+
if parts[1].rsplit(":", 1)[-1] == str(port) and parts[-1].isdigit():
|
|
245
|
+
if parts[-1] not in pids:
|
|
246
|
+
pids.append(parts[-1])
|
|
247
|
+
|
|
248
|
+
names: list[str] = []
|
|
249
|
+
for pid in pids:
|
|
250
|
+
try:
|
|
251
|
+
raw = subprocess.run(
|
|
252
|
+
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
|
253
|
+
capture_output=True,
|
|
254
|
+
text=True,
|
|
255
|
+
timeout=5,
|
|
256
|
+
check=False,
|
|
257
|
+
).stdout
|
|
258
|
+
row = next(csv.reader(io.StringIO(raw)), [])
|
|
259
|
+
name = row[0] if row else ""
|
|
260
|
+
except (OSError, subprocess.SubprocessError, StopIteration):
|
|
261
|
+
name = ""
|
|
262
|
+
names.append(f"{name} (PID {pid})" if name else f"PID {pid}")
|
|
263
|
+
return "、".join(names) if names else None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def check_port(host: str, port: int) -> PortProblem | None:
|
|
267
|
+
"""端口可用返回 ``None``,否则返回带原因(和占用者)的 :class:`PortProblem`。"""
|
|
268
|
+
if port <= 0:
|
|
269
|
+
return None
|
|
270
|
+
if port < 1024 and os.name != "nt" and hasattr(os, "geteuid") and os.geteuid() != 0:
|
|
271
|
+
return PortProblem(host, port, "监听 <1024 的端口需要 root")
|
|
272
|
+
|
|
273
|
+
connect_host = probe_host(host)
|
|
274
|
+
if _is_listening(connect_host, port):
|
|
275
|
+
return PortProblem(host, port, "端口已被占用", find_port_owner(port), occupied=True)
|
|
276
|
+
|
|
277
|
+
exc = _bind_error(host, port)
|
|
278
|
+
if exc is not None:
|
|
279
|
+
return PortProblem(host, port, _describe_bind_error(exc))
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def suggest_free_port(host: str, port: int, *, limit: int = 20) -> int | None:
|
|
284
|
+
"""在 ``port+1`` 之后找一个能用的端口(找不到返回 ``None``)。"""
|
|
285
|
+
for candidate in range(port + 1, port + 1 + limit):
|
|
286
|
+
if check_port(host, candidate) is None:
|
|
287
|
+
return candidate
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def port_owner_hint(port: int) -> str:
|
|
292
|
+
if os.name == "nt":
|
|
293
|
+
return f"netstat -ano | findstr :{port}"
|
|
294
|
+
return f"ss -ltnp 'sport = :{port}'"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main(argv: list[str] | None = None) -> int:
|
|
298
|
+
args = build_parser().parse_args(argv)
|
|
299
|
+
settings = build_settings(args)
|
|
300
|
+
|
|
301
|
+
if args.print_config:
|
|
302
|
+
print_config(settings)
|
|
303
|
+
return 0
|
|
304
|
+
if args.check_dirs:
|
|
305
|
+
return 0 if report_dirs(settings) else 1
|
|
306
|
+
|
|
307
|
+
logging.basicConfig(
|
|
308
|
+
level=getattr(logging, settings.log_level.upper(), logging.INFO),
|
|
309
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
settings.ensure_dirs()
|
|
314
|
+
except OSError as exc:
|
|
315
|
+
logger.error("无法创建目录: %s", exc)
|
|
316
|
+
logger.error("目录检查:")
|
|
317
|
+
for label, path, status in settings.directory_checks():
|
|
318
|
+
logger.error(" %-12s %s : %s", label, path, status)
|
|
319
|
+
logger.error(
|
|
320
|
+
"提示:若只有 runtime_home 失败,用 --runtime-home 指到可写目录;"
|
|
321
|
+
"也可以把 --data-dir 换到 %LOCALAPPDATA% 下(避开 Desktop/OneDrive 的访问限制)。"
|
|
322
|
+
)
|
|
323
|
+
if os.environ.get("DSH_SHELL") == "1":
|
|
324
|
+
logger.error(
|
|
325
|
+
"检测到 DSH_SHELL=1:当前终端是 DSH 的沙箱 shell,只能写会话工作区;"
|
|
326
|
+
"请换普通 PowerShell,或把 --workspace/--data-dir 指到当前工作区内(如 .local\\data)。"
|
|
327
|
+
)
|
|
328
|
+
return 1
|
|
329
|
+
|
|
330
|
+
logger.info(
|
|
331
|
+
"workspace=%s data_dir=%s runtime_home=%s provider=%s model=%s",
|
|
332
|
+
settings.workspace,
|
|
333
|
+
settings.data_dir,
|
|
334
|
+
settings.resolved_dsh_home,
|
|
335
|
+
settings.provider,
|
|
336
|
+
settings.model,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
problem = check_port(settings.host, settings.port)
|
|
340
|
+
if problem is not None:
|
|
341
|
+
logger.error("无法监听 %s:%s —— %s", problem.host, problem.port, problem.reason)
|
|
342
|
+
if problem.owner:
|
|
343
|
+
logger.error("当前占用者:%s", problem.owner)
|
|
344
|
+
elif problem.occupied:
|
|
345
|
+
logger.error("占用进程不在当前用户可见范围内(常见是 Docker Desktop 的 com.docker.backend)")
|
|
346
|
+
logger.error("查看占用:%s", port_owner_hint(problem.port))
|
|
347
|
+
alternative = suggest_free_port(settings.host, settings.port)
|
|
348
|
+
if alternative is not None:
|
|
349
|
+
logger.error("换一个端口即可:%s", f"--port {alternative}")
|
|
350
|
+
return 1
|
|
351
|
+
|
|
352
|
+
# 延迟导入:避免 --help / --print-config 时也拉起 FastAPI / SDK。
|
|
353
|
+
from .server import create_app
|
|
354
|
+
|
|
355
|
+
import uvicorn
|
|
356
|
+
|
|
357
|
+
app = create_app(settings)
|
|
358
|
+
uvicorn.run(
|
|
359
|
+
app,
|
|
360
|
+
host=settings.host,
|
|
361
|
+
port=settings.port,
|
|
362
|
+
log_level=settings.log_level.lower(),
|
|
363
|
+
ssl_keyfile=settings.ssl_keyfile,
|
|
364
|
+
ssl_certfile=settings.ssl_certfile,
|
|
365
|
+
)
|
|
366
|
+
return 0
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
if __name__ == "__main__":
|
|
370
|
+
sys.exit(main())
|