diff --git a/frontend/src/app/admin/model-eval/ModelEditForm.tsx b/frontend/src/app/admin/model-eval/ModelEditForm.tsx index 25bd56b2..54ad4aef 100644 --- a/frontend/src/app/admin/model-eval/ModelEditForm.tsx +++ b/frontend/src/app/admin/model-eval/ModelEditForm.tsx @@ -47,6 +47,7 @@ import type { } from '@/lib/api'; import { PROVIDER_PRESETS, + catalogParamPrefill, formatPresetValue, formatTokens, parameterMeta, @@ -210,6 +211,34 @@ export function ModelEditForm({ model, onClose }: { model?: LlmModelItem | null; [catalogModels, form.model_id], ); + /** + * 目录命中且当前值为空/默认时补填(issue #221),覆盖两类时序: + * 编辑模式打开旧模型(context_window 未设但目录知道)、目录数据比输入 + * 晚到。规则: + * - context_window 为 null(= 未设)→ 补目录值,两种模式都生效 + * - max_tokens 仅添加模式且仍为默认 2000 时补目录输出上限;编辑模式的 + * 已保存值视为有意设置,不动(重选模型走 handleModelIdChange 才覆盖) + * 程序化预填不标记表单为脏:只把目录参考显示出来,用户保存才生效。 + */ + useEffect(() => { + if (!matchedCatalogModel) return; + setForm((f) => { + const patch: Partial = {}; + if (f.context_window == null && matchedCatalogModel.context_window != null) { + patch.context_window = matchedCatalogModel.context_window; + } + if ( + !isEdit && + f.max_tokens === 2000 && + matchedCatalogModel.max_output_tokens != null && + matchedCatalogModel.max_output_tokens !== 2000 + ) { + patch.max_tokens = matchedCatalogModel.max_output_tokens; + } + return Object.keys(patch).length ? { ...f, ...patch } : f; + }); + }, [matchedCatalogModel, isEdit]); + /** 当前渠道的默认接入地址:目录渠道取 models.dev 网关,内置渠道取预设 baseUrl */ const channelDefaultBase = useMemo(() => { const option = providerOptionsByKey.get(providerSelectKey); @@ -311,21 +340,14 @@ export function ModelEditForm({ model, onClose }: { model?: LlmModelItem | null; }; const handleModelIdChange = (modelId: string) => { - // 命中目录时预填价格与上下文窗口(models.dev,价格 USD/百万 tokens 原值); - // 未命中不猜价,留空由用户填写或走后端目录价估算兜底 + // 命中目录时预填上下文窗口、输出上限与价格(models.dev,价格 USD/百万 + // tokens 原值);未命中不猜值,留空由用户填写或走后端目录价估算兜底 const catalogMatch = catalogModels.find((m) => m.model_id === modelId); setDirty(true); setForm((f) => ({ ...f, model_id: modelId, - ...(catalogMatch - ? { - context_window: catalogMatch.context_window, - cost_per_1m_input: catalogMatch.cost_per_1m_input?.toString() ?? f.cost_per_1m_input, - cost_per_1m_input_cache_hit: catalogMatch.cost_per_1m_cache_read?.toString() ?? f.cost_per_1m_input_cache_hit, - cost_per_1m_output: catalogMatch.cost_per_1m_output?.toString() ?? f.cost_per_1m_output, - } - : {}), + ...catalogParamPrefill(catalogMatch, f), })); }; @@ -386,7 +408,7 @@ export function ModelEditForm({ model, onClose }: { model?: LlmModelItem | null; {matchedCatalogModel.cost_per_1m_input != null ? ` · $${matchedCatalogModel.cost_per_1m_input}/${matchedCatalogModel.cost_per_1m_output ?? '-'} 每百万 tokens` : ''} - (models.dev,费用与上下文窗口已预填,超出窗口的调用会自动跳过该路由) + (models.dev,上下文窗口、输出上限与费用已按目录预填,超出窗口的调用会自动跳过该路由) ) : (
diff --git a/frontend/src/app/admin/model-eval/_model-eval-utils.test.ts b/frontend/src/app/admin/model-eval/_model-eval-utils.test.ts new file mode 100644 index 00000000..bb8638c9 --- /dev/null +++ b/frontend/src/app/admin/model-eval/_model-eval-utils.test.ts @@ -0,0 +1,95 @@ +/** + * catalogParamPrefill 单测(issue #221)。 + * + * 覆盖: + * - 未命中目录返回空补丁(表单值原样) + * - 命中时预填上下文窗口 / 输出上限 / 三个价格 + * - 目录字段缺失(null)时保留表单已填值(回归:此前 context_window 会被清空) + */ +import { describe, expect, it } from 'vitest'; +import type { ModelCatalogModelItem } from '@/lib/api'; +import { catalogParamPrefill } from './_model-eval-utils'; + +function catalogItem(overrides: Partial = {}): ModelCatalogModelItem { + return { + provider: 'zhipuai', + model_id: 'glm-4.7', + name: 'GLM-4.7', + context_window: 200_000, + max_output_tokens: 32_768, + cost_per_1m_input: 0.6, + cost_per_1m_output: 2.2, + cost_per_1m_cache_read: 0.06, + supports_tool_call: true, + supports_structured_output: true, + supports_reasoning: true, + input_modalities: null, + output_modalities: null, + open_weights: false, + status: 'ga', + release_date: null, + last_updated: null, + ...overrides, + }; +} + +const prevForm = { + context_window: null, + max_tokens: 2000, + cost_per_1m_input: '', + cost_per_1m_input_cache_hit: '', + cost_per_1m_output: '', +}; + +describe('catalogParamPrefill', () => { + it('returns an empty patch when no catalog match', () => { + expect(catalogParamPrefill(null, prevForm)).toEqual({}); + expect(catalogParamPrefill(undefined, prevForm)).toEqual({}); + }); + + it('prefills context window, output cap and prices from the catalog', () => { + expect(catalogParamPrefill(catalogItem(), prevForm)).toEqual({ + context_window: 200_000, + max_tokens: 32_768, + cost_per_1m_input: '0.6', + cost_per_1m_input_cache_hit: '0.06', + cost_per_1m_output: '2.2', + }); + }); + + it('keeps already-filled form values when catalog fields are missing', () => { + const filled = { + ...prevForm, + context_window: 65_536, + max_tokens: 8_192, + cost_per_1m_input: '1', + cost_per_1m_input_cache_hit: '0.1', + cost_per_1m_output: '3', + }; + const sparse = catalogItem({ + context_window: null, + max_output_tokens: null, + cost_per_1m_input: null, + cost_per_1m_output: null, + cost_per_1m_cache_read: null, + }); + expect(catalogParamPrefill(sparse, filled)).toEqual(filled); + }); + + it('fills only what the catalog knows on partially-documented models', () => { + const sparse = catalogItem({ + context_window: 131_072, + max_output_tokens: null, + cost_per_1m_input: null, + cost_per_1m_output: null, + cost_per_1m_cache_read: null, + }); + expect(catalogParamPrefill(sparse, prevForm)).toEqual({ + context_window: 131_072, + max_tokens: 2000, + cost_per_1m_input: '', + cost_per_1m_input_cache_hit: '', + cost_per_1m_output: '', + }); + }); +}); diff --git a/frontend/src/app/admin/model-eval/_model-eval-utils.ts b/frontend/src/app/admin/model-eval/_model-eval-utils.ts index 68be1a60..42a44d71 100644 --- a/frontend/src/app/admin/model-eval/_model-eval-utils.ts +++ b/frontend/src/app/admin/model-eval/_model-eval-utils.ts @@ -17,7 +17,7 @@ */ import { EDITION_LABELS } from '@/lib/edition-naming'; -import type { LlmModelPresetCatalog, LlmModelPresetItem } from '@/lib/api'; +import type { LlmModelPresetCatalog, LlmModelPresetItem, ModelCatalogModelItem } from '@/lib/api'; import type { Tone } from '@/components/ui'; export type { Tone }; @@ -137,4 +137,37 @@ export function parseOptionalNumber(value: string): number | null { if (value.trim() === '') return null; const n = Number(value); return Number.isFinite(n) ? n : null; -} \ No newline at end of file +} +/** + * 目录命中后的表单预填补丁(issue #221)。 + * + * Model ID 精确命中 models.dev 目录项时,用目录值填上下文窗口、输出上限 + * 与三个价格;目录里缺失的字段保留表单已填值(此前 context_window 会被 + * 目录的 null 覆盖清空)。prev 是表单当前值,返回可直接 spread 进 + * setForm 的部分补丁;未命中返回空对象。 + */ +export function catalogParamPrefill( + match: ModelCatalogModelItem | null | undefined, + prev: { + context_window: number | null; + max_tokens: number; + cost_per_1m_input: string; + cost_per_1m_input_cache_hit: string; + cost_per_1m_output: string; + }, +): Partial<{ + context_window: number | null; + max_tokens: number; + cost_per_1m_input: string; + cost_per_1m_input_cache_hit: string; + cost_per_1m_output: string; +}> { + if (!match) return {}; + return { + context_window: match.context_window ?? prev.context_window, + max_tokens: match.max_output_tokens ?? prev.max_tokens, + cost_per_1m_input: match.cost_per_1m_input?.toString() ?? prev.cost_per_1m_input, + cost_per_1m_input_cache_hit: match.cost_per_1m_cache_read?.toString() ?? prev.cost_per_1m_input_cache_hit, + cost_per_1m_output: match.cost_per_1m_output?.toString() ?? prev.cost_per_1m_output, + }; +}