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),
+ });
+};