From 8fbcea9ff03ab0806bb4591b4c261c86ee6a9d14 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 1 Oct 2026 09:01:44 +0800 Subject: [PATCH] feat(providers): fill model metadata when picking a fetched model A provider's /models endpoint only returns model IDs, so a model picked from the fetched list left its context window, output limit, reasoning levels, and modalities empty, while the same model in a preset had them. When a model is picked in the Codex, OpenClaw, Hermes, OpenCode, or MiniMax Code form, look up its metadata field by field: 1. the preset model with the same endpoint (hand-verified values); 2. the models.dev provider whose API endpoint matches; 3. the vendor's own models.dev entry, without its price. Endpoints match only when host and path are equal after dropping a trailing version segment or request path, so plans that share a host (z.ai standard vs Coding Plan, OpenCode Zen vs Go) never borrow each other's values. Models are never matched by name across providers: the same model ID often has very different context windows per deployment. Only empty fields are filled, and reasoning or image input is only ever turned on. models.dev is prefetched once the model list is fetched and shared with the usage pricing import; when it arrives late the row is filled again, located by its stable row key. A single toast asks the user to review what was filled. --- .../providers/forms/CodexFormFields.tsx | 91 ++++- .../providers/forms/HermesFormFields.tsx | 40 +- .../providers/forms/McodeProviderForm.tsx | 2 + .../providers/forms/OpenClawFormFields.tsx | 40 +- .../providers/forms/OpenCodeFormFields.tsx | 41 +- .../providers/forms/modelMetadataFill.ts | 106 ++++++ src/config/presetModelMetadata.ts | 142 +++++++ src/hooks/useLatestRef.ts | 10 + src/hooks/useModelMetadataFill.ts | 137 +++++++ src/i18n/locales/en.json | 4 + src/i18n/locales/ja.json | 4 + src/i18n/locales/zh-TW.json | 4 + src/i18n/locales/zh.json | 4 + src/lib/modelMetadata.ts | 352 ++++++++++++++++++ .../components/GrokBuildProviderForm.test.tsx | 3 +- tests/components/HermesFormFields.test.tsx | 116 +++++- tests/components/OpenClawFormFields.test.tsx | 3 +- tests/components/OpenCodeFormFields.test.tsx | 2 +- tests/components/modelMetadataFill.test.ts | 127 +++++++ tests/config/presetModelMetadata.test.ts | 66 ++++ tests/hooks/useModelMetadataFill.test.tsx | 104 ++++++ tests/lib/modelMetadata.test.ts | 272 ++++++++++++++ tests/utils/testQueryClient.ts | 17 +- 23 files changed, 1662 insertions(+), 25 deletions(-) create mode 100644 src/components/providers/forms/modelMetadataFill.ts create mode 100644 src/config/presetModelMetadata.ts create mode 100644 src/hooks/useLatestRef.ts create mode 100644 src/hooks/useModelMetadataFill.ts create mode 100644 src/lib/modelMetadata.ts create mode 100644 tests/components/modelMetadataFill.test.ts create mode 100644 tests/config/presetModelMetadata.test.ts create mode 100644 tests/hooks/useModelMetadataFill.test.tsx create mode 100644 tests/lib/modelMetadata.test.ts 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), + }); +};