fix(sources): 源编辑不再清零 proxy_url / api_key_env / timeout

bf0657b 修好了 api_key,但 upsert 仍会重写整个源,于是请求无法表达的字段
一律被重置为零值。这四个字段的后果都不是"少个配置项":

  - api_key_env 丢失 ⇒ 盘上无明文密钥的源变成无凭据源,写操作返回 200,
    下一次调用上游才 401。而 README 恰恰把这个特性当作卖点在宣传。
  - proxy_url 丢失 ⇒ 一个走代理的上游变成直连(或反之),且完全无声。
  - timeout / queue_timeout 丢失 ⇒ 退回默认 120s / 60s。

触发路径不是只有脚本:WebUI 的 saveSource() 发的 payload 只含表单上的
11 个字段,而 editSource() 表单里根本没有这 4 项 ⇒ 运维在界面上改个并发数
就会静默清掉它们。

修法用「存在性」语义而不是「空即继承」:

  - 不传   → 保留已存值(部分更新的客户端要的就是这个)
  - 传了   → 覆盖,包括传空串表示清空

api_key 刻意保留它原有的「空即继承」规则,不跟着改成指针:该规则已随
v1.7.6 发布,脚本依赖它;而凭据丢失比代理丢失严重得多。两个字段的失败
模式相反,所以规则相反——这一条写进了两处注释。

另一处是差一点的:config.Source 把 Timeout/QueueTimeout 标成 json:"-",
所以 reveal 接口的结构体序列化**根本不返回它们**。表单读不到 → 输入框
恒空 → 而输入框每次都回传 → 每存一次就把 timeout 清零。等于把刚修好的
丢字段换个方向又造了一个。因此 reveal 分支现在显式返回 duration 字符串。

判据:
  - TestWebUIEditPayloadPreservesRoutingFields 用的是 WebUI 真实 payload
    的逐字节副本,并同时断言"确实改动的字段生效",否则"什么都不写"也能过
  - TestSourceEditPreservesAPIKeyEnv 单独盯 api_key_env(唯一造成凭据丢失的)
  - CanBeSet / CanBeCleared 分别盯两个方向:只有"空即继承"的实现过不了
    CanBeCleared(清空代理框会永远保留旧代理)
  - 持久化判据**重新加载 config.yaml 并按语义比对**:300s 会被重新序列化成
    5m0s,按字符串匹配是假红(我自己先踩了一次)
  - TestSourcePayloadCoversEveryEditableField 用反射卡住"这一类":新增
    Source 字段而没接到 API 上时立刻变红。反射查结构体而非 marshal 结果,
    因为指针 + omitempty 会合法地从序列化输出里消失,那正是"未提及"信号
  - 三个 UI 契约判据把 JS 侧也钉住(表单必须回传、必须从 reveal 读)

变异验证(每次都先确认 build 通过,再数红格):
  1. 去掉覆盖逻辑        → 7 个判据红
  2. 改成"空即继承"      → CanBeCleared + ClearIsScoped 红
  3. reveal 不返回 duration → TestSourceRevealExposesDurations 红
  4. 表单不回传 api_key_env → TestUIEditFormRoundTrips... 红

顺带修正 /api/v1 索引:DELETE /api/keys 的路径段写的是 {name},实际是 key
本身;PUT /api/keys/{key} 实现了却没列。

全量 + vet + race 全绿;WebUI 内联脚本过 node --check。
This commit is contained in:
JianFeeeee
2026-10-01 20:36:48 +08:00
parent 980f4a0e40
commit 18e422a943
5 changed files with 785 additions and 5 deletions

View File

