From c6e4db72478cb1c1914f7bfb4172e23e3be39ae5 Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:36:48 +0800 Subject: [PATCH 1/3] fix(ui-web): say there is no new key instead of sending an empty save On a connected provider, Update (or Enter) over a key field holding no new key -- empty, or the stored key the eye revealed, unedited -- sent model.save_key with the slug alone. A key-shaped provider draws no address there, so the server could only refuse it, and the page showed its English refusal ("OpenRouter requires an API key") while the key was on screen. The page now sends nothing and says, in the pane's own error line, that there is no new key to save and how to replace it. A provider whose address is drawn there still saves it. Acceptance T3.2's observation for that press is updated to match. Co-authored-by: Claude (claude-opus-5-5) --- ...0-web-settings-model-section-acceptance.md | 2 +- i18n/messages.json | 4 +++ ui-tui/src/i18n/messages.generated.ts | 3 +++ .../providers/ProviderDetail.test.tsx | 25 +++++++++++++++---- .../settings/providers/ProviderDetail.tsx | 7 ++++++ 5 files changed, 35 insertions(+), 6 deletions(-) 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..3f58fa1e4 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 key to save. To replace the saved one, type a new key here, then press {button}.", + "zh": "没有新的密钥要保存。要换 key,先在框里填入新的,再点「{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..a6a2115f9 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 key to save. To replace the saved one, type a new key here, 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,7 @@ 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': '没有新的密钥要保存。要换 key,先在框里填入新的,再点「{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..f92761b30 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 () => { diff --git a/ui-web/src/features/settings/providers/ProviderDetail.tsx b/ui-web/src/features/settings/providers/ProviderDetail.tsx index 585350547..2ccd6cb22 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.tsx @@ -119,6 +119,13 @@ function Connection({ p }: { p: ProviderRow }): JSX.Element { const b = base.trim() if (needsKey(p) && !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 } + /* Connected, with no new key and no address drawn here to send: the server + could only refuse the save, in English, so the page says what pressing + it would have needed instead. */ + if (needsKey(p) && !k && !(b && basePlace)) { + 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 From deb3d88df90f694eb037b0c8740bdccad32c9e01 Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:43:49 +0800 Subject: [PATCH 2/3] fix(ui-web): ask for the key before an endpoint provider's save too Custom, Azure OpenAI and MiniMax CN are endpoint-shaped: the page groups them with local servers, so the no-new-key check skipped them, yet the server refuses every save of theirs that carries no key, the address included. Update over an empty or unedited key field still sent one and showed the English refusal. They now get the same treatment as a key-shaped provider: not yet connected, "Enter the API key first"; connected with nothing changed, "no new key to save"; connected with the address edited, a new line saying the address is saved together with the key, so type the key and press Update. A save that carries a key goes out as before. Co-authored-by: Claude (claude-opus-5-5) --- i18n/messages.json | 4 ++ ui-tui/src/i18n/messages.generated.ts | 4 ++ .../providers/ProviderDetail.test.tsx | 51 +++++++++++++++++++ .../settings/providers/ProviderDetail.tsx | 22 +++++--- 4 files changed, 74 insertions(+), 7 deletions(-) diff --git a/i18n/messages.json b/i18n/messages.json index 3f58fa1e4..2aa234cdf 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -7563,6 +7563,10 @@ "en": "No new key to save. To replace the saved one, type a new key here, then press {button}.", "zh": "没有新的密钥要保存。要换 key,先在框里填入新的,再点「{button}」。" }, + "gui.settings.providers.key_with_base": { + "en": "This provider saves its address together with its API key: type the key in the key field, then press {button}.", + "zh": "这家服务商的地址要连同 API Key 一起保存:在密钥框里填入 key,再点「{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 a6a2115f9..314257d58 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -2138,6 +2138,8 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.key_first': 'Enter the API key first', 'gui.settings.providers.key_unchanged': 'No new key to save. To replace the saved one, type a new key here, then press {button}.', + 'gui.settings.providers.key_with_base': + 'This provider saves its address together with its API key: type the key in the 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', @@ -4717,6 +4719,8 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.pick_one': '在左边选一家服务商', 'gui.settings.providers.key_first': '先填入 API Key', 'gui.settings.providers.key_unchanged': '没有新的密钥要保存。要换 key,先在框里填入新的,再点「{button}」。', + 'gui.settings.providers.key_with_base': + '这家服务商的地址要连同 API Key 一起保存:在密钥框里填入 key,再点「{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 f92761b30..eb0792d7d 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx @@ -659,6 +659,57 @@ describe('provider detail, the address of a direct vendor', () => { }) }) +describe('an endpoint provider (custom, Azure) saved without a new key', () => { + /* Every save of one carries its key: the server refuses custom, Azure and + MiniMax CN without it, address change or not, so the page says what is + missing instead of sending a save that can only come back in English. */ + const KEY_WITH_BASE = 'gui.settings.providers.key_with_base {"button":"gui.settings.update"}' + 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('says the key goes with an edited address', 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(screen.getByRole('alert').textContent).toBe(KEY_WITH_BASE) + }) + + 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 2ccd6cb22..1d146f73a 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,13 +118,20 @@ 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 } + /* The server takes no save of a key-shaped provider, nor of an endpoint + (custom, Azure), without a key: an endpoint's address is saved with it. */ + 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 } - /* Connected, with no new key and no address drawn here to send: the server - could only refuse the save, in English, so the page says what pressing - it would have needed instead. */ - if (needsKey(p) && !k && !(b && basePlace)) { - store.refuse(t('gui.settings.providers.key_unchanged', { button: t('gui.settings.update') })) + /* Connected, with no new key and nothing else this save could carry: the + server could only refuse it, in English, so the page says what pressing + it would have needed instead -- for an endpoint whose address was + edited, that the key goes with it. */ + if (!k && (endpointKey || (needsKey(p) && !(b && basePlace)))) { + const button = t('gui.settings.update') + store.refuse(endpointKey && b !== shownBase.trim() + ? t('gui.settings.providers.key_with_base', { button }) + : t('gui.settings.providers.key_unchanged', { button })) return } const params: Record = { slug: p.id } From fcb6aa85f023014b3b665c041993c1f4514dc9ad Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:17:09 +0800 Subject: [PATCH 3/3] fix(ui-web): save an endpoint's edited address alone, and name the key field A connected endpoint provider (custom, Azure OpenAI, MiniMax CN) whose address was edited with no new key typed was told to type the key again. That is model.save_key's rule, not the provider's: an address on its own goes through model.set_fields, the route the settings design gives any field but the key, and the one the Advanced address field already uses. The edited address is now saved that way, and the key-with-address line is gone. The no-new-key line now names the API key field rather than saying "here", since it is drawn at the foot of the pane, and uses "API Key" like the strings around it. The comments no longer say every key-less save of a key-shaped provider is refused: Bedrock's is accepted and changes nothing, which is still no reason to send it. Co-authored-by: Claude (claude-opus-5-5) --- i18n/messages.json | 8 ++---- ui-tui/src/i18n/messages.generated.ts | 9 ++---- .../providers/ProviderDetail.test.tsx | 28 +++++++++++++++---- .../settings/providers/ProviderDetail.tsx | 24 +++++++++------- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/i18n/messages.json b/i18n/messages.json index 2aa234cdf..dc098c89d 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -7560,12 +7560,8 @@ "zh": "先填入 API Key" }, "gui.settings.providers.key_unchanged": { - "en": "No new key to save. To replace the saved one, type a new key here, then press {button}.", - "zh": "没有新的密钥要保存。要换 key,先在框里填入新的,再点「{button}」。" - }, - "gui.settings.providers.key_with_base": { - "en": "This provider saves its address together with its API key: type the key in the key field, then press {button}.", - "zh": "这家服务商的地址要连同 API Key 一起保存:在密钥框里填入 key,再点「{button}」。" + "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", diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 314257d58..d4e85b9f4 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -2137,9 +2137,7 @@ export const UI_TEXT: Record> = { '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 key to save. To replace the saved one, type a new key here, then press {button}.', - 'gui.settings.providers.key_with_base': - 'This provider saves its address together with its API key: type the key in the key field, then press {button}.', + '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', @@ -4718,9 +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': '没有新的密钥要保存。要换 key,先在框里填入新的,再点「{button}」。', - 'gui.settings.providers.key_with_base': - '这家服务商的地址要连同 API Key 一起保存:在密钥框里填入 key,再点「{button}」。', + '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 eb0792d7d..60c8fd900 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx @@ -660,10 +660,10 @@ describe('provider detail, the address of a direct vendor', () => { }) describe('an endpoint provider (custom, Azure) saved without a new key', () => { - /* Every save of one carries its key: the server refuses custom, Azure and - MiniMax CN without it, address change or not, so the page says what is - missing instead of sending a save that can only come back in English. */ - const KEY_WITH_BASE = 'gui.settings.providers.key_with_base {"button":"gui.settings.update"}' + /* `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 }] @@ -683,13 +683,29 @@ describe('an endpoint provider (custom, Azure) saved without a new key', () => { expect(screen.getByRole('alert').textContent).toBe(UNCHANGED) }) - it('says the key goes with an edited address', async () => { + 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(screen.getByRole('alert').textContent).toBe(KEY_WITH_BASE) + 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 () => { diff --git a/ui-web/src/features/settings/providers/ProviderDetail.tsx b/ui-web/src/features/settings/providers/ProviderDetail.tsx index 1d146f73a..59209653f 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.tsx @@ -118,20 +118,24 @@ 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() - /* The server takes no save of a key-shaped provider, nor of an endpoint - (custom, Azure), without a key: an endpoint's address is saved with it. */ + /* 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 } - /* Connected, with no new key and nothing else this save could carry: the - server could only refuse it, in English, so the page says what pressing - it would have needed instead -- for an endpoint whose address was - edited, that the key goes with it. */ if (!k && (endpointKey || (needsKey(p) && !(b && basePlace)))) { - const button = t('gui.settings.update') - store.refuse(endpointKey && b !== shownBase.trim() - ? t('gui.settings.providers.key_with_base', { button }) - : t('gui.settings.providers.key_unchanged', { button })) + /* 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 }