fix(sources): implement PUT and stop partial edits from clobbering api_key

Two defects on the admin source write path, both found while adding a model
to a live source by hand.

PUT /api/sources/{name} was advertised in the API index but never
implemented — handleSourcesAPI only switched on GET/POST/DELETE, so the
documented update verb answered 405 while the POST upsert behind it worked.

POST is an upsert that replaces the whole source, so a partial edit that did
not carry api_key persisted an empty or placeholder credential. The source
kept its name, base_url and models, the write returned 200, and the source
then answered 401 on the next request — long after the writing script exited
0. The WebUI had been routing around this by loading the real key through
?reveal=credentials; any script or partial update went straight into it.

- implement PUT, taking the name from the path and rejecting a body name
  that disagrees rather than silently resolving to one of them
- inherit the stored credential when api_key is omitted or sent as the
  literal "__KEEP__"; an explicit new key still rotates
- an empty api_key on a source that does not exist yet stays empty, since
  credential-less local upstreams are legitimate
- add model_ids, an additive shorthand, so "add these models" never has to
  read and echo the existing list back
- align the API index with the implementation

The model_ids merge had a first cut that dropped the existing list when the
request carried no models field; TestSourceModelIDsIsAdditive caught it.

Verified by mutation: removing PUT turns three tests red, flattening
resolveAPIKey into a pass-through turns TestSourceUpsertKeepsAPIKey red
on both subtests, and making model_ids replace instead of merge turns
TestSourceModelIDsIsAdditive red.
This commit is contained in:
JianFeeeee
2026-10-01 18:15:29 +08:00
parent 504c5ac9a0
commit bf0657bb84
3 changed files with 365 additions and 6 deletions

View File

@ -165,7 +165,7 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
{Method: "POST", Path: "/api/sources", Auth: "admin", Summary: "add or replace a source",
WriteEffect: "writes config.yaml (api_key sealed at rest)"},
{Method: "PUT", Path: "/api/sources/{name}", Auth: "admin", Summary: "update one source",
WriteEffect: "writes config.yaml"},
WriteEffect: "writes config.yaml (api_key sealed at rest)"},
{Method: "DELETE", Path: "/api/sources/{name}", Auth: "admin", Summary: "delete a source",
WriteEffect: "writes config.yaml"},
@ -187,9 +187,13 @@ func (g *Gateway) apiV1Index(w http.ResponseWriter, r *http.Request) {
{Method: "POST", Path: "/v1/chat/completions", Auth: "any", Summary: "OpenAI-compatible inference"},
},
"conventions": map[string]interface{}{
"errors": "{ \"error\": { \"type\": <code>, \"message\": <text> } }",
"path_escape": "URL-encode source and key names; {name} is a single path segment",
"idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name",
"errors": "{ \"error\": { \"type\": <code>, \"message\": <text> } }",
"path_escape": "URL-encode source and key names; {name} is a single path segment",
"idempotency": "POST /api/sources and PUT /api/sources/{name} both upsert by name; " +
"PUT takes the name from the path and rejects a body name that disagrees",
"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",
"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 " +