sub2api 网关接入:每用户独立账号 + 任务注入 + 网关可配置

- cloud/sub2api.py:管理 API 客户端(管理员登录/token 刷新/
  建用户/以用户发 key/删用户/查组/网关模型目录)+ 幂等开通
  ensure_user_gateway_key(入库失败尽力清理孤儿 key 与账号)
- worker:网关门(sub2api 时按 created_by 幂等开通,失败任务
  直接 failed;协调员子任务跟父任务属主)+ 默认模型走网关
  目录首个;三条执行路径(Local popen/同步、Docker 同步)注
  入任务级 env 与 --model-provider;NO_PROXY 追加网关 host
- host.py:--model-provider,sub2api 时以 -c 注入 provider 表
  (不改租户 config.toml);未配 SUB2API_BASE_URL 时 loud fail
- sandbox:Local/Docker 执行器传 --model-provider 与任务 env;
  docker 密钥只传名(-e SUB2API_API_KEY),值由 CLI 子进程
  环境提供,绝不进 argv;runner 不支持 env 时 loud fail
- server:OAuth 建号 best-effort 开通;/relay/models 走调用者
  自己的网关模型目录;init_db 补 user_gateway_keys 表
- 测试:新单测 11 个;全量 cloud 679(4 跳过)、host 65、
  deploy 19 全过
- 诚实边界:真模型调用实弹需上游账号挂组后才做;切流前
  xcmapi 保持现网
This commit is contained in:
host committed 2026-10-27 17:22:54 +08:00
1 parent 437684902a
commit ec47328d3c
7 files changed
+996 -54

No files matched your search

