diff --git a/src/components/providers/forms/CodexFormFields.tsx b/src/components/providers/forms/CodexFormFields.tsx index 76a44f70b..ee13707a5 100644 --- a/src/components/providers/forms/CodexFormFields.tsx +++ b/src/components/providers/forms/CodexFormFields.tsx @@ -53,6 +53,13 @@ import { import { CustomUserAgentField } from "./CustomUserAgentField"; import { LocalProxyRequestOverridesField } from "./LocalProxyRequestOverridesField"; import { cn } from "@/lib/utils"; +import { useLatestRef } from "@/hooks/useLatestRef"; +import { useModelMetadataFill } from "@/hooks/useModelMetadataFill"; +import { codexPresetModelSources } from "@/config/presetModelMetadata"; +import { + fillCodexCatalogModel, + metadataFilledAnything, +} from "./modelMetadataFill"; import type { ClaudeApiKeyField, CodexApiFormat, @@ -628,6 +635,62 @@ export function CodexFormFields({ t, ]); + const fillModelMetadata = useModelMetadataFill({ + baseUrl: codexBaseUrl, + presets: codexPresetModelSources, + prefetch: fetchedModels.length > 0, + }); + // 补全要在「改模型名」提交后立刻读到那一行,所以这两处经 ref 同步提交。 + const catalogRowsRef = useLatestRef(catalogRows); + const commitCatalogRows = useCallback( + (next: CodexCatalogRow[]) => { + catalogRowsRef.current = next; + setCatalogRows(next); + }, + [catalogRowsRef], + ); + + // 按模型名补上这一行已知的窗口、档位和模态(只补空着的)。 + const fillCatalogRowMetadata = useCallback( + (rowId: string, modelId: string) => + fillModelMetadata(modelId, (metadata) => { + const rows = catalogRowsRef.current; + const current = rows.find((row) => row.rowId === rowId); + if (current?.model.trim() !== modelId) return false; + const filled = fillCodexCatalogModel( + current, + metadata, + CODEX_REASONING_LEVELS, + ); + if (!metadataFilledAnything(current, filled)) return false; + commitCatalogRows( + rows.map((row) => (row.rowId === rowId ? filled : row)), + ); + return true; + }), + [catalogRowsRef, commitCatalogRows, fillModelMetadata], + ); + + const handleSelectFetchedCatalogModel = useCallback( + (rowId: string, modelId: string) => { + commitCatalogRows( + catalogRowsRef.current.map((row) => + row.rowId === rowId + ? { + ...row, + model: modelId, + displayName: row.displayName?.trim() + ? row.displayName + : modelId, + } + : row, + ), + ); + fillCatalogRowMetadata(rowId, modelId); + }, + [catalogRowsRef, commitCatalogRows, fillCatalogRowMetadata], + ); + const handleAddCatalogRow = useCallback(() => { if (!onCatalogModelsChange) return; setCatalogRows((current) => [...current, createCatalogRow()]); @@ -678,14 +741,19 @@ export function CodexFormFields({ const handleAddDefaultModelToCatalog = useCallback(() => { if (!onCatalogModelsChange || !trimmedDefaultModel) return; - setCatalogRows((current) => [ - ...current, - createCatalogRow({ - model: trimmedDefaultModel, - displayName: trimmedDefaultModel, - }), - ]); - }, [onCatalogModelsChange, trimmedDefaultModel]); + const row = createCatalogRow({ + model: trimmedDefaultModel, + displayName: trimmedDefaultModel, + }); + commitCatalogRows([...catalogRowsRef.current, row]); + fillCatalogRowMetadata(row.rowId, trimmedDefaultModel); + }, [ + catalogRowsRef, + commitCatalogRows, + fillCatalogRowMetadata, + onCatalogModelsChange, + trimmedDefaultModel, + ]); const renderCatalogActionButtons = (onAdd: () => void, addLabel: string) => (
@@ -1283,12 +1351,7 @@ export function CodexFormFields({ - handleUpdateCatalogRow(index, { - model: id, - displayName: row.displayName?.trim() - ? row.displayName - : id, - }) + handleSelectFetchedCatalogModel(row.rowId, id) } /> )} diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx index 357e9bbb9..54c5fab02 100644 --- a/src/components/providers/forms/HermesFormFields.tsx +++ b/src/components/providers/forms/HermesFormFields.tsx @@ -26,6 +26,10 @@ import { type HermesModel, } from "@/config/hermesProviderPresets"; import type { ProviderCategory } from "@/types"; +import { useLatestRef } from "@/hooks/useLatestRef"; +import { useModelMetadataFill } from "@/hooks/useModelMetadataFill"; +import { hermesPresetModelSources } from "@/config/presetModelMetadata"; +import { fillHermesModel, metadataFilledAnything } from "./modelMetadataFill"; interface HermesFormFieldsProps { baseUrl: string; @@ -185,6 +189,40 @@ export function HermesFormFields({ onModelsChange(next); }; + // 选中拉取到的模型:改 ID,再补上它已知的上下文长度。 + // 补全可能晚到,要用最新的列表和回调:父组件的回调闭包里有其余字段的旧值。 + const modelsRef = useLatestRef(models); + const onModelsChangeRef = useLatestRef(onModelsChange); + const commitModels = (next: HermesModel[]) => { + modelsRef.current = next; + onModelsChangeRef.current(next); + }; + const fillModelMetadata = useModelMetadataFill({ + baseUrl, + presets: hermesPresetModelSources, + prefetch: fetchedModels.length > 0, + }); + const handleSelectFetchedModel = (index: number, id: string) => { + commitModels( + modelsRef.current.map((model, i) => + i === index ? { ...model, id } : model, + ), + ); + // 补全晚到时这一行可能已经挪了位置(删了上面的行),按行 key 重新找。 + const rowKey = modelKeysRef.current[index]; + fillModelMetadata(id, (metadata) => { + const at = modelKeysRef.current.indexOf(rowKey); + const current = modelsRef.current[at]; + if (at < 0 || current?.id !== id) return false; + const filled = fillHermesModel(current, metadata); + if (!metadataFilledAnything(current, filled)) return false; + commitModels( + modelsRef.current.map((model, i) => (i === at ? filled : model)), + ); + return true; + }); + }; + return ( <>
@@ -348,7 +386,7 @@ export function HermesFormFields({ {fetchedModels.length > 0 && ( handleModelChange(index, "id", id)} + onSelect={(id) => handleSelectFetchedModel(index, id)} /> )}
diff --git a/src/components/providers/forms/McodeProviderForm.tsx b/src/components/providers/forms/McodeProviderForm.tsx index 57aded38b..e8d6bd6d9 100644 --- a/src/components/providers/forms/McodeProviderForm.tsx +++ b/src/components/providers/forms/McodeProviderForm.tsx @@ -12,6 +12,7 @@ import { useProvidersQuery } from "@/lib/query/queries"; import type { ProviderFormData } from "@/lib/schemas/provider"; import type { OpenCodeModel, OpenCodeProviderOptions } from "@/types"; import { mcodeProviderPresets } from "@/config/mcodeProviderPresets"; +import { mcodePresetModelSources } from "@/config/presetModelMetadata"; import { BasicFormFields } from "./BasicFormFields"; import { OpenCodeFormFields } from "./OpenCodeFormFields"; import { ProviderPresetSelector } from "./ProviderPresetSelector"; @@ -267,6 +268,7 @@ export function McodeProviderForm({ /> update({ ...config, api })} apiKey={config.options?.apiKey ?? ""} diff --git a/src/components/providers/forms/OpenClawFormFields.tsx b/src/components/providers/forms/OpenClawFormFields.tsx index 301bba89b..2cb4a8fb2 100644 --- a/src/components/providers/forms/OpenClawFormFields.tsx +++ b/src/components/providers/forms/OpenClawFormFields.tsx @@ -23,6 +23,10 @@ import { } from "@/lib/api/model-fetch"; import { openclawApiProtocols } from "@/config/openclawProviderPresets"; import type { ProviderCategory, OpenClawModel } from "@/types"; +import { useLatestRef } from "@/hooks/useLatestRef"; +import { useModelMetadataFill } from "@/hooks/useModelMetadataFill"; +import { openclawPresetModelSources } from "@/config/presetModelMetadata"; +import { fillOpenClawModel, metadataFilledAnything } from "./modelMetadataFill"; interface OpenClawFormFieldsProps { // Base URL @@ -172,6 +176,40 @@ export function OpenClawFormFields({ onModelsChange(newModels); }; + // 选中拉取到的模型:改 ID,再补上它已知的窗口、输出上限、推理、模态和价格。 + // 补全可能晚到,要用最新的列表和回调:父组件的回调闭包里有其余字段的旧值。 + const modelsRef = useLatestRef(models); + const onModelsChangeRef = useLatestRef(onModelsChange); + const commitModels = (next: OpenClawModel[]) => { + modelsRef.current = next; + onModelsChangeRef.current(next); + }; + const fillModelMetadata = useModelMetadataFill({ + baseUrl, + presets: openclawPresetModelSources, + prefetch: fetchedModels.length > 0, + }); + const handleSelectFetchedModel = (index: number, id: string) => { + commitModels( + modelsRef.current.map((model, i) => + i === index ? { ...model, id } : model, + ), + ); + // 补全晚到时这一行可能已经挪了位置(删了上面的行),按行 key 重新找。 + const rowKey = modelKeysRef.current[index]; + fillModelMetadata(id, (metadata) => { + const at = modelKeysRef.current.indexOf(rowKey); + const current = modelsRef.current[at]; + if (at < 0 || current?.id !== id) return false; + const filled = fillOpenClawModel(current, metadata); + if (!metadataFilledAnything(current, filled)) return false; + commitModels( + modelsRef.current.map((model, i) => (i === at ? filled : model)), + ); + return true; + }); + }; + // Update model cost const handleCostChange = ( index: number, @@ -363,7 +401,7 @@ export function OpenClawFormFields({ {fetchedModels.length > 0 && ( handleModelChange(index, "id", id)} + onSelect={(id) => handleSelectFetchedModel(index, id)} /> )}
diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index df1605a93..f689afa51 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -29,6 +29,11 @@ import { import { RequestHeadersEditor } from "./RequestHeadersEditor"; import { FetchedModelPicker } from "./FetchedModelPicker"; import type { ProviderCategory, OpenCodeModel } from "@/types"; +import { useLatestRef } from "@/hooks/useLatestRef"; +import { useModelMetadataFill } from "@/hooks/useModelMetadataFill"; +import type { PresetModelSource } from "@/lib/modelMetadata"; +import { opencodePresetModelSources } from "@/config/presetModelMetadata"; +import { fillOpenCodeModel, metadataFilledAnything } from "./modelMetadataFill"; /** * Model ID input with local state to prevent focus loss. @@ -156,6 +161,8 @@ function ModelOptionKeyInput({ interface OpenCodeFormFieldsProps { allowBuiltinDefaults?: boolean; + /** 选中拉取到的模型时补参数所查的预设(MiniMax Code 传自己的)。 */ + presetModelSources?: () => readonly PresetModelSource[]; apiFormats?: ReadonlyArray<{ value: string; label: string }>; // NPM Package npm: string; @@ -189,6 +196,7 @@ interface OpenCodeFormFieldsProps { export function OpenCodeFormFields({ allowBuiltinDefaults = false, + presetModelSources = opencodePresetModelSources, apiFormats = opencodeNpmPackages, npm, onNpmChange, @@ -283,13 +291,37 @@ export function OpenCodeFormFields({ }); }; + // 选中拉取到的模型时补上它已知的 limit 和模态(只补空着的)。 + // 补全可能晚到,要用最新的列表和回调:父组件的回调闭包里有其余字段的旧值。 + const modelsRef = useLatestRef(models); + const onModelsChangeRef = useLatestRef(onModelsChange); + const commitModels = (next: Record) => { + modelsRef.current = next; + onModelsChangeRef.current(next); + }; + const fillModelMetadata = useModelMetadataFill({ + baseUrl, + presets: presetModelSources, + prefetch: fetchedModels.length > 0, + }); + const fillModelMetadataFor = (id: string) => + fillModelMetadata(id, (metadata) => { + const current = modelsRef.current; + if (!Object.prototype.hasOwnProperty.call(current, id)) return false; + const filled = fillOpenCodeModel(current[id], metadata); + if (!metadataFilledAnything(current[id], filled)) return false; + commitModels({ ...current, [id]: filled }); + return true; + }); + const handleAddFetchedModels = (modelIds: string[]) => { const additions = Object.fromEntries( modelIds .filter((id) => !Object.prototype.hasOwnProperty.call(models, id)) .map((id) => [id, { name: id }]), ); - onModelsChange({ ...models, ...additions }); + commitModels({ ...models, ...additions }); + Object.keys(additions).forEach(fillModelMetadataFor); }; // Remove a model entry @@ -316,7 +348,7 @@ export function OpenCodeFormFields({ newModels[k] = v; } } - onModelsChange(newModels); + commitModels(newModels); // Update expanded set if this model was expanded if (expandedModels.has(oldKey)) { setExpandedModels((prev) => { @@ -773,7 +805,10 @@ export function OpenCodeFormFields({ {fetchedModels.length > 0 && ( handleModelIdChange(key, id)} + onSelect={(id) => { + handleModelIdChange(key, id); + fillModelMetadataFor(id); + }} /> )} diff --git a/src/components/providers/forms/modelMetadataFill.ts b/src/components/providers/forms/modelMetadataFill.ts new file mode 100644 index 000000000..6cfe89404 --- /dev/null +++ b/src/components/providers/forms/modelMetadataFill.ts @@ -0,0 +1,106 @@ +// 从拉取列表选中模型后,把查到的已知参数换成各应用的字段格式补进这一行。 +// 统一规则:只补空着的字段,用户填过的值一律不动;布尔和模态只往「支持」方向补。 + +import type { KnownModelMetadata } from "@/lib/modelMetadata"; +import type { CodexCatalogModel, OpenClawModel, OpenCodeModel } from "@/types"; +import type { HermesModel } from "@/config/hermesProviderPresets"; + +/** 补全前后是否不同:没补上任何字段时不提示用户。 */ +export const metadataFilledAnything = (before: unknown, after: unknown) => + JSON.stringify(before) !== JSON.stringify(after); + +const isBlank = (value: unknown) => + value === undefined || value === null || String(value).trim() === ""; + +/** 只认文字和图片两种输入(Codex / OpenClaw / Pi 的模态字段只有这两种)。 */ +function textImageModalities( + modalities: string[] | undefined, +): string[] | undefined { + if (!modalities) return undefined; + return modalities.includes("image") ? ["text", "image"] : ["text"]; +} + +export function fillCodexCatalogModel( + row: T, + metadata: KnownModelMetadata, + knownLevels: readonly string[], +): T { + const next = { ...row }; + if (isBlank(row.contextWindow) && metadata.contextWindow) { + next.contextWindow = String(metadata.contextWindow); + } + if (!row.reasoningLevels?.length && metadata.reasoningEfforts) { + // 按 Codex 的档位顺序排,丢掉它不认识的值。 + const levels = knownLevels.filter((level) => + metadata.reasoningEfforts?.includes(level), + ); + if (levels.length > 0) next.reasoningLevels = levels; + } + if (!row.inputModalities && metadata.inputModalities) { + next.inputModalities = textImageModalities(metadata.inputModalities); + } + return next; +} + +export function fillOpenClawModel( + model: OpenClawModel, + metadata: KnownModelMetadata, +): OpenClawModel { + const next = { ...model }; + if (isBlank(model.contextWindow) && metadata.contextWindow) { + next.contextWindow = metadata.contextWindow; + } + if (isBlank(model.maxTokens) && metadata.maxOutputTokens) { + next.maxTokens = metadata.maxOutputTokens; + } + if (model.reasoning === undefined && metadata.reasoning !== undefined) { + next.reasoning = metadata.reasoning; + } else if (metadata.reasoning === true) { + next.reasoning = true; + } + if (!model.input?.length) { + const input = textImageModalities(metadata.inputModalities); + if (input) next.input = input; + } else if ( + metadata.inputModalities?.includes("image") && + !model.input.includes("image") + ) { + // 新建的行默认只有 text,模型支持图片就补上。 + next.input = [...model.input, "image"]; + } + if (!model.cost && metadata.cost) { + next.cost = { ...metadata.cost }; + } + return next; +} + +export function fillHermesModel( + model: HermesModel, + metadata: KnownModelMetadata, +): HermesModel { + return isBlank(model.context_length) && metadata.contextWindow + ? { ...model, context_length: metadata.contextWindow } + : model; +} + +export function fillOpenCodeModel( + model: OpenCodeModel, + metadata: KnownModelMetadata, +): OpenCodeModel { + const next = { ...model }; + const limit = { ...(model.limit ?? {}) }; + if (isBlank(limit.context) && metadata.contextWindow) { + limit.context = metadata.contextWindow; + } + if (isBlank(limit.output) && metadata.maxOutputTokens) { + limit.output = metadata.maxOutputTokens; + } + if (Object.keys(limit).length > 0) next.limit = limit; + if (model.modalities === undefined && metadata.inputModalities) { + next.modalities = { + input: metadata.inputModalities, + output: metadata.outputModalities ?? ["text"], + }; + } + return next; +} diff --git a/src/config/presetModelMetadata.ts b/src/config/presetModelMetadata.ts new file mode 100644 index 000000000..c55d46ae1 --- /dev/null +++ b/src/config/presetModelMetadata.ts @@ -0,0 +1,142 @@ +// 把各应用预设里手工核实过的模型参数整理成 `PresetModelSource`,作为表单补全 +// 模型参数时的第一优先级(见 `resolveModelMetadata`)。每个应用首次用到时才整理。 + +import { + positiveInteger, + stringList, + type KnownModelMetadata, + type PresetModelSource, +} from "@/lib/modelMetadata"; +import { extractCodexBaseUrl } from "@/utils/providerConfigUtils"; +import type { OpenCodeModel } from "@/types"; +import { codexProviderPresets } from "./codexProviderPresets"; +import { hermesProviderPresets } from "./hermesProviderPresets"; +import { mcodeProviderPresets } from "./mcodeProviderPresets"; +import { openclawProviderPresets } from "./openclawProviderPresets"; +import { opencodeProviderPresets } from "./opencodeProviderPresets"; +import { piProviderPresets } from "./piProviderPresets"; + +function compact(metadata: KnownModelMetadata): KnownModelMetadata | null { + const entries = Object.entries(metadata).filter( + ([, value]) => value !== undefined, + ); + return entries.length > 0 + ? (Object.fromEntries(entries) as KnownModelMetadata) + : null; +} + +function buildSources( + endpoints: (string | undefined)[], + models: Iterable, + toMetadata: (model: M) => KnownModelMetadata, +): PresetModelSource[] { + const map = new Map(); + for (const [id, model] of models) { + const metadata = id ? compact(toMetadata(model)) : null; + if (metadata) map.set(id, metadata); + } + if (map.size === 0) return []; + return [...new Set(endpoints.filter((url): url is string => !!url))].map( + (baseUrl) => ({ baseUrl, models: map }), + ); +} + +function lazy(build: () => T): () => T { + let value: T | undefined; + return () => (value ??= build()); +} + +export const codexPresetModelSources = lazy(() => + codexProviderPresets.flatMap((preset) => + buildSources( + [ + extractCodexBaseUrl(preset.config), + ...(preset.endpointCandidates ?? []), + ], + (preset.modelCatalog ?? []).map((row) => [row.model, row] as const), + (row) => ({ + contextWindow: positiveInteger(row.contextWindow), + reasoningEfforts: stringList(row.reasoningLevels), + inputModalities: stringList(row.inputModalities), + }), + ), + ), +); + +export const openclawPresetModelSources = lazy(() => + openclawProviderPresets.flatMap((preset) => + buildSources( + [preset.settingsConfig.baseUrl], + (preset.settingsConfig.models ?? []).map( + (model) => [model.id, model] as const, + ), + (model) => ({ + contextWindow: positiveInteger(model.contextWindow), + maxOutputTokens: positiveInteger(model.maxTokens), + reasoning: model.reasoning, + inputModalities: stringList(model.input), + cost: model.cost, + }), + ), + ), +); + +export const hermesPresetModelSources = lazy(() => + hermesProviderPresets.flatMap((preset) => + buildSources( + [preset.settingsConfig.base_url], + (preset.settingsConfig.models ?? []).map( + (model) => [model.id, model] as const, + ), + (model) => ({ contextWindow: positiveInteger(model.context_length) }), + ), + ), +); + +function openCodeModelMetadata(model: OpenCodeModel): KnownModelMetadata { + const modalities = + model.modalities && typeof model.modalities === "object" + ? (model.modalities as { input?: unknown; output?: unknown }) + : undefined; + return { + contextWindow: positiveInteger(model.limit?.context), + maxOutputTokens: positiveInteger(model.limit?.output), + inputModalities: stringList(modalities?.input), + outputModalities: stringList(modalities?.output), + }; +} + +export const opencodePresetModelSources = lazy(() => + opencodeProviderPresets.flatMap((preset) => + buildSources( + [preset.settingsConfig.options?.baseURL], + Object.entries(preset.settingsConfig.models ?? {}), + openCodeModelMetadata, + ), + ), +); + +export const mcodePresetModelSources = lazy(() => + mcodeProviderPresets.flatMap((preset) => + buildSources( + [preset.settingsConfig.options.baseURL], + Object.entries(preset.settingsConfig.models ?? {}), + openCodeModelMetadata, + ), + ), +); + +export const piPresetModelSources = lazy(() => + piProviderPresets.flatMap((preset) => + buildSources( + [preset.settingsConfig.baseUrl], + preset.settingsConfig.models.map((model) => [model.id, model] as const), + (model) => ({ + contextWindow: positiveInteger(model.contextWindow), + maxOutputTokens: positiveInteger(model.maxTokens), + reasoning: model.reasoning, + inputModalities: stringList(model.input), + }), + ), + ), +); diff --git a/src/hooks/useLatestRef.ts b/src/hooks/useLatestRef.ts new file mode 100644 index 000000000..d0e42c67b --- /dev/null +++ b/src/hooks/useLatestRef.ts @@ -0,0 +1,10 @@ +import { useEffect, useRef } from "react"; + +/** 始终指向最近一次渲染的值;给异步回调读最新的 props / state 用。 */ +export function useLatestRef(value: T) { + const ref = useRef(value); + useEffect(() => { + ref.current = value; + }, [value]); + return ref; +} diff --git a/src/hooks/useModelMetadataFill.ts b/src/hooks/useModelMetadataFill.ts new file mode 100644 index 000000000..0f2e270c1 --- /dev/null +++ b/src/hooks/useModelMetadataFill.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + fetchModelsDev, + MODELS_DEV_QUERY_KEY, + MODELS_DEV_STALE_TIME_MS, + type ModelsDevResponse, +} from "@/lib/modelsDev"; +import { + resolveModelMetadata, + type ModelMetadataSource, + type PresetModelSource, + type ResolvedModelMetadata, +} from "@/lib/modelMetadata"; +import { useLatestRef } from "./useLatestRef"; + +const FILLED_TOAST_ID = "model-metadata-filled"; +const MODELS_DEV_QUERY = { + queryKey: MODELS_DEV_QUERY_KEY, + queryFn: fetchModelsDev, + staleTime: MODELS_DEV_STALE_TIME_MS, + // 表单只预取不订阅,没有订阅者时缓存按 gcTime 回收(默认 5 分钟),这里与 staleTime 对齐。 + gcTime: MODELS_DEV_STALE_TIME_MS, +}; + +/** + * 表单从拉取列表里选中模型后,调 `fill(modelId, apply)` 补这一行空着的参数。 + * + * `apply(metadata)` 把参数补进当时仍是这个模型的那一行并提交,返回是否真的改动 + * 了字段。它会被调用最多两次:当场一次(同地址预设 + 已缓存的 models.dev), + * models.dev 数据晚到时再一次,所以调用方先提交「改模型 ID」,`apply` 读最新的 + * 行、确认模型没被用户改掉、只补空字段。有改动时由这里提示用户核对;同一轮里 + * 补了几个模型(批量添加、两次先后到达)只合并成一条提示。 + * + * `prefetch` 为真(已经拉过模型列表)时提前下载 models.dev,和价格导入共用同 + * 一份查询缓存;离线或下载失败时只用预设里的值,不打扰用户。 + */ +export function useModelMetadataFill({ + baseUrl, + presets, + prefetch, +}: { + baseUrl: string; + presets: () => readonly PresetModelSource[]; + prefetch: boolean; +}) { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + // 只下载不订阅:表单不读这份数据,数据到了也不必重渲染。 + useEffect(() => { + if (prefetch) void queryClient.prefetchQuery(MODELS_DEV_QUERY); + }, [prefetch, queryClient]); + + const baseUrlRef = useLatestRef(baseUrl); + const presetsRef = useLatestRef(presets); + + const pending = useRef<{ + models: Set; + sources: Set; + timer?: ReturnType; + }>({ models: new Set(), sources: new Set() }); + useEffect(() => () => clearTimeout(pending.current.timer), []); + + const notify = useCallback( + (modelId: string, sources: readonly ModelMetadataSource[]) => { + const batch = pending.current; + batch.models.add(modelId); + for (const source of sources) batch.sources.add(source); + clearTimeout(batch.timer); + batch.timer = setTimeout(() => { + const models = [...batch.models]; + const fromPreset = batch.sources.has("preset"); + const fromModelsDev = batch.sources.has("models-dev"); + batch.models.clear(); + batch.sources.clear(); + const source = + fromPreset && fromModelsDev + ? t("providerForm.modelMetadataSourceBoth", { + defaultValue: "预设和 models.dev", + }) + : fromPreset + ? t("providerForm.modelMetadataSourcePreset", { + defaultValue: "预设", + }) + : "models.dev"; + toast.info( + models.length === 1 + ? t("providerForm.modelMetadataFilled", { + model: models[0], + source, + defaultValue: + "已补全 {{model}} 的上下文等参数(来源:{{source}}),请核对", + }) + : t("providerForm.modelMetadataFilledMany", { + count: models.length, + source, + defaultValue: + "已补全 {{count}} 个模型的上下文等参数(来源:{{source}}),请核对", + }), + { id: FILLED_TOAST_ID }, + ); + }, 50); + }, + [t], + ); + + return useCallback( + ( + modelId: string, + apply: (metadata: ResolvedModelMetadata) => boolean, + ): void => { + const resolve = (modelsDev?: ModelsDevResponse) => + resolveModelMetadata(modelId, { + baseUrl: baseUrlRef.current, + presets: presetsRef.current(), + modelsDev, + }); + const run = (metadata: ResolvedModelMetadata | null) => { + if (metadata && apply(metadata)) notify(modelId, metadata.sources); + }; + + const cached = + queryClient.getQueryData(MODELS_DEV_QUERY_KEY); + run(resolve(cached)); + if (cached) return; + queryClient + .fetchQuery(MODELS_DEV_QUERY) + .then((data) => run(resolve(data))) + .catch(() => { + // 离线或 models.dev 不可用:保持预设值补过的样子。 + }); + }, + [baseUrlRef, notify, presetsRef, queryClient], + ); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d2866b97f..bbb56e5a7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1341,6 +1341,10 @@ "searchModelEmpty": "No matching models.", "searchModelAriaLabel": "Select model", "fetchModelsSuccess": "Found {{count}} models", + "modelMetadataFilled": "Filled in context window and other parameters for {{model}} (source: {{source}}). Please double-check.", + "modelMetadataFilledMany": "Filled in context window and other parameters for {{count}} models (source: {{source}}). Please double-check.", + "modelMetadataSourcePreset": "preset", + "modelMetadataSourceBoth": "preset and models.dev", "fetchedModelsTitle": "Available models ({{count}})", "modelAlreadyAdded": "Already added", "addSelectedModels": "Add selected ({{count}})", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 67947e353..0e014257d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1341,6 +1341,10 @@ "searchModelEmpty": "一致するモデルがありません", "searchModelAriaLabel": "モデルを選択", "fetchModelsSuccess": "{{count}}件のモデルを取得", + "modelMetadataFilled": "{{model}} のコンテキスト長などのパラメータを補完しました(出典:{{source}})。内容をご確認ください", + "modelMetadataFilledMany": "{{count}} 件のモデルのコンテキスト長などのパラメータを補完しました(出典:{{source}})。内容をご確認ください", + "modelMetadataSourcePreset": "プリセット", + "modelMetadataSourceBoth": "プリセットと models.dev", "fetchedModelsTitle": "利用可能なモデル({{count}}件)", "modelAlreadyAdded": "追加済み", "addSelectedModels": "選択したモデルを追加({{count}}件)", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 9b2d40d72..07745e358 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1342,6 +1342,10 @@ "searchModelEmpty": "沒有符合的模型", "searchModelAriaLabel": "選擇模型", "fetchModelsSuccess": "取得 {{count}} 個模型", + "modelMetadataFilled": "已補齊 {{model}} 的上下文等參數(來源:{{source}}),請核對", + "modelMetadataFilledMany": "已補齊 {{count}} 個模型的上下文等參數(來源:{{source}}),請核對", + "modelMetadataSourcePreset": "預設", + "modelMetadataSourceBoth": "預設和 models.dev", "fetchedModelsTitle": "可用模型({{count}})", "modelAlreadyAdded": "已新增", "addSelectedModels": "新增所選模型({{count}})", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cb07b1c8b..0717f8521 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1341,6 +1341,10 @@ "searchModelEmpty": "没有匹配的模型", "searchModelAriaLabel": "选择模型", "fetchModelsSuccess": "获取到 {{count}} 个模型", + "modelMetadataFilled": "已补全 {{model}} 的上下文等参数(来源:{{source}}),请核对", + "modelMetadataFilledMany": "已补全 {{count}} 个模型的上下文等参数(来源:{{source}}),请核对", + "modelMetadataSourcePreset": "预设", + "modelMetadataSourceBoth": "预设和 models.dev", "fetchedModelsTitle": "可用模型({{count}})", "modelAlreadyAdded": "已添加", "addSelectedModels": "添加所选模型({{count}})", diff --git a/src/lib/modelMetadata.ts b/src/lib/modelMetadata.ts new file mode 100644 index 000000000..565274587 --- /dev/null +++ b/src/lib/modelMetadata.ts @@ -0,0 +1,352 @@ +import { + normalizeModelsDevModelId, + type ModelsDevModel, + type ModelsDevResponse, +} from "./modelsDev"; + +/** + * 一个模型的已知参数,供表单在用户从拉取列表里选中模型时补全空着的字段。 + * 各应用把它换成自己的字段格式(Codex `contextWindow`、Hermes `context_length`、 + * OpenCode `limit.context`……)。 + */ +export interface KnownModelMetadata { + contextWindow?: number; + maxOutputTokens?: number; + reasoning?: boolean; + /** 模型接受的思考档位(models.dev `reasoning_options` 里 effort 类的取值)。 */ + reasoningEfforts?: string[]; + inputModalities?: string[]; + outputModalities?: string[]; + /** 美元 / 百万 token。只取自同一家供应商,原厂兜底不带价格。 */ + cost?: { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + }; +} + +/** 参数来自哪里:同地址预设,还是 models.dev。补全提示里告诉用户。 */ +export type ModelMetadataSource = "preset" | "models-dev"; + +export interface ResolvedModelMetadata extends KnownModelMetadata { + /** 实际贡献了字段的来源,按优先级排序。 */ + sources: ModelMetadataSource[]; +} + +/** 一个预设的地址和它手工核实过的模型参数。 */ +export interface PresetModelSource { + baseUrl: string; + models: ReadonlyMap; +} + +const VERSION_SEGMENT = /^v\d+(?:(?:alpha|beta)\d*)?$/; +const REQUEST_SEGMENTS = new Set(["responses", "messages"]); + +/** + * 把地址归一成「域名 + 路径」,去掉末尾的版本号(`v1`、`v4`、`v1beta`)和请求 + * 路径(`chat/completions`、`responses`、`messages`):这些写不写都是同一个接口。 + * 其余路径必须完全一致才算同一个接口——`opencode.ai/zen/v1` 和 `/zen/go/v1`、 + * `api.z.ai/api/paas/v4` 和 `/api/coding/paas/v4` 都是同域名下的不同套餐, + * 窗口和价格不同,不能因为一方是另一方的上级路径就混用。 + */ +function endpointKey(url: string): string | null { + const trimmed = url.trim(); + if (!trimmed) return null; + let parsed: URL; + try { + parsed = new URL( + /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`, + ); + } catch { + return null; + } + const segments = parsed.pathname + .split("/") + .filter(Boolean) + .map((segment) => segment.toLowerCase()); + for (;;) { + const last = segments[segments.length - 1]; + if (last === "completions" && segments[segments.length - 2] === "chat") { + segments.length -= 2; + } else if (REQUEST_SEGMENTS.has(last) || VERSION_SEGMENT.test(last)) { + segments.length -= 1; + } else { + break; + } + } + return [parsed.hostname.toLowerCase(), ...segments].join("/"); +} + +/** 挑出与地址是同一个接口的候选,保持原有顺序。 */ +function matchEndpoint( + baseUrl: string, + candidates: readonly T[], + endpointOf: (candidate: T) => string | null, +): T[] { + const target = endpointKey(baseUrl); + if (!target) return []; + return candidates.filter((candidate) => endpointOf(candidate) === target); +} + +function findModelInMap( + models: ReadonlyMap, + modelId: string, +): T | undefined { + const exact = models.get(modelId); + if (exact !== undefined) return exact; + const lower = modelId.toLowerCase(); + const normalized = normalizeModelsDevModelId(modelId); + let normalizedMatch: T | undefined; + for (const [id, value] of models) { + if (id.toLowerCase() === lower) return value; + if ( + normalizedMatch === undefined && + normalizeModelsDevModelId(id) === normalized + ) { + normalizedMatch = value; + } + } + return normalizedMatch; +} + +interface IndexedProvider { + id: string; + endpoint: string | null; + models: Map; +} + +interface ModelsDevIndex { + providers: IndexedProvider[]; + byId: Map; + /** 规范化 ID → 各家同名条目,给原厂兜底用。 */ + byNormalizedId: Map; + /** 被 `canonical_model_id` 指向过的供应商,即原厂。 */ + vendors: Set; +} + +const indexCache = new WeakMap(); + +function indexModelsDev(data: ModelsDevResponse): ModelsDevIndex { + const cached = indexCache.get(data); + if (cached) return cached; + + const providers: IndexedProvider[] = []; + const byId = new Map(); + const byNormalizedId = new Map< + string, + { providerId: string; model: ModelsDevModel }[] + >(); + const vendors = new Set(); + + for (const [providerId, provider] of Object.entries(data)) { + if (!provider || typeof provider !== "object") continue; + const models = new Map(); + for (const [modelId, model] of Object.entries(provider.models ?? {})) { + if (!model || typeof model !== "object") continue; + models.set(modelId, model); + const normalized = normalizeModelsDevModelId(modelId); + if (normalized) { + const list = byNormalizedId.get(normalized) ?? []; + list.push({ providerId, model }); + byNormalizedId.set(normalized, list); + } + const canonical = model.canonical_model_id; + if (typeof canonical === "string" && canonical.includes("/")) { + vendors.add(canonical.slice(0, canonical.indexOf("/"))); + } + } + const indexed: IndexedProvider = { + id: providerId, + endpoint: + typeof provider.api === "string" ? endpointKey(provider.api) : null, + models, + }; + providers.push(indexed); + byId.set(providerId, indexed); + } + + const index = { providers, byId, byNormalizedId, vendors }; + indexCache.set(data, index); + return index; +} + +/** 正整数(接受数字字符串,表单里的窗口常存成字符串);其余返回 undefined。 */ +export function positiveInteger(value: unknown): number | undefined { + const parsed = + typeof value === "string" && value.trim() ? Number(value) : value; + return typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0 + ? Math.trunc(parsed) + : undefined; +} + +/** 去空、小写、去重后的字符串列表;空列表返回 undefined。 */ +export function stringList(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const list = value + .filter((item): item is string => typeof item === "string") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean); + return list.length > 0 ? [...new Set(list)] : undefined; +} + +function metadataFromModelsDev( + model: ModelsDevModel, + withCost: boolean, +): KnownModelMetadata { + const efforts = stringList( + (model.reasoning_options ?? []) + .filter((option) => option?.type === "effort") + .flatMap((option) => option.values ?? []), + ); + const cost = model.cost; + const hasCost = + withCost && + typeof cost?.input === "number" && + typeof cost?.output === "number"; + return { + contextWindow: positiveInteger(model.limit?.context), + maxOutputTokens: positiveInteger(model.limit?.output), + reasoning: + typeof model.reasoning === "boolean" ? model.reasoning : undefined, + reasoningEfforts: efforts, + inputModalities: stringList(model.modalities?.input), + outputModalities: stringList(model.modalities?.output), + cost: hasCost + ? { + input: cost.input as number, + output: cost.output as number, + ...(typeof cost.cache_read === "number" + ? { cacheRead: cost.cache_read } + : {}), + ...(typeof cost.cache_write === "number" + ? { cacheWrite: cost.cache_write } + : {}), + } + : undefined, + }; +} + +/** 按地址认出 models.dev 里的那家供应商,再查它名下的这个模型。 */ +function findByProvider( + index: ModelsDevIndex, + baseUrl: string, + modelId: string, +): ModelsDevModel | undefined { + for (const provider of matchEndpoint( + baseUrl, + index.providers, + (p) => p.endpoint, + )) { + const model = findModelInMap(provider.models, modelId); + if (model) return model; + } + return undefined; +} + +/** + * 原厂兜底:同名条目指向的原厂条目,或者原厂自己名下的同名条目。 + * 指向不止一家原厂时说不清是哪个模型,放弃。 + */ +function findByVendor( + index: ModelsDevIndex, + modelId: string, +): ModelsDevModel | undefined { + const normalized = normalizeModelsDevModelId(modelId); + const found = new Map(); + for (const { providerId, model } of index.byNormalizedId.get(normalized) ?? + []) { + const canonical = model.canonical_model_id; + if (typeof canonical === "string" && canonical.includes("/")) { + const vendorId = canonical.slice(0, canonical.indexOf("/")); + const vendorModel = index.byId + .get(vendorId) + ?.models.get(canonical.slice(vendorId.length + 1)); + if (vendorModel) { + found.set(canonical, vendorModel); + continue; + } + } + if (index.vendors.has(providerId)) { + found.set(`${providerId}/${model.id ?? normalized}`, model); + } + } + return found.size === 1 ? found.values().next().value : undefined; +} + +function mergeMissing( + primary: KnownModelMetadata, + fallback: KnownModelMetadata, +): KnownModelMetadata { + const merged: KnownModelMetadata = { ...primary }; + for (const [key, value] of Object.entries(fallback) as [ + keyof KnownModelMetadata, + KnownModelMetadata[keyof KnownModelMetadata], + ][]) { + if (merged[key] === undefined && value !== undefined) { + (merged as Record)[key] = value; + } + } + return merged; +} + +function fieldCount(metadata: KnownModelMetadata): number { + return Object.values(metadata).filter((value) => value !== undefined).length; +} + +/** + * 查一个模型的已知参数。逐字段按优先级取值: + * 1. 同地址预设里手工核实过的那一行(同一个模型各家部署的窗口差很多, + * 比如 glm-5.2 从 200K 到 1M 都有,预设写的就是这一家的真实值); + * 2. models.dev 里按地址认出的同一家供应商; + * 3. models.dev 里的原厂条目(不带价格:转售价不等于原厂价), + * 认出了供应商也照样用它补供应商条目里没写的字段。 + * 都查不到返回 null。 + */ +export function resolveModelMetadata( + modelId: string, + options: { + baseUrl: string; + presets?: readonly PresetModelSource[]; + modelsDev?: ModelsDevResponse; + }, +): ResolvedModelMetadata | null { + const id = modelId.trim(); + if (!id) return null; + + let metadata: KnownModelMetadata = {}; + const sources: ModelMetadataSource[] = []; + const add = (source: ModelMetadataSource, found: KnownModelMetadata) => { + const before = fieldCount(metadata); + metadata = mergeMissing(metadata, found); + if (fieldCount(metadata) > before && !sources.includes(source)) { + sources.push(source); + } + }; + + for (const preset of matchEndpoint( + options.baseUrl, + options.presets ?? [], + (source) => endpointKey(source.baseUrl), + )) { + const presetModel = findModelInMap(preset.models, id); + if (presetModel) { + add("preset", presetModel); + break; + } + } + + if (options.modelsDev) { + const index = indexModelsDev(options.modelsDev); + const providerModel = findByProvider(index, options.baseUrl, id); + if (providerModel) { + add("models-dev", metadataFromModelsDev(providerModel, true)); + } + const vendorModel = findByVendor(index, id); + if (vendorModel) { + add("models-dev", metadataFromModelsDev(vendorModel, false)); + } + } + + return sources.length > 0 ? { ...metadata, sources } : null; +} diff --git a/tests/components/GrokBuildProviderForm.test.tsx b/tests/components/GrokBuildProviderForm.test.tsx index 6b378fbc4..6ed35e125 100644 --- a/tests/components/GrokBuildProviderForm.test.tsx +++ b/tests/components/GrokBuildProviderForm.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; +import { renderWithQueryClient as render } from "../utils/testQueryClient"; import userEvent from "@testing-library/user-event"; import { parse as parseToml } from "smol-toml"; import { describe, expect, it, vi } from "vitest"; diff --git a/tests/components/HermesFormFields.test.tsx b/tests/components/HermesFormFields.test.tsx index 473bd4423..8e5bd39cb 100644 --- a/tests/components/HermesFormFields.test.tsx +++ b/tests/components/HermesFormFields.test.tsx @@ -1,9 +1,15 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import type { ComponentProps, PropsWithChildren } from "react"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { http, HttpResponse } from "msw"; +import { renderWithQueryClient as render } from "../utils/testQueryClient"; +import { useState, type ComponentProps, type PropsWithChildren } from "react"; import { useForm } from "react-hook-form"; import { describe, expect, it, vi } from "vitest"; import { HermesFormFields } from "@/components/providers/forms/HermesFormFields"; +import type { HermesModel } from "@/config/hermesProviderPresets"; import { Form } from "@/components/ui/form"; +import { MODELS_DEV_API_URL } from "@/lib/modelsDev"; +import { server } from "../msw/server"; type HermesFormFieldsProps = ComponentProps; @@ -45,6 +51,112 @@ const renderHermesForm = (overrides: Partial = {}) => { }; describe("HermesFormFields", () => { + it("fills the context length of a model picked from the fetched list", async () => { + Element.prototype.scrollIntoView = vi.fn(); + const user = userEvent.setup(); + server.use( + http.post("http://tauri.local/fetch_models_for_config", () => + HttpResponse.json([{ id: "model-x", ownedBy: "example" }]), + ), + http.get(MODELS_DEV_API_URL, () => + HttpResponse.json({ + example: { + api: "https://api.example.com/v1", + models: { "model-x": { limit: { context: 123456 } } }, + }, + }), + ), + ); + const onModelsChange = vi.fn(); + const { props } = renderHermesForm({ onModelsChange }); + + // 拉模型列表时就开始预取 models.dev,选中时一次提交就带上参数。 + await user.click( + screen.getByRole("button", { name: "providerForm.fetchModels" }), + ); + await user.click( + (await screen.findAllByRole("button", { name: "Select model" }))[0], + ); + await user.click(await screen.findByRole("option", { name: "model-x" })); + + await waitFor(() => + expect(onModelsChange).toHaveBeenLastCalledWith([ + { ...props.models[0], id: "model-x", context_length: 123456 }, + props.models[1], + ]), + ); + }); + + it("fills the picked row even after rows above it were removed", async () => { + Element.prototype.scrollIntoView = vi.fn(); + const user = userEvent.setup(); + let releaseModelsDev!: () => void; + const modelsDevReady = new Promise((resolve) => { + releaseModelsDev = resolve; + }); + server.use( + http.post("http://tauri.local/fetch_models_for_config", () => + HttpResponse.json([{ id: "model-x", ownedBy: "example" }]), + ), + http.get(MODELS_DEV_API_URL, async () => { + await modelsDevReady; + return HttpResponse.json({ + example: { + api: "https://api.example.com/v1", + models: { "model-x": { limit: { context: 123456 } } }, + }, + }); + }), + ); + const committed = vi.fn(); + const Stateful = () => { + const [models, setModels] = useState([ + { id: "model-a", name: "Model A" }, + { id: "model-b", name: "Model B" }, + ]); + return ( + + { + committed(next); + setModels(next); + }} + rateLimitDelay={0.5} + onRateLimitDelayChange={vi.fn()} + /> + + ); + }; + render(); + + // 给第二行选模型,models.dev 还没返回时删掉第一行,第二行挪到最上面。 + await user.click( + screen.getByRole("button", { name: "providerForm.fetchModels" }), + ); + await user.click( + (await screen.findAllByRole("button", { name: "Select model" }))[1], + ); + await user.click(await screen.findByRole("option", { name: "model-x" })); + await user.click(screen.getAllByRole("button", { name: "移除模型" })[0]); + releaseModelsDev(); + + await waitFor(() => + expect(committed).toHaveBeenLastCalledWith([ + { id: "model-x", name: "Model B", context_length: 123456 }, + ]), + ); + }); + it("uses the clean Pi-style model rows without role badges", () => { renderHermesForm(); diff --git a/tests/components/OpenClawFormFields.test.tsx b/tests/components/OpenClawFormFields.test.tsx index 130aaba5b..d842c5b23 100644 --- a/tests/components/OpenClawFormFields.test.tsx +++ b/tests/components/OpenClawFormFields.test.tsx @@ -1,4 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; +import { renderWithQueryClient as render } from "../utils/testQueryClient"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import { FormProvider, useForm } from "react-hook-form"; diff --git a/tests/components/OpenCodeFormFields.test.tsx b/tests/components/OpenCodeFormFields.test.tsx index 2372f127f..d7f83918f 100644 --- a/tests/components/OpenCodeFormFields.test.tsx +++ b/tests/components/OpenCodeFormFields.test.tsx @@ -1,11 +1,11 @@ import { act, fireEvent, - render, screen, waitFor, within, } from "@testing-library/react"; +import { renderWithQueryClient as render } from "../utils/testQueryClient"; import userEvent from "@testing-library/user-event"; import type { ComponentProps, PropsWithChildren } from "react"; import { useForm } from "react-hook-form"; diff --git a/tests/components/modelMetadataFill.test.ts b/tests/components/modelMetadataFill.test.ts new file mode 100644 index 000000000..620f76a70 --- /dev/null +++ b/tests/components/modelMetadataFill.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; + +import { + fillCodexCatalogModel, + fillHermesModel, + fillOpenClawModel, + fillOpenCodeModel, +} from "@/components/providers/forms/modelMetadataFill"; +import type { KnownModelMetadata } from "@/lib/modelMetadata"; + +const metadata: KnownModelMetadata = { + contextWindow: 262144, + maxOutputTokens: 32768, + reasoning: true, + reasoningEfforts: ["max", "low", "turbo", "high"], + inputModalities: ["text", "image", "video"], + outputModalities: ["text"], + cost: { input: 1, output: 4 }, +}; + +const CODEX_LEVELS = ["none", "low", "medium", "high", "xhigh", "max"]; + +describe("fillCodexCatalogModel", () => { + it("fills blank fields in Codex's level order", () => { + expect( + fillCodexCatalogModel( + { model: "kimi-k2.6", contextWindow: "" }, + metadata, + CODEX_LEVELS, + ), + ).toEqual({ + model: "kimi-k2.6", + contextWindow: "262144", + reasoningLevels: ["low", "high", "max"], + inputModalities: ["text", "image"], + }); + }); + + it("never overwrites what the user already set", () => { + const row = { + model: "kimi-k2.6", + contextWindow: "128000", + reasoningLevels: ["high"], + inputModalities: ["text"], + }; + expect(fillCodexCatalogModel(row, metadata, CODEX_LEVELS)).toEqual(row); + }); +}); + +describe("fillOpenClawModel", () => { + it("fills numbers and cost, and upgrades the default text-only input", () => { + expect( + fillOpenClawModel({ id: "m", name: "m", input: ["text"] }, metadata), + ).toEqual({ + id: "m", + name: "m", + contextWindow: 262144, + maxTokens: 32768, + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 4 }, + }); + }); + + it("keeps user values and never downgrades capabilities", () => { + const model = { + id: "m", + name: "m", + contextWindow: 1000, + maxTokens: 10, + reasoning: true, + input: ["text", "image"], + cost: { input: 9, output: 9 }, + }; + expect( + fillOpenClawModel(model, { + ...metadata, + reasoning: false, + inputModalities: ["text"], + }), + ).toEqual(model); + }); +}); + +describe("fillHermesModel", () => { + it("fills context_length only when blank", () => { + expect(fillHermesModel({ id: "m" }, metadata)).toEqual({ + id: "m", + context_length: 262144, + }); + expect(fillHermesModel({ id: "m", context_length: 1 }, metadata)).toEqual({ + id: "m", + context_length: 1, + }); + }); +}); + +describe("fillOpenCodeModel", () => { + it("fills missing limits and modalities", () => { + expect(fillOpenCodeModel({ name: "m" }, metadata)).toEqual({ + name: "m", + limit: { context: 262144, output: 32768 }, + modalities: { input: ["text", "image", "video"], output: ["text"] }, + }); + }); + + it("only fills the missing half of limit", () => { + expect( + fillOpenCodeModel( + { + name: "m", + limit: { context: 1000 }, + modalities: { input: ["text"] }, + }, + metadata, + ), + ).toEqual({ + name: "m", + limit: { context: 1000, output: 32768 }, + modalities: { input: ["text"] }, + }); + }); + + it("leaves the model untouched when nothing is known", () => { + expect(fillOpenCodeModel({ name: "m" }, {})).toEqual({ name: "m" }); + }); +}); diff --git a/tests/config/presetModelMetadata.test.ts b/tests/config/presetModelMetadata.test.ts new file mode 100644 index 000000000..a22e7984f --- /dev/null +++ b/tests/config/presetModelMetadata.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { + codexPresetModelSources, + hermesPresetModelSources, + mcodePresetModelSources, + openclawPresetModelSources, + opencodePresetModelSources, + piPresetModelSources, +} from "@/config/presetModelMetadata"; +import { resolveModelMetadata } from "@/lib/modelMetadata"; +import type { ModelsDevResponse } from "@/lib/modelsDev"; + +describe("preset model metadata sources", () => { + it.each([ + ["codex", codexPresetModelSources], + ["openclaw", openclawPresetModelSources], + ["hermes", hermesPresetModelSources], + ["opencode", opencodePresetModelSources], + ["mcode", mcodePresetModelSources], + ["pi", piPresetModelSources], + ])("collects reviewed model values from %s presets", (_app, sources) => { + const list = sources(); + expect(list.length).toBeGreaterThan(0); + expect(sources()).toBe(list); + for (const source of list) { + expect(source.baseUrl).toBeTruthy(); + expect(source.models.size).toBeGreaterThan(0); + } + }); + + it("keeps a partner's own window over the vendor default", () => { + // FluxA 转售的百度国际 team 部署:glm-5.2 是 500K,不是原厂的 1M。 + const modelsDev: ModelsDevResponse = { + zhipuai: { + api: "https://open.bigmodel.cn/api/paas/v4", + models: { "glm-5.2": { limit: { context: 1000000 } } }, + }, + openrouter: { + models: { + "z-ai/glm-5.2": { canonical_model_id: "zhipuai/glm-5.2" }, + }, + }, + }; + expect( + resolveModelMetadata("glm-5.2", { + baseUrl: "https://api.baiduqianfan.ai/v2/tokenplan/team", + presets: codexPresetModelSources(), + modelsDev, + })?.contextWindow, + ).toBe(500000); + }); + + it("reads OpenCode limits from the preset for the same address", () => { + expect( + resolveModelMetadata("kimi-k2.6", { + baseUrl: "https://api.moonshot.cn/v1", + presets: opencodePresetModelSources(), + }), + ).toMatchObject({ + contextWindow: 262144, + maxOutputTokens: 262144, + inputModalities: ["text", "image"], + }); + }); +}); diff --git a/tests/hooks/useModelMetadataFill.test.tsx b/tests/hooks/useModelMetadataFill.test.tsx new file mode 100644 index 000000000..69fa27d9f --- /dev/null +++ b/tests/hooks/useModelMetadataFill.test.tsx @@ -0,0 +1,104 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { useModelMetadataFill } from "@/hooks/useModelMetadataFill"; +import type { PresetModelSource } from "@/lib/modelMetadata"; +import { MODELS_DEV_API_URL } from "@/lib/modelsDev"; +import { server } from "../msw/server"; +import { createTestQueryClient } from "../utils/testQueryClient"; + +const toastInfo = vi.hoisted(() => vi.fn()); +vi.mock("sonner", () => ({ toast: { info: toastInfo } })); + +const presets = (): PresetModelSource[] => [ + { + baseUrl: "https://api.example.com/v1", + models: new Map([ + ["model-x", { contextWindow: 500000 }], + ["model-y", { contextWindow: 1000 }], + ]), + }, +]; + +const renderFill = () => { + const client = createTestQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return renderHook( + () => + useModelMetadataFill({ + baseUrl: "https://api.example.com/v1", + presets, + prefetch: false, + }), + { wrapper }, + ); +}; + +describe("useModelMetadataFill", () => { + it("applies preset values at once and models.dev values when they arrive", async () => { + server.use( + http.get(MODELS_DEV_API_URL, () => + HttpResponse.json({ + example: { + api: "https://api.example.com/v1", + models: { + "model-x": { limit: { context: 1000000, output: 64000 } }, + }, + }, + }), + ), + ); + const { result } = renderFill(); + const apply = vi.fn(() => true); + + result.current("model-x", apply); + + expect(apply).toHaveBeenCalledTimes(1); + expect(apply).toHaveBeenCalledWith({ + contextWindow: 500000, + sources: ["preset"], + }); + await waitFor(() => + expect(apply).toHaveBeenLastCalledWith({ + contextWindow: 500000, + maxOutputTokens: 64000, + sources: ["preset", "models-dev"], + }), + ); + }); + + it("keeps preset values and stays quiet when models.dev is unreachable", async () => { + server.use(http.get(MODELS_DEV_API_URL, () => HttpResponse.error())); + const { result } = renderFill(); + const apply = vi.fn(() => true); + + result.current("unknown", apply); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(apply).not.toHaveBeenCalled(); + }); + + it("merges fills that changed something into one reminder", async () => { + server.use(http.get(MODELS_DEV_API_URL, () => HttpResponse.error())); + toastInfo.mockClear(); + const { result } = renderFill(); + + result.current("model-x", () => true); + result.current("model-y", () => true); + result.current("model-x", () => false); + await waitFor(() => expect(toastInfo).toHaveBeenCalledTimes(1)); + expect(toastInfo).toHaveBeenCalledWith( + "已补全 2 个模型的上下文等参数(来源:预设),请核对", + { id: "model-metadata-filled" }, + ); + + toastInfo.mockClear(); + result.current("model-y", () => false); + await new Promise((resolve) => setTimeout(resolve, 80)); + expect(toastInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/lib/modelMetadata.test.ts b/tests/lib/modelMetadata.test.ts new file mode 100644 index 000000000..e6ea40137 --- /dev/null +++ b/tests/lib/modelMetadata.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveModelMetadata, + type KnownModelMetadata, + type PresetModelSource, +} from "@/lib/modelMetadata"; +import type { ModelsDevResponse } from "@/lib/modelsDev"; + +const modelsDev: ModelsDevResponse = { + zhipuai: { + id: "zhipuai", + api: "https://open.bigmodel.cn/api/paas/v4", + models: { + "glm-5": { + id: "glm-5", + limit: { context: 204800, output: 131072 }, + reasoning: true, + reasoning_options: [{ type: "toggle" }], + modalities: { input: ["text"], output: ["text"] }, + cost: { input: 1, output: 3.2 }, + }, + }, + }, + zai: { + id: "zai", + api: "https://api.z.ai/api/paas/v4", + models: { + "glm-5": { id: "glm-5", limit: { context: 200000, output: 128000 } }, + }, + }, + "zai-coding-plan": { + id: "zai-coding-plan", + api: "https://api.z.ai/api/coding/paas/v4", + models: { + "glm-5": { + id: "glm-5", + limit: { context: 202752, output: 98304 }, + cost: { input: 0, output: 0 }, + }, + }, + }, + "opencode-go": { + id: "opencode-go", + api: "https://opencode.ai/zen/go/v1", + models: { + "glm-5.3": { + id: "glm-5.3", + limit: { context: 1000000, output: 131072 }, + reasoning_options: [{ type: "effort", values: ["low", "high", "max"] }], + }, + }, + }, + openrouter: { + id: "openrouter", + api: "https://openrouter.ai/api/v1", + models: { + "z-ai/glm-5": { + id: "z-ai/glm-5", + limit: { context: 202800 }, + canonical_model_id: "zhipuai/glm-5", + }, + "someone/kimi-k9": { + id: "someone/kimi-k9", + limit: { context: 65536 }, + canonical_model_id: "moonshotai/kimi-k9", + }, + }, + }, + moonshotai: { + id: "moonshotai", + api: "https://api.moonshot.ai/v1", + models: { + "kimi-k9": { id: "kimi-k9", limit: { context: 262144 } }, + }, + }, + other: { + id: "other", + models: { + "kimi-k9": { + id: "kimi-k9", + limit: { context: 100000 }, + canonical_model_id: "elsewhere/kimi-k9", + }, + }, + }, + elsewhere: { + id: "elsewhere", + models: { "kimi-k9": { id: "kimi-k9", limit: { context: 50000 } } }, + }, +}; + +const preset = ( + baseUrl: string, + models: Record, +): PresetModelSource => ({ baseUrl, models: new Map(Object.entries(models)) }); + +describe("resolveModelMetadata", () => { + it("uses the provider recognized by its API address", () => { + expect( + resolveModelMetadata("glm-5.3", { + baseUrl: "https://opencode.ai/zen/go/v1/", + modelsDev, + }), + ).toMatchObject({ + contextWindow: 1000000, + maxOutputTokens: 131072, + reasoningEfforts: ["low", "high", "max"], + }); + }); + + it("prefers the provider whose path matches the address best", () => { + expect( + resolveModelMetadata("glm-5", { + baseUrl: "https://api.z.ai/api/coding/paas/v4", + modelsDev, + })?.contextWindow, + ).toBe(202752); + expect( + resolveModelMetadata("glm-5", { + baseUrl: "https://api.z.ai/api/paas/v4", + modelsDev, + })?.contextWindow, + ).toBe(200000); + }); + + it("matches ids case-insensitively and across namespaces", () => { + expect( + resolveModelMetadata("Z-AI/GLM-5", { + baseUrl: "https://openrouter.ai/api/v1", + modelsDev, + })?.contextWindow, + ).toBe(202800); + expect( + resolveModelMetadata("zai-org/glm-5", { + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + modelsDev, + })?.contextWindow, + ).toBe(204800); + }); + + it("falls back to the vendor entry without carrying its price", () => { + const metadata = resolveModelMetadata("glm-5", { + baseUrl: "https://relay.example.com/v1", + modelsDev, + }); + expect(metadata).toMatchObject({ + contextWindow: 204800, + reasoning: true, + inputModalities: ["text"], + sources: ["models-dev"], + }); + expect(metadata?.cost).toBeUndefined(); + expect(metadata?.reasoningEfforts).toBeUndefined(); + }); + + it("keeps the price when the provider itself is recognized", () => { + expect( + resolveModelMetadata("glm-5", { + baseUrl: "https://open.bigmodel.cn/api/paas/v4", + modelsDev, + })?.cost, + ).toEqual({ input: 1, output: 3.2 }); + }); + + it("gives up when the same name points at different vendors", () => { + expect( + resolveModelMetadata("kimi-k9", { + baseUrl: "https://relay.example.com/v1", + modelsDev, + }), + ).toBeNull(); + }); + + it("lets the matching preset row win field by field", () => { + const presets = [ + preset("https://api.z.ai/api/coding/paas/v4", { + "glm-5": { contextWindow: 500000 }, + }), + preset("https://relay.example.com/v1", { + "glm-5": { contextWindow: 1 }, + }), + ]; + expect( + resolveModelMetadata("glm-5", { + baseUrl: "https://api.z.ai/api/coding/paas/v4", + presets, + modelsDev, + }), + ).toMatchObject({ + contextWindow: 500000, + maxOutputTokens: 98304, + sources: ["preset", "models-dev"], + }); + }); + + it("does not take another plan's preset on the same host", () => { + expect( + resolveModelMetadata("glm-5", { + baseUrl: "https://api.z.ai/api/paas/v4", + presets: [ + preset("https://api.z.ai/api/coding/paas/v4", { + "glm-5": { contextWindow: 500000 }, + }), + ], + modelsDev, + }), + ).toMatchObject({ contextWindow: 200000, sources: ["models-dev"] }); + }); + + it("accepts the same endpoint written with or without a version suffix", () => { + expect( + resolveModelMetadata("glm-5.3", { + baseUrl: "https://opencode.ai/zen/go", + modelsDev, + })?.contextWindow, + ).toBe(1000000); + }); + + it("does not treat a parent path as the plans nested under it", () => { + expect( + resolveModelMetadata("glm-5.3", { + baseUrl: "https://opencode.ai/zen", + presets: [ + preset("https://opencode.ai/zen/go/v1", { + "glm-5.3": { contextWindow: 1 }, + }), + ], + modelsDev, + }), + ).toBeNull(); + }); + + it("ignores a pasted request path after the base address", () => { + expect( + resolveModelMetadata("glm-5", { + baseUrl: "https://api.z.ai/api/coding/paas/v4/chat/completions", + modelsDev, + })?.contextWindow, + ).toBe(202752); + }); + + it("fills fields the provider entry leaves out from the vendor entry", () => { + const metadata = resolveModelMetadata("glm-5", { + baseUrl: "https://api.z.ai/api/paas/v4", + modelsDev, + }); + expect(metadata).toMatchObject({ + contextWindow: 200000, + maxOutputTokens: 128000, + reasoning: true, + inputModalities: ["text"], + }); + expect(metadata?.cost).toBeUndefined(); + }); + + it("uses presets alone when models.dev is unavailable", () => { + expect( + resolveModelMetadata("glm-5", { + baseUrl: "https://api.z.ai/api/coding/paas/v4", + presets: [ + preset("https://api.z.ai/api/coding/paas/v4", { + "glm-5": { contextWindow: 500000 }, + }), + ], + }), + ).toEqual({ contextWindow: 500000, sources: ["preset"] }); + expect( + resolveModelMetadata("glm-5", { baseUrl: "https://api.z.ai/v1" }), + ).toBeNull(); + }); +}); diff --git a/tests/utils/testQueryClient.ts b/tests/utils/testQueryClient.ts index f7d0f3acd..ee4d7c781 100644 --- a/tests/utils/testQueryClient.ts +++ b/tests/utils/testQueryClient.ts @@ -1,4 +1,6 @@ -import { QueryClient } from "@tanstack/react-query"; +import { createElement, type ReactElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, type RenderOptions } from "@testing-library/react"; export const createTestQueryClient = () => new QueryClient({ @@ -8,3 +10,16 @@ export const createTestQueryClient = () => }, }, }); + +/** `render`,外面包一层 QueryClientProvider(`rerender` 也保留)。 */ +export const renderWithQueryClient = ( + ui: ReactElement, + options?: Omit, +) => { + const client = createTestQueryClient(); + return render(ui, { + ...options, + wrapper: ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children), + }); +};