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} /> + + + + {t("连通性测试设置")} + + {t("设置 Codex 类型 route 执行连通性测试时使用的客户端标识。")} + + + +
+
+ + +

+ {probeUserAgentMode === "codex" + ? t("发送官方格式的 User-Agent、originator 和 Codex 客户端指纹请求头。") + : t("仅使用指定的 User-Agent,不附加 Codex 客户端指纹。")} +

+
+ + {probeUserAgentMode === "custom" ? ( +
+ + setProbeUserAgent(event.target.value)} + /> + {!probeUserAgent.trim() ? ( +

{t("请输入自定义 User-Agent")}

+ ) : null} +
+ ) : null} +
+ + + + + +
+
+ { 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: