Просмотр исходного кода

feat(ui-models): drop the provider-scoped reasoning effort, and red-flag a bad route id

**Reasoning effort leaves the provider cards entirely.** It is a per-MODEL
capability and the models under one provider disagree about which levels
they accept: setting `anthropic` to `max` made six of its eight models
throw UNSUPPORTED_REASONING_EFFORT, and because the catalog build catches
per provider, the whole provider vanished from the picker behind one error
row. A provider-scoped control can only ever be set to a value some of its
models reject.

The composer's model picker already offers each model its own levels, and
a switch there now records provider, model, and effort together as the
next session's default — so the setting has a better home at the right
granularity. The profile field stays in `settings.yaml` for a deployment
that knows its route; only the control is gone, from both cards and both
adapter families. Two `components.spec` cases used the control as the
vehicle for their op assertions and now use `baseURL`, which is what they
were actually testing.

**A rejected Provider ID now reads as a fault.** It shared the neutral
hint paragraph with the field's guidance, so the copy telling the user
what they got wrong looked like advice. Reuses the existing `.error`
style, matching the split the key field already makes.
Yichen Jiang 1 месяц назад
Родитель
Сommit
2dc1406dfd

+ 9 - 9
apps/web/tests/models-settings.e2e.ts

@@ -159,23 +159,23 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click()
     await dialog.getByText('自定义设置').click()
-    const effort = dialog.getByLabel('推理强度')
-    await effort.waitFor({ timeout: 10_000 })
-    await effort.selectOption('high')
+    const url = dialog.getByLabel('API 地址')
+    await url.waitFor({ timeout: 10_000 })
+    await url.fill('https://gateway.minimax.example/v1')
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
     // The editor closes back to the row; the fold's write merged into the
     // stored profile beside the reference.
-    await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
+    await expect.poll(async () => dialog.getByLabel('API 地址').count(), { timeout: 10_000 }).toBe(0)
     await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 })
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
-    expect(document).toContain('reasoning: high')
+    expect(document).toContain('baseURL: https://gateway.minimax.example/v1')
     expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('declares a route the adapter does not ship, without a reasoning control', async () => {
+  it('declares a route the adapter does not ship', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
     const dialog = page.getByRole('dialog', { name: '设置' })
     const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
@@ -184,9 +184,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await dialog.getByLabel('Provider ID').fill('acme-gateway')
     await dialog.getByLabel('显示名称').fill('Acme Gateway')
     await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
-    // No reasoning effort anywhere for a hand-declared route: its models carry
-    // no reasoning capability, so a profile effort would make every model on
-    // the route fail to resolve and drop the provider out of the picker.
+    // No reasoning effort on a provider card at all: effort is a per-model
+    // capability, the models under one provider disagree about it, and a
+    // switch in the composer already records provider+model+effort together.
     expect(await dialog.getByLabel('推理强度').count()).toBe(0)
     await dialog.getByRole('button', { name: '添加模型' }).click()
     await dialog.getByLabel('模型 ID 1').fill('acme-large')

+ 0 - 6
apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md

@@ -25,12 +25,6 @@
         - text: 自定义设置 API 地址
         - textbox "API 地址":
           - /placeholder: https://api.deepseek.com
-        - text: 推理强度
-        - combobox "推理强度":
-          - option "默认" [selected]
-          - option "off"
-          - option "high"
-          - option "max"
         - region "模型目录":
           - text: 模型目录 已自定义模型目录
           - button "恢复默认模型"

+ 2 - 2
packages/client/ui-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
-README.md: dec43de43899ef99e74b1fd73ffb4bf3c4e97b3e
-README.zh.md: c17eb611f071c4054d12d36acb2de8a94fa95a20
+README.md: cf4e50630339c4055e9ae2df37246b814af06966
+README.zh.md: 2b9158fa4419bf07f496fce47c938744f2a4233f

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/client/ui-models/README.md


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
packages/client/ui-models/README.zh.md


+ 10 - 10
packages/client/ui-models/src/client/CustomProviderCard.tsx

@@ -14,13 +14,11 @@
  * and at least one model — are required here rather than at load, so the
  * failure names the field while the user is still looking at it.
  *
- * There is deliberately no reasoning-effort control. A hand-declared model
- * carries no reasoning capability — pi-ai's installed catalog is what supplies
- * one, and it has nothing under this route — so a profile effort here makes
- * `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the
- * route, which drops the whole provider out of the model picker. The editor
- * card hides the control for the same reason once the directory reports the
- * route as declared.
+ * There is deliberately no reasoning-effort control, here or on the editor
+ * card: effort is a per-MODEL capability, and the models under one provider
+ * disagree about it, so a provider-scoped control can only be set to a value
+ * some of them reject. The composer's model picker offers each model its own
+ * levels instead.
  */
 
 import { useState } from 'react'
@@ -206,9 +204,11 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           onChange={(event) => { setRoute(event.target.value) }}
         />
       </div>
-      <p className={styles['advancedHint']}>
-        {routeInvalid ? t('customRouteInvalid') : routeTaken ? t('customRouteTaken') : t('customRouteHint')}
-      </p>
+      {/* A rejected id reads as a fault, not as guidance — the same split the
+          key field below already makes between its failure and its hint. */}
+      {routeInvalid || routeTaken
+        ? <p className={styles['error']}>{t(routeInvalid ? 'customRouteInvalid' : 'customRouteTaken')}</p>
+        : <p className={styles['advancedHint']}>{t('customRouteHint')}</p>}
       <div className={styles['field']}>
         <span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
         <input

+ 0 - 4
packages/client/ui-models/src/client/ModelsSection.tsx

@@ -54,8 +54,6 @@ interface EditorTarget extends ProviderIdentity {
   settingsPath: readonly string[]
   /** Writable credential identified under this page's conventional reference. */
   credentialRef?: string
-  /** Directory passthrough: the owning adapter ships nothing under this route. */
-  declared?: boolean
 }
 
 /** Values that vary around the shared provider-editor rendering. */
@@ -73,7 +71,6 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
       provider={target.provider}
       displayName={target.displayName}
       settingsPath={target.settingsPath}
-      {...target.declared === undefined ? {} : { declared: target.declared }}
       {...props}
     />
   )
@@ -140,7 +137,6 @@ function targetOf(row: ProviderRow): EditorTarget {
     settingsNs: row.entry.settingsNs,
     settingsPath: row.entry.settingsPath,
     ...credentialRef === undefined ? {} : { credentialRef },
-    ...row.entry.declared === undefined ? {} : { declared: row.entry.declared },
   }
 }
 

+ 8 - 31
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -7,10 +7,12 @@
  * a key is entered; a blank key materializes a reference-free profile for
  * provider-native authentication);
  * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
- * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai —
- * withheld for a hand-declared route, whose models have no reasoning
- * capability to configure — and DeepSeek's id/name/context-window model
- * catalog). Everything else stays
+ * both families and DeepSeek's id/name/context-window model catalog).
+ * Reasoning effort is deliberately absent: it is a per-MODEL capability, and
+ * the models under one provider disagree about it, so a provider-scoped
+ * control can only be set to a value some of them reject. The composer's
+ * model picker offers each model its own levels; `settings.yaml` keeps the
+ * profile field for a deployment that knows its route. Everything else stays
  * owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
  * path ops against the stored section — the card reads the redacted
  * descriptor, so it names only the fields it can see and a stored literal
@@ -29,14 +31,12 @@ import {
 import { apiKeyFailure } from './apiKey.ts'
 import { EditorFooter } from './EditorFooter.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
-import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx'
-import type { EffortFamily } from './ReasoningEffortField.tsx'
 import { deriveKeyRef, messageOf } from './store.ts'
 import type { en } from './locales.ts'
 import styles from './ModelsSection.module.css'
 
 /** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
-type EditorLayout = EffortFamily | 'unknown'
+type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
 
 /** The public DeepSeek endpoint shown as the deepseek base-URL placeholder. */
 const DEEPSEEK_PUBLIC_BASE_URL = 'https://api.deepseek.com'
@@ -57,13 +57,6 @@ export interface ProviderEditorProps {
   api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
   /** Section copy. */
   t: (key: keyof typeof en) => string
-  /**
-   * Whether the owning adapter knows this route only because configuration
-   * declared it. Such a route's models carry no reasoning capability, so the
-   * effort control is withheld; absent means the adapter draws no such
-   * distinction and the control shows.
-   */
-  declared?: boolean
   /** Disable writes (read-only settings provider). */
   readOnly: boolean
   /** Close the editor; `changed` reports whether an Apply committed. */
@@ -303,8 +296,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: EffortFamily): ReactNode => {
-    const effortField = EFFORT_FIELD[family]
+  const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
     const customModels = getPath(draft, ['models'])
     const modelsOverridden = hasPath(draft, ['models'])
     const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
@@ -361,21 +353,6 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                 }}
               />
             </div>
-            {/* A hand-declared route's models carry no reasoning capability
-                (pi-ai's installed catalog is what supplies one, and it has
-                nothing under such a route), so a profile effort would make
-                `resolveModel` throw for every model on it and drop the whole
-                provider out of the picker. Offering the control at all would
-                be offering a way to break the route. */}
-            {props.declared === true ? null : (
-              <ReasoningEffortField
-                family={family}
-                value={stringAt(draft, effortField) ?? ''}
-                onChange={(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. */}

+ 0 - 72
packages/client/ui-models/src/client/ReasoningEffortField.tsx

@@ -1,72 +0,0 @@
-/**
- * The provider-level reasoning-effort select: 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.
- *
- * It carries the per-family vocabulary and field name so the editor's two
- * layouts cannot spell them differently. Only routes the adapter ships get
- * this control at all — a hand-declared model has no reasoning capability to
- * configure, and a profile effort over one makes its whole route fail to
- * resolve — so the create card renders nothing here by construction.
- */
-
-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<EffortFamily, readonly string[]> = {
-  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<EffortFamily, string> = {
-  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 (
-    <div className={styles['field']}>
-      <span className={styles['fieldLabel']}>{t('effort')}</span>
-      <select
-        className={`${styles['input']} ${styles['selectInput']}`}
-        value={value}
-        aria-label={t('effort')}
-        disabled={disabled}
-        onChange={(event) => { onChange(event.target.value === '' ? undefined : event.target.value) }}
-      >
-        <option value="">{t('effortInherit')}</option>
-        {EFFORT_CHOICES[family].map(choice => (
-          <option key={choice} value={choice}>{choice}</option>
-        ))}
-      </select>
-    </div>
-  )
-}

+ 0 - 4
packages/client/ui-models/src/client/locales.ts

@@ -35,8 +35,6 @@ export const en = {
   customized: 'Customized settings',
   baseUrl: 'Base URL',
   baseUrlDefault: 'Provider default',
-  effort: 'Reasoning effort',
-  effortInherit: 'Default',
   models: 'Models',
   modelsInherited: 'Using the adapter defaults',
   modelsCustomized: 'Customized model catalog',
@@ -130,8 +128,6 @@ export const zh: typeof en = {
   customized: '自定义设置',
   baseUrl: 'API 地址',
   baseUrlDefault: '提供方默认',
-  effort: '推理强度',
-  effortInherit: '默认',
   models: '模型目录',
   modelsInherited: '正在使用适配器默认模型',
   modelsCustomized: '已自定义模型目录',

+ 11 - 13
packages/client/ui-models/tests/components.spec.tsx

@@ -92,13 +92,12 @@ function wireNamespaces(): SettingsNamespaceView[] {
       value: {
         apiKeyEnv: 'DEEPSEEK_API_KEY',
         baseURL: 'https://base',
-        reasoningEffort: 'high',
         defaultContextWindow: 1_000_000,
         maxTokens: 256_000,
         models: DEFAULT_DEEPSEEK_MODELS,
       },
       base: { defaultContextWindow: 1_000_000, maxTokens: 256_000, models: DEFAULT_DEEPSEEK_MODELS },
-      user: { reasoningEffort: 'high' },
+      user: { baseURL: 'https://base' },
       applies: 'live',
       secrets: [{ path: ['apiKey'], set: false }],
       revision: 0,
@@ -729,16 +728,16 @@ describe('ModelsSection', () => {
     // user layer and replaced it wholesale, deleting any stored literal key.
     const { replace, update, mutate } = await mountSection()
     fireEvent.click(screen.getByText(en.customized))
-    const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
-    expect(effort.value).toBe('high')
-    fireEvent.change(effort, { target: { value: '' } })
+    const url = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
+    expect(url.value).toBe('https://base')
+    fireEvent.change(url, { target: { value: '' } })
     fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
     expect(replace).not.toHaveBeenCalled()
     expect(update).not.toHaveBeenCalled()
     expect(mutate.mock.calls[0]?.[0]).toEqual({
       ns: 'llm-deepseek',
-      ops: [{ op: 'unset', path: ['reasoningEffort'] }],
+      ops: [{ op: 'unset', path: ['baseURL'] }],
       expectedRevision: 0,
     })
   })
@@ -795,17 +794,16 @@ describe('ModelsSection', () => {
     const urls = screen.getAllByLabelText<HTMLInputElement>(en.baseUrl)
     expect(urls).toHaveLength(2)
     expect((urls[1] as HTMLInputElement).value).toBe('https://proxy')
-    const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
-    fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
+    fireEvent.change(urls[1] as HTMLInputElement, { target: { value: 'https://proxy/v2' } })
     fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
     await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
-    // Only the edited field travels: apiKeyEnv, baseURL and headers were
-    // already stored with these values, so no op restates them — and the
-    // profile's stored literal apiKey, absent from the redacted view the card
-    // read, is named by nothing at all.
+    // Only the edited field travels: apiKeyEnv and headers were already stored
+    // with these values, so no op restates them — and the profile's stored
+    // literal apiKey, absent from the redacted view the card read, is named by
+    // nothing at all.
     expect(mutate.mock.calls[0]?.[0]).toEqual({
       ns: 'llm-pi-ai',
-      ops: [{ op: 'set', path: ['providers', 'openai', 'reasoning'], value: 'xhigh' }],
+      ops: [{ op: 'set', path: ['providers', 'openai', 'baseURL'], value: 'https://proxy/v2' }],
       expectedRevision: 0,
     })
   })

+ 26 - 23
packages/client/ui-models/tests/provider-form.spec.tsx

@@ -705,35 +705,24 @@ describe('hand-declared providers', () => {
     expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
   })
 
-  it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => {
+  it('scopes each card to fields a provider can actually own', async () => {
+    // Reasoning effort used to sit here. It is a per-MODEL capability and the
+    // models under one provider disagree about it, so a provider-scoped
+    // control could only be set to a value some of them reject — which took
+    // the whole provider out of the picker. The composer's model picker owns
+    // the choice, and a switch there records provider+model+effort together.
+    const fields = () => [...document.querySelectorAll('input,select')]
+      .map(el => el.getAttribute('aria-label')).filter(Boolean)
+
     mountCard()
     fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
-    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
-    // A hand-declared model carries no reasoning capability — pi-ai's
-    // installed catalog is what supplies one, and it ships nothing under this
-    // route — so a profile effort makes `resolveModel` throw
-    // UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole
-    // provider out of the picker. Offering the control would be offering a way
-    // to break the route.
-    expect(screen.queryByLabelText(en.effort)).toBeNull()
-    cleanup()
-
-    // The editor card withholds it for the same route for the same reason...
-    await mountSection({
-      providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } },
-      declaredRoutes: ['acme-gateway'],
-    })
-    openEditor('acme-gateway')
-    expect(screen.queryByLabelText(en.effort)).toBeNull()
+    expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
     cleanup()
 
-    // ...and keeps it for a route the adapter actually ships, whose models do
-    // carry the capability.
     await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
     openEditor('openai')
-    const select = screen.getByLabelText<HTMLSelectElement>(en.effort)
-    expect([...select.options].map(option => option.value))
-      .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
+    fireEvent.click(screen.getByText(en.customized))
+    expect(fields()).toEqual([en.keyInput, en.baseUrl])
   })
 
   it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
@@ -827,6 +816,20 @@ describe('hand-declared providers', () => {
     expect(screen.queryByText(en.customRouteInvalid)).toBeNull()
   })
 
+  it('styles a rejected route id as a fault and its guidance as a hint', () => {
+    mountCard()
+    const routeField = screen.getByLabelText(en.customRoute)
+    // Same split the key field makes: what the user got wrong reads as a
+    // fault, what they have yet to do reads as guidance.
+    expect(screen.getByText(en.customRouteHint).className).toMatch(/advancedHint/)
+
+    fireEvent.change(routeField, { target: { value: '2' } })
+    expect(screen.getByText(en.customRouteInvalid).className).toMatch(/error/)
+
+    fireEvent.change(routeField, { target: { value: 'openai' } })
+    expect(screen.getByText(en.customRouteTaken).className).toMatch(/error/)
+  })
+
   it('derives a reference the credential seam accepts for every id it admits', () => {
     // The two rules have to stay in step; this is the relation, checked
     // directly rather than through the DOM.

Некоторые файлы не были показаны из-за большого количества измененных файлов