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` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象