diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx
index 6c768b68ef..883c3d1ff8 100644
--- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx
+++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx
@@ -29,7 +29,7 @@ import { ConfigPageHeader, ConfigPageLayout, ConfigPageContent, ConfigPageSectio
import DefaultModelConfig from './DefaultModelConfig';
import SubagentModelConfig from './SubagentModelConfig';
import SessionTitleConfig from './SessionTitleConfig';
-import ReasoningConfigPanel from './ReasoningConfigPanel';
+import ReasoningConfigPanel, { type ReasoningConfigApplyResult } from './ReasoningConfigPanel';
import { createLogger } from '@/shared/utils/logger';
import { translateConnectionTestMessage } from '@/shared/utils/aiConnectionTestMessages';
import { i18nService } from '@/infrastructure/i18n';
@@ -61,6 +61,10 @@ interface SelectedModelDraft {
maxTokens?: number;
reasoning: ReasoningConfig;
reasoningProjectionCatalog?: ReasoningCatalogBinding;
+ reasoningProjectionSnapshot?: {
+ catalog: ReasoningCatalogBinding;
+ projection?: ReasoningCatalogProjection | null;
+ };
}
interface ProviderGroup {
@@ -796,6 +800,17 @@ const AIModelConfig: React.FC = () => {
))
);
+ const resolveDraftReasoningProjection = (draft: SelectedModelDraft) => {
+ const snapshot = draft.reasoningProjectionSnapshot;
+ if (snapshot && reasoningCatalogBindingsEqual(draft.reasoning.catalog, snapshot.catalog)) {
+ return snapshot.projection ?? undefined;
+ }
+ if (reasoningCatalogBindingsEqual(draft.reasoning.catalog, draft.reasoningProjectionCatalog)) {
+ return resolveDraftCatalogEntry(draft)?.reasoning;
+ }
+ return undefined;
+ };
+
const toggleSelectedModelCardExpanded = useCallback((draftKey: string) => {
setExpandedModelCards(prev => {
const next = new Set(prev);
@@ -1523,19 +1538,19 @@ const AIModelConfig: React.FC = () => {
notification.warning(t('messages.contextWindowTooSmall'));
return;
}
- if (draftsToSave.some(draft => (
- validateReasoningConfig(
- draft.reasoning,
- reasoningCatalogBindingsEqual(
- draft.reasoning.catalog,
- draft.reasoningProjectionCatalog,
- )
- ? resolveDraftCatalogEntry(draft)?.reasoning?.presets
- ?.filter(preset => preset.source !== 'model_config')
- .map(preset => preset.id)
- : [],
- ) !== null
- ))) {
+ const reasoningValidationResults = draftsToSave.map(draft => ({
+ modelName: draft.modelName,
+ reasoning: draft.reasoning,
+ projectionCatalog: draft.reasoningProjectionCatalog,
+ snapshotCatalog: draft.reasoningProjectionSnapshot?.catalog,
+ generatedPresetIds: resolveDraftReasoningProjection(draft)?.presets
+ ?.filter(preset => preset.source !== 'model_config')
+ .map(preset => preset.id) ?? [],
+ })).map(entry => ({
+ ...entry,
+ validationError: validateReasoningConfig(entry.reasoning, entry.generatedPresetIds),
+ }));
+ if (reasoningValidationResults.some(entry => entry.validationError !== null)) {
notification.warning(t('messages.invalidReasoningPresets'));
return;
}
@@ -2220,11 +2235,7 @@ const AIModelConfig: React.FC = () => {
const categoryLabel = categoryCompactLabels[draft.category] ?? draft.category;
const canToggleExpand = selectedModelDrafts.length > 1;
const modelDisplayName = draft.modelName;
- const catalogEntry = resolveDraftCatalogEntry(draft);
- const reasoningProjection = reasoningCatalogBindingsEqual(
- draft.reasoning.catalog,
- draft.reasoningProjectionCatalog,
- ) ? catalogEntry?.reasoning : undefined;
+ const reasoningProjection = resolveDraftReasoningProjection(draft);
return (
{
? selectedModelDrafts.find(draft => draft.key === reasoningPanelDraftKey)
: undefined;
const reasoningPanelProjection = reasoningPanelDraft
- && reasoningCatalogBindingsEqual(
- reasoningPanelDraft.reasoning.catalog,
- reasoningPanelDraft.reasoningProjectionCatalog,
- )
- ? resolveDraftCatalogEntry(reasoningPanelDraft)?.reasoning
+ ? resolveDraftReasoningProjection(reasoningPanelDraft)
: undefined;
const reasoningPanelProjectionRequest = reasoningPanelDraft && editingConfig
? {
@@ -3772,9 +3779,14 @@ const AIModelConfig: React.FC = () => {
|| reasoningPanelProjectionRequest.provider
: undefined}
onCancel={() => setReasoningPanelDraftKey(null)}
- onApply={(reasoning) => {
+ onApply={(result: ReasoningConfigApplyResult) => {
updateModelDraft(reasoningPanelDraft.modelName, {
- reasoning,
+ reasoning: result.reasoning,
+ reasoningProjectionCatalog: result.projectionCatalog,
+ reasoningProjectionSnapshot: {
+ catalog: result.projectionCatalog,
+ projection: result.projection,
+ },
});
setReasoningPanelDraftKey(null);
}}
diff --git a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx
index 353dfd71e9..250ef1628c 100644
--- a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx
+++ b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.test.tsx
@@ -3,7 +3,7 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import type { ReasoningConfig } from '../types';
+import type { ReasoningCatalogProjection, ReasoningConfig } from '../types';
import ReasoningConfigPanel from './ReasoningConfigPanel';
const { projectReasoningCatalog } = vi.hoisted(() => ({
@@ -107,8 +107,12 @@ describe('ReasoningConfigPanel', () => {
act(() => apply?.click());
expect(onApply).toHaveBeenCalledWith({
- catalog: { source: 'auto' },
- presets: [{ id: 'custom', actions: [{ type: 'effort', value: 'high' }] }],
+ reasoning: {
+ catalog: { source: 'auto' },
+ presets: [{ id: 'custom', actions: [{ type: 'effort', value: 'high' }] }],
+ },
+ projectionCatalog: { source: 'auto' },
+ projection: undefined,
});
});
@@ -135,15 +139,17 @@ describe('ReasoningConfigPanel', () => {
});
it('refreshes generated presets after an explicit models.dev binding change', async () => {
+ const onApply = vi.fn();
+ const modelsDevProjection: ReasoningCatalogProjection = {
+ status: 'known',
+ presets: [
+ { id: 'low', label: 'Low', order: 0, source: 'models_dev', actions: [] },
+ { id: 'high', label: 'High', order: 1, source: 'models_dev', actions: [] },
+ ],
+ };
projectReasoningCatalog
.mockResolvedValueOnce({ status: 'unknown', presets: [] })
- .mockResolvedValueOnce({
- status: 'known',
- presets: [
- { id: 'low', label: 'Low', order: 0, source: 'models_dev', actions: [] },
- { id: 'high', label: 'High', order: 1, source: 'models_dev', actions: [] },
- ],
- });
+ .mockResolvedValueOnce(modelsDevProjection);
await act(async () => root.render(
{
baseUrl: 'https://gateway.example.com/v1',
}}
onCancel={vi.fn()}
- onApply={vi.fn()}
+ onApply={onApply}
/>,
));
@@ -174,5 +180,18 @@ describe('ReasoningConfigPanel', () => {
});
expect(container.querySelector('[data-testid="generated-presets"]')?.textContent)
.toBe('low,high');
+
+ const apply = Array.from(container.querySelectorAll('button'))
+ .find(button => button.textContent === 'reasoningPresets.apply');
+ act(() => apply?.click());
+
+ expect(onApply).toHaveBeenCalledWith({
+ reasoning: {
+ catalog: { source: 'models_dev', provider: 'openai', model: 'gpt-test' },
+ presets: [],
+ },
+ projectionCatalog: { source: 'models_dev', provider: 'openai', model: 'gpt-test' },
+ projection: modelsDevProjection,
+ });
});
});
diff --git a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx
index e10bb21d42..25fb897013 100644
--- a/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx
+++ b/src/web-ui/src/infrastructure/config/components/ReasoningConfigPanel.tsx
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/component-library';
-import type { ReasoningCatalogProjection, ReasoningConfig } from '../types';
+import type { ReasoningCatalogBinding, ReasoningCatalogProjection, ReasoningConfig } from '../types';
import type { ModelsDevReasoningCatalog } from '@/infrastructure/api/service-api/AIApi';
import { aiApi } from '@/infrastructure/api';
import type { ReasoningCatalogProjectionRequest } from '@/infrastructure/api/service-api/AIApi';
@@ -20,7 +20,13 @@ interface ReasoningConfigPanelProps {
projectionRequest?: Omit;
requestFormatLabel?: string;
onCancel: () => void;
- onApply: (value: ReasoningConfig) => void;
+ onApply: (value: ReasoningConfigApplyResult) => void;
+}
+
+export interface ReasoningConfigApplyResult {
+ reasoning: ReasoningConfig;
+ projectionCatalog: ReasoningCatalogBinding;
+ projection?: ReasoningCatalogProjection | null;
}
export const ReasoningConfigPanel: React.FC = ({
@@ -134,7 +140,15 @@ export const ReasoningConfigPanel: React.FC = ({
-