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
44 changes: 33 additions & 11 deletions frontend/src/app/admin/model-eval/ModelEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
} from '@/lib/api';
import {
PROVIDER_PRESETS,
catalogParamPrefill,
formatPresetValue,
formatTokens,
parameterMeta,
Expand Down Expand Up @@ -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<typeof f> = {};
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);
Expand Down Expand Up @@ -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),
}));
};

Expand Down Expand Up @@ -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,上下文窗口、输出上限与费用已按目录预填,超出窗口的调用会自动跳过该路由)
</div>
) : (
<div className="mt-1 text-[11px] leading-4 text-ink-500">
Expand Down
95 changes: 95 additions & 0 deletions frontend/src/app/admin/model-eval/_model-eval-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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: '',
});
});
});
37 changes: 35 additions & 2 deletions frontend/src/app/admin/model-eval/_model-eval-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;
}
}
/**
* 目录命中后的表单预填补丁(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,
};
}
Loading