feat: adaptive reasoning detection for Codex Chat providers

Auto-detect each Chat-routed Codex provider's reasoning interface from
its name, base URL, and model, then inject the matching thinking
parameter without manual configuration:

- Platform-first inference (OpenRouter, SiliconFlow) overrides model
  rules, since the same model exposes different reasoning controls
  depending on the hosting platform.
- Effort tiers are forwarded only to providers that support them
  (DeepSeek, OpenRouter, and StepFun's step-3.5-flash-2603); on/off-only
  providers (Kimi, GLM, Qwen, MiniMax, MiMo, SiliconFlow) drop the level
  instead of sending a field the upstream rejects.
- OpenRouter uses the native reasoning:{effort} object, clamps max to
  xhigh (its enum has no max), and forwards an explicit effort:"none" so
  reasoning can be turned off.
- StepFun falls back to inference so per-model effort support is honored
  (the static preset would have forced effort on step-3.5-flash too).

Includes the Codex provider-form reasoning controls, i18n strings
(zh/en/ja), and response-side reasoning extraction.
This commit is contained in:
Jason
2026-05-21 22:29:18 +08:00
parent 5048ed632d
commit 44d9aabbf3
13 changed files with 1058 additions and 16 deletions
@@ -4,8 +4,20 @@ import { Button } from "@/components/ui/button";
import { FormLabel } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { toast } from "sonner";
import { Download, Loader2, Plus, Trash2 } from "lucide-react";
import {
ChevronDown,
ChevronRight,
Download,
Loader2,
Plus,
Trash2,
} from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
import {
@@ -16,6 +28,7 @@ import {
import type {
CodexApiFormat,
CodexCatalogModel,
CodexChatReasoning,
ProviderCategory,
} from "@/types";
@@ -50,6 +63,8 @@ interface CodexFormFieldsProps {
// Note: wire_api is always "responses" for Codex; apiFormat controls proxy-layer conversion
apiFormat: CodexApiFormat;
onApiFormatChange: (format: CodexApiFormat) => void;
codexChatReasoning?: CodexChatReasoning;
onCodexChatReasoningChange?: (value: CodexChatReasoning) => void;
// Model Catalog
catalogModels?: CodexCatalogModel[];
@@ -108,6 +123,8 @@ export function CodexFormFields({
onAutoSelectChange,
apiFormat,
onApiFormatChange,
codexChatReasoning = {},
onCodexChatReasoningChange,
catalogModels = [],
onCatalogModelsChange,
speedTestEndpoints,
@@ -116,8 +133,14 @@ export function CodexFormFields({
const [fetchedModels, setFetchedModels] = useState<FetchedModel[]>([]);
const [isFetchingModels, setIsFetchingModels] = useState(false);
const [reasoningExpanded, setReasoningExpanded] = useState(false);
const needsLocalRouting = apiFormat === "openai_chat";
const canEditCatalog = Boolean(onCatalogModelsChange);
const canEditReasoning = Boolean(onCodexChatReasoningChange);
const supportsThinking =
codexChatReasoning.supportsThinking === true ||
codexChatReasoning.supportsEffort === true;
const supportsEffort = codexChatReasoning.supportsEffort === true;
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
catalogModels.map((m) => createCatalogRow(m)),
@@ -157,6 +180,33 @@ export function CodexFormFields({
[onApiFormatChange],
);
const handleReasoningThinkingChange = useCallback(
(checked: boolean) => {
if (!onCodexChatReasoningChange) return;
onCodexChatReasoningChange({
...codexChatReasoning,
supportsThinking: checked,
supportsEffort: checked ? codexChatReasoning.supportsEffort : false,
});
},
[codexChatReasoning, onCodexChatReasoningChange],
);
const handleReasoningEffortChange = useCallback(
(checked: boolean) => {
if (!onCodexChatReasoningChange) return;
onCodexChatReasoningChange({
...codexChatReasoning,
supportsThinking: checked ? true : codexChatReasoning.supportsThinking,
supportsEffort: checked,
effortParam: checked
? (codexChatReasoning.effortParam ?? "reasoning_effort")
: "none",
});
},
[codexChatReasoning, onCodexChatReasoningChange],
);
const handleFetchModels = useCallback(() => {
if (!codexBaseUrl || !codexApiKey) {
showFetchModelsError(null, t, {
@@ -303,6 +353,87 @@ export function CodexFormFields({
</div>
)}
{needsLocalRouting && canEditReasoning && (
<Collapsible
open={reasoningExpanded}
onOpenChange={setReasoningExpanded}
className="rounded-lg border border-border-default p-4"
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant={null}
size="sm"
className="h-8 w-full justify-start gap-1.5 px-0 text-sm font-medium text-foreground hover:opacity-70"
>
{reasoningExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t("codexConfig.reasoningSectionToggle", {
defaultValue: "思考能力(高级·通常自动识别)",
})}
</Button>
</CollapsibleTrigger>
{!reasoningExpanded && (
<p className="mt-1 ml-1 text-xs text-muted-foreground">
{t("codexConfig.reasoningSectionHint", {
defaultValue:
"预设供应商已自动配置;自定义供应商会按名称/地址自动推断。仅当自动识别不准时才需展开手动覆盖。",
})}
</p>
)}
<CollapsibleContent className="space-y-3 pt-3">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningModeHint", {
defaultValue:
"上游 Chat Completions 接口支持开启或关闭 thinking 时启用。Kimi、GLM、Qwen 等通常属于这一类。",
})}
</p>
</div>
<Switch
checked={supportsThinking}
onCheckedChange={handleReasoningThinkingChange}
aria-label={t("codexConfig.reasoningModeToggle", {
defaultValue: "支持思考模式",
})}
/>
</div>
<div className="flex items-center justify-between gap-4 border-t border-border-default pt-3">
<div className="space-y-1">
<FormLabel>
{t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
</FormLabel>
<p className="text-xs leading-relaxed text-muted-foreground">
{t("codexConfig.reasoningEffortHint", {
defaultValue:
"上游支持 low/high/max 等思考深度控制时启用。启用后会自动启用思考模式,并把 Codex 的 reasoning.effort 转成上游 Chat 参数。",
})}
</p>
</div>
<Switch
checked={supportsEffort}
onCheckedChange={handleReasoningEffortChange}
aria-label={t("codexConfig.reasoningEffortToggle", {
defaultValue: "支持思考等级",
})}
/>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Codex 模型映射 —— 仅在本地路由 + 可编辑时显示 */}
{needsLocalRouting && canEditCatalog && (
<div className="space-y-4 rounded-lg border border-border-default p-4">
@@ -16,6 +16,7 @@ import type {
ClaudeApiFormat,
CodexApiFormat,
CodexCatalogModel,
CodexChatReasoning,
ClaudeApiKeyField,
} from "@/types";
import {
@@ -174,6 +175,41 @@ export const normalizeCodexCatalogModelsForSave = (
return normalized;
};
const normalizeCodexChatReasoningForSave = (
value?: CodexChatReasoning,
): CodexChatReasoning | undefined => {
const supportsEffort = value?.supportsEffort === true;
const supportsThinking = value?.supportsThinking === true || supportsEffort;
const hasExplicitConfig = value && Object.keys(value).length > 0;
if (!supportsThinking && !supportsEffort) {
return hasExplicitConfig
? {
supportsThinking: false,
supportsEffort: false,
thinkingParam: "none",
effortParam: "none",
outputFormat: value?.outputFormat ?? "auto",
}
: undefined;
}
return {
supportsThinking,
supportsEffort,
thinkingParam: supportsThinking
? (value?.thinkingParam ?? "thinking")
: "none",
effortParam: supportsEffort
? (value?.effortParam ?? "reasoning_effort")
: "none",
effortValueMode: supportsEffort
? (value?.effortValueMode ?? "passthrough")
: undefined,
outputFormat: value?.outputFormat ?? "auto",
};
};
export interface ProviderFormProps {
appId: AppId;
providerId?: string;
@@ -316,6 +352,7 @@ function ProviderFormFull({
initialData?.meta?.pricingModelSource,
),
});
setCodexChatReasoning(initialData?.meta?.codexChatReasoning ?? {});
}, [appId, initialData, supportsFullUrl]);
const defaultValues: ProviderFormData = useMemo(
@@ -466,6 +503,10 @@ function ProviderFormFull({
const [codexFastMode, setCodexFastMode] = useState<boolean>(
() => initialData?.meta?.codexFastMode ?? false,
);
const [codexChatReasoning, setCodexChatReasoning] =
useState<CodexChatReasoning>(
() => initialData?.meta?.codexChatReasoning ?? {},
);
const {
codexAuth,
@@ -530,6 +571,7 @@ function ProviderFormFull({
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
setCodexChatReasoning({});
}
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
@@ -1336,6 +1378,12 @@ function ProviderFormFull({
? selectedGitHubAccountId
: undefined,
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
codexChatReasoning:
appId === "codex" &&
category !== "official" &&
localCodexApiFormat === "openai_chat"
? normalizeCodexChatReasoningForSave(codexChatReasoning)
: undefined,
testConfig: testConfig.enabled ? testConfig : undefined,
costMultiplier: pricingConfig.enabled
? pricingConfig.costMultiplier
@@ -1473,6 +1521,7 @@ function ProviderFormFull({
if (appId === "codex") {
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
setCodexChatReasoning({});
setLocalCodexApiFormat(
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
"openai_responses",
@@ -1513,6 +1562,7 @@ function ProviderFormFull({
const config = preset.config ?? "";
resetCodexConfig(auth, config, preset.modelCatalog ?? []);
setCodexChatReasoning(preset.codexChatReasoning ?? {});
setLocalCodexApiFormat(
preset.apiFormat ??
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
@@ -1984,6 +2034,8 @@ function ProviderFormFull({
onAutoSelectChange={setEndpointAutoSelect}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
codexChatReasoning={codexChatReasoning}
onCodexChatReasoningChange={setCodexChatReasoning}
catalogModels={codexCatalogModels}
onCatalogModelsChange={setCodexCatalogModels}
speedTestEndpoints={speedTestEndpoints}