From 615e8d66f4bbdefaf9168cf25b3935f9197f3005 Mon Sep 17 00:00:00 2001 From: Zyy Date: Sat, 17 Oct 2026 20:36:58 +0800 Subject: [PATCH] fix(openai): enforce account model access for standalone search Apply model allowlists and denylists before selecting search accounts. Use unrestricted accounts when the request model cannot be determined, and preserve plugin body rewrites after selection. Co-authored-by: Codex --- .../openai/src/credential/selector.rs | 16 +- .../openai/src/provider/execution.rs | 3 +- .../provider/contract/image_model_access.rs | 30 -- .../openai/tests/provider/contract/mod.rs | 1 + .../provider/contract/search_model_access.rs | 371 ++++++++++++++++++ docs/api.md | 12 +- 6 files changed, 389 insertions(+), 44 deletions(-) create mode 100644 backend/crates/providers/openai/tests/provider/contract/search_model_access.rs diff --git a/backend/crates/providers/openai/src/credential/selector.rs b/backend/crates/providers/openai/src/credential/selector.rs index 402db46d1..64a68ecde 100644 --- a/backend/crates/providers/openai/src/credential/selector.rs +++ b/backend/crates/providers/openai/src/credential/selector.rs @@ -108,10 +108,10 @@ pub(crate) struct SelectCodexProviderEndpointCredential<'a> { pub requires_oauth: bool, } -/// 选号使用的模型类别,区分图片资格与不涉及模型的端点 +/// 选号使用的模型类别,图片额外检查套餐资格,原生端点允许模型未知 #[derive(Clone, Copy)] pub(crate) enum CodexSelectionModel<'a> { - NotApplicable, + Search(Option<&'a str>), Requested(&'a str), Image(Option<&'a str>), } @@ -119,9 +119,8 @@ pub(crate) enum CodexSelectionModel<'a> { impl<'a> CodexSelectionModel<'a> { pub(crate) const fn model(self) -> Option<&'a str> { match self { - Self::NotApplicable => None, Self::Requested(model) => Some(model), - Self::Image(model) => model, + Self::Image(model) | Self::Search(model) => model, } } @@ -150,10 +149,11 @@ impl<'a> CodexSelectionModel<'a> { pub(crate) fn allows(self, scope: &FrozenAccountScope, account: &ProviderAccountId) -> bool { match self { - Self::NotApplicable => scope.allows(account), - Self::Requested(model) | Self::Image(Some(model)) => scope.allows_model(account, model), - // 缺失、重复或不可解析的图片模型不能绕过黑白名单;不猜上游默认值 - Self::Image(None) => { + Self::Requested(model) | Self::Image(Some(model)) | Self::Search(Some(model)) => { + scope.allows_model(account, model) + } + // 缺失、重复或不可解析的模型不能绕过黑白名单;不猜上游默认值 + Self::Image(None) | Self::Search(None) => { scope.allows(account) && scope.directory().account(account).is_some_and(|account| { account.model_access().mode() == AccountModelAccessMode::All diff --git a/backend/crates/providers/openai/src/provider/execution.rs b/backend/crates/providers/openai/src/provider/execution.rs index 479977f47..547547e34 100644 --- a/backend/crates/providers/openai/src/provider/execution.rs +++ b/backend/crates/providers/openai/src/provider/execution.rs @@ -153,12 +153,13 @@ impl CodexProvider { ) -> Result { let requested_model = match &request.operation { Operation::GenerateImage(image) => endpoint_requested_model(image.payload()), + Operation::Search(search) => endpoint_requested_model(search.payload()), _ => None, }; let model = if matches!(&request.operation, Operation::GenerateImage(_)) { CodexSelectionModel::Image(requested_model.as_ref().map(|model| model.as_str())) } else { - CodexSelectionModel::NotApplicable + CodexSelectionModel::Search(requested_model.as_ref().map(|model| model.as_str())) }; let selection_started_at = Instant::now(); let lease = self diff --git a/backend/crates/providers/openai/tests/provider/contract/image_model_access.rs b/backend/crates/providers/openai/tests/provider/contract/image_model_access.rs index 6441b0abe..cfe45e877 100644 --- a/backend/crates/providers/openai/tests/provider/contract/image_model_access.rs +++ b/backend/crates/providers/openai/tests/provider/contract/image_model_access.rs @@ -339,36 +339,6 @@ async fn final_image_model_after_middleware_must_be_allowed_by_the_selected_acco assert_eq!(server.received_requests().await.unwrap().len(), 2); } -#[tokio::test] -async fn standalone_search_does_not_inherit_image_model_restrictions() { - let server = MockServer::start().await; - let store = Arc::new(MemoryAccountStore::default()); - create_account(&store, CPA_ACCOUNT).await; - let search = Operation::Search(StandaloneSearchRequest::from_raw_json( - RawJsonPayload::new( - "openai", - Bytes::from_static(br#"{"id":"search","commands":{}}"#), - ) - .unwrap(), - )); - let stream = provider_with_base_url(&store, server.uri()) - .execute( - planned_provider_endpoint_request("openai", search), - attempt( - AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(scope( - policy(AccountModelAccessMode::Allowlist, "kimi-k3"), - AccountModelAccess::all(), - )), - ), - ) - .await - .unwrap(); - assert_eq!( - stream.metadata().provider_account_id().as_str(), - CPA_ACCOUNT - ); -} - fn image_operation(kind: ImageRequestKind, body: &[u8]) -> Operation { Operation::GenerateImage(ImageRequest::from_raw_json( kind, diff --git a/backend/crates/providers/openai/tests/provider/contract/mod.rs b/backend/crates/providers/openai/tests/provider/contract/mod.rs index 66ca98a70..2c5224714 100644 --- a/backend/crates/providers/openai/tests/provider/contract/mod.rs +++ b/backend/crates/providers/openai/tests/provider/contract/mod.rs @@ -12,6 +12,7 @@ mod precommit; mod privacy; mod recovery; mod response_interrupt; +mod search_model_access; mod session_binding; mod timing; mod upstream_adapter; diff --git a/backend/crates/providers/openai/tests/provider/contract/search_model_access.rs b/backend/crates/providers/openai/tests/provider/contract/search_model_access.rs new file mode 100644 index 000000000..380c14d84 --- /dev/null +++ b/backend/crates/providers/openai/tests/provider/contract/search_model_access.rs @@ -0,0 +1,371 @@ +//! 独立搜索的账号模型限制、未知模型、亲和与重试选号回归 + +use super::*; +use gateway_core::account::{AccountAffinity, AccountModelAccess, AccountModelAccessMode}; + +const FIRST: &str = "acct_provider_contract"; +const SECOND: &str = "acct_scope_new"; +const MODEL: &str = "gpt-6-astra"; +const BODY: &[u8] = br#"{ "model":"gpt-6-astra", "commands":{"search_query":[{"q":"test"}]}, "future":1, "future":9007199254740993 }"#; + +fn policy(mode: AccountModelAccessMode, model: &str) -> AccountModelAccess { + AccountModelAccess::new(mode, vec![model.to_owned()]).unwrap() +} + +fn scope(first: AccountModelAccess, second: AccountModelAccess) -> Arc { + Arc::new(FrozenAccountScope::new( + Arc::new(RuntimeAccountDirectory::new( + [(FIRST, first), (SECOND, second)] + .into_iter() + .map(|(id, policy)| { + ( + ProviderAccountId::new(id).unwrap(), + RuntimeAccount::new(ProviderKind::new("openai").unwrap(), BTreeSet::new()) + .with_model_access(policy), + ) + }) + .collect(), + )), + ClientRoutingScope::all_accounts(), + )) +} + +fn search(body: &[u8]) -> ProviderRequest { + planned_provider_endpoint_request( + "openai", + Operation::Search(StandaloneSearchRequest::from_raw_json( + RawJsonPayload::new("openai", Bytes::copy_from_slice(body)).unwrap(), + )), + ) +} + +fn attempt( + accounts: AccountAttemptContext, + affinity: AccountAffinity, + index: u32, + middleware: Option>, +) -> AttemptContext { + let middleware = middleware.map(|plan| { + FrozenMiddlewarePlan::new( + plan, + ExtensionSetReference::new( + ExtensionSetId::new("search-access".to_owned()).unwrap(), + Arc::new(TestExtensionLease), + ), + ) + }); + AttemptContext::new( + RequestAttemptContext::new( + ModelRequestId::new("req_search_access").unwrap(), + ClientApiKeyId::new("key_openai_contract").unwrap(), + ) + .with_middleware( + middleware, + Arc::from([]), + "/v1/alpha/search".to_owned(), + ClientTransport::HttpJson, + ), + NonZeroU32::new(index).unwrap(), + SystemTime::now() + Duration::from_secs(30), + account_policy().with_openai_account_affinity(affinity), + accounts, + None, + CancellationToken::new(), + ) +} + +#[tokio::test] +async fn search_filters_oauth_and_api_key_accounts_before_weight_and_preserves_wire() { + let api = MockServer::start().await; + let oauth = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + store + .seed_api_key( + FIRST, + api.uri(), + provider_openai::credential::ResponsesTransport::Http, + ) + .await; + create_account(&store, SECOND).await; + store.set_scheduling(FIRST, None, AccountWeight::new(100).unwrap()); + for (server, endpoint) in [(&api, "/alpha/search"), (&oauth, "/codex/alpha/search")] { + Mock::given(method("POST")) + .and(path(endpoint)) + .and(body_bytes(BODY.to_vec())) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"output":"result"}))) + .expect(2) + .mount(server) + .await; + } + let leases = Arc::new(TestLeaseCoordinator::default()); + let provider = provider_with_affinity_and_base_url_and_leases( + &store, + Arc::default(), + oauth.uri(), + leases.clone(), + ); + for denied in [ + policy(AccountModelAccessMode::Denylist, MODEL), + policy(AccountModelAccessMode::Allowlist, "other-model"), + ] { + let allowed = policy(AccountModelAccessMode::Allowlist, MODEL); + for (scope, expected) in [ + (scope(denied.clone(), allowed.clone()), SECOND), + (scope(allowed, denied.clone()), FIRST), + ] { + let mut stream = provider + .clone() + .execute( + search(BODY), + attempt( + AccountAttemptContext::new(BTreeSet::new(), None, None) + .with_account_scope(scope), + AccountAffinity::Strict, + 1, + None, + ), + ) + .await + .unwrap(); + assert_eq!(stream.metadata().provider_account_id().as_str(), expected); + while let Some(event) = stream.next().await { + event.unwrap(); + } + } + } + assert_eq!( + leases + .requests + .lock() + .unwrap() + .iter() + .map(|request| request.account_id().as_str()) + .collect::>(), + [SECOND, FIRST, SECOND, FIRST] + ); +} + +#[tokio::test] +async fn search_does_not_use_forbidden_accounts_when_pinned_or_after_failover() { + let server = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, FIRST).await; + create_account(&store, SECOND).await; + let leases = Arc::new(TestLeaseCoordinator::default()); + let provider = provider_with_affinity_and_base_url_and_leases( + &store, + Arc::default(), + server.uri(), + leases.clone(), + ); + for denied in [ + policy(AccountModelAccessMode::Denylist, MODEL), + policy(AccountModelAccessMode::Allowlist, "other-model"), + ] { + for (required, excluded) in [ + ( + Some(ProviderAccountId::new(FIRST).unwrap()), + BTreeSet::new(), + ), + ( + None, + BTreeSet::from([ProviderAccountId::new(SECOND).unwrap()]), + ), + ] { + for index in [1, 2] { + let error = provider + .clone() + .execute( + search(BODY), + attempt( + AccountAttemptContext::new(excluded.clone(), required.clone(), None) + .with_account_scope(scope( + denied.clone(), + AccountModelAccess::all(), + )), + AccountAffinity::Strict, + index, + None, + ), + ) + .await + .err() + .expect("forbidden accounts must not be leased"); + assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + } + } + } + assert!(leases.requests.lock().unwrap().is_empty()); + assert!(server.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test] +async fn unresolved_search_models_only_use_unrestricted_accounts_and_preserve_wire() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/alpha/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"output":"result"}))) + .mount(&server) + .await; + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, FIRST).await; + create_account(&store, SECOND).await; + store.set_scheduling(FIRST, None, AccountWeight::new(100).unwrap()); + let provider = provider_with_base_url(&store, server.uri()); + let bodies: &[&[u8]] = &[ + br#"{"commands":{}}"#, + br#"{"model":null}"#, + br#"{"model":42}"#, + br#"{"model":""}"#, + br#"{"model":{"future":"unknown"}}"#, + br#"{"model":"gpt-6-astra","model":"other-model"}"#, + br#"{"model":"other-model","model":"gpt-6-astra"}"#, + br#"{"model":"gpt-6-astra", broken"#, + ]; + for mode in [ + AccountModelAccessMode::Allowlist, + AccountModelAccessMode::Denylist, + ] { + for body in bodies { + for unrestricted in [true, false] { + let scope = scope( + policy(mode, MODEL), + if unrestricted { + AccountModelAccess::all() + } else { + policy(mode, MODEL) + }, + ); + let result = provider + .clone() + .execute( + search(body), + attempt( + AccountAttemptContext::new(BTreeSet::new(), None, None) + .with_account_scope(scope), + AccountAffinity::Strict, + 1, + None, + ), + ) + .await; + if unrestricted { + let mut stream = result.expect("unrestricted account accepts opaque search"); + assert_eq!(stream.metadata().provider_account_id().as_str(), SECOND); + while let Some(event) = stream.next().await { + event.unwrap(); + } + assert_eq!( + server + .received_requests() + .await + .unwrap() + .last() + .unwrap() + .body, + *body + ); + } else { + let error = result + .err() + .expect("unknown model cannot bypass model policy"); + assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount); + assert_eq!(error.send_state(), UpstreamSendState::NotSent); + } + } + } + } + assert_eq!( + server.received_requests().await.unwrap().len(), + bodies.len() * 2 + ); +} + +#[tokio::test] +async fn search_model_policy_overrides_existing_session_affinity() { + for affinity in [ + AccountAffinity::Strict, + AccountAffinity::Preferred, + AccountAffinity::Relaxed, + ] { + let server = MockServer::start().await; + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, FIRST).await; + create_account(&store, SECOND).await; + store.set_scheduling(FIRST, None, AccountWeight::new(100).unwrap()); + let provider = provider_with_affinity_and_base_url(&store, Arc::default(), server.uri()); + let body = br#"{"id":"search-model-session","model":"gpt-6-astra","commands":{}}"#; + for (scope, expected) in [ + ( + scope(AccountModelAccess::all(), AccountModelAccess::all()), + FIRST, + ), + ( + scope( + policy(AccountModelAccessMode::Denylist, MODEL), + AccountModelAccess::all(), + ), + SECOND, + ), + ] { + let stream = provider + .clone() + .execute( + search(body), + attempt( + AccountAttemptContext::new(BTreeSet::new(), None, None) + .with_account_scope(scope), + affinity, + 1, + None, + ), + ) + .await + .unwrap(); + assert_eq!(stream.metadata().provider_account_id().as_str(), expected); + drop(stream); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } +} + +#[tokio::test] +async fn search_model_selection_does_not_restrict_plugin_body_rewrites() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/alpha/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"output":"result"}))) + .expect(1) + .mount(&server) + .await; + let store = Arc::new(MemoryAccountStore::default()); + create_account(&store, FIRST).await; + let middleware = Arc::new(RecordingMiddleware { + observed: Arc::default(), + replacement: ("model".to_owned(), json!("plugin-model")), + request_headers: Vec::new(), + }); + let mut stream = provider_with_base_url(&store, server.uri()) + .execute( + search(BODY), + attempt( + AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(scope( + policy(AccountModelAccessMode::Allowlist, MODEL), + AccountModelAccess::all(), + )), + AccountAffinity::Strict, + 1, + Some(middleware), + ), + ) + .await + .unwrap(); + while let Some(event) = stream.next().await { + event.unwrap(); + } + assert_eq!( + captured_request_body(&server.received_requests().await.unwrap()[0])["model"], + "plugin-model" + ); +} diff --git a/docs/api.md b/docs/api.md index 0a6618aff..4138ec43e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -447,7 +447,8 @@ Images 固定使用 OpenAI Provider,不重建正文或映射模型字段;Pro Images 排除已知为 Free 套餐的 OAuth 账号;套餐缺失或未知时不推断为 Free,API Key 账号不套用该套餐规则。 其余账号仍需满足模型限制、额度等调度条件,实际生图权限由上游决定 -`/v1/alpha/search` 使用相同的 OpenAI Provider 原生端点边界:body(包括 `model`)不解析、不映射, +`/v1/alpha/search` 使用相同的 OpenAI Provider 原生端点边界:正文原样转发,模型不映射, +Provider 旁路读取请求中的 `model`,按[账号模型限制](#账号模型限制)选号。 `x-codex-turn-metadata` 在移除客户端账号身份并按当前 lease 重写 installation ID 后转发;上游账号 Authorization、Cookie、account ID、originator 和 User-Agent 均由代理安全重建。 Images 与 Search 保留通过现有过滤规则的普通业务请求头及多值字节;Responses 原连接的 turn state @@ -795,11 +796,12 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修 批量接口的调度、分组、模型与代理字段均可省略;省略的字段保持各账号原值。 提供 `groupIds` 时替换完整分组集合,提供 `concurrencyLimit: null` 时恢复继承运行参数 -限制适用于 Responses HTTP、WebSocket 及其带压缩触发的请求,以及 Images 生图与编辑选号,包括重试、亲和和换号;没有合规账号时沿用 +限制适用于 Responses HTTP、WebSocket 及其带压缩触发的请求,以及 Images 生图、编辑与独立 Search 选号,包括重试、亲和和换号;没有合规账号时沿用 无可用账号错误,不会回退到被禁止的账号。已开始请求使用冻结的政策,新请求使用已发布的新配置。 -Images 按请求正文的图片模型 ID 检查,不依赖文本模型目录;可手动添加目录未列出的图片模型 ID。 -图片模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。 -独立 Search 及管理员连接测试不受该模型限制;连接测试成功只证明指定账号的上游能力。 +Images 和 Search 按原始请求正文的模型 ID 检查,不依赖模型目录或全局模型映射;可手动添加目录未列出的模型 ID。 +模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。 +Search 的选号限制不约束插件在选号后主动改写正文。 +管理员连接测试不受该模型限制;连接测试成功只证明指定账号的上游能力。 普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示; 同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象