feat: Add Codex OAuth FAST mode toggle (#2210)

* Add Codex OAuth FAST mode toggle

* fix(codex-oauth): default FAST mode to off to avoid surprise quota burn

service_tier="priority" consumes ChatGPT subscription quota at a higher
rate. Users must now opt in explicitly rather than inherit FAST mode
silently when this feature ships.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
Jesus Díaz Rivas
2026-04-23 06:05:17 +02:00
committed by GitHub
parent 444c123ad0
commit 10e0772d8c
11 changed files with 181 additions and 40 deletions

View File

@@ -262,6 +262,9 @@ pub struct ProviderMeta {
/// conversions fall back to provider ID.
#[serde(rename = "promptCacheKey", skip_serializing_if = "Option::is_none")]
pub prompt_cache_key: Option<String>,
/// Codex OAuth FAST mode: inject `service_tier = "priority"` for ChatGPT Codex requests.
#[serde(rename = "codexFastMode", skip_serializing_if = "Option::is_none")]
pub codex_fast_mode: Option<bool>,
/// 累加模式应用中,该 provider 是否已写入 live config。
/// `None` 表示旧数据/未知状态,`Some(false)` 表示明确仅存在于数据库中。
#[serde(rename = "liveConfigManaged", skip_serializing_if = "Option::is_none")]
@@ -277,6 +280,12 @@ pub struct ProviderMeta {
}
impl ProviderMeta {
/// Codex OAuth FAST mode 是否启用。默认关闭,因为 `service_tier="priority"`
/// 会按更高速率消耗 ChatGPT 订阅配额,用户需显式开启以换取更低延迟。
pub fn codex_fast_mode_enabled(&self) -> bool {
self.codex_fast_mode.unwrap_or(false)
}
/// 解析指定托管认证供应商绑定的账号 ID。
///
/// 新版优先读取 authBinding旧版继续兼容 githubAccountId。

View File

@@ -151,10 +151,16 @@ pub fn transform_claude_request_for_api_format(
"openai_responses" => {
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
let codex_fast_mode = provider
.meta
.as_ref()
.map(|m| m.codex_fast_mode_enabled())
.unwrap_or(false);
super::transform_responses::anthropic_to_responses(
body,
Some(cache_key),
is_codex_oauth,
codex_fast_mode,
)
}
"openai_chat" => {
@@ -1330,6 +1336,43 @@ mod tests {
assert_eq!(transformed["prompt_cache_key"], "explicit-cache-key");
}
#[test]
fn test_transform_claude_request_for_api_format_codex_oauth_fast_mode_off() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
provider_type: Some("codex_oauth".to_string()),
codex_fast_mode: Some(false),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
None,
None,
)
.unwrap();
assert_eq!(transformed["store"], json!(false));
assert!(transformed.get("service_tier").is_none());
assert_eq!(
transformed["include"],
json!(["reasoning.encrypted_content"])
);
}
#[test]
fn test_transform_claude_request_for_api_format_gemini_native() {
let provider = create_provider_with_meta(

View File

@@ -17,10 +17,13 @@ use serde_json::{json, Value};
/// `is_codex_oauth`: 当目标后端是 ChatGPT Plus/Pro 反代 (`chatgpt.com/backend-api/codex`) 时为 true。
/// 该后端强制要求 `store: false`,并要求 `include` 包含 `reasoning.encrypted_content`
/// 以便在无服务端状态下保持多轮 reasoning 上下文。
/// `codex_fast_mode`: 仅在 `is_codex_oauth` 为 true 时生效,控制是否注入
/// `service_tier = "priority"`。
pub fn anthropic_to_responses(
body: Value,
cache_key: Option<&str>,
is_codex_oauth: bool,
codex_fast_mode: bool,
) -> Result<Value, ProxyError> {
let mut result = json!({});
@@ -125,10 +128,15 @@ pub fn anthropic_to_responses(
// codex-rs 结构体根本没有这三个字段OpenAI 自己的客户端不发它们)
// - instructions / tools / parallel_tool_calls: 必填字段,缺则兜底默认值
// cc-switch 的 transform 当前是"条件写入",可能产生缺失)
// - service_tier: 仅在 FAST mode 开启时写入 "priority"
// (与 OpenAI 官方 codex-rs 当前请求结构保持一致)
// - stream: 必须永远 truecodex-rs 硬编码 true且 cc-switch 的
// SSE 解析层只处理流式响应,强制覆盖避免客户端误传 false
if is_codex_oauth {
result["store"] = json!(false);
if codex_fast_mode {
result["service_tier"] = json!("priority");
}
const REASONING_MARKER: &str = "reasoning.encrypted_content";
let mut includes: Vec<Value> = body
@@ -520,7 +528,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["model"], "gpt-4o");
assert_eq!(result["max_output_tokens"], 1024);
assert_eq!(result["input"][0]["role"], "user");
@@ -539,7 +547,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["instructions"], "You are a helpful assistant.");
// system should not appear in input
assert_eq!(result["input"].as_array().unwrap().len(), 1);
@@ -557,7 +565,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["instructions"], "Part 1\n\nPart 2");
}
@@ -574,7 +582,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["tools"][0]["type"], "function");
assert_eq!(result["tools"][0]["name"], "get_weather");
assert!(result["tools"][0].get("parameters").is_some());
@@ -591,7 +599,7 @@ mod tests {
"tool_choice": {"type": "any"}
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["tool_choice"], "required");
}
@@ -604,7 +612,7 @@ mod tests {
"tool_choice": {"type": "tool", "name": "get_weather"}
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["tool_choice"]["type"], "function");
assert_eq!(result["tool_choice"]["name"], "get_weather");
}
@@ -623,7 +631,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
let input_arr = result["input"].as_array().unwrap();
// Should produce: assistant message (text) + function_call item
@@ -653,7 +661,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
let input_arr = result["input"].as_array().unwrap();
// Should produce: function_call_output item (lifted)
@@ -677,7 +685,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
let input_arr = result["input"].as_array().unwrap();
// thinking should be discarded, only text remains
@@ -700,7 +708,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
let content = result["input"][0]["content"].as_array().unwrap();
assert_eq!(content[0]["type"], "input_text");
@@ -858,7 +866,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["model"], "o3-mini");
}
@@ -870,7 +878,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, Some("my-provider-id"), false).unwrap();
let result = anthropic_to_responses(input, Some("my-provider-id"), false, true).unwrap();
assert_eq!(result["prompt_cache_key"], "my-provider-id");
}
@@ -888,7 +896,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert!(result["tools"][0].get("cache_control").is_none());
}
@@ -905,7 +913,7 @@ mod tests {
}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert!(result["input"][0]["content"][0]
.get("cache_control")
.is_none());
@@ -967,7 +975,7 @@ mod tests {
"max_tokens": 4096,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["max_output_tokens"], 4096);
assert!(result.get("max_completion_tokens").is_none());
}
@@ -981,7 +989,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
@@ -995,7 +1003,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
@@ -1008,7 +1016,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["reasoning"]["effort"], "low");
}
@@ -1021,7 +1029,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["reasoning"]["effort"], "medium");
}
@@ -1034,7 +1042,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["reasoning"]["effort"], "high");
}
@@ -1047,7 +1055,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["reasoning"]["effort"], "xhigh");
}
@@ -1060,7 +1068,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert!(result.get("reasoning").is_none());
}
@@ -1074,10 +1082,11 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
// store 必须显式为 falseChatGPT 后端拒绝 true
assert_eq!(result["store"], json!(false));
assert_eq!(result["service_tier"], json!("priority"));
// include 必须包含 reasoning.encrypted_content无服务端状态下保持多轮 reasoning
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
@@ -1093,9 +1102,10 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert!(result.get("store").is_none());
assert!(result.get("service_tier").is_none());
assert!(result.get("include").is_none());
}
@@ -1109,7 +1119,7 @@ mod tests {
"include": ["something.else", "reasoning.encrypted_content"]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
let includes = result["include"]
.as_array()
.expect("include should be array");
@@ -1130,6 +1140,21 @@ mod tests {
assert_eq!(marker_count, 1, "marker 不应被重复添加idempotent 失败)");
}
#[test]
fn test_anthropic_to_responses_codex_oauth_fast_mode_can_be_disabled() {
let input = json!({
"model": "gpt-5-codex",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true, false).unwrap();
assert_eq!(result["store"], json!(false));
assert!(result.get("service_tier").is_none());
assert_eq!(result["include"], json!(["reasoning.encrypted_content"]));
}
#[test]
fn test_anthropic_to_responses_codex_oauth_strips_max_output_tokens() {
// ChatGPT Plus/Pro 反代不接受 max_output_tokensOpenAI 官方 codex-rs 的
@@ -1141,7 +1166,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert!(
result.get("max_output_tokens").is_none(),
@@ -1159,7 +1184,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["max_output_tokens"], json!(1024));
}
@@ -1177,7 +1202,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert!(
result.get("temperature").is_none(),
@@ -1195,7 +1220,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert!(
result.get("top_p").is_none(),
@@ -1212,7 +1237,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert_eq!(
result["instructions"],
@@ -1246,7 +1271,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert_eq!(
result["instructions"],
@@ -1270,7 +1295,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, true).unwrap();
let result = anthropic_to_responses(input, None, true, true).unwrap();
assert_eq!(
result["stream"],
@@ -1291,7 +1316,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert_eq!(result["temperature"], json!(0.7));
assert_eq!(result["top_p"], json!(0.9));
@@ -1307,7 +1332,7 @@ mod tests {
"messages": [{"role": "user", "content": "Hello"}]
});
let result = anthropic_to_responses(input, None, false).unwrap();
let result = anthropic_to_responses(input, None, false, true).unwrap();
assert!(
result.get("parallel_tool_calls").is_none(),

View File

@@ -360,10 +360,20 @@ impl StreamCheckService {
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
let codex_fast_mode = provider
.meta
.as_ref()
.map(|m| m.codex_fast_mode_enabled())
.unwrap_or(false);
let body = if is_openai_responses {
anthropic_to_responses(anthropic_body, Some(&provider.id), is_codex_oauth)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
anthropic_to_responses(
anthropic_body,
Some(&provider.id),
is_codex_oauth,
codex_fast_mode,
)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?
} else if is_gemini_native {
anthropic_to_gemini(anthropic_body)
.map_err(|e| AppError::Message(format!("Failed to build test request: {e}")))?

View File

@@ -82,6 +82,8 @@ interface ClaudeFormFieldsProps {
isCodexOauthAuthenticated?: boolean;
selectedCodexAccountId?: string | null;
onCodexAccountSelect?: (accountId: string | null) => void;
codexFastMode?: boolean;
onCodexFastModeChange?: (enabled: boolean) => void;
// Template Values
templateValueEntries: Array<[string, TemplateValueConfig]>;
@@ -148,6 +150,8 @@ export function ClaudeFormFields({
isCodexOauthPreset,
selectedCodexAccountId,
onCodexAccountSelect,
codexFastMode,
onCodexFastModeChange,
templateValueEntries,
templateValues,
templatePresetName,
@@ -374,6 +378,8 @@ export function ClaudeFormFields({
<CodexOAuthSection
selectedAccountId={selectedCodexAccountId}
onAccountSelect={onCodexAccountSelect}
fastModeEnabled={codexFastMode}
onFastModeChange={onCodexFastModeChange}
/>
)}

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
@@ -30,6 +31,10 @@ interface CodexOAuthSectionProps {
selectedAccountId?: string | null;
/** 账号选择回调 */
onAccountSelect?: (accountId: string | null) => void;
/** 是否开启 Codex FAST mode */
fastModeEnabled?: boolean;
/** FAST mode 切换回调 */
onFastModeChange?: (enabled: boolean) => void;
}
/**
@@ -42,6 +47,8 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
className,
selectedAccountId,
onAccountSelect,
fastModeEnabled = false,
onFastModeChange,
}) => {
const { t } = useTranslation();
const [copied, setCopied] = React.useState(false);
@@ -140,6 +147,27 @@ export const CodexOAuthSection: React.FC<CodexOAuthSectionProps> = ({
</div>
)}
{onFastModeChange && (
<div className="flex items-center justify-between rounded-md border bg-muted/30 p-3">
<div className="space-y-1 pr-4">
<Label className="text-sm font-medium">
{t("codexOauth.fastMode", "FAST mode")}
</Label>
<p className="text-xs text-muted-foreground">
{t("codexOauth.fastModeDescription", {
defaultValue:
'Send service_tier="priority" for lower latency. Turn it off if the ChatGPT Codex backend rejects the parameter.',
})}
</p>
</div>
<Switch
checked={fastModeEnabled}
onCheckedChange={onFastModeChange}
aria-label={t("codexOauth.fastMode", "FAST mode")}
/>
</div>
)}
{/* 已登录账号列表 */}
{hasAnyAccount && (
<div className="space-y-2">

View File

@@ -382,6 +382,9 @@ export function ProviderForm({
const [selectedCodexAccountId, setSelectedCodexAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const [codexFastMode, setCodexFastMode] = useState<boolean>(
() => initialData?.meta?.codexFastMode ?? false,
);
const {
codexAuth,
@@ -1115,7 +1118,7 @@ export function ProviderForm({
const providerType =
templatePreset?.providerType || initialData?.meta?.providerType;
payload.meta = {
const nextMeta: ProviderMeta = {
...(baseMeta ?? {}),
commonConfigEnabled:
appId === "claude"
@@ -1146,6 +1149,7 @@ export function ProviderForm({
isCopilotProvider && selectedGitHubAccountId
? selectedGitHubAccountId
: undefined,
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
testConfig: testConfig.enabled ? testConfig : undefined,
costMultiplier: pricingConfig.enabled
? pricingConfig.costMultiplier
@@ -1170,6 +1174,12 @@ export function ProviderForm({
: undefined,
};
if (!isCodexOauthProvider && "codexFastMode" in nextMeta) {
delete nextMeta.codexFastMode;
}
payload.meta = nextMeta;
await onSubmit(payload);
};
@@ -1735,6 +1745,8 @@ export function ProviderForm({
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
codexFastMode={codexFastMode}
onCodexFastModeChange={setCodexFastMode}
templateValueEntries={templateValueEntries}
templateValues={templateValues}
templatePresetName={templatePreset?.name || ""}

View File

@@ -929,7 +929,9 @@
"addAnotherAccount": "Add another account",
"logoutAll": "Logout all accounts",
"retry": "Retry",
"copyCode": "Copy code"
"copyCode": "Copy code",
"fastMode": "FAST mode",
"fastModeDescription": "Send service_tier=\"priority\" for lower latency. Off by default — enabling it consumes your ChatGPT quota at a higher rate."
},
"endpointTest": {
"title": "API Endpoint Management",

View File

@@ -929,7 +929,9 @@
"addAnotherAccount": "別のアカウントを追加",
"logoutAll": "すべてのアカウントをログアウト",
"retry": "再試行",
"copyCode": "コードをコピー"
"copyCode": "コードをコピー",
"fastMode": "FAST モード",
"fastModeDescription": "低遅延のため service_tier=\"priority\" を送信します。既定ではオフ——オンにすると ChatGPT のクォータがより速く消費されます。"
},
"endpointTest": {
"title": "API エンドポイント管理",

View File

@@ -929,7 +929,9 @@
"addAnotherAccount": "添加其他账号",
"logoutAll": "注销所有账号",
"retry": "重试",
"copyCode": "复制代码"
"copyCode": "复制代码",
"fastMode": "FAST 模式",
"fastModeDescription": "发送 service_tier=\"priority\" 换取更低延迟。默认关闭——开启后会按更高速率消耗 ChatGPT 配额。"
},
"endpointTest": {
"title": "请求地址管理",

View File

@@ -155,6 +155,8 @@ export interface ProviderMeta {
isFullUrl?: boolean;
// Prompt cache key for OpenAI Responses-compatible endpoints (improves cache hit rate)
promptCacheKey?: string;
// Codex OAuth FAST mode: injects service_tier="priority" on ChatGPT Codex requests
codexFastMode?: boolean;
// 供应商类型(用于识别 Copilot 等特殊供应商)
providerType?: string;
// GitHub Copilot 关联账号 ID旧字段保留兼容读取