diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b4c655472a..4bd14d1179 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -22,6 +22,7 @@ import { EditorFooter } from './EditorFooter.tsx' import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' +import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx' import { deriveKeyRef, messageOf } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -69,6 +70,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { const [baseURL, setBaseURL] = useState('') const [protocol, setProtocol] = useState(protocols[0] ?? '') const [keyDraft, setKeyDraft] = useState('') + const [effort, setEffort] = useState(undefined) const [models, setModels] = useState([]) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) @@ -101,6 +103,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { apiKeyEnv: keyRef, api: protocol, baseURL, + // Inherit is the field being absent, not an empty string: the schema + // types it as an effort name, and an empty one would fail the write. + ...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort }, models: models.map(model => ({ ...model })), } const response = await api.settings.mutate({ @@ -209,6 +214,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { onChange={(event) => { setKeyDraft(event.target.value) }} /> + {/* The same control the editor card shows for this namespace: a route + declared here and edited there must offer the same profile. */} + = { - deepseek: ['off', 'high', 'max'], - 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], -} - -/** The draft key the effort select edits, per layout. */ -const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = { - deepseek: 'reasoningEffort', - 'pi-ai': 'reasoning', -} +type EditorLayout = EffortFamily | 'unknown' /** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */ const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com' @@ -279,7 +269,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { * family as a parameter is what makes `EFFORT_FIELD` total here: an * unknown namespace never reaches this body. */ - const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => { + const curatedFields = (family: EffortFamily): ReactNode => { const effortField = EFFORT_FIELD[family] const customModels = getPath(draft, ['models']) const modelsOverridden = hasPath(draft, ['models']) @@ -333,23 +323,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { }} /> -
- {t('effort')} - -
+ { setField(effortField, effort) }} + t={t} + disabled={disabled} + /> {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} diff --git a/packages/client/ui-models/src/client/ReasoningEffortField.tsx b/packages/client/ui-models/src/client/ReasoningEffortField.tsx new file mode 100644 index 0000000000..10b696a4ea --- /dev/null +++ b/packages/client/ui-models/src/client/ReasoningEffortField.tsx @@ -0,0 +1,71 @@ +/** + * The provider-level reasoning-effort select, shared by every card that writes + * a provider profile. It lives here rather than inside one card because both + * write the SAME field of the same profile: a route declared without this + * control and then edited with it would offer a setting the creating user was + * never given, which is exactly the drift that put it here. + * + * The value is the profile's own default effort, applied to every model on the + * route unless a request names one; the empty option means "inherit", which on + * the wire is the field being absent rather than an empty string. + */ + +import type { ReactNode } from 'react' +import type { en } from './locales.ts' +import styles from './ModelsSection.module.css' + +/** The adapter families that expose a provider-level effort, and their vocabularies. */ +export type EffortFamily = 'deepseek' | 'pi-ai' + +/** Reasoning vocabularies per family; the empty option means "inherit". */ +export const EFFORT_CHOICES: Record = { + deepseek: ['off', 'high', 'max'], + 'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'], +} + +/** The profile key each family's effort lives under. */ +export const EFFORT_FIELD: Record = { + deepseek: 'reasoningEffort', + 'pi-ai': 'reasoning', +} + +/** Props of {@link ReasoningEffortField}. */ +export interface ReasoningEffortFieldProps { + /** Which vocabulary to offer. */ + family: EffortFamily + /** Current value; the empty string is the inherit option. */ + value: string + /** Receives the chosen effort, or undefined for inherit. */ + onChange: (effort: string | undefined) => void + /** Section copy. */ + t: (key: keyof typeof en) => string + /** Disable the control (busy or read-only). */ + disabled: boolean +} + +/** + * Render the provider-level reasoning-effort select. + * @param props - family vocabulary, current value, change sink, copy, and disabled state. + * @returns the labelled select. + */ +export function ReasoningEffortField( + { family, value, onChange, t, disabled }: ReasoningEffortFieldProps, +): ReactNode { + return ( +
+ {t('effort')} + +
+ ) +} diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..b35302f78a 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -652,6 +652,40 @@ describe('hand-declared providers', () => { expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' }) }) + it('offers the same reasoning effort the editor does, and omits it when inherited', async () => { + const { mutate, onClose } = mountCard() + const declare = (): void => { + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) + } + declare() + + // The vocabulary is the namespace's, not DeepSeek's — a route declared + // here is edited by the pi-ai layout, which offers exactly these. + const select = screen.getByLabelText(en.effort) as HTMLSelectElement + expect([...select.options].map(option => option.value)) + .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']) + + fireEvent.change(select, { target: { value: 'high' } }) + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(firstMutate(mutate).ops[0]).toMatchObject({ + path: ['providers', 'acme'], + value: { reasoning: 'high' }, + }) + + // Inherit is the field being absent: an empty string would fail the schema + // that types this as an effort name. + cleanup() + const second = mountCard() + declare() + fireEvent.click(screen.getByText(en.create)) + await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) }) + expect(firstMutate(second.mutate).ops[0].value).not.toHaveProperty('reasoning') + }) + it('names the blocked gate under the form, and nothing once it is satisfied', () => { mountCard() fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })