You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 48dac17
Browse filesBrowse the repository at this point in the historyBrowse files
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>
Copy file name to clipboardExpand all lines: docs/webui.md
+56-2Lines changed: 56 additions & 2 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -906,11 +906,14 @@ deep-link — opens the desktop's modal instead of appending a rail draft:
906
906
907
907
| Dialog region | Contract |
908
908
| --- | --- |
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`|
910
912
| 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 |
911
913
| 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) |
912
914
| 「+ 添加」 / 「自动获取」 | 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 |
914
917
915
918
Ticket 54 invariants — no server-contract change (the `/api/providers` PUT
916
919
body, `/api/set-model`, and every endpoint are untouched; the whole delta is
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 |
|`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
+
994
1048
**Ticket 53 invariants** — no server-contract change (the delta:
995
1049
`panels.tsx` / `usage-models-cards.tsx` / `icons.tsx` / `i18n.ts` plus two
996
1050
test files); the h2 header, the per-tab fade-in and the 760px page width are
0 commit comments