diff --git a/docs/specs/2026-09-20-web-settings-model-section-acceptance.md b/docs/specs/2026-09-20-web-settings-model-section-acceptance.md index d03391652..50947ace5 100644 --- a/docs/specs/2026-09-20-web-settings-model-section-acceptance.md +++ b/docs/specs/2026-09-20-web-settings-model-section-acceptance.md @@ -154,7 +154,7 @@ seeded key is sent). | T <- A | Host | Steps | Observe (owning layer) | Red when | Rings | * | |---|---|---|---|---|---|---| | T3.1 <- A7 | yes | Click, in turn, deepseek, moonshot, minimax_global, ollama_chat. | Each head shows the mark and the name; a link only when `options.json` carries `homepage` or `key_url` for that slug; the status line reads connected / needs an API key / needs authorisation / needs an address, matching `authenticated` and `auth_type` in the reply. | a status line disagreeing with the reply; a link for a provider the reply gives none | T E V R | | -| T3.2 <- A8 | yes | Moonshot: paste a key, press the eye, press it again, connect. Reopen the row; press the eye over the empty field, press "update", press the eye again. | `eval` on the key input's `type` reads `text` then `password`; after connect `jq .providers.moonshot.apiKey` equals the pasted string and the status reads connected; on reopen the field is empty with the "update" placeholder; the eye over it fills it with the stored key as `text`, carried by the one `model.reveal_key` answer to that press; "update" sends `model.save_key` without `api_key`; the second press empties the field and masks it; no other frame in `web.log` after the connect carries the stored key (T3.3 checks the address save). Amended 2026-10-09 (design C10): the stored key used to appear in no field at all. | the stored key appears in any frame other than that `model.reveal_key` answer, or in the field without the eye pressed; an unedited revealed key is sent back; the eye does not flip the type | T P E V R | | +| T3.2 <- A8 | yes | Moonshot: paste a key, press the eye, press it again, connect. Reopen the row; press the eye over the empty field, press "update", press the eye again. | `eval` on the key input's `type` reads `text` then `password`; after connect `jq .providers.moonshot.apiKey` equals the pasted string and the status reads connected; on reopen the field is empty with the "update" placeholder; the eye over it fills it with the stored key as `text`, carried by the one `model.reveal_key` answer to that press; "update" sends no frame and the pane says there is no new key to save; the second press empties the field and masks it; no other frame in `web.log` after the connect carries the stored key (T3.3 checks the address save). Amended 2026-10-09 (design C10): the stored key used to appear in no field at all. | the stored key appears in any frame other than that `model.reveal_key` answer, or in the field without the eye pressed; an unedited revealed key is sent back, or "update" over it sends any frame; the eye does not flip the type | T P E V R | | | T3.3 <- A9 | yes | From T3.2 (Moonshot connected). OpenRouter: the address field is in the body. Moonshot: open Advanced, set the address to `https://api.moonshot.cn/v1`, save; press reset. | `eval`: openrouter's address input is not inside the Advanced fold; moonshot's is. The save's frame is `model.set_fields` with no `api_key` -- the stored key never returns to the wire; after save `jq .providers.moonshot.apiBase` equals the address and `apiKey` is unchanged (`diff` shows one path); after reset `apiBase` is gone or equals the registry default and the button disappears. | the field in the wrong place; the key changed; reset writing something else | T P E V R | | | T3.4 <- A10 | yes | Azure OpenAI: key, address, deployment `gpt-4o-eu`, API version `2024-10-21`, connect. | `jq .providers.azure_openai` holds all four (prior T3.4's check); the two Azure fields sit under the address in the body. | any field missing | T E V R | | | T3.5 <- A11 | yes | MiniMax Global: read the pane; press "authorise in browser"; wait for the code. | Snapshot: the prototype's layout -- head, an authorisation section, no key field, no CLI instruction; after the press one `model.oauth_login` frame, and the device code and URL drawn in the pane (the flow starts without an account; only completing it needs one). | the CLI text appears; no code within the reply's `expires_in` | T P V R | | diff --git a/i18n/messages.json b/i18n/messages.json index 25850406d..dc098c89d 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -7559,6 +7559,10 @@ "en": "Enter the API key first", "zh": "先填入 API Key" }, + "gui.settings.providers.key_unchanged": { + "en": "No new API key to save. To replace the saved one, type a new key in the API key field, then press {button}.", + "zh": "没有新的 API Key 要保存。要更换,先在「API 密钥」框里填入新的,再点「{button}」。" + }, "gui.settings.providers.key_not_ascii": { "en": "This doesn't look like an API key: it has characters a key never contains. Check what was pasted", "zh": "这不像是 API Key:里面有 Key 不会包含的字符,请检查粘贴的内容" diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index cb9324c23..d4e85b9f4 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -2136,6 +2136,8 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.paste_key': 'Paste an API key', 'gui.settings.providers.pick_one': 'Choose a provider on the left', 'gui.settings.providers.key_first': 'Enter the API key first', + 'gui.settings.providers.key_unchanged': + 'No new API key to save. To replace the saved one, type a new key in the API key field, then press {button}.', 'gui.settings.providers.key_not_ascii': "This doesn't look like an API key: it has characters a key never contains. Check what was pasted", 'gui.settings.providers.base': 'Service address', @@ -4714,6 +4716,8 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.paste_key': '粘贴 API Key', 'gui.settings.providers.pick_one': '在左边选一家服务商', 'gui.settings.providers.key_first': '先填入 API Key', + 'gui.settings.providers.key_unchanged': + '没有新的 API Key 要保存。要更换,先在「API 密钥」框里填入新的,再点「{button}」。', 'gui.settings.providers.key_not_ascii': '这不像是 API Key:里面有 Key 不会包含的字符,请检查粘贴的内容', 'gui.settings.providers.base': '服务地址', 'gui.settings.providers.api_base': 'API 地址', diff --git a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx index 57d3ad77b..60c8fd900 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx @@ -42,6 +42,9 @@ async function open(slug: string): Promise { await act(async () => { store.set({ provider: slug }) }) } +/* What Update over a connected provider's key field says when it holds no new key. */ +const UNCHANGED = 'gui.settings.providers.key_unchanged {"button":"gui.settings.update"}' + /* The connection card's key field and the eye beside it. */ const keyField = (): HTMLInputElement => document.querySelector('.settings-tp-main input[aria-label="gui.settings.providers.api_key"]') as HTMLInputElement @@ -124,17 +127,29 @@ describe('the eye beside a connected provider\'s key', () => { fireEvent.click(keyField().closest('.settings-taglist')!.querySelector(':scope > button.mini')!) }], ['Enter', (): void => { fireEvent.keyDown(keyField(), { key: 'Enter' }) }], - ])('sends no key on %s while the revealed one is unedited: the stored key does not go back', async (_how, press) => { - /* What the empty field sends, so it is refused the way the empty field is, - rather than rewriting the config with the key it already holds. */ + ])('sends nothing on %s while the revealed key is unedited, and says there is no new key', async (_how, press) => { + /* The stored key does not go back, and nothing else would: the server + could only refuse an empty save, in English. */ const { calls } = install(snap(), { revealKey: answering('sk-or-saved') }) await open('openrouter') await act(async () => { fireEvent.click(keyEye()) }) await waitFor(() => expect(keyField().value).toBe('sk-or-saved')) await act(async () => { press() }) - expect(calls.filter(([name]) => name === 'provider').map(([, args]) => args)) - .toEqual([{ op: 'save_key', slug: 'openrouter' }]) + expect(calls.filter(([name]) => name === 'provider')).toEqual([]) + expect(screen.getByRole('alert').textContent).toBe(UNCHANGED) + expect(keyField().value).toBe('sk-or-saved') + }) + + it('sends nothing on Update over the empty field of a connected provider, and says why', async () => { + const { calls } = install(snap()) + await open('openrouter') + expect(keyField().value).toBe('') + await act(async () => { + fireEvent.click(keyField().closest('.settings-taglist')!.querySelector(':scope > button.mini')!) + }) + expect(calls.filter(([name]) => name === 'provider')).toEqual([]) + expect(screen.getByRole('alert').textContent).toBe(UNCHANGED) }) it('saves an edited revealed key as the new key', async () => { @@ -644,6 +659,73 @@ describe('provider detail, the address of a direct vendor', () => { }) }) +describe('an endpoint provider (custom, Azure) saved without a new key', () => { + /* `model.save_key` refuses custom, Azure and MiniMax CN without a key, so a + press with nothing new says so instead of sending a save that can only come + back in English -- and an address edited on its own goes the way any other + field does, through `model.set_fields`, with no key asked for again. */ + const withCustom = (on: boolean): ReturnType => { + const data = snap() + data.providers = [...data.providers, { id: 'custom', name: 'Custom', models: [], configured: [], on, kind: 'endpoint', acceptsKey: true, needsBase: true }] + ;(data.raw.providers as Record).custom = { apiBase: 'https://relay.example/v1' } + return data + } + const baseField = (): HTMLInputElement => screen.getByLabelText('gui.settings.providers.base') as HTMLInputElement + const keyUpdate = (): HTMLButtonElement => + keyField().closest('.settings-taglist')!.querySelector(':scope > button.mini') as HTMLButtonElement + const saves = (calls: Call[]): unknown[] => calls.filter(([name]) => name === 'provider').map(([, args]) => args) + + it('says there is no new key when nothing was changed', async () => { + const { calls } = install(withCustom(true)) + await open('custom') + await act(async () => { fireEvent.click(keyUpdate()) }) + expect(saves(calls)).toEqual([]) + expect(screen.getByRole('alert').textContent).toBe(UNCHANGED) + }) + + it('saves an edited address on its own, without asking for the key again', async () => { + const { calls } = install(withCustom(true)) + await open('custom') + fireEvent.change(baseField(), { target: { value: 'https://relay2.example/v1' } }) + await act(async () => { fireEvent.click(keyUpdate()) }) + expect(saves(calls)).toEqual([]) + expect(calls.filter(([name]) => name === 'setFields')) + .toEqual([['setFields', { slug: 'custom', fields: { api_base: 'https://relay2.example/v1' } }]]) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('saves an edited address on its own with the revealed key still unedited in the field', async () => { + /* The stored key on screen as text is still no new key (C10): it does not + go back, and the address goes without it. */ + const { calls } = install(withCustom(true), { revealKey: async () => 'cu-saved' }) + await open('custom') + await act(async () => { fireEvent.click(keyEye()) }) + await waitFor(() => expect(keyField().value).toBe('cu-saved')) + fireEvent.change(baseField(), { target: { value: 'https://relay2.example/v1' } }) + await act(async () => { fireEvent.click(keyUpdate()) }) + expect(saves(calls)).toEqual([]) + expect(calls.filter(([name]) => name === 'setFields')) + .toEqual([['setFields', { slug: 'custom', fields: { api_base: 'https://relay2.example/v1' } }]]) + }) + + it('saves an edited address with a key typed beside it', async () => { + const { calls } = install(withCustom(true)) + await open('custom') + fireEvent.change(baseField(), { target: { value: 'https://relay2.example/v1' } }) + fireEvent.change(keyField(), { target: { value: 'sk-relay' } }) + await act(async () => { fireEvent.click(keyUpdate()) }) + expect(saves(calls)).toEqual([{ op: 'save_key', slug: 'custom', api_key: 'sk-relay', api_base: 'https://relay2.example/v1' }]) + }) + + it('asks for the key first on a provider not yet connected', async () => { + const { calls } = install(withCustom(false)) + await open('custom') + await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) + expect(saves(calls)).toEqual([]) + expect(screen.getByRole('alert').textContent).toBe('gui.settings.providers.key_first') + }) +}) + describe('provider detail, Azure', () => { it('shows the stored deployment and API version from the config section', async () => { const data = snap() diff --git a/ui-web/src/features/settings/providers/ProviderDetail.tsx b/ui-web/src/features/settings/providers/ProviderDetail.tsx index 585350547..59209653f 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.tsx @@ -99,7 +99,8 @@ function Connection({ p }: { p: ProviderRow }): JSX.Element { setKey(saved) setRevealed(saved) } - const [base, setBase] = useState(p.apiBase || rawStr(store.get().snap.raw, p.id, 'apiBase') || p.defaultApiBase || '') + const shownBase = p.apiBase || rawStr(store.get().snap.raw, p.id, 'apiBase') || p.defaultApiBase || '' + const [base, setBase] = useState(shownBase) const kind = kindOf(p) const checking = store.isBusy(busy(p.id)) /* Where this block draws the address field, if anywhere: above the key for a @@ -117,8 +118,26 @@ function Connection({ p }: { p: ProviderRow }): JSX.Element { here) for no change, so it goes as an empty field does. */ const k = key === revealed ? '' : key.trim() const b = base.trim() - if (needsKey(p) && !k && !p.on) { store.refuse(t('gui.settings.providers.key_first')); return } + /* A key-shaped provider, or an endpoint (custom, Azure), connects by its + key: `model.save_key` refuses such a save without one -- all but + Bedrock's, which takes an ambient credential, accepts it and changes + nothing. */ + const endpointKey = p.kind === 'endpoint' && takesKey(p) + if ((needsKey(p) || endpointKey) && !k && !p.on) { store.refuse(t('gui.settings.providers.key_first')); return } if (takesBase(p) && !b) { store.refuse(t('gui.settings.providers.base_first')); return } + if (!k && (endpointKey || (needsKey(p) && !(b && basePlace)))) { + /* An endpoint's edited address with no new key goes on its own, the way + any other field does (`model.set_fields`, as AddressRow sends it): + `model.save_key` would demand the key again. */ + if (endpointKey && b !== shownBase.trim()) { + void store.run(busy(p.id), () => store.source().setFields(p.id, { api_base: b })) + return + } + /* Nothing new to send: the save could only be refused, in English, or + change nothing, so the page says what pressing it would have needed. */ + store.refuse(t('gui.settings.providers.key_unchanged', { button: t('gui.settings.update') })) + return + } const params: Record = { slug: p.id } if (k) params.api_key = k if (b && basePlace) params.api_base = b