+129 -35
View File
@@ -130,9 +130,20 @@ def _truncate_stdout(text):
return str(text)[:MAX_STDOUT_CHARS]
def _default_runner(cmd):
"""subprocess.run 的薄封装:捕获输出、文本模式。"""
return subprocess.run(cmd, capture_output=True, text=True)
def _default_runner(cmd, env=None):
"""subprocess.run 的薄封装:捕获输出、文本模式。
env 非空时以它为子进程环境(worker 注入任务级密钥时用);
不传则继承当前进程环境,与旧行为一致。
"""
if env is None:
return subprocess.run(cmd, capture_output=True, text=True)
return subprocess.run(cmd, capture_output=True, text=True, env=env)
# docker -e 只传名不传值的密钥类变量:值由 CLI 子进程环境提供,
# 绝不进 docker argv(见 build_run_command 与 _docker_cli_env)。
_DOCKER_SECRET_ENV_NAMES = frozenset({"SUB2API_API_KEY"})
def _approval_cli_args(approval):
@@ -190,13 +201,15 @@ class LocalExecutor(Executor):
self.model = model
def _base_command(self, model=None, effort=None, approval=None,
sandbox=None):
sandbox=None, model_provider=None):
cmd = [sys.executable, self.host_py_path]
if self.config_path is not None:
cmd += ["--config", self.config_path]
effective = model or self.model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
@@ -207,6 +220,23 @@ class LocalExecutor(Executor):
cmd += ["--sandbox", sandbox]
return cmd
def _run_local(self, cmd, extra_env):
"""本地 runner 调用:extra_env 非空时合并进子进程环境。
假 runner 只接受 cmd 时 TypeError:extra_env 是任务级密钥
(如 sub2api key),丢了它跑任务等于跑错网关,必须 loud
fail,绝不静默降级成无 key 运行。
"""
if not extra_env:
return self.runner(cmd)
merged = dict(os.environ)
merged.update(extra_env)
try:
return self.runner(cmd, env=merged)
except TypeError:
raise RuntimeError(
"本地 runner 不支持 env,无法注入任务级环境变量")
def _command(self, task_id):
cmd = self._base_command()
cmd += ["status", "--task", str(task_id)]
@@ -289,7 +319,8 @@ class LocalExecutor(Executor):
raw = str(task.get("id"))
return re.sub(r"[^A-Za-z0-9_.-]", "-", raw)
def dispatch_task(self, task, seed_dir=None) -> dict:
def dispatch_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
"""调 host.py dispatch:建 host 任务并跑一轮 turn。
命令:host.py [--config CFG] [--model M] dispatch --name <host 名>
@@ -311,7 +342,8 @@ class LocalExecutor(Executor):
effort=task.get("effort"),
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None))
if task.get("plan") else None),
model_provider=model_provider)
cmd += ["dispatch", "--name", self._host_name(task),
"--task", task["prompt"]]
repo = task.get("repo") or self.repo
@@ -320,7 +352,7 @@ class LocalExecutor(Executor):
if seed_dir:
cmd += ["--seed-dir", seed_dir]
return self._result_from_runner(
task["id"], self.runner(cmd))
task["id"], self._run_local(cmd, extra_env))
def fork_task(self, task, source_id, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
@@ -343,7 +375,7 @@ class LocalExecutor(Executor):
return self._result_from_runner(
task["id"], self.runner(cmd))
def run_task(self, task) -> dict:
def run_task(self, task, extra_env=None, model_provider=None) -> dict:
"""调 host.py run:在已有 host 任务上再跑一轮 turn。
命令:host.py [--config CFG] [--model M] run --task <host 名>
@@ -359,13 +391,15 @@ class LocalExecutor(Executor):
effort=task.get("effort"),
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None))
if task.get("plan") else None),
model_provider=model_provider)
cmd += ["run", "--task", self._host_name(task),
"--prompt", task["prompt"]]
return self._result_from_runner(
task["id"], self.runner(cmd))
task["id"], self._run_local(cmd, extra_env))
def goal_task(self, task, seed_dir=None) -> dict:
def goal_task(self, task, seed_dir=None, extra_env=None,
model_provider=None) -> dict:
"""调 host.py goal:目标循环(turn→验测→喂回重试)。
命令:host.py [--config CFG] [--model M] goal --name <host 名>
@@ -391,7 +425,8 @@ class LocalExecutor(Executor):
rounds = 3
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"))
approval=task.get("approval"),
model_provider=model_provider)
cmd += ["goal", "--name", self._host_name(task),
"--task", goal_text]
if verify:
@@ -403,9 +438,10 @@ class LocalExecutor(Executor):
if seed_dir:
cmd += ["--seed-dir", seed_dir]
return self._goal_result_from_runner(
task["id"], self.runner(cmd))
task["id"], self._run_local(cmd, extra_env))
def goal_resume_task(self, task, goal_text) -> dict:
def goal_resume_task(self, task, goal_text, extra_env=None,
model_provider=None) -> dict:
"""调 host.py goal-resume --goal-text:在旧线程上开新目标
循环(旧任务接续 goal 模式,云端 /continue 带 goal_mode=1
时走这里)。命令:host.py goal-resume --task <host 名>
@@ -419,12 +455,13 @@ class LocalExecutor(Executor):
raise ValueError("goal-resume 缺少目标文本")
cmd = self._base_command(model=task.get("model"),
effort=task.get("effort"),
approval=task.get("approval"))
approval=task.get("approval"),
model_provider=model_provider)
cmd += ["goal-resume", "--task", self._host_name(task),
"--goal-text", goal_text,
"--max-rounds", "0"]
return self._goal_result_from_runner(
task["id"], self.runner(cmd))
task["id"], self._run_local(cmd, extra_env))
def _goal_result_from_runner(self, task_id, result):
"""把 host.py goal 的 runner 返回值解析成 goal 结果 dict。
@@ -471,7 +508,8 @@ class LocalExecutor(Executor):
"usage": _parse_goal_usage(stdout_full),
}
def popen_task(self, task, kind="dispatch", seed_dir=None):
def popen_task(self, task, kind="dispatch", seed_dir=None,
extra_env=None, model_provider=None):
"""进程组启动 host.py 子进程,返回 Popen(供真取消用)。
start_new_session=True 让子进程自成进程组,worker 取消时
@@ -494,7 +532,8 @@ class LocalExecutor(Executor):
effort=task.get("effort"),
approval=task.get("approval"),
sandbox=("read-only"
if task.get("plan") else None))
if task.get("plan") else None),
model_provider=model_provider)
if kind == "run":
cmd += ["run", "--task", self._host_name(task),
"--prompt", task["prompt"]]
@@ -531,6 +570,12 @@ class LocalExecutor(Executor):
cmd += ["--repo", repo]
if seed_dir:
cmd += ["--seed-dir", seed_dir]
if extra_env:
merged = dict(os.environ)
merged.update(extra_env)
return subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
start_new_session=True, env=merged)
return subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True,
start_new_session=True)
@@ -723,7 +768,9 @@ class DockerExecutor(Executor):
-v <worktree>:/work -v <代码目录>:/app:ro -w /work
[-e <env_key名>] [-e <NAME=VALUE>...] <镜像> <容器内命令...>。
-e 只传名字,不带 =值(env_key);extra_env 的 -e 带值,
只用于非密钥变量(如 egress 代理地址)。
只用于非密钥变量(如 egress 代理地址);密钥类变量
(见 _DOCKER_SECRET_ENV_NAMES)只传名,值由 CLI 子进程
环境提供,绝不进 argv。
extra_mounts 给定时替换构造时的静态挂载(多租户隔离
用:worker 按项目算出挂载后每次调用传入);不给时用
静态挂载。network 给定时容器进该 Docker 网络(多租户
@@ -767,13 +814,35 @@ class DockerExecutor(Executor):
if extra_env:
merged_env.update(extra_env)
for name, value in sorted(merged_env.items()):
cmd += ["-e", "%s=%s" % (name, value)]
# 密钥类变量只传名不传值:docker 从 CLI 子进程环境里
# 取值(见 _docker_cli_env),值绝不进 docker argv
#(ps/docker inspect 可见 argv,带值等于明文泄露)。
if name in _DOCKER_SECRET_ENV_NAMES:
cmd += ["-e", name]
else:
cmd += ["-e", "%s=%s" % (name, value)]
cmd += [self.image]
cmd += list(inner_cmd)
return cmd
def _docker_cli_env(self, extra_env):
"""docker CLI 子进程环境:extra_env 含密钥名时才需要。
返回合并后的 env(供 -e <名> 按名解析);不含密钥名时
返回 None,保持旧行为(CLI 继承 worker 环境)。
"""
if not extra_env:
return None
if not any(name in _DOCKER_SECRET_ENV_NAMES
for name in extra_env):
return None
merged = dict(os.environ)
merged.update(extra_env)
return merged
def _inner_host_command(self, *args, model=None, effort=None,
approval=None, sandbox=None):
approval=None, sandbox=None,
model_provider=None):
"""容器内 host.py 命令:python3 /app/host.py [args...]。
config_path 若已给,容器内用 /app/<basename> 指代它——
@@ -792,6 +861,8 @@ class DockerExecutor(Executor):
effective = model or self.model
if effective:
cmd += ["--model", effective]
if model_provider:
cmd += ["--model-provider", model_provider]
if effort:
cmd += ["--effort", effort]
cmd += _approval_cli_args(approval)
@@ -801,19 +872,31 @@ class DockerExecutor(Executor):
return cmd
# -- runner 调用 / 清理 --------------------------------------------
def _call_runner(self, cmd, timeout=None):
def _call_runner(self, cmd, timeout=None, env=None):
"""调 runner:优先传 timeout,假 runner 只接受 cmd 时回退。
timeout 参数只影响本次调用(并发下两个任务各用各的
超时);不传时用实例的 self.timeout。绝不能在调用方
临时改 self.timeout 再恢复,那在并发下会互相覆盖。
env 非空时是 docker CLI 子进程的环境(供 -e <名> 按名
解析密钥用):runner 不接受 env 时 loud fail,绝不静默
丢掉密钥跑任务(那样容器内 key 缺失,任务必失败还难查)。
"""
if env is None:
try:
return self.runner(
cmd,
timeout=self.timeout if timeout is None else timeout)
except TypeError:
return self.runner(cmd)
try:
return self.runner(
cmd,
timeout=self.timeout if timeout is None else timeout)
timeout=self.timeout if timeout is None else timeout,
env=env)
except TypeError:
return self.runner(cmd)
raise RuntimeError(
"docker runner 不支持 env,无法按名注入任务密钥")
def list_containers(self, prefix="codex-"):
"""列出名字以 prefix 开头的容器(默认 codex-),失败返回 []。
@@ -901,7 +984,9 @@ class DockerExecutor(Executor):
network=network,
extra_env=extra_env)
try:
result = self._call_runner(cmd, timeout=timeout)
result = self._call_runner(
cmd, timeout=timeout,
env=self._docker_cli_env(extra_env))
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
self.kill_container(task)
@@ -926,7 +1011,7 @@ class DockerExecutor(Executor):
def dispatch_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None) -> dict:
seed_host_dir=None, model_provider=None) -> dict:
"""容器内调 host.py dispatch:建 host 任务并跑一轮 turn。
seed_host_dir 非空时把暂存目录只读挂载进容器
@@ -941,7 +1026,8 @@ class DockerExecutor(Executor):
"dispatch", "--name", self._host_name(task),
"--task", task["prompt"], model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"),
sandbox=("read-only" if task.get("plan") else None))
sandbox=("read-only" if task.get("plan") else None),
model_provider=model_provider)
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
@@ -972,7 +1058,8 @@ class DockerExecutor(Executor):
extra_env=extra_env)
def run_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None) -> dict:
network=None, extra_env=None,
model_provider=None) -> dict:
"""容器内调 host.py run:在已有 host 任务上再跑一轮 turn。"""
if not isinstance(task, dict) or task.get("id") is None:
raise ValueError("任务缺少 id")
@@ -982,7 +1069,8 @@ class DockerExecutor(Executor):
"run", "--task", self._host_name(task),
"--prompt", task["prompt"], model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"),
sandbox=("read-only" if task.get("plan") else None))
sandbox=("read-only" if task.get("plan") else None),
model_provider=model_provider)
return self._run_in_container(task, inner, timeout=timeout,
extra_mounts=extra_mounts,
network=network,
@@ -990,7 +1078,7 @@ class DockerExecutor(Executor):
def goal_task(self, task, timeout=None, extra_mounts=None,
network=None, extra_env=None,
seed_host_dir=None) -> dict:
seed_host_dir=None, model_provider=None) -> dict:
"""容器内调 host.py goal:目标循环(turn→验测→喂回重试)。
seed_host_dir 语义同 dispatch_task(只读挂载 + 内层
@@ -1020,7 +1108,8 @@ class DockerExecutor(Executor):
inner_args += ["--max-rounds", str(rounds)]
inner = self._inner_host_command(
*inner_args, model=task.get("model"),
effort=task.get("effort"), approval=task.get("approval"))
effort=task.get("effort"), approval=task.get("approval"),
model_provider=model_provider)
repo = task.get("repo")
if repo:
inner += ["--repo", repo]
@@ -1033,7 +1122,9 @@ class DockerExecutor(Executor):
network=network,
extra_env=extra_env)
try:
result = self._call_runner(cmd, timeout=timeout)
result = self._call_runner(
cmd, timeout=timeout,
env=self._docker_cli_env(extra_env))
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
self.kill_container(task)
@@ -1092,7 +1183,7 @@ class DockerExecutor(Executor):
def goal_resume_task(self, task, goal_text, timeout=None,
extra_mounts=None, network=None,
extra_env=None) -> dict:
extra_env=None, model_provider=None) -> dict:
"""容器内调 host.py goal-resume --goal-text:在旧线程上开
新目标循环(旧任务接续 goal 模式)。解析与 goal_task 同款
(完整 stdout 结论、存库截尾),但没有“已存在”分支:
@@ -1107,13 +1198,16 @@ class DockerExecutor(Executor):
"goal-resume", "--task", self._host_name(task),
"--goal-text", goal_text, "--max-rounds", "0",
model=task.get("model"), effort=task.get("effort"),
approval=task.get("approval"))
approval=task.get("approval"),
model_provider=model_provider)
cmd = self.build_run_command(task, inner,
extra_mounts=extra_mounts,
network=network,
extra_env=extra_env)
try:
result = self._call_runner(cmd, timeout=timeout)
result = self._call_runner(
cmd, timeout=timeout,
env=self._docker_cli_env(extra_env))
except Exception as exc:
if exc.__class__.__name__ == "TimeoutExpired":
self.kill_container(task)