mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(codex): xAI (Grok) OAuth managed provider with native Responses compat
Add a managed "xAI (Grok) OAuth" Codex provider that routes through the
local proxy to api.x.ai via the shared Grok CLI OAuth identity, plus the
native-Responses compatibility layer that makes Codex 0.142+ traffic work
against xAI's strict upstream serde parser.
Provider:
- codex.rs: recognize the xai_oauth placeholder in extract_auth, hard-pin
the base URL to api.x.ai and the tool profile to native Responses
- forwarder.rs: treat xAI OAuth auth failures as non-retryable
- presets + ProviderForm/CodexFormFields: managed OAuth preset that hides
the api key/endpoint fields and derives the provider type across apps
Native Responses compatibility (gated on is_xai_oauth, so no other
provider is affected):
- transform_codex_responses_namespace: flatten Codex's private
namespace/plugin tool declarations into top-level function tools on the
request; restore the flat function_call names back to {name, namespace}
on the response (streaming and non-streaming) so the client matches its
own namespaced tool registry
- transform_codex_responses_xai_sanitize: strip the OpenAI-backend-private
fields xAI rejects (external_web_access, prompt_cache_retention,
safety_identifier, the additional_tools carrier, tool_search, ...) with
deterministic removals that keep the prompt-cache prefix stable
- wire both into the native passthrough after the request transform;
response restore runs in a dedicated handler so the generic passthrough
hot path is untouched
Ports the proven approach of sub2api's Grok Responses gateway. Verified
with a 4-round codex -> xAI OAuth workload: all tasks green, zero upstream
errors.
This commit is contained in:
@@ -27,8 +27,10 @@ import {
|
||||
} from "lucide-react";
|
||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||
import { ApiKeySection, EndpointField, ModelDropdown } from "./shared";
|
||||
import { XaiOAuthSection } from "./XaiOAuthSection";
|
||||
import {
|
||||
fetchModelsForConfig,
|
||||
fetchXaiOauthModels,
|
||||
showFetchModelsError,
|
||||
type FetchedModel,
|
||||
} from "@/lib/api/model-fetch";
|
||||
@@ -52,6 +54,11 @@ interface EndpointCandidate {
|
||||
interface CodexFormFieldsProps {
|
||||
appId?: AppId;
|
||||
providerId?: string;
|
||||
// xAI OAuth 托管预设(Grok 订阅):隐藏 API Key / 端点输入,挂账号选择区块
|
||||
isXaiOauthPreset?: boolean;
|
||||
isXaiOauthAuthenticated?: boolean;
|
||||
selectedXaiAccountId?: string | null;
|
||||
onXaiAccountSelect?: (accountId: string | null) => void;
|
||||
// API Key
|
||||
codexApiKey: string;
|
||||
onApiKeyChange: (key: string) => void;
|
||||
@@ -159,6 +166,10 @@ function catalogRowsMatchModels(
|
||||
export function CodexFormFields({
|
||||
appId = "codex",
|
||||
providerId,
|
||||
isXaiOauthPreset,
|
||||
isXaiOauthAuthenticated,
|
||||
selectedXaiAccountId,
|
||||
onXaiAccountSelect,
|
||||
codexApiKey,
|
||||
onApiKeyChange,
|
||||
category,
|
||||
@@ -212,7 +223,15 @@ export function CodexFormFields({
|
||||
useEffect(() => {
|
||||
fetchModelsSeqRef.current += 1;
|
||||
setFetchedModels((prev) => (prev.length === 0 ? prev : []));
|
||||
}, [codexBaseUrl, isFullUrl, codexApiKey, customUserAgent]);
|
||||
}, [
|
||||
codexBaseUrl,
|
||||
isFullUrl,
|
||||
codexApiKey,
|
||||
customUserAgent,
|
||||
isXaiOauthPreset,
|
||||
isXaiOauthAuthenticated,
|
||||
selectedXaiAccountId,
|
||||
]);
|
||||
// 思考能力随 Chat 格式显示(仅 Chat Completions 转换路径用得上);模型映射常驻
|
||||
//(填了才生成 catalog)。两者都已与「路由接管」概念解耦。
|
||||
const isChatFormat = apiFormat === "openai_chat";
|
||||
@@ -239,14 +258,20 @@ export function CodexFormFields({
|
||||
supportsEffort ||
|
||||
promptCacheRouting !== "auto" ||
|
||||
!!maxOutputTokens;
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(hasAnyAdvancedValue);
|
||||
const [advancedExpanded, setAdvancedExpanded] = useState(
|
||||
isXaiOauthPreset ? false : hasAnyAdvancedValue,
|
||||
);
|
||||
|
||||
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠)
|
||||
// 预设/编辑加载填充高级值后自动展开(仅从折叠→展开,不会自动折叠);
|
||||
// xAI OAuth 托管预设的高级值都是预设自带的,无需展示,保持折叠
|
||||
useEffect(() => {
|
||||
if (isXaiOauthPreset) {
|
||||
return;
|
||||
}
|
||||
if (hasAnyAdvancedValue) {
|
||||
setAdvancedExpanded(true);
|
||||
}
|
||||
}, [hasAnyAdvancedValue]);
|
||||
}, [hasAnyAdvancedValue, isXaiOauthPreset]);
|
||||
|
||||
const [catalogRows, setCatalogRows] = useState<CodexCatalogRow[]>(() =>
|
||||
catalogModels.map((m) => createCatalogRow(m)),
|
||||
@@ -307,6 +332,40 @@ export function CodexFormFields({
|
||||
);
|
||||
|
||||
const handleFetchModels = useCallback(() => {
|
||||
// xAI OAuth 托管预设:不走 base_url + key 的 /models 探测,
|
||||
// 直接用托管账号 token 拉取(与 Claude 表单同一后端命令)
|
||||
if (isXaiOauthPreset) {
|
||||
if (!isXaiOauthAuthenticated) {
|
||||
toast.error(
|
||||
t("xaiOauth.loginRequired", {
|
||||
defaultValue: "请先登录 xAI 账号",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const seq = ++fetchModelsSeqRef.current;
|
||||
setIsFetchingModels(true);
|
||||
fetchXaiOauthModels(selectedXaiAccountId ?? null)
|
||||
.then((models) => {
|
||||
if (seq !== fetchModelsSeqRef.current) return;
|
||||
setFetchedModels(models);
|
||||
if (models.length === 0) {
|
||||
toast.info(t("providerForm.fetchModelsEmpty"));
|
||||
} else {
|
||||
toast.success(
|
||||
t("providerForm.fetchModelsSuccess", { count: models.length }),
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (seq !== fetchModelsSeqRef.current) return;
|
||||
console.warn("[XaiOAuth] Failed to fetch models:", err);
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!codexBaseUrl || !codexApiKey) {
|
||||
showFetchModelsError(null, t, {
|
||||
hasApiKey: !!codexApiKey,
|
||||
@@ -340,7 +399,16 @@ export function CodexFormFields({
|
||||
showFetchModelsError(err, t);
|
||||
})
|
||||
.finally(() => setIsFetchingModels(false));
|
||||
}, [codexBaseUrl, codexApiKey, isFullUrl, customUserAgent, t]);
|
||||
}, [
|
||||
codexBaseUrl,
|
||||
codexApiKey,
|
||||
isFullUrl,
|
||||
customUserAgent,
|
||||
isXaiOauthPreset,
|
||||
isXaiOauthAuthenticated,
|
||||
selectedXaiAccountId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleAddCatalogRow = useCallback(() => {
|
||||
if (!onCatalogModelsChange) return;
|
||||
@@ -433,29 +501,39 @@ export function CodexFormFields({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Codex API Key 输入框 */}
|
||||
<ApiKeySection
|
||||
id="codexApiKey"
|
||||
label="API Key"
|
||||
value={codexApiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
placeholder={{
|
||||
official: t("providerForm.codexOfficialNoApiKey", {
|
||||
defaultValue: "官方供应商无需 API Key",
|
||||
}),
|
||||
thirdParty: t("providerForm.codexApiKeyAutoFill", {
|
||||
defaultValue: "输入 API Key,将自动填充到配置",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
{/* xAI OAuth 认证(Grok 订阅托管账号) */}
|
||||
{isXaiOauthPreset && (
|
||||
<XaiOAuthSection
|
||||
selectedAccountId={selectedXaiAccountId}
|
||||
onAccountSelect={onXaiAccountSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex Base URL 输入框 */}
|
||||
{shouldShowSpeedTest && (
|
||||
{/* Codex API Key 输入框(托管 OAuth 预设无需 Key) */}
|
||||
{!isXaiOauthPreset && (
|
||||
<ApiKeySection
|
||||
id="codexApiKey"
|
||||
label="API Key"
|
||||
value={codexApiKey}
|
||||
onChange={onApiKeyChange}
|
||||
category={category}
|
||||
shouldShowLink={shouldShowApiKeyLink}
|
||||
websiteUrl={websiteUrl}
|
||||
isPartner={isPartner}
|
||||
partnerPromotionKey={partnerPromotionKey}
|
||||
placeholder={{
|
||||
official: t("providerForm.codexOfficialNoApiKey", {
|
||||
defaultValue: "官方供应商无需 API Key",
|
||||
}),
|
||||
thirdParty: t("providerForm.codexApiKeyAutoFill", {
|
||||
defaultValue: "输入 API Key,将自动填充到配置",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Codex Base URL 输入框(托管 OAuth 端点由 adapter 硬定向,不展示) */}
|
||||
{shouldShowSpeedTest && !isXaiOauthPreset && (
|
||||
<EndpointField
|
||||
id="codexBaseUrl"
|
||||
label={t("codexConfig.apiUrlLabel")}
|
||||
@@ -571,8 +649,9 @@ export function CodexFormFields({
|
||||
)}
|
||||
<CollapsibleContent className="space-y-3 pt-3">
|
||||
{/* 上游格式 —— Chat 需开启路由接管(走代理转换),Responses 原生直连。
|
||||
沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换。 */}
|
||||
{shouldShowSpeedTest && (
|
||||
沿用 shouldShowSpeedTest 门控,cloud_provider 保持不可切换;
|
||||
xAI OAuth 托管预设格式钉死 Responses,不可切换。 */}
|
||||
{shouldShowSpeedTest && !isXaiOauthPreset && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<FormLabel htmlFor="codex-upstream-format">
|
||||
|
||||
@@ -717,6 +717,17 @@ function ProviderFormFull({
|
||||
}));
|
||||
}, [appId]);
|
||||
|
||||
// 预设声明的托管身份类型(github_copilot / codex_oauth / xai_oauth)。
|
||||
// 跨应用通用:claude 的 templatePreset 与此查同一张 presetEntries 表,
|
||||
// codex 等其它应用没有 templatePreset,只能走这里。
|
||||
const presetProviderType = useMemo(() => {
|
||||
if (!selectedPresetId) return undefined;
|
||||
const preset = presetEntries.find(
|
||||
(entry) => entry.id === selectedPresetId,
|
||||
)?.preset;
|
||||
return preset && "providerType" in preset ? preset.providerType : undefined;
|
||||
}, [presetEntries, selectedPresetId]);
|
||||
|
||||
const {
|
||||
templateValues,
|
||||
templateValueEntries,
|
||||
@@ -1153,14 +1164,14 @@ function ProviderFormFull({
|
||||
|
||||
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
|
||||
const isCopilotProvider =
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
presetProviderType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com");
|
||||
const isCodexOauthProvider =
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
presetProviderType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth";
|
||||
const isXaiOauthProvider =
|
||||
templatePreset?.providerType === "xai_oauth" ||
|
||||
presetProviderType === "xai_oauth" ||
|
||||
initialData?.meta?.providerType === "xai_oauth";
|
||||
if (isCopilotProvider && !isCopilotAuthenticated) {
|
||||
toast.error(
|
||||
@@ -1284,14 +1295,16 @@ function ProviderFormFull({
|
||||
);
|
||||
}
|
||||
} else if (appId === "codex") {
|
||||
if (!codexBaseUrl.trim()) {
|
||||
// 托管 OAuth 预设(xAI):端点由 adapter 硬定向、token 由代理注入,
|
||||
// 两项都不需要用户填写
|
||||
if (!isXaiOauthProvider && !codexBaseUrl.trim()) {
|
||||
issues.push(
|
||||
t("providerForm.endpointRequired", {
|
||||
defaultValue: "非官方供应商请填写 API 端点",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!codexApiKey.trim()) {
|
||||
if (!isXaiOauthProvider && !codexApiKey.trim()) {
|
||||
issues.push(
|
||||
t("providerForm.apiKeyRequired", {
|
||||
defaultValue: "非官方供应商请填写 API Key",
|
||||
@@ -1343,14 +1356,14 @@ function ProviderFormFull({
|
||||
|
||||
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
|
||||
const isCopilotProvider =
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
presetProviderType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com");
|
||||
const isCodexOauthProvider =
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
presetProviderType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth";
|
||||
const isXaiOauthProvider =
|
||||
templatePreset?.providerType === "xai_oauth" ||
|
||||
presetProviderType === "xai_oauth" ||
|
||||
initialData?.meta?.providerType === "xai_oauth";
|
||||
|
||||
let settingsConfig: string;
|
||||
@@ -1529,7 +1542,7 @@ function ProviderFormFull({
|
||||
|
||||
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
|
||||
const providerType =
|
||||
templatePreset?.providerType || initialData?.meta?.providerType;
|
||||
presetProviderType || initialData?.meta?.providerType;
|
||||
|
||||
const nextMeta: ProviderMeta = {
|
||||
...(baseMeta ?? {}),
|
||||
@@ -1603,7 +1616,9 @@ function ProviderFormFull({
|
||||
? "openai_responses"
|
||||
: localApiFormat
|
||||
: appId === "codex" && category !== "official"
|
||||
? localCodexApiFormat
|
||||
? isXaiOauthProvider
|
||||
? "openai_responses"
|
||||
: localCodexApiFormat
|
||||
: undefined,
|
||||
apiKeyField:
|
||||
appId === "claude" &&
|
||||
@@ -2186,26 +2201,26 @@ function ProviderFormFull({
|
||||
isPartner={isClaudePartner}
|
||||
partnerPromotionKey={claudePartnerPromotionKey}
|
||||
isCopilotPreset={
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
presetProviderType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com")
|
||||
}
|
||||
isCodexOauthPreset={
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
presetProviderType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth"
|
||||
}
|
||||
isXaiOauthPreset={
|
||||
templatePreset?.providerType === "xai_oauth" ||
|
||||
presetProviderType === "xai_oauth" ||
|
||||
initialData?.meta?.providerType === "xai_oauth"
|
||||
}
|
||||
usesOAuth={
|
||||
templatePreset?.requiresOAuth === true ||
|
||||
templatePreset?.providerType === "github_copilot" ||
|
||||
presetProviderType === "github_copilot" ||
|
||||
initialData?.meta?.providerType === "github_copilot" ||
|
||||
baseUrl.includes("githubcopilot.com") ||
|
||||
templatePreset?.providerType === "codex_oauth" ||
|
||||
presetProviderType === "codex_oauth" ||
|
||||
initialData?.meta?.providerType === "codex_oauth" ||
|
||||
templatePreset?.providerType === "xai_oauth" ||
|
||||
presetProviderType === "xai_oauth" ||
|
||||
initialData?.meta?.providerType === "xai_oauth"
|
||||
}
|
||||
isCopilotAuthenticated={isCopilotAuthenticated}
|
||||
@@ -2265,6 +2280,13 @@ function ProviderFormFull({
|
||||
{appId === "codex" && (
|
||||
<CodexFormFields
|
||||
providerId={providerId}
|
||||
isXaiOauthPreset={
|
||||
presetProviderType === "xai_oauth" ||
|
||||
initialData?.meta?.providerType === "xai_oauth"
|
||||
}
|
||||
isXaiOauthAuthenticated={isXaiOauthAuthenticated}
|
||||
selectedXaiAccountId={selectedXaiAccountId}
|
||||
onXaiAccountSelect={setSelectedXaiAccountId}
|
||||
codexApiKey={codexApiKey}
|
||||
onApiKeyChange={handleCodexApiKeyChange}
|
||||
category={category}
|
||||
|
||||
@@ -33,6 +33,10 @@ export interface CodexProviderPreset {
|
||||
iconColor?: string; // 图标颜色
|
||||
// Codex API 格式
|
||||
apiFormat?: CodexApiFormat;
|
||||
// 托管账号预设:目前仅 xAI OAuth(Grok 订阅经本地代理注入 token 直连 api.x.ai)
|
||||
providerType?: "xai_oauth";
|
||||
// OAuth 预设:隐藏 API Key 输入,保存前要求已登录托管账号
|
||||
requiresOAuth?: boolean;
|
||||
// Codex Chat 本地路由模式下的模型目录
|
||||
modelCatalog?: CodexCatalogModel[];
|
||||
// Codex Responses -> Chat Completions reasoning capability defaults
|
||||
@@ -1023,6 +1027,29 @@ requires_openai_auth = true`,
|
||||
icon: "xai",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "xAI (Grok) OAuth",
|
||||
websiteUrl: "https://x.ai/grok",
|
||||
auth: generateThirdPartyAuth(""),
|
||||
// 托管 OAuth:真实 token 由本地代理按请求注入,CodexAdapter 硬定向
|
||||
// api.x.ai;这里的 base_url / 空 auth 只是配置快照,转发时不生效。
|
||||
config: generateThirdPartyConfig("xai", "https://api.x.ai/v1", "grok-4.5"),
|
||||
apiFormat: "openai_responses",
|
||||
providerType: "xai_oauth",
|
||||
requiresOAuth: true,
|
||||
modelCatalog: modelCatalog([
|
||||
{
|
||||
model: "grok-4.5",
|
||||
displayName: "Grok 4.5",
|
||||
contextWindow: 500000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
},
|
||||
]),
|
||||
category: "third_party",
|
||||
icon: "xai",
|
||||
iconColor: "#000000",
|
||||
},
|
||||
{
|
||||
name: "Nvidia",
|
||||
websiteUrl: "https://build.nvidia.com",
|
||||
|
||||
Reference in New Issue
Block a user