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);