fix(openai): enforce image account eligibility (#374)

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-10-09 22:57:24 +08:00
committed by GitHub
co-authored by Codex
parent 7a5521953f
commit bbb8278281
11 changed files with 906 additions and 43 deletions
@@ -62,7 +62,7 @@ where
let started = match service
.start_prepared_provider_endpoint(
prepared, operation,
// 图像/搜索等原生端点没有对应的上游模型权限。
// 原生端点的模型语义由 Provider 从正文提取并检查账号政策
None, client_ip, user_agent, endpoint,
)
.await
@@ -68,7 +68,9 @@ pub use selector::{
CodexAccountFailure, CodexCredentialLease, CodexCredentialSelector, CredentialSelectionError,
SelectCodexCredential,
};
pub(crate) use selector::{CodexCyberPolicyScope, SelectCodexProviderEndpointCredential};
pub(crate) use selector::{
CodexCyberPolicyScope, CodexSelectionModel, SelectCodexProviderEndpointCredential,
};
pub use types::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexAccountProfile, CodexCookie, CodexCookieCaptureOutcome,
CodexCredentialData, CodexCredentialPrincipal, CodexOAuthCredentialData, CodexOAuthSecret,
@@ -7,11 +7,13 @@ use std::time::{Duration, SystemTime};
use gateway_core::error::ErrorSource;
use gateway_core::account::scope::FrozenAccountScope;
use gateway_core::account::{
AccountAffinity, AccountCandidate, AccountCapacitySnapshot, AccountEligibilityPolicy,
AccountErrorReason, AccountFeedbackStats, AccountRuntimeSignals, AccountSchedulingBlocker,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountStatus,
CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId, QuotaEvidence,
AccountErrorReason, AccountFeedbackStats, AccountModelAccessMode, AccountRuntimeSignals,
AccountSchedulingBlocker, AccountSelectionContext, AccountSelectionPolicy, AccountSelector,
AccountStatus, CredentialState, PreferredAccountSelection, ProviderAccount, ProviderAccountId,
QuotaEvidence,
};
use gateway_core::concurrency::{CapacityWait, ConcurrencyWaitQueue, QueueRejection, WaitPriority};
use gateway_core::engine::{AttemptContext, ContinuationAttempt, policy::AccountPolicyError};
@@ -101,12 +103,53 @@ pub(crate) struct SelectCodexProviderEndpointCredential<'a> {
pub request_url: &'a Url,
pub attempt: &'a AttemptContext,
pub session_affinity: Option<&'a CodexSessionAffinity>,
/// 本端点的上游模型;提供后按账号模型权限过滤候选(如 live 语音)。
pub upstream_model: Option<&'a str>,
pub model: CodexSelectionModel<'a>,
/// 本端点只接受 OAuth 凭据时排除 API Key 账号,避免混合池选中后必然失败。
pub requires_oauth: bool,
}
/// 选号使用的模型类别,区分图片资格与不涉及模型的端点
#[derive(Clone, Copy)]
pub(crate) enum CodexSelectionModel<'a> {
NotApplicable,
Requested(&'a str),
Image(Option<&'a str>),
}
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,
}
}
pub(crate) fn allows_account(self, account: &ProviderAccount) -> bool {
// 官方 Codex 的 image_generation_available 排除 Free,API Key 不套用 ChatGPT 套餐
// https://github.com/openai/codex/commit/0a0a9b6c8f
!(matches!(self, Self::Image(_))
&& account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH
&& account
.plan_type()
.is_some_and(|plan| plan.trim().eq_ignore_ascii_case("free")))
}
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) => {
scope.allows(account)
&& scope.directory().account(account).is_some_and(|account| {
account.model_access().mode() == AccountModelAccessMode::All
})
}
}
}
}
struct CredentialSelectionInput<'a> {
requires_websocket: bool,
requires_oauth: bool,
@@ -304,8 +347,12 @@ impl CodexCredentialSelector {
session_affinity_observation: None,
guardian: false,
};
self.select_inner(&input, None, Some(request.upstream_model))
.await
self.select_inner(
&input,
None,
CodexSelectionModel::Requested(request.upstream_model),
)
.await
}
pub(crate) async fn select_with_cyber_policy(
@@ -328,7 +375,7 @@ impl CodexCredentialSelector {
self.select_inner(
&input,
cyber_policy_session_key,
Some(request.upstream_model),
CodexSelectionModel::Requested(request.upstream_model),
)
.await
}
@@ -385,9 +432,8 @@ impl CodexCredentialSelector {
/// 为不属于 Responses 文本模型目录的 Provider 原生端点选择账号
///
/// 账号范围、健康度、配额、并发租约、cookie 与认证准备仍走同一套选择链路;
/// 原生端点默认没有 Responses 模型,不套用管理员配置的文本模型权限。
/// 端点有明确上游模型(如 live 语音)时通过 `upstream_model` 让账号
/// 模型权限参与候选过滤;`requires_oauth` 限定本端点支持的认证类型。
/// Images 与 live 语音按实际请求模型执行账号权限,不依赖文本模型目录
/// `requires_oauth` 限定本端点支持的认证类型
pub(crate) async fn select_for_provider_endpoint(
&self,
request: &SelectCodexProviderEndpointCredential<'_>,
@@ -401,15 +447,14 @@ impl CodexCredentialSelector {
session_affinity_observation: request.session_affinity,
guardian: false,
};
self.select_inner(&input, None, request.upstream_model)
.await
self.select_inner(&input, None, request.model).await
}
async fn select_inner(
&self,
request: &CredentialSelectionInput<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
upstream_model: Option<&str>,
model: CodexSelectionModel<'_>,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let follow_only = !request.attempt.is_diagnostic_required_account()
&& request
@@ -423,7 +468,7 @@ impl CodexCredentialSelector {
let result = tokio::select! {
biased;
() = request.attempt.cancellation().cancelled() => Err(CredentialSelectionError::Cancelled),
result = self.select_inner_loop(request, cyber_policy_session_key, upstream_model, follow_only) => result,
result = self.select_inner_loop(request, cyber_policy_session_key, model, follow_only) => result,
};
result.map_err(|error| {
if follow_only
@@ -446,7 +491,7 @@ impl CodexCredentialSelector {
&self,
request: &CredentialSelectionInput<'_>,
cyber_policy_session_key: Option<&ProviderSessionAffinityKey>,
upstream_model: Option<&str>,
model: CodexSelectionModel<'_>,
follow_only: bool,
) -> Result<CodexCredentialLease, CredentialSelectionError> {
let diagnostic = request.attempt.is_diagnostic_required_account();
@@ -561,17 +606,17 @@ impl CodexCredentialSelector {
.is_some_and(|scope| scope.allows(account.id())))
&& (!request.requires_oauth
|| account.authentication_kind() == CODEX_AUTHENTICATION_KIND_OAUTH)
&& (diagnostic
|| upstream_model.is_none_or(|upstream_model| {
let allowed =
request.attempt.account_scope().is_some_and(|scope| {
scope.allows_model(account.id(), upstream_model)
});
if !allowed {
model_access_rejected += 1;
}
allowed
}))
&& model.allows_account(account)
&& (diagnostic || {
let allowed = request
.attempt
.account_scope()
.is_some_and(|scope| model.allows(scope, account.id()));
if !allowed {
model_access_rejected += 1;
}
allowed
})
})
.collect::<Vec<_>>();
let mut eligible = Vec::with_capacity(accounts.len());
@@ -742,7 +787,7 @@ impl CodexCredentialSelector {
}
let selection = match request
.attempt
.select_account(&self.provider_kind, upstream_model, &candidates, &context)
.select_account(&self.provider_kind, model.model(), &candidates, &context)
.await
{
Ok(selection) => selection,
@@ -3,6 +3,7 @@
use gateway_core::metering::{CalculatedCost, Usage};
use super::*;
use crate::credential::CodexSelectionModel;
use gateway_core::operation::RawJsonPayload;
impl CodexProvider {
@@ -150,6 +151,15 @@ impl CodexProvider {
context: AttemptContext,
request: RawJsonEndpointRequest,
) -> Result<ProviderStream, ProviderError> {
let requested_model = match &request.operation {
Operation::GenerateImage(image) => endpoint_requested_model(image.payload()),
_ => None,
};
let model = if matches!(&request.operation, Operation::GenerateImage(_)) {
CodexSelectionModel::Image(requested_model.as_ref().map(|model| model.as_str()))
} else {
CodexSelectionModel::NotApplicable
};
let selection_started_at = Instant::now();
let lease = self
.selector
@@ -157,8 +167,7 @@ impl CodexProvider {
request_url: &request.response_origin,
attempt: &context,
session_affinity: request.session_affinity.as_ref(),
// 图像/搜索端点没有对应的上游模型权限,也不限定认证类型。
upstream_model: None,
model,
requires_oauth: false,
})
.await
@@ -175,7 +184,7 @@ impl CodexProvider {
.execute_middleware(
operation,
provider_kind,
None,
requested_model.map(|model| model.as_str().to_owned()),
account_id,
Box::new(move |operation, middleware_headers| {
Box::pin(async move {
@@ -206,6 +215,23 @@ impl CodexProvider {
) -> Result<ProviderStream, ProviderError> {
let affinity = match &operation {
Operation::GenerateImage(image) => {
let model = endpoint_requested_model(image.payload());
let selection =
CodexSelectionModel::Image(model.as_ref().map(|model| model.as_str()));
// 中间件可改写正文,最终图片模型必须仍被当前租约账号允许
// 校验先于亲和变更和发送,不能在持有租约时重新选号
if !selection.allows_account(lease.account())
|| (!context.is_diagnostic_required_account()
&& !context
.account_scope()
.is_some_and(|scope| selection.allows(scope, lease.account_id())))
{
return Err(provider_error(
ProviderErrorKind::NoEligibleAccount,
UpstreamSendState::NotSent,
)
.with_retry_prohibited());
}
self.image_session_affinity(image.payload(), &middleware_headers, &context)
.await?
}
@@ -32,7 +32,7 @@ use uuid::Uuid;
use super::*;
use crate::credential::{
CODEX_AUTHENTICATION_KIND_OAUTH, CodexCredentialRepository,
CODEX_AUTHENTICATION_KIND_OAUTH, CodexCredentialRepository, CodexSelectionModel,
SelectCodexProviderEndpointCredential,
};
use crate::transport::headers::websocket_header_pairs;
@@ -252,7 +252,7 @@ impl CodexProvider {
request_url: &self.live_calls_url,
attempt: &context,
session_affinity: session_affinity.as_ref(),
upstream_model: Some(upstream_model.as_str()),
model: CodexSelectionModel::Requested(upstream_model.as_str()),
// realtime calls 端点绑定 ChatGPT OAuth 身份;在候选阶段就排除
// API Key 账号,避免混合账号池选中不支持语音的账号后必然失败。
requires_oauth: true,
@@ -8,7 +8,7 @@ pub(super) fn endpoint_requested_model(
if payload.protocol() != PROVIDER_NAME {
return None;
}
// 只读取 model,避免把编辑请求中的整张图片复制到观测数据
// 选号与观测共用模型提取,跳过编辑请求中的图片且不改写原始正文
#[derive(Deserialize)]
struct RequestModel {
model: Option<Value>,
@@ -0,0 +1,317 @@
//! Images 的 OAuth 套餐资格、混合池、亲和和失败换号回归
use super::*;
use gateway_core::account::{AccountAffinity, ProviderAccountUpdate};
const PLUS: &str = "acct_provider_contract";
const FREE: [&str; 3] = ["acct_scope_new", "acct_scope_old", "acct_scope_same"];
async fn oauth_account(store: &MemoryAccountStore, id: &str, plan: Option<&str>) {
let mut verified_account = profile(&format!("chatgpt-{id}"));
verified_account.plan_type = plan.map(str::to_owned);
store
.seed_oauth_credential(ImportCodexOAuthCredential {
account_id: id.to_owned(),
name: id.to_owned(),
secret: secret(&format!("at-{id}")),
verified_account,
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
enabled: true,
})
.await;
}
fn image(kind: ImageRequestKind, body: Value) -> Operation {
Operation::GenerateImage(ImageRequest::from_raw_json(
kind,
RawJsonPayload::new("openai", Bytes::from(serde_json::to_vec(&body).unwrap())).unwrap(),
))
}
fn selection_attempt(
accounts: AccountAttemptContext,
affinity: AccountAffinity,
index: u32,
) -> AttemptContext {
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_image_eligibility").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
),
NonZeroU32::new(index).unwrap(),
SystemTime::now() + Duration::from_secs(30),
account_policy().with_openai_account_affinity(affinity),
accounts.with_account_scope(contract_account_scope()),
None,
CancellationToken::new(),
)
}
#[tokio::test]
async fn images_use_plus_in_a_pool_with_three_higher_weight_free_accounts() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(header("chatgpt-account-id", format!("chatgpt-{PLUS}")))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.expect(2)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("plus")).await;
for id in FREE {
oauth_account(&store, id, Some("free")).await;
store.set_scheduling(id, None, AccountWeight::new(100).unwrap());
}
let leases = Arc::new(TestLeaseCoordinator::default());
let provider = provider_with_affinity_and_base_url_and_leases(
&store,
Arc::default(),
server.uri(),
leases.clone(),
);
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let mut stream = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(kind, json!({"model":"gpt-image-2","prompt":"test"})),
),
context("req_plus_images", CancellationToken::new()),
)
.await
.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), PLUS);
while let Some(event) = stream.next().await {
event.unwrap();
}
}
assert!(
leases
.requests
.lock()
.unwrap()
.iter()
.all(|request| request.account_id().as_str() == PLUS)
);
}
#[tokio::test]
async fn images_never_fall_back_to_free_when_plus_is_excluded_or_free_is_pinned() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("plus")).await;
oauth_account(&store, FREE[0], Some("free")).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 kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
for body in [json!({"model":"gpt-image-2"}), json!({"prompt":"test"})] {
for (required, excluded) in [
(
Some(ProviderAccountId::new(FREE[0]).unwrap()),
BTreeSet::new(),
),
(
None,
BTreeSet::from([ProviderAccountId::new(PLUS).unwrap()]),
),
] {
for index in [1, 2] {
let error = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", image(kind, body.clone())),
selection_attempt(
AccountAttemptContext::new(
excluded.clone(),
required.clone(),
None,
),
AccountAffinity::Strict,
index,
),
)
.await
.err()
.expect("Free must remain ineligible for Images");
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 images_escape_free_session_bindings_while_search_remains_available() {
for mode in [
AccountAffinity::Strict,
AccountAffinity::Preferred,
AccountAffinity::Relaxed,
] {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("plus")).await;
oauth_account(&store, FREE[0], Some("free")).await;
store.set_scheduling(FREE[0], None, AccountWeight::new(100).unwrap());
let affinity = Arc::new(MemorySessionAffinity::default());
let provider = provider_with_affinity_and_base_url(&store, affinity.clone(), server.uri());
let search = Operation::Search(StandaloneSearchRequest::from_raw_json(
RawJsonPayload::new(
"openai",
Bytes::from_static(br#"{"id":"image-plan-session","commands":{}}"#),
)
.unwrap(),
));
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", search),
selection_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None),
mode,
1,
),
)
.await
.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), FREE[0]);
drop(stream);
assert_eq!(affinity.binding_count(), 1);
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let key = affinity.lookup_keys()[0].clone();
affinity.seed_binding(
&ProviderKind::new("openai").unwrap(),
&key,
ProviderAccountId::new(FREE[0]).unwrap(),
);
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(
kind,
json!({
"model":"gpt-image-2", "session_id":"image-plan-session"
}),
),
),
selection_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None),
mode,
1,
),
)
.await
.unwrap();
assert_eq!(stream.metadata().provider_account_id().as_str(), PLUS);
drop(stream);
assert_eq!(affinity.binding_count(), 1);
}
assert!(server.received_requests().await.unwrap().is_empty());
}
}
#[tokio::test]
async fn image_plan_eligibility_tracks_account_updates_without_affecting_text() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
oauth_account(&store, PLUS, Some("free")).await;
let provider = provider_with_base_url(&store, server.uri());
drop(
provider
.clone()
.execute(
planned_request("openai", generate_operation()),
context("req_free_text", CancellationToken::new()),
)
.await
.expect("Free can still use Responses"),
);
for plan in [
Some("free"),
Some("plus"),
Some("free"),
None,
Some("future-plan"),
] {
store
.update_account(ProviderAccountUpdate {
account_id: ProviderAccountId::new(PLUS).unwrap(),
name: PLUS.to_owned(),
email: None,
plan_type: plan.map(str::to_owned),
})
.await
.unwrap();
let result = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(ImageRequestKind::Generation, json!({"model":"gpt-image-2"})),
),
context("req_plan_change", CancellationToken::new()),
)
.await;
if plan == Some("free") {
let error = result.err().expect("Free must be excluded");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
} else {
drop(result.expect("unknown plans are not inferred to be Free"));
}
}
}
#[tokio::test]
async fn api_key_image_accounts_do_not_inherit_chatgpt_free_plan_restrictions() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.expect(2)
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
PLUS,
server.uri(),
provider_openai::credential::ResponsesTransport::Http,
)
.await;
store
.update_account(ProviderAccountUpdate {
account_id: ProviderAccountId::new(PLUS).unwrap(),
name: PLUS.to_owned(),
email: None,
plan_type: Some("free".to_owned()),
})
.await
.unwrap();
let provider = provider_with_base_url(&store, server.uri());
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let mut stream = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image(kind, json!({"model":"gpt-image-2"})),
),
context("req_api_key_images", CancellationToken::new()),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
}
}
@@ -0,0 +1,461 @@
//! Images 混合账号池的模型权限、亲和、重试与中间件改写回归
use super::*;
use gateway_core::account::{AccountAffinity, AccountModelAccess, AccountModelAccessMode};
const CPA_ACCOUNT: &str = "acct_provider_contract";
const IMAGE_ACCOUNT: &str = "acct_scope_new";
const IMAGE_BODY: &[u8] =
br#"{ "model":"gpt-image-2", "prompt":"first", "prompt":"last", "future":9007199254740993 }"#;
fn policy(mode: AccountModelAccessMode, model: &str) -> AccountModelAccess {
AccountModelAccess::new(mode, vec![model.to_owned()]).unwrap()
}
fn scope(first: AccountModelAccess, second: AccountModelAccess) -> Arc<FrozenAccountScope> {
Arc::new(FrozenAccountScope::new(
Arc::new(RuntimeAccountDirectory::new(
[(CPA_ACCOUNT, first), (IMAGE_ACCOUNT, 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 attempt(accounts: AccountAttemptContext) -> AttemptContext {
configured_attempt(accounts, AccountAffinity::Strict, 1, None)
}
fn configured_attempt(
accounts: AccountAttemptContext,
affinity: AccountAffinity,
index: u32,
middleware: Option<Arc<dyn MiddlewarePlan>>,
) -> AttemptContext {
let middleware = middleware.map(|plan| {
FrozenMiddlewarePlan::new(
plan,
ExtensionSetReference::new(
ExtensionSetId::new("image-access".to_owned()).unwrap(),
Arc::new(TestExtensionLease),
),
)
});
AttemptContext::new(
RequestAttemptContext::new(
ModelRequestId::new("req_image_access").unwrap(),
ClientApiKeyId::new("key_openai_contract").unwrap(),
)
.with_middleware(
middleware,
Arc::from([]),
"/v1/images/generations".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 images_do_not_use_forbidden_accounts_when_pinned_or_after_failover() {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
create_account(&store, IMAGE_ACCOUNT).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 kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
for (required, excluded) in [
(
Some(ProviderAccountId::new(CPA_ACCOUNT).unwrap()),
BTreeSet::new(),
),
(
None,
BTreeSet::from([ProviderAccountId::new(IMAGE_ACCOUNT).unwrap()]),
),
] {
for index in [1, 2] {
let scope = scope(
policy(AccountModelAccessMode::Allowlist, "kimi-k3"),
policy(AccountModelAccessMode::Allowlist, "gpt-image-2"),
);
let error = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image_operation(kind, IMAGE_BODY),
),
configured_attempt(
AccountAttemptContext::new(excluded.clone(), required.clone(), None)
.with_account_scope(scope),
AccountAffinity::Strict,
index,
None,
),
)
.await
.err()
.expect("forbidden account must not be used");
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_image_models_only_use_unrestricted_accounts_without_rewriting_bodies() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
create_account(&store, IMAGE_ACCOUNT).await;
store.set_scheduling(CPA_ACCOUNT, None, AccountWeight::new(100).unwrap());
let provider = provider_with_base_url(&store, server.uri());
let bodies: &[&[u8]] = &[
br#"{"prompt":"default model"}"#,
br#"{"model":null}"#,
br#"{"model":{ "future":"unknown" }}"#,
br#"{"model":""}"#,
br#"{"model":"gpt-image-2","model":"forbidden"}"#,
br#"{"model":"forbidden","model":"gpt-image-2"}"#,
br#"{"model":"gpt-image-2", broken"#,
b"--image-boundary\r\nopaque multipart body\r\n--image-boundary--\r\n",
];
for mode in [
AccountModelAccessMode::Allowlist,
AccountModelAccessMode::Denylist,
] {
for body in bodies {
for unrestricted in [true, false] {
let scope = scope(
policy(mode, "gpt-image-2"),
if unrestricted {
AccountModelAccess::all()
} else {
policy(mode, "gpt-image-2")
},
);
let result = provider
.clone()
.execute(
planned_provider_endpoint_request(
"openai",
image_operation(ImageRequestKind::Edit, body),
),
attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(scope),
),
)
.await;
if unrestricted {
let mut stream = result.expect("unrestricted account accepts opaque payloads");
assert_eq!(
stream.metadata().provider_account_id().as_str(),
IMAGE_ACCOUNT
);
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 either 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 image_model_access_overrides_existing_session_affinity() {
for mode in [
AccountAffinity::Strict,
AccountAffinity::Preferred,
AccountAffinity::Relaxed,
] {
let server = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
create_account(&store, IMAGE_ACCOUNT).await;
store.set_scheduling(CPA_ACCOUNT, None, AccountWeight::new(100).unwrap());
let affinity = Arc::new(MemorySessionAffinity::default());
let provider = provider_with_affinity_and_base_url(&store, affinity.clone(), server.uri());
let operation = image_operation(
ImageRequestKind::Generation,
br#"{"model":"gpt-image-2","session_id":"image-model-session","prompt":"test"}"#,
);
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", operation.clone()),
configured_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(
scope(AccountModelAccess::all(), AccountModelAccess::all()),
),
mode,
1,
None,
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
CPA_ACCOUNT
);
drop(stream);
let stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", operation),
configured_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None).with_account_scope(
scope(
policy(AccountModelAccessMode::Allowlist, "kimi-k3"),
policy(AccountModelAccessMode::Allowlist, "gpt-image-2"),
),
),
mode,
1,
None,
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
IMAGE_ACCOUNT
);
assert!(server.received_requests().await.unwrap().is_empty());
}
}
#[tokio::test]
async fn final_image_model_after_middleware_must_be_allowed_by_the_selected_account() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({"data":[]})))
.mount(&server)
.await;
let store = Arc::new(MemoryAccountStore::default());
create_account(&store, CPA_ACCOUNT).await;
let provider = provider_with_base_url(&store, server.uri());
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
for (replacement, allowed) in [
(json!("forbidden"), false),
(Value::Null, false),
(json!("gpt-image-future"), true),
] {
let scope = scope(
AccountModelAccess::new(
AccountModelAccessMode::Allowlist,
vec!["gpt-image-2".to_owned(), "gpt-image-future".to_owned()],
)
.unwrap(),
AccountModelAccess::all(),
);
let middleware = Arc::new(RecordingMiddleware {
observed: Arc::default(),
replacement: ("model".to_owned(), replacement.clone()),
request_headers: Vec::new(),
});
let result = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", image_operation(kind, IMAGE_BODY)),
configured_attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(scope),
AccountAffinity::Strict,
1,
Some(middleware),
),
)
.await;
if allowed {
let mut stream = result.unwrap();
while let Some(event) = stream.next().await {
event.unwrap();
}
assert_eq!(
captured_request_body(
server.received_requests().await.unwrap().last().unwrap()
)["model"],
replacement
);
} else {
let error = result
.err()
.expect("rewriting cannot escape account policy");
assert_eq!(error.kind(), ProviderErrorKind::NoEligibleAccount);
assert_eq!(error.send_state(), UpstreamSendState::NotSent);
assert!(error.retry_is_prohibited());
}
}
}
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,
RawJsonPayload::new("openai", Bytes::copy_from_slice(body)).unwrap(),
))
}
#[tokio::test]
async fn images_filter_mixed_accounts_by_model_before_weight_and_preserve_wire() {
let cpa = MockServer::start().await;
let oauth = MockServer::start().await;
let store = Arc::new(MemoryAccountStore::default());
store
.seed_api_key(
CPA_ACCOUNT,
cpa.uri(),
provider_openai::credential::ResponsesTransport::Http,
)
.await;
store.set_scheduling(CPA_ACCOUNT, None, AccountWeight::new(100).unwrap());
create_account(&store, IMAGE_ACCOUNT).await;
Mock::given(method("POST"))
.and(body_bytes(IMAGE_BODY.to_vec()))
.respond_with(
ResponseTemplate::new(200).set_body_json(json!({"data":[{"b64_json":"AAEC"}]})),
)
.expect(4)
.mount(&oauth)
.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 cpa_policy in [
policy(AccountModelAccessMode::Allowlist, "kimi-k3"),
policy(AccountModelAccessMode::Denylist, "gpt-image-2"),
] {
for kind in [ImageRequestKind::Generation, ImageRequestKind::Edit] {
let scope = scope(
cpa_policy.clone(),
policy(AccountModelAccessMode::Allowlist, "gpt-image-2"),
);
let mut stream = provider
.clone()
.execute(
planned_provider_endpoint_request("openai", image_operation(kind, IMAGE_BODY)),
attempt(
AccountAttemptContext::new(BTreeSet::new(), None, None)
.with_account_scope(scope),
),
)
.await
.unwrap();
assert_eq!(
stream.metadata().provider_account_id().as_str(),
IMAGE_ACCOUNT
);
while let Some(event) = stream.next().await {
event.unwrap();
}
}
}
assert!(cpa.received_requests().await.unwrap().is_empty());
assert!(
leases
.requests
.lock()
.unwrap()
.iter()
.all(|request| request.account_id().as_str() == IMAGE_ACCOUNT)
);
assert_eq!(
oauth
.received_requests()
.await
.unwrap()
.iter()
.map(|request| request.url.path().to_owned())
.collect::<Vec<_>>(),
[
"/codex/images/generations",
"/codex/images/edits",
"/codex/images/generations",
"/codex/images/edits"
]
);
}
@@ -3,6 +3,8 @@
mod account_isolation;
mod capacity;
mod error_details;
mod image_account_eligibility;
mod image_model_access;
mod passthrough;
mod precommit;
mod response_interrupt;
+11 -5
View File
@@ -408,9 +408,13 @@ OpenAI 上游错误 envelope 和允许下发的 opaque header 值也不由 canon
`response.metadata` 的业务内容继续交付,仅其中的响应头沿用凭据、账号身份和逐跳头隔离规则。
未改写的 WebSocket 文本不依赖 JSON 旁路解析成功;插件改写后的响应仍复核必要关联,原始 SSE 心跳
和无法提取事实的未改写内容不构成协议失败。
Images 请求不读取或重建 JSON,也不要求或映射模型字段;
它固定使用 OpenAI Provider,
只在原始字节之外完成账号选择、鉴权头替换和端点路由,成功与非容量失败响应正文保持原始字节。
Images 固定使用 OpenAI Provider,不重建正文或映射模型字段;Provider 从原始 JSON 旁路提取图片模型,
按[账号模型限制](#账号模型限制)选号,并复核插件改写后的模型。账号选择、鉴权头替换和端点路由
不改变请求正文,成功与非容量失败响应正文保持原始字节。
Images 排除已知为 Free 套餐的 OAuth 账号;套餐缺失或未知时不推断为 Free,API Key 账号不套用该套餐规则。
其余账号仍需满足模型限制、额度等调度条件,实际生图权限由上游决定
`/v1/alpha/search` 使用相同的 OpenAI Provider 原生端点边界:body(包括 `model`)不解析、不映射,
`x-codex-turn-metadata` 在移除客户端账号身份并按当前 lease 重写 installation ID 后转发;上游账号
Authorization、Cookie、account ID、originator 和 User-Agent 均由代理安全重建。
@@ -717,9 +721,11 @@ Pro 使用 `all` 时也能参与 luna 调度。套餐名称不自动生成或修
批量接口的调度、分组、模型与代理字段均可省略;省略的字段保持各账号原值。
提供 `groupIds` 时替换完整分组集合,提供 `concurrencyLimit: null` 时恢复继承运行参数
限制适用于 Responses HTTP、WebSocket 及其带压缩触发的请求选号,包括重试、亲和和换号;没有合规账号时沿用
限制适用于 Responses HTTP、WebSocket 及其带压缩触发的请求,以及 Images 生图与编辑选号,包括重试、亲和和换号;没有合规账号时沿用
无可用账号错误,不会回退到被禁止的账号。已开始请求使用冻结的政策,新请求使用已发布的新配置。
Images、独立 Search 及管理员连接测试不受该文本模型限制;连接测试成功只证明指定账号的上游能力。
Images 按请求正文的图片模型 ID 检查,不依赖文本模型目录;可手动添加目录未列出的图片模型 ID。
图片模型缺失、重复或无法识别时,仅使用模型政策为 `all` 的账号,不推测上游默认模型。
独立 Search 及管理员连接测试不受该模型限制;连接测试成功只证明指定账号的上游能力。
普通 `/v1/models` 和单模型查询将已发现的 OpenAI 模型关联到来源账号,至少一个来源账号在当前 Key 范围内且政策允许时才展示;
同 Provider 中未发现该模型的 `all` 账号不会使它进入列表。原生目录保留已有来源选择和完整模型对象
+6 -2
View File
@@ -384,8 +384,9 @@ Client Key 鉴权完成后,API adapter 从有界请求头识别客户端,Cor
Responses 按模型目录编译候选;全局模型映射是精确映射,未命中时模型名原样交给候选 Provider。
Images 与 standalone Search 是 OpenAI Provider 自有端点:两者都不参与文本模型映射,只在 Client Key
的账号范围确实包含 OpenAI 账号时生成单一 OpenAI 候选。Images 不要求模型字段;Search body 中的模型
及其他字段保持原始 bytes 并由上游解释
的账号范围确实包含 OpenAI 账号时生成单一 OpenAI 候选。Images 的图片模型由 Provider 从正文旁路
提取,用于账号模型政策检查,并在中间件改写后复验当前租约账号;不要求图片模型进入文本目录。
Search body 中的模型及其他字段保持原始 bytes 并由上游解释
## 5. Provider 与协议边界
@@ -487,6 +488,9 @@ client,OIDC 的 JWKS 缓存与单飞归属对应出口状态。自动刷新提
客户端据此开放原生生图工具,图片生成和编辑由 Images 路由处理。
这只解决客户端能力识别;模型能力、客户端限制和上游账号的实际生图权限仍分别检查
OpenAI Provider 将 OAuth 套餐资格作为 Images 选号条件,复用账号持久事实,
在获取租约前与冻结的模型黑白名单共同过滤候选;亲和与重试沿用同一资格检查
`X-OpenAI-Actor-Authorization` 是客户端能力标记,不是账号凭据。API 解码和 OpenAI Provider
都过滤该 header,网关继续校验 Client Key,上游认证由服务端账号产生。
客户端配置示例维护在 [部署文档](../deploy/README.md#客户端配置)