Add Chat Completions routing for Codex providers

- Add a Codex API format selector and routing badge for Chat Completions providers.
- Convert Codex Responses requests to upstream Chat Completions when routing is required.
- Convert Chat Completions JSON and SSE responses back to Responses format.
- Keep generated Codex wire_api values on Responses for Codex compatibility.
- Add i18n labels, provider metadata handling, and focused conversion tests.
This commit is contained in:
Jason
2026-05-19 10:23:48 +08:00
parent 61e68d754c
commit 1c82b8a3fa
19 changed files with 2230 additions and 35 deletions
+27 -1
View File
@@ -18,7 +18,11 @@ import { PROVIDER_TYPES } from "@/config/constants";
import { isHermesReadOnlyProvider } from "@/config/hermesProviderPresets";
import { ProviderHealthBadge } from "@/components/providers/ProviderHealthBadge";
import { FailoverPriorityBadge } from "@/components/providers/FailoverPriorityBadge";
import { extractCodexBaseUrl } from "@/utils/providerConfigUtils";
import {
extractCodexBaseUrl,
extractCodexWireApi,
isCodexChatWireApi,
} from "@/utils/providerConfigUtils";
import { useProviderHealth } from "@/lib/query/failover";
import { useUsageQuery } from "@/lib/query/queries";
@@ -191,6 +195,20 @@ export function ProviderCard({
appId === "hermes" && isHermesReadOnlyProvider(provider.settingsConfig);
const isCodexOauth =
provider.meta?.providerType === PROVIDER_TYPES.CODEX_OAUTH;
const codexNeedsRouting = useMemo(() => {
if (appId !== "codex" || provider.category === "official") return false;
if (provider.meta?.apiFormat === "openai_chat") return true;
const config = (provider.settingsConfig as Record<string, any>)?.config;
return (
typeof config === "string" &&
isCodexChatWireApi(extractCodexWireApi(config))
);
}, [
appId,
provider.category,
provider.meta?.apiFormat,
(provider.settingsConfig as Record<string, any>)?.config,
]);
const isClaudeThirdParty =
appId === "claude" && provider.category === "third_party";
@@ -345,6 +363,14 @@ export function ProviderCard({
</span>
)}
{codexNeedsRouting && (
<span className="inline-flex items-center rounded-md bg-sky-100 px-1.5 py-0.5 text-[10px] font-semibold text-sky-700 dark:bg-sky-900/40 dark:text-sky-300">
{t("codex.needsRouting", {
defaultValue: "需要路由",
})}
</span>
)}
{appId === "claude" && provider.category === "official" && (
<span className="inline-flex items-center rounded-md bg-slate-200 px-1.5 py-0.5 text-[10px] font-semibold text-slate-700 dark:bg-slate-700/60 dark:text-slate-200">
{t("claudeCode.noRoutingSupport", {
@@ -1,6 +1,14 @@
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { FormLabel } from "@/components/ui/form";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { Download, Loader2 } from "lucide-react";
import EndpointSpeedTest from "./EndpointSpeedTest";
@@ -10,7 +18,7 @@ import {
showFetchModelsError,
type FetchedModel,
} from "@/lib/api/model-fetch";
import type { ProviderCategory } from "@/types";
import type { CodexApiFormat, ProviderCategory } from "@/types";
interface EndpointCandidate {
url: string;
@@ -39,6 +47,10 @@ interface CodexFormFieldsProps {
autoSelect: boolean;
onAutoSelectChange: (checked: boolean) => void;
// API Format
apiFormat: CodexApiFormat;
onApiFormatChange: (format: CodexApiFormat) => void;
// Model Name
shouldShowModelField?: boolean;
modelName?: string;
@@ -67,6 +79,8 @@ export function CodexFormFields({
onCustomEndpointsChange,
autoSelect,
onAutoSelectChange,
apiFormat,
onApiFormatChange,
shouldShowModelField = true,
modelName = "",
onModelNameChange,
@@ -143,6 +157,43 @@ export function CodexFormFields({
/>
)}
{/* Codex API 格式选择 */}
{shouldShowSpeedTest && (
<div className="space-y-2">
<FormLabel htmlFor="codexApiFormat">
{t("providerForm.apiFormat", { defaultValue: "API 格式" })}
</FormLabel>
<Select
value={apiFormat}
onValueChange={(value) =>
onApiFormatChange(value as CodexApiFormat)
}
>
<SelectTrigger id="codexApiFormat" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai_responses">
{t("providerForm.codexApiFormatResponses", {
defaultValue: "OpenAI Responses API (原生)",
})}
</SelectItem>
<SelectItem value="openai_chat">
{t("providerForm.codexApiFormatOpenAIChat", {
defaultValue: "OpenAI Chat Completions (需开启路由)",
})}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("providerForm.codexApiFormatHint", {
defaultValue:
"选择供应商真实支持的 Codex API 格式;Chat Completions 会通过本地路由自动转换为 Responses。",
})}
</p>
</div>
)}
{/* Codex Model Name 输入框 */}
{shouldShowModelField && onModelNameChange && (
<div className="space-y-2">
@@ -14,6 +14,7 @@ import type {
ProviderMeta,
ProviderTestConfig,
ClaudeApiFormat,
CodexApiFormat,
ClaudeApiKeyField,
} from "@/types";
import {
@@ -51,6 +52,10 @@ import {
hasApiKeyField,
} from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import {
extractCodexWireApi,
setCodexWireApi,
} from "@/utils/providerConfigUtils";
import { isNonNegativeDecimalString } from "@/types/usage";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
@@ -118,6 +123,25 @@ type PresetEntry = {
| HermesProviderPreset;
};
const codexApiFormatFromWireApi = (
wireApi: string | undefined,
): CodexApiFormat | undefined => {
switch (wireApi?.trim().toLowerCase()) {
case "chat":
case "chat_completions":
case "chat-completions":
case "openai_chat":
case "openai-chat":
return "openai_chat";
case "responses":
case "openai_responses":
case "openai-responses":
return "openai_responses";
default:
return undefined;
}
};
export interface ProviderFormProps {
appId: AppId;
providerId?: string;
@@ -419,6 +443,7 @@ function ProviderFormFull({
codexModelName,
codexAuthError,
setCodexAuth,
setCodexConfig,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelNameChange,
@@ -426,17 +451,52 @@ function ProviderFormFull({
resetCodexConfig,
} = useCodexConfigState({ initialData });
const [localCodexApiFormat, setLocalCodexApiFormat] =
useState<CodexApiFormat>(() => {
if (initialData?.meta?.apiFormat === "openai_chat") {
return "openai_chat";
}
if (initialData?.meta?.apiFormat === "openai_responses") {
return "openai_responses";
}
return (
codexApiFormatFromWireApi(
extractCodexWireApi(
typeof initialData?.settingsConfig?.config === "string"
? initialData.settingsConfig.config
: "",
),
) ?? "openai_responses"
);
});
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
const handleCodexConfigChange = useCallback(
(value: string) => {
originalHandleCodexConfigChange(value);
const nextFormat = codexApiFormatFromWireApi(extractCodexWireApi(value));
if (nextFormat) {
setLocalCodexApiFormat(nextFormat);
}
debouncedValidate(value);
},
[originalHandleCodexConfigChange, debouncedValidate],
);
const handleCodexApiFormatChange = useCallback(
(format: CodexApiFormat) => {
setLocalCodexApiFormat(format);
setCodexConfig((prev) => {
const updated = setCodexWireApi(prev, "responses");
debouncedValidate(updated);
return updated;
});
},
[setCodexConfig, debouncedValidate],
);
useEffect(() => {
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
const template = getCodexCustomTemplate();
@@ -1049,9 +1109,13 @@ function ProviderFormFull({
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
const normalizedCodexConfig =
category !== "official" && (codexConfig ?? "").trim()
? setCodexWireApi(codexConfig ?? "", "responses")
: (codexConfig ?? "");
const configObj = {
auth: authJson,
config: codexConfig ?? "",
config: normalizedCodexConfig,
};
settingsConfig = JSON.stringify(configObj);
} catch (err) {
@@ -1236,7 +1300,9 @@ function ProviderFormFull({
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
: undefined,
: appId === "codex" && category !== "official"
? localCodexApiFormat
: undefined,
apiKeyField:
appId === "claude" &&
category !== "official" &&
@@ -1360,6 +1426,10 @@ function ProviderFormFull({
if (appId === "codex") {
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
setLocalCodexApiFormat(
codexApiFormatFromWireApi(extractCodexWireApi(template.config)) ??
"openai_responses",
);
}
if (appId === "gemini") {
resetGeminiConfig({}, {});
@@ -1396,6 +1466,11 @@ function ProviderFormFull({
const config = preset.config ?? "";
resetCodexConfig(auth, config);
setLocalCodexApiFormat(
preset.apiFormat ??
codexApiFormatFromWireApi(extractCodexWireApi(config)) ??
"openai_responses",
);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
@@ -1860,6 +1935,8 @@ function ProviderFormFull({
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
apiFormat={localCodexApiFormat}
onApiFormatChange={handleCodexApiFormatChange}
shouldShowModelField={category !== "official"}
modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange}