diff --git a/.changelog/next/changed-agent-5ad8ec54.md b/.changelog/next/changed-agent-5ad8ec54.md new file mode 100644 index 0000000000..77a19f2fad --- /dev/null +++ b/.changelog/next/changed-agent-5ad8ec54.md @@ -0,0 +1 @@ +- AI provider settings support a configurable default reasoning effort level diff --git a/client/src/pages/AIProviders.jsx b/client/src/pages/AIProviders.jsx index cc506be0b0..c04cf533f8 100644 --- a/client/src/pages/AIProviders.jsx +++ b/client/src/pages/AIProviders.jsx @@ -3,7 +3,7 @@ import { AlertTriangle } from 'lucide-react'; import toast from '../components/ui/Toast'; import * as api from '../services/api'; import socket from '../services/socket'; -import { filterSelectableModels, filterGenerationModels, isEmbeddingModel, mergeModelLists, configuredDefaultIn, localBackendForProvider, modelOptionLabel, providerTypeClass, isTuiProvider, isApiProvider, isProcessProvider, supportsModelRefresh, isGrokBuildCli, isLocalEndpoint, effectiveModelContextWindow, isRunnerAllowedCommand } from '../utils/providers'; +import { filterSelectableModels, filterGenerationModels, isEmbeddingModel, mergeModelLists, configuredDefaultIn, localBackendForProvider, modelOptionLabel, providerTypeClass, isTuiProvider, isApiProvider, isProcessProvider, supportsModelRefresh, isGrokBuildCli, isLocalEndpoint, effectiveModelContextWindow, isRunnerAllowedCommand, effortLevelsForProvider } from '../utils/providers'; import useLocalModels from '../hooks/useLocalModels'; import BrailleSpinner from '../components/BrailleSpinner'; import EmptyState from '../components/EmptyState'; @@ -19,6 +19,7 @@ import { } from '../utils/formatters'; import SettingsTabsHeader from '../components/settings/SettingsTabsHeader'; import CodeReviewDefaultsPanel from '../components/providers/CodeReviewDefaultsPanel'; +import EffortSelect from '../components/cos/EffortSelect'; import Modal from '../components/ui/Modal'; import { FormField } from '../components/ui/FormField'; @@ -607,6 +608,9 @@ export default function AIProviders() { {provider.defaultModel && (

Default: {provider.defaultModel}

)} + {provider.effort && ( +

Default effort: {provider.effort}

+ )} {(() => { const windowLabel = formatContextLength(effectiveModelContextWindow(provider, provider.defaultModel)); return windowLabel ? ( @@ -794,6 +798,7 @@ function ProviderForm({ provider, onClose, onSave, allProviders = [], runnerAllo allowCustomEndpoint: provider?.allowCustomEndpoint === true, models: provider?.models || [], defaultModel: provider?.defaultModel || '', + effort: provider?.effort || '', lightModel: provider?.lightModel || '', mediumModel: provider?.mediumModel || '', heavyModel: provider?.heavyModel || '', @@ -835,6 +840,7 @@ function ProviderForm({ provider, onClose, onSave, allProviders = [], runnerAllo // option for it the four selects below would hold a value matching no option // and render blank — reading as "no model configured" when one is. const configuredDefault = configuredDefaultIn(mergedModels); + const effortProvider = { ...formData, id: provider?.id, models: mergedModels }; // Shared option list for the Default Model + Light/Medium/Heavy tier selects, // so the sentinel option can't be added to some and missed on others. const modelSelectOptions = ( @@ -900,6 +906,13 @@ function ProviderForm({ provider, onClose, onSave, allProviders = [], runnerAllo for (const field of ['defaultModel', 'lightModel', 'mediumModel', 'heavyModel', 'fallbackModel']) { if (isEmbeddingModel(data[field])) data[field] = ''; } + // Effort is meaningful only for providers/models that expose an effort + // ladder. Clear a stale value when an edit switches to an effort-less + // provider or Antigravity model; narrowed ladders are clamped by the + // server and remain visible in the selector. + if (!isProcessProvider(data) || !effortLevelsForProvider({ ...data, id: provider?.id }, data.defaultModel)) { + data.effort = ''; + } if (parsedTimeout != null) { data.timeout = parsedTimeout; } else if (timeoutInput === '') { @@ -1150,6 +1163,15 @@ function ProviderForm({ provider, onClose, onSave, allProviders = [], runnerAllo

+ setFormData(prev => ({ ...prev, effort }))} + label="Default Effort" + hint="Reasoning effort used when a run does not specify one." + /> + {/* Model Tiers */}

Model Tiers

diff --git a/client/src/pages/AIProviders.test.jsx b/client/src/pages/AIProviders.test.jsx index 8c5edc8035..a765015cab 100644 --- a/client/src/pages/AIProviders.test.jsx +++ b/client/src/pages/AIProviders.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router'; const api = vi.hoisted(() => ({ @@ -9,6 +9,7 @@ const api = vi.hoisted(() => ({ getProviderStatuses: vi.fn(), getSampleProviders: vi.fn(), createProvider: vi.fn(), + updateProvider: vi.fn(), })); const toast = vi.hoisted(() => ({ @@ -260,5 +261,35 @@ describe('CoS Agent Runner allowlist warning', () => { expect(await screen.findByText(/command allowlist/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Save' })).not.toBeDisabled(); }); -}); + it('shows the provider default-effort selector for an effort-capable provider', async () => { + api.getProviders.mockResolvedValue({ + providers: [{ + id: 'codex', + name: 'Codex', + type: 'cli', + command: 'codex', + enabled: true, + models: ['gpt-5'], + defaultModel: 'gpt-5', + effort: '', + }], + activeProvider: 'codex', + }); + + renderPage(); + + fireEvent.click(await screen.findByRole('button', { name: 'Edit' })); + + const effort = await screen.findByLabelText('Default Effort'); + expect(effort).toHaveValue(''); + fireEvent.change(effort, { target: { value: 'xhigh' } }); + expect(effort).toHaveValue('xhigh'); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(api.updateProvider).toHaveBeenCalledWith( + 'codex', + expect.objectContaining({ effort: 'xhigh' }), + )); + }); +}); diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js index 4e06c99283..8c350e7a6f 100644 --- a/server/lib/aiToolkit/providers.js +++ b/server/lib/aiToolkit/providers.js @@ -533,6 +533,7 @@ export function createProviderService(config = {}) { apiKey: providerData.apiKey || '', models: providerData.models || [], defaultModel: providerData.defaultModel || null, + effort: providerData.effort || null, lightModel: providerData.lightModel || null, mediumModel: providerData.mediumModel || null, heavyModel: providerData.heavyModel || null, diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js index ad7dcb28e2..25c2eea074 100644 --- a/server/lib/aiToolkit/providers.test.js +++ b/server/lib/aiToolkit/providers.test.js @@ -129,6 +129,7 @@ describe('Provider Service', () => { apiKey: 'sk-test-secret', models: ['model-a', 'model-b', 'model-c'], defaultModel: 'model-a', + effort: 'xhigh', lightModel: 'model-b', mediumModel: 'model-a', heavyModel: 'model-c', diff --git a/server/lib/aiToolkit/validation.js b/server/lib/aiToolkit/validation.js index fcc488fd7e..a4f5ecbc6f 100644 --- a/server/lib/aiToolkit/validation.js +++ b/server/lib/aiToolkit/validation.js @@ -7,6 +7,17 @@ import { MAX_TIMEOUT, MIN_TIMEOUT } from './constants.js'; // rejected. const ALLOWED_SCREENSHOT_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']); +// Reasoning-effort values accepted by the effort-capable CLI providers. Keep +// this local because the vendored toolkit must remain self-contained; the +// runtime mirror lives in server/lib/providerModels.js and the UI mirror lives +// in client/src/utils/providers.js. +const PROVIDER_EFFORT_LEVELS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra']; + +const providerEffort = z.preprocess( + (value) => (typeof value === 'string' && value.trim() === '' ? null : value), + z.enum(PROVIDER_EFFORT_LEVELS).nullable().optional() +); + /** * Sanitize the untrusted `screenshots[]` array from POST /api/runs into safe, * screenshots-dir-relative basenames. `screenshots[]` is unauthenticated user @@ -63,6 +74,8 @@ export const providerSchema = z.object({ apiKey: z.string().optional(), models: z.array(z.string()).optional(), defaultModel: z.string().nullable().optional(), + // Empty string is the UI's "use the provider/CLI default" sentinel. + effort: providerEffort, lightModel: z.string().nullable().optional(), mediumModel: z.string().nullable().optional(), heavyModel: z.string().nullable().optional(), diff --git a/server/lib/aiToolkit/validation.test.js b/server/lib/aiToolkit/validation.test.js index 108a1daf46..459b676aad 100644 --- a/server/lib/aiToolkit/validation.test.js +++ b/server/lib/aiToolkit/validation.test.js @@ -134,6 +134,19 @@ describe('providerSchema', () => { }); }); + describe('default reasoning effort', () => { + it('accepts known effort levels and treats an empty UI value as unset', () => { + expect(providerSchema.safeParse({ ...minimalProvider, effort: 'xhigh' }).success).toBe(true); + const empty = providerSchema.safeParse({ ...minimalProvider, effort: '' }); + expect(empty.success).toBe(true); + expect(empty.data.effort).toBeNull(); + }); + + it('rejects an unknown effort level', () => { + expect(providerSchema.safeParse({ ...minimalProvider, effort: 'turbo' }).success).toBe(false); + }); + }); + describe('type enum + required name', () => { it('rejects an unknown type and a missing/empty name', () => { expect(providerSchema.safeParse({ name: 'X', type: 'magic' }).success).toBe(false);