@ -78,7 +78,9 @@ func (g *Gateway) apiV1Routes(w http.ResponseWriter, r *http.Request) {
"admin role required to reveal credentials")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"source": s})
// The edit form round-trips this response, so it needs the credential in the
// clear AND the durations — see maskSourceFields.
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSourceFields(s, true, true)})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"source": maskSource(s)})
@ -176,7 +178,11 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
{Method: "GET", Path: "/api/keys", Auth: "admin", Summary: "gateway keys"},
{Method: "POST", Path: "/api/keys", Auth: "admin", Summary: "create a gateway key",
WriteEffect: "writes config.yaml"},
{Method: "DELETE", Path: "/api/keys/{name}", Auth: "admin", Summary: "delete a gateway key",
{Method: "PUT", Path: "/api/keys/{key}", Auth: "admin",
Summary: "update a gateway key (name, role, model scopes and their per-model quotas)",
WriteEffect: "writes config.yaml"},
{Method: "DELETE", Path: "/api/keys/{key}", Auth: "admin",
Summary: "delete a gateway key — the path segment is the KEY itself, not its name",
WriteEffect: "writes config.yaml"},
{Method: "GET", Path: "/api/status", Auth: "any", Summary: "per-source health detail"},
@ -194,6 +200,14 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
"partial_update": "api_key may be omitted or sent as the literal \"__KEEP__\" to inherit the " +
"current credential; model_ids adds models to the existing list instead of replacing it, " +
"so a one-field edit never needs to read the source first",
"optional_fields": "proxy_url, api_key_env, timeout and queue_timeout use presence semantics: " +
"OMITTED from the request keeps the stored value, present (even as \"\") overwrites it. " +
"The upsert rewrites the whole source, so without this a one-field edit would silently reset " +
"them — and api_key_env in particular is only visible as a credential failure on the NEXT " +
"upstream call. api_key deliberately keeps the older \"empty means inherit\" rule instead, " +
"because losing a credential breaks the source while losing a proxy only changes its route.",
"durations": "timeout and queue_timeout are Go duration strings (\"300s\", \"5m\", \"1h\"); " +
"the empty string clears them back to the config defaults",
"config_truth": "all configuration lives in config.yaml; API writes are persisted immediately",
"credentials": "credentials are masked by default. GET /api/v1/sources/{name}?reveal=credentials " +
"returns them in the clear and is admin-only — the Web UI edit dialog uses it, because a form " +
@ -294,23 +308,48 @@ func maskSources(srcs []config.Source) []map[string]interface{} {
}
func maskSource(s config.Source) map[string]interface{} {
return map[string]interface{}{
return maskSourceFields(s, false, false)
}
// maskSourceFields builds the API view of a source.
//
// revealCredentials swaps the masked api_key for the real one — admin-only, and
// used by the WebUI edit dialog, whose form has to round-trip the whole source
// or saving an unrelated field would blank the key.
//
// exposeDurations adds the two durations as strings, matching what the write
// path accepts. It rides along with the credential reveal because both exist for
// the same reason: config.Source tags Timeout/QueueTimeout `json:"-"`, so a
// plain marshal of the struct omits them. A form that cannot SEE the stored
// timeout would clear it on every save, since the timeout box is always sent.
func maskSourceFields(s config.Source, revealCredentials, exposeDurations bool) map[string]interface{} {
key := maskKey(s.APIKey)
if revealCredentials {
key = s.APIKey
}
m := map[string]interface{}{
"name": s.Name,
"base_url": s.BaseURL,
"adapter": s.Adapter,
"endpoint": s.Endpoint,
"image_endpoint": s.ImageEndpoint,
"api_key": maskKey(s.APIKey),
"api_key": key,
"api_key_set": s.APIKey != "",
"models": s.Models,
"headers": maskHeaders(s.Headers),
"proxy_url": s.ProxyURL,
"api_key_env": s.APIKeyEnv,
"meta": s.Meta,
"temperature": s.Temperature,
"max_tokens": s.MaxTokens,
"max_concurrent": s.MaxConcurrent,
"rpm": s.RPM,
}
if exposeDurations {
m["timeout"] = s.Timeout.String()
m["queue_timeout"] = s.QueueTimeout.String()
}
return m
}
func maskKey(k string) string {