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}
+3
View File
@@ -2,6 +2,7 @@
* Codex 预设供应商配置模板
*/
import { ProviderCategory } from "../types";
import type { CodexApiFormat } from "../types";
import type { PresetTheme } from "./claudeProviderPresets";
export interface CodexProviderPreset {
@@ -24,6 +25,8 @@ export interface CodexProviderPreset {
// 图标配置
icon?: string; // 图标名称
iconColor?: string; // 图标颜色
// Codex API 格式
apiFormat?: CodexApiFormat;
}
/**
+18
View File
@@ -19,6 +19,10 @@ import {
} from "@/lib/query";
import { extractErrorMessage } from "@/utils/errorUtils";
import { openclawKeys } from "@/hooks/useOpenClaw";
import {
extractCodexWireApi,
isCodexChatWireApi,
} from "@/utils/providerConfigUtils";
/**
* Hook for managing provider actions (add, update, delete, switch)
@@ -149,6 +153,16 @@ export function useProviderActions(
const isCopilotProvider =
activeApp === "claude" &&
provider.meta?.providerType === "github_copilot";
const isCodexChatFormat =
activeApp === "codex" &&
(provider.meta?.apiFormat === "openai_chat" ||
(typeof (provider.settingsConfig as Record<string, any>)?.config ===
"string" &&
isCodexChatWireApi(
extractCodexWireApi(
(provider.settingsConfig as Record<string, any>).config,
),
)));
// Determine why this provider requires the proxy
let proxyRequiredReason: string | null = null;
@@ -171,6 +185,10 @@ export function useProviderActions(
proxyRequiredReason = t("notifications.proxyReasonOpenAIResponses", {
defaultValue: "使用 OpenAI Responses 接口格式",
});
} else if (isCodexChatFormat) {
proxyRequiredReason = t("notifications.proxyReasonOpenAIChat", {
defaultValue: "使用 OpenAI Chat 接口格式",
});
} else if (
activeApp === "claude-desktop" &&
provider.meta?.claudeDesktopMode === "proxy"
+4
View File
@@ -178,6 +178,7 @@
"noRoutingSupport": "No Routing Support"
},
"codex": {
"needsRouting": "Needs Routing",
"noRoutingSupport": "No Routing Support"
},
"claudeDesktop": {
@@ -908,6 +909,9 @@
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires routing)",
"apiFormatOpenAIResponses": "OpenAI Responses API (Requires routing)",
"apiFormatGeminiNative": "Gemini Native generateContent (Requires routing)",
"codexApiFormatResponses": "OpenAI Responses API (Native)",
"codexApiFormatOpenAIChat": "OpenAI Chat Completions (Requires routing)",
"codexApiFormatHint": "Select the Codex API format actually supported by this provider; the config stays on Responses for newer Codex versions, while Chat Completions is converted through local routing.",
"authField": "Auth Field",
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN (Default)",
"authFieldApiKey": "ANTHROPIC_API_KEY",
+4
View File
@@ -178,6 +178,7 @@
"noRoutingSupport": "ルーティング非対応"
},
"codex": {
"needsRouting": "ルーティングが必要",
"noRoutingSupport": "ルーティング非対応"
},
"claudeDesktop": {
@@ -908,6 +909,9 @@
"apiFormatOpenAIChat": "OpenAI Chat Completions(ルーティングが必要)",
"apiFormatOpenAIResponses": "OpenAI Responses API(ルーティングが必要)",
"apiFormatGeminiNative": "Gemini Native generateContent(ルーティングが必要)",
"codexApiFormatResponses": "OpenAI Responses API(ネイティブ)",
"codexApiFormatOpenAIChat": "OpenAI Chat Completions(ルーティングが必要)",
"codexApiFormatHint": "このプロバイダーが実際に対応している Codex API フォーマットを選択します。新しい Codex との互換性のため設定は Responses のままにし、Chat Completions はローカルルーティングで変換します。",
"authField": "認証フィールド",
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(デフォルト)",
"authFieldApiKey": "ANTHROPIC_API_KEY",
+4
View File
@@ -178,6 +178,7 @@
"noRoutingSupport": "不支持路由"
},
"codex": {
"needsRouting": "需要路由",
"noRoutingSupport": "不支持路由"
},
"claudeDesktop": {
@@ -908,6 +909,9 @@
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启路由)",
"apiFormatOpenAIResponses": "OpenAI Responses API (需开启路由)",
"apiFormatGeminiNative": "Gemini Native generateContent (需开启路由)",
"codexApiFormatResponses": "OpenAI Responses API (原生)",
"codexApiFormatOpenAIChat": "OpenAI Chat Completions (需开启路由)",
"codexApiFormatHint": "选择供应商真实支持的 Codex API 格式;配置仍保持 Responses 以兼容新版 CodexChat Completions 会通过本地路由自动转换。",
"authField": "认证字段",
"authFieldAuthToken": "ANTHROPIC_AUTH_TOKEN(默认)",
"authFieldApiKey": "ANTHROPIC_API_KEY",
+6 -1
View File
@@ -161,7 +161,7 @@ export interface ProviderMeta {
costMultiplier?: string;
// 供应商计费模式来源
pricingModelSource?: string;
// Claude API 格式(Claude 供应商使用)
// API 格式(Claude / Codex 供应商使用)
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
// - "openai_responses": OpenAI Responses API 格式,需要格式转换
@@ -204,6 +204,11 @@ export type ClaudeApiFormat =
| "openai_responses"
| "gemini_native";
// Codex API 格式类型
// - "openai_responses": OpenAI Responses API 格式,直接透传
// - "openai_chat": OpenAI Chat Completions 格式,需要本地路由转换
export type CodexApiFormat = "openai_responses" | "openai_chat";
// Claude 认证字段类型
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
+147
View File
@@ -418,6 +418,8 @@ const TOML_SECTION_HEADER_PATTERN = /^\s*\[([^\]\r\n]+)\]\s*$/;
const TOML_BASE_URL_PATTERN =
/^\s*base_url\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PATTERN = /^\s*model\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_WIRE_API_PATTERN =
/^\s*wire_api\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PROVIDER_LINE_PATTERN =
/^\s*model_provider\s*=\s*(["'])([^"'\r\n]+)\1\s*(?:#.*)?$/;
const TOML_MODEL_PROVIDER_PATTERN =
@@ -585,6 +587,8 @@ const getRecoverableBaseUrlAssignments = (
!isOtherProviderSection(sectionName, targetSectionName),
);
const getRecoverableCodexProviderAssignments = getRecoverableBaseUrlAssignments;
const getTopLevelModelProviderLineIndex = (lines: string[]): number => {
const topLevelEndIndex = getTopLevelEndIndex(lines);
@@ -597,6 +601,149 @@ const getTopLevelModelProviderLineIndex = (lines: string[]): number => {
return -1;
};
const CODEX_CHAT_WIRE_API_VALUES = new Set([
"chat",
"chat_completions",
"chat-completions",
"openai_chat",
"openai-chat",
"openai_chat_completions",
]);
// 判断给定的 wire_api 字符串是否表示 Codex 的 Chat Completions 协议
export const isCodexChatWireApi = (
wireApi: string | undefined | null,
): boolean =>
CODEX_CHAT_WIRE_API_VALUES.has(
(wireApi ?? "").trim().toLowerCase(),
);
// 从 Codex 的 TOML 配置文本中提取 wire_api(支持单/双引号)
export const extractCodexWireApi = (
configText: string | undefined | null,
): string | undefined => {
try {
const raw = typeof configText === "string" ? configText : "";
const text = normalizeTomlText(raw);
if (!text) return undefined;
const lines = text.split("\n");
const targetSectionName = getCodexProviderSectionName(text);
if (targetSectionName) {
const sectionRange = getTomlSectionRange(lines, targetSectionName);
if (sectionRange) {
const match = findTomlAssignmentInRange(
lines,
TOML_WIRE_API_PATTERN,
sectionRange.bodyStartIndex,
sectionRange.bodyEndIndex,
targetSectionName,
);
if (match?.value) {
return match.value;
}
}
}
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_WIRE_API_PATTERN,
0,
getTopLevelEndIndex(lines),
);
if (topLevelMatch?.value) {
return topLevelMatch.value;
}
const fallbackAssignments = getRecoverableCodexProviderAssignments(
findTomlAssignments(lines, TOML_WIRE_API_PATTERN),
targetSectionName,
);
return fallbackAssignments.length === 1
? fallbackAssignments[0].value
: undefined;
} catch {
return undefined;
}
};
// 在 Codex 的 TOML 配置文本中写入或更新 wire_api 字段
export const setCodexWireApi = (
configText: string,
wireApi: "responses" | "chat",
): string => {
const normalizedText = normalizeTomlText(configText);
const lines = normalizedText ? normalizedText.split("\n") : [];
const targetSectionName = getCodexProviderSectionName(normalizedText);
const replacementLine = `wire_api = "${wireApi}"`;
const allAssignments = findTomlAssignments(lines, TOML_WIRE_API_PATTERN);
const recoverableAssignments = getRecoverableCodexProviderAssignments(
allAssignments,
targetSectionName,
);
if (targetSectionName) {
let targetSectionRange = getTomlSectionRange(lines, targetSectionName);
const targetMatch = targetSectionRange
? findTomlAssignmentInRange(
lines,
TOML_WIRE_API_PATTERN,
targetSectionRange.bodyStartIndex,
targetSectionRange.bodyEndIndex,
targetSectionName,
)
: undefined;
if (targetMatch) {
lines[targetMatch.index] = replacementLine;
return finalizeTomlText(lines);
}
if (recoverableAssignments.length === 1) {
lines.splice(recoverableAssignments[0].index, 1);
targetSectionRange = getTomlSectionRange(lines, targetSectionName);
}
if (targetSectionRange) {
const insertIndex = getTomlSectionInsertIndex(lines, targetSectionRange);
lines.splice(insertIndex, 0, replacementLine);
return finalizeTomlText(lines);
}
if (lines.length > 0 && lines[lines.length - 1].trim() !== "") {
lines.push("");
}
lines.push(`[${targetSectionName}]`, replacementLine);
return finalizeTomlText(lines);
}
const topLevelEndIndex = getTopLevelEndIndex(lines);
const topLevelMatch = findTomlAssignmentInRange(
lines,
TOML_WIRE_API_PATTERN,
0,
topLevelEndIndex,
);
if (topLevelMatch) {
lines[topLevelMatch.index] = replacementLine;
return finalizeTomlText(lines);
}
const modelProviderIndex = getTopLevelModelProviderLineIndex(lines);
if (modelProviderIndex !== -1) {
lines.splice(modelProviderIndex + 1, 0, replacementLine);
return finalizeTomlText(lines);
}
if (lines.length === 0) {
return `${replacementLine}\n`;
}
lines.splice(topLevelEndIndex, 0, replacementLine);
return finalizeTomlText(lines);
};
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
export const extractCodexBaseUrl = (
configText: string | undefined | null,