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:
1 parent
437684902a
commit
ec47328d3c
7 files changed
+996
-54
No files matched your search
+129
-35
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user