mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 16:05:27 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3d622554 | ||
|
|
0f71cf6df2 | ||
|
|
a1d3eba874 | ||
|
|
d22c1d4ab9 | ||
|
|
cc8dfe3dd8 | ||
|
|
f25edadd5f | ||
|
|
4f50c67332 | ||
|
|
75f9d2a290 | ||
|
|
b93b8859f9 |
@@ -45,7 +45,7 @@ jobs:
|
||||
ref: ${{ inputs.ref }}
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Build runtime image
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
${{ hashFiles('trivy-results.sarif') != '' &&
|
||||
(github.event_name != 'pull_request' ||
|
||||
github.event.pull_request.head.repo.full_name == github.repository) }}
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
uses: github/codeql-action/upload-sarif@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
@@ -448,7 +448,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
@@ -519,7 +519,7 @@ jobs:
|
||||
path: dist/release
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||
|
||||
Generated
+18
-18
@@ -216,9 +216,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-sdk-s3"
|
||||
version = "1.141.0"
|
||||
version = "1.142.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9f9420d3a2467eed22ed3635ca653653162c386a0b0f65c78189f9bd3c1379e"
|
||||
checksum = "f9e15a5c55e05f4b0b7e483160b3c85cccdf77cff02c95504f3e71d460855cd2"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"aws-credential-types",
|
||||
@@ -308,9 +308,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-eventstream"
|
||||
version = "0.61.1"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
|
||||
checksum = "6de526c7b567420a31bc283657a7921b45c4cafe0827fdf2490713dcc770c28f"
|
||||
dependencies = [
|
||||
"aws-smithy-types",
|
||||
"bytes",
|
||||
@@ -341,9 +341,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-http-client"
|
||||
version = "1.2.0"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
|
||||
checksum = "3c1c8a04cb31ba74d0115af5a890bb8c0d48fba64b52812fa13929a6ef0cc83c"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api",
|
||||
@@ -385,9 +385,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime"
|
||||
version = "1.12.1"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
|
||||
checksum = "483b858ff67522011c4786310c5cd8fd88d0be7ea3d5f1a48328446300c4269e"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-http",
|
||||
@@ -411,9 +411,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-runtime-api"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb"
|
||||
checksum = "954c563ce84507722d2679f07a35d21b9c6466b3872d513020d0281fc8112ac9"
|
||||
dependencies = [
|
||||
"aws-smithy-async",
|
||||
"aws-smithy-runtime-api-macros",
|
||||
@@ -1246,7 +1246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2714,7 +2714,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2813,9 +2813,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "1.5.0"
|
||||
version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3257df217f7eab0044627a268c9cc6cdb60c0c421c88f83ac41c4e31520b6b84"
|
||||
checksum = "e37a4ca5c6ca42aa3e6df2fd32b987a65d32a4c2159a6f3fe0fd1df306a2658f"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arcstr",
|
||||
@@ -2984,7 +2984,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3628,7 +3628,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4100,9 +4100,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.24.0"
|
||||
version = "1.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
|
||||
@@ -231,6 +231,32 @@ pub trait ProviderSessionAffinityPort: Send + Sync {
|
||||
ttl: Duration,
|
||||
) -> BoxFuture<'a, Result<(), ProviderStoreError>>;
|
||||
|
||||
/// 仅在亲和键尚未绑定时写入候选账号,并返回原子操作后的实际绑定。
|
||||
///
|
||||
/// 已存在的绑定绝不会被候选账号覆盖;同一根会话的并发首次请求据此收敛到
|
||||
/// 单一账号。TTL 只在首次写入时设置,已有绑定由成功反馈负责刷新。
|
||||
fn claim_or_load<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
candidate_account_id: &'a ProviderAccountId,
|
||||
ttl: Duration,
|
||||
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>>;
|
||||
|
||||
/// 仅当当前绑定等于 `expected_account_id`(或键已过期)时写入新账号,
|
||||
/// 并返回原子操作后的实际绑定。
|
||||
///
|
||||
/// Provider 用它迁移不可用账号,以及在成功后以 `expected == replacement`
|
||||
/// 刷新 TTL;迟到的旧账号成功不能覆盖较新的会话 winner。
|
||||
fn compare_and_bind<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
expected_account_id: &'a ProviderAccountId,
|
||||
replacement_account_id: &'a ProviderAccountId,
|
||||
ttl: Duration,
|
||||
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>>;
|
||||
|
||||
fn clear<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
|
||||
@@ -16,6 +16,24 @@ use super::{namespace, resource_fingerprint};
|
||||
|
||||
const MAX_SESSION_AFFINITY_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
const CLAIM_OR_LOAD_SCRIPT: &str = r#"
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if current then
|
||||
return current
|
||||
end
|
||||
redis.call('PSETEX', KEYS[1], tonumber(ARGV[2]), ARGV[1])
|
||||
return ARGV[1]
|
||||
"#;
|
||||
|
||||
const COMPARE_AND_BIND_SCRIPT: &str = r#"
|
||||
local current = redis.call('GET', KEYS[1])
|
||||
if not current or current == ARGV[1] then
|
||||
redis.call('PSETEX', KEYS[1], tonumber(ARGV[3]), ARGV[2])
|
||||
return ARGV[2]
|
||||
end
|
||||
return current
|
||||
"#;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RedisProviderSessionAffinityRepository {
|
||||
connection: ConnectionManager,
|
||||
@@ -79,11 +97,7 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
|
||||
ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<(), ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
|
||||
return Err(provider_invalid("validate provider session affinity TTL"));
|
||||
}
|
||||
let ttl_millis = u64::try_from(ttl.as_millis())
|
||||
.map_err(|_| provider_invalid("validate provider session affinity TTL"))?;
|
||||
let ttl_millis = session_affinity_ttl_millis(ttl)?;
|
||||
let mut connection = self.connection.clone();
|
||||
redis::cmd("PSETEX")
|
||||
.arg(self.key(provider_kind, key)?)
|
||||
@@ -95,6 +109,50 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
|
||||
})
|
||||
}
|
||||
|
||||
fn claim_or_load<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
candidate_account_id: &'a ProviderAccountId,
|
||||
ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let ttl_millis = session_affinity_ttl_millis(ttl)?;
|
||||
let mut connection = self.connection.clone();
|
||||
let effective_account_id = redis::Script::new(CLAIM_OR_LOAD_SCRIPT)
|
||||
.key(self.key(provider_kind, key)?)
|
||||
.arg(candidate_account_id.as_str())
|
||||
.arg(ttl_millis)
|
||||
.invoke_async::<String>(&mut connection)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("claim provider session affinity"))?;
|
||||
decode_account_id(effective_account_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn compare_and_bind<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
expected_account_id: &'a ProviderAccountId,
|
||||
replacement_account_id: &'a ProviderAccountId,
|
||||
ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let ttl_millis = session_affinity_ttl_millis(ttl)?;
|
||||
let mut connection = self.connection.clone();
|
||||
let effective_account_id = redis::Script::new(COMPARE_AND_BIND_SCRIPT)
|
||||
.key(self.key(provider_kind, key)?)
|
||||
.arg(expected_account_id.as_str())
|
||||
.arg(replacement_account_id.as_str())
|
||||
.arg(ttl_millis)
|
||||
.invoke_async::<String>(&mut connection)
|
||||
.await
|
||||
.map_err(|_| provider_unavailable("compare provider session affinity"))?;
|
||||
decode_account_id(effective_account_id)
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
@@ -112,6 +170,19 @@ impl ProviderSessionAffinityPort for RedisProviderSessionAffinityRepository {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_affinity_ttl_millis(ttl: Duration) -> Result<u64, ProviderStoreError> {
|
||||
if ttl.is_zero() || ttl > MAX_SESSION_AFFINITY_TTL {
|
||||
return Err(provider_invalid("validate provider session affinity TTL"));
|
||||
}
|
||||
u64::try_from(ttl.as_millis())
|
||||
.map_err(|_| provider_invalid("validate provider session affinity TTL"))
|
||||
}
|
||||
|
||||
fn decode_account_id(account_id: String) -> Result<ProviderAccountId, ProviderStoreError> {
|
||||
ProviderAccountId::new(account_id)
|
||||
.map_err(|_| provider_invalid("decode provider session affinity"))
|
||||
}
|
||||
|
||||
fn provider_unavailable(operation: &'static str) -> ProviderStoreError {
|
||||
ProviderStoreError::new(ProviderStoreErrorKind::Unavailable, operation)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,55 @@ async fn session_affinity_should_overwrite_the_previous_account() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_affinity_claim_should_keep_the_first_account() {
|
||||
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
|
||||
return;
|
||||
};
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let key = ProviderSessionAffinityKey::try_new("claim-session").expect("affinity key");
|
||||
let first = ProviderAccountId::new("acct_first").expect("first account");
|
||||
let second = ProviderAccountId::new("acct_second").expect("second account");
|
||||
|
||||
let first_winner = repository
|
||||
.claim_or_load(&provider, &key, &first, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("claim first affinity");
|
||||
let second_winner = repository
|
||||
.claim_or_load(&provider, &key, &second, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("load existing affinity");
|
||||
|
||||
assert_eq!((first_winner, second_winner), (first.clone(), first));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_affinity_compare_should_not_replace_a_newer_winner() {
|
||||
let Some((repository, _connection, _namespace)) = affinity_repository().await else {
|
||||
return;
|
||||
};
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let key = ProviderSessionAffinityKey::try_new("compare-session").expect("affinity key");
|
||||
let first = ProviderAccountId::new("acct_first").expect("first account");
|
||||
let second = ProviderAccountId::new("acct_second").expect("second account");
|
||||
let stale = ProviderAccountId::new("acct_stale").expect("stale account");
|
||||
repository
|
||||
.bind(&provider, &key, &first, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("seed affinity");
|
||||
|
||||
let migrated = repository
|
||||
.compare_and_bind(&provider, &key, &first, &second, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("migrate expected affinity");
|
||||
let stale_result = repository
|
||||
.compare_and_bind(&provider, &key, &first, &stale, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("reject stale affinity migration");
|
||||
|
||||
assert_eq!((migrated, stale_result), (second.clone(), second));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_affinity_should_apply_ttl_and_support_explicit_clear() {
|
||||
let Some((repository, mut connection, namespace)) = affinity_repository().await else {
|
||||
|
||||
@@ -155,8 +155,6 @@ impl CodexApiConfig {
|
||||
pub struct CodexWebSocketPoolSettings {
|
||||
pub enabled: bool,
|
||||
pub max_age_ms: u64,
|
||||
pub max_per_account: usize,
|
||||
pub max_total: usize,
|
||||
pub max_connecting: usize,
|
||||
pub initial_event_timeout_ms: u64,
|
||||
}
|
||||
@@ -166,8 +164,6 @@ impl Default for CodexWebSocketPoolSettings {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_age_ms: 55 * 60 * 1000,
|
||||
max_per_account: 8,
|
||||
max_total: 64,
|
||||
max_connecting: 8,
|
||||
initial_event_timeout_ms: 20_000,
|
||||
}
|
||||
@@ -176,12 +172,7 @@ impl Default for CodexWebSocketPoolSettings {
|
||||
|
||||
impl CodexWebSocketPoolSettings {
|
||||
fn validate(&self) -> Result<(), OpenAiConfigError> {
|
||||
if self.max_age_ms == 0
|
||||
|| self.max_per_account == 0
|
||||
|| self.max_total == 0
|
||||
|| self.max_connecting == 0
|
||||
|| self.max_connecting > self.max_total
|
||||
{
|
||||
if self.max_age_ms == 0 || self.max_connecting == 0 {
|
||||
return Err(OpenAiConfigError::InvalidField("openai.ws_pool"));
|
||||
}
|
||||
Ok(())
|
||||
@@ -191,8 +182,6 @@ impl CodexWebSocketPoolSettings {
|
||||
CodexWebSocketPoolConfig {
|
||||
enabled: self.enabled,
|
||||
max_age: Duration::from_millis(self.max_age_ms),
|
||||
max_per_account: self.max_per_account,
|
||||
max_total: self.max_total,
|
||||
max_connecting: self.max_connecting,
|
||||
initial_event_timeout: (self.initial_event_timeout_ms != 0)
|
||||
.then(|| Duration::from_millis(self.initial_event_timeout_ms)),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OpenAI 会话锚点到 Store 不透明亲和键的单向派生。
|
||||
//! OpenAI 根会话锚点到 Store 不透明账号亲和键的单向派生。
|
||||
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::provider_ports::ProviderSessionAffinityKey;
|
||||
@@ -11,34 +11,8 @@ pub(crate) fn derive_codex_session_affinity_key(
|
||||
request: &CodexResponsesRequest,
|
||||
client_api_key_id: &ClientApiKeyId,
|
||||
) -> Option<ProviderSessionAffinityKey> {
|
||||
let session_key = if let Some(conversation_id) = request
|
||||
.local_conversation_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
if let Ok(key) = ProviderSessionAffinityKey::try_new(conversation_id.to_owned()) {
|
||||
Some(key)
|
||||
} else {
|
||||
opaque_affinity_key("local-conversation", conversation_id)
|
||||
}
|
||||
} else {
|
||||
let (domain, value) = derive_conversation_anchor(request)?;
|
||||
opaque_affinity_key(domain, &value)
|
||||
};
|
||||
|
||||
let session_discriminator = request.subagent_kind();
|
||||
let session_key =
|
||||
session_key.and_then(|session_key| match session_discriminator.as_deref() {
|
||||
None => Some(session_key),
|
||||
Some(discriminator) => {
|
||||
// 仅扩展原会话锚点的键空间;后续查找、绑定和调度仍使用同一套亲和逻辑。
|
||||
opaque_affinity_key(
|
||||
"subagent-session",
|
||||
&format!("{discriminator}\0{}", session_key.expose_to_store()),
|
||||
)
|
||||
}
|
||||
})?;
|
||||
let (domain, value) = derive_account_affinity_anchor(request)?;
|
||||
let session_key = opaque_affinity_key(domain, &value)?;
|
||||
opaque_affinity_key(
|
||||
"client-session",
|
||||
&format!(
|
||||
@@ -49,6 +23,33 @@ pub(crate) fn derive_codex_session_affinity_key(
|
||||
)
|
||||
}
|
||||
|
||||
/// 账号选择优先使用根会话事实;`local_conversation_id` 仍可按 child thread 隔离
|
||||
/// WebSocket 与 continuation 状态,但 child 身份不再拆分账号首选项。账号不可调度时
|
||||
/// selector 仍可按既有策略切换,并由 request scope 清理账号绑定状态。
|
||||
fn derive_account_affinity_anchor(
|
||||
request: &CodexResponsesRequest,
|
||||
) -> Option<(&'static str, String)> {
|
||||
non_empty(request.client_session_id.as_deref())
|
||||
.map(|value| ("root-session", value.to_owned()))
|
||||
.or_else(|| {
|
||||
non_empty(request.client_conversation_id.as_deref())
|
||||
.map(|value| ("root-conversation", value.to_owned()))
|
||||
})
|
||||
.or_else(|| {
|
||||
request
|
||||
.explicit_prompt_cache_key
|
||||
.then(|| request.prompt_cache_key())
|
||||
.flatten()
|
||||
.and_then(|value| non_empty(Some(value)))
|
||||
.map(|value| ("root-prompt-cache", value.to_owned()))
|
||||
})
|
||||
.or_else(|| {
|
||||
non_empty(request.local_conversation_id.as_deref())
|
||||
.map(|value| ("local-conversation", value.to_owned()))
|
||||
})
|
||||
.or_else(|| derive_conversation_anchor(request))
|
||||
}
|
||||
|
||||
fn opaque_affinity_key(domain: &str, value: &str) -> Option<ProviderSessionAffinityKey> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"codex-session-affinity-v1\0");
|
||||
|
||||
@@ -476,14 +476,16 @@ impl CodexCredentialSelector {
|
||||
{
|
||||
affinity.escape(AffinityEscapeReason::PinnedAccount);
|
||||
}
|
||||
let preferred = pinned_account
|
||||
.clone()
|
||||
.or_else(|| affinity.preferred_account().cloned());
|
||||
let mut observed_affinity_account = affinity.bound_account().cloned();
|
||||
let mut shortest_retry = None;
|
||||
let policy = request.attempt.account_selection_policy();
|
||||
|
||||
loop {
|
||||
let preferred = pinned_account
|
||||
.clone()
|
||||
.or_else(|| affinity.preferred_account().cloned());
|
||||
let context = AccountSelectionContext {
|
||||
policy: request.attempt.account_selection_policy(),
|
||||
policy,
|
||||
now: SystemTime::now(),
|
||||
excluded_accounts: excluded.clone(),
|
||||
preferred_account: preferred.clone(),
|
||||
@@ -511,7 +513,6 @@ impl CodexCredentialSelector {
|
||||
.map(|candidate| candidate.account.clone())
|
||||
.ok_or(CredentialSelectionError::InvalidCredential)?;
|
||||
let allows_account_state_mutation = !diagnostic || account.enabled();
|
||||
let policy = request.attempt.account_selection_policy();
|
||||
match self
|
||||
.leases
|
||||
.try_acquire(ProviderLeaseRequest::Scheduling(
|
||||
@@ -532,6 +533,40 @@ impl CodexCredentialSelector {
|
||||
excluded.insert(account.id().clone());
|
||||
}
|
||||
ProviderLeaseAcquisition::Acquired(guard) => {
|
||||
let initial_affinity_claim = if !diagnostic
|
||||
&& observed_affinity_account.is_none()
|
||||
&& let Some(key) = request.session_affinity_key
|
||||
{
|
||||
self.claim_initial_session_affinity(key, account.id()).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// 原生 continuation/required account 比根亲和绑定更严格;它可以
|
||||
// 使用 owner 账号,但不能把已经迁移的根会话拉回旧号。
|
||||
if pinned_account.is_none()
|
||||
&& let Some(effective_account) = initial_affinity_claim
|
||||
&& &effective_account != account.id()
|
||||
{
|
||||
drop(guard);
|
||||
observed_affinity_account = Some(effective_account.clone());
|
||||
affinity = affinity_selection_for_bound_account(
|
||||
effective_account,
|
||||
&candidates,
|
||||
SystemTime::now(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let affinity_expected_account_id = if pinned_account
|
||||
.as_ref()
|
||||
.zip(observed_affinity_account.as_ref())
|
||||
.is_some_and(|(pinned, bound)| pinned != bound)
|
||||
{
|
||||
account.id().clone()
|
||||
} else {
|
||||
observed_affinity_account
|
||||
.clone()
|
||||
.unwrap_or_else(|| account.id().clone())
|
||||
};
|
||||
let affinity_telemetry = affinity.telemetry(account.id());
|
||||
tracing::info!(
|
||||
request_id = %request.attempt.request_id(),
|
||||
@@ -583,6 +618,7 @@ impl CodexCredentialSelector {
|
||||
cyber_policy_scope,
|
||||
allows_account_state_mutation,
|
||||
affinity_telemetry,
|
||||
affinity_expected_account_id,
|
||||
_guard: guard,
|
||||
});
|
||||
}
|
||||
@@ -603,24 +639,7 @@ impl CodexCredentialSelector {
|
||||
else {
|
||||
return AffinitySelection::default();
|
||||
};
|
||||
let Some(candidate) = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.account.id() == &account_id)
|
||||
else {
|
||||
self.clear_session_affinity(key).await;
|
||||
return AffinitySelection::escaped(account_id, AffinityEscapeReason::HardUnavailable);
|
||||
};
|
||||
if candidate
|
||||
.account
|
||||
.status_projection(now, candidate.signals.rate_limited_until)
|
||||
.status
|
||||
== AccountStatus::Normal
|
||||
{
|
||||
return AffinitySelection::preferred(account_id);
|
||||
}
|
||||
let reason = affinity_unavailable_reason(candidate, now);
|
||||
self.clear_session_affinity(key).await;
|
||||
AffinitySelection::escaped(account_id, reason)
|
||||
affinity_selection_for_bound_account(account_id, candidates, now)
|
||||
}
|
||||
|
||||
async fn lookup_session_affinity(
|
||||
@@ -649,22 +668,38 @@ impl CodexCredentialSelector {
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_session_affinity(&self, key: &ProviderSessionAffinityKey) {
|
||||
async fn claim_initial_session_affinity(
|
||||
&self,
|
||||
key: &ProviderSessionAffinityKey,
|
||||
selected_account_id: &ProviderAccountId,
|
||||
) -> Option<ProviderAccountId> {
|
||||
match tokio::time::timeout(
|
||||
SESSION_AFFINITY_TIMEOUT,
|
||||
self.session_affinity.clear(&self.provider_kind, key),
|
||||
self.session_affinity.claim_or_load(
|
||||
&self.provider_kind,
|
||||
key,
|
||||
selected_account_id,
|
||||
SESSION_AFFINITY_TTL,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Ok(account_id)) => Some(account_id),
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(error = %error, "OpenAI stale session affinity clear failed open");
|
||||
tracing::warn!(
|
||||
account_id = %selected_account_id,
|
||||
error = %error,
|
||||
"OpenAI initial session affinity claim failed open"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
account_id = %selected_account_id,
|
||||
timeout_ms = SESSION_AFFINITY_TIMEOUT.as_millis(),
|
||||
"OpenAI stale session affinity clear timed out"
|
||||
"OpenAI initial session affinity claim timed out"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,6 +936,7 @@ impl CodexCredentialSelector {
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
session_affinity_key: Option<&ProviderSessionAffinityKey>,
|
||||
expected_affinity_account_id: &ProviderAccountId,
|
||||
) {
|
||||
self.restore_recoverable_account_state(account).await;
|
||||
self.risk_recovery
|
||||
@@ -910,24 +946,26 @@ impl CodexCredentialSelector {
|
||||
let Some(key) = session_affinity_key else {
|
||||
return;
|
||||
};
|
||||
if matches!(
|
||||
self.lookup_session_affinity(key).await,
|
||||
SessionAffinityLookup::Unavailable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
match tokio::time::timeout(
|
||||
SESSION_AFFINITY_TIMEOUT,
|
||||
self.session_affinity.bind(
|
||||
self.session_affinity.compare_and_bind(
|
||||
&self.provider_kind,
|
||||
key,
|
||||
expected_affinity_account_id,
|
||||
account.id(),
|
||||
SESSION_AFFINITY_TTL,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Ok(effective_account)) if &effective_account == account.id() => {}
|
||||
Ok(Ok(effective_account)) => {
|
||||
tracing::debug!(
|
||||
account_id = %account.id(),
|
||||
effective_account_id = %effective_account,
|
||||
"OpenAI late success left the newer session affinity binding unchanged"
|
||||
);
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::warn!(
|
||||
account_id = %account.id(),
|
||||
@@ -1104,6 +1142,32 @@ impl CodexCredentialSelector {
|
||||
}
|
||||
}
|
||||
|
||||
fn affinity_selection_for_bound_account(
|
||||
account_id: ProviderAccountId,
|
||||
candidates: &[AccountCandidate],
|
||||
now: SystemTime,
|
||||
) -> AffinitySelection {
|
||||
let Some(candidate) = candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.account.id() == &account_id)
|
||||
else {
|
||||
return AffinitySelection::escaped(account_id, AffinityEscapeReason::HardUnavailable);
|
||||
};
|
||||
match candidate
|
||||
.account
|
||||
.status_projection(now, candidate.signals.rate_limited_until)
|
||||
.status
|
||||
{
|
||||
AccountStatus::Normal => AffinitySelection::preferred(account_id),
|
||||
AccountStatus::QuotaExhausted
|
||||
| AccountStatus::RateLimited
|
||||
| AccountStatus::Disabled
|
||||
| AccountStatus::Error => {
|
||||
AffinitySelection::escaped(account_id, affinity_unavailable_reason(candidate, now))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn affinity_unavailable_reason(
|
||||
candidate: &AccountCandidate,
|
||||
now: SystemTime,
|
||||
@@ -1159,6 +1223,7 @@ pub struct CodexCredentialLease {
|
||||
cyber_policy_scope: Option<CodexCyberPolicyScope>,
|
||||
allows_account_state_mutation: bool,
|
||||
affinity_telemetry: AffinityTelemetry,
|
||||
affinity_expected_account_id: ProviderAccountId,
|
||||
_guard: Box<dyn ProviderLeaseGuard>,
|
||||
}
|
||||
|
||||
@@ -1216,6 +1281,12 @@ impl CodexCredentialLease {
|
||||
pub const fn account_switch(&self) -> bool {
|
||||
self.affinity_telemetry.account_switch
|
||||
}
|
||||
|
||||
/// 成功反馈只能从选择时观察到的绑定迁移,避免迟到请求覆盖较新 winner。
|
||||
#[must_use]
|
||||
pub(crate) const fn affinity_expected_account_id(&self) -> &ProviderAccountId {
|
||||
&self.affinity_expected_account_id
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CodexCredentialLease {
|
||||
|
||||
@@ -71,6 +71,9 @@ use crate::transport::profile::{
|
||||
use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
|
||||
};
|
||||
use crate::transport::protocol::websocket::{
|
||||
PREVIOUS_RESPONSE_NOT_FOUND_CODE, PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
|
||||
};
|
||||
use crate::transport::request::{
|
||||
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
|
||||
};
|
||||
@@ -318,8 +321,14 @@ impl Provider for CodexProvider {
|
||||
.is_some_and(|state| state.account_id != lease.account_id().as_str());
|
||||
let has_explicit_state_owner =
|
||||
context.account_state_owner().is_some() || previous_session.is_some();
|
||||
let cross_account =
|
||||
state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch());
|
||||
let account_scope =
|
||||
if state_owner_cross_account || (!has_explicit_state_owner && lease.account_switch()) {
|
||||
RequestAccountScope::Different
|
||||
} else if has_explicit_state_owner || lease.affinity_hit() {
|
||||
RequestAccountScope::Same
|
||||
} else {
|
||||
RequestAccountScope::Unknown
|
||||
};
|
||||
if context.continuation_attempt() != ContinuationAttempt::None
|
||||
&& let Some(continuation) = context.continuation()
|
||||
{
|
||||
@@ -366,13 +375,11 @@ impl Provider for CodexProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
let account_scope = if !cross_account {
|
||||
RequestAccountScope::SameAccount
|
||||
} else if upstream_request.previous_response_id().is_some() {
|
||||
RequestAccountScope::CrossAccountContinuationProbe
|
||||
} else {
|
||||
RequestAccountScope::CrossAccountFullRequest
|
||||
};
|
||||
if upstream_request.previous_response_id().is_some()
|
||||
&& !account_scope.can_reuse_account_state()
|
||||
{
|
||||
return Err(continuation_replay_required_error());
|
||||
}
|
||||
scope_request_to_account(
|
||||
&mut upstream_request,
|
||||
lease.installation_id(),
|
||||
@@ -1178,7 +1185,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
// 完成事件一旦交给下游,Core 可以立刻停止轮询 Provider stream;
|
||||
// 在此之前持久化亲和关系,保证成功请求不会因流被提前 drop 而丢失绑定。
|
||||
selector
|
||||
.record_success(&active_account, session_affinity_key.as_ref())
|
||||
.record_success(
|
||||
&active_account,
|
||||
session_affinity_key.as_ref(),
|
||||
lease.affinity_expected_account_id(),
|
||||
)
|
||||
.await;
|
||||
selector
|
||||
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
|
||||
@@ -1282,7 +1293,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
if allows_account_state_mutation && completed && terminal_failure.is_none() {
|
||||
// 同上:尾部 finish() 也可能产出 completed,亲和记录必须先于任何下游 yield。
|
||||
selector
|
||||
.record_success(&active_account, session_affinity_key.as_ref())
|
||||
.record_success(
|
||||
&active_account,
|
||||
session_affinity_key.as_ref(),
|
||||
lease.affinity_expected_account_id(),
|
||||
)
|
||||
.await;
|
||||
selector
|
||||
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
|
||||
@@ -2364,6 +2379,19 @@ fn map_handshake_error(error: CodexClientError) -> MappedProviderFailure {
|
||||
map_client_error(error, UpstreamSendState::Ambiguous, true)
|
||||
}
|
||||
|
||||
fn continuation_replay_required_error() -> ProviderError {
|
||||
ProviderError::new(
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
UpstreamSendState::NotSent,
|
||||
)
|
||||
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
|
||||
.with_client_visible_upstream_error(ClientVisibleUpstreamError::new(
|
||||
PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE,
|
||||
Some(PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned()),
|
||||
Some("invalid_request_error".to_owned()),
|
||||
))
|
||||
}
|
||||
|
||||
fn map_stream_error(error: CodexClientError) -> MappedProviderFailure {
|
||||
let allows_pre_delivery_retry = stream_transport_allows_pre_delivery_retry(&error);
|
||||
let mut failure = map_client_error(error, UpstreamSendState::Sent, false);
|
||||
|
||||
@@ -525,11 +525,6 @@ impl CodexResponsesRequest {
|
||||
.map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// 替换输入条目(仅用于代理持有完整历史时的换号重放)。
|
||||
pub fn set_input(&mut self, input: Vec<Value>) {
|
||||
self.body.insert("input".to_string(), Value::Array(input));
|
||||
}
|
||||
|
||||
/// 是否流式返回。
|
||||
pub fn stream(&self) -> bool {
|
||||
self.body
|
||||
@@ -617,11 +612,12 @@ impl CodexResponsesRequest {
|
||||
)
|
||||
}
|
||||
|
||||
/// 返回会话亲和所用的子代理区分值。
|
||||
/// 返回请求语义与 child transport 隔离所用的子代理区分值。
|
||||
///
|
||||
/// Codex 原生请求在 turn metadata 中声明 `subagent_kind`;网关的
|
||||
/// `/responses/review` 等入口则通过 `client_metadata.x-openai-subagent`
|
||||
/// 传递给上游。该可选值仅参与会话亲和键派生。
|
||||
/// 传递给上游。它不拆分根会话的账号首选项;`thread_spawn` 仍用它派生独立
|
||||
/// WebSocket/continuation transport identity。
|
||||
pub fn subagent_kind(&self) -> Option<String> {
|
||||
self.semantics()
|
||||
.subagent_kind
|
||||
|
||||
@@ -5,7 +5,9 @@ use serde_json::{Map, Value};
|
||||
use crate::transport::protocol::responses::{CodexResponsesRequest, transport_requirement};
|
||||
|
||||
const REDACTED_PAYLOAD_VALUE: &str = "<redacted>";
|
||||
const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
|
||||
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_CODE: &str = "previous_response_not_found";
|
||||
pub(crate) const PREVIOUS_RESPONSE_NOT_FOUND_MESSAGE: &str =
|
||||
"Previous response was not found. Retrying the full request.";
|
||||
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
|
||||
|
||||
/// WebSocket 握手审计快照。
|
||||
|
||||
@@ -63,18 +63,14 @@ const INSTALLATION_ID_KEYS: &[&str] = &[
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RequestAccountScope {
|
||||
SameAccount,
|
||||
CrossAccountFullRequest,
|
||||
CrossAccountContinuationProbe,
|
||||
Same,
|
||||
Different,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl RequestAccountScope {
|
||||
const fn is_cross_account(self) -> bool {
|
||||
!matches!(self, Self::SameAccount)
|
||||
}
|
||||
|
||||
const fn preserves_continuation(self) -> bool {
|
||||
matches!(self, Self::CrossAccountContinuationProbe)
|
||||
pub(crate) const fn can_reuse_account_state(self) -> bool {
|
||||
matches!(self, Self::Same)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +201,9 @@ pub(crate) fn derive_conversation_anchor(
|
||||
})
|
||||
}
|
||||
|
||||
/// A `thread_spawn` shares its parent session but is a separate child execution.
|
||||
/// Its child thread must therefore own the affinity and WebSocket pool identity.
|
||||
/// `thread_spawn` 与父任务共享根会话,但它本身是独立的子任务执行。
|
||||
/// 因此子线程必须拥有独立的 continuation/WebSocket 传输身份;账号亲和则从共享的
|
||||
/// 根会话单独派生。
|
||||
fn thread_spawn_conversation_anchor(
|
||||
request: &CodexResponsesRequest,
|
||||
) -> Option<(&'static str, String)> {
|
||||
@@ -306,21 +303,17 @@ fn normalize_conversation_anchor_text(text: &str) -> String {
|
||||
///
|
||||
/// 真实 account ID 与 installation ID 由随后构造的 `CodexRequestContext` 注入
|
||||
/// 请求头;正文只替换客户端原本声明过的 installation 字段,绝不接受客户端
|
||||
/// 提供的 token、cookie 或账号身份。Continuation probe 保留 canonical
|
||||
/// previous-response 和原始增量 input,不能把它们误当成完整请求清理。
|
||||
/// 提供的 token、cookie 或账号身份。`input` 是 Responses 的可回放会话正文,
|
||||
/// item ID、encrypted content 和 compaction 都必须原样保留。
|
||||
pub(crate) fn scope_request_to_account(
|
||||
request: &mut CodexResponsesRequest,
|
||||
installation_id: &str,
|
||||
account_scope: RequestAccountScope,
|
||||
) {
|
||||
let cross_account = account_scope.is_cross_account();
|
||||
let continuation = account_scope
|
||||
.preserves_continuation()
|
||||
.then(|| request.previous_response_id().map(ToOwned::to_owned))
|
||||
.flatten();
|
||||
let reset_account_state = !account_scope.can_reuse_account_state();
|
||||
let client_metadata_turn_state = metadata_string(request, "x-codex-turn-state");
|
||||
let preserve_turn_state =
|
||||
!cross_account && (request.turn_state.is_some() || client_metadata_turn_state.is_some());
|
||||
let preserve_turn_state = !reset_account_state
|
||||
&& (request.turn_state.is_some() || client_metadata_turn_state.is_some());
|
||||
let turn_state = preserve_turn_state
|
||||
.then(|| request.turn_state.clone())
|
||||
.flatten();
|
||||
@@ -329,38 +322,22 @@ pub(crate) fn scope_request_to_account(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let turn_metadata = (!account_scope.preserves_continuation())
|
||||
.then(|| {
|
||||
request
|
||||
.turn_metadata
|
||||
.as_deref()
|
||||
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, cross_account))
|
||||
})
|
||||
.flatten();
|
||||
let client_metadata_turn_metadata = (!account_scope.preserves_continuation())
|
||||
.then(|| {
|
||||
metadata_string(request, "x-codex-turn-metadata")
|
||||
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, cross_account))
|
||||
})
|
||||
.flatten();
|
||||
let turn_metadata = request
|
||||
.turn_metadata
|
||||
.as_deref()
|
||||
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, reset_account_state));
|
||||
let client_metadata_turn_metadata = metadata_string(request, "x-codex-turn-metadata")
|
||||
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, reset_account_state));
|
||||
|
||||
if cross_account {
|
||||
if reset_account_state {
|
||||
request.passthrough_headers.remove("x-codex-turn-state");
|
||||
request.passthrough_headers.remove("x-codex-turn-metadata");
|
||||
if !account_scope.preserves_continuation() {
|
||||
sanitize_cross_account_input(request);
|
||||
}
|
||||
for key in CROSS_ACCOUNT_IDENTITY_KEYS
|
||||
.iter()
|
||||
.chain(ACCOUNT_BOUND_STATE_KEYS)
|
||||
{
|
||||
request.body_mut().remove(*key);
|
||||
}
|
||||
if account_scope.preserves_continuation() {
|
||||
for key in TURN_METADATA_KEYS {
|
||||
request.body_mut().remove(*key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key in INSTALLATION_ID_KEYS {
|
||||
@@ -371,7 +348,7 @@ pub(crate) fn scope_request_to_account(
|
||||
.body()
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|value| scope_turn_metadata(value, installation_id, cross_account));
|
||||
.and_then(|value| scope_turn_metadata(value, installation_id, reset_account_state));
|
||||
replace_existing_body_string(request, key, scoped.as_deref());
|
||||
}
|
||||
|
||||
@@ -383,22 +360,17 @@ pub(crate) fn scope_request_to_account(
|
||||
(
|
||||
key,
|
||||
metadata.get(key).and_then(Value::as_str).and_then(|value| {
|
||||
scope_turn_metadata(value, installation_id, cross_account)
|
||||
scope_turn_metadata(value, installation_id, reset_account_state)
|
||||
}),
|
||||
)
|
||||
});
|
||||
if cross_account {
|
||||
if reset_account_state {
|
||||
for key in CROSS_ACCOUNT_IDENTITY_KEYS
|
||||
.iter()
|
||||
.chain(ACCOUNT_BOUND_STATE_KEYS)
|
||||
{
|
||||
metadata.remove(*key);
|
||||
}
|
||||
if account_scope.preserves_continuation() {
|
||||
for key in TURN_METADATA_KEYS {
|
||||
metadata.remove(*key);
|
||||
}
|
||||
}
|
||||
}
|
||||
metadata.insert(
|
||||
"x-codex-installation-id".to_owned(),
|
||||
@@ -436,36 +408,6 @@ pub(crate) fn scope_request_to_account(
|
||||
|
||||
request.turn_state = turn_state;
|
||||
request.turn_metadata = turn_metadata;
|
||||
if let Some(continuation) = continuation {
|
||||
request.set_previous_response_id(Some(continuation));
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_cross_account_input(request: &mut CodexResponsesRequest) {
|
||||
if request.input().is_empty() {
|
||||
return;
|
||||
}
|
||||
let input = request
|
||||
.input()
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter_map(sanitize_cross_account_item)
|
||||
.collect();
|
||||
request.set_input(input);
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_cross_account_item(mut item: Value) -> Option<Value> {
|
||||
if let Value::Object(object) = &mut item {
|
||||
if matches!(
|
||||
object.get("type").and_then(Value::as_str),
|
||||
Some("compaction" | "compaction_summary" | "context_compaction")
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
object.remove("id");
|
||||
object.remove("encrypted_content");
|
||||
}
|
||||
Some(item)
|
||||
}
|
||||
|
||||
fn metadata_string(request: &CodexResponsesRequest, key: &str) -> Option<String> {
|
||||
|
||||
@@ -10,13 +10,13 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use tokio::{sync::Semaphore, time::Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use tokio_util::{sync::CancellationToken, task::TaskTracker};
|
||||
use uuid::Uuid;
|
||||
|
||||
use self::state::{
|
||||
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, account_slot_count,
|
||||
close_pooled_connection, close_pooled_connections, take_lru_idle_connection,
|
||||
WebSocketPoolConnecting, WebSocketPoolSlot, WebSocketPoolState, close_pooled_connection,
|
||||
close_pooled_connections,
|
||||
};
|
||||
use super::pump::PumpKeepalive;
|
||||
|
||||
@@ -31,8 +31,6 @@ pub(crate) use self::{
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_PER_ACCOUNT: usize = 8;
|
||||
const DEFAULT_MAX_TOTAL: usize = 64;
|
||||
const DEFAULT_MAX_CONNECTING: usize = 16;
|
||||
const DEFAULT_MAX_AGE: Duration = Duration::from_mins(55);
|
||||
const DEFAULT_MAINTENANCE_INTERVAL: Duration = Duration::from_secs(25);
|
||||
@@ -64,10 +62,6 @@ pub struct CodexWebSocketPoolConfig {
|
||||
pub enabled: bool,
|
||||
/// 单个 socket 的最大生命周期。
|
||||
pub max_age: Duration,
|
||||
/// 单个账号允许占用的最大池 slot 数。
|
||||
pub max_per_account: usize,
|
||||
/// 所有账号合计允许占用的最大池 slot 数。
|
||||
pub max_total: usize,
|
||||
/// 所有账号合计允许并发执行的 opening 数。
|
||||
pub max_connecting: usize,
|
||||
/// 后台维护间隔;`None` 表示不启动后台任务。
|
||||
@@ -87,8 +81,6 @@ impl Default for CodexWebSocketPoolConfig {
|
||||
Self {
|
||||
enabled: true,
|
||||
max_age: DEFAULT_MAX_AGE,
|
||||
max_per_account: DEFAULT_MAX_PER_ACCOUNT,
|
||||
max_total: DEFAULT_MAX_TOTAL,
|
||||
max_connecting: DEFAULT_MAX_CONNECTING,
|
||||
maintenance_interval: Some(DEFAULT_MAINTENANCE_INTERVAL),
|
||||
ping_interval: Some(DEFAULT_PING_INTERVAL),
|
||||
@@ -113,10 +105,9 @@ impl CodexWebSocketPoolConfig {
|
||||
}
|
||||
|
||||
impl CodexWebSocketPool {
|
||||
/// 构造连接池策略和状态。
|
||||
pub fn new(max_per_account: usize, max_age: Duration) -> Self {
|
||||
/// 构造不限制累计 slot 数的连接池策略和状态。
|
||||
pub fn new(max_age: Duration) -> Self {
|
||||
Self::with_config(CodexWebSocketPoolConfig {
|
||||
max_per_account: max_per_account.max(1),
|
||||
max_age,
|
||||
maintenance_interval: None,
|
||||
ping_interval: None,
|
||||
@@ -218,43 +209,25 @@ impl CodexWebSocketPool {
|
||||
|
||||
let acquire = if required_response_id.is_some() {
|
||||
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::ContinuationNotFound)
|
||||
} else if self.config.max_per_account == 0
|
||||
|| account_slot_count(&state.slots, key.account_id()) >= self.config.max_per_account
|
||||
{
|
||||
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
|
||||
} else {
|
||||
let connect_permit = self.connect_semaphore.clone().try_acquire_owned().ok();
|
||||
match connect_permit {
|
||||
Some(connect_permit) => {
|
||||
let has_total_capacity = if self.config.max_total == 0 {
|
||||
false
|
||||
} else if state.slots.len() < self.config.max_total {
|
||||
true
|
||||
} else if let Some(connection) = take_lru_idle_connection(&mut state) {
|
||||
connections_to_close.push(connection);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if has_total_capacity {
|
||||
let lease = WebSocketPoolConnectLease::reserve(
|
||||
self.clone(),
|
||||
key.clone(),
|
||||
connect_permit,
|
||||
);
|
||||
state.slots.insert(
|
||||
key,
|
||||
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
|
||||
id: lease.id,
|
||||
started_at: lease.started_at,
|
||||
outcome: lease.outcome.clone(),
|
||||
cancellation: lease.cancellation.clone(),
|
||||
}),
|
||||
);
|
||||
WebSocketPoolAcquire::Connect(lease)
|
||||
} else {
|
||||
WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap)
|
||||
}
|
||||
let lease = WebSocketPoolConnectLease::reserve(
|
||||
self.clone(),
|
||||
key.clone(),
|
||||
connect_permit,
|
||||
);
|
||||
state.slots.insert(
|
||||
key,
|
||||
WebSocketPoolSlot::Connecting(WebSocketPoolConnecting {
|
||||
id: lease.id,
|
||||
started_at: lease.started_at,
|
||||
outcome: lease.outcome.clone(),
|
||||
cancellation: lease.cancellation.clone(),
|
||||
}),
|
||||
);
|
||||
WebSocketPoolAcquire::Connect(lease)
|
||||
}
|
||||
None => WebSocketPoolAcquire::Bypass(WebSocketPoolBypassReason::Cap),
|
||||
}
|
||||
@@ -292,7 +265,6 @@ impl CodexWebSocketPool {
|
||||
key.clone(),
|
||||
WebSocketPoolSlot::Idle {
|
||||
connection: Box::new(connection),
|
||||
last_used_at: Instant::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,6 @@ impl WebSocketContinuationState {
|
||||
pub(super) enum WebSocketPoolSlot {
|
||||
Idle {
|
||||
connection: Box<PooledWebSocketConnection>,
|
||||
last_used_at: Instant,
|
||||
},
|
||||
Busy(WebSocketPoolReservation),
|
||||
Connecting(WebSocketPoolConnecting),
|
||||
@@ -138,34 +137,6 @@ pub(super) struct WebSocketPoolReservation {
|
||||
pub(super) latest_response_id: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn account_slot_count(
|
||||
slots: &HashMap<CodexWebSocketPoolKey, WebSocketPoolSlot>,
|
||||
account_id: &str,
|
||||
) -> usize {
|
||||
slots
|
||||
.keys()
|
||||
.filter(|key| key.account_id() == account_id)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub(super) fn take_lru_idle_connection(
|
||||
state: &mut WebSocketPoolState,
|
||||
) -> Option<PooledWebSocketConnection> {
|
||||
let key = state
|
||||
.slots
|
||||
.iter()
|
||||
.filter_map(|(key, slot)| match slot {
|
||||
WebSocketPoolSlot::Idle { last_used_at, .. } => Some((key, *last_used_at)),
|
||||
WebSocketPoolSlot::Busy(_) | WebSocketPoolSlot::Connecting(_) => None,
|
||||
})
|
||||
.min_by_key(|(_, last_used_at)| *last_used_at)
|
||||
.map(|(key, _)| key.clone())?;
|
||||
let Some(WebSocketPoolSlot::Idle { connection, .. }) = state.slots.remove(&key) else {
|
||||
return None;
|
||||
};
|
||||
Some(*connection)
|
||||
}
|
||||
|
||||
pub(super) async fn close_pooled_connection(connection: PooledWebSocketConnection) {
|
||||
connection.websocket.close().await;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::num::NonZeroU32;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use futures::{StreamExt, future::BoxFuture};
|
||||
use gateway_admin::model::accounts::AccountRecord;
|
||||
use gateway_admin::model::observability::{
|
||||
CurrencyCost, DesktopReleaseStatus, ProviderBillingInput,
|
||||
@@ -23,7 +23,12 @@ use gateway_core::engine::credential::{
|
||||
CredentialRevision, CredentialState, OpaqueProviderData, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaObservation, QuotaState,
|
||||
};
|
||||
use gateway_core::engine::provider::ProviderRequest;
|
||||
use gateway_core::engine::{
|
||||
AccountAttemptContext, AttemptContext, CancellationToken, ModelRequestId, RequestAttemptContext,
|
||||
};
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::provider_ports::{
|
||||
NewOAuthPendingFlow, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
|
||||
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome,
|
||||
@@ -32,7 +37,11 @@ use gateway_core::provider_ports::{
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderRefreshPolicy,
|
||||
ProviderRuntimePolicyPort, ProviderScopedCooldown, ProviderStoreError, ProviderStorePorts,
|
||||
};
|
||||
use gateway_core::routing::{ProviderKind, UpstreamModelId};
|
||||
use gateway_core::routing::{
|
||||
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
|
||||
ProviderModel, PublicModelId, RoutingContext, RuntimeAccount, RuntimeAccountDirectory,
|
||||
RuntimeSnapshot, UpstreamModelId,
|
||||
};
|
||||
use gateway_core::task::{WorkerContribution, WorkerKind, WorkerRunnable};
|
||||
use provider_openai::config::{CodexWireProfileConfig, OpenAiConfig};
|
||||
use provider_openai::credential::{CodexCredentialCodec, ImportCodexOAuthCredential};
|
||||
@@ -46,9 +55,14 @@ use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{
|
||||
MemoryAccountStore, MemorySessionAffinity, MemorySessionExclusions, TestLeaseCoordinator,
|
||||
profile, secret,
|
||||
account_policy, profile, secret,
|
||||
};
|
||||
|
||||
const COMPLETED_SESSION_SSE: &str = concat!(
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_initialized_session\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
|
||||
);
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions_once() {
|
||||
let config = valid_config();
|
||||
@@ -97,6 +111,84 @@ async fn openai_bundle_exposes_one_core_provider_and_drains_worker_contributions
|
||||
assert!(bundle.take_worker_contributions().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialized_provider_keeps_thread_spawn_transport_conversations_distinct() {
|
||||
let account_id = "acct_initialized_thread_spawn";
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: account_id.to_owned(),
|
||||
name: account_id.to_owned(),
|
||||
secret: secret("at-initialized-thread-spawn"),
|
||||
verified_account: profile("chatgpt-initialized-thread-spawn"),
|
||||
next_refresh_at: Some(Utc::now() + chrono::Duration::minutes(30)),
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_string(COMPLETED_SESSION_SSE),
|
||||
)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut config = valid_config();
|
||||
config.config.api.base_url = server.uri();
|
||||
let bundle = provider_openai::initialize(
|
||||
config.config.clone(),
|
||||
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.expect("initialized OpenAI provider");
|
||||
let provider = bundle.core_provider();
|
||||
let thread_spawn = r#"{"subagent_kind":"thread_spawn"}"#;
|
||||
let mut conversation_ids = Vec::new();
|
||||
|
||||
for (request_id, thread_id) in [
|
||||
("req_initialized_thread_spawn_first", "child-one"),
|
||||
("req_initialized_thread_spawn_second", "child-two"),
|
||||
] {
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("child task")),
|
||||
("session_id".to_owned(), json!("parent-session")),
|
||||
("thread_id".to_owned(), json!(thread_id)),
|
||||
("turnMetadata".to_owned(), json!(thread_spawn)),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
|
||||
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(payload));
|
||||
let mut stream = provider
|
||||
.execute(
|
||||
initialized_provider_request(operation, account_id),
|
||||
initialized_attempt_context(request_id, account_id),
|
||||
)
|
||||
.await
|
||||
.expect("prepare child provider stream");
|
||||
let mut conversation_id = None;
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event.expect("child provider response");
|
||||
if let Some(update) = event.session_update() {
|
||||
conversation_id = update
|
||||
.payload()
|
||||
.get("conversation_id")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned);
|
||||
}
|
||||
}
|
||||
conversation_ids.push(conversation_id.expect("child transport conversation id"));
|
||||
}
|
||||
|
||||
assert_ne!(conversation_ids[0], conversation_ids[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_admin_provider_exposes_live_wire_profile_and_validated_billing() {
|
||||
let config = valid_config();
|
||||
@@ -921,6 +1013,63 @@ fn reset_credit_command(account_id: ProviderAccountId) -> ConsumeProviderResetCr
|
||||
}
|
||||
}
|
||||
|
||||
fn initialized_provider_request(operation: Operation, account_id: &str) -> ProviderRequest {
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let upstream_model = UpstreamModelId::new("gpt-5.4").expect("upstream model");
|
||||
let public_model = PublicModelId::new(upstream_model.as_str()).expect("public model");
|
||||
let account_scope = initialized_account_scope(account_id);
|
||||
let snapshot = RuntimeSnapshot::new(
|
||||
ConfigRevision::new(1).expect("revision"),
|
||||
account_policy(),
|
||||
vec![provider.clone()],
|
||||
vec![ProviderModel::new(
|
||||
provider,
|
||||
upstream_model,
|
||||
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000))
|
||||
.with_upstream_feature_validation(),
|
||||
)],
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("runtime snapshot");
|
||||
let plan = snapshot
|
||||
.plan(
|
||||
&public_model,
|
||||
&operation,
|
||||
account_scope,
|
||||
&RoutingContext::default(),
|
||||
)
|
||||
.expect("routing plan");
|
||||
|
||||
ProviderRequest::new(operation, plan.candidates()[0].clone())
|
||||
}
|
||||
|
||||
fn initialized_attempt_context(request_id: &str, account_id: &str) -> AttemptContext {
|
||||
AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new(request_id).expect("request id"),
|
||||
ClientApiKeyId::new("key_openai_initialized").expect("client key id"),
|
||||
),
|
||||
NonZeroU32::new(1).expect("attempt"),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(BTreeSet::new(), None, None)
|
||||
.with_account_scope(initialized_account_scope(account_id)),
|
||||
None,
|
||||
CancellationToken::new(),
|
||||
)
|
||||
}
|
||||
|
||||
fn initialized_account_scope(account_id: &str) -> Arc<FrozenAccountScope> {
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
Arc::new(FrozenAccountScope::new(
|
||||
Arc::new(RuntimeAccountDirectory::new(BTreeMap::from([(
|
||||
ProviderAccountId::new(account_id).expect("account id"),
|
||||
RuntimeAccount::new(provider, BTreeSet::new()),
|
||||
)]))),
|
||||
ClientRoutingScope::all_accounts(),
|
||||
))
|
||||
}
|
||||
|
||||
fn provider_ports() -> ProviderStorePorts {
|
||||
provider_ports_with(
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
|
||||
@@ -101,8 +101,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
|
||||
config.api.base_url.as_str(),
|
||||
config.ws_pool.enabled,
|
||||
config.ws_pool.max_age_ms,
|
||||
config.ws_pool.max_per_account,
|
||||
config.ws_pool.max_total,
|
||||
config.ws_pool.max_connecting,
|
||||
config.ws_pool.initial_event_timeout_ms,
|
||||
config.quota.refresh_interval_minutes,
|
||||
@@ -115,8 +113,6 @@ fn openai_config_defaults_to_the_provider_owned_operating_values() {
|
||||
true,
|
||||
3_300_000,
|
||||
8,
|
||||
64,
|
||||
8,
|
||||
20_000,
|
||||
15,
|
||||
true,
|
||||
|
||||
@@ -445,6 +445,74 @@ fn selector_round_robin_cursor_advances_across_requests() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selector_should_claim_the_initial_session_account_before_upstream_send() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_first", "at-first");
|
||||
create_account(&store, "acct_second", "at-second");
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
let selector = selector_with_affinity(
|
||||
&store,
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
Arc::clone(&affinity),
|
||||
);
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let key = ProviderSessionAffinityKey::try_new("initial-claim").expect("affinity key");
|
||||
let request_attempt = attempt(BTreeSet::new());
|
||||
|
||||
let selected = selector
|
||||
.select(&SelectCodexCredential {
|
||||
upstream_model: "gpt-5.4",
|
||||
request_url: &Url::parse("https://chatgpt.com/backend-api/codex/responses")
|
||||
.expect("request URL"),
|
||||
attempt: &request_attempt,
|
||||
session_affinity_key: Some(&key),
|
||||
})
|
||||
.await
|
||||
.expect("select initial account");
|
||||
|
||||
assert_eq!(
|
||||
affinity
|
||||
.load(&provider, &key)
|
||||
.await
|
||||
.expect("load claimed affinity"),
|
||||
Some(selected.account_id().clone())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_success_should_not_overwrite_a_newer_session_winner() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_first", "at-first");
|
||||
create_account(&store, "acct_second", "at-second");
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
let selector = selector_with_affinity(
|
||||
&store,
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
Arc::clone(&affinity),
|
||||
);
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let key = ProviderSessionAffinityKey::try_new("late-success").expect("affinity key");
|
||||
let first = store.account("acct_first").expect("first account");
|
||||
let second = ProviderAccountId::new("acct_second").expect("second account");
|
||||
affinity
|
||||
.bind(&provider, &key, &second, Duration::from_secs(60))
|
||||
.await
|
||||
.expect("seed newer affinity");
|
||||
|
||||
selector
|
||||
.record_success(&first, Some(&key), first.id())
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
affinity
|
||||
.load(&provider, &key)
|
||||
.await
|
||||
.expect("load preserved affinity"),
|
||||
Some(second)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selector_should_reuse_the_account_bound_to_the_same_session() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -469,7 +537,9 @@ async fn selector_should_reuse_the_account_bound_to_the_same_session() {
|
||||
})
|
||||
.await
|
||||
.expect("select first account");
|
||||
selector.record_success(first.account(), Some(&key)).await;
|
||||
selector
|
||||
.record_success(first.account(), Some(&key), first.account_id())
|
||||
.await;
|
||||
let first_account = first.account_id().clone();
|
||||
|
||||
let second_attempt = attempt(BTreeSet::new());
|
||||
@@ -535,7 +605,7 @@ async fn selector_should_replace_a_busy_affinity_binding_after_the_fallback_succ
|
||||
.await
|
||||
.expect("select fallback account");
|
||||
selector
|
||||
.record_success(selected.account(), Some(&key))
|
||||
.record_success(selected.account(), Some(&key), &bound)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
@@ -1005,7 +1075,7 @@ fn successful_upstream_response_recovers_non_quota_terminal_states() {
|
||||
let current = store.account("acct_primary").expect("stale account");
|
||||
let selector = selector(&store, Arc::new(TestLeaseCoordinator::default()));
|
||||
|
||||
block_on(selector.record_success(¤t, None));
|
||||
block_on(selector.record_success(¤t, None, current.id()));
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
@@ -1289,7 +1359,7 @@ fn cloudflare_challenge_does_not_change_persisted_account_facts() {
|
||||
.credential_state(),
|
||||
CredentialState::Ready
|
||||
);
|
||||
block_on(selector.record_success(lease.account(), None));
|
||||
block_on(selector.record_success(lease.account(), None, lease.account_id()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -344,6 +344,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
"acct_local_affinity",
|
||||
"acct_metadata_new",
|
||||
"acct_metadata_old",
|
||||
"acct_unknown_continuation",
|
||||
"acct_unknown_turn_state",
|
||||
"acct_prefetch_limit",
|
||||
"acct_presentation",
|
||||
"acct_provider_contract",
|
||||
@@ -352,7 +354,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
"acct_scope_same",
|
||||
"acct_semantic_failure",
|
||||
"acct_session_affinity",
|
||||
"acct_subagent",
|
||||
"acct_subagent_a",
|
||||
"acct_subagent_b",
|
||||
"acct_success_exhausted",
|
||||
"acct_thread_spawn_affinity",
|
||||
"acct_truncated_stream",
|
||||
@@ -456,6 +459,24 @@ fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContex
|
||||
.with_continuation_attempt(ContinuationAttempt::ReplayAny)
|
||||
}
|
||||
|
||||
fn external_continuation_context(request_id: &str) -> AttemptContext {
|
||||
AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new(request_id).expect("request id"),
|
||||
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
|
||||
),
|
||||
NonZeroU32::new(1).expect("attempt"),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(BTreeSet::new(), None, None)
|
||||
.with_account_scope(contract_account_scope()),
|
||||
Some(ContinuationBinding::External(PreviousResponseId::new(
|
||||
"external-previous-response",
|
||||
))),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn capture_scoped_http_request(
|
||||
request_id: &str,
|
||||
selected_account_id: &str,
|
||||
@@ -1319,6 +1340,53 @@ async fn cross_account_scope_removes_only_account_bound_body_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_account_full_replay_should_preserve_the_complete_transcript() {
|
||||
let transcript = json!([
|
||||
{
|
||||
"type": "reasoning",
|
||||
"id": "reasoning-item-id",
|
||||
"encrypted_content": "reasoning-ciphertext",
|
||||
"summary": []
|
||||
},
|
||||
{
|
||||
"type": "compaction",
|
||||
"id": "compaction-item-id",
|
||||
"encrypted_content": "compaction-ciphertext"
|
||||
},
|
||||
{
|
||||
"type": "agent_message",
|
||||
"id": "agent-message-id",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "visible"},
|
||||
{"type": "encrypted_content", "encrypted_content": "nested-ciphertext"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "function-call-id",
|
||||
"call_id": "call-id",
|
||||
"name": "tool",
|
||||
"arguments": "{}",
|
||||
"encrypted_function_args": "function-args-ciphertext"
|
||||
}
|
||||
]);
|
||||
let request = capture_scoped_http_request(
|
||||
"req_scope_transcript",
|
||||
"acct_scope_new",
|
||||
"acct_scope_old",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), transcript.clone()),
|
||||
]),
|
||||
Map::new(),
|
||||
)
|
||||
.await;
|
||||
let body: Value = captured_request_body(&request);
|
||||
|
||||
assert_eq!(body.get("input"), Some(&transcript));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
|
||||
let request = capture_scoped_http_request(
|
||||
@@ -1359,40 +1427,19 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_account_continuation_probe_should_preserve_delta_input_and_previous_response() {
|
||||
async fn cross_account_continuation_should_require_client_replay_without_an_upstream_probe() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_scope_new").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_string(CAPTURE_COMPLETED_SSE),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let delta_input = json!([
|
||||
{
|
||||
"type": "message",
|
||||
"id": "delta-message-id",
|
||||
"encrypted_content": "opaque-delta-ciphertext",
|
||||
"role": "user",
|
||||
"content": "current turn only"
|
||||
},
|
||||
{
|
||||
"type": "compaction",
|
||||
"id": "delta-compaction-id",
|
||||
"encrypted_content": "opaque-compaction-ciphertext"
|
||||
}
|
||||
]);
|
||||
let generation = GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), delta_input.clone()),
|
||||
(
|
||||
"input".to_owned(),
|
||||
json!([{"role": "user", "content": "delta"}]),
|
||||
),
|
||||
(
|
||||
"previous_response_id".to_owned(),
|
||||
json!("client-previous-response"),
|
||||
@@ -1432,41 +1479,137 @@ async fn cross_account_continuation_probe_should_preserve_delta_input_and_previo
|
||||
)
|
||||
.expect("provider session state"),
|
||||
);
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
let result = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", Operation::Generate(generation)),
|
||||
replay_any_context("req_cross_account_probe", "acct_scope_old"),
|
||||
)
|
||||
.await;
|
||||
let Err(error) = result else {
|
||||
panic!("cross-account continuation must require a full client replay");
|
||||
};
|
||||
let detail = error
|
||||
.client_visible_upstream_error()
|
||||
.expect("client replay error detail");
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("prepare continuation probe stream");
|
||||
.expect("captured upstream requests");
|
||||
|
||||
assert_eq!(
|
||||
(
|
||||
error.kind(),
|
||||
error.send_state(),
|
||||
error.continuation_failure(),
|
||||
detail.code(),
|
||||
detail.error_type(),
|
||||
),
|
||||
(
|
||||
ProviderErrorKind::InvalidRequest,
|
||||
UpstreamSendState::NotSent,
|
||||
Some(ContinuationFailure::HistoryUnavailable),
|
||||
Some("previous_response_not_found"),
|
||||
Some("invalid_request_error"),
|
||||
)
|
||||
);
|
||||
assert!(requests.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_affinity_continuation_should_require_client_replay_without_an_upstream_probe() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_unknown_continuation").await;
|
||||
let server = MockServer::start().await;
|
||||
let generation = GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
(
|
||||
"input".to_owned(),
|
||||
json!([{"role": "user", "content": "delta"}]),
|
||||
),
|
||||
(
|
||||
"previous_response_id".to_owned(),
|
||||
json!("external-previous-response"),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([
|
||||
("use_websocket".to_owned(), json!(false)),
|
||||
("session_id".to_owned(), json!("expired-affinity-session")),
|
||||
])),
|
||||
);
|
||||
let result = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", Operation::Generate(generation)),
|
||||
external_continuation_context("req_unknown_continuation"),
|
||||
)
|
||||
.await;
|
||||
let Err(error) = result else {
|
||||
panic!("unowned continuation must require a full client replay");
|
||||
};
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("captured upstream requests");
|
||||
|
||||
assert_eq!(
|
||||
error
|
||||
.client_visible_upstream_error()
|
||||
.and_then(|detail| detail.code()),
|
||||
Some("previous_response_not_found")
|
||||
);
|
||||
assert!(requests.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_affinity_full_request_should_clear_unowned_turn_state() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_unknown_turn_state").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "text/event-stream")
|
||||
.set_body_string(CAPTURE_COMPLETED_SSE),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let generation = GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("full transcript")),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([
|
||||
("use_websocket".to_owned(), json!(false)),
|
||||
("session_id".to_owned(), json!("expired-turn-state-session")),
|
||||
("turn_id".to_owned(), json!("turn-after-affinity-expiry")),
|
||||
("turn_state".to_owned(), json!("unowned-turn-state")),
|
||||
])),
|
||||
);
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", Operation::Generate(generation)),
|
||||
context("req_unknown_turn_state", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare full replay stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("continuation probe response");
|
||||
event.expect("full replay response");
|
||||
}
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("captured continuation probe request");
|
||||
let body: Value = captured_request_body(&requests[0]);
|
||||
.expect("captured full replay request");
|
||||
|
||||
assert_eq!(body.get("input"), Some(&delta_input));
|
||||
assert_eq!(
|
||||
body.get("previous_response_id"),
|
||||
Some(&json!("upstream-previous-response"))
|
||||
);
|
||||
assert!(body.get("authorization").is_none());
|
||||
assert!(body.get("turnMetadata").is_none());
|
||||
assert!(
|
||||
body.pointer("/client_metadata/x-codex-turn-state")
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
body.pointer("/client_metadata/x-codex-turn-metadata")
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
body.pointer("/client_metadata/future"),
|
||||
Some(&json!("keep"))
|
||||
);
|
||||
assert!(captured_header_values(&requests[0], "x-codex-turn-state").is_empty());
|
||||
}
|
||||
|
||||
@@ -1637,9 +1780,10 @@ async fn prompt_cache_key_should_become_an_opaque_session_affinity_lookup_key()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn subagent_requests_should_use_an_affinity_key_isolated_from_the_root_session() {
|
||||
async fn subagent_requests_should_share_the_root_session_account_affinity_key() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_subagent").await;
|
||||
create_account(&store, "acct_subagent_a").await;
|
||||
create_account(&store, "acct_subagent_b").await;
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
@@ -1689,10 +1833,25 @@ async fn subagent_requests_should_use_an_affinity_key_isolated_from_the_root_ses
|
||||
|
||||
let keys = affinity.lookup_keys();
|
||||
assert_eq!(keys.len(), 3);
|
||||
assert_ne!(keys[0], keys[1], "subagent must not inherit the root key");
|
||||
assert_eq!(
|
||||
keys[1], keys[2],
|
||||
"the same subagent should retain its own key"
|
||||
assert!(
|
||||
keys.iter().all(|key| key == &keys[0]),
|
||||
"root and derived subagent requests must prefer the same account"
|
||||
);
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("captured root and subagent requests");
|
||||
let selected_accounts = requests
|
||||
.iter()
|
||||
.filter_map(|request| request.headers.get("chatgpt-account-id"))
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(selected_accounts.len(), 3);
|
||||
assert!(
|
||||
selected_accounts
|
||||
.iter()
|
||||
.all(|account| account == &selected_accounts[0]),
|
||||
"root and subagents should route to the same preferred account"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1867,7 +2026,7 @@ async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_st
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
|
||||
async fn thread_spawn_children_should_share_the_parent_account_affinity_key() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_thread_spawn_affinity").await;
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
@@ -1912,10 +2071,10 @@ async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
|
||||
|
||||
let keys = affinity.lookup_keys();
|
||||
assert_eq!(keys.len(), 5);
|
||||
assert_ne!(keys[0], keys[1]);
|
||||
assert_eq!(keys[0], keys[2]);
|
||||
assert_ne!(keys[3], keys[4]);
|
||||
assert_ne!(keys[0], keys[3]);
|
||||
assert!(
|
||||
keys.iter().all(|key| key == &keys[0]),
|
||||
"child thread transport identities must not split parent account affinity"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2539,7 +2698,7 @@ async fn bare_response_failed_should_remain_an_atomic_replay_safe_failure() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previous_response_not_found_should_remain_client_visible_without_internal_replay_proof() {
|
||||
async fn same_account_previous_response_not_found_should_remain_client_visible() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_client_history").await;
|
||||
let server = MockServer::start().await;
|
||||
@@ -2578,7 +2737,7 @@ async fn previous_response_not_found_should_remain_client_visible_without_intern
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
|
||||
),
|
||||
context("req_history_missing", CancellationToken::new()),
|
||||
context_with_state_owner("req_history_missing", "acct_client_history"),
|
||||
)
|
||||
.await
|
||||
.expect("prepare missing-history stream");
|
||||
|
||||
@@ -678,6 +678,55 @@ impl ProviderSessionAffinityPort for MemorySessionAffinity {
|
||||
})
|
||||
}
|
||||
|
||||
fn claim_or_load<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
candidate_account_id: &'a ProviderAccountId,
|
||||
_ttl: Duration,
|
||||
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let mut bindings = self.bindings.lock().expect("session affinity lock");
|
||||
Ok(bindings
|
||||
.entry((
|
||||
provider_kind.as_str().to_owned(),
|
||||
key.expose_to_store().to_owned(),
|
||||
))
|
||||
.or_insert_with(|| candidate_account_id.clone())
|
||||
.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn compare_and_bind<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
key: &'a ProviderSessionAffinityKey,
|
||||
expected_account_id: &'a ProviderAccountId,
|
||||
replacement_account_id: &'a ProviderAccountId,
|
||||
_ttl: Duration,
|
||||
) -> BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move {
|
||||
let binding_key = (
|
||||
provider_kind.as_str().to_owned(),
|
||||
key.expose_to_store().to_owned(),
|
||||
);
|
||||
let mut bindings = self.bindings.lock().expect("session affinity lock");
|
||||
if bindings
|
||||
.get(&binding_key)
|
||||
.is_none_or(|current| current == expected_account_id)
|
||||
{
|
||||
bindings.insert(binding_key, replacement_account_id.clone());
|
||||
return Ok(replacement_account_id.clone());
|
||||
}
|
||||
bindings.get(&binding_key).cloned().ok_or_else(|| {
|
||||
ProviderStoreError::new(
|
||||
gateway_core::provider_ports::ProviderStoreErrorKind::Unavailable,
|
||||
"resolve in-memory provider session affinity",
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn clear<'a>(
|
||||
&'a self,
|
||||
provider_kind: &'a ProviderKind,
|
||||
|
||||
@@ -265,7 +265,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
|
||||
format!("http://{address}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
|
||||
|
||||
let response = backend
|
||||
.create_response(
|
||||
@@ -480,7 +480,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
|
||||
format!("http://{address}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1))));
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
|
||||
|
||||
client
|
||||
.create_response(
|
||||
@@ -932,7 +932,7 @@ async fn websocket_should_keep_an_exact_chain_while_new_connections_adopt_the_la
|
||||
});
|
||||
|
||||
let profile = test_wire_profile();
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let client = CodexBackendClient::new(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
|
||||
@@ -300,7 +300,7 @@ async fn account_eviction_should_cancel_a_background_websocket_opening() {
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -354,7 +354,7 @@ async fn pool_shutdown_should_cancel_and_join_a_background_websocket_opening() {
|
||||
.unwrap();
|
||||
assert_eq!(read, 0);
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -733,7 +733,7 @@ async fn concurrent_multi_conversation_continuations_should_select_each_exact_so
|
||||
tokio::join!(first_continuation, second_continuation);
|
||||
});
|
||||
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -1190,7 +1190,7 @@ async fn cancelled_half_open_opening_should_allow_another_probe() {
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -1325,7 +1325,7 @@ async fn fast_path_miss_and_late_failure_should_count_as_one_breaker_failure() {
|
||||
write_completed_sse_response(&mut second_http).await;
|
||||
}
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
|
||||
@@ -495,8 +495,6 @@ fn websocket_pool_config_for_tests(
|
||||
CodexWebSocketPoolConfig {
|
||||
enabled: true,
|
||||
max_age: Duration::from_mins(1),
|
||||
max_per_account: 8,
|
||||
max_total: 64,
|
||||
max_connecting: 16,
|
||||
maintenance_interval,
|
||||
ping_interval,
|
||||
|
||||
@@ -126,7 +126,7 @@ async fn codex_backend_client_should_decode_permessage_deflate_context_takeover_
|
||||
websocket.send(Message::Text(payload.into())).await.unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -932,7 +932,7 @@ async fn codex_backend_client_should_not_reuse_websocket_rate_limit_headers() {
|
||||
.unwrap();
|
||||
websocket.close(None).await.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -1914,7 +1914,7 @@ async fn codex_backend_client_should_use_websocket_when_previous_response_id_is_
|
||||
let mut request = codex_request("gpt-5.5", "be brief", Vec::new());
|
||||
request.set_previous_response_id(Some("resp_previous".to_string()));
|
||||
request.previous_response_scope = Some(PreviousResponseScope::Persisted);
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
|
||||
@@ -35,7 +35,7 @@ async fn codex_backend_client_should_reuse_pooled_websocket_for_same_account_and
|
||||
}
|
||||
websocket.close(None).await.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -97,7 +97,7 @@ async fn exact_continuation_reuses_owning_socket_after_connection_profile_update
|
||||
format!("http://{addr}"),
|
||||
profile.clone(),
|
||||
)
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1))));
|
||||
.with_websocket_pool(Arc::new(CodexWebSocketPool::new(Duration::from_mins(1))));
|
||||
let first_request = pooled_websocket_request("wire-profile-continuation");
|
||||
let first = backend
|
||||
.create_response(
|
||||
@@ -401,7 +401,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
|
||||
async fn codex_backend_client_stream_should_use_http_when_connecting_limit_is_exhausted() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let accepted_connections = Arc::new(AtomicUsize::new(0));
|
||||
@@ -414,7 +414,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
|
||||
write_completed_sse_response(&mut first_stream).await;
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
|
||||
max_per_account: 0,
|
||||
max_connecting: 0,
|
||||
initial_event_timeout: Some(Duration::from_millis(30)),
|
||||
..websocket_pool_config_for_tests(None, None, None)
|
||||
}));
|
||||
@@ -432,7 +432,7 @@ async fn codex_backend_client_stream_should_use_http_when_pool_is_at_cap() {
|
||||
request_context("req_structural_bypass", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("pool cap should select HTTP before sending payload");
|
||||
.expect("exhausted connection-opening limit should select HTTP before sending payload");
|
||||
assert_eq!(response.transport, CodexBackendTransport::HttpSse);
|
||||
assert_eq!(
|
||||
response.transport_metrics.decision,
|
||||
@@ -472,7 +472,7 @@ async fn codex_backend_client_should_not_reuse_pooled_websocket_across_local_acc
|
||||
websocket.close(None).await.unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -841,140 +841,32 @@ async fn websocket_pool_shutdown_should_cancel_and_join_active_stream() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_use_http_for_new_keys_after_account_cap() {
|
||||
async fn websocket_pool_should_keep_sixty_fifth_conversation_on_same_account_on_websocket() {
|
||||
const REQUEST_COUNT: usize = 65;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let accepted_connections = Arc::new(AtomicUsize::new(0));
|
||||
let accepted_connections_for_server = Arc::clone(&accepted_connections);
|
||||
let server = tokio::spawn(async move {
|
||||
let (first_stream, _) = listener.accept().await.unwrap();
|
||||
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
let mut first_websocket = accept_codex_test_websocket(first_stream).await;
|
||||
let _first_message = first_websocket.next().await.unwrap().unwrap();
|
||||
first_websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_cap_first", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let mut websockets = Vec::with_capacity(REQUEST_COUNT);
|
||||
for index in 0..REQUEST_COUNT {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
accepted_connections_for_server.fetch_add(1, Ordering::SeqCst);
|
||||
let request = read_http_request(&mut stream).await;
|
||||
assert!(request.starts_with("POST /codex/responses HTTP/1.1"));
|
||||
write_completed_sse_response(&mut stream).await;
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let _message = websocket.next().await.unwrap().unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response(&format!("resp_same_account_{index}"), 2, 1)
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
websockets.push(websocket);
|
||||
}
|
||||
drop(first_websocket);
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(1, Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(pool);
|
||||
let first_request = pooled_websocket_request("conversation-cap-one");
|
||||
let second_request = pooled_websocket_request("conversation-cap-two");
|
||||
|
||||
let first = backend
|
||||
.create_response(
|
||||
&first_request,
|
||||
request_context("req_cap_first", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("first capped websocket response should succeed");
|
||||
let second = backend
|
||||
.create_response(
|
||||
&second_request,
|
||||
request_context("req_cap_second", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("new key over account cap should use HTTP");
|
||||
let third = backend
|
||||
.create_response(
|
||||
&second_request,
|
||||
request_context("req_cap_third", Some("chatgpt-account")),
|
||||
)
|
||||
.await
|
||||
.expect("capped key should keep using HTTP instead of opening one-shot sockets");
|
||||
server.await.unwrap();
|
||||
|
||||
assert!(first.body.contains("resp_cap_first"));
|
||||
assert!(second.body.contains("response.completed"));
|
||||
assert!(third.body.contains("response.completed"));
|
||||
assert_eq!(first.websocket_pool_decision.unwrap().kind(), "new");
|
||||
assert_eq!(
|
||||
second.transport_metrics.decision,
|
||||
Some(CodexTransportDecision::Http2PoolUnavailable)
|
||||
);
|
||||
assert_eq!(
|
||||
third.transport_metrics.decision,
|
||||
Some(CodexTransportDecision::Http2PoolUnavailable)
|
||||
);
|
||||
assert_eq!(accepted_connections.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
|
||||
let (websockets, http) = concurrent_pool_transport_counts(8, 2).await;
|
||||
|
||||
assert_eq!((websockets, http), (2, 6));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn websocket_pool_should_bound_total_slots_across_accounts() {
|
||||
let (websockets, http) = concurrent_pool_transport_counts(3, 8).await;
|
||||
|
||||
assert_eq!((websockets, http), (3, 5));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (first_stream, _) = listener.accept().await.unwrap();
|
||||
let mut first = accept_codex_test_websocket(first_stream).await;
|
||||
let _ = first.next().await.unwrap().unwrap();
|
||||
first
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_global_lru_first", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (second_stream, _) = listener.accept().await.unwrap();
|
||||
let mut second = accept_codex_test_websocket(second_stream).await;
|
||||
let _ = second.next().await.unwrap().unwrap();
|
||||
second
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_global_lru_second", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let close = timeout(Duration::from_secs(2), first.next())
|
||||
.await
|
||||
.expect("global LRU eviction should close the oldest idle socket")
|
||||
.expect("oldest idle socket should emit a close frame")
|
||||
.expect("oldest idle socket close should be valid");
|
||||
std::assert_matches!(close, Message::Close(_));
|
||||
|
||||
let (third_stream, _) = listener.accept().await.unwrap();
|
||||
let mut third = accept_codex_test_websocket(third_stream).await;
|
||||
let _ = third.next().await.unwrap().unwrap();
|
||||
third
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_global_lru_third", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
|
||||
max_per_account: 8,
|
||||
max_total: 2,
|
||||
max_connecting: 2,
|
||||
max_connecting: 1,
|
||||
..websocket_pool_config_for_tests(None, None, None)
|
||||
}));
|
||||
let backend = CodexBackendClient::new(
|
||||
@@ -983,36 +875,52 @@ async fn websocket_pool_should_evict_lru_idle_connection_at_total_cap() {
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
|
||||
let mut responses = Vec::new();
|
||||
for index in 0..3 {
|
||||
let request = pooled_websocket_request(&format!("conversation-global-lru-{index}"));
|
||||
let account_id = format!("account-global-lru-{index}");
|
||||
let request_id = format!("req_global_lru_{index}");
|
||||
let mut responses = Vec::with_capacity(REQUEST_COUNT);
|
||||
for index in 0..REQUEST_COUNT {
|
||||
let request = pooled_websocket_request(&format!("conversation-same-account-{index}"));
|
||||
responses.push(
|
||||
backend
|
||||
.create_response(&request, request_context(&request_id, Some(&account_id)))
|
||||
.create_response(
|
||||
&request,
|
||||
request_context(
|
||||
&format!("req_same_account_{index}"),
|
||||
Some("chatgpt-account"),
|
||||
),
|
||||
)
|
||||
.await
|
||||
.expect("global LRU request should keep using websocket"),
|
||||
.expect("same-account conversation should use websocket"),
|
||||
);
|
||||
}
|
||||
server.await.unwrap();
|
||||
|
||||
assert!(responses[0].body.contains("resp_global_lru_first"));
|
||||
assert!(responses[1].body.contains("resp_global_lru_second"));
|
||||
assert!(responses[2].body.contains("resp_global_lru_third"));
|
||||
assert!(
|
||||
responses
|
||||
.iter()
|
||||
.all(|response| response.transport == CodexBackendTransport::WebSocket)
|
||||
assert_eq!(
|
||||
(
|
||||
responses
|
||||
.iter()
|
||||
.filter(|response| response.transport == CodexBackendTransport::WebSocket)
|
||||
.count(),
|
||||
accepted_connections.load(Ordering::SeqCst),
|
||||
),
|
||||
(REQUEST_COUNT, REQUEST_COUNT)
|
||||
);
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
async fn concurrent_pool_transport_counts(
|
||||
max_total: usize,
|
||||
max_connecting: usize,
|
||||
) -> (usize, usize) {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn websocket_pool_should_bound_concurrent_openings_across_accounts() {
|
||||
let (websockets, http) = concurrent_pool_transport_counts(2).await;
|
||||
|
||||
assert_eq!((websockets, http), (2, 6));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn websocket_pool_should_allow_all_openings_when_connect_permits_are_available() {
|
||||
let (websockets, http) = concurrent_pool_transport_counts(8).await;
|
||||
|
||||
assert_eq!((websockets, http), (8, 0));
|
||||
}
|
||||
|
||||
async fn concurrent_pool_transport_counts(max_connecting: usize) -> (usize, usize) {
|
||||
const REQUEST_COUNT: usize = 8;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
@@ -1051,7 +959,7 @@ async fn concurrent_pool_transport_counts(
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response(
|
||||
&format!("resp_global_cap_{max_total}_{max_connecting}_{index}"),
|
||||
&format!("resp_connecting_cap_{max_connecting}_{index}"),
|
||||
2,
|
||||
1,
|
||||
)
|
||||
@@ -1063,8 +971,6 @@ async fn concurrent_pool_transport_counts(
|
||||
(websocket_count, http_count)
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig {
|
||||
max_per_account: REQUEST_COUNT,
|
||||
max_total,
|
||||
max_connecting,
|
||||
..websocket_pool_config_for_tests(None, None, None)
|
||||
}));
|
||||
@@ -1096,7 +1002,7 @@ async fn concurrent_pool_transport_counts(
|
||||
let response = request
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("globally capped request should complete");
|
||||
.expect("concurrent request should complete");
|
||||
if response.transport == CodexBackendTransport::WebSocket {
|
||||
websocket_responses += 1;
|
||||
}
|
||||
@@ -1910,7 +1816,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_error_termin
|
||||
.unwrap();
|
||||
second_websocket.close(None).await.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
@@ -1987,7 +1893,7 @@ async fn codex_backend_client_should_discard_pooled_websocket_after_unknown_resp
|
||||
.unwrap();
|
||||
second_websocket.close(None).await.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::new(8, Duration::from_mins(1)));
|
||||
let pool = Arc::new(CodexWebSocketPool::new(Duration::from_mins(1)));
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
|
||||
@@ -713,6 +713,27 @@ impl ProviderSessionAffinityPort for TestSessionAffinity {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn claim_or_load<'a>(
|
||||
&'a self,
|
||||
_provider_kind: &'a ProviderKind,
|
||||
_key: &'a ProviderSessionAffinityKey,
|
||||
candidate_account_id: &'a ProviderAccountId,
|
||||
_ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move { Ok(candidate_account_id.clone()) })
|
||||
}
|
||||
|
||||
fn compare_and_bind<'a>(
|
||||
&'a self,
|
||||
_provider_kind: &'a ProviderKind,
|
||||
_key: &'a ProviderSessionAffinityKey,
|
||||
_expected_account_id: &'a ProviderAccountId,
|
||||
replacement_account_id: &'a ProviderAccountId,
|
||||
_ttl: Duration,
|
||||
) -> futures::future::BoxFuture<'a, Result<ProviderAccountId, ProviderStoreError>> {
|
||||
Box::pin(async move { Ok(replacement_account_id.clone()) })
|
||||
}
|
||||
|
||||
fn clear<'a>(
|
||||
&'a self,
|
||||
_provider_kind: &'a ProviderKind,
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ RUN npm install --global pnpm@11.7.0 \
|
||||
COPY frontend ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM rust:1.97-bookworm@sha256:77fac8b98f9f46062bb680b6d25d5bcaabfc400143952ebc572e924bcbedc3fa AS backend-builder
|
||||
FROM rust:1.98-bookworm@sha256:e70e2eec3d495fd5c8e0be74adda86507dfac7f51a724fbf9813ff59b2b247c7 AS backend-builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -18,15 +18,15 @@
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.31.0",
|
||||
"@lucide/vue": "^1.33.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"axios": "^1.19.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.13",
|
||||
"dayjs": "^1.11.23",
|
||||
"dompurify": "^3.4.14",
|
||||
"echarts": "^6.1.0",
|
||||
"es-toolkit": "^1.50.0",
|
||||
"es-toolkit": "^1.51.0",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^18.0.9",
|
||||
"marked": "^18.0.10",
|
||||
"pinia": "^4.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.41",
|
||||
@@ -38,12 +38,12 @@
|
||||
"@types/node": "^26.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vue-tsc": "^3.3.10"
|
||||
"vite": "^8.2.2",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+258
-248
File diff suppressed because it is too large
Load Diff
@@ -393,7 +393,7 @@ function normalizedInputColor() {
|
||||
<div class="min-w-0 flex-1">
|
||||
<BaseInput
|
||||
v-model="customInput"
|
||||
class="bg-cp-fill-tertiary!"
|
||||
class="rounded-cp bg-cp-fill-tertiary!"
|
||||
:aria-label="props.allowAlpha ? 'RGBA 颜色' : 'HEX 颜色'"
|
||||
:placeholder="props.allowAlpha ? 'rgba(96, 165, 250, 1)' : '#2563EB'"
|
||||
autocomplete="off"
|
||||
|
||||
@@ -24,7 +24,7 @@ defineProps<{
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="flex min-w-0 basis-full flex-wrap items-center justify-start gap-2 min-[640px]:mt-0.5 min-[640px]:basis-auto min-[640px]:shrink-0 min-[640px]:justify-end"
|
||||
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Check,
|
||||
Moon,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
Sun,
|
||||
Undo2,
|
||||
} from '@lucide/vue'
|
||||
|
||||
@@ -52,10 +50,6 @@ const scopeOptions = [
|
||||
{ label: '全局', value: 'global' },
|
||||
{ label: '组件', value: 'component' },
|
||||
]
|
||||
const previewThemeOptions = [
|
||||
{ label: '浅色预览', value: 'light', icon: Sun },
|
||||
{ label: '深色预览', value: 'dark', icon: Moon },
|
||||
]
|
||||
|
||||
function switchScope(value: string) {
|
||||
scope.value = value === 'component' ? 'component' : 'global'
|
||||
@@ -95,13 +89,6 @@ function saveTheme(event: MouseEvent) {
|
||||
size="sm"
|
||||
@update:model-value="switchScope"
|
||||
/>
|
||||
<BaseSegmented
|
||||
v-model="previewTheme"
|
||||
label="预览明暗模式"
|
||||
display="icon"
|
||||
:options="previewThemeOptions"
|
||||
size="sm"
|
||||
/>
|
||||
<BaseButton size="sm" variant="ghost" :disabled="!dirty" @click="resetDraft">
|
||||
<template #icon>
|
||||
<Undo2 class="size-3.5" />
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.3.0"
|
||||
version: "3.3.1"
|
||||
|
||||
Reference in New Issue
Block a user