Merge remote-tracking branch 'origin/master' into worktree/default-model-persistence
Carries two edits beyond conflict resolution, both forced by what master brought in: - `CustomProviderCard`: master added front-end key validation and a component-level `keyValue` (already trimmed) while still writing `apiKeyEnv` unconditionally. Kept this branch's blank-key rule and its committed-profile retry gate, and adopted master's single `keyValue` so the component has one spelling of the key rather than two. - `docs/user/guide/providers`: master merged #1810, whose default-model section still taught overriding the `api-gateway` row in `$DSH_HOME/config.yaml` — the behavior this branch replaced. Rewritten for the settings section the picker now writes, plus the review fix from #1810 replacing the colloquial 挂着 in the opener.
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
@@ -90,12 +91,22 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
// bad row is named by its position here too. Capacities have route-level
|
||||
// fallbacks; what a route cannot default is at least one model.
|
||||
const modelFailure = validateDeepSeekModels(models)
|
||||
const keyFailure = apiKeyFailure(keyDraft)
|
||||
// The typed key with paste whitespace removed. A blank field yields an empty
|
||||
// string, which the create path reads as "no key supplied" — a route may
|
||||
// legitimately authenticate through the provider's own ambient discovery.
|
||||
const keyValue = keyDraft.trim()
|
||||
const ready = route.length > 0 && !routeInvalid && !routeTaken
|
||||
&& baseURL.length > 0 && models.length > 0 && modelFailure === undefined
|
||||
&& keyFailure === undefined
|
||||
// The one blocked gate worth a line under the form. The route id is omitted
|
||||
// because its own field already explains itself, and a satisfied card says
|
||||
// nothing at all rather than printing an empty paragraph.
|
||||
const hint = failure !== undefined || ready
|
||||
// The key field prints its own failure directly beneath itself, so a card
|
||||
// blocked only by the key stays silent here rather than answering with the
|
||||
// next unmet gate — which is satisfied, and reads as a second, false fault.
|
||||
|| keyFailure !== undefined
|
||||
? undefined
|
||||
: baseURL.length === 0
|
||||
? t('customNeedsBaseUrl')
|
||||
@@ -106,8 +117,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
/** Perform the create, returning a failure message or undefined. */
|
||||
const createOnce = async (): Promise<string | undefined> => {
|
||||
const keyRef = deriveKeyRef(route)
|
||||
const normalizedKey = keyDraft.trim()
|
||||
const storesKey = normalizedKey.length > 0
|
||||
const storesKey = keyValue.length > 0
|
||||
if (!committed) {
|
||||
const profile = {
|
||||
...displayName.length === 0 ? {} : { displayName },
|
||||
@@ -139,7 +149,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
setCommitted(true)
|
||||
}
|
||||
if (storesKey) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
|
||||
// The profile landed; saying the key did not is the only honest report,
|
||||
// and the retry above now goes straight back to this write.
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
@@ -234,6 +244,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{/* A create card has no stored key to keep, so the blank case says
|
||||
what a blank field means here instead: this route may authenticate
|
||||
through the provider's own ambient discovery or OAuth. */}
|
||||
{keyFailure === undefined
|
||||
? null
|
||||
: <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>}
|
||||
</div>
|
||||
{/* The same control the editor card shows for this namespace: a route
|
||||
declared here and edited there must offer the same profile. */}
|
||||
@@ -251,8 +267,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
settingsNs: NS,
|
||||
baseURL,
|
||||
api: protocol,
|
||||
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
|
||||
...keyValue.length === 0 ? {} : { apiKey: keyValue },
|
||||
}}
|
||||
probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
|
||||
api={api}
|
||||
t={t}
|
||||
disabled={profileDisabled}
|
||||
|
||||
@@ -74,6 +74,13 @@ export interface ModelListEditorProps {
|
||||
onReset?: () => void
|
||||
/** Endpoint facts for the fetch action. */
|
||||
probe: ProbeTarget
|
||||
/**
|
||||
* Copy key naming why the fetch action is unavailable, or `undefined` when
|
||||
* it is. The card owns this because the key it would send is judged there:
|
||||
* asking with a key the form has already refused spends a round trip to be
|
||||
* told what the field already says.
|
||||
*/
|
||||
probeBlocked?: keyof typeof en | undefined
|
||||
/** Wire face the fetch action calls. */
|
||||
api: Pick<IApiClient, 'llm'>
|
||||
/** Section copy. */
|
||||
@@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={styles['linkButton']}
|
||||
disabled={disabled || busy || !askable}
|
||||
title={askable ? undefined : t('fetchNeedsBaseUrl')}
|
||||
disabled={disabled || busy || !askable || props.probeBlocked !== undefined}
|
||||
title={props.probeBlocked !== undefined
|
||||
? t(props.probeBlocked)
|
||||
: askable ? undefined : t('fetchNeedsBaseUrl')}
|
||||
onClick={() => { void fetchModels() }}
|
||||
>
|
||||
{busy ? t('fetching') : t('fetchModels')}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
|
||||
} from './DeepSeekModelsEditor.tsx'
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx'
|
||||
@@ -158,15 +159,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
|
||||
const stringAt = (source: unknown, key: string): string | undefined => {
|
||||
const value = getPath(source, [key])
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
||||
}
|
||||
const setField = (key: string, next: string | undefined): void => {
|
||||
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
|
||||
// A value of nothing but whitespace is cleared, not stored: `stringAt`
|
||||
// already reports it as absent, so the field would otherwise render empty
|
||||
// while the draft still carried the spaces into `settings.yaml`, where
|
||||
// both adapters would accept that non-empty string as a real value.
|
||||
const value = next === undefined || next.trim().length === 0 ? undefined : next
|
||||
setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value))
|
||||
}
|
||||
|
||||
// The model list is validated by the same per-row checker for both families,
|
||||
// so a bad row is named by its position rather than by a blanket message.
|
||||
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
|
||||
const keyFailure = apiKeyFailure(keyDraft)
|
||||
// What a probe or a write must carry: the typed key with paste whitespace
|
||||
// removed. A blank field yields an empty string, which both call sites read
|
||||
// as "no key supplied" rather than as a key — that is how a card whose
|
||||
// provider already has a stored key is edited without re-entering it.
|
||||
const keyValue = keyDraft.trim()
|
||||
// What the form currently shows, which is what an interrogation must ask:
|
||||
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
|
||||
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
|
||||
@@ -178,7 +190,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
provider: props.provider,
|
||||
...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
|
||||
...probeApi === undefined ? {} : { api: probeApi },
|
||||
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
|
||||
...keyValue.length === 0 ? {} : { apiKey: keyValue },
|
||||
}
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
@@ -189,11 +201,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
const normalizedKey = keyDraft.trim()
|
||||
// A pi-ai profile names the conventional reference only when this page is
|
||||
// about to store a key. Otherwise the provider keeps its native auth path.
|
||||
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
@@ -230,8 +241,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
setExpectedRevision(response.result.value.revision)
|
||||
setDraft(next)
|
||||
}
|
||||
if (normalizedKey.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
|
||||
if (keyValue.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
@@ -320,6 +331,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
|
||||
</div>
|
||||
<details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
@@ -360,7 +372,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
|
||||
/>
|
||||
)
|
||||
: <ModelListEditor {...catalogProps} probe={probe} api={api} />}
|
||||
: <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
@@ -393,7 +405,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<EditorFooter
|
||||
t={t}
|
||||
busy={busy}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
|
||||
|| keyFailure !== undefined}
|
||||
submitLabel="apply"
|
||||
submitBusyLabel="applying"
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Browser-side judgement of a typed API key.
|
||||
* @module @deepseek-ai/dsh-client-ui-models/apiKey
|
||||
*/
|
||||
|
||||
/**
|
||||
* Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space
|
||||
* excluded. Client packages reference only client packages, so the charset
|
||||
* rule is mirrored here rather than imported; keep the two in step, as
|
||||
* `validateDeepSeekModels` is kept in step with the host's `catalogModel`.
|
||||
*/
|
||||
const LEGAL_API_KEY = /^[\x21-\x7E]+$/
|
||||
|
||||
/**
|
||||
* A pasted `NAME=value` environment line. Two narrowings keep real keys clear
|
||||
* of it: the name must be upper-case, so `sk-` forms break at the hyphen, and
|
||||
* the `=` must be followed by something other than another `=`, so base64
|
||||
* padding on an all-upper-case key (`ABCD==`) is not mistaken for an
|
||||
* assignment. This heuristic runs only here — a resolver applying it could
|
||||
* lock a user out of a gateway whose key legitimately takes this shape, with
|
||||
* the environment refusing it too and no way through.
|
||||
*/
|
||||
const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/
|
||||
|
||||
/**
|
||||
* Copy key naming why a typed key cannot be saved. A wrapped paste reports the
|
||||
* same format failure as an illegal character: the reader's next move is the
|
||||
* same either way — look at the key and paste it again — so naming the two
|
||||
* causes apart would spend the field's one line on a distinction that changes
|
||||
* nothing about what to do.
|
||||
*/
|
||||
export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters'
|
||||
|
||||
/** Whether a value is wrapped in one matching pair of quotes. */
|
||||
function isQuoted(value: string): boolean {
|
||||
const first = value[0]
|
||||
if (first !== '"' && first !== '\'' && first !== '`') return false
|
||||
return value.length > 1 && value.endsWith(first)
|
||||
}
|
||||
|
||||
/**
|
||||
* Judge the key input's current value.
|
||||
*
|
||||
* An empty field is not a failure: every card opens with it empty even when a
|
||||
* key is already stored, where it means keep that one. A field holding only
|
||||
* whitespace is a failure rather than an empty field, so typed input is never
|
||||
* silently discarded.
|
||||
* @param draft - the key input's current value, untrimmed.
|
||||
* @returns the copy key for a field-level failure, or `undefined` to allow submit.
|
||||
*/
|
||||
export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined {
|
||||
if (draft.length === 0) return undefined
|
||||
const value = draft.trim()
|
||||
if (value.length === 0) return 'keyBlank'
|
||||
if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters'
|
||||
if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters'
|
||||
return undefined
|
||||
}
|
||||
@@ -53,6 +53,9 @@ export const en = {
|
||||
addModel: 'Add model',
|
||||
removeModel: 'Delete model',
|
||||
modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
|
||||
keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.',
|
||||
keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.',
|
||||
keyIllegalCharacters: 'This API key is not in a valid format. Please check it.',
|
||||
modelIdRequired: 'Model ID is required.',
|
||||
modelIdDuplicate: 'Model ID must be unique.',
|
||||
modelNameInvalid: 'Display name cannot be empty.',
|
||||
@@ -145,6 +148,9 @@ export const zh: typeof en = {
|
||||
addModel: '添加模型',
|
||||
removeModel: '删除模型',
|
||||
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
|
||||
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
|
||||
keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
|
||||
keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
|
||||
modelIdRequired: '模型 ID 不能为空。',
|
||||
modelIdDuplicate: '模型 ID 不能重复。',
|
||||
modelNameInvalid: '显示名称不能为空。',
|
||||
|
||||
Reference in New Issue
Block a user