Seed the four-model setup on later service starts

Why: The dev service is an rdm build, so startup skipped model seeding and kept the old MiniMax chat model. Later starts need the saved env file applied even without an admin key command.

Evidence: seed and rdm launcher tests passed. The live dev database now uses the four-model chat route and keeps the original MiniMax endpoint as reserve.

Impact: Restart of a service with envfile set rewrites system model settings. HTTP seeding is unchanged for builds that can mint an admin key. MiniMax is stored separately because fallbacks cannot switch gateways.

Refs: .task/feature-four-model-vision.md
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-15 04:14:22 +00:00
co-authored by Cursor
parent e568fef5f1
commit 8e5b44bc2c
2 changed files with 125 additions and 25 deletions
+2
View File
@@ -793,6 +793,8 @@ scripts/svc.sh prod deploy --env .env.prod # 等价
| `SVC_LIGHT_BASE/KEY/NAME` | 轻量模型 |
| `SVC_VISION_MODEL` | 视觉兜底模型(播种 `settings.models.vision.model`) |
| `SVC_VISION_CAPABLE` | 可看图模型清单(逗号分隔,播种 `settings.models.vision.capable`) |
| `SVC_MINIMAX_BASE/KEY` | 原 MiniMax 端点(播种 `settings.models.reserve.minimax`,不替换当前对话模型) |
| `SVC_MINIMAX_CHAT/FAST` | 原 MiniMax 对话模型与快速模型名 |
| `SVC_TAVILY_KEYS` | Tavily 密钥池(逗号分隔) |
### 9.4 持久化语义
+123 -25
View File
@@ -528,6 +528,8 @@ dotenv 格式:KEY=VALUE,支持 # 注释、成对引号、export 前缀;只
SVC_LIGHT_BASE/KEY/NAME 轻量模型
SVC_VISION_MODEL 视觉兜底模型(播种 models.vision.model)
SVC_VISION_CAPABLE 可看图模型清单(逗号分隔,播种 models.vision.capable)
SVC_MINIMAX_BASE/KEY 原 MiniMax 端点(播种 models.reserve.minimax,不作为当前对话模型)
SVC_MINIMAX_CHAT/FAST 原 MiniMax 对话模型与快速模型名
SVC_TAVILY_KEYS Tavily 密钥池
SVC_SEED_OWNER 播种目标账号(缺省取库内首个管理员)
播种幂等:设置持久化在 agent 数据目录,播过一次后续启动无需再传。
@@ -681,6 +683,39 @@ load_env_file() {
done < "$f"
}
# load_env_file_if_unset FILE:只填当前为空的变量,不覆盖调用方已经传入的环境。
load_env_file_if_unset() {
local f="$1" line k v
[ -f "$f" ] || return 0
while IFS= read -r line || [ -n "$line" ]; do
line="${line%$'\r'}"
case "$line" in ''|'#'*) continue ;; esac
[[ "$line" =~ ^[[:space:]]*(export[[:space:]]+)?[A-Za-z_][A-Za-z0-9_]*= ]] || continue
k="${line%%=*}"
k="${k#"${k%%[![:space:]]*}"}"
k="${k#export}"
k="${k#"${k%%[![:space:]]*}"}"
[ -n "${!k:-}" ] && continue
v="${line#*=}"
v="${v#"${v%%[![:space:]]*}"}"
v="${v%\"}"; v="${v%\'}"; v="${v#\"}"; v="${v#\'}"
printf -v "$k" '%s' "$v"
done < "$f"
}
# load_saved_envfile:svc.env 记住的 envfile= 在播种前补进空变量。
load_saved_envfile() {
local f
[ -n "${SVC_ENVFILE:-}" ] && return 0
f="$(svc_get envfile)"
[ -n "$f" ] || return 0
case "$f" in
/*) ;;
*) f="$ROOT/$f" ;;
esac
load_env_file_if_unset "$f"
}
# ---- 命令行参数(覆盖层)----
CLI_ADDR=""; CLI_HOST=""; CLI_PORT=""; CLI_DATA=""; CLI_DSN=""
CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N=""
@@ -854,7 +889,7 @@ svc_merge_tags() {
# agent 服务经统一解析启动后按生效态写 db=(pg/mock;显式 sqlite)与 dbref=/dsn=
# (托管绑定不重复存 DSN,外部直连存受保护 dsn=);未走解析的调用保持旧值。
record_state() {
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn merged_tags
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_envfile merged_tags
pid="$(read_pid)"
prev_src=""
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
@@ -862,6 +897,7 @@ record_state() {
prev_conffile="$(svc_get conffile)"
prev_db="$(svc_get db)"
prev_dsn="$(svc_get dsn)"
prev_envfile="$(svc_get envfile)"
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
merged_tags="$(svc_merge_tags)"
local db_out="" dbref_out="" dsn_out=""
@@ -899,6 +935,11 @@ record_state() {
[ -n "$dbref_out" ] && printf 'dbref=%s\n' "$dbref_out"
[ -n "$dsn_out" ] && printf 'dsn=%s\n' "$dsn_out"
[ -n "$prev_conffile" ] && printf 'conffile=%s\n' "$prev_conffile"
if [ -n "${SVC_ENVFILE:-}" ]; then
printf 'envfile=%s\n' "$SVC_ENVFILE"
elif [ -n "$prev_envfile" ]; then
printf 'envfile=%s\n' "$prev_envfile"
fi
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$tmp"
chmod 600 "$tmp"
@@ -2823,26 +2864,18 @@ print_agent_plan() {
return 0
}
# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入系统设置(幂等)。
# P2 受管(提案 2026-10-13 rdm-settings-members-duties-perms-increment):models.*
# 与 tools.tavily.* 是系统受管项,user 层写入整笔拒绝,播种改走系统管理面
# PUT /api/settings/system。认证开启(PG 档默认)时该面要求管理员凭证:自动铸一把
# 临时 API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员
#(系统面必须 isAdmin,不再回落普通账号),--seed-owner / SVC_SEED_OWNER 可指定。
# 铸 key 失败维持无头 PUT(仅无认证单机档可成)。
seed_settings() {
local host="$1" port="$2"
# model_seed_payload:把当前环境里的模型配置打成系统设置 JSON。没有可写项时输出空。
model_seed_payload() {
local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}"
local lb="${CLI_LBASE:-${SVC_LIGHT_BASE:-}}" lk="${CLI_LKEY:-${SVC_LIGHT_KEY:-}}" ln="${CLI_LNAME:-${SVC_LIGHT_NAME:-}}"
local tk="${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}"
local fb="${SVC_MODEL_FALLBACKS:-}" vm="${SVC_VISION_MODEL:-}" vc="${SVC_VISION_CAPABLE:-}"
[ -z "$cb$ck$cn$lb$lk$ln$tk$fb$vm$vc" ] && return 0
command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
local payload
payload="$(python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" "$fb" "$vm" "$vc" <<'PY'
local mb="${SVC_MINIMAX_BASE:-}" mk="${SVC_MINIMAX_KEY:-}" mc="${SVC_MINIMAX_CHAT:-}" mf="${SVC_MINIMAX_FAST:-}"
[ -z "$cb$ck$cn$lb$lk$ln$tk$fb$vm$vc$mb$mk$mc$mf" ] && return 0
command -v python3 >/dev/null 2>&1 || return 1
python3 - "$cb" "$ck" "$cn" "$lb" "$lk" "$ln" "$tk" "$fb" "$vm" "$vc" "$mb" "$mk" "$mc" "$mf" <<'PY'
import json, sys
cb, ck, cn, lb, lk, ln, tk, fb, vm, vc = sys.argv[1:11]
out = {}
cb, ck, cn, lb, lk, ln, tk, fb, vm, vc, mb, mk, mc, mf = sys.argv[1:15]
def sub(base, key, name):
d = {}
if base: d["baseUrl"] = base
@@ -2859,11 +2892,33 @@ vision = {}
if vm: vision["model"] = vm
if vc: vision["capable"] = vc
if vision: models["vision"] = vision
reserve = {}
if mb: reserve["baseUrl"] = mb
if mk: reserve["apiKey"] = mk
if mc: reserve["chat"] = mc
if mf: reserve["fast"] = mf
if reserve:
models["reserve"] = {"minimax": reserve}
out = {}
if models: out["models"] = models
if tk: out["tools"] = {"tavily": {"apiKeys": tk}}
print(json.dumps(out))
PY
)"
}
# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入系统设置(幂等)。
# P2 受管(提案 2026-10-13 rdm-settings-members-duties-perms-increment):models.*
# 与 tools.tavily.* 是系统受管项,user 层写入整笔拒绝,播种改走系统管理面
# PUT /api/settings/system。认证开启(PG 档默认)时该面要求管理员凭证:自动铸一把
# 临时 API key(name=svc-seed)完成写入后立即回收;owner 缺省取库内首个管理员
#(系统面必须 isAdmin,不再回落普通账号),--seed-owner / SVC_SEED_OWNER 可指定。
# 铸 key 失败维持无头 PUT(仅无认证单机档可成)。
seed_settings() {
local host="$1" port="$2"
local payload
payload="$(model_seed_payload)" || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
[ -n "$payload" ] && [ "$payload" != "{}" ] || return 0
command -v python3 >/dev/null 2>&1 || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
# 认证开启时的播种凭证:临时 key,明文只存在于本函数局部变量,不回显、不落盘。
# apikey 子命令不读环境 DSN,只能经 CLI 传递(进程列表短暂可见;serve 走
# AGENT_SYSTEM_DSN 环境注入,此路对 CLI 子命令不可用)。
@@ -2901,17 +2956,59 @@ for u in users:
fi
if echo "$resp" | grep -q '"ok": *true'; then
local did=""
[ -n "$cb$ck$cn" ] && did="主模型"
[ -n "$lb$lk$ln" ] && did="${did:+$did、}轻量模型"
[ -n "$fb" ] && did="${did:+$did、}回退链"
[ -n "$vm$vc" ] && did="${did:+$did、}视觉模型"
[ -n "$tk" ] && did="${did:+$did、}Tavily"
[ -n "${CLI_MBASE:-${SVC_MODEL_BASE:-}}${CLI_MKEY:-${SVC_MODEL_KEY:-}}${CLI_MNAME:-${SVC_MODEL_NAME:-}}" ] && did="主模型"
[ -n "${CLI_LBASE:-${SVC_LIGHT_BASE:-}}${CLI_LKEY:-${SVC_LIGHT_KEY:-}}${CLI_LNAME:-${SVC_LIGHT_NAME:-}}" ] && did="${did:+$did、}轻量模型"
[ -n "${SVC_MODEL_FALLBACKS:-}" ] && did="${did:+$did、}回退链"
[ -n "${SVC_VISION_MODEL:-}${SVC_VISION_CAPABLE:-}" ] && did="${did:+$did、}视觉模型"
[ -n "${SVC_MINIMAX_BASE:-}${SVC_MINIMAX_KEY:-}${SVC_MINIMAX_CHAT:-}${SVC_MINIMAX_FAST:-}" ] && did="${did:+$did、}MiniMax 备用"
[ -n "${CLI_TAVILY:-${SVC_TAVILY_KEYS:-}}" ] && did="${did:+$did、}Tavily"
c_ok "[$ENV_NAME] 已播种设置:$did(持久化于 $ENV_DATA,后续启动无需再传)"
else
c_warn "[$ENV_NAME] 设置播种未成功:${resp:-<无响应>}(认证开启时需库内已有账号以铸临时密钥,或用 --seed-owner 指定;也可在设置页手动配置)"
fi
}
# seed_settings_db:rdm 产物不能铸 API key 时,把同一份配置合并进嵌入式系统设置。
# 库还不存在时不创建,避免在空数据目录里写出半套表。
seed_settings_db() {
local payload db
payload="$(model_seed_payload)" || { c_warn "[$ENV_NAME] 跳过配置播种:需要 python3 构造请求"; return 0; }
[ -n "$payload" ] && [ "$payload" != "{}" ] || return 0
db="${ENV_DATA:-}/agent-system.db"
[ -f "$db" ] || return 0
if python3 - "$db" "$payload" <<'PY'
import json, sqlite3, sys
db, payload = sys.argv[1], sys.argv[2]
incoming = json.loads(payload)
con = sqlite3.connect(db, timeout=10)
tables = {row[0] for row in con.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if "settings" not in tables:
raise SystemExit(2)
row = con.execute('SELECT "values" FROM settings WHERE owner=? AND scope=?', ("", "system")).fetchone()
current = json.loads(row[0]) if row else {}
def merge(dst, src):
for key, value in src.items():
if isinstance(value, dict) and isinstance(dst.get(key), dict):
merge(dst[key], value)
else:
dst[key] = value
return dst
blob = json.dumps(merge(current, incoming), ensure_ascii=False, separators=(",", ":"))
if row:
con.execute('UPDATE settings SET "values"=? WHERE owner=? AND scope=?', (blob, "", "system"))
else:
con.execute('INSERT INTO settings(owner, scope, "values") VALUES(?,?,?)', ("", "system", blob))
con.commit()
PY
then
c_ok "[$ENV_NAME] 已把模型配置写入数据目录(rdm 产物无播种命令,直接更新系统设置)"
else
c_warn "[$ENV_NAME] 数据目录中的模型配置未写上"
fi
}
# dsn_from_dbref DBREF:由 dbref(pg实例/库名)从该实例登记重建连接串。
# 实例未登记 / 非 postgres / 缺 addr·user·password 时返回非零且不输出。
dsn_from_dbref() {
@@ -3330,10 +3427,11 @@ agent_do_start() {
svc_mark_autostart
c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)"
hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}"
# rdm 组合产物无 user/apikey CLI 子命令:设置播种(--model-*/--tavily-keys)仅
# 基座产物支持;记录态 svc.env 的 source=rdm@* 跨裸 start/restart 稳定标识形态。
load_saved_envfile
# rdm 组合产物没有 user/apikey 子命令,不能铸临时管理员密钥走 HTTP。
# 嵌入式库改为直接合并系统设置,避免重启后回到旧模型。
case "${SVC_SOURCE:-}" in
rdm@*) ;;
rdm@*) seed_settings_db ;;
*) seed_settings "$phost" "${probe##*:}" ;;
esac
return 0