From 0a28ee5312bd7a662897b5e8bfb92700df98b21f Mon Sep 17 00:00:00 2001 From: Lling0000 Date: Thu, 16 Jul 2026 22:57:32 +0800 Subject: [PATCH] feat: add automatic model routing --- crates/agent-gui/src/i18n/config.ts | 19 +++ crates/agent-gui/src/lib/settings/index.ts | 36 +++++ crates/agent-gui/src/pages/ChatPage.tsx | 123 +++++++++--------- .../src/pages/chat/components/ChatHeader.tsx | 47 ++++++- .../src/pages/chat/runtime/autoModelRouter.ts | 104 +++++++++++++++ .../src/pages/chat/runtime/modelSelection.ts | 30 ++++- .../src/pages/settings/ProvidersSection.tsx | 25 ++++ .../test/chat/model-selection.test.mjs | 117 +++++++++++++++++ 8 files changed, 438 insertions(+), 63 deletions(-) create mode 100644 crates/agent-gui/src/pages/chat/runtime/autoModelRouter.ts diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 8696b0e8..fd7aa8f7 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -171,6 +171,9 @@ export const translations: Record> = { "chat.retryConfirmTitle": "重试该回复?", "chat.retryConfirmDescription": "将以原始提问重新发送,该回复及之后的对话内容将被删除。", "chat.selectModel": "选择模型", + "chat.autoModel": "Auto · 自动选模型", + "chat.autoModelHint": "根据当前任务自动选择模型,并在同一会话中接力", + "chat.autoModelConfigureHint": "先在供应商设置中为模型配置 Auto 路由档位", "chat.searchModel": "搜索模型...", "chat.noModelFound": "未找到匹配的模型", "chat.expandProvider": "展开该提供商的模型", @@ -1228,6 +1231,12 @@ export const translations: Record> = { "settings.refreshModels": "刷新模型列表", "settings.addModel": "手动添加模型", "settings.modelSettings": "模型参数", + "settings.autoRoutingTier": "Auto 路由档位", + "settings.autoRoutingTierHint": "Auto 会按任务复杂度选择档位;关闭的模型不会参与路由。", + "settings.autoRoutingTier.off": "不参与", + "settings.autoRoutingTier.fast": "Fast · 快速", + "settings.autoRoutingTier.balanced": "Balanced · 均衡", + "settings.autoRoutingTier.reasoning": "Reasoning · 深度推理", "settings.modelName": "模型名称", "settings.contextWindow": "Context Window", "settings.maxOutputToken": "Max Output Token", @@ -1943,6 +1952,9 @@ export const translations: Record> = { "chat.retryConfirmDescription": "The original prompt will be sent again. This reply and everything after it will be removed.", "chat.selectModel": "Select Model", + "chat.autoModel": "Auto · Choose for me", + "chat.autoModelHint": "Choose a model for each task while preserving the conversation context", + "chat.autoModelConfigureHint": "Assign Auto routing tiers to models in provider settings first", "chat.searchModel": "Search models...", "chat.noModelFound": "No matching models found", "chat.expandProvider": "Expand provider models", @@ -3041,6 +3053,13 @@ export const translations: Record> = { "settings.refreshModels": "Refresh model list", "settings.addModel": "Add model manually", "settings.modelSettings": "Model settings", + "settings.autoRoutingTier": "Auto routing tier", + "settings.autoRoutingTierHint": + "Auto selects a tier from task complexity. Disabled models are never routed.", + "settings.autoRoutingTier.off": "Not routed", + "settings.autoRoutingTier.fast": "Fast", + "settings.autoRoutingTier.balanced": "Balanced", + "settings.autoRoutingTier.reasoning": "Reasoning", "settings.modelName": "Model name", "settings.contextWindow": "Context Window", "settings.maxOutputToken": "Max Output Token", diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index f5f75110..f65c10ab 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -161,10 +161,34 @@ export type SelectedModel = { model: string; }; +export const AUTO_ROUTER_PROVIDER_ID = "__liveagent_auto__"; +export const AUTO_ROUTER_MODEL_ID = "auto"; +export type AutoModelSelection = SelectedModel & { + customProviderId: typeof AUTO_ROUTER_PROVIDER_ID; + model: typeof AUTO_ROUTER_MODEL_ID; +}; + +export const AUTO_ROUTER_SELECTION: AutoModelSelection = { + customProviderId: AUTO_ROUTER_PROVIDER_ID, + model: AUTO_ROUTER_MODEL_ID, +}; + +export function isAutoModelSelection( + selectedModel: SelectedModel | undefined, +): selectedModel is AutoModelSelection { + return ( + selectedModel?.customProviderId === AUTO_ROUTER_PROVIDER_ID && + selectedModel.model === AUTO_ROUTER_MODEL_ID + ); +} + +export type AutoRoutingTier = "fast" | "balanced" | "reasoning"; + export type ProviderModelConfig = { id: string; contextWindow: number; maxOutputToken: number; + autoRoutingTier?: AutoRoutingTier; }; export type ChatRuntimeControls = { @@ -1010,6 +1034,12 @@ export function normalizeProviderModelConfig( if (!id) return null; const defaults = getProviderModelDefaults(providerId, id); + const autoRoutingTier = + obj.autoRoutingTier === "fast" || + obj.autoRoutingTier === "balanced" || + obj.autoRoutingTier === "reasoning" + ? obj.autoRoutingTier + : undefined; return { id, contextWindow: normalizePositiveInteger(obj.contextWindow, defaults.contextWindow), @@ -1017,6 +1047,7 @@ export function normalizeProviderModelConfig( obj.maxOutputToken ?? obj.maxTokens, defaults.maxOutputToken, ), + ...(autoRoutingTier ? { autoRoutingTier } : {}), }; } @@ -1491,10 +1522,14 @@ export function computeNextMemoryOrganizerRunAt( export function normalizeSelectedModelForProviders( selectedModel: SelectedModel | undefined, customProviders: CustomProvider[], + options?: { allowAuto?: boolean }, ): SelectedModel | undefined { if (!selectedModel) { return undefined; } + if (options?.allowAuto && isAutoModelSelection(selectedModel)) { + return AUTO_ROUTER_SELECTION; + } const provider = customProviders.find((item) => item.id === selectedModel.customProviderId); if (!provider) { @@ -1829,6 +1864,7 @@ export function normalizeSettings(input?: Partial | null): AppSetti const selectedModel = normalizeSelectedModelForProviders( normalizeSelectedModel(obj.selectedModel), customProviders, + { allowAuto: true }, ); return { diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 9b81eb0d..aa6a587c 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -118,6 +118,7 @@ import { getSshProjectHostIds, isAgentDevMode, isAgentExecutionMode, + isAutoModelSelection, isRightDockSingletonTabOpen, normalizeChatRuntimeControls, normalizeChatRuntimeControlsForProvider, @@ -2720,7 +2721,9 @@ export function ChatPage(props: ChatPageProps) { getDefaultNewConversationWorkdir: () => isAgentMode ? activeWorkspaceProjectPath || undefined : undefined, resolveConversationSelectedModel: (json) => - normalizeSelectedModelForProviders(parseSelectedModelJson(json), settings.customProviders), + normalizeSelectedModelForProviders(parseSelectedModelJson(json), settings.customProviders, { + allowAuto: true, + }), setCurrentConversationId, setErrorMessage, setHydratingConversationId, @@ -3275,6 +3278,7 @@ export function ChatPage(props: ChatPageProps) { selectedModel: normalizeSelectedModelForProviders( parseSelectedModelJson(record.selectedModelJson), settings.customProviders, + { allowAuto: true }, ), }); const historySummary: ChatHistorySummary = { @@ -3601,6 +3605,63 @@ export function ChatPage(props: ChatPageProps) { })); } + const textOverride = + typeof overrides?.textOverride === "string" ? overrides.textOverride : null; + const hasTextOverride = textOverride !== null; + const composerDraft = hasTextOverride + ? null + : (overrides?.composerDraftOverride ?? composerRef.current?.getDraft() ?? null); + let text = hasTextOverride + ? textOverride.trim() + : composerDraft + ? (effectiveIsAgentMode && composerDraft.largePastes.length > 0 + ? composerDraft.textWithoutLargePastes + : buildTextFromComposerDraft(composerDraft) + ).trim() + : ""; + let uploadedFiles = overrides?.uploadedFilesOverride ?? pendingUploadedFiles; + + if ( + effectiveIsAgentMode && + composerDraft && + composerDraft.largePastes.length > 0 && + !hasTextOverride + ) { + isImportingPastedTextRef.current = true; + setIsImportingPastedText(true); + try { + const imported = await importPastedTextsAsFiles( + effectiveWorkdir, + composerDraft.largePastes, + ); + text = buildTextFromComposerDraft(composerDraft, imported.fileByPasteId).trim(); + uploadedFiles = mergePendingUploadedFiles(uploadedFiles, imported.files); + } catch (error) { + const message = asErrorMessage(error, "大段粘贴内容导入工作区失败"); + setConversationErrorState(message); + setErrorMessage(message); + gatewayBridgeEvents.emitError(message, conversationId); + gatewayBridgeEvents.close(); + return false; + } finally { + isImportingPastedTextRef.current = false; + setIsImportingPastedText(false); + } + } + + const userMessage = createUserMessageWithUploads(text, uploadedFiles, Date.now()); + if (!userMessage) { + if (gatewayBridgeRequest) { + const message = "Message is required."; + gatewayBridgeEvents.emitError(message, conversationId); + gatewayBridgeEvents.close(); + } + return false; + } + const pendingUserMessage = userMessage; + const content = + typeof pendingUserMessage.content === "string" ? pendingUserMessage.content : ""; + let effectiveSelectedModel: EffectiveChatModelSelection; try { effectiveSelectedModel = resolveEffectiveChatModelSelection({ @@ -3608,6 +3669,7 @@ export function ChatPage(props: ChatPageProps) { conversationSelectedModel: conversationRuntimeCacheRef.current.get(conversationId)?.selectedModel, gatewaySelectedModel: gatewayBridgeRequest?.selectedModelOverride, + routingInput: { text, hasAttachments: uploadedFiles.length > 0 }, }); } catch (error) { const message = asErrorMessage(error, "当前模型配置不可用,请重新选择后重试。"); @@ -3664,63 +3726,6 @@ export function ChatPage(props: ChatPageProps) { providerConfig.modelConfig, ); - const textOverride = - typeof overrides?.textOverride === "string" ? overrides.textOverride : null; - const hasTextOverride = textOverride !== null; - const composerDraft = hasTextOverride - ? null - : (overrides?.composerDraftOverride ?? composerRef.current?.getDraft() ?? null); - let text = hasTextOverride - ? textOverride.trim() - : composerDraft - ? (effectiveIsAgentMode && composerDraft.largePastes.length > 0 - ? composerDraft.textWithoutLargePastes - : buildTextFromComposerDraft(composerDraft) - ).trim() - : ""; - let uploadedFiles = overrides?.uploadedFilesOverride ?? pendingUploadedFiles; - - if ( - effectiveIsAgentMode && - composerDraft && - composerDraft.largePastes.length > 0 && - !hasTextOverride - ) { - isImportingPastedTextRef.current = true; - setIsImportingPastedText(true); - try { - const imported = await importPastedTextsAsFiles( - effectiveWorkdir, - composerDraft.largePastes, - ); - text = buildTextFromComposerDraft(composerDraft, imported.fileByPasteId).trim(); - uploadedFiles = mergePendingUploadedFiles(uploadedFiles, imported.files); - } catch (error) { - const message = asErrorMessage(error, "大段粘贴内容导入工作区失败"); - setConversationErrorState(message); - setErrorMessage(message); - gatewayBridgeEvents.emitError(message, conversationId); - gatewayBridgeEvents.close(); - return false; - } finally { - isImportingPastedTextRef.current = false; - setIsImportingPastedText(false); - } - } - - const userMessage = createUserMessageWithUploads(text, uploadedFiles, Date.now()); - if (!userMessage) { - if (gatewayBridgeRequest) { - const message = "Message is required."; - gatewayBridgeEvents.emitError(message, conversationId); - gatewayBridgeEvents.close(); - } - return false; - } - const pendingUserMessage = userMessage; - const content = - typeof pendingUserMessage.content === "string" ? pendingUserMessage.content : ""; - const titleSourceText = text || uploadedFiles.map((file) => file.fileName).join(", "); const sessionId = runtimeEntry.sessionId; @@ -4892,6 +4897,7 @@ export function ChatPage(props: ChatPageProps) { const currentModelLabel = (() => { if (!activeSelectedModel) return t("chat.selectModel"); + if (isAutoModelSelection(activeSelectedModel)) return t("chat.autoModel"); const opt = modelOptions.find((o) => o.value === selectedValue); if (opt) return `${opt.providerName} / ${opt.model}`; return activeSelectedModel.model; @@ -4943,6 +4949,7 @@ export function ChatPage(props: ChatPageProps) { const parsed = normalizeSelectedModelForProviders( parseSelectedModelJson(displayedConversationPersistedModelJson), settings.customProviders, + { allowAuto: true }, ); if (!parsed) return; const entry = conversationRuntimeCacheRef.current.get(currentConversationId); diff --git a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx index 180cc966..85a5d080 100644 --- a/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx +++ b/crates/agent-gui/src/pages/chat/components/ChatHeader.tsx @@ -10,6 +10,7 @@ import { PanelLeft, Search, Settings, + Sparkles, Sun, } from "../../../components/icons"; @@ -23,9 +24,10 @@ import { DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { useLocale } from "../../../i18n"; -import { type ModelOption, parseModelValue } from "../../../lib/providers/llm"; +import { type ModelOption, parseModelValue, toModelValue } from "../../../lib/providers/llm"; import { type AppSettings, + AUTO_ROUTER_SELECTION, getNextTheme, type ProviderId, type SelectedModel, @@ -98,6 +100,19 @@ export const ChatHeader = memo(function ChatHeader(props: { }, [isModelMenuOpen]); const normalizedSearch = modelSearch.trim().toLowerCase(); + const autoOptionLabel = t("chat.autoModel"); + const autoValue = toModelValue( + AUTO_ROUTER_SELECTION.customProviderId, + AUTO_ROUTER_SELECTION.model, + ); + const autoSelected = selectedValue === autoValue; + const hasAutoModels = settings.customProviders.some((provider) => + provider.activeModels.some((model) => + provider.models.some((item) => item.id === model && item.autoRoutingTier), + ), + ); + const showAutoOption = + normalizedSearch.length === 0 || autoOptionLabel.toLowerCase().includes(normalizedSearch); const groups: { name: string; providerType: ProviderId; @@ -167,7 +182,9 @@ export const ChatHeader = memo(function ChatHeader(props: { } > - {selectedOption ? ( + {autoSelected ? ( + + ) : selectedOption ? ( ) : null} {currentModelLabel} @@ -199,6 +216,28 @@ export const ChatHeader = memo(function ChatHeader(props: {
+ {showAutoOption ? ( + <> + onSelectModel(AUTO_ROUTER_SELECTION)} + title={ + hasAutoModels ? t("chat.autoModelHint") : t("chat.autoModelConfigureHint") + } + className={cn( + "model-selector-item h-[34px] max-w-full shrink-0 justify-between gap-3 overflow-hidden rounded-md py-0 text-xs font-normal leading-5 text-foreground", + autoSelected && "bg-primary/10 font-medium text-primary", + )} + > + + + {autoOptionLabel} + + {autoSelected ? : null} + + + + ) : null} {(() => { let animationIndex = 0; const filteredGroups = normalizedSearch @@ -214,7 +253,7 @@ export const ChatHeader = memo(function ChatHeader(props: { .filter((g) => g.opts.length > 0) : groups; - if (filteredGroups.length === 0) { + if (filteredGroups.length === 0 && !showAutoOption) { return (
{t("chat.noModelFound")} @@ -222,6 +261,8 @@ export const ChatHeader = memo(function ChatHeader(props: { ); } + if (filteredGroups.length === 0) return null; + return filteredGroups.map((group, groupIndex) => { const expanded = isGroupExpanded(group.name); return ( diff --git a/crates/agent-gui/src/pages/chat/runtime/autoModelRouter.ts b/crates/agent-gui/src/pages/chat/runtime/autoModelRouter.ts new file mode 100644 index 00000000..cbcb71c1 --- /dev/null +++ b/crates/agent-gui/src/pages/chat/runtime/autoModelRouter.ts @@ -0,0 +1,104 @@ +import type { AppSettings, AutoRoutingTier, SelectedModel } from "../../../lib/settings"; + +export type AutoRoutingInput = { + text: string; + hasAttachments?: boolean; +}; + +export type AutoRoutingReason = + | "attachment" + | "long-prompt" + | "medium-prompt" + | "complex-task" + | "simple-task"; + +export type AutoRoutingDecision = { + requestedTier: AutoRoutingTier; + selectedTier: AutoRoutingTier; + reasons: AutoRoutingReason[]; + selectedModel: SelectedModel; + provider: AppSettings["customProviders"][number]; + model: string; +}; + +const COMPLEX_TASK_PATTERNS = [ + /\b(?:analy[sz]e|architecture|compare|debug|design|implement|investigate|plan|refactor|review|test)\b/gi, + /\b(?:bug|error|failure|multi[- ]?step|performance|security|trade-?off)\b/gi, + /(?:分析|架构|比较|调试|规划|设计|实现|排查|评审|重构|测试|修复|性能|安全|多步骤)/g, +]; + +const FALLBACK_TIERS: Record = { + fast: ["fast", "balanced", "reasoning"], + balanced: ["balanced", "reasoning", "fast"], + reasoning: ["reasoning", "balanced", "fast"], +}; + +export function classifyAutoRoutingInput(input: AutoRoutingInput): { + tier: AutoRoutingTier; + reasons: AutoRoutingReason[]; +} { + const text = input.text.trim(); + const reasons: AutoRoutingReason[] = []; + + if (input.hasAttachments) reasons.push("attachment"); + if (text.length >= 1200) reasons.push("long-prompt"); + else if (text.length >= 400) reasons.push("medium-prompt"); + + let complexitySignals = 0; + for (const pattern of COMPLEX_TASK_PATTERNS) { + pattern.lastIndex = 0; + complexitySignals += text.match(pattern)?.length ?? 0; + } + if (complexitySignals > 0) reasons.push("complex-task"); + + if (input.hasAttachments || text.length >= 1200 || complexitySignals >= 2) { + return { tier: "reasoning", reasons }; + } + if (text.length >= 400 || complexitySignals === 1) { + return { tier: "balanced", reasons }; + } + return { tier: "fast", reasons: ["simple-task"] }; +} + +export function routeAutoModel( + settings: AppSettings, + input: AutoRoutingInput, +): AutoRoutingDecision { + const classification = classifyAutoRoutingInput(input); + const candidates: Array<{ + tier: AutoRoutingTier; + selectedModel: SelectedModel; + provider: AppSettings["customProviders"][number]; + model: string; + }> = []; + + for (const provider of settings.customProviders) { + for (const model of provider.activeModels) { + const tier = provider.models.find((item) => item.id === model)?.autoRoutingTier; + if (!tier) continue; + candidates.push({ + tier, + selectedModel: { customProviderId: provider.id, model }, + provider, + model, + }); + } + } + + for (const tier of FALLBACK_TIERS[classification.tier]) { + const candidate = candidates.find((item) => item.tier === tier); + if (!candidate) continue; + return { + requestedTier: classification.tier, + selectedTier: tier, + reasons: classification.reasons, + selectedModel: candidate.selectedModel, + provider: candidate.provider, + model: candidate.model, + }; + } + + throw new Error( + "Auto 模式没有可用模型。请在供应商设置中为至少一个已启用模型配置 Auto 路由档位。", + ); +} diff --git a/crates/agent-gui/src/pages/chat/runtime/modelSelection.ts b/crates/agent-gui/src/pages/chat/runtime/modelSelection.ts index 4422ebb1..7e9ee3ec 100644 --- a/crates/agent-gui/src/pages/chat/runtime/modelSelection.ts +++ b/crates/agent-gui/src/pages/chat/runtime/modelSelection.ts @@ -1,14 +1,21 @@ -import type { AppSettings, ProviderId, SelectedModel } from "../../../lib/settings"; +import { + type AppSettings, + isAutoModelSelection, + type ProviderId, + type SelectedModel, +} from "../../../lib/settings"; import { type GatewaySelectedModelEvent, normalizeGatewayProviderType, } from "../gateway/gatewayBridgeTypes"; +import { type AutoRoutingDecision, type AutoRoutingInput, routeAutoModel } from "./autoModelRouter"; export type EffectiveChatModelSelection = { selectedModel: SelectedModel; provider: AppSettings["customProviders"][number]; providerId: ProviderId; model: string; + autoRouting?: Pick; }; export function resolveActiveModelSelection( @@ -29,14 +36,33 @@ export function resolveEffectiveChatModelSelection(params: { settings: AppSettings; conversationSelectedModel?: SelectedModel; gatewaySelectedModel?: GatewaySelectedModelEvent; + routingInput?: AutoRoutingInput; }): EffectiveChatModelSelection { - const { settings, conversationSelectedModel, gatewaySelectedModel } = params; + const { settings, conversationSelectedModel, gatewaySelectedModel, routingInput } = params; const resolveLocalSelection = (): EffectiveChatModelSelection => { const activeSelectedModel = resolveActiveModelSelection(settings, conversationSelectedModel); if (!activeSelectedModel) { throw new Error("请先在左上角选择一个模型(或先去设置添加模型)。"); } + if (isAutoModelSelection(activeSelectedModel)) { + if (!routingInput) { + throw new Error("Auto 模式需要当前消息才能选择模型。"); + } + const routed = routeAutoModel(settings, routingInput); + return { + selectedModel: activeSelectedModel, + provider: routed.provider, + providerId: routed.provider.type, + model: routed.model, + autoRouting: { + requestedTier: routed.requestedTier, + selectedTier: routed.selectedTier, + reasons: routed.reasons, + }, + }; + } + const { customProviderId, model } = activeSelectedModel; const provider = settings.customProviders.find((item) => item.id === customProviderId); if (!provider) { diff --git a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx index b68b9b52..ec932af8 100644 --- a/crates/agent-gui/src/pages/settings/ProvidersSection.tsx +++ b/crates/agent-gui/src/pages/settings/ProvidersSection.tsx @@ -45,6 +45,7 @@ import { useLocale } from "../../i18n"; import { buildModelOptions } from "../../lib/chat/page/chatPageHelpers"; import { parseModelValue, toModelValue } from "../../lib/providers/llm"; import { + type AutoRoutingTier, CODEX_REQUEST_FORMAT_LABELS, type CodexRequestFormat, type CustomProvider, @@ -156,6 +157,9 @@ function ModelSettingsModal({ model, onClose, onSave }: ModelSettingsModalProps) const { t } = useLocale(); const [contextWindow, setContextWindow] = useState(String(model.contextWindow)); const [maxOutputToken, setMaxOutputToken] = useState(String(model.maxOutputToken)); + const [autoRoutingTier, setAutoRoutingTier] = useState( + model.autoRoutingTier ?? "off", + ); const parsedContextWindow = parsePositiveInteger(contextWindow); const parsedMaxOutputToken = parsePositiveInteger(maxOutputToken); @@ -167,6 +171,7 @@ function ModelSettingsModal({ model, onClose, onSave }: ModelSettingsModalProps) ...model, contextWindow: parsedContextWindow, maxOutputToken: parsedMaxOutputToken, + autoRoutingTier: autoRoutingTier === "off" ? undefined : autoRoutingTier, }); } @@ -211,6 +216,26 @@ function ModelSettingsModal({ model, onClose, onSave }: ModelSettingsModalProps) />
+
+ + +
{t("settings.autoRoutingTierHint")}
+
+ {!canSave ? (
{t("settings.positiveIntegerRequired")} diff --git a/crates/agent-gui/test/chat/model-selection.test.mjs b/crates/agent-gui/test/chat/model-selection.test.mjs index 5f90db61..70720269 100644 --- a/crates/agent-gui/test/chat/model-selection.test.mjs +++ b/crates/agent-gui/test/chat/model-selection.test.mjs @@ -5,6 +5,7 @@ import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; const loader = createTsModuleLoader(); const settings = loader.loadModule("src/lib/settings/index.ts"); const modelSelection = loader.loadModule("src/pages/chat/runtime/modelSelection.ts"); +const autoModelRouter = loader.loadModule("src/pages/chat/runtime/autoModelRouter.ts"); function provider(overrides = {}) { const id = overrides.id ?? "provider-1"; @@ -30,6 +31,19 @@ function appSettings(customProviders, selectedModel) { }); } +function routedProvider(id, models) { + return provider({ + id, + models: models.map(({ id: modelId, tier }) => ({ + id: modelId, + contextWindow: 128_000, + maxOutputToken: 16_000, + autoRoutingTier: tier, + })), + activeModels: models.map((item) => item.id), + }); +} + test("local chat model selection resolves only an enabled selected model", () => { const app = appSettings( [provider({ id: "openai-main", models: ["gpt-5", "gpt-5-mini"] })], @@ -250,3 +264,106 @@ test("selected model json round-trips and rejects malformed payloads", () => { assert.equal(settings.parseSelectedModelJson('{"model":"m1"}'), undefined); assert.equal(settings.serializeSelectedModelJson(undefined), undefined); }); + +test("Auto selection survives chat settings normalization", () => { + const providers = [routedProvider("openai-main", [{ id: "gpt-fast", tier: "fast" }])]; + const app = appSettings(providers, settings.AUTO_ROUTER_SELECTION); + + assert.deepEqual(app.selectedModel, settings.AUTO_ROUTER_SELECTION); + assert.equal( + settings.normalizeSelectedModelForProviders( + settings.AUTO_ROUTER_SELECTION, + app.customProviders, + ), + undefined, + ); + assert.deepEqual( + settings.normalizeSelectedModelForProviders( + settings.AUTO_ROUTER_SELECTION, + app.customProviders, + { allowAuto: true }, + ), + settings.AUTO_ROUTER_SELECTION, + ); +}); + +test("Auto classifies simple, complex, and attachment turns", () => { + assert.equal(autoModelRouter.classifyAutoRoutingInput({ text: "hello" }).tier, "fast"); + assert.equal( + autoModelRouter.classifyAutoRoutingInput({ text: "请分析架构并实现修复方案" }).tier, + "reasoning", + ); + assert.equal( + autoModelRouter.classifyAutoRoutingInput({ text: "summarize this", hasAttachments: true }) + .tier, + "reasoning", + ); +}); + +test("Auto routes each turn while preserving Auto as the conversation selection", () => { + const app = appSettings( + [ + routedProvider("openai-main", [ + { id: "gpt-fast", tier: "fast" }, + { id: "gpt-balanced", tier: "balanced" }, + { id: "gpt-reasoning", tier: "reasoning" }, + ]), + ], + settings.AUTO_ROUTER_SELECTION, + ); + + const simple = modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + routingInput: { text: "hello" }, + }); + const complex = modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + routingInput: { text: "Analyze the architecture, debug the failure, and implement tests." }, + }); + + assert.equal(simple.model, "gpt-fast"); + assert.equal(simple.autoRouting.requestedTier, "fast"); + assert.deepEqual(simple.selectedModel, settings.AUTO_ROUTER_SELECTION); + assert.equal(complex.model, "gpt-reasoning"); + assert.equal(complex.autoRouting.requestedTier, "reasoning"); + assert.deepEqual(complex.selectedModel, settings.AUTO_ROUTER_SELECTION); +}); + +test("Auto falls back to the nearest configured tier deterministically", () => { + const app = appSettings( + [ + routedProvider("first-provider", [{ id: "first-reasoning", tier: "reasoning" }]), + routedProvider("second-provider", [ + { id: "second-reasoning", tier: "reasoning" }, + { id: "second-fast", tier: "fast" }, + ]), + ], + settings.AUTO_ROUTER_SELECTION, + ); + + const resolved = modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + routingInput: { text: "review this" }, + }); + + assert.equal(resolved.autoRouting.requestedTier, "balanced"); + assert.equal(resolved.autoRouting.selectedTier, "reasoning"); + assert.equal(resolved.provider.id, "first-provider"); + assert.equal(resolved.model, "first-reasoning"); +}); + +test("Auto explains when no enabled model is tagged for routing", () => { + const app = appSettings( + [provider({ id: "openai-main", models: ["gpt-5"] })], + settings.AUTO_ROUTER_SELECTION, + ); + + assert.throws( + () => + modelSelection.resolveEffectiveChatModelSelection({ + settings: app, + routingInput: { text: "hello" }, + }), + /Auto 模式没有可用模型/, + ); +});