Skip to content

Commit 48dac17

Browse files
fengzhi09s39-dev
andauthored
feat(webui): the add-provider dialog can carry custom headers (#127)
* feat(webui): the add-provider dialog can carry custom headers The desktop dialog has a "Custom headers" section and ours did not. The runtime already merged options.headers into every upstream request; the webui was the only layer dropping them, so a header typed into the dialog never reached the provider. Folds the name/value rows in one place, validates rather than sanitises — a CRLF header rejects the whole PUT by name instead of being quietly stripped, because a silently stripped header looks applied and never arrives — stores it, returns it verbatim and projects it into the engine. It is not redacted: the API key is injected by the server and must be hidden, whereas a header is routing the operator typed and has to be readable to be editable. Two bugs only the end-to-end probe caught. An empty headers object is truthy, so "omit when empty" never fired and every provider without headers got a stray {}; and the providers route rebuilt auth field by field, which threw the headers away — the dialog sent them, the route dropped them, and the probe was the only place it showed. * docs(webui): the three desktop fields the add-provider dialog was missing API 格式, 自定义 Headers and the form-level 连通检测 / 跳过连通检测 pair, recorded in both languages at equal weight, along with the correction that adding was already dialog-based on this branch. 自定义 Headers is the only one of the three that touched a contract, so it carries the reasoning the other two do not need: the five layers it travels (dialog fold, PUT, GET, engine projection, probe), why an operator-authored header comes back unmasked while the server-substituted API key stays masked, why a malformed header rejects the whole PUT by name rather than being stripped, and the probe's asymmetry — operator headers spread first so the protocol's own required headers still win. Also states what is deliberately still absent: the 模型 01 sub-card is a screenshot-only shape, newer than the reference implementation, with no second source to check it against. --------- Co-authored-by: s39-dev <s39-dev@local>
1 parent b6f2ab4 commit 48dac17

14 files changed

Lines changed: 1648 additions & 108 deletions

‎docs/webui.md‎

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -906,11 +906,14 @@ deep-link — opens the desktop's modal instead of appending a rail draft:
906906

907907
| Dialog region | Contract |
908908
| --- | --- |
909-
| Provider select (「请选择提供商」) | Options are `GET /api/providers/presets` plus a 「+ 其他(自定义)」 sentinel; choosing a preset fills id / label / protocol / baseURL / auth-type, choosing the sentinel expands the custom fields (id, display name, protocol, auth type, baseURL). DeepSeek / Zhipu AI(智谱)/ Moonshot AI (China) carry the reference's spellings; other local presets keep their catalogue labels. A 404 catalogue degrades to the custom-only dropdown |
909+
| Provider select (「请选择提供商」) | Options are `GET /api/providers/presets` plus a 「+ 其他(自定义)」 sentinel; choosing a preset fills id / label / auth-type / baseURL and seeds 「API 格式」, choosing the sentinel expands the custom fields (id, display name, auth type, baseURL). DeepSeek / Zhipu AI(智谱)/ Moonshot AI (China) carry the reference's spellings; other local presets keep their catalogue labels. A 404 catalogue degrades to the custom-only dropdown |
910+
| API 格式 | The desktop's **second** field, rendered for every provider rather than only for 「其他(自定义)」. It is the existing wire `protocol` under the desktop's labels — `OpenAI Completions` / `Anthropic Messages` / `Gemini` — so no new format reaches the backend. Choosing a preset seeds it from that preset's own protocol and it stays editable afterwards. The protocol select that used to sit inside the custom branch was removed rather than kept alongside: two controls bound to one value is how the preset and custom branches end up disagreeing about what gets saved |
911+
| 自定义 Headers | Rows of (name, value) with 「+ 添加 Header」 and a per-row remove, held as a **list** rather than an object so a half-typed row survives editing. A blank name is dropped, a name is trimmed but a value is not, and a later duplicate wins — all three decided in one place (`headerPairsToRecord`), so the dialog, the PUT body and the server cannot disagree. Zero rows render an explicit placeholder rather than collapsing. The collapse result lands in `auth.headers` on the PUT body and comes back in `auth.headers` on `GET /api/providers` |
910912
| API key (`AntInput.Password`) | The eye toggle is safe here and only here: the field's value is what the user just typed, not a masked placeholder — the editor's no-reveal rule (keep-existing-key convention) is untouched |
911913
| Model entries (「模型 01…」 + connectivity test + ↻ reset + 🗑 delete) | Five fields: name → `id`, context window → `contextLimit`, max output tokens → **disabled with the 「本地版不适用」 marker** (the `/api/providers` PUT contract has no field to persist it), reasoning levels → `thinkingLevels` fed from `THINKING_LEVELS` (the low/medium/high contract is frozen; the reference's 「max」 placeholder example is deliberately not copied), attachments → four checkboxes 图片/PDF/视频/音频 mapping to `image`/`file`/`video`/`audio` (`file` joined `MODALITIES`; `text` passes through untouched) |
912914
| 「+ 添加」 / 「自动获取」 | Add appends a blank entry; auto-fetch opens the 「已获取模型」 dialog listing the **selected preset's built-in catalogue** with the note that it is not a live per-key query — the local backend has no model-listing proxy. With no preset selected the dialog states the missing capability instead of inventing rows. 「全选(n/N)」 + 取消/添加 follow the reference; picked entries arrive with their catalogue metadata. Both actions carry tooltips spelling out the split (manual entry vs catalogue pick; auto-fetch reads the list only and saves nothing); with zero entries the models section renders a dashed placeholder naming both paths instead of collapsing |
913-
| 取消 / 保存 | Save validates (provider chosen, unique id, per-entry `validateModelRow`), appends the draft to the panel's list, and PUTs through the **unchanged** `draftToWire` + `api.putProviders({version: 2})` path; on failure the dialog stays open with the typed input intact. The pair sits in a dedicated footer region (hairline separator + 16px clearance) at the h-9 control height, the black primary carrying the token shadow |
915+
| 跳过连通检测 / 连通检测 | The desktop's **form-level** check, on the left of the footer bar. It reuses the existing `POST /api/providers/test` contract with the live form values (protocol, key, baseURL **and the custom headers**, so the probe exercises the request that will actually be sent) and records one verdict, rendered as 「可达 · Nms」 / 「不可达:错误」. This is a different scope from the per-entry 检测 on each model card — that one asks whether a model id responds, this one asks whether the provider is reachable at all — so both exist. Editing any probed input (provider, API 格式, key, baseURL, auth type, any header row) drops the verdict, because a verdict that survived an edit is a pass for a request the provider will never see. Header values are re-validated server-side on this path too, since the test endpoint takes `auth` straight from the body without the PUT normaliser |
916+
| 取消 / 保存 | Save validates (provider chosen, unique id, per-entry `validateModelRow`), appends the draft to the panel's list, and PUTs through the **unchanged** `draftToWire` + `api.putProviders({version: 2})` path; on failure the dialog stays open with the typed input intact. The pair sits in a dedicated footer region (hairline separator + 16px clearance) at the h-9 control height, the black primary carrying the token shadow. 保存 is **disabled until the form-level check passes**, matching the reference footer and the greyed 保存 in the reference screenshot; 跳过连通检测 is the escape hatch for an operator who cannot reach the endpoint, and a line states which of the two is blocking. A disabled control with a stated reason and two ways to lift it is not a dead button |
914917

915918
Ticket 54 invariants — no server-contract change (the `/api/providers` PUT
916919
body, `/api/set-model`, and every endpoint are untouched; the whole delta is
@@ -991,6 +994,57 @@ reference `design-ref/screenshots/byok-custom-model-official.png`):
991994
live per-key query), which the fetch dialog already states
992995
honestly; no behaviour change was needed.
993996

997+
**Ticket 85 — the three desktop fields the local dialog was missing**
998+
(`API 格式`, `自定义 Headers`, footer `连通检测` / `跳过连通检测`)
999+
1000+
The add surface was **already** dialog-based on this branch — that part of
1001+
the request was a no-op, and no work was spent re-doing it. What was
1002+
genuinely missing is below; the per-model-entry 检测 from ticket 56 and
1003+
the page-inline **editing** flow are both untouched.
1004+
1005+
`自定义 Headers` is the only one of the three that needed a contract
1006+
change, so it is the one worth reading closely:
1007+
1008+
| Layer | What it does |
1009+
| --- | --- |
1010+
| Dialog | Rows of (name, value). Held as a list, collapsed by `headerPairsToRecord` (blank names dropped, names trimmed, values not, later duplicate wins) |
1011+
| `PUT /api/providers` | New **optional** field `providers[].auth.headers: Record<string,string>`. A body that omits it is byte-identical to the pre-ticket body and a stored provider without it loads to `{}` — the field is additive, not a migration |
1012+
| `GET /api/providers` | Returns `auth.headers` **verbatim, unmasked** |
1013+
| `engine-provider-sync` | Copies it to the engine's `options.headers`, omitted when empty. This is the load-bearing link: `local-runtime-v2` already merges `options.headers` into every upstream request for the provider (`catalog/provider-views.ts:218`), so no runtime change was needed |
1014+
| `POST /api/providers/test` | Carries the headers into the probe, re-validated through the same grammar because this route takes `auth` from the body without the PUT normaliser |
1015+
1016+
**Why headers are not masked, when `apiKey` is.** `apiKey` is masked
1017+
because the server substitutes it on the operator's behalf — the operator
1018+
never needs to read it back. A custom header is routing or tenant
1019+
configuration the operator typed and must be able to edit, so masking it
1020+
would create a write-only field. An operator who treats a header *value*
1021+
as a secret has no way to express that here; the honest statement is that
1022+
this field is not a place to keep credentials. The API Key field remains
1023+
the only masked one.
1024+
1025+
**Validation is a reject, not a strip.** Names must match the RFC 9110
1026+
token grammar and values may not carry CR, LF or NUL; a record that fails
1027+
rejects the whole PUT with an error naming the provider and the header.
1028+
Silently stripping the character would leave the operator believing a
1029+
header is in effect when the upstream never received it intact. Ceilings:
1030+
20 headers, 128-char names, 4096-char values.
1031+
1032+
**Probe asymmetry, stated.** The connectivity probe spreads operator
1033+
headers FIRST, so the protocol's own required headers (`Content-Type`,
1034+
`anthropic-version`, `Accept`) overwrite them. A probe answers "can I
1035+
reach this provider", not "replay my headers exactly"; letting a
1036+
mistyped `Content-Type` break the probe would make it answer a question
1037+
the operator did not ask. The production request path has no such
1038+
restriction.
1039+
1040+
**Still not done** (deliberately, for the next batch): the desktop's
1041+
「模型 01」 nested sub-card with 模型名称 / 上下文窗口 / 最大输出 Token is
1042+
a **screenshot-only** shape — the reference implementation carries a
1043+
single 模型名称 textarea instead, so the desktop is newer than the
1044+
reference and there is no second source to check it against. Rebuilding
1045+
the model-entry structure is a larger change than this batch and is left
1046+
alone.
1047+
9941048
**Ticket 53 invariants** — no server-contract change (the delta:
9951049
`panels.tsx` / `usage-models-cards.tsx` / `icons.tsx` / `i18n.ts` plus two
9961050
test files); the h2 header, the per-tab fade-in and the 760px page width are

0 commit comments

Comments
 (0)