Add unified thinking effort control across all providers (#9242)

Signed-off-by: jh-block <jhugo@block.xyz>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jh-block
2026-05-20 10:42:42 +02:00
committed by GitHub
parent c467e7f998
commit 98a54e9ec6
30 changed files with 2175 additions and 526 deletions
+80 -1
View File
@@ -1369,6 +1369,56 @@
}
}
},
"/config/providers/{name}/model-info": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_provider_model_info",
"parameters": [
{
"name": "name",
"in": "path",
"description": "Provider name (e.g., openai)",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderModelInfoQuery"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Model metadata fetched successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelInfo"
}
}
}
},
"400": {
"description": "Unknown provider, provider not configured, or authentication error"
},
"429": {
"description": "Rate limit exceeded"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/config/providers/{name}/models": {
"get": {
"tags": [
@@ -1394,7 +1444,7 @@
"schema": {
"type": "array",
"items": {
"type": "string"
"$ref": "#/components/schemas/ModelInfo"
}
}
}
@@ -6533,6 +6583,10 @@
"description": "Cost per token for output in USD (optional)",
"nullable": true
},
"reasoning": {
"type": "boolean",
"description": "Whether this model supports reasoning/thinking controls"
},
"supports_cache_control": {
"type": "boolean",
"description": "Whether this model supports cache control",
@@ -6546,6 +6600,7 @@
"provider",
"model",
"context_limit",
"reasoning",
"currency"
],
"properties": {
@@ -6586,6 +6641,9 @@
},
"provider": {
"type": "string"
},
"reasoning": {
"type": "boolean"
}
}
},
@@ -6973,6 +7031,17 @@
}
}
},
"ProviderModelInfoQuery": {
"type": "object",
"required": [
"model"
],
"properties": {
"model": {
"type": "string"
}
}
},
"ProviderTemplate": {
"type": "object",
"required": [
@@ -8568,6 +8637,16 @@
}
}
},
"ThinkingEffort": {
"type": "string",
"enum": [
"off",
"low",
"medium",
"high",
"max"
]
},
"TokenState": {
"type": "object",
"required": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+48 -1
View File
@@ -818,6 +818,10 @@ export type ModelInfo = {
* Cost per token for output in USD (optional)
*/
output_token_cost?: number | null;
/**
* Whether this model supports reasoning/thinking controls
*/
reasoning?: boolean;
/**
* Whether this model supports cache control
*/
@@ -834,6 +838,7 @@ export type ModelInfoData = {
model: string;
output_token_cost?: number | null;
provider: string;
reasoning: boolean;
};
export type ModelInfoQuery = {
@@ -1000,6 +1005,10 @@ export type ProviderMetadata = {
setup_steps?: Array<string>;
};
export type ProviderModelInfoQuery = {
model: string;
};
export type ProviderTemplate = {
api_url: string;
doc_url: string;
@@ -1482,6 +1491,8 @@ export type ThinkingContent = {
thinking: string;
};
export type ThinkingEffort = 'off' | 'low' | 'medium' | 'high' | 'max';
export type TokenState = {
accumulatedCost?: number | null;
accumulatedInputTokens: number;
@@ -2728,6 +2739,42 @@ export type CleanupProviderCacheResponses = {
export type CleanupProviderCacheResponse = CleanupProviderCacheResponses[keyof CleanupProviderCacheResponses];
export type GetProviderModelInfoData = {
body: ProviderModelInfoQuery;
path: {
/**
* Provider name (e.g., openai)
*/
name: string;
};
query?: never;
url: '/config/providers/{name}/model-info';
};
export type GetProviderModelInfoErrors = {
/**
* Unknown provider, provider not configured, or authentication error
*/
400: unknown;
/**
* Rate limit exceeded
*/
429: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type GetProviderModelInfoResponses = {
/**
* Model metadata fetched successfully
*/
200: ModelInfo;
};
export type GetProviderModelInfoResponse = GetProviderModelInfoResponses[keyof GetProviderModelInfoResponses];
export type GetProviderModelsData = {
body?: never;
path: {
@@ -2759,7 +2806,7 @@ export type GetProviderModelsResponses = {
/**
* Models fetched successfully
*/
200: Array<string>;
200: Array<ModelInfo>;
};
export type GetProviderModelsResponse = GetProviderModelsResponses[keyof GetProviderModelsResponses];
@@ -108,8 +108,8 @@ export const RecipeModelSelector = ({
const modelList = models || [];
const options = modelList.map((m) => ({
value: m,
label: m,
value: m.name,
label: m.name,
provider: p.name,
}));
@@ -1,4 +1,10 @@
import { ProviderDetails, getProviderModels, listLocalModels } from '../../../api';
import {
ProviderDetails,
ThinkingEffort,
getProviderModelInfo,
getProviderModels,
listLocalModels,
} from '../../../api';
import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils';
export default interface Model {
@@ -9,7 +15,8 @@ export default interface Model {
alias?: string; // optional model display name
subtext?: string; // goes below model name if not the provider
context_limit?: number; // optional context limit override
request_params?: Record<string, unknown>; // provider-specific request parameters
reasoning?: boolean; // optional reasoning/thinking support metadata
request_params?: Record<string, unknown> & { thinking_effort?: ThinkingEffort }; // provider-specific request parameters
}
export function createModelStruct(
@@ -45,7 +52,7 @@ export async function getProviderMetadata(
export interface ProviderModelsResult {
provider: ProviderDetails;
models: string[] | null;
models: Model[] | null;
error: string | null;
warning: string | null;
}
@@ -61,7 +68,7 @@ export async function fetchModelsForProviders(
const allModels = response.data || [];
const downloadedModels = allModels
.filter((m) => m.status.state === 'Downloaded')
.map((m) => m.id);
.map((m) => ({ name: m.id, provider: p.name }) as Model);
return { provider: p, models: downloadedModels, error: null, warning: null };
}
@@ -69,12 +76,28 @@ export async function fetchModelsForProviders(
path: { name: p.name },
throwOnError: true,
});
const models = response.data || [];
const models = (response.data || []).map(
(m) =>
({
name: m.name,
provider: p.name,
context_limit: m.context_limit,
reasoning: m.reasoning ?? undefined,
}) as Model
);
return { provider: p, models, error: null, warning: null };
} catch (e: unknown) {
// For custom providers, fall back to the configured model list
if (p.provider_type === 'Custom') {
const fallbackModels = p.metadata.known_models.map((m) => m.name);
const fallbackModels = p.metadata.known_models.map(
(m) =>
({
name: m.name,
provider: p.name,
context_limit: m.context_limit,
reasoning: m.reasoning ?? undefined,
}) as Model
);
if (fallbackModels.length > 0) {
console.warn(`Failed to fetch models for ${p.name}:`, getErrorMessage(e));
return {
@@ -99,3 +122,19 @@ export async function fetchModelsForProviders(
return await Promise.all(modelPromises);
}
export async function fetchModelReasoning(
provider: string,
model: string,
fallback?: boolean
): Promise<boolean | null> {
try {
const response = await getProviderModelInfo({
path: { name: provider },
body: { model },
});
return response.data?.reasoning ?? fallback ?? null;
} catch {
return fallback ?? null;
}
}
@@ -17,12 +17,20 @@ import { Select } from '../../../ui/Select';
import { useConfig } from '../../../ConfigContext';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import type { View } from '../../../../utils/navigationUtils';
import Model, { getProviderMetadata, fetchModelsForProviders } from '../modelInterface';
import Model, {
fetchModelReasoning,
fetchModelsForProviders,
getProviderMetadata,
} from '../modelInterface';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
import { ProviderType } from '../../../../api';
import type { ProviderType, ThinkingEffort } from '../../../../api';
import { trackModelChanged } from '../../../../utils/analytics';
const i18n = defineMessages({
thinkingEffortOff: {
id: 'switchModelModal.thinkingEffortOff',
defaultMessage: 'Off - No extended thinking',
},
thinkingLevelLow: {
id: 'switchModelModal.thinkingLevelLow',
defaultMessage: 'Low - Better latency, lighter reasoning',
@@ -185,16 +193,7 @@ const i18n = defineMessages({
},
});
// THINKING_LEVEL_OPTIONS and CLAUDE_THINKING_EFFORT_OPTIONS are created inside the component to support i18n.
function isClaudeModel(name: string | null | undefined): boolean {
return !!name && name.toLowerCase().startsWith('claude-');
}
function supportsAdaptiveThinking(name: string): boolean {
const lower = name.toLowerCase();
return lower.includes('claude-opus-4-6') || lower.includes('claude-sonnet-4-6');
}
// Thinking effort options are created inside the component to support i18n.
const PREFERRED_MODEL_PATTERNS = [
/claude-sonnet-4/i,
@@ -256,12 +255,8 @@ export const SwitchModelModal = ({
}: SwitchModelModalProps) => {
const intl = useIntl();
const THINKING_LEVEL_OPTIONS = [
{ value: 'low', label: intl.formatMessage(i18n.thinkingLevelLow) },
{ value: 'high', label: intl.formatMessage(i18n.thinkingLevelHigh) },
];
const CLAUDE_THINKING_EFFORT_OPTIONS = [
const THINKING_EFFORT_OPTIONS: { value: ThinkingEffort; label: string }[] = [
{ value: 'off', label: intl.formatMessage(i18n.thinkingEffortOff) },
{ value: 'low', label: intl.formatMessage(i18n.claudeEffortLow) },
{ value: 'medium', label: intl.formatMessage(i18n.claudeEffortMedium) },
{ value: 'high', label: intl.formatMessage(i18n.claudeEffortHigh) },
@@ -278,7 +273,13 @@ export const SwitchModelModal = ({
const currentModel = sessionModel ?? configModel;
const currentProvider = sessionProvider ?? configProvider;
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
type ModelOption = { value: string; label: string; provider: string; isDisabled?: boolean };
type ModelOption = {
value: string;
label: string;
provider: string;
isDisabled?: boolean;
reasoning?: boolean;
};
const [modelOptions, setModelOptions] = useState<{ options: ModelOption[] }[]>([]);
const [provider, setProvider] = useState<string | null>(
initialProvider || currentProvider || null
@@ -304,43 +305,56 @@ export const SwitchModelModal = ({
import('../../../../api').ProviderDetails[]
>([]);
const fetchedProviders = useRef<Set<string>>(new Set());
const [thinkingLevel, setThinkingLevel] = useState<string>('low');
const [claudeThinkingType, setClaudeThinkingType] = useState<string>('disabled');
const [claudeThinkingEffort, setClaudeThinkingEffort] = useState<string>('high');
const [claudeThinkingBudget, setClaudeThinkingBudget] = useState<string>('16000');
const reasoningRequestId = useRef(0);
const [thinkingEffort, setThinkingEffort] = useState<ThinkingEffort | null>(null);
const [selectedModelReasoning, setSelectedModelReasoning] = useState<boolean | null>(null);
const modelName = usePredefinedModels ? selectedPredefinedModel?.name : model;
const isGemini3Model = modelName?.toLowerCase().startsWith('gemini-3') ?? false;
const showClaudeThinking = isClaudeModel(modelName);
const modelSupportsAdaptive = modelName ? supportsAdaptiveThinking(modelName) : false;
const modelReasoning = selectedModelReasoning ?? selectedPredefinedModel?.reasoning;
const showThinkingControl = modelReasoning === true;
const resolveSelectedModelReasoning = useCallback(
(providerName: string, modelName: string, fallback?: boolean) => {
const requestId = ++reasoningRequestId.current;
setSelectedModelReasoning(fallback ?? null);
fetchModelReasoning(providerName, modelName, fallback).then((reasoning) => {
if (requestId === reasoningRequestId.current) {
setSelectedModelReasoning(reasoning);
}
});
},
[]
);
useEffect(() => {
if (!showClaudeThinking) return;
if (claudeThinkingType === 'adaptive' && !modelSupportsAdaptive) {
setClaudeThinkingType('disabled');
}
}, [modelName, showClaudeThinking, modelSupportsAdaptive, claudeThinkingType]);
useEffect(() => {
const readConfig = async (key: string): Promise<string | null> => {
try {
const val = (await read(key, false)) as string;
return val || null;
} catch (e) {
console.warn(`Could not read ${key}, using default:`, e);
return null;
}
};
(async () => {
const tt = await readConfig('CLAUDE_THINKING_TYPE');
if (tt) setClaudeThinkingType(tt);
const effort = await readConfig('CLAUDE_THINKING_EFFORT');
if (effort) setClaudeThinkingEffort(effort);
const budget = await readConfig('CLAUDE_THINKING_BUDGET');
if (budget) setClaudeThinkingBudget(budget);
try {
const effort = (await read('GOOSE_THINKING_EFFORT', false)) as ThinkingEffort;
if (effort) setThinkingEffort(effort);
} catch (e) {
console.warn('Could not read GOOSE_THINKING_EFFORT, using default:', e);
}
})();
}, [read]);
useEffect(() => {
if (!provider || !model) return;
const selectedOption = modelOptions
.flatMap((group) => group.options)
.find((option) => option.provider === provider && option.value === model);
if (selectedOption) {
resolveSelectedModelReasoning(provider, model, selectedOption.reasoning);
return;
}
setSelectedModelReasoning(null);
const timeout = setTimeout(() => {
resolveSelectedModelReasoning(provider, model);
}, 400);
return () => clearTimeout(timeout);
}, [model, provider, modelOptions, resolveSelectedModelReasoning]);
// Validate form data
const validateForm = useCallback(() => {
const errors = {
@@ -393,36 +407,18 @@ export const SwitchModelModal = ({
subtext: providerDisplayName,
} as Model;
}
modelObj = {
...modelObj,
reasoning: selectedModelReasoning ?? modelObj.reasoning,
};
if (isGemini3Model) {
if (showThinkingControl) {
const effort = thinkingEffort ?? modelObj.request_params?.thinking_effort ?? 'off';
modelObj = {
...modelObj,
request_params: { ...modelObj.request_params, thinking_level: thinkingLevel },
request_params: { ...modelObj.request_params, thinking_effort: effort },
};
}
if (showClaudeThinking) {
const params: Record<string, unknown> = {
...modelObj.request_params,
thinking_type: claudeThinkingType,
};
if (claudeThinkingType === 'adaptive') {
params.effort = claudeThinkingEffort;
} else if (claudeThinkingType === 'enabled') {
params.budget_tokens = parseInt(claudeThinkingBudget, 10) || 16000;
}
modelObj = { ...modelObj, request_params: params };
upsert('CLAUDE_THINKING_TYPE', claudeThinkingType, false).catch(console.warn);
if (claudeThinkingType === 'adaptive') {
upsert('CLAUDE_THINKING_EFFORT', claudeThinkingEffort, false).catch(console.warn);
} else if (claudeThinkingType === 'enabled') {
upsert(
'CLAUDE_THINKING_BUDGET',
parseInt(claudeThinkingBudget, 10) || 16000,
false
).catch(console.warn);
}
upsert('GOOSE_THINKING_EFFORT', effort, false).catch(console.warn);
}
const success = await changeModel(sessionId, modelObj);
@@ -450,8 +446,13 @@ export const SwitchModelModal = ({
const matchingModel = models.find((m) => m.name === currentModel);
if (matchingModel) {
setSelectedPredefinedModel(matchingModel);
resolveSelectedModelReasoning(
matchingModel.provider,
matchingModel.name,
matchingModel.reasoning
);
}
}, [usePredefinedModels, currentModel]);
}, [usePredefinedModels, currentModel, resolveSelectedModelReasoning]);
// For manual mode: one-time sync of provider/model when session data
// arrives after the modal has already mounted. Uses a ref so it only
@@ -515,7 +516,7 @@ export const SwitchModelModal = ({
if (cancelled) return;
const newGroupedOptions: {
options: { value: string; label: string; provider: string; providerType: ProviderType }[];
options: (ModelOption & { providerType: ProviderType })[];
}[] = [];
const newErrors: Record<string, string> = {};
const newWarnings: Record<string, string> = {};
@@ -536,11 +537,13 @@ export const SwitchModelModal = ({
label: string;
provider: string;
providerType: ProviderType;
reasoning?: boolean;
}[] = modelList.map((m) => ({
value: m,
label: m,
value: m.name,
label: m.name,
provider: p.name,
providerType: p.provider_type,
reasoning: m.reasoning,
}));
if (p.provider_type !== 'Custom') {
@@ -613,30 +616,51 @@ export const SwitchModelModal = ({
}
}, [provider, modelOptions, loadingModels, model, isCustomModel, userClearedModel, activeProvidersList]);
const handlePredefinedModelChange = (model: Model) => {
setSelectedPredefinedModel(model);
resolveSelectedModelReasoning(model.provider, model.name, model.reasoning);
};
// Handle model selection change
const handleModelChange = (newValue: unknown) => {
const selectedOption = newValue as { value: string; label: string; provider: string } | null;
const selectedOption = newValue as {
value: string;
label: string;
provider: string;
reasoning?: boolean;
} | null;
if (selectedOption?.value === 'custom') {
setIsCustomModel(true);
setModel('');
setProvider(selectedOption.provider);
setSelectedModelReasoning(null);
setUserClearedModel(false);
} else if (selectedOption === null) {
// User cleared the selection
setIsCustomModel(false);
setModel('');
setSelectedModelReasoning(null);
setUserClearedModel(true);
} else {
setIsCustomModel(false);
setModel(selectedOption?.value || '');
setProvider(selectedOption?.provider || '');
if (selectedOption?.provider && selectedOption.value) {
resolveSelectedModelReasoning(
selectedOption.provider,
selectedOption.value,
selectedOption.reasoning
);
} else {
setSelectedModelReasoning(selectedOption?.reasoning ?? null);
}
setUserClearedModel(false);
}
};
// Store the original model options in state, initialized from modelOptions
const [originalModelOptions, setOriginalModelOptions] =
useState<{ options: { value: string; label: string; provider: string }[] }[]>(modelOptions);
useState<{ options: ModelOption[] }[]>(modelOptions);
const handleInputChange = (inputValue: string) => {
if (!provider) return;
@@ -680,54 +704,20 @@ export const SwitchModelModal = ({
}
};
const claudeThinkingTypeOptions = [
...(modelSupportsAdaptive
? [{ value: 'adaptive', label: intl.formatMessage(i18n.claudeAdaptive) }]
: []),
{ value: 'enabled', label: intl.formatMessage(i18n.claudeEnabled) },
{ value: 'disabled', label: intl.formatMessage(i18n.claudeDisabled) },
];
const claudeThinkingControls = showClaudeThinking && (
<div className="mt-2 flex flex-col gap-3">
<div>
<label className="text-sm text-textSubtle mb-1 block">{intl.formatMessage(i18n.extendedThinking)}</label>
<Select
options={claudeThinkingTypeOptions}
value={claudeThinkingTypeOptions.find((o) => o.value === claudeThinkingType)}
onChange={(newValue: unknown) => {
const option = newValue as { value: string; label: string } | null;
setClaudeThinkingType(option?.value || 'disabled');
}}
placeholder={intl.formatMessage(i18n.selectThinkingMode)}
/>
</div>
{claudeThinkingType === 'adaptive' && (
<div>
<label className="text-sm text-textSubtle mb-1 block">{intl.formatMessage(i18n.thinkingEffort)}</label>
<Select
options={CLAUDE_THINKING_EFFORT_OPTIONS}
value={CLAUDE_THINKING_EFFORT_OPTIONS.find((o) => o.value === claudeThinkingEffort)}
onChange={(newValue: unknown) => {
const option = newValue as { value: string; label: string } | null;
setClaudeThinkingEffort(option?.value || 'high');
}}
placeholder={intl.formatMessage(i18n.selectEffortLevel)}
/>
</div>
)}
{claudeThinkingType === 'enabled' && (
<div>
<label className="text-sm text-textSubtle mb-1 block">{intl.formatMessage(i18n.thinkingBudget)}</label>
<Input
className="border-2 px-4 py-2"
type="number"
min="1024"
value={claudeThinkingBudget}
onChange={(e) => setClaudeThinkingBudget(e.target.value)}
/>
</div>
)}
const thinkingEffortControl = showThinkingControl && (
<div className="mt-2">
<label className="text-sm text-textSubtle mb-1 block">
{intl.formatMessage(i18n.thinkingEffort)}
</label>
<Select
options={THINKING_EFFORT_OPTIONS}
value={THINKING_EFFORT_OPTIONS.find((o) => o.value === (thinkingEffort ?? 'off'))}
onChange={(newValue: unknown) => {
const option = newValue as { value: ThinkingEffort; label: string } | null;
setThinkingEffort(option?.value || 'off');
}}
placeholder={intl.formatMessage(i18n.selectEffortLevel)}
/>
</div>
);
@@ -760,7 +750,7 @@ export const SwitchModelModal = ({
? 'bg-background-secondary'
: 'bg-background-primary hover:bg-background-secondary'
} rounded-lg transition-all`}
onClick={() => setSelectedPredefinedModel(model)}
onClick={() => handlePredefinedModelChange(model)}
>
<div className="flex-1">
<div className="flex items-center justify-between">
@@ -786,7 +776,7 @@ export const SwitchModelModal = ({
name="predefined-model"
value={model.name}
checked={selectedPredefinedModel?.name === model.name}
onChange={() => setSelectedPredefinedModel(model)}
onChange={() => handlePredefinedModelChange(model)}
className="peer sr-only"
/>
<div
@@ -805,25 +795,7 @@ export const SwitchModelModal = ({
<div className="text-red-500 text-sm mt-1">{validationErrors.model}</div>
)}
{isGemini3Model && (
<div className="mt-2">
<label className="text-sm text-textSubtle mb-1 block">
{intl.formatMessage(i18n.thinkingLevel)}
<span className="text-xs text-textMuted ml-2">{intl.formatMessage(i18n.geminiOnly)}</span>
</label>
<Select
options={THINKING_LEVEL_OPTIONS}
value={THINKING_LEVEL_OPTIONS.find((o) => o.value === thinkingLevel)}
onChange={(newValue: unknown) => {
const option = newValue as { value: string; label: string } | null;
setThinkingLevel(option?.value || 'low');
}}
placeholder={intl.formatMessage(i18n.selectThinkingLevel)}
/>
</div>
)}
{claudeThinkingControls}
{thinkingEffortControl}
</div>
) : (
/* Manual Provider/Model Selection */
@@ -970,25 +942,7 @@ export const SwitchModelModal = ({
</div>
)}
{isGemini3Model && (
<div className="mt-2">
<label className="text-sm text-textSubtle mb-1 block">
Thinking Level
<span className="text-xs text-textMuted ml-2">(Gemini 3 models only)</span>
</label>
<Select
options={THINKING_LEVEL_OPTIONS}
value={THINKING_LEVEL_OPTIONS.find((o) => o.value === thinkingLevel)}
onChange={(newValue: unknown) => {
const option = newValue as { value: string; label: string } | null;
setThinkingLevel(option?.value || 'low');
}}
placeholder="Select thinking level"
/>
</div>
)}
{claudeThinkingControls}
{thinkingEffortControl}
</>
)}
</div>
+3
View File
@@ -4475,6 +4475,9 @@
"switchModelModal.thinkingEffort": {
"defaultMessage": "Thinking Effort"
},
"switchModelModal.thinkingEffortOff": {
"defaultMessage": "Off - No extended thinking"
},
"switchModelModal.thinkingLevel": {
"defaultMessage": "Thinking Level"
},