diff --git a/apps/src/app/aggregate-api/page.tsx b/apps/src/app/aggregate-api/page.tsx
index 77cedefbe..94c59173c 100644
--- a/apps/src/app/aggregate-api/page.tsx
+++ b/apps/src/app/aggregate-api/page.tsx
@@ -12,6 +12,7 @@ import {
PencilLine,
Plus,
RefreshCw,
+ Settings2,
ShieldCheck,
Trash2,
Unplug,
@@ -25,6 +26,16 @@ import { ConfirmDialog } from "@/components/modals/confirm-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
@@ -53,6 +64,8 @@ import { useDesktopPageActive } from "@/hooks/useDesktopPageActive";
import { usePageTransitionReady } from "@/hooks/usePageTransitionReady";
import { useRuntimeCapabilities } from "@/hooks/useRuntimeCapabilities";
import { accountClient } from "@/lib/api/account-client";
+import { appClient } from "@/lib/api/app-client";
+import { getAppErrorMessage } from "@/lib/api/transport";
import { aggregateApiProviderMatchesFilter } from "@/lib/aggregate-api-provider";
import { useI18n } from "@/lib/i18n/provider";
import { useAppStore } from "@/lib/store/useAppStore";
@@ -115,6 +128,8 @@ export default function AggregateApiPage() {
const { t } = useI18n();
const queryClient = useQueryClient();
const serviceStatus = useAppStore((state) => state.serviceStatus);
+ const appSettings = useAppStore((state) => state.appSettings);
+ const setAppSettings = useAppStore((state) => state.setAppSettings);
const { canAccessManagementRpc } = useRuntimeCapabilities();
const isServiceReady = canAccessManagementRpc && serviceStatus.connected;
const isPageActive = useDesktopPageActive("/aggregate-api/");
@@ -139,6 +154,9 @@ export default function AggregateApiPage() {
const [associationItems, setAssociationItems] = useState([]);
const [fetchingModelsApiId, setFetchingModelsApiId] = useState(null);
const [associatingModels, setAssociatingModels] = useState(false);
+ const [probeSettingsOpen, setProbeSettingsOpen] = useState(false);
+ const [probeUserAgentMode, setProbeUserAgentMode] = useState("codex");
+ const [probeUserAgent, setProbeUserAgent] = useState("");
const { data: aggregateApis = [], isLoading } = useQuery({
queryKey: ["aggregate-apis"],
@@ -158,6 +176,7 @@ export default function AggregateApiPage() {
setEditingId(null);
setDeleteId(null);
setRevealedSecrets({});
+ setProbeSettingsOpen(false);
});
return () => window.cancelAnimationFrame(frameId);
}, [isPageActive]);
@@ -219,6 +238,29 @@ export default function AggregateApiPage() {
},
});
+ const probeSettingsMutation = useMutation({
+ mutationFn: () =>
+ appClient.setSettings({
+ aggregateApiProbeUserAgentMode: probeUserAgentMode,
+ aggregateApiProbeUserAgent: probeUserAgent.trim(),
+ }),
+ onSuccess: (settings) => {
+ queryClient.setQueryData(["app-settings-snapshot"], settings);
+ setAppSettings(settings);
+ setProbeSettingsOpen(false);
+ toast.success(t("连通性测试设置已更新"));
+ },
+ onError: (error: unknown) => {
+ toast.error(`${t("更新连通性测试设置失败")}: ${getAppErrorMessage(error)}`);
+ },
+ });
+
+ const openProbeSettings = () => {
+ setProbeUserAgentMode(appSettings.aggregateApiProbeUserAgentMode || "codex");
+ setProbeUserAgent(appSettings.aggregateApiProbeUserAgent || "");
+ setProbeSettingsOpen(true);
+ };
+
const balanceMutation = useMutation({
mutationFn: (apiId: string) => accountClient.refreshAggregateApiBalance(apiId),
onMutate: (apiId) => setRefreshingBalanceId(apiId),
@@ -363,18 +405,36 @@ export default function AggregateApiPage() {
{t("连通性测试只使用已配置路由对应的模型。")}
-
+
+
+
+
+ }
+ >
+
+
+ {t("连通性测试设置")}
+
+
@@ -530,6 +590,86 @@ export default function AggregateApiPage() {
defaultSort={defaultCreateSort}
/>
+
+
{
diff --git a/apps/src/lib/api/normalize.ts b/apps/src/lib/api/normalize.ts
index f8ac26f96..31f94acb8 100644
--- a/apps/src/lib/api/normalize.ts
+++ b/apps/src/lib/api/normalize.ts
@@ -1811,6 +1811,9 @@ export function normalizeAppSettings(payload: unknown): AppSettings {
source.threadAwareAccountDistributionEnabled,
true
),
+ aggregateApiProbeUserAgentMode:
+ asString(source.aggregateApiProbeUserAgentMode) || "codex",
+ aggregateApiProbeUserAgent: asString(source.aggregateApiProbeUserAgent),
quotaGuard: normalizeQuotaGuard(source.quotaGuard ?? source.quota_guard),
gatewayOriginator:
asString(source.gatewayOriginator) || DEFAULT_CODEX_ORIGINATOR,
diff --git a/apps/src/lib/i18n/messages/sections/en-aggregate-api.ts b/apps/src/lib/i18n/messages/sections/en-aggregate-api.ts
index 95bd2f58b..b1a077fa5 100644
--- a/apps/src/lib/i18n/messages/sections/en-aggregate-api.ts
+++ b/apps/src/lib/i18n/messages/sections/en-aggregate-api.ts
@@ -39,6 +39,19 @@ export const EN_AGGREGATE_API_MESSAGES: MessageCatalog = {
上游连接: "Upstream connections",
"连通性测试只使用已配置路由对应的模型。":
"Connection tests only use models referenced by configured routes.",
+ 连通性测试设置: "Connection test settings",
+ "设置 Codex 类型 route 执行连通性测试时使用的客户端标识。":
+ "Configure the client identity used when testing Codex-type routes.",
+ 测试客户端: "Test client",
+ "Codex 官方客户端(默认)": "Official Codex client (default)",
+ "自定义 User-Agent": "Custom User-Agent",
+ "发送官方格式的 User-Agent、originator 和 Codex 客户端指纹请求头。":
+ "Send the official User-Agent, originator, and Codex client fingerprint headers.",
+ "仅使用指定的 User-Agent,不附加 Codex 客户端指纹。":
+ "Use only the specified User-Agent without Codex client fingerprint headers.",
+ "请输入自定义 User-Agent": "Enter a custom User-Agent",
+ 连通性测试设置已更新: "Connection test settings updated",
+ 更新连通性测试设置失败: "Failed to update connection test settings",
"通用兼容(Codex + Claude)": "Compatible (Codex + Claude)",
"按请求路径原样转发 Codex 与 Claude 协议;自定义 action 会自动关闭。":
"Forwards Codex and Claude protocols using the incoming path; custom action is disabled automatically.",
diff --git a/apps/src/lib/i18n/messages/sections/ko-aggregate-api.ts b/apps/src/lib/i18n/messages/sections/ko-aggregate-api.ts
index 044249378..8a8a27e8b 100644
--- a/apps/src/lib/i18n/messages/sections/ko-aggregate-api.ts
+++ b/apps/src/lib/i18n/messages/sections/ko-aggregate-api.ts
@@ -39,6 +39,19 @@ export const KO_AGGREGATE_API_MESSAGES: MessageCatalog = {
上游连接: "업스트림 연결",
"连通性测试只使用已配置路由对应的模型。":
"연결 테스트는 설정된 라우트에 연결된 모델만 사용합니다.",
+ 连通性测试设置: "연결 테스트 설정",
+ "设置 Codex 类型 route 执行连通性测试时使用的客户端标识。":
+ "Codex 유형 route 연결 테스트에 사용할 클라이언트 식별자를 설정합니다.",
+ 测试客户端: "테스트 클라이언트",
+ "Codex 官方客户端(默认)": "Codex 공식 클라이언트 (기본값)",
+ "自定义 User-Agent": "사용자 지정 User-Agent",
+ "发送官方格式的 User-Agent、originator 和 Codex 客户端指纹请求头。":
+ "공식 형식의 User-Agent, originator 및 Codex 클라이언트 지문 헤더를 전송합니다.",
+ "仅使用指定的 User-Agent,不附加 Codex 客户端指纹。":
+ "지정한 User-Agent만 사용하고 Codex 클라이언트 지문은 추가하지 않습니다.",
+ "请输入自定义 User-Agent": "사용자 지정 User-Agent를 입력하세요",
+ 连通性测试设置已更新: "연결 테스트 설정이 업데이트되었습니다",
+ 更新连通性测试设置失败: "연결 테스트 설정 업데이트 실패",
"通用兼容(Codex + Claude)": "호환 모드 (Codex + Claude)",
"按请求路径原样转发 Codex 与 Claude 协议;自定义 action 会自动关闭。":
"요청 경로를 그대로 사용해 Codex 및 Claude 프로토콜을 전달합니다. 사용자 지정 action은 자동으로 꺼집니다.",
diff --git a/apps/src/lib/i18n/messages/sections/ru-aggregate-api.ts b/apps/src/lib/i18n/messages/sections/ru-aggregate-api.ts
index d4aba16c3..7a172ed8b 100644
--- a/apps/src/lib/i18n/messages/sections/ru-aggregate-api.ts
+++ b/apps/src/lib/i18n/messages/sections/ru-aggregate-api.ts
@@ -39,6 +39,19 @@ export const RU_AGGREGATE_API_MESSAGES: MessageCatalog = {
上游连接: "Upstream-подключения",
"连通性测试只使用已配置路由对应的模型。":
"Проверка подключения использует только модели из настроенных маршрутов.",
+ 连通性测试设置: "Настройки проверки подключения",
+ "设置 Codex 类型 route 执行连通性测试时使用的客户端标识。":
+ "Настройте идентификатор клиента для проверки маршрутов типа Codex.",
+ 测试客户端: "Клиент для проверки",
+ "Codex 官方客户端(默认)": "Официальный клиент Codex (по умолчанию)",
+ "自定义 User-Agent": "Пользовательский User-Agent",
+ "发送官方格式的 User-Agent、originator 和 Codex 客户端指纹请求头。":
+ "Отправлять официальный User-Agent, originator и заголовки отпечатка клиента Codex.",
+ "仅使用指定的 User-Agent,不附加 Codex 客户端指纹。":
+ "Использовать только указанный User-Agent без отпечатка клиента Codex.",
+ "请输入自定义 User-Agent": "Введите пользовательский User-Agent",
+ 连通性测试设置已更新: "Настройки проверки подключения обновлены",
+ 更新连通性测试设置失败: "Не удалось обновить настройки проверки подключения",
"通用兼容(Codex + Claude)": "Совместимый режим (Codex + Claude)",
"按请求路径原样转发 Codex 与 Claude 协议;自定义 action 会自动关闭。":
"Передаёт протоколы Codex и Claude по исходному пути запроса; пользовательский action отключается автоматически.",
diff --git a/apps/src/lib/store/useAppStore.ts b/apps/src/lib/store/useAppStore.ts
index 35a9dc410..63eb0ad35 100644
--- a/apps/src/lib/store/useAppStore.ts
+++ b/apps/src/lib/store/useAppStore.ts
@@ -112,6 +112,8 @@ export const useAppStore = create((set) => ({
compactModelForwardRules: "",
accountMaxInflight: 1,
threadAwareAccountDistributionEnabled: true,
+ aggregateApiProbeUserAgentMode: "codex",
+ aggregateApiProbeUserAgent: "",
quotaGuard: {
enabled: true,
primaryMinRemainingPercent: 5,
diff --git a/apps/src/types/settings.ts b/apps/src/types/settings.ts
index e6c5faa59..b1369ce3d 100644
--- a/apps/src/types/settings.ts
+++ b/apps/src/types/settings.ts
@@ -81,6 +81,8 @@ export interface AppSettings extends GatewayTransportValues {
compactModelForwardRules: string;
accountMaxInflight: number;
threadAwareAccountDistributionEnabled: boolean;
+ aggregateApiProbeUserAgentMode: string;
+ aggregateApiProbeUserAgent: string;
quotaGuard: QuotaGuardSettings;
gatewayOriginator: string;
gatewayOriginatorDefault: string;
diff --git a/crates/service/src/aggregate_api.rs b/crates/service/src/aggregate_api.rs
index a66bfa820..801291dd1 100644
--- a/crates/service/src/aggregate_api.rs
+++ b/crates/service/src/aggregate_api.rs
@@ -1348,12 +1348,27 @@ fn build_gemini_probe_body() -> serde_json::Value {
/// # 返回
/// 返回函数执行结果
fn add_codex_probe_headers(
- builder: reqwest::blocking::RequestBuilder,
+ mut builder: reqwest::blocking::RequestBuilder,
) -> Result {
+ let mode = crate::app_settings::current_aggregate_api_probe_user_agent_mode();
+ if mode == crate::app_settings::AGGREGATE_API_PROBE_USER_AGENT_MODE_CODEX {
+ let request_id = gateway::next_trace_id();
+ builder = builder
+ .header("user-agent", gateway::current_codex_user_agent())
+ .header("originator", gateway::current_wire_originator())
+ .header("session-id", request_id.as_str())
+ .header("thread-id", request_id.as_str())
+ .header("x-client-request-id", request_id.as_str())
+ .header("x-codex-window-id", format!("{request_id}:0"));
+ } else {
+ let user_agent = crate::app_settings::current_aggregate_api_probe_user_agent();
+ if user_agent.is_empty() {
+ return Err("aggregate api probe custom user agent is required".to_string());
+ }
+ builder = builder.header("user-agent", user_agent);
+ }
Ok(builder
.header("accept", "application/json")
- .header("user-agent", gateway::current_codex_user_agent())
- .header("originator", gateway::current_wire_originator())
.header("accept-encoding", "identity"))
}
diff --git a/crates/service/src/aggregate_api_tests.rs b/crates/service/src/aggregate_api_tests.rs
index 0f936a1ad..ecfcd4539 100644
--- a/crates/service/src/aggregate_api_tests.rs
+++ b/crates/service/src/aggregate_api_tests.rs
@@ -27,6 +27,13 @@ fn new_test_dir(prefix: &str) -> PathBuf {
dir
}
+fn captured_header<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
+ headers
+ .iter()
+ .find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
+ .map(|(_, value)| value.as_str())
+}
+
struct EnvGuard {
key: &'static str,
original: Option,
@@ -448,6 +455,11 @@ fn codex_probe_uses_configured_model_without_model_discovery() {
#[test]
fn codex_responses_probe_uses_valid_input_text_content() {
+ let _lock = crate::test_env_guard();
+ let dir = new_test_dir("aggregate-api-codex-probe-headers");
+ let db_path = dir.join("codexmanager.db");
+ let _guard = EnvGuard::set("CODEXMANAGER_DB_PATH", db_path.to_string_lossy().as_ref());
+ crate::initialize_storage_if_needed().expect("init storage");
let server = Server::http("127.0.0.1:0").expect("start mock server");
let base_url = format!("http://{}", server.server_addr());
let (tx, rx) = mpsc::channel();
@@ -461,7 +473,17 @@ fn codex_responses_probe_uses_valid_input_text_content() {
.as_reader()
.read_to_string(&mut body)
.expect("read request body");
- tx.send((request.url().to_string(), body))
+ let headers = request
+ .headers()
+ .iter()
+ .map(|header| {
+ (
+ header.field.as_str().to_string(),
+ header.value.as_str().to_string(),
+ )
+ })
+ .collect::>();
+ tx.send((request.url().to_string(), body, headers))
.expect("send responses request");
request
.respond(Response::from_string(r#"{"id":"resp_probe"}"#))
@@ -489,6 +511,79 @@ fn codex_responses_probe_uses_valid_input_text_content() {
assert_eq!(body["model"], "gpt-5.6-sol");
assert_eq!(body["input"][0]["content"][0]["type"], "input_text");
assert_eq!(body["store"], false);
+ assert!(captured_header(&captured.2, "user-agent")
+ .is_some_and(|value| value.starts_with("codex_cli_rs/")));
+ assert_eq!(
+ captured_header(&captured.2, "originator"),
+ Some("codex_cli_rs")
+ );
+ assert!(captured_header(&captured.2, "session-id").is_some());
+ assert!(captured_header(&captured.2, "x-client-request-id").is_some());
+ assert!(captured_header(&captured.2, "x-codex-window-id").is_some());
+}
+
+#[test]
+fn codex_probe_custom_user_agent_does_not_add_codex_fingerprint_headers() {
+ let _lock = crate::test_env_guard();
+ let dir = new_test_dir("aggregate-api-custom-probe-user-agent");
+ let db_path = dir.join("codexmanager.db");
+ let _guard = EnvGuard::set("CODEXMANAGER_DB_PATH", db_path.to_string_lossy().as_ref());
+ crate::initialize_storage_if_needed().expect("init storage");
+ crate::app_settings::set_aggregate_api_probe_user_agent_settings(
+ Some("custom"),
+ Some("Custom-Probe/2.0"),
+ )
+ .expect("set custom probe user agent");
+
+ let server = Server::http("127.0.0.1:0").expect("start mock server");
+ let base_url = format!("http://{}", server.server_addr());
+ let (tx, rx) = mpsc::channel();
+ let join = thread::spawn(move || {
+ let mut request = server
+ .recv_timeout(Duration::from_secs(2))
+ .expect("receive responses request")
+ .expect("responses request present");
+ let headers = request
+ .headers()
+ .iter()
+ .map(|header| {
+ (
+ header.field.as_str().to_string(),
+ header.value.as_str().to_string(),
+ )
+ })
+ .collect::>();
+ let mut body = String::new();
+ request
+ .as_reader()
+ .read_to_string(&mut body)
+ .expect("read request body");
+ tx.send(headers).expect("send request headers");
+ request
+ .respond(Response::from_string(r#"{"id":"resp_probe"}"#))
+ .expect("respond responses");
+ });
+
+ let mut api = aggregate_api_with_action(None);
+ api.provider_type = "codex".to_string();
+ api.url = base_url;
+ let client = reqwest::blocking::Client::builder()
+ .timeout(Duration::from_secs(5))
+ .build()
+ .expect("build client");
+
+ probe_codex_endpoint(&client, &api, "secret", "gpt-5.6-sol").expect("probe succeeds");
+
+ let headers = rx
+ .recv_timeout(Duration::from_secs(2))
+ .expect("captured headers");
+ join.join().expect("join mock server");
+ assert_eq!(
+ captured_header(&headers, "user-agent"),
+ Some("Custom-Probe/2.0")
+ );
+ assert_eq!(captured_header(&headers, "originator"), None);
+ assert_eq!(captured_header(&headers, "x-codex-window-id"), None);
}
#[test]
diff --git a/crates/service/src/app_settings/aggregate_api.rs b/crates/service/src/app_settings/aggregate_api.rs
new file mode 100644
index 000000000..97b429086
--- /dev/null
+++ b/crates/service/src/app_settings/aggregate_api.rs
@@ -0,0 +1,80 @@
+use super::{
+ get_persisted_app_setting, save_persisted_app_setting,
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_KEY,
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_MODE_KEY,
+};
+
+pub(crate) const AGGREGATE_API_PROBE_USER_AGENT_MODE_CODEX: &str = "codex";
+pub(crate) const AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM: &str = "custom";
+const MAX_AGGREGATE_API_PROBE_USER_AGENT_BYTES: usize = 512;
+
+fn normalize_probe_user_agent_mode(raw: &str) -> Result {
+ let normalized = raw.trim().to_ascii_lowercase();
+ match normalized.as_str() {
+ AGGREGATE_API_PROBE_USER_AGENT_MODE_CODEX | AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM => {
+ Ok(normalized)
+ }
+ _ => Err("aggregate api probe user agent mode must be codex or custom".to_string()),
+ }
+}
+
+fn normalize_probe_user_agent(raw: &str) -> Result {
+ let normalized = raw.trim();
+ if normalized.len() > MAX_AGGREGATE_API_PROBE_USER_AGENT_BYTES {
+ return Err(format!(
+ "aggregate api probe user agent must not exceed {MAX_AGGREGATE_API_PROBE_USER_AGENT_BYTES} bytes"
+ ));
+ }
+ if normalized.chars().any(|ch| ch.is_ascii_control()) {
+ return Err("aggregate api probe user agent contains control characters".to_string());
+ }
+ Ok(normalized.to_string())
+}
+
+pub(crate) fn current_aggregate_api_probe_user_agent_mode() -> String {
+ get_persisted_app_setting(APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_MODE_KEY)
+ .as_deref()
+ .and_then(|value| normalize_probe_user_agent_mode(value).ok())
+ .unwrap_or_else(|| AGGREGATE_API_PROBE_USER_AGENT_MODE_CODEX.to_string())
+}
+
+pub(crate) fn current_aggregate_api_probe_user_agent() -> String {
+ get_persisted_app_setting(APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_KEY)
+ .and_then(|value| normalize_probe_user_agent(value.as_str()).ok())
+ .unwrap_or_default()
+}
+
+pub(crate) fn set_aggregate_api_probe_user_agent_settings(
+ mode: Option<&str>,
+ user_agent: Option<&str>,
+) -> Result<(String, String), String> {
+ let next_mode = match mode {
+ Some(value) => normalize_probe_user_agent_mode(value)?,
+ None => current_aggregate_api_probe_user_agent_mode(),
+ };
+ let next_user_agent = match user_agent {
+ Some(value) => normalize_probe_user_agent(value)?,
+ None => current_aggregate_api_probe_user_agent(),
+ };
+ if next_mode == AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM && next_user_agent.is_empty() {
+ return Err("aggregate api probe custom user agent is required".to_string());
+ }
+
+ save_persisted_app_setting(
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_MODE_KEY,
+ Some(next_mode.as_str()),
+ )?;
+ save_persisted_app_setting(
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_KEY,
+ if next_user_agent.is_empty() {
+ None
+ } else {
+ Some(next_user_agent.as_str())
+ },
+ )?;
+ Ok((next_mode, next_user_agent))
+}
+
+#[cfg(test)]
+#[path = "aggregate_api_settings_tests.rs"]
+mod tests;
diff --git a/crates/service/src/app_settings/aggregate_api_settings_tests.rs b/crates/service/src/app_settings/aggregate_api_settings_tests.rs
new file mode 100644
index 000000000..a970c4a0a
--- /dev/null
+++ b/crates/service/src/app_settings/aggregate_api_settings_tests.rs
@@ -0,0 +1,98 @@
+use super::{
+ current_aggregate_api_probe_user_agent, current_aggregate_api_probe_user_agent_mode,
+ set_aggregate_api_probe_user_agent_settings, AGGREGATE_API_PROBE_USER_AGENT_MODE_CODEX,
+ AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM,
+};
+use crate::app_settings::{
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_KEY,
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_MODE_KEY,
+};
+use codexmanager_core::storage::Storage;
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+struct EnvGuard {
+ previous: Option,
+}
+
+impl EnvGuard {
+ fn set_db_path(path: &PathBuf) -> Self {
+ let previous = std::env::var_os("CODEXMANAGER_DB_PATH");
+ std::env::set_var("CODEXMANAGER_DB_PATH", path);
+ Self { previous }
+ }
+}
+
+impl Drop for EnvGuard {
+ fn drop(&mut self) {
+ if let Some(value) = self.previous.as_ref() {
+ std::env::set_var("CODEXMANAGER_DB_PATH", value);
+ } else {
+ std::env::remove_var("CODEXMANAGER_DB_PATH");
+ }
+ }
+}
+
+fn unique_temp_db_path() -> PathBuf {
+ let unique = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .expect("clock")
+ .as_nanos();
+ std::env::temp_dir().join(format!("codexmanager-aggregate-probe-settings-{unique}.db"))
+}
+
+#[test]
+fn aggregate_api_probe_settings_default_to_codex_profile() {
+ let _guard = crate::test_env_guard();
+ let db_path = unique_temp_db_path();
+ let _db_env = EnvGuard::set_db_path(&db_path);
+
+ assert_eq!(
+ current_aggregate_api_probe_user_agent_mode(),
+ AGGREGATE_API_PROBE_USER_AGENT_MODE_CODEX
+ );
+ assert_eq!(current_aggregate_api_probe_user_agent(), "");
+}
+
+#[test]
+fn aggregate_api_probe_settings_validate_and_persist_custom_user_agent() {
+ let _guard = crate::test_env_guard();
+ let db_path = unique_temp_db_path();
+ let _db_env = EnvGuard::set_db_path(&db_path);
+ crate::initialize_storage_if_needed().expect("init storage");
+
+ let missing = set_aggregate_api_probe_user_agent_settings(
+ Some(AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM),
+ Some(" "),
+ )
+ .expect_err("custom mode requires user agent");
+ assert!(missing.contains("custom user agent is required"));
+
+ let result = set_aggregate_api_probe_user_agent_settings(
+ Some(AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM),
+ Some("Codex-Probe-Test/1.0"),
+ )
+ .expect("persist custom probe settings");
+ assert_eq!(
+ result,
+ (
+ AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM.to_string(),
+ "Codex-Probe-Test/1.0".to_string()
+ )
+ );
+
+ let storage = Storage::open(&db_path).expect("open storage");
+ assert_eq!(
+ storage
+ .get_app_setting(APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_MODE_KEY)
+ .expect("read mode"),
+ Some(AGGREGATE_API_PROBE_USER_AGENT_MODE_CUSTOM.to_string())
+ );
+ assert_eq!(
+ storage
+ .get_app_setting(APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_KEY)
+ .expect("read user agent"),
+ Some("Codex-Probe-Test/1.0".to_string())
+ );
+ let _ = std::fs::remove_file(db_path);
+}
diff --git a/crates/service/src/app_settings/api/current.rs b/crates/service/src/app_settings/api/current.rs
index 10e257e21..0fa8f6d4b 100644
--- a/crates/service/src/app_settings/api/current.rs
+++ b/crates/service/src/app_settings/api/current.rs
@@ -5,6 +5,9 @@ use chrono::Local;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
+use super::super::aggregate_api::{
+ current_aggregate_api_probe_user_agent, current_aggregate_api_probe_user_agent_mode,
+};
use super::author_links::{
default_author_server_recommendations, default_author_sponsors, load_author_link_items,
serialize_author_link_items,
@@ -218,6 +221,8 @@ fn current_app_settings_value_inner(
let account_max_inflight = current_gateway_account_max_inflight();
let thread_aware_account_distribution_enabled =
current_gateway_thread_aware_account_distribution_enabled();
+ let aggregate_api_probe_user_agent_mode = current_aggregate_api_probe_user_agent_mode();
+ let aggregate_api_probe_user_agent = current_aggregate_api_probe_user_agent();
let quota_guard = current_gateway_quota_guard();
let gateway_originator = current_gateway_originator();
let gateway_user_agent_version = current_gateway_user_agent_version();
@@ -389,6 +394,14 @@ fn current_app_settings_value_inner(
});
if let Some(object) = result.as_object_mut() {
object.insert("zoomFactor".to_string(), zoom_factor.into());
+ object.insert(
+ "aggregateApiProbeUserAgentMode".to_string(),
+ aggregate_api_probe_user_agent_mode.into(),
+ );
+ object.insert(
+ "aggregateApiProbeUserAgent".to_string(),
+ aggregate_api_probe_user_agent.into(),
+ );
}
if let Some(object) = result.as_object_mut() {
object.insert(
diff --git a/crates/service/src/app_settings/api/patch.rs b/crates/service/src/app_settings/api/patch.rs
index 16a2ecee8..70a88c593 100644
--- a/crates/service/src/app_settings/api/patch.rs
+++ b/crates/service/src/app_settings/api/patch.rs
@@ -2,6 +2,7 @@ use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
+use super::super::aggregate_api::set_aggregate_api_probe_user_agent_settings;
use super::author_links::{
normalize_author_link_items, serialize_author_link_items, AuthorLinkItem,
};
@@ -46,6 +47,8 @@ pub(super) struct AppSettingsPatch {
compact_model_forward_rules: Option,
account_max_inflight: Option,
thread_aware_account_distribution_enabled: Option,
+ aggregate_api_probe_user_agent_mode: Option,
+ aggregate_api_probe_user_agent: Option,
gateway_originator: Option,
gateway_user_agent_version: Option,
gateway_residency_requirement: Option,
@@ -158,6 +161,14 @@ pub(super) fn apply_app_settings_patch(patch: AppSettingsPatch) -> Result<(), St
if let Some(enabled) = patch.thread_aware_account_distribution_enabled {
let _ = set_gateway_thread_aware_account_distribution_enabled(enabled)?;
}
+ if patch.aggregate_api_probe_user_agent_mode.is_some()
+ || patch.aggregate_api_probe_user_agent.is_some()
+ {
+ let _ = set_aggregate_api_probe_user_agent_settings(
+ patch.aggregate_api_probe_user_agent_mode.as_deref(),
+ patch.aggregate_api_probe_user_agent.as_deref(),
+ )?;
+ }
if let Some(originator) = patch.gateway_originator {
let _ = set_gateway_originator(&originator)?;
}
diff --git a/crates/service/src/app_settings/mod.rs b/crates/service/src/app_settings/mod.rs
index dae87e29e..30b0185fa 100644
--- a/crates/service/src/app_settings/mod.rs
+++ b/crates/service/src/app_settings/mod.rs
@@ -1,3 +1,4 @@
+mod aggregate_api;
mod api;
mod codex_latest_sync;
mod env_overrides;
@@ -8,6 +9,12 @@ mod shared;
mod store;
mod ui;
+#[cfg(test)]
+pub(crate) use aggregate_api::set_aggregate_api_probe_user_agent_settings;
+pub(crate) use aggregate_api::{
+ current_aggregate_api_probe_user_agent, current_aggregate_api_probe_user_agent_mode,
+ AGGREGATE_API_PROBE_USER_AGENT_MODE_CODEX,
+};
pub use api::{
app_settings_get, app_settings_get_with_overrides, app_settings_set, author_content_get,
};
@@ -49,6 +56,8 @@ pub use service::{
};
pub(crate) use shared::{normalize_optional_text, parse_bool_with_default};
pub use shared::{
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_KEY,
+ APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_MODE_KEY,
APP_SETTING_AUTHOR_SERVER_RECOMMENDATIONS_KEY, APP_SETTING_AUTHOR_SPONSORS_KEY,
APP_SETTING_AUTO_START_ENABLED_KEY, APP_SETTING_CLOSE_TO_TRAY_ON_CLOSE_KEY,
APP_SETTING_DISTRIBUTION_ENABLED_KEY, APP_SETTING_ENV_OVERRIDES_KEY,
diff --git a/crates/service/src/app_settings/shared.rs b/crates/service/src/app_settings/shared.rs
index 39b4e264c..668d77a45 100644
--- a/crates/service/src/app_settings/shared.rs
+++ b/crates/service/src/app_settings/shared.rs
@@ -39,6 +39,9 @@ pub const APP_SETTING_GATEWAY_SSE_KEEPALIVE_ENABLED_KEY: &str = "gateway.sse_kee
pub const APP_SETTING_GATEWAY_SSE_KEEPALIVE_INTERVAL_MS_KEY: &str =
"gateway.sse_keepalive_interval_ms";
pub const APP_SETTING_GATEWAY_BACKGROUND_TASKS_KEY: &str = "gateway.background_tasks";
+pub const APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_MODE_KEY: &str =
+ "aggregate_api.probe_user_agent_mode";
+pub const APP_SETTING_AGGREGATE_API_PROBE_USER_AGENT_KEY: &str = "aggregate_api.probe_user_agent";
pub const APP_SETTING_PLUGIN_MARKET_MODE_KEY: &str = "plugin.market_mode";
pub const APP_SETTING_PLUGIN_MARKET_SOURCE_URL_KEY: &str = "plugin.market_source_url";
pub const APP_SETTING_ENV_OVERRIDES_KEY: &str = "app.env_overrides";
diff --git a/crates/service/tests/app_settings.rs b/crates/service/tests/app_settings.rs
index a6ca9b674..e9fb5c284 100644
--- a/crates/service/tests/app_settings.rs
+++ b/crates/service/tests/app_settings.rs
@@ -87,6 +87,8 @@ fn reset_runtime_defaults() {
"freeAccountMaxModel": "gpt-5.2",
"modelForwardRules": "",
"compactModelForwardRules": "",
+ "aggregateApiProbeUserAgentMode": "codex",
+ "aggregateApiProbeUserAgent": "",
"quotaGuard": {
"enabled": true,
"primaryMinRemainingPercent": 5,
@@ -363,6 +365,34 @@ fn app_settings_roundtrip_account_manager_mode_and_bootstrap() {
});
}
+#[test]
+fn app_settings_roundtrip_aggregate_api_probe_user_agent() {
+ with_temp_db(|_| {
+ let defaults = codexmanager_service::app_settings_get().expect("read default settings");
+ assert_eq!(defaults["aggregateApiProbeUserAgentMode"], "codex");
+ assert_eq!(defaults["aggregateApiProbeUserAgent"], "");
+
+ let updated = codexmanager_service::app_settings_set(Some(&json!({
+ "aggregateApiProbeUserAgentMode": "custom",
+ "aggregateApiProbeUserAgent": "Custom-Probe/3.0"
+ })))
+ .expect("save aggregate API probe settings");
+ assert_eq!(updated["aggregateApiProbeUserAgentMode"], "custom");
+ assert_eq!(updated["aggregateApiProbeUserAgent"], "Custom-Probe/3.0");
+
+ let persisted = codexmanager_service::app_settings_get().expect("read persisted settings");
+ assert_eq!(persisted["aggregateApiProbeUserAgentMode"], "custom");
+ assert_eq!(persisted["aggregateApiProbeUserAgent"], "Custom-Probe/3.0");
+
+ let err = codexmanager_service::app_settings_set(Some(&json!({
+ "aggregateApiProbeUserAgentMode": "custom",
+ "aggregateApiProbeUserAgent": ""
+ })))
+ .expect_err("custom probe user agent should be required");
+ assert!(err.contains("custom user agent is required"));
+ });
+}
+
#[test]
fn app_settings_rejects_password_mode_without_password() {
with_temp_db(|_| {
diff --git "a/docs/zh-CN/report/\350\201\232\345\220\210API\350\257\267\346\261\202\350\247\204\345\210\231\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/docs/zh-CN/report/\350\201\232\345\220\210API\350\257\267\346\261\202\350\247\204\345\210\231\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md"
index 3add69ae9..6a8d4f0bc 100644
--- "a/docs/zh-CN/report/\350\201\232\345\220\210API\350\257\267\346\261\202\350\247\204\345\210\231\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md"
+++ "b/docs/zh-CN/report/\350\201\232\345\220\210API\350\257\267\346\261\202\350\247\204\345\210\231\344\270\216\351\205\215\347\275\256\350\257\264\346\230\216.md"
@@ -679,6 +679,25 @@ Gemini 模型同样在模型目录 V2 中手工新增并配置 route;管理员
- 每个候选独立使用自己的 route `upstreamModel`,请求体不会在候选间泄漏。
- 连接测试从引用当前聚合 API 的 enabled V2 routes 中选择具体模型,不做发现或导入。
+### 连通性测试客户端标识
+
+聚合 API 页面右上角的“连通性测试设置”只影响 `codex` 和 `compatible` 类型使用 Codex 协议执行的 probe,不改变真实 route 转发请求。
+
+设置通过现有 `appSettings/get`、`appSettings/set` 持久化:
+
+| API 字段 | 存储键 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `aggregateApiProbeUserAgentMode` | `aggregate_api.probe_user_agent_mode` | `codex` | `codex` 或 `custom`。 |
+| `aggregateApiProbeUserAgent` | `aggregate_api.probe_user_agent` | 空 | `custom` 模式使用的完整 User-Agent。 |
+
+`codex` 模式会模拟 Codex 官方客户端身份:
+
+- 使用当前 Gateway 配置生成官方格式的 `User-Agent` 和 `originator`。
+- 每次 probe 生成独立的 `session-id`、`thread-id` 和 `x-client-request-id`。
+- 发送与该请求锚点对应的 `x-codex-window-id`,用于满足会校验 Codex 客户端指纹的供应商。
+
+`custom` 模式只发送用户指定的 `User-Agent`,不会附加 `originator` 或 `x-codex-*` 指纹。自定义值不能为空、不能包含控制字符,最大长度为 512 bytes。
+
## 管理接口
桌面端通过 Tauri command 调 service RPC: