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

+176 -12
View File
@@ -147,6 +147,10 @@ from cloud.models import ModelRouter, is_fallbackable, record_fallback
from cloud.peer_messages import deliver_peer_message as _deliver_peer_message
from cloud.peer_messages import peer_message_prefix as _peer_prefix
from cloud.peer_messages import row_get as _row_get
from cloud.sub2api import ensure_user_gateway_key as _ensure_sub2api_key
from cloud.sub2api import gateway_name as _gateway_name
from cloud.sub2api import gateway_origin as _gateway_origin
from cloud.sub2api import list_gateway_models as _list_gateway_models
from cloud.pr import PrSkipped, PrStepError
from cloud.pr_exec import (claim_pr_job, execute_task_pr, finish_pr_job)
from cloud.queue import budget_exceeded, ready_tasks
@@ -209,6 +213,18 @@ def resolve_env_key(env_key=None, codex_config_path=None):
return DEFAULT_ENV_KEY
def _merge_env(base, override):
"""两份 env 合并:override 优先;任一为空时返回另一份原样
(保持旧调用在双 None 时传 None,不改执行器行为)。"""
if not base:
return override
if not override:
return base
merged = dict(base)
merged.update(override)
return merged
def _resolve_model(model=None, config_path=None):
"""模型名:显式参数 > host 配置文件里的 model > host 默认值。"""
if model:
@@ -1119,6 +1135,93 @@ class Worker:
is _sandbox_default_runner
and hasattr(self.executor, "popen_task"))
def _gateway_context(self, task):
"""网关注入:xcmapi -> (None, None);sub2api -> (provider, env)。
sub2api 时按任务属主(tasks.created_by)幂等开通并取 key;
开通失败直接抛(任务失败,不跑错网关)。env 含
SUB2API_API_KEY、SUB2API_BASE_URL,并把网关 host 追加进
NO_PROXY/no_proxy(否则 codex 经 egress 代理访问内网网关
会被挡;proxy_env 的 NO_PROXY 会被这份 env 覆盖,所以这里
直接给超集 localhost,127.0.0.1,<网关 host>)。
"""
if _gateway_name() != "sub2api":
return None, None
origin = _gateway_origin()
if not origin:
raise RuntimeError("网关为 sub2api 但 SUB2API_BASE_URL 未配置")
owner = task.get("created_by")
if not owner and task.get("parent_task_id"):
# 协调员子任务:属主跟父任务走(子任务行可能无 created_by)。
try:
conn = _db_connect(self.db_path)
try:
row = conn.execute(
"SELECT created_by FROM tasks WHERE id = ?",
(task["parent_task_id"],)).fetchone()
finally:
conn.close()
if row is not None:
try:
owner = row[0]
except (TypeError, IndexError):
owner = None
except Exception:
owner = None
if not owner:
raise RuntimeError("任务缺少 created_by,无法解析网关属主")
conn = _db_connect(self.db_path)
try:
key = _ensure_sub2api_key(conn, owner)
finally:
conn.close()
host = origin.split("://", 1)[-1].split("/", 1)[0]
host = host.split(":", 1)[0]
no_proxy = "localhost,127.0.0.1"
if host and host not in ("localhost", "127.0.0.1"):
no_proxy += "," + host
env = {
"SUB2API_API_KEY": key,
"SUB2API_BASE_URL": origin,
"NO_PROXY": no_proxy,
"no_proxy": no_proxy,
}
return "sub2api", env
def _ensure_gateway(self, task):
"""任务 dict 上的网关缓存:已算过直接取,否则算一次并存回。
键是 _gateway_provider/_gateway_env(与 _resume 同款下划线
私有键约定,不进库)。run_one 算过后 _run_attempt 不再
重算;_run_fork 直达 _run_attempt 时在这里补算。
"""
if "_gateway_provider" in task:
return task["_gateway_provider"], task["_gateway_env"]
provider, env = self._gateway_context(task)
task["_gateway_provider"] = provider
task["_gateway_env"] = env
return provider, env
def _sub2api_default_model(self, gateway_env):
"""sub2api 默认模型:环境变量优先,否则取该用户网关模型首个。
网关模型目录因用户而异(key 绑定的组决定),worker 构造时
没有属主,只能到任务时现取;取不到(组里无模型)就 loud
fail,不让任务拿着 xcmapi 的模型名去撞 sub2api。
"""
raw = os.environ.get("CLOUD_SUB2API_DEFAULT_MODEL", "").strip()
if raw:
return raw
origin = _gateway_origin()
key = (gateway_env or {}).get("SUB2API_API_KEY")
if not origin or not key:
raise RuntimeError("sub2api 默认模型无法解析:缺少网关 key")
models = _list_gateway_models(origin, key)
if not models:
raise RuntimeError(
"sub2api 网关暂无可用模型(组里未挂上游账号?)")
return models[0]
def _task_seed_dir(self, task):
"""任务附件暂存目录(派发时 seed 用):无附件返回 None;
有附件行但目录已不在(如终局后已清理)也返回 None——
@@ -1175,6 +1278,7 @@ class Worker:
里)。Docker/同步路径没有这个轮询钩子,只走边界检查。
"""
task_id = task["id"]
gateway_provider, gateway_env = self._ensure_gateway(task)
if self.process_factory is not None:
proc = self.process_factory(task)
else:
@@ -1182,7 +1286,8 @@ class Worker:
# (文件已在 worktree 里);popen_task 内部同样只在
# dispatch/goal 命令上追加 --seed-dir。
proc = self.executor.popen_task(
task, kind=kind, seed_dir=seed_host_dir)
task, kind=kind, seed_dir=seed_host_dir,
extra_env=gateway_env, model_provider=gateway_provider)
self._running_set(task_id, proc=proc)
try:
self._set_child_pid(task_id, getattr(proc, "pid", None))
@@ -1899,6 +2004,7 @@ class Worker:
except (TypeError, ValueError):
effective_docker_timeout = self.task_timeout
try:
gateway_provider, gateway_env = self._ensure_gateway(task)
if self._use_popen():
if goal_mode and task.get("_resume"):
popen_kind = "goal-resume"
@@ -1944,42 +2050,60 @@ class Worker:
timeout=effective_docker_timeout,
extra_mounts=tenant_mounts,
network=tenant_network,
extra_env=proxy_env)
extra_env=_merge_env(proxy_env,
gateway_env),
model_provider=gateway_provider)
else:
result = self.executor.goal_task(
task, timeout=effective_docker_timeout,
extra_mounts=tenant_mounts,
network=tenant_network,
extra_env=proxy_env,
seed_host_dir=seed_host_dir)
extra_env=_merge_env(proxy_env,
gateway_env),
seed_host_dir=seed_host_dir,
model_provider=gateway_provider)
else:
result = self.executor.dispatch_task(
task, timeout=effective_docker_timeout,
extra_mounts=tenant_mounts,
network=tenant_network,
extra_env=proxy_env,
seed_host_dir=seed_host_dir)
extra_env=_merge_env(proxy_env, gateway_env),
seed_host_dir=seed_host_dir,
model_provider=gateway_provider)
else:
# seed 参数只传给 LocalExecutor:CloudExecutor
# 基类与测试假执行器的 goal_task/dispatch_task
# 只有 (task) 一个参数,多传 seed_dir 会直接
# TypeError(2026-10-27 实测炸掉 3 个旧测试);
# Docker 路径上面已有 is_docker 守卫,不走这里。
# 网关任务在非 Local 执行器上无法注入 key 与
# provider,必须 loud fail,不静默跑错网关。
if gateway_provider and not isinstance(
self.executor, LocalExecutor):
raise RuntimeError(
"sub2api 网关不支持该执行器:%s"
% type(self.executor).__name__)
if goal_mode:
if task.get("_resume") and isinstance(
self.executor, LocalExecutor):
result = self.executor.goal_resume_task(
task, task["prompt"])
task, task["prompt"],
extra_env=gateway_env,
model_provider=gateway_provider)
elif isinstance(self.executor, LocalExecutor):
result = self.executor.goal_task(
task, seed_dir=seed_host_dir)
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
else:
result = self.executor.goal_task(task)
else:
if isinstance(self.executor, LocalExecutor):
result = self.executor.dispatch_task(
task, seed_dir=seed_host_dir)
task, seed_dir=seed_host_dir,
extra_env=gateway_env,
model_provider=gateway_provider)
else:
result = self.executor.dispatch_task(task)
combined = (result.get("stdout", "") or "") + \
@@ -1993,10 +2117,21 @@ class Worker:
if self.tenant_isolation else None,
network=tenant_network
if self.tenant_isolation else None,
extra_env=proxy_env
if self.egress_proxy_port else None)
extra_env=_merge_env(
proxy_env if self.egress_proxy_port
else None, gateway_env),
model_provider=gateway_provider)
else:
result = self.executor.run_task(task)
if gateway_provider and not isinstance(
self.executor, LocalExecutor):
raise RuntimeError(
"sub2api 网关不支持该执行器:%s"
% type(self.executor).__name__)
result = self.executor.run_task(
task, extra_env=gateway_env,
model_provider=gateway_provider) \
if isinstance(self.executor, LocalExecutor) \
else self.executor.run_task(task)
except Exception as exc: # runner 异常也如实写回 failed
result = {"executor": "local", "task_id": task_id,
"status": "failed", "stdout": "", "returncode": None,
@@ -2445,6 +2580,35 @@ class Worker:
# 再兜底)。此前这里无条件写成 worker 主模型,导致控制
# 台选的模型被悄悄覆盖(任务级模型实际不生效);修掉。
# fallback 时下面再把 task["model"] 换成 fallback 模型。
# 网关门:sub2api 时先给属主开通网关账号(幂等)并解析默认
# 模型;失败直接 failed,不进执行器(避免跑错网关或拿着
# xcmapi 模型名去撞 sub2api)。xcmapi 下 _ensure_gateway
# 直接返回 (None, None),不做任何事。注意:这里改的是
# 原 task dict,随后 task = dict(task) 会原样拷走。
try:
gateway_provider, gateway_env = self._ensure_gateway(task)
if not task.get("model") and gateway_provider == "sub2api":
task["model"] = self._sub2api_default_model(gateway_env)
except Exception as exc:
error = "网关准备失败: %s" % exc
self._set_status(task_id, "failed")
self._write_execution_result(
task_id, error_text=error, actual_model=self.model,
started_at=started_at, finished_at=time.time())
self._audit(task["project_id"], "worker_gateway_failed",
"%s: %s" % (task_id, error))
log_event(self.logger, "worker_gateway_failed",
task_id=task_id, project_id=task["project_id"],
status="failed")
self._notify_task_finished(
task_id, task["project_id"], "failed", error=error)
return {"dispatched": True, "task_id": task_id,
"host_name": self.executor._host_name(task)
if hasattr(self.executor, "_host_name") else task_id,
"status": "failed", "executor": self._executor_name(),
"stdout": "", "error": error, "events": [],
"cost_record_id": None}
task = dict(task)
if not task.get("model"):
task["model"] = self.model