Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 | |
Expand Down
4 changes: 4 additions & 0 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 不会包含的字符,请检查粘贴的内容"
Expand Down
4 changes: 4 additions & 0 deletions ui-tui/src/i18n/messages.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,8 @@ export const UI_TEXT: Record<Locale, Record<string, string>> = {
'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',
Expand Down Expand Up @@ -4714,6 +4716,8 @@ export const UI_TEXT: Record<Locale, Record<string, string>> = {
'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 地址',
Expand Down
92 changes: 87 additions & 5 deletions ui-web/src/features/settings/providers/ProviderDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ async function open(slug: string): Promise<void> {
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
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<typeof snap> => {
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<string, unknown>).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()
Expand Down
23 changes: 21 additions & 2 deletions ui-web/src/features/settings/providers/ProviderDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown> = { slug: p.id }
if (k) params.api_key = k
if (b && basePlace) params.api_base = b
Expand Down
Loading