mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 08:05:32 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb5a29699 | ||
|
|
61788804b4 | ||
|
|
2751ea6aa4 | ||
|
|
58fe66789c | ||
|
|
573b6b26db | ||
|
|
d425428ebe | ||
|
|
b26d3b386a | ||
|
|
cf0b14c5fd | ||
|
|
056561de41 | ||
|
|
f2215df72e | ||
|
|
72f8957163 | ||
|
|
73f0c15790 | ||
|
|
0558693949 | ||
|
|
2a9cb02cc9 | ||
|
|
5ce36931a8 | ||
|
|
0d826d9eba | ||
|
|
304242957a | ||
|
|
ef37ae8d9e | ||
|
|
fc13d4f2b6 | ||
|
|
78f9c0c800 | ||
|
|
a3bc7bc445 | ||
|
|
0132114285 | ||
|
|
5970efbb7c | ||
|
|
7bf28b571d | ||
|
|
bc3bd6bc43 | ||
|
|
9acfab0a86 | ||
|
|
3e5a84d2a0 | ||
|
|
92948f51a9 |
@@ -55,6 +55,9 @@ jobs:
|
||||
target: runtime
|
||||
load: true
|
||||
tags: ${{ env.CPR_IMAGE }}
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime
|
||||
cache-from: type=gha,scope=runtime-ci
|
||||
cache-to: type=gha,mode=max,scope=runtime-ci
|
||||
|
||||
@@ -76,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@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: runtime-container
|
||||
|
||||
@@ -468,6 +468,9 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
CPR_PREBUILT_DIR=dist/docker
|
||||
no-cache-filters: |
|
||||
runtime-base
|
||||
runtime-prebuilt
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.os }}-${{ matrix.arch }},mode=min
|
||||
provenance: mode=max
|
||||
|
||||
Generated
+35
-35
@@ -120,9 +120,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
version = "0.1.92"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -451,9 +451,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-smithy-types"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
|
||||
checksum = "fce83ce9abbb198d25bc7131e468d0f9fe1257125e58c39f3f9fc9f5098c9647"
|
||||
dependencies = [
|
||||
"base64-simd",
|
||||
"bytes",
|
||||
@@ -801,9 +801,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.1"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
"time",
|
||||
@@ -1246,7 +1246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1384,9 +1384,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1399,9 +1399,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -1409,15 +1409,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -1437,32 +1437,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-timer"
|
||||
@@ -1472,9 +1472,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.33"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1686,9 +1686,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.15"
|
||||
version = "0.4.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
|
||||
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2714,7 +2714,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2984,7 +2984,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3628,23 +3628,23 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.19"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.19"
|
||||
version = "2.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -31,23 +31,51 @@ pub enum ProviderAdminErrorKind {
|
||||
Internal,
|
||||
}
|
||||
|
||||
/// 不泄漏 OAuth material 与 Provider 响应体的管理错误。
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
/// 不携带 OAuth 请求材料的管理错误。
|
||||
///
|
||||
/// `message` 用于当前认证管理请求展示;上游没有标准 `error.message` 时,
|
||||
/// Provider 可以按其官方契约回退为完整的非成功响应正文。
|
||||
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("provider admin operation failed: {kind:?}")]
|
||||
pub struct ProviderAdminError {
|
||||
kind: ProviderAdminErrorKind,
|
||||
message: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ProviderAdminError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ProviderAdminError")
|
||||
.field("kind", &self.kind)
|
||||
.field("message", &self.message.as_ref().map(|_| "<redacted>"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderAdminError {
|
||||
#[must_use]
|
||||
pub const fn new(kind: ProviderAdminErrorKind) -> Self {
|
||||
Self { kind }
|
||||
Self {
|
||||
kind,
|
||||
message: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_message(mut self, message: impl Into<String>) -> Self {
|
||||
self.message = Some(message.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn kind(&self) -> ProviderAdminErrorKind {
|
||||
self.kind
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
self.message.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// 一个具体 Provider 对管理控制面提供的解析、验证、上游交互与运行时资源回收能力。
|
||||
|
||||
@@ -565,11 +565,7 @@ impl AccountsService for DefaultAccountsService {
|
||||
let upstream_body = error
|
||||
.upstream_response()
|
||||
.map(|response| String::from_utf8_lossy(response.body()).into_owned());
|
||||
let message = upstream_body
|
||||
.as_deref()
|
||||
.filter(|body| !body.is_empty())
|
||||
.unwrap_or_else(|| error.client_message())
|
||||
.to_owned();
|
||||
let message = error.client_message().to_owned();
|
||||
vec![AccountConnectionTestEvent::Failed {
|
||||
message,
|
||||
provider_error_code: error.client_error_code().map(ToOwned::to_owned),
|
||||
|
||||
@@ -59,7 +59,11 @@ fn map_provider_error(
|
||||
ProviderAdminErrorKind::Unavailable => AdminErrorKind::Unavailable,
|
||||
ProviderAdminErrorKind::Internal => AdminErrorKind::Internal,
|
||||
};
|
||||
AdminError::new(kind, format!("{resource} operation failed"))
|
||||
let message = error
|
||||
.message()
|
||||
.map(ToOwned::to_owned)
|
||||
.unwrap_or_else(|| format!("{resource} operation failed"));
|
||||
AdminError::new(kind, message)
|
||||
}
|
||||
|
||||
async fn publish_committed(
|
||||
|
||||
@@ -60,7 +60,7 @@ pub(super) type EventLog = Arc<Mutex<Vec<&'static str>>>;
|
||||
pub(super) struct FakeProviderAdmin {
|
||||
kind: ProviderKind,
|
||||
events: EventLog,
|
||||
failure: Mutex<Option<ProviderAdminErrorKind>>,
|
||||
failure: Mutex<Option<ProviderAdminError>>,
|
||||
quota_failure: Mutex<Option<ProviderAdminErrorKind>>,
|
||||
pending: Arc<Mutex<Option<PendingAuthorizationMutation>>>,
|
||||
retry_authorization_after_abort: Mutex<bool>,
|
||||
@@ -89,7 +89,12 @@ impl FakeProviderAdmin {
|
||||
}
|
||||
|
||||
pub(super) fn fail_next(&self, kind: ProviderAdminErrorKind) {
|
||||
*self.failure.lock().expect("provider failure") = Some(kind);
|
||||
*self.failure.lock().expect("provider failure") = Some(ProviderAdminError::new(kind));
|
||||
}
|
||||
|
||||
fn fail_next_with_message(&self, kind: ProviderAdminErrorKind, message: &str) {
|
||||
*self.failure.lock().expect("provider failure") =
|
||||
Some(ProviderAdminError::new(kind).with_message(message));
|
||||
}
|
||||
|
||||
pub(super) fn fail_next_quota(&self, kind: ProviderAdminErrorKind) {
|
||||
@@ -145,7 +150,7 @@ impl FakeProviderAdmin {
|
||||
|
||||
fn require_available(&self) -> Result<(), ProviderAdminError> {
|
||||
match self.failure.lock().expect("provider failure").take() {
|
||||
Some(kind) => Err(ProviderAdminError::new(kind)),
|
||||
Some(error) => Err(error),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
@@ -1650,6 +1655,32 @@ async fn accounts_refresh_provider_failure_should_not_call_store_commit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_refresh_should_preserve_the_provider_failure_message() {
|
||||
let events = events();
|
||||
let provider = FakeProviderAdmin::new("openai", events.clone());
|
||||
let upstream_message = "Your refresh token has already been used.";
|
||||
provider.fail_next_with_message(ProviderAdminErrorKind::Conflict, upstream_message);
|
||||
let store = FakeAccountStore::new("openai", events.clone());
|
||||
let services = accounts_service(provider, store).await;
|
||||
|
||||
let error = services
|
||||
.accounts()
|
||||
.refresh(
|
||||
&context("refresh-provider-message"),
|
||||
ProviderAccountId::new("acct_test").expect("account ID"),
|
||||
)
|
||||
.await
|
||||
.expect_err("Provider preparation must fail");
|
||||
|
||||
assert_eq!(error.kind(), gateway_admin::model::AdminErrorKind::Conflict);
|
||||
assert_eq!(error.to_string(), upstream_message);
|
||||
assert_eq!(
|
||||
recorded(&events),
|
||||
["store.load_account", "provider.prepare_refresh"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accounts_refresh_store_failure_should_drop_guard_after_commit_attempt() {
|
||||
let events = events();
|
||||
|
||||
@@ -73,7 +73,6 @@ pub(super) fn response_header_is_forwardable(name: &str, connection_options: &[S
|
||||
| "x-openai-organization"
|
||||
| "x-openai-project"
|
||||
| "x-codex-installation-id"
|
||||
| "x-codex-turn-state"
|
||||
| "x-codex-turn-metadata"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -788,6 +788,10 @@ async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresenta
|
||||
let trace = Arc::new(Trace::default());
|
||||
let headers = vec![
|
||||
ProviderResponseHeader::new("x-models-etag", Bytes::from_static(b"models-v2")),
|
||||
ProviderResponseHeader::new(
|
||||
"x-codex-turn-state",
|
||||
Bytes::from_static(b"turn-state-from-upstream"),
|
||||
),
|
||||
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"first")),
|
||||
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"second")),
|
||||
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
|
||||
@@ -834,6 +838,13 @@ async fn streaming_response_should_append_ordinary_headers_and_skip_unrepresenta
|
||||
.map(HeaderValue::as_bytes),
|
||||
Some(b"\xffopaque".as_slice())
|
||||
);
|
||||
assert_eq!(
|
||||
response
|
||||
.headers()
|
||||
.get("x-codex-turn-state")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("turn-state-from-upstream")
|
||||
);
|
||||
assert!(response.headers().get("authorization").is_none());
|
||||
assert!(response.headers().get("connection").is_none());
|
||||
assert!(response.headers().get("x-hop-secret").is_none());
|
||||
|
||||
@@ -411,6 +411,10 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
|
||||
ProviderResponseHeader::new("x-future-header", Bytes::from_static(b"future-value")),
|
||||
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"first")),
|
||||
ProviderResponseHeader::new("x-future-multi", Bytes::from_static(b"second")),
|
||||
ProviderResponseHeader::new(
|
||||
"x-codex-turn-state",
|
||||
Bytes::from_static(b"turn-state-from-upstream"),
|
||||
),
|
||||
ProviderResponseHeader::new("x-future-bytes", Bytes::from_static(b"\xffopaque")),
|
||||
ProviderResponseHeader::new("bad\0name", Bytes::from_static(b"unrepresentable")),
|
||||
ProviderResponseHeader::new(
|
||||
@@ -483,6 +487,10 @@ async fn websocket_response_metadata_should_preserve_ordinary_headers_without_bl
|
||||
// 官方 metadata 是 string map,同名多值只能保持既有的后值覆盖语义。
|
||||
assert_eq!(headers.get("x-future-multi"), Some(&json!("second")));
|
||||
assert_eq!(headers.get("x-request-id"), Some(&json!("req_upstream")));
|
||||
assert_eq!(
|
||||
headers.get("x-codex-turn-state"),
|
||||
Some(&json!("turn-state-from-upstream"))
|
||||
);
|
||||
for omitted in [
|
||||
"x-future-bytes",
|
||||
"bad\0name",
|
||||
|
||||
@@ -1162,10 +1162,11 @@ where
|
||||
true
|
||||
}
|
||||
|
||||
/// 原生续写的原账号在本地调度阶段已不可用时,改用完整输入重放。
|
||||
/// 原生续写的原账号在本地调度阶段已不可用时,交给 Provider 执行跨账号恢复。
|
||||
///
|
||||
/// 这不是强制 Smart:下一次 attempt 仍使用请求配置的调度策略。只有网关持有
|
||||
/// 对应 Provider 的可携带会话状态时,才可安全移除 native handle 并换号。
|
||||
/// 对应 Provider 的会话状态时才进入恢复;是否保留 native handle、执行 probe
|
||||
/// 或使用完整 transcript,由 Provider 自己的协议边界决定。
|
||||
fn prepare_unavailable_native_continuation_replay(&mut self, error: &ProviderError) -> bool {
|
||||
if self.account_selection.required_account().is_some()
|
||||
|| self.continuation_attempt != ContinuationAttempt::Native
|
||||
|
||||
@@ -946,7 +946,28 @@ impl fmt::Debug for LoadedCredential {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新成功后的完整 CAS 写回。
|
||||
/// 与 credential revision CAS 同事务提交的账号错误事实。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CredentialStateWrite {
|
||||
pub credential_state: CredentialState,
|
||||
pub observed_at: SystemTime,
|
||||
pub error_reason: Option<AccountErrorReason>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// [`CredentialCasUpdate`] 跨 store 边界的命名字段。
|
||||
pub struct CredentialCasUpdateParts {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub profile: ProviderAccountUpdate,
|
||||
pub credential: PlaintextCredential,
|
||||
pub has_refresh_token: bool,
|
||||
pub access_token_expires_at: Option<SystemTime>,
|
||||
pub next_refresh_at: Option<SystemTime>,
|
||||
pub account_state: Option<Box<CredentialStateWrite>>,
|
||||
}
|
||||
|
||||
/// 刷新后的完整 CAS 写回。
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct CredentialCasUpdate {
|
||||
account_id: ProviderAccountId,
|
||||
@@ -956,6 +977,7 @@ pub struct CredentialCasUpdate {
|
||||
has_refresh_token: bool,
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
next_refresh_at: Option<SystemTime>,
|
||||
account_state: Option<Box<CredentialStateWrite>>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for CredentialCasUpdate {
|
||||
@@ -969,6 +991,7 @@ impl fmt::Debug for CredentialCasUpdate {
|
||||
.field("has_refresh_token", &self.has_refresh_token)
|
||||
.field("access_token_expires_at", &self.access_token_expires_at)
|
||||
.field("next_refresh_at", &self.next_refresh_at)
|
||||
.field("account_state", &self.account_state)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -1002,9 +1025,34 @@ impl CredentialCasUpdate {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
account_state: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 将刷新调度与账号错误事实放入同一个 revision CAS。
|
||||
#[must_use]
|
||||
pub fn with_account_state(
|
||||
mut self,
|
||||
credential_state: CredentialState,
|
||||
observed_at: SystemTime,
|
||||
error_reason: Option<AccountErrorReason>,
|
||||
message: Option<String>,
|
||||
) -> Self {
|
||||
let message = message.filter(|value| !value.trim().is_empty());
|
||||
let error_reason = if credential_state == CredentialState::Ready {
|
||||
message.as_ref().and(error_reason)
|
||||
} else {
|
||||
error_reason.or_else(|| credential_state.error_reason())
|
||||
};
|
||||
self.account_state = Some(Box::new(CredentialStateWrite {
|
||||
credential_state,
|
||||
observed_at,
|
||||
error_reason,
|
||||
message,
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn account_id(&self) -> &ProviderAccountId {
|
||||
&self.account_id
|
||||
@@ -1041,26 +1089,17 @@ impl CredentialCasUpdate {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_parts(
|
||||
self,
|
||||
) -> (
|
||||
ProviderAccountId,
|
||||
CredentialRevision,
|
||||
ProviderAccountUpdate,
|
||||
PlaintextCredential,
|
||||
bool,
|
||||
Option<SystemTime>,
|
||||
Option<SystemTime>,
|
||||
) {
|
||||
(
|
||||
self.account_id,
|
||||
self.expected_revision,
|
||||
self.profile,
|
||||
self.credential,
|
||||
self.has_refresh_token,
|
||||
self.access_token_expires_at,
|
||||
self.next_refresh_at,
|
||||
)
|
||||
pub fn into_parts(self) -> CredentialCasUpdateParts {
|
||||
CredentialCasUpdateParts {
|
||||
account_id: self.account_id,
|
||||
expected_revision: self.expected_revision,
|
||||
profile: self.profile,
|
||||
credential: self.credential,
|
||||
has_refresh_token: self.has_refresh_token,
|
||||
access_token_expires_at: self.access_token_expires_at,
|
||||
next_refresh_at: self.next_refresh_at,
|
||||
account_state: self.account_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,6 +1141,14 @@ pub enum QuotaWriteOutcome {
|
||||
Conflict,
|
||||
}
|
||||
|
||||
/// 只推进 Provider quota 文档的最后成功查询时间。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QuotaObservationTouch {
|
||||
pub account_id: ProviderAccountId,
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub observed_at: SystemTime,
|
||||
}
|
||||
|
||||
/// 不改 Provider 原始 quota JSON 的额度访问事实写入。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct QuotaAccessChange {
|
||||
@@ -1117,9 +1164,13 @@ pub struct AccountStateChange {
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub credential_state: CredentialState,
|
||||
pub observed_at: SystemTime,
|
||||
/// 受控错误原因;恢复 Ready 时必须清空。
|
||||
/// 受控错误原因;无失败事实的 Ready 写入必须清空。
|
||||
///
|
||||
/// Ready 凭据可以在 AT 过期后继续 RT 恢复,此时允许保留最近一次
|
||||
/// 刷新失败原因,供错误状态投影展示。
|
||||
pub error_reason: Option<AccountErrorReason>,
|
||||
/// 已脱敏的上游诊断信息;恢复 Ready 时必须清空。
|
||||
/// 供管理端展示的错误消息;结构化上游失败应保留原始 message,不能写入整个正文。
|
||||
/// 刷新成功或其他无失败事实的 Ready 写入必须清空。
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1170,6 +1221,11 @@ pub trait ProviderAccountStore: Send + Sync {
|
||||
observation: QuotaObservation,
|
||||
) -> Result<QuotaWriteOutcome, StoreError>;
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, StoreError>;
|
||||
|
||||
async fn apply_quota_access(
|
||||
&self,
|
||||
change: QuotaAccessChange,
|
||||
|
||||
@@ -362,9 +362,14 @@ pub enum ContinuationAttempt {
|
||||
None,
|
||||
/// 使用 Store 解析出的原生 handle 与账号绑定。
|
||||
Native,
|
||||
/// 在原账号上用完整连接内 transcript 重放。
|
||||
/// 在原账号上执行 Provider 定义的恢复。
|
||||
///
|
||||
/// 只有持有可携带 transcript 的 Provider 才能清除 native handle 后重放;
|
||||
/// 只持有 opaque state 的 Provider 必须保留 continuation 依赖或返回客户端错误。
|
||||
ReplayOwner,
|
||||
/// transcript 已可携带,允许选择其他账号。
|
||||
/// 允许选择其他账号并执行 Provider 定义的恢复。
|
||||
///
|
||||
/// 该枚举本身不证明客户端 input 完整,也不授权 Provider 清除 previous-response。
|
||||
ReplayAny,
|
||||
}
|
||||
|
||||
|
||||
@@ -493,6 +493,31 @@ fn bare_atomic_response_failed(response_id: &str) -> ProviderError {
|
||||
.with_atomic_client_events(vec![failed])
|
||||
}
|
||||
|
||||
fn atomic_history_unavailable(response_id: &str) -> ProviderError {
|
||||
let failed = ProviderEvent::wire(
|
||||
ProtocolWireEvent::json(
|
||||
"openai",
|
||||
Some("response.failed".to_owned()),
|
||||
json!({
|
||||
"type": "response.failed",
|
||||
"response": {
|
||||
"id": response_id,
|
||||
"status": "failed",
|
||||
"error": {
|
||||
"code": "previous_response_not_found",
|
||||
"message": "Previous response was not found. Retrying the full request."
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.expect("history unavailable wire"),
|
||||
);
|
||||
ProviderError::new(ProviderErrorKind::InvalidRequest, UpstreamSendState::Sent)
|
||||
.with_status(400)
|
||||
.with_continuation_failure(ContinuationFailure::HistoryUnavailable)
|
||||
.with_atomic_client_events(vec![failed])
|
||||
}
|
||||
|
||||
fn image_stream(image_output_tokens: Option<u64>) -> Vec<Result<GatewayEvent, ProviderError>> {
|
||||
let mut usage = Usage::new();
|
||||
usage.input_tokens = Some(12);
|
||||
@@ -1481,7 +1506,7 @@ fn streaming_native_continuation_delivers_started_event_before_later_output() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_continuation_replays_owner_before_safely_switching_account() {
|
||||
fn native_continuation_uses_provider_defined_owner_then_cross_account_recovery() {
|
||||
let Operation::Generate(generate) = generate_operation() else {
|
||||
panic!("generate operation");
|
||||
};
|
||||
@@ -1536,7 +1561,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("portable replay succeeds");
|
||||
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
|
||||
let contexts = provider.contexts.lock().expect("contexts lock");
|
||||
@@ -1561,7 +1586,7 @@ fn native_continuation_replays_owner_before_safely_switching_account() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_native_continuation_replays_with_the_configured_account_policy() {
|
||||
fn unavailable_native_continuation_uses_provider_recovery_with_the_configured_account_policy() {
|
||||
let Operation::Generate(generate) = generate_operation() else {
|
||||
panic!("generate operation");
|
||||
};
|
||||
@@ -1602,7 +1627,7 @@ fn unavailable_native_continuation_replays_with_the_configured_account_policy()
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
block_on(session.collect_uncommitted()).expect("portable replay succeeds");
|
||||
block_on(session.collect_uncommitted()).expect("provider-defined recovery succeeds");
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit response");
|
||||
|
||||
let contexts = provider.contexts.lock().expect("contexts lock");
|
||||
@@ -1625,6 +1650,76 @@ fn unavailable_native_continuation_replays_with_the_configured_account_policy()
|
||||
assert_eq!(state.finalizations[0].outcome, ExecutionOutcome::Succeeded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_history_failure_without_replay_proof_is_delivered_without_hidden_retry() {
|
||||
let Operation::Generate(generate) = generate_operation() else {
|
||||
panic!("generate operation");
|
||||
};
|
||||
let operation = Operation::Generate(generate.with_provider_session_state(
|
||||
ProviderSessionState::new("openai", Map::new()).expect("provider session state"),
|
||||
));
|
||||
let route_plan = plan(&operation);
|
||||
let (coordinator, _, provider) = coordinator(vec![
|
||||
Script::ObservedStream {
|
||||
account_id: "acct_one",
|
||||
items: vec![Err(atomic_history_unavailable("response-history-missing"))],
|
||||
},
|
||||
Script::Stream {
|
||||
account_id: "acct_two",
|
||||
items: complete_stream(None),
|
||||
},
|
||||
]);
|
||||
let continuation = NativeContinuationPin::new(
|
||||
PreviousResponseId::new("previous-response"),
|
||||
PreviousResponseId::new("upstream-response"),
|
||||
ClientApiKeyId::new("key_client_1").expect("client key"),
|
||||
ProviderKind::new("openai").expect("provider"),
|
||||
ProviderAccountId::new("acct_one").expect("account"),
|
||||
);
|
||||
let mut session = block_on(coordinator.start(
|
||||
model_request(&operation, SystemTime::now() + Duration::from_secs(30)),
|
||||
operation,
|
||||
route_plan,
|
||||
None,
|
||||
Some(ContinuationBinding::Pinned(continuation)),
|
||||
CancellationToken::new(),
|
||||
))
|
||||
.expect("start execution");
|
||||
|
||||
let terminal = block_on(session.next_event())
|
||||
.expect("history failure remains deliverable")
|
||||
.expect("terminal history failure batch");
|
||||
assert_eq!(
|
||||
terminal.commit_requirement(),
|
||||
CommitRequirement::CommitBeforeDelivery
|
||||
);
|
||||
let events = terminal.into_provider_events();
|
||||
let failure = events
|
||||
.iter()
|
||||
.filter_map(ProviderEvent::wire_event)
|
||||
.find(|wire| wire.event_type() == Some("response.failed"))
|
||||
.expect("raw response.failed event");
|
||||
assert_eq!(
|
||||
failure
|
||||
.data()
|
||||
.pointer("/response/error/code")
|
||||
.and_then(Value::as_str),
|
||||
Some("previous_response_not_found")
|
||||
);
|
||||
block_on(session.commit_downstream(Some(200))).expect("commit terminal history failure");
|
||||
assert!(matches!(
|
||||
block_on(session.next_event()),
|
||||
Err(EngineError::Provider(_))
|
||||
));
|
||||
|
||||
let contexts = provider.contexts.lock().expect("contexts lock");
|
||||
assert_eq!(contexts.len(), 1);
|
||||
assert_eq!(
|
||||
contexts[0].continuation_attempt(),
|
||||
ContinuationAttempt::Native
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_account_reaches_provider_and_matching_metadata_succeeds() {
|
||||
let operation = operation(RetrySafety::Idempotent);
|
||||
|
||||
@@ -208,11 +208,24 @@ pub struct RateLimitWindow {
|
||||
pub reset_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// 限流项键的来源。
|
||||
///
|
||||
/// HTTP headers 和事件里的 `metered_limit_name` 提供稳定 `limit_id`;
|
||||
/// WebSocket `additional_rate_limits` 在部分协议版本里只以可读名称为 map key,
|
||||
/// 此时必须由持久化层用既有名称元数据解析,不能把名称误当成稳定 ID。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RateLimitKeySource {
|
||||
LimitId,
|
||||
LimitName,
|
||||
}
|
||||
|
||||
/// 单个计量项的限流信息。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RateLimitDetails {
|
||||
/// 稳定的计量项 ID。
|
||||
pub limit_id: String,
|
||||
/// [`limit_id`](Self::limit_id) 是稳定 ID,还是名称生成的临时解析键。
|
||||
pub key_source: RateLimitKeySource,
|
||||
/// 上游提供的可读名称。
|
||||
pub limit_name: Option<String>,
|
||||
/// 当前请求是否被允许。
|
||||
@@ -341,20 +354,40 @@ pub fn parse_rate_limits_event(value: &Value) -> Option<ParsedRateLimits> {
|
||||
return None;
|
||||
}
|
||||
|
||||
let limit_id = rate_limit_name(value)
|
||||
.map(normalize_limit_id)
|
||||
let explicit_limit_id = top_level_rate_limit_id(value).map(normalize_limit_id);
|
||||
let explicit_limit_name = non_empty_string(value, "limit_name");
|
||||
let default_limit_id = explicit_limit_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| "codex".to_string());
|
||||
let limit_name = value
|
||||
.get("metered_limit_name")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|_| value.get("limit_name"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToString::to_string);
|
||||
let details = value
|
||||
.get("rate_limits")
|
||||
.and_then(|details| parse_details_from_object(details, &limit_id, limit_name));
|
||||
let default_details = value.get("rate_limits").and_then(|details| {
|
||||
parse_details_from_object(
|
||||
details,
|
||||
&default_limit_id,
|
||||
explicit_limit_name,
|
||||
RateLimitKeySource::LimitId,
|
||||
)
|
||||
});
|
||||
let mut limits = parse_additional_rate_limits(value);
|
||||
let matching_additional_ids = default_details
|
||||
.as_ref()
|
||||
.map(|default| {
|
||||
limits
|
||||
.iter()
|
||||
.filter(|(_, additional)| same_rate_limit_facts(default, additional))
|
||||
.map(|(limit_id, _)| limit_id.clone())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 当前 WebSocket 协议会把活动具名桶同时镜像到顶层 `rate_limits`,但不附
|
||||
// `metered_limit_name`。只要 additional 中存在相同事实,顶层就不是 core 桶。
|
||||
let default_is_additional_alias =
|
||||
explicit_limit_id.is_none() && !matching_additional_ids.is_empty();
|
||||
if let Some(details) = default_details
|
||||
&& !default_is_additional_alias
|
||||
{
|
||||
limits.insert(default_limit_id.clone(), details);
|
||||
}
|
||||
let credits = value.get("credits").and_then(parse_credits_from_object);
|
||||
let plan_type = value
|
||||
.get("plan_type")
|
||||
@@ -363,17 +396,18 @@ pub fn parse_rate_limits_event(value: &Value) -> Option<ParsedRateLimits> {
|
||||
.filter(|plan| !plan.is_empty())
|
||||
.map(ToString::to_string);
|
||||
|
||||
if details.is_none() && credits.is_none() && plan_type.is_none() {
|
||||
if limits.is_empty() && credits.is_none() && plan_type.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut limits = BTreeMap::new();
|
||||
if let Some(details) = details {
|
||||
limits.insert(limit_id.clone(), details);
|
||||
}
|
||||
let active_limit = explicit_limit_id.or_else(|| match matching_additional_ids.as_slice() {
|
||||
[limit_id] => Some(limit_id.clone()),
|
||||
[] if !limits.is_empty() => Some(default_limit_id),
|
||||
_ => None,
|
||||
});
|
||||
Some(ParsedRateLimits {
|
||||
limits,
|
||||
active_limit: Some(limit_id),
|
||||
active_limit,
|
||||
credits,
|
||||
plan_type,
|
||||
promo_message: value
|
||||
@@ -597,6 +631,7 @@ fn parse_details_from_lookup(
|
||||
}
|
||||
Some(RateLimitDetails {
|
||||
limit_id: limit_id.to_string(),
|
||||
key_source: RateLimitKeySource::LimitId,
|
||||
limit_name,
|
||||
allowed,
|
||||
limit_reached,
|
||||
@@ -687,6 +722,7 @@ fn parse_details_from_object(
|
||||
value: &Value,
|
||||
limit_id: &str,
|
||||
limit_name: Option<String>,
|
||||
key_source: RateLimitKeySource,
|
||||
) -> Option<RateLimitDetails> {
|
||||
let primary = value.get("primary").and_then(parse_window_from_object);
|
||||
let secondary = value.get("secondary").and_then(parse_window_from_object);
|
||||
@@ -697,6 +733,7 @@ fn parse_details_from_object(
|
||||
}
|
||||
Some(RateLimitDetails {
|
||||
limit_id: limit_id.to_string(),
|
||||
key_source,
|
||||
limit_name,
|
||||
allowed,
|
||||
limit_reached,
|
||||
@@ -752,11 +789,59 @@ fn value_as_positive_i64(value: &Value) -> Option<i64> {
|
||||
.filter(|value| *value > 0)
|
||||
}
|
||||
|
||||
fn rate_limit_name(value: &Value) -> Option<&str> {
|
||||
fn rate_limit_id(value: &Value) -> Option<&str> {
|
||||
value
|
||||
.get("metered_limit_name")
|
||||
.or_else(|| value.get("limit_name"))
|
||||
.or_else(|| value.get("limit_id"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn top_level_rate_limit_id(value: &Value) -> Option<&str> {
|
||||
rate_limit_id(value).or_else(|| {
|
||||
value
|
||||
.get("limit_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
})
|
||||
}
|
||||
|
||||
fn non_empty_string(value: &Value, field: &str) -> Option<String> {
|
||||
value
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn parse_additional_rate_limits(value: &Value) -> BTreeMap<String, RateLimitDetails> {
|
||||
value
|
||||
.get("additional_rate_limits")
|
||||
.and_then(Value::as_object)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(map_key, value)| {
|
||||
let explicit_limit_id = rate_limit_id(value).map(normalize_limit_id);
|
||||
let (limit_id, key_source) = explicit_limit_id.map_or_else(
|
||||
|| (normalize_limit_id(map_key), RateLimitKeySource::LimitName),
|
||||
|limit_id| (limit_id, RateLimitKeySource::LimitId),
|
||||
);
|
||||
let limit_name = non_empty_string(value, "limit_name")
|
||||
.or_else(|| Some(map_key.trim().to_owned()).filter(|name| !name.is_empty()));
|
||||
parse_details_from_object(value, &limit_id, limit_name, key_source)
|
||||
.map(|details| (limit_id, details))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_rate_limit_facts(left: &RateLimitDetails, right: &RateLimitDetails) -> bool {
|
||||
left.allowed == right.allowed
|
||||
&& left.limit_reached == right.limit_reached
|
||||
&& left.primary == right.primary
|
||||
&& left.secondary == right.secondary
|
||||
}
|
||||
|
||||
fn normalize_limit_id(value: impl AsRef<str>) -> String {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use gateway_protocol::openai::events::{
|
||||
RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage, is_codex_quota_header_name,
|
||||
is_rate_limit_header_name, parse_rate_limit_headers, parse_rate_limits_event,
|
||||
rate_limits_to_header_pairs, retry_after_seconds_from_body,
|
||||
RateLimitKeySource, RateLimitWindow, TokenUsage, extract_sse_usage, extract_usage,
|
||||
is_codex_quota_header_name, is_rate_limit_header_name, parse_rate_limit_headers,
|
||||
parse_rate_limits_event, rate_limits_to_header_pairs, retry_after_seconds_from_body,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -483,6 +483,58 @@ fn parse_rate_limits_event_should_extract_websocket_metered_limit() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rate_limits_event_should_preserve_name_keyed_additional_limit() {
|
||||
let event = json!({
|
||||
"type": "codex.rate_limits",
|
||||
"plan_type": "pro",
|
||||
"rate_limits": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary": {
|
||||
"used_percent": 0,
|
||||
"window_minutes": 10080,
|
||||
"reset_after_seconds": 354916,
|
||||
"reset_at": 1787396838
|
||||
},
|
||||
"secondary": null
|
||||
},
|
||||
"additional_rate_limits": {
|
||||
"GPT-5.3-Codex-Spark": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary": {
|
||||
"used_percent": 0,
|
||||
"window_minutes": 10080,
|
||||
"reset_after_seconds": 354916,
|
||||
"reset_at": 1787396838
|
||||
},
|
||||
"secondary": null
|
||||
}
|
||||
},
|
||||
"credits": null
|
||||
});
|
||||
|
||||
let parsed = parse_rate_limits_event(&event).expect("rate-limit event");
|
||||
let details = parsed
|
||||
.limits
|
||||
.get("gpt_5.3_codex_spark")
|
||||
.expect("name-keyed additional limit");
|
||||
|
||||
assert_eq!(parsed.active_limit.as_deref(), Some("gpt_5.3_codex_spark"));
|
||||
assert_eq!(details.key_source, RateLimitKeySource::LimitName);
|
||||
assert_eq!(details.limit_name.as_deref(), Some("GPT-5.3-Codex-Spark"));
|
||||
assert_eq!(
|
||||
details.primary,
|
||||
Some(RateLimitWindow {
|
||||
used_percent: 0.0,
|
||||
window_minutes: Some(10080),
|
||||
reset_at: Some(1787396838),
|
||||
})
|
||||
);
|
||||
assert!(!parsed.limits.contains_key("codex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rate_limits_event_should_reject_unrelated_event_type() {
|
||||
for event in [
|
||||
|
||||
@@ -103,7 +103,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
&self,
|
||||
update: CredentialCasUpdate,
|
||||
) -> Result<CredentialCasOutcome, CoreStoreError> {
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
profile,
|
||||
@@ -111,7 +111,8 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = update.into_parts();
|
||||
account_state,
|
||||
} = update.into_parts();
|
||||
if profile.account_id != account_id {
|
||||
return Err(CoreStoreError::new(CoreStoreErrorKind::InvalidData));
|
||||
}
|
||||
@@ -122,13 +123,38 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
CREDENTIALS_MAX_BYTES,
|
||||
)
|
||||
.map_err(core_store_error)?;
|
||||
let credential_state = account_state
|
||||
.as_ref()
|
||||
.map(|state| state.credential_state.as_str());
|
||||
let credential_observed_at = account_state
|
||||
.as_ref()
|
||||
.map(|state| DateTime::<Utc>::from(state.observed_at));
|
||||
let error_reason = account_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.error_reason.map(AccountErrorReason::as_str));
|
||||
let message = account_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.message.as_deref());
|
||||
let next = sqlx::query_scalar::<_, i64>(
|
||||
"update provider_accounts
|
||||
set name = $3, email = $4, plan_type = $5,
|
||||
provider_credentials_json = $6,
|
||||
credential_revision = credential_revision + 1,
|
||||
has_refresh_token = $7, access_token_expires_at = $8,
|
||||
next_refresh_at = $9, updated_at = now()
|
||||
next_refresh_at = $9,
|
||||
credential_state = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $10 else credential_state end,
|
||||
credential_observed_at = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $11 else credential_observed_at end,
|
||||
last_error_reason = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $12 else last_error_reason end,
|
||||
last_error_message = case
|
||||
when $10::text is not null and enabled and upstream_user_id is not null
|
||||
then $13 else last_error_message end,
|
||||
updated_at = greatest(now(), coalesce($11, now()))
|
||||
where id = $1 and credential_revision = $2
|
||||
returning credential_revision",
|
||||
)
|
||||
@@ -141,6 +167,10 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
.bind(has_refresh_token)
|
||||
.bind(access_token_expires_at.map(DateTime::<Utc>::from))
|
||||
.bind(next_refresh_at.map(DateTime::<Utc>::from))
|
||||
.bind(credential_state)
|
||||
.bind(credential_observed_at)
|
||||
.bind(error_reason)
|
||||
.bind(message)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::Unavailable))?;
|
||||
@@ -280,6 +310,25 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
})
|
||||
}
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, CoreStoreError> {
|
||||
let updated = self
|
||||
.touch_provider_quota_observation(
|
||||
touch.account_id.as_str(),
|
||||
Revision::new(touch.expected_revision.get()).map_err(core_store_error)?,
|
||||
DateTime::<Utc>::from(touch.observed_at),
|
||||
)
|
||||
.await
|
||||
.map_err(core_store_error)?;
|
||||
Ok(if updated {
|
||||
QuotaWriteOutcome::Updated
|
||||
} else {
|
||||
QuotaWriteOutcome::Conflict
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_state_change(&self, change: AccountStateChange) -> Result<(), CoreStoreError> {
|
||||
let updated = self
|
||||
.apply_provider_account_state(ProviderAccountStateUpdate {
|
||||
|
||||
@@ -38,12 +38,13 @@ use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||
|
||||
use gateway_core::engine::credential::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision as CoreCredentialRevision,
|
||||
CredentialState, LoadedCredential, NewProviderAccount as CoreNewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount as CoreProviderAccount,
|
||||
ProviderAccountId as CoreProviderAccountId, ProviderAccountIdentity, ProviderAccountStore,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
|
||||
CredentialRevision as CoreCredentialRevision, CredentialState, LoadedCredential,
|
||||
NewProviderAccount as CoreNewProviderAccount, OpaqueProviderData, PlaintextCredential,
|
||||
ProviderAccount as CoreProviderAccount, ProviderAccountId as CoreProviderAccountId,
|
||||
ProviderAccountIdentity, ProviderAccountStore,
|
||||
ProviderAccountUpdate as CoreProviderAccountUpdate, QuotaAccessChange, QuotaAccessState,
|
||||
QuotaEvidence, QuotaObservation, QuotaState, QuotaWriteOutcome,
|
||||
QuotaEvidence, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::{StoreError as CoreStoreError, StoreErrorKind as CoreStoreErrorKind};
|
||||
use gateway_core::routing::{AccountGroupId, ProviderKind};
|
||||
|
||||
@@ -29,6 +29,12 @@ pub trait ProviderAccountRepository: Send + Sync {
|
||||
observed_at: DateTime<Utc>,
|
||||
state: QuotaState,
|
||||
) -> StoreResult<bool>;
|
||||
async fn touch_provider_quota_observation(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected_revision: Revision,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> StoreResult<bool>;
|
||||
async fn apply_provider_quota_access(
|
||||
&self,
|
||||
account_id: &str,
|
||||
@@ -237,13 +243,11 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
else credential_observed_at
|
||||
end,
|
||||
last_error_reason = case
|
||||
when enabled and upstream_user_id is not null then
|
||||
case when $3 = 'ready' then null else $5 end
|
||||
when enabled and upstream_user_id is not null then $5
|
||||
else last_error_reason
|
||||
end,
|
||||
last_error_message = case
|
||||
when enabled and upstream_user_id is not null then
|
||||
case when $3 = 'ready' then null else $6 end
|
||||
when enabled and upstream_user_id is not null then $6
|
||||
else last_error_message
|
||||
end,
|
||||
updated_at = case when enabled then greatest(now(), $4) else updated_at end
|
||||
@@ -354,6 +358,29 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn touch_provider_quota_observation(
|
||||
&self,
|
||||
account_id: &str,
|
||||
expected_revision: Revision,
|
||||
observed_at: DateTime<Utc>,
|
||||
) -> StoreResult<bool> {
|
||||
require_nonempty(ENTITY, "account_id", account_id)?;
|
||||
let result = sqlx::query(
|
||||
"update provider_accounts
|
||||
set quota_observed_at = $3, updated_at = greatest(now(), $3)
|
||||
where id = $1 and credential_revision = $2
|
||||
and provider_quota_json is not null
|
||||
and (quota_observed_at is null or quota_observed_at <= $3)",
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(to_i64(expected_revision.get())?)
|
||||
.bind(observed_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("touch provider quota observation"))?;
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
async fn delete_provider_account(&self, id: &str) -> StoreResult<bool> {
|
||||
require_nonempty(ENTITY, "id", id)?;
|
||||
let result = sqlx::query("delete from provider_accounts where id = $1 and not enabled")
|
||||
|
||||
@@ -18,8 +18,8 @@ use gateway_core::engine::credential::{
|
||||
AccountErrorReason, AccountStateChange, CredentialCasOutcome, CredentialCasUpdate,
|
||||
CredentialRevision, CredentialState, OpaqueProviderData, PlaintextCredential,
|
||||
ProviderAccountId, ProviderAccountIdentity, ProviderAccountStore, ProviderAccountUpdate,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
|
||||
QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::routing::{AccountGroupId, ProviderKind};
|
||||
use gateway_store::{
|
||||
@@ -51,6 +51,22 @@ struct RecoveredAccountRow {
|
||||
access_token_expires_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct CoreRefreshRow {
|
||||
name: String,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
provider_credentials_json: serde_json::Value,
|
||||
credential_revision: i64,
|
||||
credential_state: String,
|
||||
last_error_reason: Option<String>,
|
||||
last_error_message: Option<String>,
|
||||
quota_access_state: String,
|
||||
quota_evidence: Option<String>,
|
||||
quota_access_observed_at: Option<chrono::DateTime<Utc>>,
|
||||
quota_reset_at: Option<chrono::DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_provider_account_adapter_implements_core_port() {
|
||||
fn assert_port<T: ProviderAccountStore>() {}
|
||||
@@ -156,6 +172,115 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_observation_touch_preserves_quota_state_and_advances_account_update_time() {
|
||||
let Some(database) = TestDatabase::create("provider_account_quota_touch").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgProviderAccountRepository::new(database.pool.clone());
|
||||
let account_id = ProviderAccountId::new("acct_quota_touch").expect("account id");
|
||||
repository
|
||||
.insert_provider_account(account(account_id.as_str(), "user-quota-touch"))
|
||||
.await
|
||||
.expect("insert quota fixture");
|
||||
let revision = CredentialRevision::new(1).expect("revision");
|
||||
let observed_at = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.expect("old observation time");
|
||||
let touched_at = observed_at
|
||||
.checked_add(Duration::from_secs(90))
|
||||
.expect("new observation time");
|
||||
let reset_at = observed_at
|
||||
.checked_add(Duration::from_secs(3_600))
|
||||
.expect("quota reset time");
|
||||
let quota_json = json!({
|
||||
"rate_limit": {
|
||||
"allowed": false,
|
||||
"limit_reached": true,
|
||||
"primary_window": {"used_percent": 100, "reset_at": 1_900_000_000}
|
||||
}
|
||||
});
|
||||
assert_eq!(
|
||||
repository
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: revision,
|
||||
quota: OpaqueProviderData::new(
|
||||
quota_json.as_object().expect("quota object").clone(),
|
||||
),
|
||||
observed_at,
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
observed_at,
|
||||
Some(reset_at),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("persist exhausted quota"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
let account_updated_at: chrono::DateTime<Utc> = sqlx::query_scalar(
|
||||
"select updated_at from provider_accounts where id = 'acct_quota_touch'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load account update time before quota touch");
|
||||
|
||||
assert_eq!(
|
||||
repository
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
account_id,
|
||||
expected_revision: revision,
|
||||
observed_at: touched_at,
|
||||
})
|
||||
.await
|
||||
.expect("touch quota refresh time"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
|
||||
let stored = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
serde_json::Value,
|
||||
chrono::DateTime<Utc>,
|
||||
String,
|
||||
Option<String>,
|
||||
chrono::DateTime<Utc>,
|
||||
Option<chrono::DateTime<Utc>>,
|
||||
chrono::DateTime<Utc>,
|
||||
),
|
||||
>(
|
||||
"select provider_quota_json, quota_observed_at, quota_access_state,
|
||||
quota_evidence, quota_access_observed_at, quota_reset_at, updated_at
|
||||
from provider_accounts where id = 'acct_quota_touch'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load touched quota row");
|
||||
assert_eq!(stored.0, quota_json);
|
||||
assert_eq!(
|
||||
stored.1.timestamp_micros(),
|
||||
chrono::DateTime::<Utc>::from(touched_at).timestamp_micros()
|
||||
);
|
||||
assert_eq!(stored.2, "exhausted");
|
||||
assert_eq!(stored.3.as_deref(), Some("usage_limit_reached"));
|
||||
assert_eq!(
|
||||
stored.4.timestamp_micros(),
|
||||
chrono::DateTime::<Utc>::from(observed_at).timestamp_micros()
|
||||
);
|
||||
assert_eq!(
|
||||
stored.5.map(|value| value.timestamp_micros()),
|
||||
Some(chrono::DateTime::<Utc>::from(reset_at).timestamp_micros())
|
||||
);
|
||||
assert_eq!(
|
||||
stored.6.timestamp_micros(),
|
||||
chrono::DateTime::<Utc>::from(touched_at).timestamp_micros()
|
||||
);
|
||||
assert!(stored.6 > account_updated_at);
|
||||
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() {
|
||||
let Some(database) = TestDatabase::create("provider_account_observation_fence").await else {
|
||||
@@ -280,6 +405,27 @@ async fn credential_error_message_is_persisted_and_cleared_on_recovery() {
|
||||
Some("upstream returned 402 payment required")
|
||||
);
|
||||
|
||||
repository
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: Some("Invalid refresh token.".to_owned()),
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: revision,
|
||||
credential_state: CredentialState::Ready,
|
||||
observed_at: SystemTime::now(),
|
||||
error_reason: Some(AccountErrorReason::AccessTokenExpired),
|
||||
})
|
||||
.await
|
||||
.expect("persist recoverable refresh failure");
|
||||
let recoverable: (Option<String>, Option<String>) = sqlx::query_as(
|
||||
"select last_error_reason, last_error_message from provider_accounts where id = $1",
|
||||
)
|
||||
.bind(account_id.as_str())
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("read recoverable refresh failure");
|
||||
assert_eq!(recoverable.0.as_deref(), Some("access_token_expired"));
|
||||
assert_eq!(recoverable.1.as_deref(), Some("Invalid refresh token."));
|
||||
|
||||
repository
|
||||
.apply_state_change(AccountStateChange {
|
||||
message: None,
|
||||
@@ -1208,8 +1354,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
})
|
||||
.await
|
||||
.expect("seed provider account");
|
||||
sqlx::query(
|
||||
"update provider_accounts
|
||||
set last_error_reason = 'access_token_expired',
|
||||
last_error_message = 'stale refresh failure'
|
||||
where id = 'acct_core_refresh'",
|
||||
)
|
||||
.execute(&database.pool)
|
||||
.await
|
||||
.expect("seed refresh failure details");
|
||||
|
||||
let account_id = ProviderAccountId::new("acct_core_refresh").expect("account ID");
|
||||
let quota_observed_at = SystemTime::now();
|
||||
let quota_reset_at = quota_observed_at
|
||||
.checked_add(Duration::from_secs(3_600))
|
||||
.expect("quota reset time");
|
||||
assert_eq!(
|
||||
repository
|
||||
.apply_quota_access(QuotaAccessChange {
|
||||
account_id: account_id.clone(),
|
||||
expected_revision: CredentialRevision::new(1).expect("credential revision"),
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
quota_observed_at,
|
||||
Some(quota_reset_at),
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("seed quota exhaustion"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
let state_observed_at = SystemTime::now();
|
||||
let refreshed = CredentialCasUpdate::new(
|
||||
account_id.clone(),
|
||||
CredentialRevision::new(1).expect("credential revision"),
|
||||
@@ -1224,7 +1399,8 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
Some(SystemTime::now() + Duration::from_secs(3_600)),
|
||||
Some(SystemTime::now() + Duration::from_secs(1_800)),
|
||||
)
|
||||
.expect("valid refresh update");
|
||||
.expect("valid refresh update")
|
||||
.with_account_state(CredentialState::Ready, state_observed_at, None, None);
|
||||
assert_eq!(
|
||||
repository
|
||||
.compare_and_swap_credential(refreshed)
|
||||
@@ -1235,24 +1411,37 @@ async fn core_refresh_cas_updates_profile_and_credential_under_one_revision() {
|
||||
)
|
||||
);
|
||||
|
||||
let row: (
|
||||
String,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
serde_json::Value,
|
||||
i64,
|
||||
) = sqlx::query_as(
|
||||
"select name, email, plan_type, provider_credentials_json, credential_revision
|
||||
let row = sqlx::query_as::<_, CoreRefreshRow>(
|
||||
"select name, email, plan_type, provider_credentials_json, credential_revision,
|
||||
credential_state, last_error_reason, last_error_message,
|
||||
quota_access_state, quota_evidence, quota_access_observed_at, quota_reset_at
|
||||
from provider_accounts where id = 'acct_core_refresh'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.expect("load refreshed account");
|
||||
assert_eq!(row.0, "after refresh");
|
||||
assert_eq!(row.1.as_deref(), Some("after@example.invalid"));
|
||||
assert_eq!(row.2.as_deref(), Some("premium"));
|
||||
assert_eq!(row.3["access_token"], "after-secret");
|
||||
assert_eq!(row.4, 2);
|
||||
assert_eq!(row.name, "after refresh");
|
||||
assert_eq!(row.email.as_deref(), Some("after@example.invalid"));
|
||||
assert_eq!(row.plan_type.as_deref(), Some("premium"));
|
||||
assert_eq!(
|
||||
row.provider_credentials_json["access_token"],
|
||||
"after-secret"
|
||||
);
|
||||
assert_eq!(row.credential_revision, 2);
|
||||
assert_eq!(row.credential_state, "ready");
|
||||
assert_eq!(row.last_error_reason, None);
|
||||
assert_eq!(row.last_error_message, None);
|
||||
assert_eq!(row.quota_access_state, "exhausted");
|
||||
assert_eq!(row.quota_evidence.as_deref(), Some("usage_limit_reached"));
|
||||
assert_eq!(
|
||||
row.quota_access_observed_at
|
||||
.map(|value| value.timestamp_micros()),
|
||||
Some(chrono::DateTime::<Utc>::from(quota_observed_at).timestamp_micros())
|
||||
);
|
||||
assert_eq!(
|
||||
row.quota_reset_at.map(|value| value.timestamp_micros()),
|
||||
Some(chrono::DateTime::<Utc>::from(quota_reset_at).timestamp_micros())
|
||||
);
|
||||
|
||||
assert!(
|
||||
repository
|
||||
|
||||
@@ -25,8 +25,9 @@ use gateway_admin::model::{MutationActor, MutationContext, Revision};
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
use gateway_core::accounting::Money;
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
@@ -49,7 +50,9 @@ use crate::credential::{
|
||||
CompleteCodexOAuthAuthorization, CompletedCodexOAuthCredential, ExportManagedCodexCredential,
|
||||
StartCodexOAuthAuthorization, StoredCodexPendingAuthorization,
|
||||
};
|
||||
use crate::credential::{CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref};
|
||||
use crate::credential::{
|
||||
CodexCredentialCodec, CodexOAuthSecret, oauth_owner_ref, parse_access_token_expiration,
|
||||
};
|
||||
use crate::transport::CodexWebSocketPool;
|
||||
use crate::transport::profile::{
|
||||
CodexDesktopReleaseSnapshot, CodexDesktopReleaseStatus, CodexWireProfile, CodexWireProfileState,
|
||||
@@ -258,7 +261,7 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
))
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
log_import_failure("prepare_document", credential_admin_error_code(*error));
|
||||
log_import_failure("prepare_document", credential_admin_error_code(error));
|
||||
})
|
||||
.map_err(map_credential_admin_error)?;
|
||||
let observed_at = Utc::now();
|
||||
@@ -380,8 +383,10 @@ impl ProviderAdmin for OpenAiAdminProvider {
|
||||
.oauth()
|
||||
.and_then(|oauth| oauth.id_token.clone());
|
||||
}
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let prepared = CodexCredentialAdmin
|
||||
.prepare_refreshed_oauth_rotation(current, secret, None, None)
|
||||
.prepare_refreshed_oauth_rotation(current, secret, access_token_expires_at, None)
|
||||
.map_err(map_credential_admin_error)?;
|
||||
prepared_rotation(prepared, command.account.provider_kind)
|
||||
}
|
||||
@@ -546,15 +551,16 @@ fn prepared_rotation(
|
||||
provider_kind: ProviderKind,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
let (profile, credential, replacement_identity, guard) = prepared.into_parts();
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
credential_profile,
|
||||
profile: credential_profile,
|
||||
credential,
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = credential.into_parts();
|
||||
account_state: _account_state,
|
||||
} = credential.into_parts();
|
||||
if profile != credential_profile || profile.account_id != account_id {
|
||||
return Err(provider_admin_error(ProviderAdminErrorKind::Internal));
|
||||
}
|
||||
@@ -1275,31 +1281,37 @@ fn map_store_error(error: gateway_core::error::StoreError) -> ProviderAdminError
|
||||
|
||||
fn map_credential_admin_error(error: CodexCredentialAdminError) -> ProviderAdminError {
|
||||
use CodexCredentialAdminError as Error;
|
||||
provider_admin_error(match error {
|
||||
let upstream_message = error.upstream_message().map(ToOwned::to_owned);
|
||||
let kind = match error {
|
||||
Error::InvalidInput
|
||||
| Error::InvalidCredential
|
||||
| Error::MissingRefreshToken
|
||||
| Error::RefreshRejected
|
||||
| Error::AccountBanned => ProviderAdminErrorKind::Invalid,
|
||||
| Error::RefreshRejected { .. }
|
||||
| Error::AccountBanned { .. } => ProviderAdminErrorKind::Invalid,
|
||||
Error::NotFound => ProviderAdminErrorKind::NotFound,
|
||||
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous => {
|
||||
Error::RefreshLeaseUnavailable | Error::RefreshAmbiguous { .. } => {
|
||||
ProviderAdminErrorKind::Conflict
|
||||
}
|
||||
Error::RefreshUnavailable => ProviderAdminErrorKind::Unavailable,
|
||||
})
|
||||
};
|
||||
let error = provider_admin_error(kind);
|
||||
match upstream_message {
|
||||
Some(message) => error.with_message(message),
|
||||
None => error,
|
||||
}
|
||||
}
|
||||
|
||||
const fn credential_admin_error_code(error: CodexCredentialAdminError) -> &'static str {
|
||||
const fn credential_admin_error_code(error: &CodexCredentialAdminError) -> &'static str {
|
||||
match error {
|
||||
CodexCredentialAdminError::InvalidInput => "invalid_input",
|
||||
CodexCredentialAdminError::InvalidCredential => "invalid_credential",
|
||||
CodexCredentialAdminError::NotFound => "not_found",
|
||||
CodexCredentialAdminError::MissingRefreshToken => "missing_refresh_token",
|
||||
CodexCredentialAdminError::RefreshLeaseUnavailable => "refresh_lease_unavailable",
|
||||
CodexCredentialAdminError::RefreshRejected => "refresh_rejected",
|
||||
CodexCredentialAdminError::AccountBanned => "account_banned",
|
||||
CodexCredentialAdminError::RefreshRejected { .. } => "refresh_rejected",
|
||||
CodexCredentialAdminError::AccountBanned { .. } => "account_banned",
|
||||
CodexCredentialAdminError::RefreshUnavailable => "refresh_unavailable",
|
||||
CodexCredentialAdminError::RefreshAmbiguous => "refresh_ambiguous",
|
||||
CodexCredentialAdminError::RefreshAmbiguous { .. } => "refresh_ambiguous",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1368,9 +1380,7 @@ fn map_quota_error(error: CodexCredentialQuotaError) -> ProviderAdminError {
|
||||
fn map_catalog_error(error: CodexCredentialCatalogError) -> ProviderAdminError {
|
||||
use CodexCredentialCatalogError as Error;
|
||||
provider_admin_error(match error {
|
||||
Error::InvalidCredentialData | Error::ConflictingModelFacts | Error::InvalidEtag => {
|
||||
ProviderAdminErrorKind::Invalid
|
||||
}
|
||||
Error::InvalidCredentialData | Error::InvalidEtag => ProviderAdminErrorKind::Invalid,
|
||||
Error::NoEligibleCredential => ProviderAdminErrorKind::NotFound,
|
||||
Error::ConcurrentUpdate => ProviderAdminErrorKind::Conflict,
|
||||
Error::Upstream { .. } | Error::Cache => ProviderAdminErrorKind::Unavailable,
|
||||
|
||||
@@ -376,7 +376,7 @@ impl fmt::Debug for PreparedCodexCredentialRotationGuard {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error)]
|
||||
pub enum CodexCredentialAdminError {
|
||||
#[error("Codex account input is invalid")]
|
||||
InvalidInput,
|
||||
@@ -389,13 +389,30 @@ pub enum CodexCredentialAdminError {
|
||||
#[error("Codex refresh lease is unavailable")]
|
||||
RefreshLeaseUnavailable,
|
||||
#[error("Codex refresh token was rejected")]
|
||||
RefreshRejected,
|
||||
RefreshRejected { message: Option<String> },
|
||||
#[error("Codex account is banned")]
|
||||
AccountBanned,
|
||||
AccountBanned { message: Option<String> },
|
||||
#[error("Codex refresh service is unavailable")]
|
||||
RefreshUnavailable,
|
||||
#[error("Codex refresh send state is ambiguous")]
|
||||
RefreshAmbiguous,
|
||||
RefreshAmbiguous { message: Option<String> },
|
||||
}
|
||||
|
||||
impl CodexCredentialAdminError {
|
||||
#[must_use]
|
||||
pub fn upstream_message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::RefreshRejected { message }
|
||||
| Self::AccountBanned { message }
|
||||
| Self::RefreshAmbiguous { message } => message.as_deref(),
|
||||
Self::InvalidInput
|
||||
| Self::InvalidCredential
|
||||
| Self::NotFound
|
||||
| Self::MissingRefreshToken
|
||||
| Self::RefreshLeaseUnavailable
|
||||
| Self::RefreshUnavailable => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 无状态的 Codex Admin command preparer。
|
||||
@@ -800,8 +817,15 @@ impl CodexCredentialAdminService {
|
||||
.refresher
|
||||
.refresh(refresh_token.expose_secret())
|
||||
.await
|
||||
.inspect_err(|error| log_manual_refresh_failure(&account_id, error))
|
||||
.map_err(map_refresh_failure)?;
|
||||
let access_token_rotated = tokens.access_token.is_some();
|
||||
let access_token_expires_at = match tokens.access_token.as_deref() {
|
||||
Some(access_token) => parse_access_token_expiration(access_token),
|
||||
None => current
|
||||
.account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
};
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: tokens
|
||||
.access_token
|
||||
@@ -821,17 +845,6 @@ impl CodexCredentialAdminService {
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
let access_token_expires_at = if access_token_rotated {
|
||||
tokens
|
||||
.expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from)
|
||||
} else {
|
||||
current
|
||||
.account
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from)
|
||||
};
|
||||
// 正常预刷新由 worker 读取当时的 runtime policy 动态判断;这里仅清除
|
||||
// 之前瞬态失败留下的 retry-not-before。
|
||||
let next_refresh_at = None;
|
||||
@@ -998,6 +1011,7 @@ impl CodexCredentialAdminService {
|
||||
let access_token = tokens
|
||||
.access_token
|
||||
.ok_or(CodexCredentialAdminError::InvalidCredential)?;
|
||||
let access_token_expires_at = parse_access_token_expiration(&access_token);
|
||||
let secret = CodexOAuthSecret {
|
||||
access_token: SecretString::from(access_token),
|
||||
// RT 未轮换时仍保留导入提供的 RT,保证本次补全不会丢失后续刷新能力。
|
||||
@@ -1012,10 +1026,6 @@ impl CodexCredentialAdminService {
|
||||
Some(account_id.as_str()),
|
||||
&secret,
|
||||
);
|
||||
let access_token_expires_at = tokens
|
||||
.expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from);
|
||||
Ok((secret, access_token_expires_at))
|
||||
}
|
||||
|
||||
@@ -1133,15 +1143,35 @@ fn china_rfc3339(value: DateTime<Utc>) -> String {
|
||||
.to_rfc3339()
|
||||
}
|
||||
|
||||
const fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
|
||||
fn map_refresh_failure(error: RefreshFailure) -> CodexCredentialAdminError {
|
||||
match error {
|
||||
RefreshFailure::InvalidGrant => CodexCredentialAdminError::RefreshRejected,
|
||||
RefreshFailure::Banned => CodexCredentialAdminError::AccountBanned,
|
||||
RefreshFailure::RetryableTransport => CodexCredentialAdminError::RefreshUnavailable,
|
||||
RefreshFailure::Transport => CodexCredentialAdminError::RefreshAmbiguous,
|
||||
RefreshFailure::InvalidGrant { message, .. } => {
|
||||
CodexCredentialAdminError::RefreshRejected { message }
|
||||
}
|
||||
RefreshFailure::Banned { message, .. } => {
|
||||
CodexCredentialAdminError::AccountBanned { message }
|
||||
}
|
||||
RefreshFailure::RetryableTransport { .. } => CodexCredentialAdminError::RefreshUnavailable,
|
||||
RefreshFailure::Transport { message, .. } => {
|
||||
CodexCredentialAdminError::RefreshAmbiguous { message }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn log_manual_refresh_failure(account_id: &ProviderAccountId, error: &RefreshFailure) {
|
||||
let upstream = error.upstream();
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
failure_class = error.classification(),
|
||||
upstream_message = ?error.message(),
|
||||
upstream_status = ?upstream.map(super::token_client::RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(super::token_client::RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(super::token_client::RefreshUpstreamFailure::body),
|
||||
"OpenAI OAuth manual refresh failed"
|
||||
);
|
||||
}
|
||||
|
||||
fn parse_import_document(
|
||||
payload: &Value,
|
||||
) -> Result<Vec<ParsedCodexImportAccount>, CodexCredentialAdminError> {
|
||||
|
||||
@@ -138,8 +138,6 @@ pub enum CodexCredentialCatalogError {
|
||||
InvalidCredentialData,
|
||||
#[error("Codex model catalog upstream query failed: {detail}")]
|
||||
Upstream { detail: String },
|
||||
#[error("Codex model catalog contains conflicting account facts")]
|
||||
ConflictingModelFacts,
|
||||
#[error("Codex model catalog cache is unavailable")]
|
||||
Cache,
|
||||
#[error("Codex model catalog ETag is invalid")]
|
||||
@@ -359,11 +357,6 @@ impl CodexCredentialCatalogService {
|
||||
union_order.push(entry.key().clone());
|
||||
entry.insert(model.clone());
|
||||
}
|
||||
// 展示与元数据允许跨套餐漂移,union 取首见值;只有路由
|
||||
// 事实冲突才让整轮刷新失败。
|
||||
Entry::Occupied(entry) if conflicting_routing_facts(entry.get(), model) => {
|
||||
return Err(CodexCredentialCatalogError::ConflictingModelFacts);
|
||||
}
|
||||
Entry::Occupied(_) => {}
|
||||
}
|
||||
}
|
||||
@@ -661,13 +654,6 @@ fn model_ids(models: &[CodexCatalogModel]) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 路由正确性只依赖 Responses API 支持证据(决定 Generate operation 是否
|
||||
/// 编译进 `ModelCapabilities`);display_name/limits/metadata 等展示事实
|
||||
/// 跨套餐漂移不构成冲突。
|
||||
fn conflicting_routing_facts(existing: &CodexCatalogModel, candidate: &CodexCatalogModel) -> bool {
|
||||
existing.capabilities().responses_api() != candidate.capabilities().responses_api()
|
||||
}
|
||||
|
||||
fn catalog_candidates_by_scope(
|
||||
accounts: Vec<ProviderAccount>,
|
||||
) -> Result<BTreeMap<CodexCatalogScope, Vec<ProviderAccount>>, CodexCredentialCatalogError> {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(crate) use affinity::{
|
||||
derive_codex_cyber_policy_session_key, derive_codex_session_affinity_key,
|
||||
};
|
||||
pub(crate) use oauth::oauth_owner_ref;
|
||||
pub(crate) use types::parse_access_token_expiration;
|
||||
|
||||
pub use agent_identity::{
|
||||
CodexAgentIdentityError, CodexAgentIdentitySecret, CodexAgentIdentityTaskRegistrar,
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
//! Codex Authorization Code + PKCE/OIDC 管理流。
|
||||
|
||||
use std::{
|
||||
fmt,
|
||||
sync::Arc,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use std::{fmt, sync::Arc, time::Duration};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
@@ -35,7 +31,7 @@ use super::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OFFICIAL_CODEX_OAUTH_CLIENT_ID, OFFICIAL_CODEX_REDIRECT_URI,
|
||||
};
|
||||
use super::types::parse_chatgpt_jwt_claims;
|
||||
use super::types::{parse_access_token_expiration, parse_chatgpt_jwt_claims};
|
||||
use crate::transport::profile::CodexWireProfileState;
|
||||
|
||||
const AUTHORIZATION_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
|
||||
@@ -527,13 +523,12 @@ impl CodexOAuthAdminService {
|
||||
.map(current_oauth_refresh_token)
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let (mut secret, id_token, expires_in) = self
|
||||
let (mut secret, id_token) = self
|
||||
.exchange_pending(&pending, callback_url, fallback_refresh_token)
|
||||
.await?;
|
||||
let mutation = pending.mutation.clone();
|
||||
let access_token_expires_at = expires_in
|
||||
.and_then(|expires_in| SystemTime::now().checked_add(expires_in))
|
||||
.map(DateTime::<Utc>::from);
|
||||
let access_token_expires_at =
|
||||
parse_access_token_expiration(secret.access_token.expose_secret());
|
||||
let metadata = parse_chatgpt_jwt_claims(id_token.expose_secret())
|
||||
.map_err(|_| CodexOAuthAdminError::TokenRejected)?;
|
||||
secret.id_token = Some(id_token);
|
||||
@@ -671,14 +666,7 @@ impl CodexOAuthAdminService {
|
||||
pending: &CodexPendingAuthorization,
|
||||
callback_url: &str,
|
||||
fallback_refresh_token: Option<SecretString>,
|
||||
) -> Result<
|
||||
(
|
||||
super::types::CodexOAuthSecret,
|
||||
SecretString,
|
||||
Option<Duration>,
|
||||
),
|
||||
CodexOAuthAdminError,
|
||||
> {
|
||||
) -> Result<(super::types::CodexOAuthSecret, SecretString), CodexOAuthAdminError> {
|
||||
let (code, callback_state) = callback_parts(callback_url)?;
|
||||
if !constant_time_equal(
|
||||
pending.state.expose_secret().as_bytes(),
|
||||
@@ -709,7 +697,7 @@ impl CodexOAuthAdminService {
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
Ok((secret, tokens.id_token, tokens.expires_in))
|
||||
Ok((secret, tokens.id_token))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! OpenAI quota 文档的规范化 map 与上游 `/usage` 输入适配。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use gateway_protocol::openai::events::{RateLimitDetails, RateLimitKeySource};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) const DEFAULT_CODEX_LIMIT_ID: &str = "codex";
|
||||
pub(super) const RATE_LIMITS_BY_LIMIT_ID: &str = "rate_limits_by_limit_id";
|
||||
|
||||
/// 官方新协议的多桶视图:每个 `limit_id` 对应一个独立快照。
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct RateLimitSnapshotsByLimitId {
|
||||
snapshots: BTreeMap<String, Map<String, Value>>,
|
||||
}
|
||||
|
||||
impl RateLimitSnapshotsByLimitId {
|
||||
/// 接收上游 `/usage` 形状或已经规范化的 map;输出始终只保留 map。
|
||||
pub(super) fn take_from_document(quota: &mut Map<String, Value>) -> Self {
|
||||
let mut result = Self::default();
|
||||
|
||||
if let Some(by_limit_id) = quota
|
||||
.remove(RATE_LIMITS_BY_LIMIT_ID)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
{
|
||||
for (raw_limit_id, value) in by_limit_id {
|
||||
let Some(limit_id) = normalize_limit_id(&raw_limit_id) else {
|
||||
continue;
|
||||
};
|
||||
let mut snapshot = snapshot_object(value);
|
||||
snapshot.insert("limit_id".to_owned(), Value::String(limit_id.clone()));
|
||||
result.snapshots.insert(limit_id, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(rate_limit) = quota.remove("rate_limit") {
|
||||
result.insert_usage_snapshot(DEFAULT_CODEX_LIMIT_ID, None, rate_limit);
|
||||
}
|
||||
if let Some(rate_limit) = quota.remove("code_review_rate_limit") {
|
||||
result.insert_usage_snapshot("code_review", Some("code_review"), rate_limit);
|
||||
}
|
||||
|
||||
let additional = quota
|
||||
.remove("additional_rate_limits")
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
for (index, value) in additional.into_iter().enumerate() {
|
||||
let Value::Object(mut item) = value else {
|
||||
result.insert_usage_snapshot(&format!("additional_{index}"), None, value);
|
||||
continue;
|
||||
};
|
||||
let limit_id = item_limit_id(&item).unwrap_or_else(|| format!("additional_{index}"));
|
||||
let limit_name = item
|
||||
.get("limit_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
let rate_limit = item.remove("rate_limit").unwrap_or(Value::Null);
|
||||
result.insert_usage_snapshot(&limit_id, limit_name.as_deref(), rate_limit);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn insert_usage_snapshot(
|
||||
&mut self,
|
||||
limit_id: &str,
|
||||
limit_name: Option<&str>,
|
||||
rate_limit: Value,
|
||||
) {
|
||||
let Some(limit_id) = normalize_limit_id(limit_id) else {
|
||||
return;
|
||||
};
|
||||
let mut snapshot = snapshot_object(rate_limit);
|
||||
snapshot.insert("limit_id".to_owned(), Value::String(limit_id.clone()));
|
||||
if let Some(limit_name) = limit_name {
|
||||
snapshot.insert(
|
||||
"limit_name".to_owned(),
|
||||
Value::String(limit_name.to_owned()),
|
||||
);
|
||||
}
|
||||
// map 协议中一个 ID 只能有一个快照;规范化 map 与顶层账号桶优先。
|
||||
self.snapshots.entry(limit_id).or_insert(snapshot);
|
||||
}
|
||||
|
||||
pub(super) fn upsert(
|
||||
&mut self,
|
||||
limit_id: &str,
|
||||
details: &RateLimitDetails,
|
||||
rate_limit: Map<String, Value>,
|
||||
) {
|
||||
let mut snapshot = self.snapshots.remove(limit_id).unwrap_or_default();
|
||||
for field in [
|
||||
"allowed",
|
||||
"limit_reached",
|
||||
"primary_window",
|
||||
"secondary_window",
|
||||
] {
|
||||
snapshot.remove(field);
|
||||
}
|
||||
snapshot.extend(rate_limit);
|
||||
snapshot.insert("limit_id".to_owned(), Value::String(limit_id.to_owned()));
|
||||
if let Some(name) = details.limit_name.as_ref() {
|
||||
snapshot.insert("limit_name".to_owned(), Value::String(name.clone()));
|
||||
}
|
||||
self.snapshots.insert(limit_id.to_owned(), snapshot);
|
||||
}
|
||||
|
||||
/// 把 wire 观察解析到稳定 `limit_id`。
|
||||
///
|
||||
/// WebSocket 的 `additional_rate_limits` 可能只返回可读名称;这时只接受
|
||||
/// `/usage` 或 HTTP headers 已建立的唯一名称映射。无法唯一解析就不落库,
|
||||
/// 避免凭名称猜 ID 或污染默认 `codex` 桶。
|
||||
pub(super) fn resolve_limit_id(&self, details: &RateLimitDetails) -> Option<String> {
|
||||
if details.key_source == RateLimitKeySource::LimitId {
|
||||
return normalize_limit_id(&details.limit_id);
|
||||
}
|
||||
|
||||
let limit_name = details.limit_name.as_deref()?.trim();
|
||||
let mut matches = self
|
||||
.snapshots
|
||||
.iter()
|
||||
.filter(|(_, snapshot)| {
|
||||
snapshot
|
||||
.get("limit_name")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|stored| stored.trim().eq_ignore_ascii_case(limit_name))
|
||||
})
|
||||
.map(|(limit_id, _)| limit_id.clone());
|
||||
let resolved = matches.next()?;
|
||||
matches.next().is_none().then_some(resolved)
|
||||
}
|
||||
|
||||
pub(super) fn write_to_document(self, quota: &mut Map<String, Value>) {
|
||||
let by_limit_id = self
|
||||
.snapshots
|
||||
.into_iter()
|
||||
.map(|(limit_id, snapshot)| (limit_id, Value::Object(snapshot)))
|
||||
.collect::<Map<_, _>>();
|
||||
quota.insert(
|
||||
RATE_LIMITS_BY_LIMIT_ID.to_owned(),
|
||||
Value::Object(by_limit_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn canonicalize_rate_limit_document(
|
||||
mut quota: Map<String, Value>,
|
||||
) -> Map<String, Value> {
|
||||
let snapshots = RateLimitSnapshotsByLimitId::take_from_document(&mut quota);
|
||||
snapshots.write_to_document(&mut quota);
|
||||
quota
|
||||
}
|
||||
|
||||
fn snapshot_object(value: Value) -> Map<String, Value> {
|
||||
value.as_object().cloned().unwrap_or_else(|| {
|
||||
let mut snapshot = Map::new();
|
||||
snapshot.insert("rate_limit".to_owned(), value);
|
||||
snapshot
|
||||
})
|
||||
}
|
||||
|
||||
fn item_limit_id(item: &Map<String, Value>) -> Option<String> {
|
||||
item.get("limit_id")
|
||||
.or_else(|| item.get("metered_feature"))
|
||||
.or_else(|| item.get("limit_name"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_limit_id)
|
||||
}
|
||||
|
||||
fn normalize_limit_id(value: &str) -> Option<String> {
|
||||
let value = value.trim();
|
||||
(!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control))
|
||||
.then(|| value.to_ascii_lowercase().replace('-', "_"))
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Codex quota 服务编排:权威额度事实、展示投影、主动/被动同步与 429 冷却。
|
||||
//!
|
||||
//! - [`document`]:上游 `/usage` 输入到多桶 map 的单向规范化。
|
||||
//! - [`snapshot`]:快照/窗口解析、聚合、O(1) 滚动与调度信号。
|
||||
//! - [`evidence`]:额度接口错误到凭据/额度事实的分类。
|
||||
|
||||
mod document;
|
||||
pub(crate) mod evidence;
|
||||
pub(crate) mod snapshot;
|
||||
|
||||
@@ -19,8 +21,8 @@ use chrono::Utc;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountErrorReason, AccountQuotaSignals, CredentialRevision, CredentialState,
|
||||
OpaqueProviderData, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaObservation, QuotaObservationTouch,
|
||||
QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::provider_ports::{ProviderCooldown, ProviderCooldownPort};
|
||||
use gateway_protocol::openai::events::{
|
||||
@@ -38,6 +40,10 @@ use crate::transport::{CodexBackendClient, CodexClientError, CodexRequestContext
|
||||
|
||||
use super::agent_identity::{CodexAgentIdentityTaskService, PreparedCodexRuntimeCredential};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use document::{
|
||||
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, RateLimitSnapshotsByLimitId,
|
||||
canonicalize_rate_limit_document,
|
||||
};
|
||||
use evidence::{QuotaEndpointFailure, classify_quota_endpoint_failure};
|
||||
use snapshot::{
|
||||
parse_account_quota_snapshot, quota_projection_ttl, quota_snapshot_from_observation,
|
||||
@@ -646,7 +652,25 @@ impl CodexCredentialQuotaService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !quota_refresh_can_update(account, &snapshot, previous.as_ref()) {
|
||||
if account.quota().is_exhausted()
|
||||
&& !quota_refresh_allows_recovery(account, &snapshot, previous.as_ref())
|
||||
{
|
||||
let outcome = self
|
||||
.store
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
observed_at,
|
||||
})
|
||||
.await?;
|
||||
if outcome == QuotaWriteOutcome::Conflict {
|
||||
summary.stale += 1;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(previous) = previous {
|
||||
self.scheduling
|
||||
.observe(&previous.with_observed_at(observed_at));
|
||||
}
|
||||
summary.exhausted += 1;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -688,10 +712,25 @@ impl CodexCredentialQuotaService {
|
||||
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let has_quota_facts = rate_limits
|
||||
.limits
|
||||
.values()
|
||||
.any(|details| passive_rate_limit_snapshot(details).is_some());
|
||||
self.synchronize_passive_rate_limits(account, std::slice::from_ref(&rate_limits))
|
||||
.await
|
||||
}
|
||||
|
||||
/// 把一次推理响应中采集的结构化限流观察合并后单次落库。
|
||||
pub async fn synchronize_passive_rate_limits(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
rate_limits: &[ParsedRateLimits],
|
||||
) -> Result<bool, CodexCredentialQuotaError> {
|
||||
if rate_limits.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let has_quota_facts = rate_limits.iter().any(|observation| {
|
||||
observation
|
||||
.limits
|
||||
.values()
|
||||
.any(|details| passive_rate_limit_snapshot(details).is_some())
|
||||
});
|
||||
let existing = self
|
||||
.store
|
||||
.get_quotas(std::slice::from_ref(account.id()))
|
||||
@@ -716,14 +755,14 @@ impl CodexCredentialQuotaService {
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
quota: OpaqueProviderData::new(merge_passive_quota(existing, &rate_limits)),
|
||||
quota: OpaqueProviderData::new(merge_passive_quota(existing, rate_limits)),
|
||||
observed_at: SystemTime::now(),
|
||||
state,
|
||||
})
|
||||
.await?;
|
||||
return Ok(outcome != QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
let quota = merge_passive_quota(existing, &rate_limits);
|
||||
let quota = merge_passive_quota(existing, rate_limits);
|
||||
let observed_at = SystemTime::now();
|
||||
let snapshot = parse_account_quota_snapshot(
|
||||
account.id().clone(),
|
||||
@@ -930,8 +969,29 @@ impl CodexCredentialQuotaService {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !quota_refresh_can_update(&account, &snapshot, previous.as_ref()) {
|
||||
return Ok(previous.unwrap_or_else(|| snapshot.with_quota_state(account.quota())));
|
||||
if !quota_refresh_allows_recovery(&account, &snapshot, previous.as_ref()) {
|
||||
if self
|
||||
.store
|
||||
.touch_quota_observation(QuotaObservationTouch {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
observed_at,
|
||||
})
|
||||
.await?
|
||||
== QuotaWriteOutcome::Conflict
|
||||
{
|
||||
return Err(CodexCredentialQuotaError::RevisionConflict);
|
||||
}
|
||||
return Ok(match previous {
|
||||
Some(previous) => {
|
||||
let previous = previous.with_observed_at(observed_at);
|
||||
self.scheduling.observe(&previous);
|
||||
previous
|
||||
}
|
||||
None => snapshot
|
||||
.with_quota_state(account.quota())
|
||||
.with_observed_at(observed_at),
|
||||
});
|
||||
}
|
||||
let state = if account.quota().is_exhausted() {
|
||||
QuotaState::allowed(observed_at)
|
||||
@@ -1027,7 +1087,7 @@ impl CodexCredentialQuotaService {
|
||||
}
|
||||
}
|
||||
|
||||
fn quota_refresh_can_update(
|
||||
fn quota_refresh_allows_recovery(
|
||||
account: &ProviderAccount,
|
||||
refreshed: &CodexAccountQuotaSnapshot,
|
||||
previous: Option<&CodexAccountQuotaSnapshot>,
|
||||
@@ -1138,18 +1198,48 @@ fn access_token_is_current(account: &ProviderAccount, now: SystemTime) -> bool {
|
||||
|
||||
fn merge_passive_quota(
|
||||
mut quota: Map<String, Value>,
|
||||
rate_limits: &ParsedRateLimits,
|
||||
observations: &[ParsedRateLimits],
|
||||
) -> Map<String, Value> {
|
||||
let active_limit = rate_limits
|
||||
.active_limit
|
||||
.as_deref()
|
||||
.or_else(|| rate_limits.limits.contains_key("codex").then_some("codex"));
|
||||
if let Some(active_limit) = active_limit {
|
||||
quota.insert(
|
||||
"active_limit".to_owned(),
|
||||
Value::String(active_limit.to_owned()),
|
||||
);
|
||||
let mut snapshots = RateLimitSnapshotsByLimitId::take_from_document(&mut quota);
|
||||
for rate_limits in observations {
|
||||
let default_is_named_alias = default_limit_is_named_alias(rate_limits);
|
||||
let mut resolved_limit_ids = BTreeMap::new();
|
||||
for (wire_limit_id, details) in &rate_limits.limits {
|
||||
// HTTP 与 WebSocket 都可能把活动具名桶镜像为默认 `codex` 窗口。
|
||||
// 同一 wire 观察里存在相同具名事实时丢弃镜像,不触碰 core 桶。
|
||||
if wire_limit_id == DEFAULT_CODEX_LIMIT_ID && default_is_named_alias {
|
||||
continue;
|
||||
}
|
||||
let Some(limit_id) = snapshots.resolve_limit_id(details) else {
|
||||
continue;
|
||||
};
|
||||
let Some(rate_limit) = passive_rate_limit_snapshot(details) else {
|
||||
continue;
|
||||
};
|
||||
snapshots.upsert(&limit_id, details, rate_limit);
|
||||
resolved_limit_ids.insert(wire_limit_id.as_str(), limit_id);
|
||||
}
|
||||
|
||||
let active_limit = rate_limits
|
||||
.active_limit
|
||||
.as_deref()
|
||||
.and_then(|wire_limit_id| {
|
||||
resolved_limit_ids.get(wire_limit_id).cloned().or_else(|| {
|
||||
(!rate_limits.limits.contains_key(wire_limit_id))
|
||||
.then(|| wire_limit_id.to_owned())
|
||||
})
|
||||
})
|
||||
.or_else(|| resolved_limit_ids.get(DEFAULT_CODEX_LIMIT_ID).cloned());
|
||||
if let Some(active_limit) = active_limit {
|
||||
quota.insert("active_limit".to_owned(), Value::String(active_limit));
|
||||
}
|
||||
merge_passive_metadata(&mut quota, rate_limits);
|
||||
}
|
||||
snapshots.write_to_document(&mut quota);
|
||||
quota
|
||||
}
|
||||
|
||||
fn merge_passive_metadata(quota: &mut Map<String, Value>, rate_limits: &ParsedRateLimits) {
|
||||
if let Some(plan_type) = rate_limits.plan_type.as_ref() {
|
||||
quota.insert("plan_type".to_owned(), Value::String(plan_type.clone()));
|
||||
}
|
||||
@@ -1177,48 +1267,19 @@ fn merge_passive_quota(
|
||||
}
|
||||
quota.insert("credits".to_owned(), Value::Object(value));
|
||||
}
|
||||
}
|
||||
|
||||
let mut additional = quota
|
||||
.remove("additional_rate_limits")
|
||||
.and_then(|value| value.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
for (limit_id, details) in &rate_limits.limits {
|
||||
let Some(rate_limit) = passive_rate_limit_snapshot(details) else {
|
||||
continue;
|
||||
};
|
||||
if Some(limit_id.as_str()) == active_limit {
|
||||
quota.insert("rate_limit".to_owned(), Value::Object(rate_limit));
|
||||
continue;
|
||||
}
|
||||
let index = additional.iter().position(|item| {
|
||||
item.get("metered_feature")
|
||||
.or_else(|| item.get("limit_name"))
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|existing| existing == limit_id)
|
||||
});
|
||||
let mut item = index
|
||||
.and_then(|index| additional.get(index))
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
item.insert(
|
||||
"metered_feature".to_owned(),
|
||||
Value::String(limit_id.clone()),
|
||||
);
|
||||
if let Some(name) = details.limit_name.as_ref() {
|
||||
item.insert("limit_name".to_owned(), Value::String(name.clone()));
|
||||
}
|
||||
item.insert("rate_limit".to_owned(), Value::Object(rate_limit));
|
||||
match index {
|
||||
Some(index) => additional[index] = Value::Object(item),
|
||||
None => additional.push(Value::Object(item)),
|
||||
}
|
||||
}
|
||||
quota.insert(
|
||||
"additional_rate_limits".to_owned(),
|
||||
Value::Array(additional),
|
||||
);
|
||||
quota
|
||||
fn default_limit_is_named_alias(rate_limits: &ParsedRateLimits) -> bool {
|
||||
let Some(default_primary) = rate_limits
|
||||
.limits
|
||||
.get(DEFAULT_CODEX_LIMIT_ID)
|
||||
.and_then(|details| details.primary)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
rate_limits.limits.iter().any(|(limit_id, details)| {
|
||||
limit_id != DEFAULT_CODEX_LIMIT_ID && details.primary == Some(default_primary)
|
||||
})
|
||||
}
|
||||
|
||||
/// 丢弃 core 限额中上游给出的无事实 `secondary_window` 占位。
|
||||
@@ -1230,7 +1291,13 @@ fn merge_passive_quota(
|
||||
/// 无事实值。带 reset、时长、正用量、触顶、未知或非法字段的次级窗口均完全按
|
||||
/// 原有额度逻辑保留。
|
||||
fn normalize_quota_window_placeholders(mut quota: Map<String, Value>) -> Map<String, Value> {
|
||||
if let Some(rate_limit) = quota.get_mut("rate_limit").and_then(Value::as_object_mut) {
|
||||
quota = canonicalize_rate_limit_document(quota);
|
||||
if let Some(rate_limit) = quota
|
||||
.get_mut(RATE_LIMITS_BY_LIMIT_ID)
|
||||
.and_then(Value::as_object_mut)
|
||||
.and_then(|limits| limits.get_mut(DEFAULT_CODEX_LIMIT_ID))
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
drop_secondary_window_placeholder(rate_limit);
|
||||
}
|
||||
quota
|
||||
|
||||
@@ -12,6 +12,9 @@ use gateway_core::engine::credential::{
|
||||
};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::document::{
|
||||
DEFAULT_CODEX_LIMIT_ID, RATE_LIMITS_BY_LIMIT_ID, canonicalize_rate_limit_document,
|
||||
};
|
||||
use super::{CodexCredentialQuotaError, QUOTA_SCHEDULING_TTL};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -151,6 +154,13 @@ impl CodexAccountQuotaSnapshot {
|
||||
self.quota = quota;
|
||||
self
|
||||
}
|
||||
|
||||
/// 只更新展示与调度使用的最后成功查询时间,保留既有额度事实。
|
||||
#[must_use]
|
||||
pub(crate) const fn with_observed_at(mut self, observed_at: SystemTime) -> Self {
|
||||
self.observed_at = observed_at;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl CodexQuotaFact {
|
||||
@@ -166,9 +176,18 @@ impl CodexQuotaFact {
|
||||
}
|
||||
|
||||
pub fn parse_codex_quota_usage(usage: &Value) -> Result<CodexQuotaFact, CodexCredentialQuotaError> {
|
||||
let object = usage
|
||||
.as_object()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let object = canonicalize_rate_limit_document(
|
||||
usage
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
|
||||
);
|
||||
parse_codex_quota_object(&object)
|
||||
}
|
||||
|
||||
fn parse_codex_quota_object(
|
||||
object: &Map<String, Value>,
|
||||
) -> Result<CodexQuotaFact, CodexCredentialQuotaError> {
|
||||
let mut aggregate = QuotaAggregate::default();
|
||||
for limit in canonical_rate_limits(object)? {
|
||||
aggregate.observe_rate_limit(limit.rate_limit)?;
|
||||
@@ -189,6 +208,15 @@ pub fn parse_codex_quota_usage(usage: &Value) -> Result<CodexQuotaFact, CodexCre
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_quota_object(usage: &Value) -> Result<Map<String, Value>, CodexCredentialQuotaError> {
|
||||
Ok(canonicalize_rate_limit_document(
|
||||
usage
|
||||
.as_object()
|
||||
.cloned()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn quota_snapshot_from_observation(
|
||||
observation: &QuotaObservation,
|
||||
) -> Option<CodexAccountQuotaSnapshot> {
|
||||
@@ -270,13 +298,11 @@ pub(crate) fn parse_account_quota_snapshot(
|
||||
observed_at: SystemTime,
|
||||
usage: &Value,
|
||||
) -> Result<CodexAccountQuotaSnapshot, CodexCredentialQuotaError> {
|
||||
let fact = parse_codex_quota_usage(usage)?;
|
||||
let object = usage
|
||||
.as_object()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let quota = authoritative_quota_state(object, observed_at)?;
|
||||
let object = canonical_quota_object(usage)?;
|
||||
let fact = parse_codex_quota_object(&object)?;
|
||||
let quota = authoritative_quota_state(&object, observed_at)?;
|
||||
let mut windows = Vec::new();
|
||||
for limit in canonical_rate_limits(object)? {
|
||||
for limit in canonical_rate_limits(&object)? {
|
||||
parse_rate_limit_windows(
|
||||
&limit.key,
|
||||
&limit.source,
|
||||
@@ -309,12 +335,16 @@ fn quota_source_order(source: &str) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶层 account-wide Codex 桶是唯一可改变账号额度访问结论的 quota 响应字段。
|
||||
/// 默认 `codex` 桶是唯一可改变账号额度访问结论的 quota 响应字段。
|
||||
fn authoritative_quota_state(
|
||||
usage: &Map<String, Value>,
|
||||
observed_at: SystemTime,
|
||||
) -> Result<QuotaState, CodexCredentialQuotaError> {
|
||||
let Some(rate_limit) = usage.get("rate_limit") else {
|
||||
let Some(rate_limit) = usage
|
||||
.get(RATE_LIMITS_BY_LIMIT_ID)
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|limits| limits.get(DEFAULT_CODEX_LIMIT_ID))
|
||||
else {
|
||||
return Ok(QuotaState::observed_unknown(observed_at));
|
||||
};
|
||||
if rate_limit.is_null() {
|
||||
@@ -391,64 +421,32 @@ fn canonical_rate_limits(
|
||||
object: &Map<String, Value>,
|
||||
) -> Result<Vec<CanonicalRateLimit<'_>>, CodexCredentialQuotaError> {
|
||||
let mut limits = Vec::new();
|
||||
let has_primary_codex_rate_limit = object
|
||||
.get("rate_limit")
|
||||
.is_some_and(|value| !value.is_null());
|
||||
if let Some(rate_limit) = object.get("rate_limit") {
|
||||
// 官方 Codex 将顶层 `rate_limit` 固定识别为 `codex`,而不把
|
||||
// `active_limit` 投影成另一个展示桶。后者只是当前活动套餐的元数据。
|
||||
limits.push(CanonicalRateLimit {
|
||||
key: "core".to_owned(),
|
||||
source: "codex".to_owned(),
|
||||
account_wide: true,
|
||||
limit_name: None,
|
||||
rate_limit,
|
||||
});
|
||||
}
|
||||
if let Some(rate_limit) = object
|
||||
.get("code_review_rate_limit")
|
||||
.filter(|value| !value.is_null())
|
||||
{
|
||||
limits.push(CanonicalRateLimit {
|
||||
key: "code-review".to_owned(),
|
||||
source: "code_review".to_owned(),
|
||||
account_wide: false,
|
||||
limit_name: Some("code_review".to_owned()),
|
||||
rate_limit,
|
||||
});
|
||||
}
|
||||
let Some(additional) = object
|
||||
.get("additional_rate_limits")
|
||||
.filter(|value| !value.is_null())
|
||||
else {
|
||||
let Some(by_limit_id) = object.get(RATE_LIMITS_BY_LIMIT_ID) else {
|
||||
return Ok(limits);
|
||||
};
|
||||
for (index, value) in additional
|
||||
.as_array()
|
||||
for (limit_id, rate_limit) in by_limit_id
|
||||
.as_object()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?
|
||||
.iter()
|
||||
.enumerate()
|
||||
{
|
||||
let item = value
|
||||
let item = rate_limit
|
||||
.as_object()
|
||||
.ok_or(CodexCredentialQuotaError::InvalidCredentialData)?;
|
||||
let (source, limit_name) = canonical_rate_limit_source(item, index);
|
||||
// 部分上游响应会在 `additional_rate_limits` 中重复默认的 `codex`
|
||||
// 桶。它没有独立的 limit id,不能在账号面板再次展示。
|
||||
if has_primary_codex_rate_limit && is_codex_limit_id(&source) {
|
||||
continue;
|
||||
}
|
||||
if let Some(rate_limit) = item.get("rate_limit") {
|
||||
// 非 Codex additional 桶保留为独立快照;额外用索引生成窗口键,
|
||||
// 避免同名 additional 产生重复前端 key。
|
||||
limits.push(CanonicalRateLimit {
|
||||
key: format!("additional-{index}-{source}"),
|
||||
account_wide: is_codex_limit_id(&source),
|
||||
source,
|
||||
limit_name,
|
||||
rate_limit,
|
||||
});
|
||||
}
|
||||
let source = item
|
||||
.get("limit_id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(limit_id)
|
||||
.to_owned();
|
||||
let limit_name = item
|
||||
.get("limit_name")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned);
|
||||
limits.push(CanonicalRateLimit {
|
||||
key: source.clone(),
|
||||
account_wide: is_codex_limit_id(&source),
|
||||
source,
|
||||
limit_name,
|
||||
rate_limit,
|
||||
});
|
||||
}
|
||||
Ok(limits)
|
||||
}
|
||||
@@ -457,30 +455,6 @@ fn is_codex_limit_id(value: &str) -> bool {
|
||||
value.trim().eq_ignore_ascii_case("codex")
|
||||
}
|
||||
|
||||
fn canonical_rate_limit_source(
|
||||
item: &Map<String, Value>,
|
||||
index: usize,
|
||||
) -> (String, Option<String>) {
|
||||
let source = item
|
||||
.get("metered_feature")
|
||||
.or_else(|| item.get("limit_name"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| {
|
||||
!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control)
|
||||
})
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("additional-{index}"));
|
||||
let limit_name = item
|
||||
.get("limit_name")
|
||||
.or_else(|| item.get("metered_feature"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| {
|
||||
!value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control)
|
||||
})
|
||||
.map(str::to_owned);
|
||||
(source, limit_name)
|
||||
}
|
||||
|
||||
fn parse_rate_limit_windows(
|
||||
key_source: &str,
|
||||
source: &str,
|
||||
|
||||
@@ -20,8 +20,8 @@ use thiserror::Error;
|
||||
|
||||
use super::recovery_log::{CodexOAuthRecoveryOperation, record_oauth_recovery};
|
||||
use super::repository::{CodexCredentialRepository, CredentialRepositoryError};
|
||||
use super::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use super::types::CodexOAuthSecret;
|
||||
use super::token_client::{RefreshFailure, RefreshUpstreamFailure, TokenPair, TokenRefresher};
|
||||
use super::types::{CodexOAuthSecret, parse_access_token_expiration};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
const MAX_REFRESH_BATCH: u32 = 1_000;
|
||||
@@ -32,7 +32,7 @@ const REFRESH_BACKOFF_MAX_ATTEMPTS: u32 = 5;
|
||||
/// 耗尽指数退避后的 OAuth 恢复周期。
|
||||
const REFRESH_RECOVERY_DELAY: Duration = Duration::from_secs(10 * 60);
|
||||
/// 过期 AT 仍允许 RT 恢复的最长窗口。
|
||||
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const REFRESH_RECOVERY_WINDOW: Duration = Duration::from_secs(2 * 60 * 60);
|
||||
|
||||
pub(crate) fn refresh_recovery_deadline(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
@@ -91,12 +91,19 @@ fn log_refresh_deferred(
|
||||
access_token_expires_at: Option<SystemTime>,
|
||||
attempt: u32,
|
||||
reason: &'static str,
|
||||
upstream_message: Option<&str>,
|
||||
upstream: Option<&RefreshUpstreamFailure>,
|
||||
retry_at: SystemTime,
|
||||
) {
|
||||
tracing::warn!(
|
||||
account_id = %account_id,
|
||||
attempt,
|
||||
reason,
|
||||
upstream_message = ?upstream_message,
|
||||
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
|
||||
retry_at = %DateTime::<Utc>::from(retry_at),
|
||||
access_token_expires_at = ?access_token_expires_at.map(DateTime::<Utc>::from),
|
||||
recovery_deadline = ?refresh_recovery_deadline(access_token_expires_at)
|
||||
@@ -105,6 +112,26 @@ fn log_refresh_deferred(
|
||||
);
|
||||
}
|
||||
|
||||
struct RefreshFailureContext<'a> {
|
||||
reason: &'static str,
|
||||
message: Option<String>,
|
||||
upstream: Option<&'a RefreshUpstreamFailure>,
|
||||
}
|
||||
|
||||
impl<'a> RefreshFailureContext<'a> {
|
||||
const fn new(
|
||||
reason: &'static str,
|
||||
message: Option<String>,
|
||||
upstream: Option<&'a RefreshUpstreamFailure>,
|
||||
) -> Self {
|
||||
Self {
|
||||
reason,
|
||||
message,
|
||||
upstream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_due_at(
|
||||
account: &ProviderAccount,
|
||||
policy: ProviderRefreshPolicy,
|
||||
@@ -248,20 +275,10 @@ impl CodexCredentialRefreshService {
|
||||
policy: ProviderRefreshPolicy,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
let account_id = due.account.id().to_string();
|
||||
if refresh_recovery_window_exhausted(
|
||||
let recovery_window_exhausted = refresh_recovery_window_exhausted(
|
||||
due.account.access_token_expires_at(),
|
||||
SystemTime::now(),
|
||||
) {
|
||||
return self
|
||||
.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Expired,
|
||||
AccountErrorReason::CredentialExpired,
|
||||
"refresh_recovery_window_exhausted",
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await;
|
||||
}
|
||||
);
|
||||
let capacity = self
|
||||
.leases
|
||||
.try_acquire(ProviderLeaseRequest::RefreshCapacity(
|
||||
@@ -291,31 +308,72 @@ impl CodexCredentialRefreshService {
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.ok_or(CodexCredentialRefreshError::InvalidRefreshResponse)?;
|
||||
match self.refresher.refresh(refresh_token.expose_secret()).await {
|
||||
let refresh_result = self.refresher.refresh(refresh_token.expose_secret()).await;
|
||||
if recovery_window_exhausted && let Err(failure) = &refresh_result {
|
||||
let message = failure.message().map(str::to_owned);
|
||||
let upstream = failure.upstream();
|
||||
return match failure {
|
||||
RefreshFailure::Banned { .. } => {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Banned,
|
||||
AccountErrorReason::AccountBanned,
|
||||
RefreshFailureContext::new(
|
||||
"refresh_recovery_window_exhausted",
|
||||
message,
|
||||
upstream,
|
||||
),
|
||||
CodexCredentialRefreshOutcome::Banned { account_id },
|
||||
)
|
||||
.await
|
||||
}
|
||||
RefreshFailure::InvalidGrant { .. }
|
||||
| RefreshFailure::RetryableTransport { .. }
|
||||
| RefreshFailure::Transport { .. } => {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Expired,
|
||||
AccountErrorReason::CredentialExpired,
|
||||
RefreshFailureContext::new(
|
||||
"refresh_recovery_window_exhausted",
|
||||
message,
|
||||
upstream,
|
||||
),
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
}
|
||||
match refresh_result {
|
||||
Ok(tokens) => self.persist_success(due, tokens).await,
|
||||
Err(RefreshFailure::InvalidGrant) => {
|
||||
Err(RefreshFailure::InvalidGrant { message, upstream }) => {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Expired,
|
||||
AccountErrorReason::CredentialExpired,
|
||||
"refresh_invalid_grant",
|
||||
RefreshFailureContext::new(
|
||||
"refresh_invalid_grant",
|
||||
message,
|
||||
upstream.as_deref(),
|
||||
),
|
||||
CodexCredentialRefreshOutcome::Invalidated { account_id },
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(RefreshFailure::Banned) => {
|
||||
Err(RefreshFailure::Banned { message, upstream }) => {
|
||||
self.persist_terminal(
|
||||
&due.account,
|
||||
CredentialState::Banned,
|
||||
AccountErrorReason::AccountBanned,
|
||||
"account_banned",
|
||||
RefreshFailureContext::new("account_banned", message, upstream.as_deref()),
|
||||
CodexCredentialRefreshOutcome::Banned { account_id },
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(RefreshFailure::RetryableTransport) => {
|
||||
Err(RefreshFailure::RetryableTransport { message }) => {
|
||||
if self
|
||||
.defer_refresh(&due.account, "transport-not-sent")
|
||||
.defer_refresh(&due.account, "transport-not-sent", Some(&message), None)
|
||||
.await?
|
||||
{
|
||||
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
|
||||
@@ -323,11 +381,16 @@ impl CodexCredentialRefreshService {
|
||||
Ok(CodexCredentialRefreshOutcome::Stale { account_id })
|
||||
}
|
||||
}
|
||||
Err(RefreshFailure::Transport) => {
|
||||
// 上游瞬态(429/5xx/超时/畸形响应等,发送状态未知)保留现有凭据、
|
||||
// 推进退避重试;仅 invalid_grant/banned 才终态失效账号。
|
||||
Err(RefreshFailure::Transport { message, upstream }) => {
|
||||
// 上游瞬态(401/429/5xx/超时/畸形响应等)保留现有凭据、
|
||||
// 记录最近一次失败并推进有界退避。
|
||||
if self
|
||||
.defer_refresh(&due.account, "transport-ambiguous")
|
||||
.defer_refresh(
|
||||
&due.account,
|
||||
"transport-ambiguous",
|
||||
message.as_deref(),
|
||||
upstream.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(CodexCredentialRefreshOutcome::Transient { account_id })
|
||||
@@ -403,9 +466,11 @@ impl CodexCredentialRefreshService {
|
||||
access_token,
|
||||
refresh_token: rotated_refresh_token,
|
||||
id_token: rotated_id_token,
|
||||
expires_in,
|
||||
} = tokens;
|
||||
let access_token_rotated = access_token.is_some();
|
||||
let access_token_expires_at = match access_token.as_deref() {
|
||||
Some(access_token) => parse_access_token_expiration(access_token).map(SystemTime::from),
|
||||
None => due.account.access_token_expires_at(),
|
||||
};
|
||||
let CodexOAuthSecret {
|
||||
access_token: current_access_token,
|
||||
refresh_token: current_refresh_token,
|
||||
@@ -432,12 +497,6 @@ impl CodexCredentialRefreshService {
|
||||
.as_ref()
|
||||
.map(ExposeSecret::expose_secret),
|
||||
);
|
||||
let observed_at = SystemTime::now();
|
||||
let access_token_expires_at = if access_token_rotated {
|
||||
expires_in.and_then(|value| observed_at.checked_add(value))
|
||||
} else {
|
||||
due.account.access_token_expires_at()
|
||||
};
|
||||
// 成功轮换清掉仅用于失败退避的 retry-not-before;正常预刷新窗口由
|
||||
// worker 结合当前 runtime policy 动态判断,不写入账号时间字段。
|
||||
let next_refresh_at = None;
|
||||
@@ -483,9 +542,14 @@ impl CodexCredentialRefreshService {
|
||||
account: &ProviderAccount,
|
||||
credential_state: CredentialState,
|
||||
error_reason: AccountErrorReason,
|
||||
reason: &'static str,
|
||||
failure: RefreshFailureContext<'_>,
|
||||
outcome: CodexCredentialRefreshOutcome,
|
||||
) -> Result<CodexCredentialRefreshOutcome, CodexCredentialRefreshError> {
|
||||
let RefreshFailureContext {
|
||||
reason,
|
||||
message,
|
||||
upstream,
|
||||
} = failure;
|
||||
match self.repository.load_runtime_credential(account).await {
|
||||
Ok(_) => {}
|
||||
Err(CredentialRepositoryError::RevisionConflict) => {
|
||||
@@ -495,6 +559,12 @@ impl CodexCredentialRefreshService {
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
let persisted_message = message
|
||||
.as_ref()
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.map(String::as_str)
|
||||
.or_else(|| account.last_error_message())
|
||||
.map(str::to_owned);
|
||||
match self
|
||||
.repository
|
||||
.apply_state_with_reason(
|
||||
@@ -502,7 +572,7 @@ impl CodexCredentialRefreshService {
|
||||
credential_state,
|
||||
SystemTime::now(),
|
||||
Some(error_reason),
|
||||
Some(reason.to_owned()),
|
||||
persisted_message,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -511,6 +581,11 @@ impl CodexCredentialRefreshService {
|
||||
account_id = %account.id(),
|
||||
?credential_state,
|
||||
reason,
|
||||
upstream_message = ?message.as_deref(),
|
||||
upstream_status = ?upstream.map(RefreshUpstreamFailure::status),
|
||||
upstream_code = ?upstream.and_then(RefreshUpstreamFailure::code),
|
||||
upstream_type = ?upstream.and_then(RefreshUpstreamFailure::error_type),
|
||||
upstream_body = ?upstream.map(RefreshUpstreamFailure::body),
|
||||
access_token_expires_at = ?account.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
recovery_deadline = ?refresh_recovery_deadline(account.access_token_expires_at())
|
||||
@@ -532,6 +607,8 @@ impl CodexCredentialRefreshService {
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
reason: &'static str,
|
||||
upstream_message: Option<&str>,
|
||||
upstream: Option<&RefreshUpstreamFailure>,
|
||||
) -> Result<bool, CodexCredentialRefreshError> {
|
||||
let attempt = self
|
||||
.credential_state
|
||||
@@ -545,13 +622,28 @@ impl CodexCredentialRefreshService {
|
||||
attempt,
|
||||
reason,
|
||||
)?;
|
||||
match self.repository.defer_refresh(account, retry_at).await {
|
||||
let message = upstream_message
|
||||
.filter(|message| !message.trim().is_empty())
|
||||
.map(str::to_owned);
|
||||
let error_reason = upstream.map(|_| {
|
||||
account
|
||||
.credential_state()
|
||||
.error_reason()
|
||||
.unwrap_or(AccountErrorReason::AccessTokenExpired)
|
||||
});
|
||||
match self
|
||||
.repository
|
||||
.defer_refresh(account, retry_at, error_reason, message)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
log_refresh_deferred(
|
||||
account.id(),
|
||||
account.access_token_expires_at(),
|
||||
attempt,
|
||||
reason,
|
||||
upstream_message,
|
||||
upstream,
|
||||
retry_at,
|
||||
);
|
||||
Ok(true)
|
||||
|
||||
@@ -120,15 +120,18 @@ impl CodexCredentialRepository {
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
)
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?
|
||||
.with_account_state(CredentialState::Ready, SystemTime::now(), None, None);
|
||||
cas_revision(self.store.compare_and_swap_credential(update).await?)
|
||||
}
|
||||
|
||||
/// token 签名边界不可用时,以相同 credential 推进持久刷新退避。
|
||||
/// 以相同 credential 原子推进刷新退避及本次上游错误事实。
|
||||
pub async fn defer_refresh(
|
||||
&self,
|
||||
account: &ProviderAccount,
|
||||
next_refresh_at: SystemTime,
|
||||
error_reason: Option<AccountErrorReason>,
|
||||
message: Option<String>,
|
||||
) -> Result<CredentialRevision, CredentialRepositoryError> {
|
||||
let current = self
|
||||
.store
|
||||
@@ -140,7 +143,7 @@ impl CodexCredentialRepository {
|
||||
let data = CodexCredentialCodec::decode_complete(¤t.credential)?;
|
||||
let has_refresh_token = data.has_refresh_token();
|
||||
let credential = CodexCredentialCodec::encode_complete(data)?;
|
||||
let update = CredentialCasUpdate::new(
|
||||
let mut update = CredentialCasUpdate::new(
|
||||
account.id().clone(),
|
||||
account.revision(),
|
||||
unchanged_profile(account),
|
||||
@@ -150,6 +153,14 @@ impl CodexCredentialRepository {
|
||||
Some(next_refresh_at),
|
||||
)
|
||||
.map_err(|_| CredentialRepositoryError::InvalidCredentialData)?;
|
||||
if let Some(error_reason) = error_reason {
|
||||
update = update.with_account_state(
|
||||
account.credential_state(),
|
||||
SystemTime::now(),
|
||||
Some(error_reason),
|
||||
message,
|
||||
);
|
||||
}
|
||||
cas_revision(self.store.compare_and_swap_credential(update).await?)
|
||||
}
|
||||
|
||||
@@ -245,13 +256,11 @@ impl CodexCredentialRepository {
|
||||
if !account.enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
let (error_reason, message) = if credential_state == CredentialState::Ready {
|
||||
(None, None)
|
||||
let message = message.filter(|value| !value.trim().is_empty());
|
||||
let error_reason = if credential_state == CredentialState::Ready {
|
||||
message.as_ref().and(error_reason)
|
||||
} else {
|
||||
(
|
||||
error_reason.or_else(|| credential_state.error_reason()),
|
||||
message.filter(|value| !value.trim().is_empty()),
|
||||
)
|
||||
error_reason.or_else(|| credential_state.error_reason())
|
||||
};
|
||||
self.store
|
||||
.apply_state_change(AccountStateChange {
|
||||
|
||||
@@ -720,7 +720,15 @@ impl CodexCredentialSelector {
|
||||
.map(chrono::DateTime::<chrono::Utc>::from),
|
||||
"OpenAI access token expired; retaining account for bounded OAuth refresh recovery"
|
||||
);
|
||||
return Ok(());
|
||||
return self
|
||||
.apply_credential_state(
|
||||
account,
|
||||
CredentialState::Ready,
|
||||
AccountErrorReason::AccessTokenExpired,
|
||||
now,
|
||||
message,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
self.apply_credential_state(
|
||||
account,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{Client, StatusCode, redirect::Policy};
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -27,9 +27,6 @@ pub struct TokenPair {
|
||||
pub refresh_token: Option<String>,
|
||||
/// 官方刷新响应省略时由持久化调用方保留当前 ID token。
|
||||
pub id_token: Option<String>,
|
||||
/// OAuth endpoint 可以省略或返回零 expiry;这不影响已经成功的 token
|
||||
/// exchange,只意味着调用方不能据此安排下一次刷新。
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for TokenPair {
|
||||
@@ -45,22 +42,131 @@ impl fmt::Debug for TokenPair {
|
||||
&self.refresh_token.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("id_token", &self.id_token.as_ref().map(|_| "[REDACTED]"))
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex token 刷新的稳定失败分类。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
#[derive(Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum RefreshFailure {
|
||||
#[error("refresh token is invalid or expired")]
|
||||
InvalidGrant,
|
||||
InvalidGrant {
|
||||
message: Option<String>,
|
||||
upstream: Option<Box<RefreshUpstreamFailure>>,
|
||||
},
|
||||
#[error("account is banned")]
|
||||
Banned,
|
||||
Banned {
|
||||
message: Option<String>,
|
||||
upstream: Option<Box<RefreshUpstreamFailure>>,
|
||||
},
|
||||
#[error("refresh transport failed before server processing")]
|
||||
RetryableTransport,
|
||||
RetryableTransport { message: String },
|
||||
#[error("refresh transport failed after possible server processing")]
|
||||
Transport,
|
||||
Transport {
|
||||
message: Option<String>,
|
||||
upstream: Option<Box<RefreshUpstreamFailure>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for RefreshFailure {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RefreshFailure")
|
||||
.field("classification", &self.classification())
|
||||
.field("message", &self.message().map(|_| "<redacted>"))
|
||||
.field("upstream", &self.upstream())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl RefreshFailure {
|
||||
#[must_use]
|
||||
pub fn message(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::InvalidGrant { message, .. }
|
||||
| Self::Banned { message, .. }
|
||||
| Self::Transport { message, .. } => message.as_deref(),
|
||||
Self::RetryableTransport { message } => Some(message),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn upstream(&self) -> Option<&RefreshUpstreamFailure> {
|
||||
match self {
|
||||
Self::InvalidGrant { upstream, .. }
|
||||
| Self::Banned { upstream, .. }
|
||||
| Self::Transport { upstream, .. } => upstream.as_deref(),
|
||||
Self::RetryableTransport { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn classification(&self) -> &'static str {
|
||||
match self {
|
||||
Self::InvalidGrant { .. } => "invalid-grant",
|
||||
Self::Banned { .. } => "account-banned",
|
||||
Self::RetryableTransport { .. } => "transport-not-sent",
|
||||
Self::Transport { .. } => "transport-ambiguous",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 OAuth 刷新请求收到的完整非成功响应。
|
||||
///
|
||||
/// 该值不持久化;`Debug` 不输出正文。调用方仅在受控刷新失败日志中显式记录正文。
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RefreshUpstreamFailure {
|
||||
status: u16,
|
||||
code: Option<String>,
|
||||
error_type: Option<String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl RefreshUpstreamFailure {
|
||||
fn new(status: StatusCode, body: &[u8], error: Option<&RefreshErrorResponse>) -> Self {
|
||||
Self {
|
||||
status: status.as_u16(),
|
||||
code: error
|
||||
.and_then(RefreshErrorResponse::code)
|
||||
.map(str::to_owned),
|
||||
error_type: error
|
||||
.and_then(RefreshErrorResponse::error_type)
|
||||
.map(str::to_owned),
|
||||
body: String::from_utf8_lossy(body).into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn status(&self) -> u16 {
|
||||
self.status
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn code(&self) -> Option<&str> {
|
||||
self.code.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn error_type(&self) -> Option<&str> {
|
||||
self.error_type.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn body(&self) -> &str {
|
||||
&self.body
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RefreshUpstreamFailure {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("RefreshUpstreamFailure")
|
||||
.field("status", &self.status)
|
||||
.field("code", &self.code)
|
||||
.field("error_type", &self.error_type)
|
||||
.field("body", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Codex token 刷新端口。
|
||||
@@ -92,7 +198,6 @@ impl fmt::Debug for AuthorizationCodeGrant {
|
||||
pub struct AuthorizationTokenSet {
|
||||
pub secret: crate::credential::CodexOAuthSecret,
|
||||
pub id_token: SecretString,
|
||||
pub expires_in: Option<Duration>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for AuthorizationTokenSet {
|
||||
@@ -101,7 +206,6 @@ impl fmt::Debug for AuthorizationTokenSet {
|
||||
.debug_struct("AuthorizationTokenSet")
|
||||
.field("secret", &"[REDACTED]")
|
||||
.field("id_token", &"[REDACTED]")
|
||||
.field("expires_in", &self.expires_in)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -183,7 +287,60 @@ struct RefreshTokenResponse {
|
||||
access_token: Option<String>,
|
||||
refresh_token: Option<String>,
|
||||
id_token: Option<String>,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshTokenRequest<'a> {
|
||||
client_id: &'a str,
|
||||
grant_type: &'static str,
|
||||
refresh_token: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RefreshErrorResponse {
|
||||
error: Option<RefreshError>,
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RefreshError {
|
||||
Details(RefreshErrorDetails),
|
||||
Code(String),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RefreshErrorDetails {
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
error_type: Option<String>,
|
||||
}
|
||||
|
||||
impl RefreshErrorResponse {
|
||||
fn code(&self) -> Option<&str> {
|
||||
match self.error.as_ref() {
|
||||
Some(RefreshError::Details(error)) => error.code.as_deref(),
|
||||
Some(RefreshError::Code(code)) => Some(code.as_str()),
|
||||
None => None,
|
||||
}
|
||||
.or(self.code.as_deref())
|
||||
}
|
||||
|
||||
fn message(&self) -> Option<String> {
|
||||
match self.error.as_ref() {
|
||||
Some(RefreshError::Details(error)) => error.message.as_deref(),
|
||||
Some(RefreshError::Code(_)) | None => None,
|
||||
}
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn error_type(&self) -> Option<&str> {
|
||||
match self.error.as_ref() {
|
||||
Some(RefreshError::Details(error)) => error.error_type.as_deref(),
|
||||
Some(RefreshError::Code(_)) | None => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -191,7 +348,6 @@ struct AuthorizationCodeResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
id_token: String,
|
||||
expires_in: Option<u64>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -200,11 +356,11 @@ impl TokenRefresher for OpenAiTokenClient {
|
||||
let response = self
|
||||
.client
|
||||
.post(&self.config.token_endpoint)
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("client_id", self.config.client_id.as_str()),
|
||||
("refresh_token", refresh_token),
|
||||
])
|
||||
.json(&RefreshTokenRequest {
|
||||
client_id: self.config.client_id.as_str(),
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| refresh_transport_failure(&error))?;
|
||||
@@ -212,7 +368,10 @@ impl TokenRefresher for OpenAiTokenClient {
|
||||
if !status.is_success() {
|
||||
return Err(classify_refresh_failure(status, &body));
|
||||
}
|
||||
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport)
|
||||
parse_token_pair(&body).map_err(|()| RefreshFailure::Transport {
|
||||
message: Some("OpenAI OAuth refresh returned an invalid success response".to_owned()),
|
||||
upstream: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +419,6 @@ impl AuthorizationCodeExchanger for OpenAiTokenClient {
|
||||
id_token: None,
|
||||
},
|
||||
id_token,
|
||||
expires_in: optional_expiry(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -273,13 +431,21 @@ async fn read_bounded_response(
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| RefreshFailure::Transport)?
|
||||
.map_err(|error| RefreshFailure::Transport {
|
||||
message: Some(error.to_string()),
|
||||
upstream: None,
|
||||
})?
|
||||
{
|
||||
let next_len = body
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.filter(|length| *length <= MAX_OAUTH_RESPONSE_BYTES)
|
||||
.ok_or(RefreshFailure::Transport)?;
|
||||
.ok_or_else(|| RefreshFailure::Transport {
|
||||
message: Some(format!(
|
||||
"OpenAI OAuth response exceeded {MAX_OAUTH_RESPONSE_BYTES} bytes"
|
||||
)),
|
||||
upstream: None,
|
||||
})?;
|
||||
body.reserve(next_len.saturating_sub(body.len()));
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
@@ -301,43 +467,68 @@ fn parse_token_pair(body: &[u8]) -> Result<TokenPair, ()> {
|
||||
refresh_token: tokens.refresh_token,
|
||||
// 官方刷新响应允许省略 ID token;缺失时由调用方保留当前值。
|
||||
id_token: tokens.id_token,
|
||||
expires_in: optional_expiry(tokens.expires_in),
|
||||
})
|
||||
}
|
||||
|
||||
fn optional_expiry(value: Option<u64>) -> Option<Duration> {
|
||||
value
|
||||
.filter(|seconds| *seconds > 0)
|
||||
.map(Duration::from_secs)
|
||||
}
|
||||
|
||||
fn classify_refresh_failure(status: StatusCode, body: &[u8]) -> RefreshFailure {
|
||||
// 5xx 与 429 按状态码判为瞬态:其正文(CDN/网关页等)不是权威 OAuth 错误响应,
|
||||
// 不据此终态失效账号。
|
||||
if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
|
||||
return RefreshFailure::Transport;
|
||||
// 官方刷新错误的消息与错误码分别位于 `error.message`、`error.code`;
|
||||
// `error` 字符串与顶层 `code` 仅用于兼容官方客户端自身的错误码提取契约。
|
||||
let error = serde_json::from_slice::<RefreshErrorResponse>(body).ok();
|
||||
let message = error.as_ref().and_then(RefreshErrorResponse::message);
|
||||
let upstream = || {
|
||||
Some(Box::new(RefreshUpstreamFailure::new(
|
||||
status,
|
||||
body,
|
||||
error.as_ref(),
|
||||
)))
|
||||
};
|
||||
let normalized_code = error
|
||||
.as_ref()
|
||||
.and_then(RefreshErrorResponse::code)
|
||||
.map(str::to_ascii_lowercase);
|
||||
// 生产策略故意与官方 Codex 的“任意 401 立即终态”不同:
|
||||
// 显式 401 先进入有界恢复退避,避免瞬时授权故障直接失效账号。
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
return RefreshFailure::Transport {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
// 4xx 正文才是权威 OAuth 错误响应,据其区分真过期/封禁与其余瞬态。
|
||||
let lower = String::from_utf8_lossy(body).to_ascii_lowercase();
|
||||
if lower.contains("account has been deactivated") {
|
||||
return RefreshFailure::Banned;
|
||||
// 非 401 响应仍与官方一致:三个明确的 RT 原因是永久失败。
|
||||
if matches!(
|
||||
normalized_code.as_deref(),
|
||||
Some("refresh_token_expired" | "refresh_token_reused" | "refresh_token_invalidated")
|
||||
) {
|
||||
return RefreshFailure::InvalidGrant {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
if lower.contains("invalid_grant")
|
||||
|| lower.contains("invalid_token")
|
||||
|| lower.contains("access_denied")
|
||||
|| lower.contains("refresh_token_expired")
|
||||
|| lower.contains("refresh_token_reused")
|
||||
{
|
||||
return RefreshFailure::InvalidGrant;
|
||||
if message.as_deref().is_some_and(|message| {
|
||||
message
|
||||
.to_ascii_lowercase()
|
||||
.contains("account has been deactivated")
|
||||
}) {
|
||||
return RefreshFailure::Banned {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
};
|
||||
}
|
||||
RefreshFailure::Transport {
|
||||
message,
|
||||
upstream: upstream(),
|
||||
}
|
||||
RefreshFailure::Transport
|
||||
}
|
||||
|
||||
fn refresh_transport_failure(error: &reqwest::Error) -> RefreshFailure {
|
||||
let message = error.to_string();
|
||||
if is_safe_to_retry_refresh_transport(error) {
|
||||
RefreshFailure::RetryableTransport
|
||||
RefreshFailure::RetryableTransport { message }
|
||||
} else {
|
||||
RefreshFailure::Transport
|
||||
RefreshFailure::Transport {
|
||||
message: Some(message),
|
||||
upstream: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use bytes::Bytes;
|
||||
use futures::{StreamExt, future::BoxFuture};
|
||||
use gateway_core::engine::continuation::{ContinuationBinding, NativeContinuationScope};
|
||||
use gateway_core::engine::credential::{AccountFeedbackStats, ProviderAccount};
|
||||
@@ -33,6 +34,9 @@ use gateway_core::task::{
|
||||
WorkerDefinitionError, WorkerId, WorkerKind, WorkerLeaseRequest, WorkerRegistration,
|
||||
WorkerRunnable, WorkerSchedule, WorkerTaskError,
|
||||
};
|
||||
use gateway_protocol::openai::events::{
|
||||
ParsedRateLimits, parse_rate_limit_headers, rate_limits_to_header_pairs,
|
||||
};
|
||||
use reqwest::Client;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -63,16 +67,16 @@ use crate::transport::protocol::responses::{
|
||||
CodexResponsesRequest, PreviousResponseScope, ResponseEventSignals,
|
||||
};
|
||||
use crate::transport::request::{
|
||||
CodexRequestEncodeError, encode_generate_request, scope_request_to_account,
|
||||
CodexRequestEncodeError, RequestAccountScope, encode_generate_request, scope_request_to_account,
|
||||
};
|
||||
use crate::transport::session::CodexSessionIdentity;
|
||||
use crate::transport::usage::normalize_service_tier;
|
||||
use crate::transport::websocket::{CodexWebSocketExchangeError, PreviousResponseUnavailableReason};
|
||||
use crate::transport::{
|
||||
CODEX_RESPONSES_PATH, CodexAccountSelectionTelemetry, CodexBackendClient,
|
||||
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError,
|
||||
CodexRateLimitHeaderUpdates, CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics,
|
||||
CodexUpstreamDiagnostics, CodexWebSocketPool, endpoint_url,
|
||||
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexRateLimitUpdates,
|
||||
CodexRequestContext, CodexResponseMetadata, CodexTransportMetrics, CodexUpstreamDiagnostics,
|
||||
CodexWebSocketPool, endpoint_url,
|
||||
};
|
||||
|
||||
const PROVIDER_NAME: &str = "openai";
|
||||
@@ -247,7 +251,12 @@ impl Provider for CodexProvider {
|
||||
if let Some(identity) = &self.session_identity {
|
||||
identity.prepare_local_conversation(&mut upstream_request);
|
||||
}
|
||||
if upstream_request.turn_state.is_none() {
|
||||
if previous_session.as_ref().is_some_and(|state| {
|
||||
same_client_turn(
|
||||
state.client_turn_id.as_deref(),
|
||||
upstream_request.client_turn_id.as_deref(),
|
||||
)
|
||||
}) {
|
||||
upstream_request.turn_state = previous_session
|
||||
.as_ref()
|
||||
.and_then(|state| state.turn_state.clone());
|
||||
@@ -277,57 +286,48 @@ impl Provider for CodexProvider {
|
||||
let output_started_at = Instant::now();
|
||||
let provider_kind = ProviderKind::new(PROVIDER_NAME)
|
||||
.map_err(|_| provider_error(ProviderErrorKind::Protocol, UpstreamSendState::NotSent))?;
|
||||
let cross_account = context
|
||||
let state_owner_cross_account = context
|
||||
.account_state_owner()
|
||||
.is_some_and(|owner| !owner.matches(&provider_kind, lease.account_id()))
|
||||
|| previous_session
|
||||
.as_ref()
|
||||
.is_some_and(|state| state.account_id != lease.account_id().as_str());
|
||||
let replay_previous_response = matches!(
|
||||
context.continuation_attempt(),
|
||||
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny
|
||||
) || previous_session.as_ref().is_some_and(|state| {
|
||||
state.continuation_scope == OpenAiContinuationScope::ReplayRequired
|
||||
});
|
||||
if replay_previous_response && previous_session.is_some() {
|
||||
// Codex 客户端 input 是完整历史;恢复时只解除上游续链绑定,避免重复叠加代理副本。
|
||||
upstream_request.set_previous_response_id(None);
|
||||
upstream_request.previous_response_scope = None;
|
||||
upstream_request.turn_state = None;
|
||||
}
|
||||
scope_request_to_account(
|
||||
&mut upstream_request,
|
||||
lease.installation_id(),
|
||||
cross_account,
|
||||
);
|
||||
if context.continuation_attempt() == ContinuationAttempt::Native
|
||||
&& !replay_previous_response
|
||||
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());
|
||||
if context.continuation_attempt() != ContinuationAttempt::None
|
||||
&& let Some(continuation) = context.continuation()
|
||||
{
|
||||
match continuation {
|
||||
ContinuationBinding::Pinned(continuation) => {
|
||||
let previous_response_scope = match previous_session
|
||||
.as_ref()
|
||||
.map(|state| state.continuation_scope)
|
||||
{
|
||||
Some(OpenAiContinuationScope::Persisted) => {
|
||||
PreviousResponseScope::Persisted
|
||||
}
|
||||
Some(OpenAiContinuationScope::ConnectionLocal) => {
|
||||
PreviousResponseScope::ConnectionLocal
|
||||
}
|
||||
Some(OpenAiContinuationScope::ReplayRequired) => {
|
||||
return Err(provider_error(
|
||||
ProviderErrorKind::Protocol,
|
||||
UpstreamSendState::NotSent,
|
||||
));
|
||||
}
|
||||
None => match continuation.scope() {
|
||||
NativeContinuationScope::Persisted => PreviousResponseScope::Persisted,
|
||||
NativeContinuationScope::ConnectionLocal => {
|
||||
let previous_response_scope = match context.continuation_attempt() {
|
||||
ContinuationAttempt::Native => match previous_session
|
||||
.as_ref()
|
||||
.map(|state| state.continuation_scope)
|
||||
{
|
||||
Some(OpenAiContinuationScope::Persisted) => {
|
||||
PreviousResponseScope::Persisted
|
||||
}
|
||||
Some(OpenAiContinuationScope::ConnectionLocal) => {
|
||||
PreviousResponseScope::ConnectionLocal
|
||||
}
|
||||
Some(OpenAiContinuationScope::ReplayRequired) => {
|
||||
PreviousResponseScope::ExternalUnknown
|
||||
}
|
||||
None => match continuation.scope() {
|
||||
NativeContinuationScope::Persisted => {
|
||||
PreviousResponseScope::Persisted
|
||||
}
|
||||
NativeContinuationScope::ConnectionLocal => {
|
||||
PreviousResponseScope::ConnectionLocal
|
||||
}
|
||||
},
|
||||
},
|
||||
ContinuationAttempt::ReplayOwner | ContinuationAttempt::ReplayAny => {
|
||||
PreviousResponseScope::ExternalUnknown
|
||||
}
|
||||
ContinuationAttempt::None => PreviousResponseScope::ExternalUnknown,
|
||||
};
|
||||
upstream_request.set_previous_response_id(Some(
|
||||
continuation.upstream_response_id().as_str().to_owned(),
|
||||
@@ -342,6 +342,18 @@ 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
|
||||
};
|
||||
scope_request_to_account(
|
||||
&mut upstream_request,
|
||||
lease.installation_id(),
|
||||
account_scope,
|
||||
);
|
||||
let metadata = ProviderCallMetadata::new(
|
||||
provider_kind,
|
||||
candidate.upstream_model().clone(),
|
||||
@@ -359,6 +371,7 @@ impl Provider for CodexProvider {
|
||||
account_id: lease.account_id().as_str().to_owned(),
|
||||
conversation_id: upstream_request.local_conversation_id.clone(),
|
||||
turn_state: upstream_request.turn_state.clone(),
|
||||
client_turn_id: upstream_request.client_turn_id.clone(),
|
||||
response_store,
|
||||
continuation_scope: None,
|
||||
});
|
||||
@@ -411,6 +424,8 @@ struct OpenAiSessionState {
|
||||
conversation_id: Option<String>,
|
||||
#[serde(default)]
|
||||
turn_state: Option<String>,
|
||||
#[serde(default)]
|
||||
client_turn_id: Option<String>,
|
||||
continuation_scope: OpenAiContinuationScope,
|
||||
}
|
||||
|
||||
@@ -426,10 +441,17 @@ struct OpenAiSessionCapture {
|
||||
account_id: String,
|
||||
conversation_id: Option<String>,
|
||||
turn_state: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
response_store: bool,
|
||||
continuation_scope: Option<OpenAiContinuationScope>,
|
||||
}
|
||||
|
||||
fn same_client_turn(previous: Option<&str>, current: Option<&str>) -> bool {
|
||||
previous
|
||||
.zip(current)
|
||||
.is_some_and(|(previous, current)| !previous.is_empty() && previous == current)
|
||||
}
|
||||
|
||||
fn decode_openai_session_state(request: &GenerateRequest) -> Option<OpenAiSessionState> {
|
||||
request
|
||||
.provider_session_state(PROVIDER_NAME)
|
||||
@@ -471,6 +493,7 @@ fn attach_openai_session_update(
|
||||
account_id: capture.account_id,
|
||||
conversation_id: capture.conversation_id,
|
||||
turn_state: capture.turn_state,
|
||||
client_turn_id: capture.client_turn_id,
|
||||
continuation_scope,
|
||||
}) else {
|
||||
return;
|
||||
@@ -698,9 +721,6 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
if let Some(observation) = observation_state.observation() {
|
||||
yield ProviderEvent::observation(observation);
|
||||
}
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota("a, &active_account, &response.rate_limit_headers).await;
|
||||
}
|
||||
if let Some(etag) = response.response_metadata.models_etag.as_deref()
|
||||
&& let Err(error) = catalog.observe_response_etag(etag)
|
||||
{
|
||||
@@ -728,7 +748,9 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let failure_diagnostics = response.diagnostics.clone();
|
||||
let failure_set_cookie_headers = response.set_cookie_headers.clone();
|
||||
let failure_rate_limit_headers = response.rate_limit_headers.clone();
|
||||
let rate_limit_updates = response.rate_limit_header_updates;
|
||||
let mut passive_quota_observation =
|
||||
OpenAiPassiveQuotaObservation::new(response.rate_limit_headers);
|
||||
let rate_limit_updates = response.rate_limit_updates;
|
||||
let turn_state_updates = response.turn_state_update;
|
||||
// OpenAI 线路为透明代理:HTTP SSE 与 WebSocket 两条上游均启用 raw 透传,
|
||||
// 下游按字节转发上游原文,避免 serde 往返改写数值/精度(大整数→f64、logprobs 等)。
|
||||
@@ -741,6 +763,14 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let mut pre_commit_events = PreCommitClientEvents::new();
|
||||
loop {
|
||||
let Some(stream_deadline) = remaining(context.deadline()) else {
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota(
|
||||
"a,
|
||||
&active_account,
|
||||
passive_quota_observation.rate_limits(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(provider_error(ProviderErrorKind::Timeout, UpstreamSendState::Sent))?;
|
||||
return;
|
||||
};
|
||||
@@ -773,15 +803,22 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
Err(failure) => {
|
||||
let updates = take_rate_limit_updates(rate_limit_updates.as_ref()).await;
|
||||
if !updates.is_empty() {
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota("a, &active_account, &updates).await;
|
||||
}
|
||||
if observation_state.merge_rate_limit_headers(&updates)
|
||||
passive_quota_observation.observe(&updates);
|
||||
let update_headers = rate_limit_update_headers(&updates);
|
||||
if observation_state.merge_rate_limit_headers(&update_headers)
|
||||
&& let Some(observation) = observation_state.observation()
|
||||
{
|
||||
yield ProviderEvent::observation(observation);
|
||||
}
|
||||
}
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota(
|
||||
"a,
|
||||
&active_account,
|
||||
passive_quota_observation.rate_limits(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
apply_failure(&failure_context, &active_account, &failure)
|
||||
.await;
|
||||
Err(failure.error)?;
|
||||
@@ -793,18 +830,19 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let rate_limits_changed = if updates.is_empty() {
|
||||
false
|
||||
} else {
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota("a, &active_account, &updates).await;
|
||||
}
|
||||
observation_state.merge_rate_limit_headers(&updates)
|
||||
passive_quota_observation.observe(&updates);
|
||||
observation_state.merge_rate_limit_headers(&rate_limit_update_headers(&updates))
|
||||
};
|
||||
if let (Some(capture), Some(updates)) =
|
||||
(session_capture.as_mut(), turn_state_updates.as_ref())
|
||||
// take:仅在上游写入过新 turn_state 时更新 capture,避免逐 chunk 克隆。
|
||||
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
|
||||
&& let Some(turn_state) = updates.lock().await.take()
|
||||
{
|
||||
capture.turn_state = Some(turn_state);
|
||||
}
|
||||
if let Some(capture) = session_capture.as_mut() {
|
||||
capture.turn_state = Some(turn_state.clone());
|
||||
}
|
||||
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
let first_event_changed =
|
||||
observation_state.observe_stream_chunk(&chunk, output_started_at);
|
||||
let chunk_len = chunk.len();
|
||||
@@ -843,6 +881,14 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
.any(|event| matches!(event, GatewayEvent::Completed(_)));
|
||||
let terminal_changed = completed
|
||||
&& observation_state.mark_completed(terminal_response_is_incomplete(&events));
|
||||
if allows_account_state_mutation && (completed || terminal_failure.is_some()) {
|
||||
synchronize_passive_quota(
|
||||
"a,
|
||||
&active_account,
|
||||
passive_quota_observation.rate_limits(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some((failure, _)) = terminal_failure.as_ref() {
|
||||
apply_failure(&failure_context, &active_account, failure)
|
||||
.await;
|
||||
@@ -861,6 +907,7 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
if (rate_limits_changed
|
||||
|| service_tier_changed
|
||||
|| timing_changed
|
||||
|| turn_state_changed
|
||||
|| terminal_changed)
|
||||
&& let Some(observation) = observation_state.observation()
|
||||
{
|
||||
@@ -920,21 +967,31 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
let rate_limits_changed = if updates.is_empty() {
|
||||
false
|
||||
} else {
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota("a, &active_account, &updates).await;
|
||||
}
|
||||
observation_state.merge_rate_limit_headers(&updates)
|
||||
passive_quota_observation.observe(&updates);
|
||||
observation_state.merge_rate_limit_headers(&rate_limit_update_headers(&updates))
|
||||
};
|
||||
if allows_account_state_mutation {
|
||||
synchronize_passive_quota(
|
||||
"a,
|
||||
&active_account,
|
||||
passive_quota_observation.rate_limits(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if let Some((failure, _)) = terminal_failure.as_ref() {
|
||||
apply_failure(&failure_context, &active_account, failure)
|
||||
.await;
|
||||
}
|
||||
if let (Some(capture), Some(updates)) =
|
||||
(session_capture.as_mut(), turn_state_updates.as_ref())
|
||||
let turn_state_changed = if let Some(updates) = turn_state_updates.as_ref()
|
||||
&& let Some(turn_state) = updates.lock().await.take()
|
||||
{
|
||||
capture.turn_state = Some(turn_state);
|
||||
}
|
||||
if let Some(capture) = session_capture.as_mut() {
|
||||
capture.turn_state = Some(turn_state.clone());
|
||||
}
|
||||
observation_state.merge_client_header("x-codex-turn-state", &turn_state)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
attach_openai_session_update(&mut events, &mut session_capture);
|
||||
let completed = events
|
||||
.iter()
|
||||
@@ -951,7 +1008,11 @@ fn cold_response_stream(response: ColdResponse) -> EventStream {
|
||||
.observe_cyber_policy_success(cyber_policy_scope.as_ref())
|
||||
.await;
|
||||
}
|
||||
if (service_tier_changed || timing_changed || rate_limits_changed || terminal_changed)
|
||||
if (service_tier_changed
|
||||
|| timing_changed
|
||||
|| rate_limits_changed
|
||||
|| turn_state_changed
|
||||
|| terminal_changed)
|
||||
&& let Some(observation) = observation_state.observation()
|
||||
{
|
||||
yield ProviderEvent::observation(observation);
|
||||
@@ -1001,6 +1062,26 @@ struct OpenAiResponseObservationState {
|
||||
terminal: Option<OpenAiResponseTerminal>,
|
||||
}
|
||||
|
||||
struct OpenAiPassiveQuotaObservation {
|
||||
rate_limits: Vec<ParsedRateLimits>,
|
||||
}
|
||||
|
||||
impl OpenAiPassiveQuotaObservation {
|
||||
fn new(headers: Vec<(String, String)>) -> Self {
|
||||
Self {
|
||||
rate_limits: parse_rate_limit_headers(&headers).into_iter().collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn observe(&mut self, updates: &[ParsedRateLimits]) {
|
||||
self.rate_limits.extend_from_slice(updates);
|
||||
}
|
||||
|
||||
fn rate_limits(&self) -> &[ParsedRateLimits] {
|
||||
&self.rate_limits
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum OpenAiResponseTerminal {
|
||||
Completed,
|
||||
@@ -1120,6 +1201,26 @@ impl OpenAiResponseObservationState {
|
||||
changed
|
||||
}
|
||||
|
||||
fn merge_client_header(&mut self, name: &str, value: &str) -> bool {
|
||||
let value = Bytes::copy_from_slice(value.as_bytes());
|
||||
if let Some((_, existing)) = self
|
||||
.response_metadata
|
||||
.client_headers
|
||||
.iter_mut()
|
||||
.find(|(existing_name, _)| existing_name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
if *existing == value {
|
||||
return false;
|
||||
}
|
||||
*existing = value;
|
||||
return true;
|
||||
}
|
||||
self.response_metadata
|
||||
.client_headers
|
||||
.push((name.to_owned(), value));
|
||||
true
|
||||
}
|
||||
|
||||
fn mark_completed(&mut self, incomplete: bool) -> bool {
|
||||
let terminal = if incomplete {
|
||||
OpenAiResponseTerminal::Incomplete
|
||||
@@ -1432,15 +1533,20 @@ fn insert_optional_metadata_millis(
|
||||
}
|
||||
}
|
||||
|
||||
async fn take_rate_limit_updates(
|
||||
updates: Option<&CodexRateLimitHeaderUpdates>,
|
||||
) -> Vec<(String, String)> {
|
||||
async fn take_rate_limit_updates(updates: Option<&CodexRateLimitUpdates>) -> Vec<ParsedRateLimits> {
|
||||
let Some(updates) = updates else {
|
||||
return Vec::new();
|
||||
};
|
||||
std::mem::take(&mut *updates.lock().await)
|
||||
}
|
||||
|
||||
fn rate_limit_update_headers(updates: &[ParsedRateLimits]) -> Vec<(String, String)> {
|
||||
updates
|
||||
.iter()
|
||||
.flat_map(rate_limits_to_header_pairs)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn terminal_response_is_incomplete(events: &[ProviderEvent]) -> bool {
|
||||
events
|
||||
.iter()
|
||||
@@ -1466,12 +1572,15 @@ fn terminal_response_is_incomplete(events: &[ProviderEvent]) -> bool {
|
||||
async fn synchronize_passive_quota(
|
||||
quota: &CodexCredentialQuotaService,
|
||||
account: &ProviderAccount,
|
||||
headers: &[(String, String)],
|
||||
rate_limits: &[ParsedRateLimits],
|
||||
) {
|
||||
if headers.is_empty() {
|
||||
if rate_limits.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Err(error) = quota.synchronize_passive_headers(account, headers).await {
|
||||
if let Err(error) = quota
|
||||
.synchronize_passive_rate_limits(account, rate_limits)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
account_id = %account.id(),
|
||||
error = %error,
|
||||
@@ -1480,6 +1589,17 @@ async fn synchronize_passive_quota(
|
||||
}
|
||||
}
|
||||
|
||||
async fn synchronize_passive_quota_headers(
|
||||
quota: &CodexCredentialQuotaService,
|
||||
account: &ProviderAccount,
|
||||
headers: &[(String, String)],
|
||||
) {
|
||||
let Some(rate_limits) = parse_rate_limit_headers(headers) else {
|
||||
return;
|
||||
};
|
||||
synchronize_passive_quota(quota, account, std::slice::from_ref(&rate_limits)).await;
|
||||
}
|
||||
|
||||
const fn actual_transport_name(transport: CodexBackendTransport) -> &'static str {
|
||||
match transport {
|
||||
CodexBackendTransport::HttpSse => HTTP_SSE_TRANSPORT,
|
||||
@@ -1492,12 +1612,10 @@ fn nonnegative_millis(value: Option<i64>) -> Option<u64> {
|
||||
}
|
||||
|
||||
fn compile_model_capabilities(model: &CodexCatalogModel) -> ProviderModelCapabilities {
|
||||
let evidence = model.capabilities();
|
||||
let mut operations = BTreeSet::new();
|
||||
if evidence.responses_api() == CodexCatalogCapabilityEvidence::DeclaredNative {
|
||||
operations.insert(OperationKind::Generate);
|
||||
}
|
||||
let capabilities = ModelCapabilities::new(operations, None).with_upstream_feature_validation();
|
||||
// Catalog membership is enough to publish Generate. `supported_in_api` is advisory;
|
||||
// the upstream response is authoritative for normal and diagnostic requests.
|
||||
let capabilities = ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), None)
|
||||
.with_upstream_feature_validation();
|
||||
ProviderModelCapabilities::new(model.request_model().clone(), capabilities)
|
||||
.with_presentation(codex_model_presentation(model))
|
||||
}
|
||||
@@ -1875,7 +1993,7 @@ async fn apply_failure(
|
||||
if !context.allows_account_state_mutation {
|
||||
return;
|
||||
}
|
||||
synchronize_passive_quota(context.quota, account, &failure.rate_limit_headers).await;
|
||||
synchronize_passive_quota_headers(context.quota, account, &failure.rate_limit_headers).await;
|
||||
let needs_authoritative_quota_refresh = matches!(
|
||||
failure.account_failure,
|
||||
Some(CodexAccountFailure::QuotaExhausted | CodexAccountFailure::UsageLimitExhausted { .. })
|
||||
@@ -2154,7 +2272,7 @@ fn map_upstream_failure(
|
||||
error = error.with_status(status.as_u16());
|
||||
}
|
||||
if replay_boundary.permits_provider_proof()
|
||||
&& (failure.replay_is_safe() || continuation_failure.is_some() || cyber_policy_failure)
|
||||
&& (failure.replay_is_safe() || cyber_policy_failure)
|
||||
{
|
||||
error = error.with_replay_safe();
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ use super::response_meta::CodexResponseMetadata;
|
||||
use super::tls::{CustomCaError, build_reqwest_client_with_custom_ca, custom_ca_env_cache_key};
|
||||
use super::websocket::{
|
||||
CodexWebSocketExchangeError, CodexWebSocketPool, CodexWebSocketPoolKey,
|
||||
CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketRequest, CodexWebSocketTurnStateUpdate,
|
||||
CodexWebSocketRateLimitUpdates, CodexWebSocketRequest, CodexWebSocketTurnStateUpdate,
|
||||
PreparedWebSocket, WebSocketOriginBreaker, WebSocketPoolDecision,
|
||||
};
|
||||
|
||||
@@ -519,8 +519,8 @@ pub struct CodexTransportMetrics {
|
||||
pub http_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 响应头之后在 live 流中采集的限流更新。
|
||||
pub type CodexRateLimitHeaderUpdates = CodexWebSocketRateLimitHeaderUpdates;
|
||||
/// 响应头之后在 live 流中采集的结构化限流更新。
|
||||
pub type CodexRateLimitUpdates = CodexWebSocketRateLimitUpdates;
|
||||
|
||||
/// 响应头之后在 live 流中采集的 turn state 更新。
|
||||
pub type CodexTurnStateUpdate = CodexWebSocketTurnStateUpdate;
|
||||
@@ -537,8 +537,8 @@ pub struct CodexBackendStreamingResponse {
|
||||
pub set_cookie_headers: Vec<String>,
|
||||
/// 上游透传的限流头。
|
||||
pub rate_limit_headers: Vec<(String, String)>,
|
||||
/// live stream 期间捕获的限流头更新。
|
||||
pub rate_limit_header_updates: Option<CodexRateLimitHeaderUpdates>,
|
||||
/// live stream 期间捕获的结构化限流更新。
|
||||
pub rate_limit_updates: Option<CodexRateLimitUpdates>,
|
||||
/// live stream 期间捕获的 turn-state 更新。
|
||||
pub turn_state_update: Option<CodexTurnStateUpdate>,
|
||||
/// WebSocket 连接池决策。
|
||||
|
||||
@@ -147,14 +147,14 @@ impl CodexBackendClient {
|
||||
});
|
||||
}
|
||||
|
||||
let rate_limit_header_updates = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let rate_limit_updates = Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
Ok(CodexBackendStreamingResponse {
|
||||
body: http_sse_stream(response, Arc::clone(&rate_limit_header_updates)),
|
||||
body: http_sse_stream(response, Arc::clone(&rate_limit_updates)),
|
||||
transport: CodexBackendTransport::HttpSse,
|
||||
turn_state,
|
||||
set_cookie_headers,
|
||||
rate_limit_headers,
|
||||
rate_limit_header_updates: Some(rate_limit_header_updates),
|
||||
rate_limit_updates: Some(rate_limit_updates),
|
||||
turn_state_update: None,
|
||||
websocket_pool_decision: None,
|
||||
diagnostics,
|
||||
@@ -245,11 +245,12 @@ impl CodexBackendClient {
|
||||
self.websocket_pool.as_deref().zip(pool_key)
|
||||
};
|
||||
let fast_path_budget = match requirement {
|
||||
TransportRequirement::PersistedContinuation
|
||||
| TransportRequirement::ExternalUnknown
|
||||
| TransportRequirement::NewChain => Some(WEBSOCKET_FAST_PATH_BUDGET),
|
||||
TransportRequirement::PersistedContinuation | TransportRequirement::NewChain => {
|
||||
Some(WEBSOCKET_FAST_PATH_BUDGET)
|
||||
}
|
||||
TransportRequirement::ExplicitWebSocketWarmup
|
||||
| TransportRequirement::ExactWebSocketContinuation => None,
|
||||
| TransportRequirement::ExactWebSocketContinuation
|
||||
| TransportRequirement::ExternalUnknown => None,
|
||||
TransportRequirement::HttpRequired => None,
|
||||
};
|
||||
let prepare_started_at = Instant::now();
|
||||
@@ -515,7 +516,7 @@ impl CodexBackendClient {
|
||||
turn_state: exchange.turn_state,
|
||||
set_cookie_headers: exchange.set_cookie_headers,
|
||||
rate_limit_headers: exchange.rate_limit_headers,
|
||||
rate_limit_header_updates: Some(exchange.rate_limit_header_updates),
|
||||
rate_limit_updates: Some(exchange.rate_limit_updates),
|
||||
turn_state_update: Some(exchange.turn_state_update),
|
||||
websocket_pool_decision: exchange.pool_decision,
|
||||
diagnostics: exchange.diagnostics,
|
||||
@@ -878,7 +879,7 @@ fn websocket_connection_profile(headers: &HeaderMap) -> String {
|
||||
|
||||
fn http_sse_stream(
|
||||
response: ReqwestResponse,
|
||||
rate_limit_updates: CodexRateLimitHeaderUpdates,
|
||||
rate_limit_updates: CodexRateLimitUpdates,
|
||||
) -> CodexBackendSseStream {
|
||||
let stream: CodexBackendSseStream =
|
||||
Box::pin(response.bytes_stream().map_err(CodexClientError::Http));
|
||||
@@ -901,7 +902,7 @@ fn http_sse_stream(
|
||||
|
||||
fn observe_http_sse_rate_limits(
|
||||
stream: CodexBackendSseStream,
|
||||
updates: CodexRateLimitHeaderUpdates,
|
||||
updates: CodexRateLimitUpdates,
|
||||
) -> CodexBackendSseStream {
|
||||
Box::pin(futures::stream::unfold(
|
||||
(stream, SseEventDecoder::default(), updates),
|
||||
@@ -925,9 +926,9 @@ fn observe_http_sse_rate_limits(
|
||||
|
||||
async fn append_http_sse_rate_limit_updates(
|
||||
frames: Vec<SseFrame>,
|
||||
updates: &CodexRateLimitHeaderUpdates,
|
||||
updates: &CodexRateLimitUpdates,
|
||||
) {
|
||||
let mut headers = Vec::new();
|
||||
let mut observations = Vec::new();
|
||||
for frame in frames {
|
||||
for event in frame.events() {
|
||||
if event
|
||||
@@ -940,10 +941,10 @@ async fn append_http_sse_rate_limit_updates(
|
||||
let Some(rate_limits) = events::parse_rate_limits_event_raw(&event.data) else {
|
||||
continue;
|
||||
};
|
||||
headers.extend(events::rate_limits_to_header_pairs(&rate_limits));
|
||||
observations.push(rate_limits);
|
||||
}
|
||||
}
|
||||
if !headers.is_empty() {
|
||||
updates.lock().await.extend(headers);
|
||||
if !observations.is_empty() {
|
||||
updates.lock().await.extend(observations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ pub use self::{
|
||||
client::{
|
||||
CodexAccountSelectionTelemetry, CodexBackendClient, CodexBackendSseStream,
|
||||
CodexBackendStreamingResponse, CodexBackendTransport, CodexClientError, CodexClientResult,
|
||||
CodexRateLimitHeaderUpdates, CodexRequestContext, CodexTransportDecision,
|
||||
CodexTransportMetrics, CodexTurnStateUpdate, build_reqwest_client,
|
||||
CodexRateLimitUpdates, CodexRequestContext, CodexTransportDecision, CodexTransportMetrics,
|
||||
CodexTurnStateUpdate, build_reqwest_client,
|
||||
},
|
||||
diagnostics::{CodexUpstreamDiagnostics, CodexUpstreamSendPhase},
|
||||
endpoints::{
|
||||
|
||||
@@ -5,6 +5,8 @@ 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";
|
||||
const INVALID_PREVIOUS_RESPONSE_ID_MESSAGE: &str = "Invalid `previous_response_id`.";
|
||||
|
||||
/// WebSocket 握手审计快照。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -54,16 +56,49 @@ pub fn websocket_event_to_sse_frame(raw: &str) -> Option<String> {
|
||||
websocket_event_frame(&value, raw)
|
||||
}
|
||||
|
||||
/// 同上,复用已解析的事件 JSON;`data` 段逐字节内嵌 `raw` 原文。
|
||||
/// 同上,复用已解析的事件 JSON;`data` 段通常逐字节内嵌 `raw` 原文。
|
||||
///
|
||||
/// 仅剥离传输层内部帧:`codex.rate_limits` 与承载 turn_state 的
|
||||
/// `response.metadata` 不下发客户端;业务事件一律原样转发,不做 schema 校验。
|
||||
/// `response.metadata` 不下发客户端。唯一的业务帧适配是为上游省略错误码的
|
||||
/// `Invalid previous_response_id` 补齐官方客户端用于完整历史重放的稳定错误码;
|
||||
/// 原始 status、type 与 message 均保持不变。
|
||||
pub(crate) fn websocket_event_frame(value: &Value, raw: &str) -> Option<String> {
|
||||
let event = websocket_event_type(value)?;
|
||||
if is_internal_websocket_event(event) || event == "response.metadata" {
|
||||
return None;
|
||||
}
|
||||
Some(encode_sse_event(event, raw))
|
||||
let normalized = previous_response_not_found_event(value);
|
||||
Some(encode_sse_event(
|
||||
event,
|
||||
normalized.as_deref().unwrap_or(raw),
|
||||
))
|
||||
}
|
||||
|
||||
fn previous_response_not_found_event(value: &Value) -> Option<String> {
|
||||
if websocket_event_type(value) != Some("error")
|
||||
|| value
|
||||
.get("status")
|
||||
.or_else(|| value.get("status_code"))
|
||||
.and_then(Value::as_u64)
|
||||
!= Some(400)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let error = value.get("error")?.as_object()?;
|
||||
if error.get("code").is_some_and(|code| !code.is_null())
|
||||
|| error.get("type").and_then(Value::as_str) != Some("invalid_request_error")
|
||||
|| error.get("message").and_then(Value::as_str)
|
||||
!= Some(INVALID_PREVIOUS_RESPONSE_ID_MESSAGE)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = value.clone();
|
||||
normalized.get_mut("error")?.as_object_mut()?.insert(
|
||||
"code".to_owned(),
|
||||
Value::String(PREVIOUS_RESPONSE_NOT_FOUND_CODE.to_owned()),
|
||||
);
|
||||
serde_json::to_string(&normalized).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn websocket_event_type(value: &Value) -> Option<&str> {
|
||||
|
||||
@@ -61,6 +61,23 @@ const INSTALLATION_ID_KEYS: &[&str] = &[
|
||||
"x-codex-installation-id",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RequestAccountScope {
|
||||
SameAccount,
|
||||
CrossAccountFullRequest,
|
||||
CrossAccountContinuationProbe,
|
||||
}
|
||||
|
||||
impl RequestAccountScope {
|
||||
const fn is_cross_account(self) -> bool {
|
||||
!matches!(self, Self::SameAccount)
|
||||
}
|
||||
|
||||
const fn preserves_continuation(self) -> bool {
|
||||
matches!(self, Self::CrossAccountContinuationProbe)
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider 专属编码错误;不保存 prompt、schema 或 option 值。
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum CodexRequestEncodeError {
|
||||
@@ -289,12 +306,18 @@ fn normalize_conversation_anchor_text(text: &str) -> String {
|
||||
///
|
||||
/// 真实 account ID 与 installation ID 由随后构造的 `CodexRequestContext` 注入
|
||||
/// 请求头;正文只替换客户端原本声明过的 installation 字段,绝不接受客户端
|
||||
/// 提供的 token、cookie 或账号身份。
|
||||
/// 提供的 token、cookie 或账号身份。Continuation probe 保留 canonical
|
||||
/// previous-response 和原始增量 input,不能把它们误当成完整请求清理。
|
||||
pub(crate) fn scope_request_to_account(
|
||||
request: &mut CodexResponsesRequest,
|
||||
installation_id: &str,
|
||||
cross_account: bool,
|
||||
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 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());
|
||||
@@ -306,23 +329,38 @@ pub(crate) fn scope_request_to_account(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let turn_metadata = request
|
||||
.turn_metadata
|
||||
.as_deref()
|
||||
.and_then(|metadata| scope_turn_metadata(metadata, installation_id, cross_account));
|
||||
let client_metadata_turn_metadata = metadata_string(request, "x-codex-turn-metadata")
|
||||
.and_then(|metadata| scope_turn_metadata(&metadata, installation_id, cross_account));
|
||||
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();
|
||||
|
||||
if cross_account {
|
||||
request.passthrough_headers.remove("x-codex-turn-state");
|
||||
request.passthrough_headers.remove("x-codex-turn-metadata");
|
||||
sanitize_cross_account_input(request);
|
||||
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 {
|
||||
@@ -356,6 +394,11 @@ pub(crate) fn scope_request_to_account(
|
||||
{
|
||||
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(),
|
||||
@@ -393,6 +436,9 @@ 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) {
|
||||
|
||||
@@ -193,7 +193,6 @@ fn client_response_header_is_forwardable(name: &str, connection_options: &[Strin
|
||||
| "x-openai-organization"
|
||||
| "x-openai-project"
|
||||
| "x-codex-installation-id"
|
||||
| "x-codex-turn-state"
|
||||
| "x-codex-turn-metadata"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::{pin::Pin, sync::Arc, time::Duration};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures::Stream;
|
||||
use gateway_protocol::openai::events::ParsedRateLimits;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -36,8 +37,8 @@ pub struct CodexWebSocketStreamingExchange {
|
||||
pub set_cookie_headers: Vec<String>,
|
||||
/// 上游握手响应里的限流头。
|
||||
pub rate_limit_headers: Vec<(String, String)>,
|
||||
/// 上游内部 rate-limit 事件里的动态更新。
|
||||
pub rate_limit_header_updates: CodexWebSocketRateLimitHeaderUpdates,
|
||||
/// 上游内部 `codex.rate_limits` 事件里的结构化动态更新。
|
||||
pub rate_limit_updates: CodexWebSocketRateLimitUpdates,
|
||||
/// 上游内部 metadata 事件里的动态 turn state。
|
||||
pub turn_state_update: CodexWebSocketTurnStateUpdate,
|
||||
/// WebSocket 连接池决策。
|
||||
@@ -53,8 +54,8 @@ pub struct CodexWebSocketStreamingExchange {
|
||||
/// Responses WebSocket live SSE 字节流。
|
||||
pub type CodexWebSocketSseStream =
|
||||
Pin<Box<dyn Stream<Item = Result<Bytes, CodexWebSocketExchangeError>> + Send + 'static>>;
|
||||
/// live 流中的限流头动态更新。
|
||||
pub type CodexWebSocketRateLimitHeaderUpdates = Arc<Mutex<Vec<(String, String)>>>;
|
||||
/// live 流中的结构化限流动态更新。
|
||||
pub type CodexWebSocketRateLimitUpdates = Arc<Mutex<Vec<ParsedRateLimits>>>;
|
||||
/// live 流中的 turn state 动态更新。
|
||||
pub type CodexWebSocketTurnStateUpdate = Arc<Mutex<Option<String>>>;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ pub(in crate::transport::websocket) enum WebSocketTerminalKind {
|
||||
}
|
||||
|
||||
pub(super) enum ExchangeAction {
|
||||
RateLimits(Vec<(String, String)>),
|
||||
RateLimits(events::ParsedRateLimits),
|
||||
TurnState(String),
|
||||
Forward {
|
||||
frame: String,
|
||||
@@ -41,8 +41,8 @@ pub(super) fn reduce_websocket_event(
|
||||
};
|
||||
if let Some(parsed) = events::parse_rate_limits_event(&value) {
|
||||
let headers = events::rate_limits_to_header_pairs(&parsed);
|
||||
metadata.rate_limit_headers.extend(headers.iter().cloned());
|
||||
return Ok(ExchangeAction::RateLimits(headers));
|
||||
metadata.rate_limit_headers.extend(headers);
|
||||
return Ok(ExchangeAction::RateLimits(parsed));
|
||||
}
|
||||
|
||||
response_meta::merge_response_metadata(
|
||||
|
||||
@@ -16,9 +16,8 @@ use super::super::{
|
||||
use super::io::{next_websocket_message, receive_idle_timeout, reused_stream_receive_error};
|
||||
use super::reducer::{ExchangeAction, WebSocketTerminalKind, reduce_websocket_event};
|
||||
use super::{
|
||||
CodexWebSocketExchangeError, CodexWebSocketRateLimitHeaderUpdates,
|
||||
CodexWebSocketStreamingExchange, CodexWebSocketTurnStateUpdate, WEBSOCKET_STREAM_BUFFER,
|
||||
reusable_websocket_metadata,
|
||||
CodexWebSocketExchangeError, CodexWebSocketRateLimitUpdates, CodexWebSocketStreamingExchange,
|
||||
CodexWebSocketTurnStateUpdate, WEBSOCKET_STREAM_BUFFER, reusable_websocket_metadata,
|
||||
};
|
||||
|
||||
pub(in crate::transport::websocket) struct WebSocketStreamPoolReturn {
|
||||
@@ -63,8 +62,8 @@ pub(in crate::transport::websocket) fn stream_websocket_response(
|
||||
) -> CodexWebSocketStreamingExchange {
|
||||
let websocket_connection_id = websocket.connection_id();
|
||||
let response_metadata = metadata.clone();
|
||||
let rate_limit_header_updates = Arc::new(Mutex::new(Vec::new()));
|
||||
let rate_limit_header_updates_for_task = Arc::clone(&rate_limit_header_updates);
|
||||
let rate_limit_updates = Arc::new(Mutex::new(Vec::new()));
|
||||
let rate_limit_updates_for_task = Arc::clone(&rate_limit_updates);
|
||||
let turn_state_update = Arc::new(Mutex::new(metadata.turn_state.clone()));
|
||||
let turn_state_update_for_task = Arc::clone(&turn_state_update);
|
||||
let (tx, rx) = mpsc::channel(WEBSOCKET_STREAM_BUFFER);
|
||||
@@ -83,7 +82,7 @@ pub(in crate::transport::websocket) fn stream_websocket_response(
|
||||
reused_connection,
|
||||
initial_event_timeout,
|
||||
shutdown,
|
||||
rate_limit_header_updates: rate_limit_header_updates_for_task,
|
||||
rate_limit_updates: rate_limit_updates_for_task,
|
||||
turn_state_update: turn_state_update_for_task,
|
||||
tx,
|
||||
})
|
||||
@@ -105,7 +104,7 @@ pub(in crate::transport::websocket) fn stream_websocket_response(
|
||||
turn_state: response_metadata.turn_state,
|
||||
set_cookie_headers: response_metadata.set_cookie_headers,
|
||||
rate_limit_headers: response_metadata.rate_limit_headers,
|
||||
rate_limit_header_updates,
|
||||
rate_limit_updates,
|
||||
turn_state_update,
|
||||
pool_decision: None,
|
||||
connection_local_continuation: false,
|
||||
@@ -121,7 +120,7 @@ struct WebSocketStreamForwardState {
|
||||
reused_connection: bool,
|
||||
initial_event_timeout: Option<Duration>,
|
||||
shutdown: CancellationToken,
|
||||
rate_limit_header_updates: CodexWebSocketRateLimitHeaderUpdates,
|
||||
rate_limit_updates: CodexWebSocketRateLimitUpdates,
|
||||
turn_state_update: CodexWebSocketTurnStateUpdate,
|
||||
tx: mpsc::Sender<Result<Bytes, CodexWebSocketExchangeError>>,
|
||||
}
|
||||
@@ -134,7 +133,7 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
|
||||
reused_connection,
|
||||
initial_event_timeout,
|
||||
shutdown,
|
||||
rate_limit_header_updates,
|
||||
rate_limit_updates,
|
||||
turn_state_update,
|
||||
tx,
|
||||
} = state;
|
||||
@@ -231,8 +230,8 @@ async fn forward_websocket_response_stream(state: WebSocketStreamForwardState) {
|
||||
};
|
||||
let (frame, terminal) = match reduce_websocket_event(&raw, &mut metadata, &mut continuation)
|
||||
{
|
||||
Ok(ExchangeAction::RateLimits(headers)) => {
|
||||
rate_limit_header_updates.lock().await.extend(headers);
|
||||
Ok(ExchangeAction::RateLimits(rate_limits)) => {
|
||||
rate_limit_updates.lock().await.push(rate_limits);
|
||||
continue;
|
||||
}
|
||||
Ok(ExchangeAction::TurnState(turn_state)) => {
|
||||
|
||||
@@ -21,8 +21,8 @@ pub use self::{
|
||||
},
|
||||
error::{CodexWebSocketCloseError, CodexWebSocketExchangeError, CodexWebSocketUpstreamError},
|
||||
exchange::{
|
||||
CodexWebSocketRateLimitHeaderUpdates, CodexWebSocketSseStream,
|
||||
CodexWebSocketStreamingExchange, CodexWebSocketTurnStateUpdate,
|
||||
CodexWebSocketRateLimitUpdates, CodexWebSocketSseStream, CodexWebSocketStreamingExchange,
|
||||
CodexWebSocketTurnStateUpdate,
|
||||
},
|
||||
handshake::responses_websocket_endpoint,
|
||||
model::{
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 gateway_admin::model::accounts::AccountRecord;
|
||||
@@ -578,7 +579,7 @@ async fn openai_admin_provider_projects_official_codex_quota_and_independent_buc
|
||||
let secondary = quota
|
||||
.windows
|
||||
.iter()
|
||||
.find(|window| window.key == "core-secondary")
|
||||
.find(|window| window.key == "codex-secondary")
|
||||
.expect("core secondary quota");
|
||||
assert_eq!(secondary.label, "次级额度");
|
||||
assert_eq!(secondary.used_percent, Some(88.0));
|
||||
@@ -762,6 +763,61 @@ async fn openai_admin_provider_rejects_unprepared_mutations_before_store_commit(
|
||||
assert_eq!(refresh_error.kind(), ProviderAdminErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_rotation_preserves_the_new_access_token_jwt_expiration() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
store
|
||||
.seed_oauth_credential(ImportCodexOAuthCredential {
|
||||
account_id: "acct_admin_rotation_expiration".to_owned(),
|
||||
name: "admin rotation expiration".to_owned(),
|
||||
secret: secret("admin-rotation-access"),
|
||||
verified_account: profile("chatgpt-admin-rotation-expiration"),
|
||||
next_refresh_at: None,
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
let account = store
|
||||
.account("acct_admin_rotation_expiration")
|
||||
.expect("stored account");
|
||||
let record = account_record(&account);
|
||||
let config = valid_config();
|
||||
let bundle = provider_openai::initialize(
|
||||
config.config.clone(),
|
||||
provider_ports_with(store, Arc::new(TestOAuthPending::default())),
|
||||
)
|
||||
.await
|
||||
.expect("OpenAI bundle");
|
||||
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(2_000_000_000, 0)
|
||||
.single()
|
||||
.expect("valid test timestamp");
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
|
||||
.expect("test JWT payload"),
|
||||
);
|
||||
let mut material = Map::new();
|
||||
material.insert(
|
||||
"access_token".to_owned(),
|
||||
Value::String(format!("unverified-header.{payload}.unverified-signature")),
|
||||
);
|
||||
material.insert(
|
||||
"refresh_token".to_owned(),
|
||||
Value::String("admin-rotation-refresh".to_owned()),
|
||||
);
|
||||
|
||||
let prepared = bundle
|
||||
.admin_provider()
|
||||
.prepare_rotation(PrepareCredentialRotation {
|
||||
account: record,
|
||||
provider_material: ProviderDocument::new(OpaqueProviderData::new(material)),
|
||||
})
|
||||
.await
|
||||
.expect("JWT rotation should be prepared");
|
||||
|
||||
assert_eq!(prepared.facts().access_token_expires_at, Some(expires_at));
|
||||
}
|
||||
|
||||
fn provider_ports() -> ProviderStorePorts {
|
||||
provider_ports_with(
|
||||
Arc::new(MemoryAccountStore::default()),
|
||||
|
||||
@@ -253,7 +253,7 @@ async fn cross_plan_presentation_drift_unions_on_the_first_seen_model() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_plan_responses_api_conflict_still_fails_the_union() {
|
||||
async fn cross_plan_supported_in_api_drift_unions_on_the_first_seen_model() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
seed_account_with_plan(&store, "acct_conflict_plus", "plus").await;
|
||||
seed_account_with_plan(&store, "acct_conflict_pro", "pro").await;
|
||||
@@ -272,15 +272,12 @@ async fn cross_plan_responses_api_conflict_still_fails_the_union() {
|
||||
.await;
|
||||
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
|
||||
|
||||
let error = service
|
||||
let snapshot = service
|
||||
.synchronize()
|
||||
.await
|
||||
.expect_err("routing facts must still agree across plans");
|
||||
.expect("supported_in_api drift must not block catalog publication");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CodexCredentialCatalogError::ConflictingModelFacts
|
||||
));
|
||||
assert_eq!(snapshot.models().len(), 1);
|
||||
server.verify().await;
|
||||
}
|
||||
|
||||
|
||||
@@ -812,13 +812,15 @@ fn credential_expired_failure_keeps_expired_oauth_for_bounded_refresh_recovery()
|
||||
))
|
||||
.expect("record credential expiry");
|
||||
|
||||
let retained = store
|
||||
.account("acct_primary")
|
||||
.expect("account retained for refresh");
|
||||
assert_eq!(retained.credential_state(), CredentialState::Ready);
|
||||
assert_eq!(
|
||||
store
|
||||
.account("acct_primary")
|
||||
.expect("account retained for refresh")
|
||||
.credential_state(),
|
||||
CredentialState::Ready
|
||||
retained.last_error_reason(),
|
||||
Some(AccountErrorReason::AccessTokenExpired)
|
||||
);
|
||||
assert_eq!(retained.last_error_message(), Some("token_expired"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -112,7 +112,6 @@ impl AuthorizationCodeExchanger for Exchanger {
|
||||
id_token: None,
|
||||
},
|
||||
id_token: SecretString::from(self.id_token.clone()),
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use gateway_core::engine::credential::{
|
||||
AccountStateChange, AccountStatus, CredentialState, ProviderAccount, ProviderAccountStore as _,
|
||||
QuotaAccessChange, QuotaAccessState, QuotaEvidence, QuotaState,
|
||||
};
|
||||
use gateway_protocol::openai::events::parse_rate_limits_event;
|
||||
use provider_openai::OFFICIAL_CODEX_BASE_URL;
|
||||
use provider_openai::credential::{
|
||||
CodexCredentialQuotaError, CodexCredentialQuotaService, ImportCodexOAuthCredential,
|
||||
@@ -163,6 +164,224 @@ async fn successful_inference_headers_are_authoritative_even_at_full_display_usa
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passive_quota_updates_keep_core_and_model_specific_buckets_independent() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_independent_passive_buckets";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("account");
|
||||
let service = quota_service(&store);
|
||||
let initial_headers = vec![
|
||||
("x-codex-active-limit".to_owned(), "codex".to_owned()),
|
||||
("x-codex-primary-used-percent".to_owned(), "81".to_owned()),
|
||||
(
|
||||
"x-codex-primary-window-minutes".to_owned(),
|
||||
"10080".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-used-percent".to_owned(),
|
||||
"0".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-window-minutes".to_owned(),
|
||||
"10080".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-limit-name".to_owned(),
|
||||
"GPT-5.3-Codex-Spark".to_owned(),
|
||||
),
|
||||
];
|
||||
service
|
||||
.synchronize_passive_headers(&account, &initial_headers)
|
||||
.await
|
||||
.expect("initial passive quota");
|
||||
|
||||
let spark_headers = vec![
|
||||
// 生产响应会把当前具名桶同时复制到旧版 `x-codex-*` 默认头;这个
|
||||
// 兼容别名不能覆盖已有的账号 core 桶。
|
||||
("x-codex-active-limit".to_owned(), "codex".to_owned()),
|
||||
("x-codex-primary-used-percent".to_owned(), "1".to_owned()),
|
||||
(
|
||||
"x-codex-primary-window-minutes".to_owned(),
|
||||
"10080".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-reset-at".to_owned(),
|
||||
"1900000100".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-used-percent".to_owned(),
|
||||
"1".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-window-minutes".to_owned(),
|
||||
"10080".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-reset-at".to_owned(),
|
||||
"1900000100".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-limit-name".to_owned(),
|
||||
"GPT-5.3-Codex-Spark".to_owned(),
|
||||
),
|
||||
];
|
||||
service
|
||||
.synchronize_passive_headers(&account, &spark_headers)
|
||||
.await
|
||||
.expect("Spark passive quota");
|
||||
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read Spark quota")
|
||||
.expect("Spark quota snapshot");
|
||||
let core = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.find(|window| window.source() == "codex")
|
||||
.expect("core quota window");
|
||||
let spark = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.find(|window| window.source() == "codex_bengalfox")
|
||||
.expect("Spark quota window");
|
||||
assert_eq!(core.used_percent(), Some(81.0));
|
||||
assert_eq!(spark.used_percent(), Some(1.0));
|
||||
|
||||
let core_headers = vec![
|
||||
("x-codex-active-limit".to_owned(), "codex".to_owned()),
|
||||
("x-codex-primary-used-percent".to_owned(), "82".to_owned()),
|
||||
(
|
||||
"x-codex-primary-window-minutes".to_owned(),
|
||||
"10080".to_owned(),
|
||||
),
|
||||
];
|
||||
service
|
||||
.synchronize_passive_headers(&account, &core_headers)
|
||||
.await
|
||||
.expect("core passive quota");
|
||||
|
||||
let snapshot = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read core quota")
|
||||
.expect("core quota snapshot");
|
||||
let core = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.find(|window| window.source() == "codex")
|
||||
.expect("core quota window");
|
||||
let spark = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.find(|window| window.source() == "codex_bengalfox")
|
||||
.expect("Spark quota window");
|
||||
assert_eq!(core.used_percent(), Some(82.0));
|
||||
assert_eq!(spark.used_percent(), Some(1.0));
|
||||
|
||||
let persisted = store.quota_json(account_id).expect("persisted quota");
|
||||
let by_limit_id = persisted
|
||||
.get("rate_limits_by_limit_id")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.expect("rate-limit map");
|
||||
assert_eq!(
|
||||
by_limit_id.keys().map(String::as_str).collect::<Vec<_>>(),
|
||||
["codex", "codex_bengalfox"]
|
||||
);
|
||||
assert!(persisted.get("rate_limit").is_none());
|
||||
assert!(persisted.get("additional_rate_limits").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_additional_rate_limit_resolves_by_name_without_touching_core() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_websocket_additional_limit";
|
||||
create_account(&store, account_id).await;
|
||||
let account = store.account(account_id).expect("account");
|
||||
let service = quota_service(&store);
|
||||
let initial_headers = vec![
|
||||
("x-codex-active-limit".to_owned(), "codex".to_owned()),
|
||||
("x-codex-primary-used-percent".to_owned(), "84".to_owned()),
|
||||
(
|
||||
"x-codex-primary-window-minutes".to_owned(),
|
||||
"10080".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-primary-reset-at".to_owned(),
|
||||
"1900000000".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-used-percent".to_owned(),
|
||||
"0".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-window-minutes".to_owned(),
|
||||
"10080".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-primary-reset-at".to_owned(),
|
||||
"1900000100".to_owned(),
|
||||
),
|
||||
(
|
||||
"x-codex-bengalfox-limit-name".to_owned(),
|
||||
"GPT-5.3-Codex-Spark".to_owned(),
|
||||
),
|
||||
];
|
||||
service
|
||||
.synchronize_passive_headers(&account, &initial_headers)
|
||||
.await
|
||||
.expect("initial quota metadata");
|
||||
|
||||
let event = json!({
|
||||
"type": "codex.rate_limits",
|
||||
"plan_type": "pro",
|
||||
"rate_limits": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary": {
|
||||
"used_percent": 2,
|
||||
"window_minutes": 10080,
|
||||
"reset_at": 1900000100
|
||||
},
|
||||
"secondary": null
|
||||
},
|
||||
"additional_rate_limits": {
|
||||
"GPT-5.3-Codex-Spark": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary": {
|
||||
"used_percent": 2,
|
||||
"window_minutes": 10080,
|
||||
"reset_at": 1900000100
|
||||
},
|
||||
"secondary": null
|
||||
}
|
||||
},
|
||||
"credits": null
|
||||
});
|
||||
let observation = parse_rate_limits_event(&event).expect("WebSocket rate-limit event");
|
||||
service
|
||||
.synchronize_passive_rate_limits(&account, std::slice::from_ref(&observation))
|
||||
.await
|
||||
.expect("WebSocket passive quota");
|
||||
|
||||
let persisted = store.quota_json(account_id).expect("persisted quota");
|
||||
let by_limit_id = persisted["rate_limits_by_limit_id"]
|
||||
.as_object()
|
||||
.expect("rate-limit map");
|
||||
assert_eq!(
|
||||
by_limit_id["codex"]["primary_window"]["used_percent"],
|
||||
json!(84.0)
|
||||
);
|
||||
assert_eq!(
|
||||
by_limit_id["codex_bengalfox"]["primary_window"]["used_percent"],
|
||||
json!(2.0)
|
||||
);
|
||||
assert_eq!(persisted["active_limit"], json!("codex_bengalfox"));
|
||||
assert_eq!(by_limit_id.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_refresh_persists_explicit_provider_denial() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -209,6 +428,14 @@ async fn quota_refresh_persists_explicit_provider_denial() {
|
||||
current.status_projection(SystemTime::now(), None).status,
|
||||
AccountStatus::QuotaExhausted
|
||||
);
|
||||
let persisted = store.quota_json(account_id).expect("persisted quota");
|
||||
assert!(
|
||||
persisted
|
||||
.get("rate_limits_by_limit_id")
|
||||
.and_then(|limits| limits.get("codex"))
|
||||
.is_some()
|
||||
);
|
||||
assert!(persisted.get("rate_limit").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -75,7 +75,7 @@ async fn quota_refresh_updates_access_fact_without_recovering_credential_error()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high() {
|
||||
async fn exhausted_quota_worker_only_touches_refresh_time_when_recovery_is_rejected() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = "acct_worker_quota_recovery_gate";
|
||||
create_account(&store, account_id).await;
|
||||
@@ -101,7 +101,7 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
|
||||
server.uri(),
|
||||
);
|
||||
|
||||
service
|
||||
let old_snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhausted quota");
|
||||
@@ -125,6 +125,14 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
|
||||
assert_eq!(summary.exhausted, 1);
|
||||
assert_eq!(summary.updated, 0);
|
||||
assert_eq!(store.quota_json(account_id), Some(old_quota));
|
||||
let refreshed = service
|
||||
.read_account(account.id())
|
||||
.await
|
||||
.expect("read refreshed quota")
|
||||
.expect("refreshed quota snapshot");
|
||||
assert!(refreshed.observed_at() > old_snapshot.observed_at());
|
||||
assert_eq!(refreshed.fact(), old_snapshot.fact());
|
||||
assert_eq!(refreshed.quota().access(), QuotaAccessState::Exhausted);
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
@@ -137,10 +145,18 @@ async fn exhausted_quota_worker_keeps_old_document_when_new_window_usage_is_high
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_percent() {
|
||||
for (suffix, reset_at, used_percent, recovered) in [
|
||||
("same_reset", 1_900_000_000_i64, 0_u64, false),
|
||||
("exactly_ten", 1_900_003_600_i64, 10_u64, false),
|
||||
("below_ten", 1_900_003_600_i64, 9_u64, true),
|
||||
for (suffix, allowed, limit_reached, reset_at, used_percent, recovered) in [
|
||||
(
|
||||
"still_exhausted",
|
||||
false,
|
||||
true,
|
||||
1_900_003_600_i64,
|
||||
0_u8,
|
||||
false,
|
||||
),
|
||||
("same_reset", true, false, 1_900_000_000_i64, 0_u8, false),
|
||||
("exactly_ten", true, false, 1_900_003_600_i64, 10_u8, false),
|
||||
("below_ten", true, false, 1_900_003_600_i64, 9_u8, true),
|
||||
] {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let account_id = format!("acct_manual_quota_recovery_{suffix}");
|
||||
@@ -166,7 +182,7 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
|
||||
.expect("client"),
|
||||
server.uri(),
|
||||
);
|
||||
service
|
||||
let old_snapshot = service
|
||||
.refresh_account(account.id())
|
||||
.await
|
||||
.expect("seed exhausted quota");
|
||||
@@ -177,8 +193,8 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
|
||||
.and(path("/api/codex/usage"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"allowed": allowed,
|
||||
"limit_reached": limit_reached,
|
||||
"primary_window": {"used_percent": used_percent, "reset_at": reset_at}
|
||||
}
|
||||
})))
|
||||
@@ -197,11 +213,12 @@ async fn manual_quota_refresh_only_recovers_after_reset_advances_below_ten_perce
|
||||
assert_ne!(store.quota_json(&account_id), Some(old_quota));
|
||||
assert_eq!(current.quota().access(), QuotaAccessState::Allowed);
|
||||
} else {
|
||||
assert_eq!(snapshot.fact().remaining_percent(), Some(0));
|
||||
assert_eq!(snapshot.fact(), old_snapshot.fact());
|
||||
assert_eq!(snapshot.quota().access(), QuotaAccessState::Exhausted);
|
||||
assert_eq!(store.quota_json(&account_id), Some(old_quota));
|
||||
assert_eq!(current.quota().access(), QuotaAccessState::Exhausted);
|
||||
}
|
||||
assert!(snapshot.observed_at() > old_snapshot.observed_at());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -396,11 +396,11 @@ async fn persisted_quota_orders_core_window_before_additional_limit() {
|
||||
.map(|window| window.source())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(sources, ["codex", "gpt-5.3-codex-spark"]);
|
||||
assert_eq!(sources, ["codex", "gpt_5.3_codex_spark"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_review_limit_projects_as_independent_window_with_limit_name() {
|
||||
async fn code_review_limit_projects_as_one_snapshot_per_limit_id() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_code_review").await;
|
||||
let account = store.account("acct_code_review").expect("account");
|
||||
@@ -455,21 +455,16 @@ async fn code_review_limit_projects_as_independent_window_with_limit_name() {
|
||||
.expect("read quota")
|
||||
.expect("quota snapshot");
|
||||
|
||||
// spend_control 不再生成窗口(只作 exhaustion 信号);顶层和 additional
|
||||
// 的 code_review 桶分别保留,避免一个快照覆盖另一个。
|
||||
// spend_control 不生成窗口(只作 exhaustion 信号);官方 map 协议中同一个
|
||||
// limit_id 只能保留一个快照,顶层 code_review 事实优先于重复 additional。
|
||||
let review = snapshot
|
||||
.windows()
|
||||
.iter()
|
||||
.filter(|window| window.source() == "code_review")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(review.len(), 2);
|
||||
assert!(review.iter().any(|window| {
|
||||
window.limit_name() == Some("code_review") && window.used_percent() == Some(80.0)
|
||||
}));
|
||||
assert!(review.iter().any(|window| {
|
||||
window.limit_name() == Some("code review") && window.used_percent() == Some(55.0)
|
||||
}));
|
||||
assert_ne!(review[0].key(), review[1].key());
|
||||
assert_eq!(snapshot.windows().len(), 3);
|
||||
assert_eq!(review.len(), 1);
|
||||
assert_eq!(review[0].limit_name(), Some("code_review"));
|
||||
assert_eq!(review[0].used_percent(), Some(80.0));
|
||||
assert_eq!(snapshot.windows().len(), 2);
|
||||
assert_eq!(snapshot.quota().access(), QuotaAccessState::Unknown);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, TimeZone as _, Utc};
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::credential::{ProviderAccountId, ProviderAccountStore};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountErrorReason, AccountStateChange, AccountStatus, CredentialState, ProviderAccountId,
|
||||
ProviderAccountStore, QuotaAccessChange, QuotaEvidence, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::provider_ports::{
|
||||
ProviderCredentialState, ProviderCredentialStatePort, ProviderLeaseAcquisition,
|
||||
@@ -14,12 +18,16 @@ use gateway_core::provider_ports::{
|
||||
ProviderSchedulingState, ProviderStoreError,
|
||||
};
|
||||
use gateway_core::routing::ProviderKind;
|
||||
use provider_openai::credential::token_client::{RefreshFailure, TokenPair, TokenRefresher};
|
||||
use provider_openai::credential::token_client::{
|
||||
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenPair, TokenRefresher,
|
||||
};
|
||||
use provider_openai::credential::{
|
||||
CodexCredentialCodec, CodexCredentialRefreshOutcome, CodexCredentialRefreshService,
|
||||
ImportCodexOAuthCredential,
|
||||
};
|
||||
use secrecy::{ExposeSecret as _, SecretString};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
use crate::support::{MemoryAccountStore, profile, secret};
|
||||
|
||||
@@ -36,7 +44,6 @@ impl SingleUseRefresher {
|
||||
access_token: Some("refreshed-access-token".to_owned()),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -52,7 +59,17 @@ impl SingleUseRefresher {
|
||||
access_token: Some("refreshed-access-token".to_owned()),
|
||||
refresh_token: None,
|
||||
id_token: Some(id_token.to_owned()),
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
fn with_access_token(access_token: String) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
response: Mutex::new(Some(TokenPair {
|
||||
access_token: Some(access_token),
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -64,7 +81,6 @@ impl SingleUseRefresher {
|
||||
access_token: None,
|
||||
refresh_token: None,
|
||||
id_token: None,
|
||||
expires_in: Some(Duration::from_secs(60 * 60)),
|
||||
})),
|
||||
})
|
||||
}
|
||||
@@ -78,7 +94,21 @@ impl TokenRefresher for SingleUseRefresher {
|
||||
.lock()
|
||||
.expect("refresh response lock")
|
||||
.take()
|
||||
.ok_or(RefreshFailure::Transport)
|
||||
.ok_or_else(|| RefreshFailure::Transport {
|
||||
message: Some("test refresh response is exhausted".to_owned()),
|
||||
upstream: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct FailingRefresher {
|
||||
failure: RefreshFailure,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TokenRefresher for FailingRefresher {
|
||||
async fn refresh(&self, _: &str) -> Result<TokenPair, RefreshFailure> {
|
||||
Err(self.failure.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +297,263 @@ async fn scheduled_refresh_uses_the_current_margin_without_persisting_a_normal_s
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_rotates_tokens_while_quota_is_exhausted() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
|
||||
let account_id = "acct_exhausted_quota_refresh";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let account = store.account(account_id).expect("seeded account");
|
||||
assert_eq!(
|
||||
store
|
||||
.apply_quota_access(QuotaAccessChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
state: QuotaState::exhausted(
|
||||
QuotaEvidence::UsageLimitReached,
|
||||
SystemTime::now(),
|
||||
None,
|
||||
),
|
||||
})
|
||||
.await
|
||||
.expect("persist exhausted quota"),
|
||||
QuotaWriteOutcome::Updated
|
||||
);
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id: refreshed_account_id,
|
||||
..
|
||||
}] if refreshed_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("refreshed account");
|
||||
assert!(account.quota().is_exhausted());
|
||||
let loaded = store
|
||||
.load_credential(account.id(), account.revision())
|
||||
.await
|
||||
.expect("refreshed credential");
|
||||
let runtime = CodexCredentialCodec::decode(&loaded.credential).expect("runtime credential");
|
||||
let oauth = runtime.authentication.oauth().expect("OAuth credential");
|
||||
assert_eq!(oauth.access_token.expose_secret(), "refreshed-access-token");
|
||||
let expected_refresh_token = format!("rt-access-{account_id}");
|
||||
assert_eq!(
|
||||
oauth
|
||||
.refresh_token
|
||||
.as_ref()
|
||||
.map(SecretString::expose_secret),
|
||||
Some(expected_refresh_token.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_persists_the_original_upstream_error_message() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let upstream_message = "Refresh token has already been used.";
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
Arc::new(FailingRefresher {
|
||||
failure: RefreshFailure::InvalidGrant {
|
||||
message: Some(upstream_message.to_owned()),
|
||||
upstream: None,
|
||||
},
|
||||
}),
|
||||
Arc::new(RefreshLeases),
|
||||
Arc::new(RefreshCredentialState),
|
||||
policy,
|
||||
);
|
||||
let account_id = "acct_invalid_refresh_token";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Invalidated {
|
||||
account_id: invalidated_account_id,
|
||||
}] if invalidated_account_id == account_id
|
||||
));
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("invalidated account")
|
||||
.last_error_message(),
|
||||
Some(upstream_message)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_persists_retryable_message_inside_the_two_hour_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let upstream_message = "Invalid refresh token.";
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"message": upstream_message,
|
||||
"type": "invalid_request_error",
|
||||
"code": "invalid_refresh_token"
|
||||
}
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
Arc::new(OpenAiTokenClient::new(
|
||||
reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.build()
|
||||
.expect("test HTTP client"),
|
||||
TokenClientConfig {
|
||||
client_id: "test-public-client".to_owned(),
|
||||
token_endpoint: format!("{}/oauth/token", server.uri()),
|
||||
},
|
||||
)),
|
||||
Arc::new(RefreshLeases),
|
||||
Arc::new(RefreshCredentialState),
|
||||
policy,
|
||||
);
|
||||
let account_id = "acct_retryable_unauthorized";
|
||||
let expires_at = SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(30 * 60))
|
||||
.expect("expired access token");
|
||||
seed_refreshable_account(&store, account_id, expires_at, None).await;
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Transient {
|
||||
account_id: deferred_account_id,
|
||||
}] if deferred_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("deferred account");
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
assert_eq!(
|
||||
account.last_error_reason(),
|
||||
Some(AccountErrorReason::AccessTokenExpired)
|
||||
);
|
||||
assert_eq!(account.last_error_message(), Some(upstream_message));
|
||||
let retry_at = account.next_refresh_at().expect("next refresh attempt");
|
||||
assert!(retry_at > SystemTime::now());
|
||||
assert!(
|
||||
retry_at
|
||||
<= expires_at
|
||||
.checked_add(Duration::from_secs(2 * 60 * 60))
|
||||
.expect("recovery deadline")
|
||||
);
|
||||
let projection = account.status_projection(SystemTime::now(), None);
|
||||
assert_eq!(projection.status, AccountStatus::Error);
|
||||
assert_eq!(projection.error_message.as_deref(), Some(upstream_message));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_uses_the_final_message_after_the_two_hour_window() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let upstream_message = "Invalid refresh token.";
|
||||
let service = CodexCredentialRefreshService::new(
|
||||
store.repository(),
|
||||
Arc::new(FailingRefresher {
|
||||
failure: RefreshFailure::Transport {
|
||||
message: Some(upstream_message.to_owned()),
|
||||
upstream: None,
|
||||
},
|
||||
}),
|
||||
Arc::new(RefreshLeases),
|
||||
Arc::new(RefreshCredentialState),
|
||||
policy,
|
||||
);
|
||||
let account_id = "acct_exhausted_unauthorized";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(2 * 60 * 60 + 60))
|
||||
.expect("expired recovery window"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Invalidated {
|
||||
account_id: invalidated_account_id,
|
||||
}] if invalidated_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("invalidated account");
|
||||
assert_eq!(account.credential_state(), CredentialState::Expired);
|
||||
assert_eq!(account.last_error_message(), Some(upstream_message));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_success_clears_a_deferred_failure_message() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let policy = MutableRuntimePolicy::new(Duration::from_secs(5 * 60));
|
||||
let account_id = "acct_recovered_after_backoff";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.expect("expired access token"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let account = store.account(account_id).expect("seeded account");
|
||||
store
|
||||
.apply_state_change(AccountStateChange {
|
||||
account_id: account.id().clone(),
|
||||
expected_revision: account.revision(),
|
||||
credential_state: CredentialState::Ready,
|
||||
observed_at: SystemTime::now(),
|
||||
error_reason: Some(AccountErrorReason::AccessTokenExpired),
|
||||
message: Some("Invalid refresh token.".to_owned()),
|
||||
})
|
||||
.await
|
||||
.expect("seed deferred failure");
|
||||
let service = refresh_service(&store, SingleUseRefresher::new(), policy);
|
||||
|
||||
let outcomes = service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert!(matches!(
|
||||
outcomes.as_slice(),
|
||||
[CodexCredentialRefreshOutcome::Refreshed {
|
||||
account_id: refreshed_account_id,
|
||||
..
|
||||
}] if refreshed_account_id == account_id
|
||||
));
|
||||
let account = store.account(account_id).expect("refreshed account");
|
||||
assert_eq!(account.credential_state(), CredentialState::Ready);
|
||||
assert_eq!(account.last_error_reason(), None);
|
||||
assert_eq!(account.last_error_message(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_persists_a_returned_id_token() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -302,6 +589,48 @@ async fn scheduled_refresh_persists_a_returned_id_token() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_uses_the_rotated_access_token_jwt_expiration() {
|
||||
let expires_at = Utc
|
||||
.timestamp_opt(2_000_000_000, 0)
|
||||
.single()
|
||||
.expect("valid test timestamp");
|
||||
let payload = URL_SAFE_NO_PAD.encode(
|
||||
serde_json::to_vec(&serde_json::json!({"exp": expires_at.timestamp()}))
|
||||
.expect("test JWT payload"),
|
||||
);
|
||||
let refresher = SingleUseRefresher::with_access_token(format!(
|
||||
"unverified-header.{payload}.unverified-signature"
|
||||
));
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let service = refresh_service(
|
||||
&store,
|
||||
refresher,
|
||||
MutableRuntimePolicy::new(Duration::from_secs(5 * 60)),
|
||||
);
|
||||
let account_id = "acct_rotated_access_expiration";
|
||||
seed_refreshable_account(
|
||||
&store,
|
||||
account_id,
|
||||
SystemTime::now()
|
||||
.checked_add(Duration::from_secs(120))
|
||||
.expect("test expiry"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
service.refresh_due().await.expect("refresh cycle");
|
||||
|
||||
assert_eq!(
|
||||
store
|
||||
.account(account_id)
|
||||
.expect("refreshed account")
|
||||
.access_token_expires_at()
|
||||
.map(DateTime::<Utc>::from),
|
||||
Some(expires_at)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduled_refresh_preserves_the_stored_token_set_when_rotation_fields_are_omitted() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use provider_openai::credential::token_client::{
|
||||
AuthorizationCodeExchangeError, AuthorizationCodeExchanger, AuthorizationCodeGrant,
|
||||
OpenAiTokenClient, RefreshFailure, TokenClientConfig, TokenRefresher,
|
||||
@@ -46,13 +44,16 @@ async fn oversized_chunked_oauth_response_should_fail_closed_and_redact_body() {
|
||||
.expect_err("oversized response must fail closed before body classification");
|
||||
let diagnostic = format!("{failure:?} {failure}");
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
assert!(matches!(
|
||||
failure,
|
||||
RefreshFailure::Transport { upstream: None, .. }
|
||||
));
|
||||
assert!(!diagnostic.contains(marker));
|
||||
assert!(!diagnostic.contains("refresh-secret-request-marker"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
|
||||
async fn bounded_oauth_response_should_parse_the_official_rotated_token_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
@@ -74,7 +75,6 @@ async fn bounded_oauth_response_should_parse_lifetime_and_rotated_token() {
|
||||
assert_eq!(tokens.access_token.as_deref(), Some("access-rotated"));
|
||||
assert_eq!(tokens.refresh_token.as_deref(), Some("refresh-rotated"));
|
||||
assert_eq!(tokens.id_token.as_deref(), Some("header.e30.signature"));
|
||||
assert_eq!(tokens.expires_in, Some(Duration::from_secs(3600)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -95,7 +95,10 @@ async fn refresh_response_should_reject_a_malformed_rotated_id_token() {
|
||||
.await
|
||||
.expect_err("malformed ID token must not replace the stored token set");
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
assert!(matches!(
|
||||
failure,
|
||||
RefreshFailure::Transport { upstream: None, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -116,7 +119,6 @@ async fn refresh_response_should_allow_all_rotated_tokens_to_be_omitted() {
|
||||
assert!(tokens.access_token.is_none());
|
||||
assert!(tokens.refresh_token.is_none());
|
||||
assert!(tokens.id_token.is_none());
|
||||
assert!(tokens.expires_in.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -142,7 +144,7 @@ async fn refresh_response_keeps_the_upstream_rotated_token_verbatim() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_should_exchange_the_official_form_fields() {
|
||||
async fn refresh_should_exchange_the_official_json_fields() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
@@ -160,11 +162,24 @@ async fn refresh_should_exchange_the_official_form_fields() {
|
||||
.await
|
||||
.expect("refresh succeeds");
|
||||
let requests = server.received_requests().await.expect("received request");
|
||||
let body = String::from_utf8(requests[0].body.clone()).expect("form body is UTF-8");
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_slice(&requests[0].body).expect("JSON request body");
|
||||
|
||||
assert!(body.contains("grant_type=refresh_token"));
|
||||
assert!(body.contains("client_id=test-public-client"));
|
||||
assert!(body.contains("refresh_token=refresh+secret"));
|
||||
assert_eq!(
|
||||
body,
|
||||
serde_json::json!({
|
||||
"client_id": "test-public-client",
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": "refresh secret"
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
requests[0]
|
||||
.headers
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some("application/json")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -273,72 +288,165 @@ async fn authorization_code_exchange_requires_id_token_and_json_response() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_token_reuse_should_be_classified_as_invalid_grant() {
|
||||
let upstream_message = "Your refresh token has already been used to generate a new access token. Please try signing in again.";
|
||||
let failure = refresh_failure(
|
||||
400,
|
||||
r#"{"error":"invalid_grant","error_description":"refresh_token_reused"}"#,
|
||||
r#"{"error":{"message":"Your refresh token has already been used to generate a new access token. Please try signing in again.","type":"invalid_request_error","param":null,"code":"refresh_token_reused"}}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(failure, RefreshFailure::InvalidGrant);
|
||||
let RefreshFailure::InvalidGrant { message, upstream } = failure else {
|
||||
panic!("refresh-token reuse must be terminal");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some(upstream_message));
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), 400);
|
||||
assert_eq!(upstream.code(), Some("refresh_token_reused"));
|
||||
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
|
||||
assert!(upstream.body().contains(r#""code":"refresh_token_reused""#));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_invalid_grant_should_remain_transient_like_official_codex() {
|
||||
let body = r#"{"error":"invalid_grant","error_description":"not the official refresh error message field"}"#;
|
||||
let failure = refresh_failure(400, body).await;
|
||||
let diagnostic = format!("{failure:?} {failure}");
|
||||
|
||||
let RefreshFailure::Transport { message, upstream } = failure else {
|
||||
panic!("unknown official refresh code must remain transient");
|
||||
};
|
||||
assert_eq!(message, None);
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.code(), Some("invalid_grant"));
|
||||
assert_eq!(upstream.body(), body);
|
||||
assert!(!diagnostic.contains("error_description"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_should_preserve_upstream_details_and_remain_retryable() {
|
||||
let body = r#"{
|
||||
"error": {
|
||||
"message": "Invalid refresh token.",
|
||||
"type": "invalid_request_error",
|
||||
"param": null,
|
||||
"code": "invalid_refresh_token"
|
||||
}
|
||||
}"#;
|
||||
let failure = refresh_failure(401, body).await;
|
||||
|
||||
let RefreshFailure::Transport { message, upstream } = failure else {
|
||||
panic!("production policy gives every 401 a bounded recovery window");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some("Invalid refresh token."));
|
||||
let upstream = upstream.expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), 401);
|
||||
assert_eq!(upstream.code(), Some("invalid_refresh_token"));
|
||||
assert_eq!(upstream.error_type(), Some("invalid_request_error"));
|
||||
assert_eq!(upstream.body(), body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unauthorized_should_back_off_even_with_a_recognized_refresh_code() {
|
||||
let failure = refresh_failure(
|
||||
401,
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_transport_failure(
|
||||
&failure,
|
||||
401,
|
||||
Some("Refresh token expired."),
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deactivated_account_should_be_classified_as_banned() {
|
||||
let failure = refresh_failure(
|
||||
403,
|
||||
r#"{"error":"access_denied","error_description":"account has been deactivated"}"#,
|
||||
r#"{"error":{"code":"access_denied","message":"account has been deactivated"}}"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Banned);
|
||||
let RefreshFailure::Banned { message, upstream } = failure else {
|
||||
panic!("deactivated account must be banned");
|
||||
};
|
||||
assert_eq!(message.as_deref(), Some("account has been deactivated"));
|
||||
assert_eq!(upstream.expect("complete upstream failure").status(), 403);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generic_banned_text_should_not_impersonate_the_deactivation_contract() {
|
||||
let failure = refresh_failure(403, "account is banned").await;
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
assert_transport_failure(&failure, 403, None, "account is banned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregistered_disabled_account_text_should_remain_a_transport_failure() {
|
||||
let failure = refresh_failure(400, "account disabled").await;
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
assert_transport_failure(&failure, 400, None, "account disabled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quota_text_should_not_disable_the_oauth_credential() {
|
||||
let failure = refresh_failure(400, "quota exceeded").await;
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
assert_transport_failure(&failure, 400, None, "quota exceeded");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn token_revoked_text_without_invalid_grant_should_remain_temporary() {
|
||||
let failure = refresh_failure(400, "token_revoked").await;
|
||||
|
||||
assert_eq!(failure, RefreshFailure::Transport);
|
||||
assert_transport_failure(&failure, 400, None, "token_revoked");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_error_or_rate_limit_must_stay_transient_even_with_oauth_error_body() {
|
||||
// 5xx/429(含 CDN/网关页恰好嵌入 invalid_grant 字样)按状态码判瞬态,
|
||||
// 不因正文子串把账号永久终态——正文只在 4xx 时才是权威 OAuth 错误。
|
||||
async fn recognized_refresh_code_should_be_permanent_on_non_unauthorized_status() {
|
||||
for status in [500, 502, 503, 429] {
|
||||
let failure = refresh_failure(
|
||||
status,
|
||||
r#"{"error":"invalid_grant","error_description":"refresh_token_expired"}"#,
|
||||
r#"{"error":{"code":"refresh_token_expired","message":"Refresh token expired."}}"#,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
failure,
|
||||
RefreshFailure::Transport,
|
||||
"status {status} must classify as transient"
|
||||
assert!(
|
||||
matches!(failure, RefreshFailure::InvalidGrant { .. }),
|
||||
"official refresh code must take precedence for status {status}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_server_error_or_rate_limit_should_remain_transient() {
|
||||
let body = r#"{"error":{"code":"temporarily_unavailable","message":"Try again."}}"#;
|
||||
for status in [500, 502, 503, 429] {
|
||||
let failure = refresh_failure(status, body).await;
|
||||
assert_transport_failure(&failure, status, Some("Try again."), body);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_transport_failure(
|
||||
failure: &RefreshFailure,
|
||||
status: u16,
|
||||
message: Option<&str>,
|
||||
body: &str,
|
||||
) {
|
||||
let RefreshFailure::Transport {
|
||||
message: actual_message,
|
||||
upstream,
|
||||
} = failure
|
||||
else {
|
||||
panic!("status {status} must classify as transient");
|
||||
};
|
||||
assert_eq!(actual_message.as_deref(), message);
|
||||
let upstream = upstream.as_deref().expect("complete upstream failure");
|
||||
assert_eq!(upstream.status(), status);
|
||||
assert_eq!(upstream.body(), body);
|
||||
}
|
||||
|
||||
async fn refresh_failure(status: u16, body: &str) -> RefreshFailure {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
|
||||
@@ -6,6 +6,9 @@ use std::time::{Duration, SystemTime};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gateway_core::engine::continuation::{
|
||||
ContinuationBinding, NativeContinuationPin, PreviousResponseId,
|
||||
};
|
||||
use gateway_core::engine::credential::{
|
||||
AccountFeedbackStats, CredentialState, OpaqueProviderData, ProviderAccountId,
|
||||
ProviderAccountStore as _, QuotaAccessChange, QuotaAccessState, QuotaEvidence,
|
||||
@@ -16,9 +19,12 @@ use gateway_core::engine::{
|
||||
AccountAttemptContext, AttemptContext, CancellationToken, ContinuationAttempt, ModelRequestId,
|
||||
ProviderAccountStateOwner, RequestAttemptContext, UpstreamSendState,
|
||||
};
|
||||
use gateway_core::error::ProviderErrorKind;
|
||||
use gateway_core::error::{ContinuationFailure, ProviderErrorKind};
|
||||
use gateway_core::event::GatewayEvent;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, ProviderSessionState};
|
||||
use gateway_core::operation::{
|
||||
CapabilityRequirements, GenerateRequest, Operation, OperationKind, ProtocolPayload,
|
||||
ProviderSessionState,
|
||||
};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
use gateway_core::routing::{
|
||||
ClientRoutingScope, ConfigRevision, FrozenAccountScope, ModelCapabilities, ProviderKind,
|
||||
@@ -267,7 +273,8 @@ fn planned_request(provider_name: &str, operation: Operation) -> ProviderRequest
|
||||
vec![ProviderModel::new(
|
||||
provider,
|
||||
upstream_model,
|
||||
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000)),
|
||||
ModelCapabilities::new(BTreeSet::from([operation.kind()]), Some(32_000))
|
||||
.with_upstream_feature_validation(),
|
||||
)],
|
||||
Vec::new(),
|
||||
)
|
||||
@@ -288,6 +295,8 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let accounts = [
|
||||
"acct_affinity",
|
||||
"acct_affinity_switch_a",
|
||||
"acct_affinity_switch_b",
|
||||
"acct_atomic_failure",
|
||||
"acct_bare_atomic_failure",
|
||||
"acct_bounded_replay_grace",
|
||||
@@ -319,6 +328,7 @@ fn contract_account_scope() -> Arc<FrozenAccountScope> {
|
||||
"acct_truncated_stream",
|
||||
"acct_usage_limit_request_path",
|
||||
"acct_websocket_close",
|
||||
"acct_websocket_turn_state",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
@@ -389,6 +399,33 @@ fn context_with_state_owner(request_id: &str, owner_account_id: &str) -> Attempt
|
||||
)
|
||||
}
|
||||
|
||||
fn replay_any_context(request_id: &str, owner_account_id: &str) -> AttemptContext {
|
||||
let owner_account = ProviderAccountId::new(owner_account_id).expect("owner account id");
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let owner = ProviderAccountStateOwner::new(provider.clone(), owner_account.clone());
|
||||
let continuation = NativeContinuationPin::new(
|
||||
PreviousResponseId::new("client-previous-response"),
|
||||
PreviousResponseId::new("upstream-previous-response"),
|
||||
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
|
||||
provider,
|
||||
owner_account,
|
||||
);
|
||||
AttemptContext::new(
|
||||
RequestAttemptContext::new(
|
||||
ModelRequestId::new(request_id).expect("request id"),
|
||||
ClientApiKeyId::new("key_openai_contract").expect("client key id"),
|
||||
),
|
||||
NonZeroU32::new(2).expect("attempt"),
|
||||
SystemTime::now() + Duration::from_secs(30),
|
||||
account_policy(),
|
||||
AccountAttemptContext::new(BTreeSet::new(), None, Some(owner))
|
||||
.with_account_scope(contract_account_scope()),
|
||||
Some(ContinuationBinding::Pinned(continuation)),
|
||||
CancellationToken::new(),
|
||||
)
|
||||
.with_continuation_attempt(ContinuationAttempt::ReplayAny)
|
||||
}
|
||||
|
||||
async fn capture_scoped_http_request(
|
||||
request_id: &str,
|
||||
selected_account_id: &str,
|
||||
@@ -433,6 +470,76 @@ async fn capture_scoped_http_request(
|
||||
requests.pop().expect("single scoped request")
|
||||
}
|
||||
|
||||
async fn capture_turn_state_request(
|
||||
request_id: &str,
|
||||
previous_turn_id: Option<&str>,
|
||||
current_turn_id: Option<&str>,
|
||||
client_turn_state: Option<&str>,
|
||||
) -> wiremock::Request {
|
||||
let account_id = "acct_session_affinity";
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, account_id).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 mut session_state = Map::from_iter([
|
||||
("account_id".to_owned(), json!(account_id)),
|
||||
("conversation_id".to_owned(), json!("conversation")),
|
||||
("turn_state".to_owned(), json!("previous-turn-state")),
|
||||
("continuation_scope".to_owned(), json!("persisted")),
|
||||
]);
|
||||
if let Some(previous_turn_id) = previous_turn_id {
|
||||
session_state.insert("client_turn_id".to_owned(), json!(previous_turn_id));
|
||||
}
|
||||
let mut protocol_context = Map::from_iter([("use_websocket".to_owned(), json!(false))]);
|
||||
if let Some(current_turn_id) = current_turn_id {
|
||||
protocol_context.insert("turn_id".to_owned(), json!(current_turn_id));
|
||||
}
|
||||
if let Some(client_turn_state) = client_turn_state {
|
||||
protocol_context.insert("turn_state".to_owned(), json!(client_turn_state));
|
||||
}
|
||||
let operation = Operation::Generate(
|
||||
GenerateRequest::from_protocol_payload(
|
||||
ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("current input")),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(protocol_context),
|
||||
)
|
||||
.with_provider_session_state(
|
||||
ProviderSessionState::new("openai", session_state).expect("provider session state"),
|
||||
),
|
||||
);
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", operation),
|
||||
context(request_id, CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare turn-state provider stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("turn-state provider response");
|
||||
}
|
||||
let mut requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("captured turn-state request");
|
||||
assert_eq!(requests.len(), 1);
|
||||
requests.pop().expect("single turn-state request")
|
||||
}
|
||||
|
||||
fn captured_header_values(request: &wiremock::Request, name: &str) -> Vec<Vec<u8>> {
|
||||
request
|
||||
.headers
|
||||
@@ -789,6 +896,94 @@ async fn websocket_close_details_are_only_exposed_through_the_client_error() {
|
||||
assert_eq!(detail.error_type(), Some("websocket_close_error"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_turn_state_metadata_is_exposed_through_response_observation() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_websocket_turn_state").await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind WebSocket listener");
|
||||
let base_url = format!(
|
||||
"http://{}",
|
||||
listener.local_addr().expect("listener address")
|
||||
);
|
||||
let server = tokio::spawn(async move {
|
||||
let (stream, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.expect("accept WebSocket connection");
|
||||
let mut websocket = accept_codex_test_websocket(stream).await;
|
||||
let _request = websocket
|
||||
.next()
|
||||
.await
|
||||
.expect("WebSocket request")
|
||||
.expect("valid WebSocket request");
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.metadata",
|
||||
"headers": {
|
||||
"x-codex-turn-state": ["turn-state-from-websocket"]
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send response metadata");
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
json!({
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_websocket_turn_state",
|
||||
"model": "gpt-5.4",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": 1,
|
||||
"total_tokens": 2
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.await
|
||||
.expect("send completed response");
|
||||
websocket.close(None).await.expect("close WebSocket");
|
||||
});
|
||||
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request("openai", generate_operation()),
|
||||
context("req_websocket_turn_state", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare WebSocket provider stream");
|
||||
let mut observed_turn_state = false;
|
||||
while let Some(event) = stream.next().await {
|
||||
let event = event.expect("provider event");
|
||||
if let Some(observation) = event.response_observation() {
|
||||
observed_turn_state |= observation.client_headers().iter().any(|header| {
|
||||
header.name().eq_ignore_ascii_case("x-codex-turn-state")
|
||||
&& header.value().as_ref() == b"turn-state-from-websocket"
|
||||
});
|
||||
}
|
||||
if event
|
||||
.canonical_facts()
|
||||
.iter()
|
||||
.any(|event| matches!(event, GatewayEvent::Completed(_)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
server.await.expect("WebSocket server");
|
||||
|
||||
assert!(observed_turn_state);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_account_is_excluded_from_normal_scheduling() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -971,9 +1166,9 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continuation_replay_should_use_the_client_full_input_without_legacy_transcript() {
|
||||
async fn cross_account_continuation_probe_should_preserve_delta_input_and_previous_response() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_client_history").await;
|
||||
create_account(&store, "acct_scope_new").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/responses"))
|
||||
@@ -985,33 +1180,61 @@ async fn continuation_replay_should_use_the_client_full_input_without_legacy_tra
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let client_input = json!([
|
||||
{"role": "user", "content": "complete client history"},
|
||||
{"role": "assistant", "content": "previous answer"},
|
||||
{"role": "user", "content": "current turn"}
|
||||
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(), client_input.clone()),
|
||||
("input".to_owned(), delta_input.clone()),
|
||||
(
|
||||
"previous_response_id".to_owned(),
|
||||
json!("client-previous-response"),
|
||||
),
|
||||
("authorization".to_owned(), json!("client-extension-secret")),
|
||||
(
|
||||
"turnMetadata".to_owned(),
|
||||
json!(r#"{"account_id":"acct_scope_old","safe":true}"#),
|
||||
),
|
||||
(
|
||||
"client_metadata".to_owned(),
|
||||
json!({
|
||||
"x-codex-turn-state": "old-account-turn-state",
|
||||
"x-codex-turn-metadata": "old-account-turn-metadata",
|
||||
"future": "keep"
|
||||
}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))])),
|
||||
.with_context(Map::from_iter([
|
||||
("use_websocket".to_owned(), json!(false)),
|
||||
("turn_id".to_owned(), json!("turn-probe")),
|
||||
("turn_state".to_owned(), json!("old-account-turn-state")),
|
||||
])),
|
||||
)
|
||||
.with_provider_session_state(
|
||||
ProviderSessionState::new(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("account_id".to_owned(), json!("acct_client_history")),
|
||||
("account_id".to_owned(), json!("acct_scope_old")),
|
||||
("conversation_id".to_owned(), json!("conversation")),
|
||||
("continuation_scope".to_owned(), json!("replay_required")),
|
||||
(
|
||||
"transcript".to_owned(),
|
||||
json!([{"client_input": {"role": "user", "content": "stale proxy copy"}}]),
|
||||
),
|
||||
("turn_state".to_owned(), json!("old-account-turn-state")),
|
||||
("client_turn_id".to_owned(), json!("turn-probe")),
|
||||
("continuation_scope".to_owned(), json!("persisted")),
|
||||
]),
|
||||
)
|
||||
.expect("provider session state"),
|
||||
@@ -1019,21 +1242,84 @@ async fn continuation_replay_should_use_the_client_full_input_without_legacy_tra
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request("openai", Operation::Generate(generation)),
|
||||
context("req_client_history", CancellationToken::new())
|
||||
.with_continuation_attempt(ContinuationAttempt::ReplayAny),
|
||||
replay_any_context("req_cross_account_probe", "acct_scope_old"),
|
||||
)
|
||||
.await
|
||||
.expect("prepare replay stream");
|
||||
.expect("prepare continuation probe stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("replay response");
|
||||
event.expect("continuation probe response");
|
||||
}
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("captured replay request");
|
||||
.expect("captured continuation probe request");
|
||||
let body: Value = captured_request_body(&requests[0]);
|
||||
|
||||
assert_eq!(body.get("input"), Some(&client_input));
|
||||
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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matching_turn_id_should_restore_previous_turn_state() {
|
||||
let request = capture_turn_state_request(
|
||||
"req_restore_same_turn_state",
|
||||
Some("turn-same"),
|
||||
Some("turn-same"),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
captured_header_values(&request, "x-codex-turn-state"),
|
||||
vec![b"previous-turn-state".to_vec()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn matching_turn_id_should_prefer_the_latest_provider_state_over_a_stale_client_echo() {
|
||||
let request = capture_turn_state_request(
|
||||
"req_replace_stale_client_turn_state",
|
||||
Some("turn-same"),
|
||||
Some("turn-same"),
|
||||
Some("stale-client-turn-state"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
captured_header_values(&request, "x-codex-turn-state"),
|
||||
vec![b"previous-turn-state".to_vec()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_or_unidentified_turn_should_not_restore_previous_turn_state() {
|
||||
for (request_id, previous_turn_id, current_turn_id) in [
|
||||
("req_new_turn_state", Some("turn-old"), Some("turn-new")),
|
||||
("req_unidentified_turn_state", None, Some("turn-new")),
|
||||
("req_missing_current_turn_state", Some("turn-old"), None),
|
||||
] {
|
||||
let request =
|
||||
capture_turn_state_request(request_id, previous_turn_id, current_turn_id, None).await;
|
||||
assert!(captured_header_values(&request, "x-codex-turn-state").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1259,6 +1545,134 @@ async fn explicit_session_id_should_override_turn_specific_prompt_cache_keys_for
|
||||
assert_eq!(keys[0], keys[1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn affinity_quota_switch_should_clear_old_turn_state_without_a_provider_state_owner() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
let first_account_id = "acct_affinity_switch_a";
|
||||
let second_account_id = "acct_affinity_switch_b";
|
||||
create_account(&store, first_account_id).await;
|
||||
let affinity = Arc::new(MemorySessionAffinity::default());
|
||||
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 (provider, quota) = provider_and_quota_with_affinity_and_base_url_and_leases(
|
||||
&store,
|
||||
Arc::clone(&affinity),
|
||||
server.uri(),
|
||||
Arc::new(TestLeaseCoordinator::default()),
|
||||
);
|
||||
let session_id = "stable-affinity-switch-session";
|
||||
let first_payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("first request")),
|
||||
("session_id".to_owned(), json!(session_id)),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
|
||||
let first = provider
|
||||
.execute(
|
||||
planned_request(
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(first_payload)),
|
||||
),
|
||||
context("req_affinity_switch_first", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare first affinity request");
|
||||
drop(first);
|
||||
let affinity_keys = affinity.lookup_keys();
|
||||
assert_eq!(affinity_keys.len(), 1);
|
||||
affinity.seed_binding(
|
||||
&ProviderKind::new("openai").expect("provider"),
|
||||
&affinity_keys[0],
|
||||
ProviderAccountId::new(first_account_id).expect("first account id"),
|
||||
);
|
||||
assert_eq!(affinity.binding_count(), 1);
|
||||
|
||||
create_account(&store, second_account_id).await;
|
||||
let first_account = store.account(first_account_id).expect("first account");
|
||||
let observed_at = SystemTime::now();
|
||||
store
|
||||
.compare_and_swap_quota(QuotaObservation {
|
||||
account_id: first_account.id().clone(),
|
||||
expected_revision: first_account.revision(),
|
||||
quota: OpaqueProviderData::new(Map::new()),
|
||||
observed_at,
|
||||
state: QuotaState::exhausted(QuotaEvidence::ProviderDenied, observed_at, None),
|
||||
})
|
||||
.await
|
||||
.expect("seed exhausted affinity account");
|
||||
let accounts = [
|
||||
store
|
||||
.account(first_account_id)
|
||||
.expect("first account after quota"),
|
||||
store.account(second_account_id).expect("second account"),
|
||||
];
|
||||
quota.prepare_scheduling(&accounts).await;
|
||||
|
||||
let second_payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("second request")),
|
||||
("session_id".to_owned(), json!(session_id)),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([
|
||||
("use_websocket".to_owned(), json!(false)),
|
||||
("turn_id".to_owned(), json!("turn-affinity-switch")),
|
||||
(
|
||||
"turn_state".to_owned(),
|
||||
json!("state-created-by-exhausted-account"),
|
||||
),
|
||||
]));
|
||||
let mut second = provider
|
||||
.execute(
|
||||
planned_request(
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(second_payload)),
|
||||
),
|
||||
context("req_affinity_switch_second", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare switched affinity request");
|
||||
while let Some(event) = second.next().await {
|
||||
event.expect("switched affinity response");
|
||||
}
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("captured affinity requests");
|
||||
assert_eq!(requests.len(), 1);
|
||||
let second_request = &requests[0];
|
||||
assert_eq!(
|
||||
captured_request_body(second_request).get("input"),
|
||||
Some(&json!("second request"))
|
||||
);
|
||||
assert!(captured_header_values(second_request, "x-codex-turn-state").is_empty());
|
||||
let expected_account_id = format!("chatgpt-{second_account_id}");
|
||||
assert_eq!(
|
||||
second_request
|
||||
.headers
|
||||
.get("chatgpt-account-id")
|
||||
.and_then(|value| value.to_str().ok()),
|
||||
Some(expected_account_id.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_spawn_children_should_have_independent_session_affinity_keys() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -1931,6 +2345,77 @@ async fn bare_response_failed_should_remain_an_atomic_replay_safe_failure() {
|
||||
let _ = server.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn previous_response_not_found_should_remain_client_visible_without_internal_replay_proof() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_client_history").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(concat!(
|
||||
"event: response.failed\n",
|
||||
"data: {\"type\":\"response.failed\",\"status_code\":400,\"response\":{\"id\":\"resp_history_missing\",\"status\":\"failed\",\"error\":{\"code\":\"previous_response_not_found\",\"message\":\"Previous response was not found. Retrying the full request.\"}}}\n\n"
|
||||
)),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let 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!("missing-previous-response"),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.expect("OpenAI payload")
|
||||
.with_context(Map::from_iter([("use_websocket".to_owned(), json!(false))]));
|
||||
let mut stream = provider_with_base_url(&store, server.uri())
|
||||
.execute(
|
||||
planned_request(
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
|
||||
),
|
||||
context("req_history_missing", CancellationToken::new()),
|
||||
)
|
||||
.await
|
||||
.expect("prepare missing-history stream");
|
||||
let mut failure = loop {
|
||||
match stream.next().await {
|
||||
Some(Ok(_)) => {}
|
||||
Some(Err(error)) => break error,
|
||||
None => panic!("previous_response_not_found must produce a typed failure"),
|
||||
}
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
failure.continuation_failure(),
|
||||
Some(ContinuationFailure::HistoryUnavailable)
|
||||
);
|
||||
assert!(!failure.replay_is_safe());
|
||||
assert_eq!(
|
||||
failure
|
||||
.upstream_code()
|
||||
.map(gateway_core::error::OpaqueUpstreamValue::as_str),
|
||||
Some("previous_response_not_found")
|
||||
);
|
||||
let events = failure.take_atomic_client_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(
|
||||
events[0].wire_event().and_then(|wire| wire.event_type()),
|
||||
Some("response.failed")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continuation_prefetch_over_64_kib_should_commit_wire_without_protocol_failure() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
@@ -2424,3 +2909,36 @@ async fn provider_compiles_catalog_presentation_for_codex_models() {
|
||||
assert!(presentation.verbosity());
|
||||
assert!(!presentation.hidden());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_routes_catalog_model_when_supported_in_api_is_false() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_non_api_model").await;
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/codex/models"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/json")
|
||||
.set_body_raw(
|
||||
br#"{"models":[{"slug":"gpt-5.6-sol-wm","display_name":"GPT-5.6-Sol-WM","supported_in_api":false,"visibility":"hide"}]}"#,
|
||||
"application/json",
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let provider = provider_with_base_url(&store, server.uri());
|
||||
|
||||
let capabilities = provider
|
||||
.query_model_capabilities()
|
||||
.await
|
||||
.expect("catalog model capabilities");
|
||||
let model = capabilities.first().expect("catalog model");
|
||||
|
||||
assert!(
|
||||
model
|
||||
.capabilities()
|
||||
.match_requirements(&CapabilityRequirements::new(OperationKind::Generate))
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ use async_trait::async_trait;
|
||||
use futures::future::BoxFuture;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountRuntimeSignals, AccountStateChange,
|
||||
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
|
||||
LoadedCredential, NewProviderAccount, OpaqueProviderData, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
AccountWeight, CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts,
|
||||
CredentialRevision, CredentialState, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
ProviderAccount, ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate,
|
||||
QuotaAccessChange, QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::{StoreError, StoreErrorKind};
|
||||
use gateway_core::policy::ClientApiKeyId;
|
||||
@@ -207,7 +207,7 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
&self,
|
||||
update: CredentialCasUpdate,
|
||||
) -> Result<CredentialCasOutcome, StoreError> {
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
profile,
|
||||
@@ -215,7 +215,8 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = update.into_parts();
|
||||
account_state,
|
||||
} = update.into_parts();
|
||||
let mut accounts = self.accounts.lock().expect("account store lock");
|
||||
let stored = accounts
|
||||
.get_mut(&account_id)
|
||||
@@ -226,15 +227,29 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
let next = expected_revision
|
||||
.next()
|
||||
.map_err(|_| store_error(StoreErrorKind::Conflict))?;
|
||||
let credential_state = account_state
|
||||
.as_ref()
|
||||
.map_or(stored.account.credential_state(), |state| {
|
||||
state.credential_state
|
||||
});
|
||||
let last_error_reason = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_reason(),
|
||||
|state| state.error_reason,
|
||||
);
|
||||
let last_error_message = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_message().map(str::to_owned),
|
||||
|state| state.message.clone(),
|
||||
);
|
||||
let state_observed_at = account_state.as_ref().map(|state| state.observed_at);
|
||||
stored.account = rebuild_account(
|
||||
&stored.account,
|
||||
AccountRebuild {
|
||||
revision: next,
|
||||
enabled: stored.account.enabled(),
|
||||
credential_state: stored.account.credential_state(),
|
||||
credential_state,
|
||||
quota: stored.account.quota(),
|
||||
last_error_reason: stored.account.last_error_reason(),
|
||||
last_error_message: stored.account.last_error_message().map(str::to_owned),
|
||||
last_error_reason,
|
||||
last_error_message,
|
||||
access_token_expires_at,
|
||||
has_refresh_token,
|
||||
next_refresh_at,
|
||||
@@ -243,6 +258,9 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
);
|
||||
stored.credential = credential;
|
||||
stored.quota = None;
|
||||
if let Some(observed_at) = state_observed_at {
|
||||
stored.state_observed_at = Some(observed_at);
|
||||
}
|
||||
Ok(CredentialCasOutcome::Updated(next))
|
||||
}
|
||||
|
||||
@@ -285,6 +303,27 @@ impl ProviderAccountStore for MemoryAccountStore {
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, StoreError> {
|
||||
let mut accounts = self.accounts.lock().expect("account store lock");
|
||||
let stored = accounts
|
||||
.get_mut(&touch.account_id)
|
||||
.ok_or_else(|| store_error(StoreErrorKind::InvalidData))?;
|
||||
if stored.account.revision() != touch.expected_revision {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
let Some(quota) = &mut stored.quota else {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
};
|
||||
if quota.observed_at > touch.observed_at {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
quota.observed_at = touch.observed_at;
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn apply_quota_access(
|
||||
&self,
|
||||
change: QuotaAccessChange,
|
||||
@@ -583,6 +622,18 @@ impl MemorySessionAffinity {
|
||||
pub(crate) fn binding_count(&self) -> usize {
|
||||
self.bindings.lock().expect("session affinity lock").len()
|
||||
}
|
||||
|
||||
pub(crate) fn seed_binding(
|
||||
&self,
|
||||
provider_kind: &ProviderKind,
|
||||
key: &str,
|
||||
account_id: ProviderAccountId,
|
||||
) {
|
||||
self.bindings.lock().expect("session affinity lock").insert(
|
||||
(provider_kind.as_str().to_owned(), key.to_owned()),
|
||||
account_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
impl ProviderSessionAffinityPort for MemorySessionAffinity {
|
||||
|
||||
@@ -371,6 +371,7 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
|
||||
.insert_header("openai-model", "gpt-5.5-2026-07-01")
|
||||
.insert_header("x-models-etag", "models-v2")
|
||||
.insert_header("x-reasoning-included", "true")
|
||||
.insert_header("x-codex-turn-state", "turn-state-from-upstream")
|
||||
.insert_header("openai-processing-ms", "42")
|
||||
.append_header("x-future-multi", "first")
|
||||
.append_header("x-future-multi", "second")
|
||||
@@ -452,6 +453,7 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
|
||||
"openai-processing-ms",
|
||||
"retry-after",
|
||||
"x-codex-safety-buffering-enabled",
|
||||
"x-codex-turn-state",
|
||||
"x-future-bytes",
|
||||
"x-future-multi",
|
||||
"x-future-multi",
|
||||
@@ -478,6 +480,15 @@ async fn codex_backend_client_should_capture_forwardable_response_metadata() {
|
||||
.iter()
|
||||
.any(|(name, value)| { name == "x-future-bytes" && value.as_ref() == b"\xffopaque" })
|
||||
);
|
||||
assert!(
|
||||
response
|
||||
.response_metadata
|
||||
.client_headers
|
||||
.iter()
|
||||
.any(|(name, value)| {
|
||||
name == "x-codex-turn-state" && value.as_ref() == b"turn-state-from-upstream"
|
||||
})
|
||||
);
|
||||
for blocked in [
|
||||
"set-cookie",
|
||||
"authorization",
|
||||
|
||||
@@ -16,6 +16,13 @@ fn new_chain_request(conversation_id: &str) -> CodexResponsesRequest {
|
||||
request
|
||||
}
|
||||
|
||||
fn external_continuation_request(conversation_id: &str) -> CodexResponsesRequest {
|
||||
let mut request = new_chain_request(conversation_id);
|
||||
request.set_previous_response_id(Some("resp_from_another_account".to_owned()));
|
||||
request.previous_response_scope = Some(PreviousResponseScope::ExternalUnknown);
|
||||
request
|
||||
}
|
||||
|
||||
fn explicit_websocket_warmup_request(conversation_id: &str) -> CodexResponsesRequest {
|
||||
let mut body = serde_json::Map::new();
|
||||
body.insert("model".to_string(), json!("gpt-5.5"));
|
||||
@@ -108,6 +115,43 @@ async fn cold_websocket_should_fall_back_without_recording_a_successful_connect(
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn external_continuation_should_wait_for_a_cold_websocket() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (websocket_stream, _) = listener.accept().await.unwrap();
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
let mut websocket = accept_codex_test_websocket(websocket_stream).await;
|
||||
let _payload = websocket.next().await.unwrap().unwrap();
|
||||
websocket
|
||||
.send(Message::Text(
|
||||
completed_websocket_response("resp_cross_account", 2, 1).into(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let pool = Arc::new(CodexWebSocketPool::default());
|
||||
let backend = CodexBackendClient::new(
|
||||
reqwest::Client::builder().no_proxy().build().unwrap(),
|
||||
format!("http://{addr}"),
|
||||
test_wire_profile(),
|
||||
)
|
||||
.with_websocket_pool(Arc::clone(&pool));
|
||||
|
||||
let response = backend
|
||||
.create_response(
|
||||
&external_continuation_request("conversation-cross-account"),
|
||||
request_context("req_cross_account", Some("chatgpt-account-b")),
|
||||
)
|
||||
.await
|
||||
.expect("external continuation should complete over WebSocket");
|
||||
server.await.unwrap();
|
||||
|
||||
assert_eq!(response.transport, CodexBackendTransport::WebSocket);
|
||||
pool.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn timed_out_websocket_should_finish_in_background_and_serve_the_next_request() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
|
||||
@@ -9,7 +9,9 @@ use std::{
|
||||
|
||||
use chrono::Utc;
|
||||
use futures::{SinkExt, StreamExt};
|
||||
use gateway_protocol::openai::events::{TokenUsage, extract_sse_usage};
|
||||
use gateway_protocol::openai::events::{
|
||||
TokenUsage, extract_sse_usage, rate_limits_to_header_pairs,
|
||||
};
|
||||
use provider_openai::transport::profile::{
|
||||
CodexBundledReleaseProfile, CodexWireProfile, CodexWireProfileState,
|
||||
};
|
||||
@@ -233,7 +235,7 @@ async fn collect_backend_response(
|
||||
mut turn_state,
|
||||
set_cookie_headers,
|
||||
mut rate_limit_headers,
|
||||
rate_limit_header_updates,
|
||||
rate_limit_updates,
|
||||
turn_state_update,
|
||||
websocket_pool_decision,
|
||||
diagnostics: _,
|
||||
@@ -251,8 +253,10 @@ async fn collect_backend_response(
|
||||
});
|
||||
body_bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if let Some(updates) = rate_limit_header_updates {
|
||||
rate_limit_headers.extend(updates.lock().await.iter().cloned());
|
||||
if let Some(updates) = rate_limit_updates {
|
||||
for update in updates.lock().await.iter() {
|
||||
rate_limit_headers.extend(rate_limits_to_header_pairs(update));
|
||||
}
|
||||
}
|
||||
if let Some(update) = turn_state_update {
|
||||
turn_state = update.lock().await.clone().or(turn_state);
|
||||
|
||||
@@ -159,6 +159,23 @@ fn websocket_event_to_sse_should_forward_public_events_and_strip_internal_events
|
||||
assert!(websocket_event_to_sse_frame("not-json").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_event_to_sse_should_add_missing_previous_response_recovery_code() {
|
||||
let event = json!({
|
||||
"type": "error",
|
||||
"status": 400,
|
||||
"error": {
|
||||
"type": "invalid_request_error",
|
||||
"message": "Invalid `previous_response_id`."
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let frame = websocket_event_to_sse_frame(&event).expect("public error event");
|
||||
|
||||
assert!(frame.contains(r#""code":"previous_response_not_found""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn websocket_metadata_turn_state_should_accept_case_insensitive_header() {
|
||||
let event = json!({
|
||||
|
||||
@@ -27,8 +27,9 @@ use gateway_admin::model::{AdminError, MutationActor, MutationContext, Revision}
|
||||
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
|
||||
use gateway_core::accounting::Money;
|
||||
use gateway_core::engine::credential::{
|
||||
CredentialRevision, LoadedCredential, NewProviderAccount, OpaqueProviderData,
|
||||
PlaintextCredential, ProviderAccount, ProviderAccountId, ProviderAccountStore,
|
||||
CredentialCasUpdateParts, CredentialRevision, LoadedCredential, NewProviderAccount,
|
||||
OpaqueProviderData, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore,
|
||||
};
|
||||
use gateway_core::error::StoreErrorKind;
|
||||
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
|
||||
@@ -906,15 +907,16 @@ fn prepared_rotation(
|
||||
provider_kind: ProviderKind,
|
||||
) -> Result<PreparedCredentialRotation, ProviderAdminError> {
|
||||
let (profile, credential, guard) = prepared.into_parts();
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
credential_profile,
|
||||
profile: credential_profile,
|
||||
credential,
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = credential.into_parts();
|
||||
account_state: _account_state,
|
||||
} = credential.into_parts();
|
||||
if profile != credential_profile || profile.account_id != account_id {
|
||||
return Err(provider_error(ProviderAdminErrorKind::Internal));
|
||||
}
|
||||
|
||||
@@ -9,10 +9,10 @@ use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use gateway_core::engine::credential::{
|
||||
AccountConcurrencyLimit, AccountErrorReason, AccountStateChange, AccountWeight,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialRevision, CredentialState,
|
||||
LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount, ProviderAccountId,
|
||||
ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange, QuotaObservation, QuotaState,
|
||||
QuotaWriteOutcome,
|
||||
CredentialCasOutcome, CredentialCasUpdate, CredentialCasUpdateParts, CredentialRevision,
|
||||
CredentialState, LoadedCredential, NewProviderAccount, PlaintextCredential, ProviderAccount,
|
||||
ProviderAccountId, ProviderAccountStore, ProviderAccountUpdate, QuotaAccessChange,
|
||||
QuotaObservation, QuotaObservationTouch, QuotaState, QuotaWriteOutcome,
|
||||
};
|
||||
use gateway_core::error::{StoreError, StoreErrorKind};
|
||||
use gateway_core::provider_ports::{
|
||||
@@ -327,7 +327,7 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
&self,
|
||||
update: CredentialCasUpdate,
|
||||
) -> Result<CredentialCasOutcome, StoreError> {
|
||||
let (
|
||||
let CredentialCasUpdateParts {
|
||||
account_id,
|
||||
expected_revision,
|
||||
profile,
|
||||
@@ -335,22 +335,36 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
has_refresh_token,
|
||||
access_token_expires_at,
|
||||
next_refresh_at,
|
||||
) = update.into_parts();
|
||||
account_state,
|
||||
} = update.into_parts();
|
||||
let mut accounts = lock(&self.accounts);
|
||||
let stored = accounts.get_mut(&account_id).ok_or_else(invalid)?;
|
||||
if stored.account.revision() != expected_revision {
|
||||
return Ok(CredentialCasOutcome::Conflict);
|
||||
}
|
||||
let next = expected_revision.next().map_err(|_| invalid())?;
|
||||
let credential_state = account_state
|
||||
.as_ref()
|
||||
.map_or(stored.account.credential_state(), |state| {
|
||||
state.credential_state
|
||||
});
|
||||
let last_error_reason = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_reason(),
|
||||
|state| state.error_reason,
|
||||
);
|
||||
let last_error_message = account_state.as_ref().map_or_else(
|
||||
|| stored.account.last_error_message().map(str::to_owned),
|
||||
|state| state.message.clone(),
|
||||
);
|
||||
stored.account = rebuild_account(
|
||||
&stored.account,
|
||||
AccountReplacement {
|
||||
revision: next,
|
||||
access_token_expires_at,
|
||||
credential_state: stored.account.credential_state(),
|
||||
credential_state,
|
||||
quota: stored.account.quota(),
|
||||
last_error_reason: stored.account.last_error_reason(),
|
||||
last_error_message: stored.account.last_error_message().map(str::to_owned),
|
||||
last_error_reason,
|
||||
last_error_message,
|
||||
enabled: stored.account.enabled(),
|
||||
has_refresh_token,
|
||||
next_refresh_at,
|
||||
@@ -396,6 +410,25 @@ impl ProviderAccountStore for MemoryProviderAccountStore {
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn touch_quota_observation(
|
||||
&self,
|
||||
touch: QuotaObservationTouch,
|
||||
) -> Result<QuotaWriteOutcome, StoreError> {
|
||||
let mut accounts = lock(&self.accounts);
|
||||
let stored = accounts.get_mut(&touch.account_id).ok_or_else(invalid)?;
|
||||
if stored.account.revision() != touch.expected_revision {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
let Some(quota) = &mut stored.quota else {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
};
|
||||
if quota.observed_at > touch.observed_at {
|
||||
return Ok(QuotaWriteOutcome::Conflict);
|
||||
}
|
||||
quota.observed_at = touch.observed_at;
|
||||
Ok(QuotaWriteOutcome::Updated)
|
||||
}
|
||||
|
||||
async fn apply_quota_access(
|
||||
&self,
|
||||
change: QuotaAccessChange,
|
||||
|
||||
@@ -104,7 +104,11 @@ continuation 或 claim 表。
|
||||
|
||||
- `store=true`:使用 Provider 持久化的 native handle。
|
||||
- `store=false`:opaque replay state 仅存在于活连接内,不落 PostgreSQL。
|
||||
- OpenAI continuation 顺序为 native、replay owner、replay any。
|
||||
- OpenAI continuation 顺序为 native、replay owner、replay any。`replay any` 是
|
||||
Provider 定义的恢复阶段,不代表 Core 已持有完整 input:OpenAI 在跨账号时保留
|
||||
canonical `previous_response_id` 与原始增量 input 做 continuation probe;上游返回
|
||||
`previous_response_not_found` 时原样交给官方客户端触发 full retry。只有确实持有
|
||||
可携带 transcript 的 Provider 才能清除 native handle 后在代理内部重放。
|
||||
- xAI 使用客户端提交的完整历史作为已验证 continuation 路径。
|
||||
- native continuation pin 同时绑定创建它的 Client Key、冻结账号范围、Provider 和账号;跨 Key 复用、
|
||||
scope 外账号或计划中不存在该 Provider 都 fail closed。`ReplayOwner` 同样固定原 Provider 和账号,
|
||||
|
||||
@@ -1,5 +1,48 @@
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
const templateStylePlugin = {
|
||||
rules: {
|
||||
'single-line-simple-interpolation': {
|
||||
meta: {
|
||||
type: 'layout',
|
||||
fixable: 'whitespace',
|
||||
schema: [],
|
||||
messages: {
|
||||
expectedSingleLine: 'Expected a simple mustache interpolation to stay on one line.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const { parserServices } = context.sourceCode
|
||||
if (!parserServices.defineTemplateBodyVisitor)
|
||||
return {}
|
||||
|
||||
return parserServices.defineTemplateBodyVisitor({
|
||||
'VExpressionContainer[expression!=null]': function (node) {
|
||||
if (node.parent.type !== 'VElement')
|
||||
return
|
||||
|
||||
const expression = node.expression
|
||||
if (
|
||||
expression.loc.start.line !== expression.loc.end.line
|
||||
|| node.loc.start.line === node.loc.end.line
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'expectedSingleLine',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, `{{ ${context.sourceCode.getText(expression)} }}`)
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default antfu(
|
||||
{
|
||||
type: 'app',
|
||||
@@ -18,4 +61,23 @@ export default antfu(
|
||||
a11y: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
plugins: {
|
||||
'template-style': templateStylePlugin,
|
||||
},
|
||||
rules: {
|
||||
'template-style/single-line-simple-interpolation': 'error',
|
||||
'vue/html-closing-bracket-newline': ['error', {
|
||||
singleline: 'never',
|
||||
multiline: 'always',
|
||||
}],
|
||||
'vue/multiline-html-element-content-newline': ['error', {
|
||||
ignores: ['pre', 'textarea'],
|
||||
ignoreWhenEmpty: true,
|
||||
allowEmptyLines: false,
|
||||
}],
|
||||
'vue/mustache-interpolation-spacing': ['error', 'always'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@boxicons/vue": "1.0.1",
|
||||
"@fontsource-variable/inter": "^5.3.0",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@lucide/vue": "^1.30.0",
|
||||
"@lucide/vue": "^1.31.0",
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"axios": "^1.19.0",
|
||||
"dayjs": "^1.11.21",
|
||||
@@ -26,7 +26,7 @@
|
||||
"es-toolkit": "^1.50.0",
|
||||
"gsap": "^3.15.0",
|
||||
"marked": "^18.0.9",
|
||||
"pinia": "^4.0.2",
|
||||
"pinia": "^4.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0"
|
||||
@@ -39,10 +39,10 @@
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.8.1",
|
||||
"eslint-plugin-format": "^2.0.1",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.6.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.2.1",
|
||||
"vue-tsc": "^3.3.9"
|
||||
"vue-tsc": "^3.3.10"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+33
-33
@@ -18,8 +18,8 @@ importers:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
'@lucide/vue':
|
||||
specifier: ^1.30.0
|
||||
version: 1.30.0(vue@3.5.41(typescript@6.0.3))
|
||||
specifier: ^1.31.0
|
||||
version: 1.31.0(vue@3.5.41(typescript@6.0.3))
|
||||
'@vueuse/core':
|
||||
specifier: ^14.4.0
|
||||
version: 14.4.0(vue@3.5.41(typescript@6.0.3))
|
||||
@@ -45,21 +45,21 @@ importers:
|
||||
specifier: ^18.0.9
|
||||
version: 18.0.9
|
||||
pinia:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
specifier: ^4.0.3
|
||||
version: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
pinia-plugin-persistedstate:
|
||||
specifier: ^4.7.1
|
||||
version: 4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
|
||||
version: 4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))
|
||||
vue:
|
||||
specifier: ^3.5.41
|
||||
version: 3.5.41(typescript@6.0.3)
|
||||
vue-router:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
|
||||
version: 5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3))
|
||||
devDependencies:
|
||||
'@antfu/eslint-config':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
|
||||
version: 9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))
|
||||
@@ -79,8 +79,8 @@ importers:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(eslint@10.8.1(jiti@2.7.0))
|
||||
eslint-plugin-vuejs-accessibility:
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
specifier: ^2.6.0
|
||||
version: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
tailwindcss:
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3
|
||||
@@ -91,8 +91,8 @@ importers:
|
||||
specifier: ^8.2.1
|
||||
version: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
|
||||
vue-tsc:
|
||||
specifier: ^3.3.9
|
||||
version: 3.3.9(typescript@6.0.3)
|
||||
specifier: ^3.3.10
|
||||
version: 3.3.10(typescript@6.0.3)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -354,8 +354,8 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lucide/vue@1.30.0':
|
||||
resolution: {integrity: sha512-EF+N6eH/zaaZN0EY0y0URvKjMAj4QUeVtdDeFxjWHjhXihve/pBge+ahaeXLtqa1np0Ax5vsZEIsOTcsGZwPNQ==}
|
||||
'@lucide/vue@1.31.0':
|
||||
resolution: {integrity: sha512-NtjEHhcAa7umPh40wrRmlESJqNGdnpc7LziBKFnW3fmlrW0g2xMCDpdWU7WeEHRSl3xOpxbqiFTLKxoK3CoVCg==}
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
@@ -849,8 +849,8 @@ packages:
|
||||
'@vue/devtools-shared@8.2.1':
|
||||
resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==}
|
||||
|
||||
'@vue/language-core@3.3.9':
|
||||
resolution: {integrity: sha512-in/68oAa4BCtVY6n/nkuhLIkV8DHYd2UivedJ6cMZ6UYtlq9jaoaSNUBHYCVO44z3nKg7MdE5OBoHKt5SxeBKQ==}
|
||||
'@vue/language-core@3.3.10':
|
||||
resolution: {integrity: sha512-CR7ByBbgPHqhxrioKPOcZBqttaozzLNwtkCzXQ+uF8gLPHnUe03srPnGpdtHD3zp+bq5iyVkZ1WNx7W564RPwg==}
|
||||
|
||||
'@vue/reactivity@3.5.41':
|
||||
resolution: {integrity: sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==}
|
||||
@@ -1297,8 +1297,8 @@ packages:
|
||||
'@typescript-eslint/parser':
|
||||
optional: true
|
||||
|
||||
eslint-plugin-vuejs-accessibility@2.5.0:
|
||||
resolution: {integrity: sha512-oZ2fL4tS91Cm/ezH3BueNP+FtpbbeS627OSqqgp9/lsN//glmoPcLBT6D53xwGocLtyBybaT99tX4ThBh8+ytA==}
|
||||
eslint-plugin-vuejs-accessibility@2.6.0:
|
||||
resolution: {integrity: sha512-QtJO3UdLH+aydue/im7aBsYKO99ouJ6hm4wEHL1Xm/BwDfYTAmXEPhTImNwlOHp2bbid7rNY7IrHz+IZzGApYA==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
peerDependencies:
|
||||
eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0
|
||||
@@ -2045,8 +2045,8 @@ packages:
|
||||
pinia:
|
||||
optional: true
|
||||
|
||||
pinia@4.0.2:
|
||||
resolution: {integrity: sha512-yKVVA7bSj5oRZFp/Ab9wLlmyb5gPUYEiIm4ryiWTe/xe7PtkRdMVOp1X1ggvq0c6Uj7Q0Du1HnV2mtAwM0Ks1g==}
|
||||
pinia@4.0.3:
|
||||
resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==}
|
||||
peerDependencies:
|
||||
'@vue/devtools-api': ^8.1.5
|
||||
typescript: '>=5.6.0'
|
||||
@@ -2377,8 +2377,8 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
vue-tsc@3.3.9:
|
||||
resolution: {integrity: sha512-TS3Y1ux/IRoE8OCP2PpACAeOseuIs0UvWrcr7u+w3PmfY+SlCfEf8zjrBgnQksHUgLpthi5vHlffcQTQTdPBZA==}
|
||||
vue-tsc@3.3.10:
|
||||
resolution: {integrity: sha512-YaDVxcW+CGtaOt3pZahMG5jYPx0hsUTxyEoPOTSMebcGUXP9lIBabQ14vfKMORb2CqqK5CxsNo/d1d+4IQwiKg==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.0'
|
||||
@@ -2431,7 +2431,7 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
|
||||
'@antfu/eslint-config@9.3.0(@typescript-eslint/typescript-estree@8.66.0(typescript@6.0.3))(@typescript-eslint/utils@8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.41)(eslint-plugin-format@2.0.1(eslint@10.8.1(jiti@2.7.0)))(eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0))(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@antfu/install-pkg': 2.0.1
|
||||
'@clack/prompts': 1.7.0
|
||||
@@ -2472,7 +2472,7 @@ snapshots:
|
||||
yaml-eslint-parser: 2.1.0
|
||||
optionalDependencies:
|
||||
eslint-plugin-format: 2.0.1(eslint@10.8.1(jiti@2.7.0))
|
||||
eslint-plugin-vuejs-accessibility: 2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
eslint-plugin-vuejs-accessibility: 2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0)
|
||||
transitivePeerDependencies:
|
||||
- '@eslint/json'
|
||||
- '@typescript-eslint/typescript-estree'
|
||||
@@ -2687,7 +2687,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@lucide/vue@1.30.0(vue@3.5.41(typescript@6.0.3))':
|
||||
'@lucide/vue@1.31.0(vue@3.5.41(typescript@6.0.3))':
|
||||
dependencies:
|
||||
vue: 3.5.41(typescript@6.0.3)
|
||||
|
||||
@@ -3086,7 +3086,7 @@ snapshots:
|
||||
|
||||
'@vue/devtools-shared@8.2.1': {}
|
||||
|
||||
'@vue/language-core@3.3.9':
|
||||
'@vue/language-core@3.3.10':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
'@vue/compiler-dom': 3.5.41
|
||||
@@ -3551,7 +3551,7 @@ snapshots:
|
||||
'@stylistic/eslint-plugin': 5.10.0(eslint@10.8.1(jiti@2.7.0))
|
||||
'@typescript-eslint/parser': 8.66.0(eslint@10.8.1(jiti@2.7.0))(typescript@6.0.3)
|
||||
|
||||
eslint-plugin-vuejs-accessibility@2.5.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
|
||||
eslint-plugin-vuejs-accessibility@2.6.0(eslint@10.8.1(jiti@2.7.0))(globals@17.9.0):
|
||||
dependencies:
|
||||
aria-query: 5.3.2
|
||||
eslint: 10.8.1(jiti@2.7.0)
|
||||
@@ -4423,13 +4423,13 @@ snapshots:
|
||||
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
pinia-plugin-persistedstate@4.7.1(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
|
||||
pinia-plugin-persistedstate@4.7.1(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))):
|
||||
dependencies:
|
||||
defu: 6.1.7
|
||||
optionalDependencies:
|
||||
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
|
||||
pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
|
||||
pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 8.1.5
|
||||
nostics: 1.2.0
|
||||
@@ -4685,7 +4685,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
|
||||
vue-router@5.2.0(@vue/compiler-sfc@3.5.41)(pinia@4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3)))(rolldown@1.2.3)(vite@8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.41(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@babel/generator': 8.0.0
|
||||
'@vue-macros/common': 3.1.4(vue@3.5.41(typescript@6.0.3))
|
||||
@@ -4708,7 +4708,7 @@ snapshots:
|
||||
yaml: 2.9.0
|
||||
optionalDependencies:
|
||||
'@vue/compiler-sfc': 3.5.41
|
||||
pinia: 4.0.2(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
pinia: 4.0.3(@vue/devtools-api@8.1.5)(typescript@6.0.3)(vue@3.5.41(typescript@6.0.3))
|
||||
vite: 8.2.1(@types/node@26.2.0)(jiti@2.7.0)(yaml@2.9.0)
|
||||
transitivePeerDependencies:
|
||||
- '@farmfe/core'
|
||||
@@ -4720,10 +4720,10 @@ snapshots:
|
||||
- unloader
|
||||
- webpack
|
||||
|
||||
vue-tsc@3.3.9(typescript@6.0.3):
|
||||
vue-tsc@3.3.10(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
'@vue/language-core': 3.3.9
|
||||
'@vue/language-core': 3.3.10
|
||||
typescript: 6.0.3
|
||||
|
||||
vue@3.5.41(typescript@6.0.3):
|
||||
|
||||
@@ -392,179 +392,3 @@ export function getUsageRecordInsightsDiagnostics(data: UsageDiagnosticsQuery) {
|
||||
params: data,
|
||||
})
|
||||
}
|
||||
|
||||
// Usage 记录的规范化 view model:组件只消费这个形状,兼容性回退只发生在本文件。
|
||||
export interface UsageViewModel {
|
||||
id: string
|
||||
requestId: string
|
||||
clientApiKeyId: string | null
|
||||
kind: string
|
||||
provider: string | null
|
||||
authenticationKind: string | null
|
||||
accountId: string | null
|
||||
accountEmail: string | null
|
||||
accountName: string | null
|
||||
route: string
|
||||
model: string
|
||||
requestedModel: string | null
|
||||
upstreamModel: string | null
|
||||
serviceTier: string | null
|
||||
statusCode: number | null
|
||||
transport: string | null
|
||||
stream: boolean | null
|
||||
apiKind: string | null
|
||||
attemptIndex: number | null
|
||||
attemptCount: number
|
||||
responseId: string | null
|
||||
upstreamRequestId: string | null
|
||||
protocol: string
|
||||
httpVersion: string | null
|
||||
clientStatusCode: number | null
|
||||
upstreamStatusCode: number | null
|
||||
websocketPool: { kind: string } | null
|
||||
imageGenerationRequested: boolean
|
||||
imageGenerationSucceeded: boolean | null
|
||||
latencyMs: number | null
|
||||
firstTokenLatencyMs: number | null
|
||||
latencyDetails: UsageLatencyDetails | null
|
||||
inputTokens: number | null
|
||||
outputTokens: number | null
|
||||
cachedTokens: number | null
|
||||
cacheWriteTokens: number | null
|
||||
reasoningTokens: number | null
|
||||
imageInputTokens: number | null
|
||||
imageOutputTokens: number | null
|
||||
message: string
|
||||
createdAt: string
|
||||
createdAtDisplay: string
|
||||
clientIp: string | null
|
||||
userAgent: string | null
|
||||
reasoningEffort: string | null
|
||||
reasoningPreset: string | null
|
||||
compact: boolean
|
||||
requestKind: string | null
|
||||
subagentKind: string | null
|
||||
tokenDetails: UsageTokenDetails
|
||||
billing: UsageBilling | null
|
||||
costs: UsageCost[]
|
||||
costCoverage: UsageCostCoverage
|
||||
firstTokenLatencyMsDisplay: string
|
||||
latencyMsDisplay: string
|
||||
logicalOutcome: string
|
||||
providerMetadata: Record<string, unknown>
|
||||
requestBody?: unknown
|
||||
responseBody?: unknown
|
||||
attempts?: UsageAttempt[]
|
||||
attemptsComplete?: boolean
|
||||
}
|
||||
|
||||
const CORE_METADATA_KEYS = new Set([
|
||||
'protocol',
|
||||
'logicalOutcome',
|
||||
'attemptCount',
|
||||
'requestedModel',
|
||||
'upstreamModel',
|
||||
'clientIp',
|
||||
'userAgent',
|
||||
'reasoningEffort',
|
||||
'reasoningPreset',
|
||||
'compact',
|
||||
'requestKind',
|
||||
'subagentKind',
|
||||
'transport',
|
||||
'httpVersion',
|
||||
'clientStatusCode',
|
||||
'upstreamStatusCode',
|
||||
'responseId',
|
||||
'upstreamRequestId',
|
||||
'websocketPool',
|
||||
'imageGenerationRequested',
|
||||
'imageGenerationSucceeded',
|
||||
'latencyDetails',
|
||||
])
|
||||
|
||||
function metadataRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
/** 一次性兼容 normalize:顶层 Core 字段优先,旧响应中的 metadata 回退只发生在这里。 */
|
||||
export function normalizeUsageRecord(record: UsageRecord | UsageRecordDetail): UsageViewModel {
|
||||
const metadata = metadataRecord(record.metadata)
|
||||
const legacy = <T>(top: T | null | undefined, key: string): T | null | undefined =>
|
||||
top ?? (metadata[key] as T | undefined)
|
||||
|
||||
const latencyDetails = legacy(record.latencyDetails, 'latencyDetails') ?? null
|
||||
const providerMetadata: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (!CORE_METADATA_KEYS.has(key))
|
||||
providerMetadata[key] = value
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
requestId: record.requestId,
|
||||
clientApiKeyId: record.clientApiKeyId,
|
||||
kind: record.kind,
|
||||
provider: record.provider,
|
||||
authenticationKind: record.authenticationKind,
|
||||
accountId: record.accountId,
|
||||
accountEmail: record.accountEmail,
|
||||
accountName: record.accountName,
|
||||
route: record.route,
|
||||
model: record.model,
|
||||
requestedModel: legacy(record.requestedModel, 'requestedModel') ?? null,
|
||||
upstreamModel: legacy(record.upstreamModel, 'upstreamModel') ?? null,
|
||||
serviceTier: record.serviceTier,
|
||||
statusCode: record.statusCode,
|
||||
transport: legacy(record.transport, 'transport') ?? null,
|
||||
stream: typeof metadata.stream === 'boolean' ? metadata.stream : null,
|
||||
apiKind: typeof metadata.apiKind === 'string' ? metadata.apiKind : null,
|
||||
attemptIndex: record.attemptIndex,
|
||||
attemptCount: legacy(record.attemptCount, 'attemptCount') ?? 0,
|
||||
responseId: legacy(record.responseId, 'responseId') ?? null,
|
||||
upstreamRequestId: legacy(record.upstreamRequestId, 'upstreamRequestId') ?? null,
|
||||
protocol: legacy(record.protocol, 'protocol') ?? '',
|
||||
httpVersion: legacy(record.httpVersion, 'httpVersion') ?? null,
|
||||
clientStatusCode: legacy(record.clientStatusCode, 'clientStatusCode') ?? null,
|
||||
upstreamStatusCode: legacy(record.upstreamStatusCode, 'upstreamStatusCode') ?? null,
|
||||
websocketPool: legacy(record.websocketPool, 'websocketPool') ?? null,
|
||||
imageGenerationRequested:
|
||||
legacy(record.imageGenerationRequested, 'imageGenerationRequested') ?? false,
|
||||
imageGenerationSucceeded:
|
||||
legacy(record.imageGenerationSucceeded, 'imageGenerationSucceeded') ?? null,
|
||||
latencyMs: record.latencyMs,
|
||||
firstTokenLatencyMs: record.firstTokenLatencyMs,
|
||||
latencyDetails,
|
||||
inputTokens: record.inputTokens,
|
||||
outputTokens: record.outputTokens,
|
||||
cachedTokens: record.cachedTokens,
|
||||
cacheWriteTokens: record.cacheWriteTokens,
|
||||
reasoningTokens: record.reasoningTokens,
|
||||
imageInputTokens: record.imageInputTokens,
|
||||
imageOutputTokens: record.imageOutputTokens,
|
||||
message: record.message,
|
||||
createdAt: record.createdAt,
|
||||
createdAtDisplay: record.createdAtDisplay,
|
||||
clientIp: legacy(record.clientIp, 'clientIp') ?? null,
|
||||
userAgent: legacy(record.userAgent, 'userAgent') ?? null,
|
||||
reasoningEffort: legacy(record.reasoningEffort, 'reasoningEffort') ?? null,
|
||||
reasoningPreset: legacy(record.reasoningPreset, 'reasoningPreset') ?? null,
|
||||
compact: legacy(record.compact, 'compact') ?? false,
|
||||
requestKind: legacy(record.requestKind, 'requestKind') ?? null,
|
||||
subagentKind: legacy(record.subagentKind, 'subagentKind') ?? null,
|
||||
tokenDetails: record.tokenDetails,
|
||||
billing: record.billing,
|
||||
costs: record.costs,
|
||||
costCoverage: record.costCoverage,
|
||||
firstTokenLatencyMsDisplay: record.firstTokenLatencyMsDisplay,
|
||||
latencyMsDisplay: record.latencyMsDisplay,
|
||||
logicalOutcome: legacy(record.logicalOutcome, 'logicalOutcome') ?? '',
|
||||
providerMetadata,
|
||||
requestBody: metadata.requestBody,
|
||||
responseBody: metadata.responseBody,
|
||||
attempts: 'attempts' in record ? record.attempts : undefined,
|
||||
attemptsComplete: 'attemptsComplete' in record ? record.attemptsComplete : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="flex min-h-11 items-center justify-between gap-3 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-2.5"
|
||||
class="flex min-h-11 items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3.5 py-2.5"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
class="size-3.5 shrink-0 rounded-[4px]"
|
||||
class="size-3.5 shrink-0 rounded-sm"
|
||||
:style="{ backgroundColor: group.color }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
</span>
|
||||
<span
|
||||
v-if="!group.enabled"
|
||||
class="shrink-0 text-[11px] font-emphasis text-(--cp-text-muted)"
|
||||
class="shrink-0 text-[11px] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
已禁用
|
||||
</span>
|
||||
@@ -58,7 +58,7 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="m-0 rounded-(--cp-input-radius-base) bg-(--cp-bg-subtle) px-3.5 py-3 text-[12px] font-emphasis text-(--cp-text-secondary)"
|
||||
class="m-0 rounded-cp-control bg-cp-subtle px-3.5 py-3 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
|
||||
</p>
|
||||
|
||||
@@ -21,11 +21,11 @@ const fullNames = computed(() => props.groups
|
||||
<span
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="size-4 shrink-0 rounded-[4px]"
|
||||
class="size-4 shrink-0 rounded-sm"
|
||||
:style="{ backgroundColor: group.color }"
|
||||
:title="group.name"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-xs text-(--cp-text-muted)">—</span>
|
||||
<span v-else class="text-xs text-cp-muted-text">—</span>
|
||||
</template>
|
||||
|
||||
@@ -7,7 +7,7 @@ import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -76,7 +76,7 @@ function toggle(groupId: string) {
|
||||
function providerSummary(group: AccountGroup) {
|
||||
return Object.entries(group.providerCounts)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([provider, count]) => `${providerDisplayName(provider) ?? provider} ${count}`)
|
||||
.map(([provider, count]) => `${formatProviderLabel(provider)} ${count}`)
|
||||
.join(' · ')
|
||||
}
|
||||
</script>
|
||||
@@ -90,72 +90,73 @@ function providerSummary(group: AccountGroup) {
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<template #prefix>
|
||||
<Search class="size-4 text-(--cp-text-tertiary)" />
|
||||
<Search class="size-4 text-cp-tertiary" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
|
||||
<BaseScrollbar
|
||||
v-if="visibleGroups.length > 0"
|
||||
max-height="260px"
|
||||
view-class="grid gap-2 pr-2"
|
||||
>
|
||||
<div
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id"
|
||||
class="flex w-full items-start gap-3 rounded-(--cp-input-radius-base) border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
:class="selectedIds.includes(group.id)
|
||||
? 'bg-(--cp-info-bg)'
|
||||
: 'bg-(--cp-bg-subtle) hover:bg-(--cp-default-bg-hover)'"
|
||||
>
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.includes(group.id)"
|
||||
:disabled="disabled || loading"
|
||||
:label="`选择${group.name}`"
|
||||
@update:model-value="toggle(group.id)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<strong class="min-w-0 truncate text-[13px] text-(--cp-text-primary)">
|
||||
{{ group.name }}
|
||||
</strong>
|
||||
<span
|
||||
v-if="!group.enabled"
|
||||
class="shrink-0 rounded-md bg-(--cp-warning-bg) px-1.5 py-1 text-[10px] leading-none font-bold text-(--cp-warning-text)"
|
||||
>
|
||||
已禁用
|
||||
<div class="grid gap-2">
|
||||
<div
|
||||
v-for="group in visibleGroups"
|
||||
:key="group.id"
|
||||
class="flex w-full items-start gap-3 rounded-cp-control border-0 px-3 py-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
:class="selectedIds.includes(group.id)
|
||||
? 'bg-cp-info-bg'
|
||||
: 'bg-cp-subtle hover:bg-cp-default-hover'"
|
||||
>
|
||||
<BaseCheckbox
|
||||
:model-value="selectedIds.includes(group.id)"
|
||||
:disabled="disabled || loading"
|
||||
:label="`选择${group.name}`"
|
||||
@update:model-value="toggle(group.id)"
|
||||
/>
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<strong class="min-w-0 truncate text-[13px] text-cp-primary">
|
||||
{{ group.name }}
|
||||
</strong>
|
||||
<span
|
||||
v-if="!group.enabled"
|
||||
class="shrink-0 rounded-md bg-cp-warning-bg px-1.5 py-1 text-[10px] leading-none font-bold text-cp-warning-text"
|
||||
>
|
||||
已禁用
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] font-emphasis text-cp-secondary">
|
||||
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<span class="mt-1 block text-[11px] font-emphasis text-(--cp-text-secondary)">
|
||||
{{ group.memberCount }} 个账号<template v-if="providerSummary(group)"> · {{ providerSummary(group) }}</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
|
||||
<BaseEmpty
|
||||
v-else-if="groups.length === 0 && !loading"
|
||||
compact
|
||||
size="sm"
|
||||
title="暂无可选分组"
|
||||
description="请先在分组管理中创建分组。"
|
||||
/>
|
||||
<BaseEmpty
|
||||
v-else-if="!loading"
|
||||
compact
|
||||
size="sm"
|
||||
title="没有匹配分组"
|
||||
description="请调整搜索关键词。"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="selectedIds.length === 0"
|
||||
class="rounded-(--cp-input-radius-base) px-3.5 py-3"
|
||||
class="rounded-cp-control px-3.5 py-3"
|
||||
:class="emptyCopy.tone === 'warning'
|
||||
? 'bg-(--cp-warning-bg) text-(--cp-warning-text)'
|
||||
: 'bg-(--cp-bg-subtle) text-(--cp-text-secondary)'"
|
||||
? 'bg-cp-warning-bg text-cp-warning-text'
|
||||
: 'bg-cp-subtle text-cp-secondary'"
|
||||
>
|
||||
<p class="m-0 text-[12px] font-bold">
|
||||
{{ emptyCopy.title }}
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-[11px] leading-[1.5] font-emphasis">
|
||||
<p class="mt-1 mb-0 text-[11px] leading-normal font-emphasis">
|
||||
{{ emptyCopy.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useUiClock } from '@/composables/useUiClock'
|
||||
import { formatDateTime, formatRelativeTime } from '@/utils/date'
|
||||
|
||||
const props = defineProps<{
|
||||
value: string | null
|
||||
}>()
|
||||
|
||||
const displayValue = computed(() => props.value ? formatRelativeTime(props.value) : '')
|
||||
const now = useUiClock()
|
||||
const displayValue = computed(() => props.value ? formatRelativeTime(props.value, now.value) : '')
|
||||
const fullValue = computed(() => props.value ? formatDateTime(props.value) : undefined)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Openai, Xai } from '@boxicons/vue'
|
||||
import { LayoutGrid } from '@lucide/vue'
|
||||
|
||||
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
|
||||
import BaseSegmented from './base/BaseSegmented.vue'
|
||||
|
||||
withDefaults(
|
||||
@@ -17,18 +18,17 @@ const provider = defineModel<string>({ required: true })
|
||||
|
||||
const providerOptions = [
|
||||
{ label: '全部平台', value: '', icon: LayoutGrid },
|
||||
{ label: 'OpenAI', value: 'openai', icon: Openai },
|
||||
{ label: 'xAI', value: 'xai', icon: Xai },
|
||||
{ label: PROVIDER_DISPLAY_NAMES.openai, value: 'openai', icon: Openai },
|
||||
{ label: PROVIDER_DISPLAY_NAMES.xai, value: 'xai', icon: Xai },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSegmented
|
||||
v-model="provider"
|
||||
label="按平台筛选"
|
||||
:options="providerOptions"
|
||||
:disabled="disabled"
|
||||
icon-only
|
||||
size="input"
|
||||
aria-label="按平台筛选"
|
||||
display="icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Key, Openai, Robot, Xai } from '@boxicons/vue'
|
||||
import { computed } from 'vue'
|
||||
import { providerDisplayName } from '@/utils/providers'
|
||||
import { formatProviderLabel } from '@/utils/providers'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -25,9 +25,7 @@ const iconContainerClass = computed(() =>
|
||||
)
|
||||
const iconClass = computed(() => (props.size === 'sm' ? 'size-3' : 'size-4'))
|
||||
|
||||
const providerLabel = computed(
|
||||
() => providerDisplayName(normalizedProvider.value) ?? (props.provider?.trim() || '未知平台'),
|
||||
)
|
||||
const providerLabel = computed(() => formatProviderLabel(props.provider, '未知平台'))
|
||||
|
||||
const authenticationLabel = computed(() => {
|
||||
if (normalizedAuthenticationKind.value === 'agent_identity')
|
||||
@@ -41,18 +39,18 @@ const authenticationLabel = computed(() => {
|
||||
<template>
|
||||
<span class="inline-flex items-center whitespace-nowrap" :class="groupGapClass">
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
|
||||
:class="iconContainerClass"
|
||||
:title="providerLabel"
|
||||
>
|
||||
<Openai v-if="normalizedProvider === 'openai'" :class="iconClass" aria-hidden="true" />
|
||||
<Xai v-else-if="normalizedProvider === 'xai'" :class="iconClass" aria-hidden="true" />
|
||||
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
|
||||
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="showAuthenticationKind"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-(--cp-bg-muted) text-(--cp-text-primary)"
|
||||
class="inline-flex shrink-0 items-center justify-center bg-cp-muted text-cp-primary"
|
||||
:class="iconContainerClass"
|
||||
:title="authenticationLabel"
|
||||
>
|
||||
@@ -66,7 +64,7 @@ const authenticationLabel = computed(() => {
|
||||
:class="iconClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-else class="text-[10px] font-heavy text-(--cp-text-muted)">?</span>
|
||||
<span v-else class="text-[10px] font-heavy text-cp-muted-text">?</span>
|
||||
</span>
|
||||
|
||||
<span class="sr-only">
|
||||
|
||||
@@ -1,119 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type ButtonVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'ghost'
|
||||
type ButtonSize = 'sm' | 'default' | 'md' | 'lg'
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive'
|
||||
type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
loading?: boolean
|
||||
spinIconOnLoading?: boolean
|
||||
disabled?: boolean
|
||||
active?: boolean
|
||||
iconOnly?: boolean
|
||||
label?: string
|
||||
title?: string
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'default',
|
||||
variant: 'secondary',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
spinIconOnLoading: false,
|
||||
disabled: false,
|
||||
active: false,
|
||||
iconOnly: false,
|
||||
type: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
const slots = useSlots()
|
||||
defineSlots<{
|
||||
icon?: () => unknown
|
||||
loading?: () => unknown
|
||||
default: () => unknown
|
||||
}>()
|
||||
|
||||
const variantClasses = {
|
||||
default:
|
||||
'bg-(--cp-bg-muted) text-(--cp-text-primary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-active) active:bg-(--cp-default-bg-active)',
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-(--cp-info) text-(--cp-info-on) hover:bg-(--cp-info-hover) active:bg-(--cp-info-pressed)',
|
||||
success:
|
||||
'bg-(--cp-success-bg) text-(--cp-success-text) hover:bg-(--cp-success-bg-hover) active:bg-(--cp-success-bg-active)',
|
||||
warning:
|
||||
'bg-(--cp-warning-bg) text-(--cp-warning-text) hover:bg-(--cp-warning-bg-hover) active:bg-(--cp-warning-bg-active)',
|
||||
danger:
|
||||
'bg-(--cp-danger-bg) text-(--cp-danger-text) hover:bg-(--cp-danger-bg-hover) active:bg-(--cp-danger-bg-active)',
|
||||
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
|
||||
secondary:
|
||||
'bg-cp-muted text-cp-primary shadow-cp-control hover:bg-cp-default-active active:bg-cp-nav-active',
|
||||
ghost:
|
||||
'bg-transparent text-(--cp-text-secondary) hover:bg-(--cp-bg-subtle) active:bg-(--cp-bg-muted)',
|
||||
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
|
||||
destructive:
|
||||
'bg-cp-danger-bg text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
|
||||
}
|
||||
|
||||
const iconOnlyDefaultClasses
|
||||
= 'bg-(--cp-bg-surface) text-(--cp-text-secondary) shadow-(--cp-shadow-control) hover:bg-(--cp-default-bg-hover) hover:text-(--cp-normal) active:bg-(--cp-default-bg-active)'
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'h-7 gap-1.5 px-3 text-xs',
|
||||
default: 'h-(--cp-input-height-default) gap-2 px-4 text-[13px]',
|
||||
md: 'h-11 gap-2 px-4 text-[13px]',
|
||||
lg: 'h-11.5 gap-2.5 px-5 text-[15px]',
|
||||
sm: 'h-cp-control-sm gap-1.5 px-3 text-xs',
|
||||
md: 'h-cp-control-md gap-2 px-4 text-[13px]',
|
||||
lg: 'h-cp-control-lg gap-2.5 px-5 text-[14px]',
|
||||
}
|
||||
|
||||
const iconOnlySizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'size-8 rounded-(--cp-icon-button-radius)',
|
||||
default: 'size-(--cp-input-height-default) rounded-(--cp-icon-button-radius)',
|
||||
md: 'size-11 rounded-(--cp-icon-button-radius)',
|
||||
lg: 'size-11.5 rounded-(--cp-icon-button-radius)',
|
||||
}
|
||||
|
||||
const loadingIconSize: Record<ButtonSize, number> = {
|
||||
const spinnerSizes: Record<ButtonSize, number> = {
|
||||
sm: 14,
|
||||
default: 15,
|
||||
md: 16,
|
||||
md: 15,
|
||||
lg: 17,
|
||||
}
|
||||
|
||||
const loadingIndicatorStyle = computed(() => {
|
||||
const iconSize = loadingIconSize[props.size]
|
||||
return {
|
||||
width: `${iconSize}px`,
|
||||
height: `${iconSize}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-flex items-center justify-center border-0 font-bold leading-[1.15] transition-[background-color,box-shadow,color,opacity,transform] duration-150 cursor-pointer outline-none motion-safe:active:translate-y-px motion-safe:active:scale-[0.985]',
|
||||
props.iconOnly ? iconOnlySizeClasses[props.size] : sizeClasses[props.size],
|
||||
props.iconOnly ? 'shrink-0' : undefined,
|
||||
props.iconOnly ? '' : 'rounded-(--cp-button-radius-base)',
|
||||
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
|
||||
props.iconOnly && (props.variant === 'default' || props.variant === 'primary')
|
||||
? iconOnlyDefaultClasses
|
||||
: variantClasses[props.variant],
|
||||
props.active ? 'bg-(--cp-default-bg-hover) text-(--cp-normal)' : undefined,
|
||||
])
|
||||
|
||||
const ariaLabel = computed(() => props.label || props.title)
|
||||
|
||||
const labelText = computed(() => {
|
||||
const defaultSlot = slots.default?.()
|
||||
const text = defaultSlot
|
||||
?.map(node => (typeof node.children === 'string' ? node.children : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
|
||||
return text || ''
|
||||
})
|
||||
|
||||
const labelClasses = computed(() => [
|
||||
'inline-flex min-w-0 items-center justify-center gap-2 [&>svg]:shrink-0',
|
||||
labelText.value.length === 2 ? 'tracking-[0.12em]' : undefined,
|
||||
])
|
||||
|
||||
const loadingIconClasses = computed(() => [
|
||||
'inline-grid shrink-0 place-items-center leading-none [&>svg]:block',
|
||||
props.loading
|
||||
? '[&>svg]:origin-center [&>svg]:animate-spin [&>svg]:transform-view [&>svg]:will-change-transform motion-reduce:[&>svg]:animate-none'
|
||||
: undefined,
|
||||
'inline-flex shrink-0 touch-manipulation items-center justify-center rounded-cp-control-sm border-0 font-bold leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:translate-y-px motion-safe:active:scale-[0.985] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -122,31 +65,24 @@ const loadingIconClasses = computed(() => [
|
||||
:type="type"
|
||||
:class="classes"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="iconOnly ? ariaLabel : undefined"
|
||||
:title="title || (iconOnly ? label : undefined)"
|
||||
:aria-busy="loading"
|
||||
:aria-busy="loading || undefined"
|
||||
>
|
||||
<span
|
||||
v-if="loading && !spinIconOnLoading"
|
||||
:class="loadingIconClasses"
|
||||
:style="loadingIndicatorStyle"
|
||||
v-if="loading"
|
||||
class="inline-grid shrink-0 place-items-center leading-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<LoaderCircle
|
||||
:size="loadingIconSize[size]"
|
||||
/>
|
||||
<slot name="loading">
|
||||
<LoaderCircle
|
||||
class="animate-spin motion-reduce:animate-none"
|
||||
:size="spinnerSizes[size]"
|
||||
/>
|
||||
</slot>
|
||||
</span>
|
||||
<span v-else-if="$slots.icon" :class="loadingIconClasses" aria-hidden="true">
|
||||
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<span v-if="!iconOnly && $slots.default" :class="labelClasses">
|
||||
<slot />
|
||||
</span>
|
||||
<span
|
||||
v-if="iconOnly && !$slots.icon && (!loading || spinIconOnLoading)"
|
||||
:class="loadingIconClasses"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="inline-flex min-w-0 items-center justify-center gap-2">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
type CardPadding = 'default' | 'compact'
|
||||
type CardLayout = 'content' | 'flush'
|
||||
type HeaderCollapseAt = 'sm' | 'lg' | 'none'
|
||||
|
||||
type CardPadding = 'none' | 'compact' | 'default'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
as?: keyof HTMLElementTagNameMap
|
||||
padding?: CardPadding
|
||||
layout?: CardLayout
|
||||
title?: string
|
||||
description?: string
|
||||
headerCollapseAt?: HeaderCollapseAt
|
||||
headerClass?: string
|
||||
bodyClass?: string
|
||||
}>(),
|
||||
{
|
||||
as: 'section',
|
||||
padding: 'default',
|
||||
layout: 'content',
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
headerCollapseAt: 'sm',
|
||||
headerClass: undefined,
|
||||
bodyClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -37,55 +26,36 @@ const slots = defineSlots<{
|
||||
default?: () => unknown
|
||||
}>()
|
||||
|
||||
const paddingClasses: Record<CardPadding, string> = {
|
||||
default: 'p-5.5',
|
||||
const paddingClasses: Record<CardPadding, string | undefined> = {
|
||||
none: undefined,
|
||||
compact: 'p-4',
|
||||
default: 'p-5.5',
|
||||
}
|
||||
|
||||
const hasManagedHeader = computed(
|
||||
() =>
|
||||
!!props.title || !!props.description || !!slots.actions || !!slots.title || !!slots.description,
|
||||
() => Boolean(props.title || props.description || slots.actions || slots.title || slots.description),
|
||||
)
|
||||
|
||||
const outerPaddingClass = computed(() => {
|
||||
if (props.layout === 'flush')
|
||||
return undefined
|
||||
return paddingClasses[props.padding]
|
||||
})
|
||||
|
||||
const managedHeaderLayoutClasses = computed(() => {
|
||||
if (props.headerCollapseAt === 'none') {
|
||||
return 'flex items-start justify-between gap-3'
|
||||
}
|
||||
|
||||
if (props.headerCollapseAt === 'lg') {
|
||||
return 'flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'
|
||||
}
|
||||
|
||||
return 'flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between'
|
||||
})
|
||||
const hasHeader = computed(() => Boolean(slots.header || hasManagedHeader.value))
|
||||
const contentClasses = computed(() => [
|
||||
hasHeader.value ? 'mt-4' : undefined,
|
||||
'flex min-h-0 min-w-0 flex-1 flex-col',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="props.as"
|
||||
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-card)"
|
||||
:class="[
|
||||
outerPaddingClass,
|
||||
]"
|
||||
class="[--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-card"
|
||||
:class="[paddingClasses[padding], hasHeader ? 'flex min-h-0 flex-col' : undefined]"
|
||||
>
|
||||
<template v-if="$slots.header || hasManagedHeader || $slots.body">
|
||||
<header
|
||||
v-if="$slots.header || hasManagedHeader"
|
||||
class="shrink-0"
|
||||
:class="props.headerClass"
|
||||
>
|
||||
<header v-if="$slots.header || hasManagedHeader" class="shrink-0">
|
||||
<slot name="header">
|
||||
<div :class="managedHeaderLayoutClasses">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0 pt-0.5">
|
||||
<h2
|
||||
v-if="props.title || $slots.title"
|
||||
class="m-0 text-xl leading-[1.15] font-heavy text-(--cp-text-primary)"
|
||||
class="m-0 text-xl leading-[1.15] font-heavy text-cp-primary text-balance"
|
||||
>
|
||||
<slot name="title">
|
||||
{{ props.title }}
|
||||
@@ -93,14 +63,13 @@ const managedHeaderLayoutClasses = computed(() => {
|
||||
</h2>
|
||||
<p
|
||||
v-if="props.description || $slots.description"
|
||||
class="mt-1.75 mb-0 text-[13px] leading-[1.15] font-emphasis text-(--cp-text-secondary)"
|
||||
class="mt-1.75 mb-0 text-[13px] leading-[1.3] font-emphasis text-cp-secondary text-pretty"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ props.description }}
|
||||
</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="shrink-0">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
@@ -108,10 +77,7 @@ const managedHeaderLayoutClasses = computed(() => {
|
||||
</slot>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="$slots.body || $slots.default"
|
||||
:class="props.bodyClass"
|
||||
>
|
||||
<div v-if="$slots.body || $slots.default" :class="contentClasses">
|
||||
<slot name="body">
|
||||
<slot />
|
||||
</slot>
|
||||
|
||||
@@ -1,70 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, Minus } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, useTemplateRef, watchEffect } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
indeterminate?: boolean
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
indeterminate: false,
|
||||
disabled: false,
|
||||
label: undefined,
|
||||
showLabel: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
|
||||
const inputRef = useTemplateRef<HTMLInputElement>('input')
|
||||
const checked = computed(() => model.value || props.indeterminate)
|
||||
|
||||
watchEffect(() => {
|
||||
if (inputRef.value)
|
||||
inputRef.value.indeterminate = props.indeterminate
|
||||
})
|
||||
|
||||
const boxClasses = computed(() => [
|
||||
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-(--cp-checkbox-radius) border-0 transition-[background-color,box-shadow,color] duration-150',
|
||||
'relative inline-flex size-4 shrink-0 items-center justify-center rounded-cp-control-sm border-0 transition-[background-color,box-shadow,color] duration-150 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-(--cp-disabled-bg) text-(--cp-disabled-icon)'
|
||||
? 'bg-cp-disabled text-cp-disabled-icon'
|
||||
: checked.value
|
||||
? 'bg-(--cp-info-bg) text-(--cp-info)'
|
||||
: 'bg-(--cp-bg-surface) text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
|
||||
? 'bg-cp-accent text-cp-accent-on'
|
||||
: 'bg-cp-surface text-transparent shadow-[inset_0_0_0_1px_var(--cp-default-border-hover)]',
|
||||
])
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
model.value = !model.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex min-h-4 min-w-4 appearance-none items-center gap-2.5 border-0 bg-transparent p-0 text-[13px] leading-none font-emphasis transition-opacity outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
|
||||
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
class="group relative inline-flex min-h-4 min-w-4 items-center gap-2.5 text-[13px] leading-none font-emphasis"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-55' : 'cursor-pointer'"
|
||||
role="checkbox"
|
||||
:aria-checked="indeterminate ? 'mixed' : model"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
>
|
||||
<span :class="boxClasses">
|
||||
<input
|
||||
ref="input"
|
||||
v-model="model"
|
||||
type="checkbox"
|
||||
class="peer sr-only"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span
|
||||
:class="boxClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Minus
|
||||
class="absolute transition-opacity duration-150 size-3"
|
||||
:class="[indeterminate ? 'opacity-100' : 'opacity-0']"
|
||||
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
|
||||
:class="indeterminate ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
<Check
|
||||
class="absolute transition-opacity duration-150 size-3"
|
||||
:class="[!indeterminate && model ? 'opacity-100' : 'opacity-0']"
|
||||
class="absolute size-3 transition-opacity duration-150 motion-reduce:transition-none"
|
||||
:class="!indeterminate && model ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
v-if="showLabel && label"
|
||||
class="text-[13px] leading-none font-emphasis"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</button>
|
||||
<span v-if="showLabel" class="text-[13px] leading-none font-emphasis">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
|
||||
const RGBA_COLOR_PATTERN
|
||||
= /^rgba\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(0(?:\.\d+)?|1(?:\.0+)?)\s*\)$/i
|
||||
|
||||
interface HsvColor {
|
||||
h: number
|
||||
s: number
|
||||
v: number
|
||||
a: number
|
||||
}
|
||||
|
||||
interface RgbaColor {
|
||||
r: number
|
||||
g: number
|
||||
b: number
|
||||
a: number
|
||||
}
|
||||
|
||||
export function hexToHsv(value: string): HsvColor | null {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
const red = Number.parseInt(normalized.slice(1, 3), 16) / 255
|
||||
const green = Number.parseInt(normalized.slice(3, 5), 16) / 255
|
||||
const blue = Number.parseInt(normalized.slice(5, 7), 16) / 255
|
||||
const alpha = Number.parseInt(normalized.slice(7, 9), 16) / 255
|
||||
const maximum = Math.max(red, green, blue)
|
||||
const minimum = Math.min(red, green, blue)
|
||||
const delta = maximum - minimum
|
||||
let hue = 0
|
||||
|
||||
if (delta > 0) {
|
||||
if (maximum === red)
|
||||
hue = 60 * (((green - blue) / delta) % 6)
|
||||
else if (maximum === green)
|
||||
hue = 60 * ((blue - red) / delta + 2)
|
||||
else
|
||||
hue = 60 * ((red - green) / delta + 4)
|
||||
}
|
||||
|
||||
return {
|
||||
h: hue < 0 ? hue + 360 : hue,
|
||||
s: maximum === 0 ? 0 : (delta / maximum) * 100,
|
||||
v: maximum * 100,
|
||||
a: alpha,
|
||||
}
|
||||
}
|
||||
|
||||
export function hsvToHex(color: HsvColor) {
|
||||
const hue = ((color.h % 360) + 360) % 360
|
||||
const saturation = clamp(color.s, 0, 100) / 100
|
||||
const brightness = clamp(color.v, 0, 100) / 100
|
||||
const chroma = brightness * saturation
|
||||
const section = hue / 60
|
||||
const intermediate = chroma * (1 - Math.abs((section % 2) - 1))
|
||||
const offset = brightness - chroma
|
||||
const [red, green, blue] = section < 1
|
||||
? [chroma, intermediate, 0]
|
||||
: section < 2
|
||||
? [intermediate, chroma, 0]
|
||||
: section < 3
|
||||
? [0, chroma, intermediate]
|
||||
: section < 4
|
||||
? [0, intermediate, chroma]
|
||||
: section < 5
|
||||
? [intermediate, 0, chroma]
|
||||
: [chroma, 0, intermediate]
|
||||
|
||||
const rgb = [red, green, blue]
|
||||
.map(channel => Math.round((channel + offset) * 255).toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const alpha = Math.round(clamp(color.a, 0, 1) * 255).toString(16).padStart(2, '0')
|
||||
return `#${rgb}${alpha}`.toUpperCase()
|
||||
}
|
||||
|
||||
export function parseRgbaColor(value: string): RgbaColor | null {
|
||||
const match = RGBA_COLOR_PATTERN.exec(value.trim())
|
||||
if (!match)
|
||||
return null
|
||||
|
||||
const red = Number(match[1])
|
||||
const green = Number(match[2])
|
||||
const blue = Number(match[3])
|
||||
const alpha = Number(match[4])
|
||||
if ([red, green, blue].some(channel => channel > 255))
|
||||
return null
|
||||
|
||||
return { r: red, g: green, b: blue, a: alpha }
|
||||
}
|
||||
|
||||
export function rgbaToHexColor(color: RgbaColor) {
|
||||
return `#${[color.r, color.g, color.b, color.a * 255]
|
||||
.map(channel => Math.round(channel).toString(16).padStart(2, '0'))
|
||||
.join('')}`.toUpperCase()
|
||||
}
|
||||
|
||||
export function formatRgbaColor(value: string) {
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (!normalized)
|
||||
return null
|
||||
|
||||
const red = Number.parseInt(normalized.slice(1, 3), 16)
|
||||
const green = Number.parseInt(normalized.slice(3, 5), 16)
|
||||
const blue = Number.parseInt(normalized.slice(5, 7), 16)
|
||||
const alpha = Number((Number.parseInt(normalized.slice(7, 9), 16) / 255).toFixed(3))
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value))
|
||||
}
|
||||
+41
-36
@@ -1,18 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { normalizeRgbaHexColor } from '@/utils/color'
|
||||
import BaseButton from '../BaseButton.vue'
|
||||
import BaseInput from '../BaseInput.vue'
|
||||
import BasePopover from '../BasePopover.vue'
|
||||
import {
|
||||
formatRgbaColor,
|
||||
hexToHsv,
|
||||
hsvToHex,
|
||||
normalizeHexColor,
|
||||
parseRgbaColor,
|
||||
rgbaToHexColor,
|
||||
} from '@/utils/color'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseInput from './BaseInput.vue'
|
||||
import BasePopover from './BasePopover.vue'
|
||||
} from './color'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -38,7 +39,7 @@ const svPanel = useTemplateRef<HTMLElement>('svPanel')
|
||||
const hueSlider = useTemplateRef<HTMLElement>('hueSlider')
|
||||
const alphaSlider = useTemplateRef<HTMLElement>('alphaSlider')
|
||||
|
||||
const normalizedModel = computed(() => normalizeHexColor(model.value) ?? '#60A5FA80')
|
||||
const normalizedModel = computed(() => normalizeRgbaHexColor(model.value) ?? '#60A5FA80')
|
||||
const draftColor = computed(() => hsvToHex({
|
||||
h: hue.value,
|
||||
s: saturation.value,
|
||||
@@ -201,18 +202,18 @@ function updateFromInput() {
|
||||
}
|
||||
|
||||
function choosePreset(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (normalized)
|
||||
resetDraft(normalized)
|
||||
}
|
||||
|
||||
function isPresetSelected(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
return normalized?.slice(0, 7) === draftColor.value.slice(0, 7)
|
||||
}
|
||||
|
||||
function presetCheckClass(value: string) {
|
||||
const normalized = normalizeHexColor(value)
|
||||
const normalized = normalizeRgbaHexColor(value)
|
||||
if (!normalized)
|
||||
return 'text-white'
|
||||
|
||||
@@ -233,10 +234,6 @@ function confirm() {
|
||||
model.value = rgbaToHexColor(rgba)
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -244,31 +241,29 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
v-model="open"
|
||||
class="p-0.75"
|
||||
placement="bottom-start"
|
||||
:width="304"
|
||||
:disabled="props.disabled"
|
||||
panel-class="p-3!"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex size-8 items-center justify-center rounded-[4px] border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) disabled:cursor-not-allowed disabled:opacity-60"
|
||||
class="group inline-flex size-8 items-center justify-center rounded-sm border-0 bg-transparent p-0 outline-none transition-opacity duration-150 hover:opacity-90 focus-visible:ring-2 focus-visible:ring-cp-info-border disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="props.disabled"
|
||||
:aria-label="props.label"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="open"
|
||||
>
|
||||
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-[4px]" aria-hidden="true">
|
||||
<span class="base-color-picker__checker relative flex size-full items-center justify-center overflow-hidden rounded-sm" aria-hidden="true">
|
||||
<span class="absolute inset-0" :style="{ backgroundColor: normalizedModel }" />
|
||||
<ChevronDown class="relative size-3.5 text-white drop-shadow-[0_1px_2px_rgb(0_0_0/0.65)]" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="grid gap-3" role="dialog" :aria-label="props.label">
|
||||
<div class="grid w-76 gap-3 p-3" role="dialog" :aria-label="props.label">
|
||||
<div class="grid h-43 grid-cols-[1fr_14px] gap-2">
|
||||
<div
|
||||
ref="svPanel"
|
||||
class="base-color-picker__sv relative touch-none overflow-hidden rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="base-color-picker__sv relative touch-none overflow-hidden rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
:style="{ backgroundColor: hueColor }"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -290,7 +285,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
|
||||
<div
|
||||
ref="hueSlider"
|
||||
class="base-color-picker__hue relative touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="base-color-picker__hue relative touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
aria-label="色相"
|
||||
@@ -303,7 +298,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
@keydown="handleHueKeydown"
|
||||
>
|
||||
<span
|
||||
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-[2px] bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
|
||||
class="absolute left-1/2 h-1.5 w-4.5 -translate-x-1/2 -translate-y-1/2 rounded-xs bg-white shadow-[0_0_0_1px_#64748b,0_1px_2px_#0000004d]"
|
||||
:style="hueThumbStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -313,7 +308,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
<div class="grid grid-cols-[1fr_36px] items-center gap-2">
|
||||
<div
|
||||
ref="alphaSlider"
|
||||
class="base-color-picker__alpha relative h-3 touch-none rounded-[4px] outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="base-color-picker__alpha relative h-3 touch-none rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-cp-info-border"
|
||||
:style="alphaTrackStyle"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -333,7 +328,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-right font-mono text-[11px] tabular-nums text-(--cp-text-secondary)">
|
||||
<span class="text-right font-mono text-[11px] tabular-nums text-cp-secondary">
|
||||
{{ Math.round(alpha * 100) }}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -348,7 +343,7 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
v-for="color in props.presets"
|
||||
:key="color"
|
||||
type="button"
|
||||
class="relative flex size-6.5 items-center justify-center rounded-[4px] border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)"
|
||||
class="relative flex size-6.5 items-center justify-center rounded-sm border-0 outline-none transition-transform duration-150 hover:scale-105 focus-visible:ring-2 focus-visible:ring-cp-info-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface"
|
||||
:style="{ backgroundColor: color }"
|
||||
role="radio"
|
||||
:aria-label="color"
|
||||
@@ -366,18 +361,28 @@ function clamp(value: number, minimum: number, maximum: number) {
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2">
|
||||
<BaseInput
|
||||
v-model="customInput"
|
||||
class="min-w-0 flex-1"
|
||||
aria-label="RGBA 颜色"
|
||||
placeholder="rgba(96, 165, 250, 1)"
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:error="inputError"
|
||||
@change="updateFromInput"
|
||||
@keydown.enter="confirm"
|
||||
/>
|
||||
<BaseButton size="default" variant="default" :disabled="!parseRgbaColor(customInput)" @click="confirm">
|
||||
<div class="min-w-0 flex-1">
|
||||
<BaseInput
|
||||
v-model="customInput"
|
||||
aria-label="RGBA 颜色"
|
||||
placeholder="rgba(96, 165, 250, 1)"
|
||||
autocomplete="off"
|
||||
:disabled="props.disabled"
|
||||
:aria-invalid="Boolean(inputError) || undefined"
|
||||
aria-describedby="color-input-error"
|
||||
@change="updateFromInput"
|
||||
@keydown.enter="confirm"
|
||||
/>
|
||||
<p
|
||||
v-if="inputError"
|
||||
id="color-input-error"
|
||||
class="mt-1.5 mb-0 text-xs font-emphasis text-cp-danger-text"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ inputError }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton size="md" variant="secondary" :disabled="!parseRgbaColor(customInput)" @click="confirm">
|
||||
确定
|
||||
</BaseButton>
|
||||
</div>
|
||||
@@ -1,28 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
|
||||
type ConfirmVariant = 'info' | 'warning' | 'danger' | 'success'
|
||||
type ButtonVariant = 'primary' | 'success' | 'warning' | 'danger'
|
||||
import BaseModal from './BaseModal/index.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
variant?: ConfirmVariant
|
||||
destructive?: boolean
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
loading?: boolean
|
||||
confirmDisabled?: boolean
|
||||
width?: string | number
|
||||
}>(),
|
||||
{
|
||||
variant: 'warning',
|
||||
destructive: false,
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
loading: false,
|
||||
confirmDisabled: false,
|
||||
width: '480px',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -31,13 +26,6 @@ const emit = defineEmits<{
|
||||
cancel: []
|
||||
}>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const confirmVariantMap: Record<ConfirmVariant, ButtonVariant> = {
|
||||
info: 'primary',
|
||||
warning: 'warning',
|
||||
danger: 'danger',
|
||||
success: 'success',
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
if (props.loading)
|
||||
return
|
||||
@@ -57,23 +45,24 @@ function handleConfirm() {
|
||||
v-model="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:variant="variant"
|
||||
:width="width"
|
||||
:close-disabled="loading"
|
||||
size="sm"
|
||||
:tone="destructive ? 'danger' : 'warning'"
|
||||
role="alertdialog"
|
||||
:dismissible="!loading"
|
||||
>
|
||||
<div
|
||||
v-if="$slots.default"
|
||||
class="text-[14px] leading-[1.55] font-emphasis text-(--cp-text-secondary)"
|
||||
class="text-[14px] leading-[1.55] font-emphasis text-cp-secondary"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="default" :disabled="loading" @click="handleCancel">
|
||||
<BaseButton variant="secondary" :disabled="loading" @click="handleCancel">
|
||||
{{ cancelText }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:variant="confirmVariantMap[variant]"
|
||||
:variant="destructive ? 'destructive' : 'primary'"
|
||||
:loading="loading"
|
||||
:disabled="confirmDisabled"
|
||||
@click="handleConfirm"
|
||||
|
||||
@@ -3,13 +3,22 @@ import type { Component } from 'vue'
|
||||
import { Inbox } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
compact?: boolean
|
||||
plain?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: Component
|
||||
size?: 'sm' | 'md'
|
||||
surface?: 'subtle' | 'none'
|
||||
}>(),
|
||||
{
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
icon: undefined,
|
||||
size: 'md',
|
||||
surface: 'subtle',
|
||||
},
|
||||
)
|
||||
|
||||
const resolvedTitle = computed(() => props.title ?? '暂无数据')
|
||||
const resolvedIcon = computed(() => props.icon ?? Inbox)
|
||||
@@ -19,22 +28,22 @@ const resolvedIcon = computed(() => props.icon ?? Inbox)
|
||||
<div
|
||||
class="grid justify-items-center text-center"
|
||||
:class="[
|
||||
plain ? 'bg-transparent' : 'rounded-(--cp-panel-radius) bg-(--cp-bg-subtle)',
|
||||
compact ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
|
||||
surface === 'none' ? 'bg-transparent' : 'rounded-cp-overlay bg-cp-subtle',
|
||||
size === 'sm' ? 'gap-2 px-4 py-5' : 'gap-3 px-6 py-8',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="inline-flex items-center justify-center rounded-(--cp-icon-button-radius) bg-(--cp-bg-muted) text-(--cp-text-muted)"
|
||||
:class="compact ? 'size-8' : 'size-10'"
|
||||
class="inline-flex items-center justify-center rounded-cp-control bg-cp-muted text-cp-muted-text"
|
||||
:class="size === 'sm' ? 'size-8' : 'size-10'"
|
||||
>
|
||||
<component :is="resolvedIcon" :size="compact ? 16 : 18" />
|
||||
<component :is="resolvedIcon" :size="size === 'sm' ? 16 : 18" />
|
||||
</span>
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-(--cp-text-primary)">
|
||||
<p class="m-0 text-[13px] leading-[1.15] font-heavy text-cp-primary">
|
||||
{{ resolvedTitle }}
|
||||
</p>
|
||||
<p
|
||||
v-if="description"
|
||||
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-(--cp-text-secondary)"
|
||||
class="m-0 max-w-72 text-xs leading-[1.45] font-semibold text-cp-secondary"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
@@ -1,18 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
import { computed, provide, useId } from 'vue'
|
||||
import { formFieldKey } from './context'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
description?: string
|
||||
error?: string
|
||||
required?: boolean
|
||||
controlId?: string
|
||||
}>(),
|
||||
{
|
||||
label: undefined,
|
||||
description: undefined,
|
||||
error: undefined,
|
||||
required: false,
|
||||
controlId: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const generatedId = useId()
|
||||
const resolvedControlId = computed(() => props.controlId ?? `field-${generatedId}`)
|
||||
const descriptionId = computed(() => props.description ? `${resolvedControlId.value}-description` : undefined)
|
||||
const errorId = computed(() => props.error ? `${resolvedControlId.value}-error` : undefined)
|
||||
const describedBy = computed(() => [descriptionId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
provide(formFieldKey, {
|
||||
controlId: resolvedControlId,
|
||||
describedBy,
|
||||
invalid: computed(() => Boolean(props.error)),
|
||||
required: computed(() => props.required),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -21,33 +39,36 @@ withDefaults(
|
||||
v-if="label || $slots.label || $slots.extra"
|
||||
class="mb-2 flex min-w-0 items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
<slot name="label">
|
||||
<span
|
||||
v-if="label"
|
||||
class="min-w-0 text-[13px] leading-none font-medium text-(--cp-text-secondary)"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</slot>
|
||||
<span v-if="required" class="text-[13px] leading-none font-bold text-(--cp-danger)">
|
||||
*
|
||||
</span>
|
||||
</div>
|
||||
<!-- The runtime id comes from the injected field context. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
:for="resolvedControlId"
|
||||
class="flex min-w-0 items-center gap-1.5 text-[13px] leading-none font-medium text-cp-secondary"
|
||||
>
|
||||
<slot name="label">{{ label }}</slot>
|
||||
<span v-if="required" class="font-bold text-cp-danger" aria-hidden="true">*</span>
|
||||
<span v-if="required" class="sr-only">必填</span>
|
||||
</label>
|
||||
<div v-if="$slots.extra" class="inline-flex shrink-0 items-center">
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-(--cp-text-muted)"
|
||||
:id="descriptionId"
|
||||
class="mt-0 mb-2 text-xs leading-[1.35] font-emphasis text-cp-muted-text"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
<div class="min-w-0">
|
||||
<slot />
|
||||
</div>
|
||||
<p v-if="error" class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-(--cp-danger-text)">
|
||||
<p
|
||||
v-if="error"
|
||||
:id="errorId"
|
||||
class="mt-2 mb-0 text-xs leading-[1.35] font-emphasis text-cp-danger-text"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComputedRef, InjectionKey } from 'vue'
|
||||
|
||||
interface FormFieldContext {
|
||||
controlId: ComputedRef<string>
|
||||
describedBy: ComputedRef<string | undefined>
|
||||
invalid: ComputedRef<boolean>
|
||||
required: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
export const formFieldKey: InjectionKey<FormFieldContext> = Symbol('form-field')
|
||||
@@ -1,52 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type FormGap = 'compact' | 'default' | 'loose'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns?: number | string
|
||||
gap?: FormGap
|
||||
}>(),
|
||||
{
|
||||
columns: 1,
|
||||
gap: 'default',
|
||||
},
|
||||
)
|
||||
|
||||
const gapClasses: Record<FormGap, string> = {
|
||||
compact: 'gap-3',
|
||||
default: 'gap-4',
|
||||
loose: 'gap-5',
|
||||
}
|
||||
|
||||
const formClasses = computed(() => ['base-form grid min-w-0 grid-cols-1', gapClasses[props.gap]])
|
||||
|
||||
const formStyle = computed<CSSProperties>(() => {
|
||||
if (typeof props.columns === 'number') {
|
||||
return {
|
||||
'--cp-form-columns':
|
||||
props.columns > 1 ? `repeat(${props.columns}, minmax(0, 1fr))` : 'minmax(0, 1fr)',
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
return {
|
||||
'--cp-form-columns': props.columns,
|
||||
} as CSSProperties
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form :class="formClasses" :style="formStyle" @submit.prevent>
|
||||
<form class="grid min-w-0 gap-4" @submit.prevent>
|
||||
<slot />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@media (min-width: 640px) {
|
||||
.base-form {
|
||||
grid-template-columns: var(--cp-form-columns);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
type IconButtonVariant = 'primary' | 'secondary' | 'success' | 'ghost' | 'destructive'
|
||||
type IconButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
variant?: IconButtonVariant
|
||||
size?: IconButtonSize
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
pressed?: boolean
|
||||
type?: 'button' | 'submit' | 'reset'
|
||||
}>(),
|
||||
{
|
||||
variant: 'ghost',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
pressed: false,
|
||||
type: 'button',
|
||||
},
|
||||
)
|
||||
|
||||
defineSlots<{
|
||||
loading?: () => unknown
|
||||
default: () => unknown
|
||||
}>()
|
||||
|
||||
const variantClasses: Record<IconButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-cp-accent text-cp-accent-on shadow-cp-control hover:bg-cp-accent-hover active:bg-cp-accent-pressed',
|
||||
secondary:
|
||||
'bg-cp-surface text-cp-secondary shadow-cp-control hover:bg-cp-default-hover hover:text-cp-primary active:bg-cp-default-active',
|
||||
success:
|
||||
'bg-cp-success-bg text-cp-success-text shadow-none hover:bg-cp-success-bg-hover active:bg-cp-success-bg-active',
|
||||
ghost:
|
||||
'bg-transparent text-cp-secondary shadow-none hover:bg-cp-subtle hover:text-cp-primary active:bg-cp-muted',
|
||||
destructive:
|
||||
'bg-transparent text-cp-danger-text shadow-none hover:bg-cp-danger-bg-hover active:bg-cp-danger-bg-active',
|
||||
}
|
||||
|
||||
const sizeClasses: Record<IconButtonSize, string> = {
|
||||
sm: 'size-cp-control-sm [&>svg]:size-3.5',
|
||||
md: 'size-cp-control-md [&>svg]:size-4',
|
||||
lg: 'size-cp-control-lg [&>svg]:size-4.5',
|
||||
}
|
||||
|
||||
const spinnerSizes: Record<IconButtonSize, number> = {
|
||||
sm: 14,
|
||||
md: 16,
|
||||
lg: 18,
|
||||
}
|
||||
|
||||
const classes = computed(() => [
|
||||
'inline-grid shrink-0 touch-manipulation place-items-center rounded-cp-control border-0 leading-none outline-none transition-[background-color,box-shadow,color,opacity,transform] duration-150 motion-safe:active:scale-[0.96] motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
'disabled:cursor-not-allowed disabled:transform-none disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
sizeClasses[props.size],
|
||||
variantClasses[props.variant],
|
||||
props.pressed ? 'bg-cp-default-active text-cp-primary' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:class="classes"
|
||||
:disabled="disabled || loading"
|
||||
:aria-label="label"
|
||||
:aria-pressed="pressed || undefined"
|
||||
:aria-busy="loading || undefined"
|
||||
:title="label"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="inline-grid place-items-center [&>svg]:block [&>svg]:origin-center [&>svg]:transform-view [&>svg]:will-change-transform"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<slot name="loading">
|
||||
<LoaderCircle
|
||||
class="animate-spin motion-reduce:animate-none"
|
||||
:size="spinnerSizes[size]"
|
||||
/>
|
||||
</slot>
|
||||
</span>
|
||||
<span v-else class="inline-grid place-items-center" aria-hidden="true">
|
||||
<slot />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,99 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject, useAttrs } from 'vue'
|
||||
import { formFieldKey } from './BaseForm/context'
|
||||
|
||||
type InputSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
type?: string
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
autocomplete?: string
|
||||
name?: string
|
||||
ariaLabel?: string
|
||||
min?: number | string
|
||||
max?: number | string
|
||||
size?: InputSize
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
type: 'text',
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
autocomplete: undefined,
|
||||
name: undefined,
|
||||
ariaLabel: undefined,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
const attrs = useAttrs()
|
||||
const field = inject(formFieldKey, null)
|
||||
|
||||
// number 输入也保持字符串契约,避免原生 v-model 隐式转换为 number。
|
||||
function updateModel(event: Event) {
|
||||
model.value = (event.target as HTMLInputElement).value
|
||||
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : field?.controlId.value)
|
||||
const invalid = computed(() => Boolean(
|
||||
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
|
||||
))
|
||||
const describedBy = computed(() => [
|
||||
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
|
||||
field?.describedBy.value,
|
||||
].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
||||
const controlAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => ![
|
||||
'class',
|
||||
'style',
|
||||
'id',
|
||||
'aria-describedby',
|
||||
'aria-invalid',
|
||||
'aria-required',
|
||||
].includes(key)),
|
||||
))
|
||||
|
||||
const sizeClasses: Record<InputSize, string> = {
|
||||
sm: 'h-cp-control-sm gap-2 px-3 text-xs',
|
||||
md: 'h-cp-control-md gap-2.5 px-3.5 text-[13px]',
|
||||
lg: 'h-cp-control-lg gap-3 px-4 text-[14px]',
|
||||
}
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
'relative inline-flex h-(--cp-input-height-default) w-full min-w-0 items-center gap-2.5 overflow-visible rounded-(--cp-input-radius-base) border-0 px-3.5 text-[13px] text-(--cp-text-primary) shadow-(--cp-shadow-input) transition-[background-color,box-shadow,color] duration-[160ms]',
|
||||
'relative inline-flex w-full min-w-0 items-center overflow-visible rounded-cp-control border-0 text-cp-primary shadow-cp-input transition-[background-color,box-shadow,color] duration-[160ms] motion-reduce:transition-none',
|
||||
sizeClasses[props.size],
|
||||
props.disabled
|
||||
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
|
||||
: props.error
|
||||
? 'bg-(--cp-input-error-soft-bg) shadow-(--cp-shadow-input-error)'
|
||||
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
|
||||
: invalid.value
|
||||
? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error'
|
||||
: [
|
||||
'bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))]',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
|
||||
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-(--cp-shadow-input-focus)',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
|
||||
'focus-within:bg-(--cp-input-soft-bg-focus) focus-within:shadow-cp-input-focus',
|
||||
],
|
||||
])
|
||||
|
||||
const iconClasses = computed(() => [
|
||||
'inline-flex shrink-0',
|
||||
props.disabled
|
||||
? 'text-(--cp-disabled-icon)'
|
||||
: props.error
|
||||
? 'text-(--cp-danger)'
|
||||
: 'text-(--cp-text-muted)',
|
||||
? 'text-cp-disabled-icon'
|
||||
: invalid.value
|
||||
? 'text-cp-danger'
|
||||
: 'text-cp-muted-text',
|
||||
])
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
'h-full min-w-0 flex-1 border-0 bg-transparent text-[13px] font-emphasis leading-[1.15] outline-0',
|
||||
'placeholder:text-(--cp-text-muted) disabled:cursor-not-allowed disabled:text-(--cp-disabled-text)',
|
||||
props.error
|
||||
? 'text-(--cp-danger-text)'
|
||||
: props.disabled
|
||||
? 'text-(--cp-disabled-text)'
|
||||
: 'text-(--cp-text-primary)',
|
||||
])
|
||||
function updateModel(event: Event) {
|
||||
model.value = (event.target as HTMLInputElement).value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div v-bind="rootAttrs" class="min-w-0">
|
||||
<span class="base-input__control" :class="containerClasses">
|
||||
<span v-if="$slots.prefix" :class="iconClasses">
|
||||
<span v-if="$slots.prefix" :class="iconClasses" aria-hidden="true">
|
||||
<slot name="prefix" />
|
||||
</span>
|
||||
<input
|
||||
v-bind="controlAttrs"
|
||||
:id="controlId"
|
||||
:value="model"
|
||||
class="base-input__field"
|
||||
:class="inputClasses"
|
||||
:name="name"
|
||||
class="base-input__field h-full min-w-0 flex-1 border-0 bg-transparent font-emphasis leading-[1.15] text-cp-primary outline-0 placeholder:text-cp-muted-text disabled:cursor-not-allowed disabled:text-cp-disabled-text"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:required="field?.required.value || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:aria-required="field?.required.value || undefined"
|
||||
@input="updateModel"
|
||||
>
|
||||
<span v-if="$slots.suffix" :class="iconClasses">
|
||||
<slot name="suffix" />
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="error" class="text-xs font-emphasis leading-[1.15] text-(--cp-danger-text)">{{
|
||||
error
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircle } from '@lucide/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: 'neutral' | 'destructive'
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
tone: 'neutral',
|
||||
disabled: false,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
defineSlots<{
|
||||
icon?: () => unknown
|
||||
loading?: () => unknown
|
||||
default: () => unknown
|
||||
}>()
|
||||
|
||||
const classes = computed(() => [
|
||||
'flex min-h-9 w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 bg-transparent px-3 text-left text-[13px] leading-none font-emphasis outline-none transition-[background-color,color] duration-150 motion-reduce:transition-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-cp-accent-border',
|
||||
'disabled:cursor-not-allowed disabled:text-cp-disabled-text',
|
||||
props.tone === 'destructive'
|
||||
? 'text-cp-danger-text hover:bg-cp-danger-bg-hover'
|
||||
: 'text-cp-primary hover:bg-cp-default-hover',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
:class="classes"
|
||||
:disabled="disabled || loading"
|
||||
:aria-busy="loading || undefined"
|
||||
>
|
||||
<span v-if="loading" class="inline-grid size-3.5 shrink-0 place-items-center" aria-hidden="true">
|
||||
<slot name="loading">
|
||||
<LoaderCircle class="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
</slot>
|
||||
</span>
|
||||
<span v-else-if="$slots.icon" class="inline-grid shrink-0 place-items-center" aria-hidden="true">
|
||||
<slot name="icon" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate"><slot /></span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useScrollLock } from '@vueuse/core'
|
||||
|
||||
let bodyScrollLocked: ReturnType<typeof useScrollLock> | undefined
|
||||
let lockCount = 0
|
||||
|
||||
function getBodyScrollLock() {
|
||||
if (typeof document === 'undefined')
|
||||
return undefined
|
||||
|
||||
bodyScrollLocked ??= useScrollLock(document.body)
|
||||
return bodyScrollLocked
|
||||
}
|
||||
|
||||
export function lockBodyScroll() {
|
||||
const scrollLock = getBodyScrollLock()
|
||||
if (!scrollLock)
|
||||
return
|
||||
|
||||
lockCount += 1
|
||||
if (lockCount === 1)
|
||||
scrollLock.value = true
|
||||
}
|
||||
|
||||
export function unlockBodyScroll() {
|
||||
if (!bodyScrollLocked)
|
||||
return
|
||||
|
||||
lockCount = Math.max(0, lockCount - 1)
|
||||
if (lockCount === 0)
|
||||
bodyScrollLocked.value = false
|
||||
}
|
||||
+73
-80
@@ -1,26 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
import { computed, nextTick, onBeforeUnmount, useTemplateRef, watch } from 'vue'
|
||||
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { lockBodyScroll, unlockBodyScroll } from '@/utils/body-scroll-lock'
|
||||
import BaseButton from './BaseButton.vue'
|
||||
import BaseScrollbar from './BaseScrollbar.vue'
|
||||
import BaseIconButton from '../BaseIconButton.vue'
|
||||
import BaseScrollbar from '../BaseScrollbar.vue'
|
||||
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
|
||||
|
||||
type ModalVariant = 'default' | 'info' | 'warning' | 'danger' | 'success'
|
||||
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
width?: string | number
|
||||
variant?: ModalVariant
|
||||
closeDisabled?: boolean
|
||||
bodyMaxHeight?: string
|
||||
bodyViewClass?: string
|
||||
hideFooter?: boolean
|
||||
}>()
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
size?: ModalSize
|
||||
tone?: ModalTone
|
||||
dismissible?: boolean
|
||||
role?: 'dialog' | 'alertdialog'
|
||||
}>(),
|
||||
{
|
||||
description: undefined,
|
||||
size: 'md',
|
||||
tone: 'neutral',
|
||||
dismissible: true,
|
||||
role: 'dialog',
|
||||
},
|
||||
)
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const panel = useTemplateRef<HTMLElement>('panel')
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
let ownsScrollLock = false
|
||||
|
||||
@@ -33,57 +43,46 @@ const focusableSelector = [
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
const variant = computed(() => props.variant ?? 'default')
|
||||
|
||||
const modalStyle = computed(() => {
|
||||
const preferredWidth
|
||||
= typeof props.width === 'number' ? `${props.width}px` : (props.width ?? '560px')
|
||||
|
||||
return {
|
||||
width: `min(${preferredWidth}, calc(100dvw - 1.5rem))`,
|
||||
}
|
||||
})
|
||||
const bodyClass = computed(() => [
|
||||
'min-h-0 overflow-hidden py-4 pr-4 pl-3.25 sm:py-7 sm:pr-7 sm:pl-6.25',
|
||||
props.description || variant.value !== 'default' ? 'pt-4 sm:pt-5' : 'pt-4 sm:pt-6',
|
||||
])
|
||||
const scrollViewClass = computed(() =>
|
||||
['pb-0.75 pl-0.75 pr-3 sm:pr-4', props.bodyViewClass].filter(Boolean).join(' '),
|
||||
)
|
||||
const sizeClasses: Record<ModalSize, string> = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-xl',
|
||||
lg: 'max-w-3xl',
|
||||
xl: 'max-w-5xl',
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
default: Info,
|
||||
neutral: Info,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
danger: AlertCircle,
|
||||
success: CheckCircle2,
|
||||
}
|
||||
|
||||
const variantClasses: Record<ModalVariant, { iconBg: string, icon: string }> = {
|
||||
default: {
|
||||
iconBg: 'bg-(--cp-info-bg)',
|
||||
icon: 'text-(--cp-info)',
|
||||
const toneClasses: Record<ModalTone, { iconBg: string, icon: string }> = {
|
||||
neutral: {
|
||||
iconBg: 'bg-cp-info-bg',
|
||||
icon: 'text-cp-info',
|
||||
},
|
||||
info: {
|
||||
iconBg: 'bg-(--cp-info-bg)',
|
||||
icon: 'text-(--cp-info)',
|
||||
iconBg: 'bg-cp-info-bg',
|
||||
icon: 'text-cp-info',
|
||||
},
|
||||
warning: {
|
||||
iconBg: 'bg-(--cp-warning-bg)',
|
||||
icon: 'text-(--cp-warning)',
|
||||
iconBg: 'bg-cp-warning-bg',
|
||||
icon: 'text-cp-warning',
|
||||
},
|
||||
danger: {
|
||||
iconBg: 'bg-(--cp-danger-bg)',
|
||||
icon: 'text-(--cp-danger)',
|
||||
iconBg: 'bg-cp-danger-bg',
|
||||
icon: 'text-cp-danger',
|
||||
},
|
||||
success: {
|
||||
iconBg: 'bg-(--cp-success-bg)',
|
||||
icon: 'text-(--cp-success)',
|
||||
iconBg: 'bg-cp-success-bg',
|
||||
icon: 'text-cp-success',
|
||||
},
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
if (props.closeDisabled)
|
||||
if (!props.dismissible)
|
||||
return
|
||||
open.value = false
|
||||
}
|
||||
@@ -187,74 +186,68 @@ onBeforeUnmount(() => {
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label="关闭弹窗"
|
||||
class="absolute inset-0 cursor-default border-0 bg-(--cp-overlay-scrim) p-0"
|
||||
class="absolute inset-0 cursor-default border-0 bg-cp-overlay p-0"
|
||||
@click="closeModal"
|
||||
/>
|
||||
<section
|
||||
ref="panel"
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-(--cp-card-radius) bg-(--cp-bg-surface) shadow-(--cp-shadow-popover) sm:max-h-[calc(100dvh-3rem)]"
|
||||
:style="modalStyle"
|
||||
role="dialog"
|
||||
class="cp-modal-panel [--cp-input-current-bg:var(--cp-input-soft-bg)] [--cp-input-current-bg-hover:var(--cp-input-soft-bg-hover)] relative grid max-h-[calc(100dvh-1.5rem)] w-full min-w-0 grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden rounded-cp-surface bg-cp-surface shadow-cp-popover sm:max-h-[calc(100dvh-3rem)]"
|
||||
:class="sizeClasses[size]"
|
||||
:role="role"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
:aria-labelledby="titleId"
|
||||
:aria-describedby="description ? descriptionId : undefined"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header
|
||||
class="grid shrink-0 grid-cols-[auto_minmax(0,1fr)_28px] gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
|
||||
class="grid shrink-0 items-start gap-3 p-4 pb-0 sm:gap-4 sm:p-7 sm:pb-0"
|
||||
:class="tone === 'neutral'
|
||||
? 'grid-cols-[minmax(0,1fr)_28px]'
|
||||
: 'grid-cols-[auto_minmax(0,1fr)_28px]'"
|
||||
>
|
||||
<span
|
||||
v-if="variant !== 'default'"
|
||||
class="inline-flex size-11 items-center justify-center rounded-(--cp-icon-button-radius)"
|
||||
:class="$slots.icon ? 'bg-(--cp-bg-subtle)' : variantClasses[variant].iconBg"
|
||||
v-if="tone !== 'neutral'"
|
||||
class="inline-flex size-11 items-center justify-center rounded-cp-control"
|
||||
:class="$slots.icon ? 'bg-cp-subtle' : toneClasses[tone].iconBg"
|
||||
>
|
||||
<slot name="icon">
|
||||
<component :is="iconMap[variant]" :size="18" :class="variantClasses[variant].icon" />
|
||||
<component :is="iconMap[tone]" :size="18" :class="toneClasses[tone].icon" />
|
||||
</slot>
|
||||
</span>
|
||||
<div class="min-w-0" :class="variant === 'default' ? 'col-span-2' : ''">
|
||||
<h2 class="m-0 text-lg leading-[1.15] font-heavy text-(--cp-text-primary)">
|
||||
<div class="min-w-0">
|
||||
<h2 :id="titleId" class="m-0 text-lg leading-[1.15] font-heavy text-cp-primary">
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="description"
|
||||
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-(--cp-text-secondary)"
|
||||
:id="descriptionId"
|
||||
class="mt-2 mb-0 text-[13px] leading-[1.45] font-semibold text-cp-secondary"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton
|
||||
icon-only
|
||||
class="col-start-3"
|
||||
<BaseIconButton
|
||||
label="关闭"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:disabled="closeDisabled"
|
||||
:disabled="!dismissible"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X :size="16" />
|
||||
</BaseButton>
|
||||
</BaseIconButton>
|
||||
</header>
|
||||
<div :class="bodyClass">
|
||||
<BaseScrollbar
|
||||
class="h-full -mr-3 sm:-mr-4"
|
||||
:max-height="bodyMaxHeight"
|
||||
:view-class="scrollViewClass"
|
||||
>
|
||||
<slot />
|
||||
<div class="min-h-0 overflow-hidden px-4 py-4 sm:px-7 sm:py-6">
|
||||
<BaseScrollbar class="h-full">
|
||||
<div class="p-0.75">
|
||||
<slot />
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</div>
|
||||
<footer
|
||||
v-if="!hideFooter"
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 flex-wrap justify-end gap-2 px-4 pb-4 sm:gap-3 sm:px-7 sm:pb-7"
|
||||
>
|
||||
<slot name="footer">
|
||||
<BaseButton variant="default" @click="open = false">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" @click="open = false">
|
||||
确认
|
||||
</BaseButton>
|
||||
</slot>
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,27 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
actionsInline?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="grid min-h-17 shrink-0 items-start"
|
||||
:class="
|
||||
props.actionsInline
|
||||
? 'grid-cols-[minmax(0,1fr)_auto] gap-x-4'
|
||||
: 'grid-cols-1 gap-y-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:gap-x-4'
|
||||
"
|
||||
class="flex min-h-17 shrink-0 flex-wrap items-start justify-between gap-x-4 gap-y-3"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-(--cp-text-primary)">
|
||||
<div class="min-w-0 flex-1 basis-64">
|
||||
<h1 class="m-0 text-[34px] leading-[1.15] font-extrabold text-cp-primary">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p
|
||||
v-if="description || $slots.description"
|
||||
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-(--cp-text-secondary)"
|
||||
class="mt-2.5 mb-0 flex items-center gap-2 text-[15px] leading-[1.15] font-semibold text-cp-secondary"
|
||||
>
|
||||
<slot name="description">
|
||||
{{ description }}
|
||||
@@ -31,7 +25,6 @@ const props = defineProps<{
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="mt-0.5 flex shrink-0 items-center justify-end gap-2"
|
||||
:class="props.actionsInline ? undefined : 'max-sm:w-full'"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
|
||||
@@ -27,12 +27,9 @@ const props = withDefaults(
|
||||
placement?: PopoverPlacement
|
||||
trigger?: PopoverTrigger
|
||||
offset?: number
|
||||
width?: number | string
|
||||
disabled?: boolean
|
||||
/** 包含 hover 的触发方式打开弹窗的延迟(毫秒);click 触发不生效。 */
|
||||
hoverDelay?: number
|
||||
panelClass?: string
|
||||
triggerClass?: string
|
||||
anchorElement?: HTMLElement | null
|
||||
animatePosition?: boolean
|
||||
}>(),
|
||||
@@ -42,8 +39,6 @@ const props = withDefaults(
|
||||
offset: 6,
|
||||
disabled: false,
|
||||
hoverDelay: 400,
|
||||
panelClass: '',
|
||||
triggerClass: '',
|
||||
anchorElement: null,
|
||||
animatePosition: false,
|
||||
},
|
||||
@@ -53,7 +48,6 @@ const open = defineModel<boolean>({ default: false })
|
||||
const attrs = useAttrs()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const popoverRef = ref<HTMLElement | null>(null)
|
||||
const popoverStyle = ref<CSSProperties>({})
|
||||
const popoverArrowStyle = ref<CSSProperties>({})
|
||||
@@ -62,39 +56,21 @@ const hoverOpenTimer = shallowRef<number>()
|
||||
const viewportTarget = computed(() => (open.value && typeof window !== 'undefined' ? window : null))
|
||||
|
||||
const popoverClasses = computed(() => [
|
||||
'fixed z-50 overflow-visible rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1.5 text-left shadow-(--cp-shadow-popover)',
|
||||
'fixed z-50 overflow-visible rounded-cp-overlay border-0 bg-cp-surface text-left text-cp-primary shadow-cp-popover',
|
||||
props.animatePosition
|
||||
? 'transition-[left,top] duration-150 ease-out motion-reduce:transition-none'
|
||||
: undefined,
|
||||
props.panelClass,
|
||||
])
|
||||
|
||||
const sizeStyle = computed<CSSProperties>(() => {
|
||||
const width = toCssLength(props.width)
|
||||
return width ? { width } : {}
|
||||
})
|
||||
|
||||
function toCssLength(value?: number | string) {
|
||||
if (typeof value === 'number') {
|
||||
return `${value}px`
|
||||
}
|
||||
|
||||
const trimmed = value?.trim()
|
||||
return trimmed || undefined
|
||||
}
|
||||
|
||||
function updatePopoverPosition() {
|
||||
const anchorElement = props.anchorElement ?? triggerRef.value
|
||||
const anchorElement = props.anchorElement ?? rootRef.value
|
||||
if (!open.value || !anchorElement)
|
||||
return
|
||||
|
||||
const viewportPadding = 8
|
||||
const triggerRect = anchorElement.getBoundingClientRect()
|
||||
const panelRect = popoverRef.value?.getBoundingClientRect()
|
||||
const panelWidth = Math.max(
|
||||
panelRect?.width ?? 0,
|
||||
toCssLength(props.width) ? 0 : triggerRect.width,
|
||||
)
|
||||
const panelWidth = panelRect?.width ?? 0
|
||||
const panelHeight = panelRect?.height ?? 0
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding)
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - panelHeight - viewportPadding)
|
||||
@@ -113,7 +89,6 @@ function updatePopoverPosition() {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
maxWidth: `calc(100vw - ${viewportPadding * 2}px)`,
|
||||
...(toCssLength(props.width) ? {} : { minWidth: `${triggerRect.width}px` }),
|
||||
}
|
||||
popoverArrowStyle.value = popoverArrowPosition({
|
||||
placement: position.placement,
|
||||
@@ -355,12 +330,12 @@ watch(
|
||||
)
|
||||
|
||||
onClickOutside(rootRef, closePopover, { ignore: [popoverRef] })
|
||||
useEventListener(triggerRef, 'click', (event) => {
|
||||
useEventListener(rootRef, 'click', (event) => {
|
||||
event.stopPropagation()
|
||||
togglePopover()
|
||||
})
|
||||
useEventListener(triggerRef, ['mouseenter', 'focusin'], handleHoverEnter)
|
||||
useEventListener(triggerRef, ['mouseleave', 'focusout'], handleHoverLeave)
|
||||
useEventListener(rootRef, ['mouseenter', 'focusin'], handleHoverEnter)
|
||||
useEventListener(rootRef, ['mouseleave', 'focusout'], handleHoverLeave)
|
||||
useEventListener(popoverRef, ['mouseenter', 'focusin'], handleHoverEnter)
|
||||
useEventListener(popoverRef, ['mouseleave', 'focusout'], handleHoverLeave)
|
||||
useEventListener(viewportTarget, 'keydown', (event) => {
|
||||
@@ -377,14 +352,8 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="relative inline-block overflow-visible" v-bind="attrs">
|
||||
<div
|
||||
ref="triggerRef"
|
||||
class="inline-flex"
|
||||
:class="props.triggerClass"
|
||||
>
|
||||
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
|
||||
</div>
|
||||
<div ref="rootRef" class="relative inline-flex overflow-visible" v-bind="attrs">
|
||||
<slot name="trigger" :open="open" :close="closePopover" :toggle="togglePopover" />
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
@@ -399,7 +368,7 @@ onBeforeUnmount(() => {
|
||||
v-if="open"
|
||||
ref="popoverRef"
|
||||
:class="popoverClasses"
|
||||
:style="[sizeStyle, popoverStyle]"
|
||||
:style="popoverStyle"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute size-2 rotate-45 bg-inherit"
|
||||
|
||||
@@ -5,20 +5,16 @@ import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, useTemplate
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
viewClass?: string
|
||||
maxHeight?: string
|
||||
height?: string
|
||||
horizontal?: boolean
|
||||
vertical?: boolean
|
||||
trackInset?: 'default' | 'none'
|
||||
}>(),
|
||||
{
|
||||
viewClass: '',
|
||||
maxHeight: undefined,
|
||||
height: undefined,
|
||||
horizontal: false,
|
||||
vertical: true,
|
||||
trackInset: 'default',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -61,7 +57,6 @@ let horizontalDragStartScrollLeft = 0
|
||||
let scrollTopPosition = 0
|
||||
let scrollLeftPosition = 0
|
||||
let scrollFrameId: number | undefined
|
||||
let rootHovering = false
|
||||
const { start: startHideTimer, stop: stopHideTimer } = useTimeoutFn(hideScrollbar, 1600, {
|
||||
immediate: false,
|
||||
})
|
||||
@@ -91,16 +86,10 @@ const wrapClasses = computed(() => [
|
||||
props.maxHeight ? undefined : 'h-full',
|
||||
])
|
||||
const verticalTrackClass = computed(() =>
|
||||
props.horizontal && canScrollX.value ? 'bottom-3' : 'bottom-1',
|
||||
props.horizontal && canScrollX.value ? 'bottom-1.5' : 'bottom-0',
|
||||
)
|
||||
const horizontalTrackClass = computed(() =>
|
||||
props.vertical && canScrollY.value ? 'right-3' : 'right-1',
|
||||
)
|
||||
const verticalTrackInsetClass = computed(() =>
|
||||
props.trackInset === 'none' ? 'right-0' : 'right-1',
|
||||
)
|
||||
const horizontalTrackInsetClass = computed(() =>
|
||||
props.trackInset === 'none' ? 'bottom-0 left-0' : 'bottom-1 left-1',
|
||||
props.vertical && canScrollY.value ? 'right-1.5' : 'right-0',
|
||||
)
|
||||
|
||||
function showScrollbar() {
|
||||
@@ -113,7 +102,10 @@ function clearHideTimer() {
|
||||
|
||||
function scheduleHideScrollbar() {
|
||||
clearHideTimer()
|
||||
if (!rootHovering) {
|
||||
if (!dragging.value
|
||||
&& !horizontalDragging.value
|
||||
&& !verticalTrackHovering.value
|
||||
&& !horizontalTrackHovering.value) {
|
||||
startHideTimer()
|
||||
}
|
||||
}
|
||||
@@ -129,12 +121,10 @@ function activateScrollbar() {
|
||||
}
|
||||
|
||||
function handleRootMouseEnter() {
|
||||
rootHovering = true
|
||||
activateScrollbar()
|
||||
}
|
||||
|
||||
function handleRootMouseLeave() {
|
||||
rootHovering = false
|
||||
hideScrollbar()
|
||||
}
|
||||
|
||||
@@ -152,7 +142,7 @@ function setTrackHovering(axis: ScrollbarAxis, hovering: boolean) {
|
||||
function handleTrackMouseEnter(axis: ScrollbarAxis) {
|
||||
setTrackHovering(axis, true)
|
||||
showScrollbar()
|
||||
scheduleHideScrollbar()
|
||||
clearHideTimer()
|
||||
}
|
||||
|
||||
function handleTrackMouseLeave(axis: ScrollbarAxis) {
|
||||
@@ -182,7 +172,12 @@ function measureVerticalScrollbar(wrap: HTMLElement) {
|
||||
const clientHeight = wrap.clientHeight
|
||||
const scrollHeight = wrap.scrollHeight
|
||||
const scrollRange = clamp(scrollHeight - clientHeight, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackHeight = clamp(clientHeight - 8, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackHeight = trackLength(
|
||||
verticalTrackRef.value,
|
||||
clientHeight,
|
||||
'top',
|
||||
'bottom',
|
||||
)
|
||||
if (scrollRange <= overflowTolerance || availableTrackHeight <= 0) {
|
||||
wrap.scrollTop = 0
|
||||
verticalMetrics.scrollRange = 0
|
||||
@@ -209,7 +204,12 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
|
||||
const clientWidth = wrap.clientWidth
|
||||
const scrollWidth = wrap.scrollWidth
|
||||
const scrollRange = clamp(scrollWidth - clientWidth, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackWidth = clamp(clientWidth - 8, 0, Number.POSITIVE_INFINITY)
|
||||
const availableTrackWidth = trackLength(
|
||||
horizontalTrackRef.value,
|
||||
clientWidth,
|
||||
'left',
|
||||
'right',
|
||||
)
|
||||
if (scrollRange <= overflowTolerance || availableTrackWidth <= 0) {
|
||||
wrap.scrollLeft = 0
|
||||
horizontalMetrics.scrollRange = 0
|
||||
@@ -225,6 +225,28 @@ function measureHorizontalScrollbar(wrap: HTMLElement) {
|
||||
horizontalThumbWidth.value = nextThumbWidth
|
||||
}
|
||||
|
||||
function trackLength(
|
||||
track: HTMLElement | null,
|
||||
viewportLength: number,
|
||||
startProperty: 'top' | 'left',
|
||||
endProperty: 'bottom' | 'right',
|
||||
) {
|
||||
if (!track)
|
||||
return clamp(viewportLength - 8, 0, Number.POSITIVE_INFINITY)
|
||||
|
||||
const style = getComputedStyle(track)
|
||||
const start = Number.parseFloat(style[startProperty])
|
||||
const end = Number.parseFloat(style[endProperty])
|
||||
const inset = (Number.isFinite(start) ? start : 4) + (Number.isFinite(end) ? end : 4)
|
||||
const root = rootRef.value
|
||||
const containerLength = root
|
||||
? startProperty === 'top'
|
||||
? root.clientHeight
|
||||
: root.clientWidth
|
||||
: viewportLength
|
||||
return clamp(containerLength - inset, 0, Number.POSITIVE_INFINITY)
|
||||
}
|
||||
|
||||
function updateVerticalThumbPosition(scrollTop: number) {
|
||||
const { scrollRange, thumbRange } = verticalMetrics
|
||||
thumbTop.value
|
||||
@@ -448,6 +470,7 @@ useEventListener(wrapRef, 'scroll', handleScroll, { passive: true })
|
||||
useEventListener(dragDocument, 'pointermove', handleDocumentPointerMove)
|
||||
useEventListener(dragDocument, ['pointerup', 'pointercancel'], handleDocumentPointerUp)
|
||||
useEventListener(rootRef, 'mouseenter', handleRootMouseEnter)
|
||||
useEventListener(rootRef, 'mousemove', activateScrollbar, { passive: true })
|
||||
useEventListener(rootRef, 'mouseleave', handleRootMouseLeave)
|
||||
useEventListener(verticalTrackRef, 'mouseenter', () => handleTrackMouseEnter('vertical'))
|
||||
useEventListener(verticalTrackRef, 'mouseleave', () => handleTrackMouseLeave('vertical'))
|
||||
@@ -471,7 +494,7 @@ defineExpose({
|
||||
:style="{ maxHeight, height }"
|
||||
>
|
||||
<div ref="wrap" :class="wrapClasses" tabindex="0">
|
||||
<div ref="view" :class="viewClass">
|
||||
<div ref="view">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,15 +503,14 @@ defineExpose({
|
||||
v-show="canScrollY"
|
||||
ref="verticalTrack"
|
||||
aria-hidden="true"
|
||||
class="absolute top-1 z-40 w-1.5 rounded-full transition-opacity duration-200"
|
||||
class="base-scrollbar-track-y absolute right-0 bottom-0 z-40 flex w-3 justify-end transition-opacity duration-200"
|
||||
:class="[
|
||||
verticalTrackInsetClass,
|
||||
verticalTrackClass,
|
||||
verticalScrollbarVisible ? 'opacity-100' : 'opacity-0',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="w-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
class="w-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
:class="dragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
|
||||
:style="thumbStyle"
|
||||
@pointerdown="handleThumbPointerDown"
|
||||
@@ -499,15 +521,14 @@ defineExpose({
|
||||
v-show="canScrollX"
|
||||
ref="horizontalTrack"
|
||||
aria-hidden="true"
|
||||
class="absolute z-40 h-1.5 rounded-full transition-opacity duration-200"
|
||||
class="base-scrollbar-track-x absolute bottom-0 left-0 z-40 flex h-3 items-end transition-opacity duration-200"
|
||||
:class="[
|
||||
horizontalTrackInsetClass,
|
||||
horizontalTrackClass,
|
||||
horizontalScrollbarVisible ? 'opacity-100' : 'opacity-0',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
class="h-1.5 rounded-full bg-(--cp-scrollbar-thumb) transition-colors duration-200 hover:bg-(--cp-scrollbar-thumb-hover)"
|
||||
:class="horizontalDragging ? 'bg-(--cp-scrollbar-thumb-hover)' : ''"
|
||||
:style="horizontalThumbStyle"
|
||||
@pointerdown="handleHorizontalThumbPointerDown"
|
||||
@@ -526,4 +547,8 @@ defineExpose({
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.base-scrollbar-track-y {
|
||||
top: var(--cp-scrollbar-track-top, 0.25rem);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface SegmentedOption {
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, nextTick } from 'vue'
|
||||
|
||||
type SegmentedSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export interface SegmentedOption {
|
||||
label: string
|
||||
value: string
|
||||
icon?: Component
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type SegmentedSize = 'default' | 'input'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
options: SegmentedOption[]
|
||||
disabled?: boolean
|
||||
iconOnly?: boolean
|
||||
display?: 'label' | 'icon'
|
||||
size?: SegmentedSize
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
iconOnly: false,
|
||||
size: 'default',
|
||||
display: 'label',
|
||||
size: 'md',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -31,73 +34,106 @@ const activeIndex = computed(() => {
|
||||
const index = props.options.findIndex(option => option.value === model.value)
|
||||
return index >= 0 ? index : 0
|
||||
})
|
||||
|
||||
const optionCount = computed(() => clamp(props.options.length, 1, Number.POSITIVE_INFINITY))
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${optionCount.value}, minmax(0, 1fr))`,
|
||||
}))
|
||||
|
||||
const isInputSize = computed(() => props.size === 'input')
|
||||
|
||||
const indicatorStyle = computed(() => ({
|
||||
width: isInputSize.value
|
||||
? `calc((100% - 6px) / ${optionCount.value})`
|
||||
: `calc((100% - 8px) / ${optionCount.value})`,
|
||||
width: `calc((100% - 6px) / ${optionCount.value})`,
|
||||
transform: `translateX(${activeIndex.value * 100}%)`,
|
||||
}))
|
||||
|
||||
const rootClasses = computed(() => isInputSize.value
|
||||
? 'h-(--cp-input-height-default) rounded-(--cp-input-radius-base) p-0.75'
|
||||
: 'h-9 rounded-(--cp-icon-button-radius) p-1')
|
||||
const rootClasses: Record<SegmentedSize, string> = {
|
||||
sm: 'h-cp-control-sm',
|
||||
md: 'h-cp-control-md',
|
||||
lg: 'h-cp-control-lg',
|
||||
}
|
||||
const optionClasses: Record<SegmentedSize, string> = {
|
||||
sm: 'h-6.5 px-2 text-[11px]',
|
||||
md: 'h-8 px-3 text-xs',
|
||||
lg: 'h-9.5 px-4 text-[13px]',
|
||||
}
|
||||
|
||||
const indicatorClasses = computed(() => isInputSize.value
|
||||
? 'inset-y-0.75 left-0.75 rounded-(--cp-input-radius-base)'
|
||||
: 'top-1 bottom-1 left-1 rounded-(--cp-input-radius-base)')
|
||||
function enabledIndexes() {
|
||||
return props.options.flatMap((option, index) => option.disabled ? [] : [index])
|
||||
}
|
||||
|
||||
const optionHeightClass = computed(() => isInputSize.value ? 'h-8' : 'h-7')
|
||||
|
||||
function selectOption(value: string) {
|
||||
if (props.disabled || value === model.value)
|
||||
function selectOption(index: number) {
|
||||
const option = props.options[index]
|
||||
if (props.disabled || !option || option.disabled || option.value === model.value)
|
||||
return
|
||||
model.value = value
|
||||
model.value = option.value
|
||||
}
|
||||
|
||||
async function handleKeydown(event: KeyboardEvent, index: number) {
|
||||
const indexes = enabledIndexes()
|
||||
if (indexes.length === 0)
|
||||
return
|
||||
|
||||
const current = indexes.indexOf(index)
|
||||
let nextIndex: number | undefined
|
||||
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown')
|
||||
nextIndex = indexes[(current + 1 + indexes.length) % indexes.length]
|
||||
else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')
|
||||
nextIndex = indexes[(current - 1 + indexes.length) % indexes.length]
|
||||
else if (event.key === 'Home')
|
||||
nextIndex = indexes[0]
|
||||
else if (event.key === 'End')
|
||||
nextIndex = indexes[indexes.length - 1]
|
||||
else
|
||||
return
|
||||
|
||||
if (nextIndex === undefined)
|
||||
return
|
||||
|
||||
event.preventDefault()
|
||||
selectOption(nextIndex)
|
||||
await nextTick()
|
||||
const buttons = (event.currentTarget as HTMLElement).parentElement?.querySelectorAll<HTMLButtonElement>('button')
|
||||
buttons?.[nextIndex]?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative inline-grid items-center bg-(--cp-bg-muted)"
|
||||
:class="rootClasses"
|
||||
class="relative inline-grid items-center rounded-cp-control bg-cp-muted p-0.75"
|
||||
:class="rootClasses[size]"
|
||||
:style="gridStyle"
|
||||
role="tablist"
|
||||
role="radiogroup"
|
||||
:aria-label="label"
|
||||
:aria-disabled="disabled || undefined"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-200 ease-out"
|
||||
:class="indicatorClasses"
|
||||
v-if="options.length > 0"
|
||||
class="pointer-events-none absolute inset-y-0.75 left-0.75 rounded-cp-control bg-cp-surface shadow-cp-control transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
:style="indicatorStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
v-for="option in options"
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
class="relative z-10 inline-flex min-w-0 items-center justify-center gap-1.5 rounded-(--cp-input-radius-base) border-0 bg-transparent px-3 text-xs leading-none font-emphasis transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-(--cp-info-border)"
|
||||
class="relative z-10 inline-flex min-w-0 touch-manipulation items-center justify-center gap-1.5 rounded-cp-control border-0 bg-transparent leading-none font-emphasis outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-cp-accent-border motion-reduce:transition-none"
|
||||
:class="[
|
||||
optionHeightClass,
|
||||
optionClasses[size],
|
||||
model === option.value
|
||||
? 'text-(--cp-text-primary)'
|
||||
: 'text-(--cp-text-secondary) hover:text-(--cp-text-primary)',
|
||||
disabled ? 'cursor-not-allowed opacity-60 hover:text-(--cp-text-secondary)' : undefined,
|
||||
iconOnly ? 'px-0' : undefined,
|
||||
? 'text-cp-primary'
|
||||
: 'text-cp-secondary hover:text-cp-primary',
|
||||
disabled || option.disabled ? 'cursor-not-allowed opacity-60 hover:text-cp-secondary' : undefined,
|
||||
display === 'icon' ? 'px-0' : undefined,
|
||||
]"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="model === option.value"
|
||||
:aria-label="iconOnly ? option.label : undefined"
|
||||
:title="iconOnly ? option.label : undefined"
|
||||
:disabled="disabled"
|
||||
@click="selectOption(option.value)"
|
||||
role="radio"
|
||||
:aria-checked="model === option.value"
|
||||
:aria-label="display === 'icon' ? option.label : undefined"
|
||||
:title="display === 'icon' ? option.label : undefined"
|
||||
:tabindex="model === option.value || (activeIndex === index && !model) ? 0 : -1"
|
||||
:disabled="disabled || option.disabled"
|
||||
@click="selectOption(index)"
|
||||
@keydown="handleKeydown($event, index)"
|
||||
>
|
||||
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" />
|
||||
<span v-if="!iconOnly">{{ option.label }}</span>
|
||||
<component :is="option.icon" v-if="option.icon" class="size-3.5 shrink-0" aria-hidden="true" />
|
||||
<span v-if="display === 'label'" class="truncate">{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
import { Check, ChevronDown } from '@lucide/vue'
|
||||
import { onClickOutside, useEventListener, useThrottleFn, whenever } from '@vueuse/core'
|
||||
import { clamp } from 'es-toolkit'
|
||||
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
|
||||
import { computed, inject, nextTick, ref, useAttrs, useId, watch } from 'vue'
|
||||
import { formFieldKey } from './BaseForm/context'
|
||||
|
||||
interface SelectOption {
|
||||
type SelectSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export interface SelectOption {
|
||||
label: string
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type SelectSize = 'default' | 'compact'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
@@ -24,19 +26,18 @@ const props = withDefaults(
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
size: 'default',
|
||||
size: 'md',
|
||||
disabled: false,
|
||||
placeholder: '请选择',
|
||||
emptyText: '暂无选项',
|
||||
ariaLabel: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ required: true })
|
||||
const attrs = useAttrs()
|
||||
const field = inject(formFieldKey, null)
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
@@ -44,7 +45,26 @@ const popoverRef = ref<HTMLElement | null>(null)
|
||||
const open = ref(false)
|
||||
const activeIndex = ref(-1)
|
||||
const popoverStyle = ref<CSSProperties>({})
|
||||
const selectId = `base-select-${Math.random().toString(36).slice(2)}`
|
||||
const selectId = `base-select-${useId()}`
|
||||
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : (field?.controlId.value ?? selectId))
|
||||
const invalid = computed(() => Boolean(
|
||||
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
|
||||
))
|
||||
const describedBy = computed(() => [
|
||||
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
|
||||
field?.describedBy.value,
|
||||
].filter(Boolean).join(' ') || undefined)
|
||||
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
||||
const triggerAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => ![
|
||||
'class',
|
||||
'style',
|
||||
'id',
|
||||
'aria-describedby',
|
||||
'aria-invalid',
|
||||
'aria-required',
|
||||
].includes(key)),
|
||||
))
|
||||
|
||||
const sizeConfig: Record<
|
||||
SelectSize,
|
||||
@@ -54,37 +74,44 @@ const sizeConfig: Record<
|
||||
icon: number
|
||||
}
|
||||
> = {
|
||||
default: {
|
||||
md: {
|
||||
trigger:
|
||||
'h-(--cp-input-height-default) px-3.5 pr-9 text-[13px] rounded-(--cp-input-radius-base)',
|
||||
'h-cp-control-md px-3.5 pr-9 text-[13px] rounded-cp-control',
|
||||
option: 'h-8.5 px-3 text-[13px]',
|
||||
icon: 16,
|
||||
},
|
||||
compact: {
|
||||
trigger: 'h-8 px-2.5 pr-7 text-xs rounded-(--cp-input-radius-base)',
|
||||
sm: {
|
||||
trigger: 'h-cp-control-sm px-2.5 pr-7 text-xs rounded-cp-control',
|
||||
option: 'h-8 px-2.5 text-xs',
|
||||
icon: 14,
|
||||
},
|
||||
lg: {
|
||||
trigger: 'h-cp-control-lg px-4 pr-10 text-[14px] rounded-cp-control',
|
||||
option: 'h-10 px-3.5 text-[14px]',
|
||||
icon: 17,
|
||||
},
|
||||
}
|
||||
|
||||
const selectedOption = computed(() => props.options.find(option => option.value === model.value))
|
||||
|
||||
const triggerClasses = computed(() => [
|
||||
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
|
||||
'relative inline-flex w-full min-w-0 items-center gap-2 overflow-visible border-0 text-left font-emphasis leading-none shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-[160ms]',
|
||||
sizeConfig[props.size].trigger,
|
||||
props.disabled
|
||||
? 'cursor-not-allowed bg-(--cp-disabled-bg) text-(--cp-disabled-text) shadow-none'
|
||||
: open.value
|
||||
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-(--cp-text-primary) shadow-(--cp-shadow-input-focus)'
|
||||
: [
|
||||
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-(--cp-text-primary)',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover)',
|
||||
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-(--cp-shadow-input-focus)',
|
||||
],
|
||||
? 'cursor-not-allowed bg-cp-disabled text-cp-disabled-text shadow-none'
|
||||
: invalid.value
|
||||
? 'cursor-pointer bg-(--cp-input-error-soft-bg) text-cp-danger-text shadow-cp-input-error'
|
||||
: open.value
|
||||
? 'cursor-pointer bg-(--cp-input-soft-bg-focus) text-cp-primary shadow-cp-input-focus'
|
||||
: [
|
||||
'cursor-pointer bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover',
|
||||
'focus-visible:bg-(--cp-input-soft-bg-focus) focus-visible:shadow-cp-input-focus',
|
||||
],
|
||||
])
|
||||
|
||||
const popoverClasses = computed(() => [
|
||||
'fixed z-50 flex flex-col gap-1 rounded-(--cp-popover-radius) border-0 bg-(--cp-bg-surface) p-1 shadow-(--cp-shadow-popover)',
|
||||
'fixed z-50 flex flex-col gap-1 rounded-cp-overlay border-0 bg-cp-surface p-1 shadow-cp-popover',
|
||||
props.options.length > 6 ? 'cp-scrollbar overflow-y-auto' : 'overflow-visible',
|
||||
])
|
||||
|
||||
@@ -228,15 +255,15 @@ function handleTriggerKeydown(event: KeyboardEvent) {
|
||||
|
||||
function optionClasses(option: SelectOption, index: number) {
|
||||
return [
|
||||
'flex w-full items-center gap-2 rounded-(--cp-input-radius-small) border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors',
|
||||
'flex w-full touch-manipulation items-center gap-2 rounded-cp-control-sm border-0 px-3 text-left font-emphasis leading-none outline-none transition-colors motion-reduce:transition-none',
|
||||
sizeConfig[props.size].option,
|
||||
option.disabled
|
||||
? 'cursor-not-allowed bg-transparent text-(--cp-disabled-text)'
|
||||
? 'cursor-not-allowed bg-transparent text-cp-disabled-text'
|
||||
: option.value === model.value
|
||||
? 'cursor-pointer bg-(--cp-info-bg) text-(--cp-info-text)'
|
||||
? 'cursor-pointer bg-cp-info-bg text-cp-info-text'
|
||||
: activeIndex.value === index
|
||||
? 'cursor-pointer bg-(--cp-default-bg-hover) text-(--cp-text-primary)'
|
||||
: 'cursor-pointer bg-transparent text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover)',
|
||||
? 'cursor-pointer bg-cp-default-hover text-cp-primary'
|
||||
: 'cursor-pointer bg-transparent text-cp-primary hover:bg-cp-default-hover',
|
||||
]
|
||||
}
|
||||
|
||||
@@ -263,19 +290,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="relative inline-block text-left"
|
||||
v-bind="attrs"
|
||||
v-bind="rootAttrs"
|
||||
>
|
||||
<button
|
||||
:id="selectId"
|
||||
v-bind="triggerAttrs"
|
||||
:id="controlId"
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
:class="triggerClasses"
|
||||
:disabled="disabled"
|
||||
role="combobox"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="`${selectId}-listbox`"
|
||||
:aria-activedescendant="open && activeIndex >= 0 ? optionId(activeIndex) : undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:aria-required="field?.required.value || undefined"
|
||||
@click="toggleMenu"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
@@ -286,21 +316,22 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2 transition-transform"
|
||||
:class="
|
||||
disabled
|
||||
? 'text-(--cp-disabled-icon)'
|
||||
? 'text-cp-disabled-icon'
|
||||
: open
|
||||
? 'rotate-180 text-(--cp-info)'
|
||||
: 'text-(--cp-text-muted)'
|
||||
? 'rotate-180 text-cp-info'
|
||||
: 'text-cp-muted-text'
|
||||
"
|
||||
:size="sizeConfig[size].icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-[opacity,transform] duration-150 ease-out"
|
||||
enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none"
|
||||
enter-from-class="-translate-y-1 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition-opacity duration-150 ease-in"
|
||||
leave-active-class="transition-opacity duration-150 ease-in motion-reduce:transition-none"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
@@ -311,11 +342,11 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
:class="popoverClasses"
|
||||
:style="popoverStyle"
|
||||
role="listbox"
|
||||
:aria-labelledby="selectId"
|
||||
:aria-labelledby="controlId"
|
||||
>
|
||||
<div
|
||||
v-if="options.length === 0"
|
||||
class="flex h-8.5 items-center rounded-(--cp-input-radius-small) px-3 text-[13px] leading-none font-emphasis text-(--cp-text-muted)"
|
||||
class="flex h-8.5 items-center rounded-cp-control-sm px-3 text-[13px] leading-none font-emphasis text-cp-muted-text"
|
||||
>
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
@@ -338,8 +369,9 @@ useEventListener(window, 'scroll', updatePopoverPositionThrottled, { capture: tr
|
||||
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
|
||||
<Check
|
||||
v-if="option.value === model"
|
||||
class="shrink-0 text-(--cp-info)"
|
||||
:size="size === 'compact' ? 13 : 15"
|
||||
class="shrink-0 text-cp-info"
|
||||
:size="size === 'sm' ? 13 : size === 'lg' ? 17 : 15"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -3,57 +3,53 @@ import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
showLabel?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
label: undefined,
|
||||
showLabel: false,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<boolean>({ default: false })
|
||||
|
||||
const buttonClasses = computed(() => [
|
||||
'group inline-flex rounded-full border-0 bg-transparent p-0 outline-none transition-[background-color,box-shadow,opacity] duration-180',
|
||||
'focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
props.disabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer',
|
||||
])
|
||||
|
||||
const trackClasses = computed(() => [
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180',
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full p-0.5 transition-[background-color,box-shadow] duration-180 motion-reduce:transition-none',
|
||||
props.disabled
|
||||
? 'bg-(--cp-disabled-bg) shadow-none'
|
||||
? 'bg-cp-disabled shadow-none'
|
||||
: model.value
|
||||
? 'bg-(--cp-info) shadow-(--cp-shadow-control) group-hover:bg-(--cp-info-hover)'
|
||||
: 'bg-(--cp-bg-muted) shadow-(--cp-shadow-control) group-hover:bg-(--cp-default-bg-active)',
|
||||
? 'bg-cp-accent shadow-cp-control group-hover:bg-cp-accent-hover'
|
||||
: 'bg-cp-muted shadow-cp-control group-hover:bg-cp-default-active',
|
||||
])
|
||||
|
||||
const thumbClasses = computed(() => [
|
||||
'size-5 rounded-full bg-(--cp-bg-surface) shadow-(--cp-shadow-control) transition-transform duration-180 ease-out',
|
||||
model.value ? 'translate-x-5' : 'translate-x-0',
|
||||
])
|
||||
|
||||
function toggle() {
|
||||
if (props.disabled)
|
||||
return
|
||||
|
||||
model.value = !model.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="model"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
:class="buttonClasses"
|
||||
@click="toggle"
|
||||
<!-- The native checkbox is nested; the rule does not resolve this Vue template pattern. -->
|
||||
<!-- eslint-disable-next-line vue-a11y/label-has-for -->
|
||||
<label
|
||||
class="group relative inline-flex items-center gap-2.5"
|
||||
:class="disabled ? 'cursor-not-allowed opacity-70' : 'cursor-pointer'"
|
||||
>
|
||||
<span :class="trackClasses">
|
||||
<span :class="thumbClasses" />
|
||||
<input
|
||||
v-model="model"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
class="peer sr-only"
|
||||
:aria-label="label"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span
|
||||
:class="trackClasses"
|
||||
class="peer-focus-visible:ring-2 peer-focus-visible:ring-cp-accent-border peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-cp-surface"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span
|
||||
class="size-5 rounded-full bg-cp-surface shadow-cp-control transition-transform duration-180 ease-out motion-reduce:transition-none"
|
||||
:class="model ? 'translate-x-5' : 'translate-x-0'"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<span v-if="showLabel" class="text-[13px] font-emphasis text-cp-primary">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { BaseTablePagination } from './pagination'
|
||||
import { ChevronLeft, ChevronRight } from '@lucide/vue'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import BaseIconButton from '../BaseIconButton.vue'
|
||||
import BaseSelect from '../BaseSelect.vue'
|
||||
import {
|
||||
|
||||
getCurrentPage,
|
||||
getPagerItems,
|
||||
getPageSizeOptions,
|
||||
@@ -49,21 +49,12 @@ function goToPage(page: number) {
|
||||
emit('pageChange', page)
|
||||
}
|
||||
|
||||
function paginationButtonClass(disabled: boolean) {
|
||||
return [
|
||||
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 bg-(--cp-bg-subtle) text-(--cp-text-secondary) transition-colors duration-150 outline-none',
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-45 shadow-none'
|
||||
: 'cursor-pointer hover:bg-(--cp-default-bg-hover) hover:text-(--cp-text-primary) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
]
|
||||
}
|
||||
|
||||
function paginationPageClass(page: number) {
|
||||
return [
|
||||
'inline-flex size-8 items-center justify-center rounded-(--cp-input-radius-base) border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
|
||||
'inline-flex size-8 items-center justify-center rounded-cp-control border-0 text-xs font-bold leading-none transition-colors duration-150 outline-none',
|
||||
page === currentPage.value
|
||||
? 'cursor-default bg-(--cp-info) text-(--cp-info-on)'
|
||||
: 'cursor-pointer bg-(--cp-bg-subtle) text-(--cp-text-primary) hover:bg-(--cp-default-bg-hover) focus-visible:ring-2 focus-visible:ring-(--cp-info-border) focus-visible:ring-offset-2 focus-visible:ring-offset-(--cp-bg-surface)',
|
||||
? 'cursor-default bg-cp-accent text-cp-accent-on'
|
||||
: 'cursor-pointer bg-cp-subtle text-cp-primary hover:bg-cp-default-hover focus-visible:ring-2 focus-visible:ring-cp-accent-border focus-visible:ring-offset-2 focus-visible:ring-offset-cp-surface',
|
||||
]
|
||||
}
|
||||
</script>
|
||||
@@ -73,7 +64,7 @@ function paginationPageClass(page: number) {
|
||||
class="mt-2 flex min-h-10 shrink-0 flex-wrap items-center justify-between gap-3 px-0 py-1"
|
||||
>
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-(--cp-text-secondary)"
|
||||
class="flex min-w-0 items-center gap-2.5 text-[12px] font-emphasis text-cp-secondary"
|
||||
>
|
||||
<span class="whitespace-nowrap">共 {{ pagination.total }} 条</span>
|
||||
</div>
|
||||
@@ -84,28 +75,27 @@ function paginationPageClass(page: number) {
|
||||
aria-label="每页条数"
|
||||
:options="pageSizeOptions"
|
||||
:disabled="loading"
|
||||
size="compact"
|
||||
size="sm"
|
||||
class="w-28"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="paginationButtonClass(loading || currentPage <= 1)"
|
||||
<BaseIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="loading || currentPage <= 1"
|
||||
title="上一页"
|
||||
aria-label="上一页"
|
||||
label="上一页"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<ChevronLeft class="size-4" />
|
||||
</button>
|
||||
</BaseIconButton>
|
||||
|
||||
<template v-for="(item, index) in pagerItems" :key="`${item}-${index}`">
|
||||
<span
|
||||
v-if="item === 'ellipsis'"
|
||||
class="inline-flex size-8 items-center justify-center text-xs font-bold text-(--cp-text-muted)"
|
||||
class="inline-flex size-8 items-center justify-center text-xs font-bold text-cp-muted-text"
|
||||
>
|
||||
...
|
||||
…
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@@ -119,16 +109,15 @@ function paginationPageClass(page: number) {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:class="paginationButtonClass(loading || currentPage >= totalPages)"
|
||||
<BaseIconButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="loading || currentPage >= totalPages"
|
||||
title="下一页"
|
||||
aria-label="下一页"
|
||||
label="下一页"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<ChevronRight class="size-4" />
|
||||
</button>
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,113 +1,236 @@
|
||||
import { clamp, sumBy } from 'es-toolkit'
|
||||
|
||||
// 行类型保持结构化约束;动态列 key 的取值统一走 cellValue。
|
||||
export type TableRow = object
|
||||
|
||||
export type TableColumnKind
|
||||
= | 'text'
|
||||
| 'identity'
|
||||
| 'meta'
|
||||
| 'status'
|
||||
| 'numeric'
|
||||
| 'datetime'
|
||||
| 'mono'
|
||||
| 'index'
|
||||
| 'selection'
|
||||
| 'expander'
|
||||
| 'actions'
|
||||
| 'custom'
|
||||
|
||||
export type TableColumnSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
|
||||
|
||||
export type TableColumnAlign = 'left' | 'right' | 'center'
|
||||
type TableColumnSticky = 'left' | 'right'
|
||||
|
||||
export interface BaseTableColumn<Row extends TableRow = TableRow> {
|
||||
key: string
|
||||
label?: string
|
||||
sortable?: boolean
|
||||
sortKey?: string
|
||||
kind?: TableColumnKind
|
||||
size?: TableColumnSize
|
||||
align?: TableColumnAlign
|
||||
sortable?: boolean | string
|
||||
format?: (value: unknown, row: Row) => unknown
|
||||
width?: number | string
|
||||
minWidth?: number | string
|
||||
maxWidth?: number | string
|
||||
flex?: number
|
||||
fixed?: 'left' | 'right' | false
|
||||
align?: 'left' | 'right' | 'center'
|
||||
ellipsis?: boolean
|
||||
emptyText?: string
|
||||
headerClass?: string
|
||||
cellClass?: string
|
||||
}
|
||||
|
||||
type BaseTableSortDirection = 'asc' | 'desc'
|
||||
|
||||
export interface BaseTableSort {
|
||||
key: string
|
||||
direction: BaseTableSortDirection
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export type ResolvedTableColumn<Row extends TableRow = TableRow> = Omit<
|
||||
BaseTableColumn<Row>,
|
||||
'fixed'
|
||||
> & {
|
||||
fixed?: 'left' | 'right'
|
||||
resolvedWidth: string
|
||||
resolvedMinWidth?: string
|
||||
resolvedMaxWidth?: string
|
||||
export interface BaseTableProps<Row extends TableRow> {
|
||||
columns: BaseTableColumn<Row>[]
|
||||
rows: Row[]
|
||||
rowKey?: string | ((row: Row, index: number) => string | number)
|
||||
selectedRowKeys?: Array<string | number>
|
||||
expandedRowKeys?: Array<string | number>
|
||||
density?: 'compact' | 'default'
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
sort?: BaseTableSort
|
||||
}
|
||||
|
||||
interface ColumnRecipe {
|
||||
size: TableColumnSize
|
||||
basisWidth?: number
|
||||
align: TableColumnAlign
|
||||
truncate: boolean
|
||||
contentClass?: string
|
||||
paddingClass?: string
|
||||
sticky?: TableColumnSticky
|
||||
}
|
||||
|
||||
const columnWidths: Record<TableColumnSize, number> = {
|
||||
'xs': 64,
|
||||
'sm': 88,
|
||||
'md': 112,
|
||||
'lg': 144,
|
||||
'xl': 184,
|
||||
'2xl': 240,
|
||||
'3xl': 288,
|
||||
'4xl': 352,
|
||||
}
|
||||
|
||||
const columnRecipes: Record<TableColumnKind, ColumnRecipe> = {
|
||||
text: {
|
||||
size: '2xl',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
},
|
||||
identity: {
|
||||
size: '2xl',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
},
|
||||
meta: {
|
||||
size: 'lg',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
contentClass: 'text-cp-secondary',
|
||||
},
|
||||
status: {
|
||||
size: 'md',
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
},
|
||||
numeric: {
|
||||
size: 'md',
|
||||
align: 'right',
|
||||
truncate: false,
|
||||
contentClass: 'font-mono tabular-nums text-cp-secondary',
|
||||
},
|
||||
datetime: {
|
||||
size: 'xl',
|
||||
align: 'left',
|
||||
truncate: false,
|
||||
contentClass: 'whitespace-nowrap font-mono text-[12px] tabular-nums text-cp-secondary',
|
||||
},
|
||||
mono: {
|
||||
size: 'xl',
|
||||
align: 'left',
|
||||
truncate: true,
|
||||
contentClass: 'font-mono text-[12px] font-emphasis',
|
||||
},
|
||||
index: {
|
||||
size: 'xs',
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
contentClass: 'font-mono tabular-nums text-cp-secondary',
|
||||
},
|
||||
selection: {
|
||||
size: 'xs',
|
||||
basisWidth: 48,
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
paddingClass: 'px-2',
|
||||
sticky: 'left',
|
||||
},
|
||||
expander: {
|
||||
size: 'xs',
|
||||
basisWidth: 40,
|
||||
align: 'center',
|
||||
truncate: false,
|
||||
paddingClass: 'px-2',
|
||||
sticky: 'left',
|
||||
},
|
||||
actions: {
|
||||
size: 'md',
|
||||
align: 'left',
|
||||
truncate: false,
|
||||
paddingClass: 'px-3',
|
||||
sticky: 'right',
|
||||
},
|
||||
custom: {
|
||||
size: 'lg',
|
||||
align: 'left',
|
||||
truncate: false,
|
||||
},
|
||||
}
|
||||
|
||||
export interface ResolvedTableColumn<Row extends TableRow = TableRow>
|
||||
extends BaseTableColumn<Row> {
|
||||
kind: TableColumnKind
|
||||
basisWidth: number
|
||||
align: TableColumnAlign
|
||||
truncate: boolean
|
||||
contentClass?: string
|
||||
paddingClass?: string
|
||||
sticky?: TableColumnSticky
|
||||
stickyOffset?: number
|
||||
}
|
||||
|
||||
export function defineTableColumns<Row extends TableRow>(columns: BaseTableColumn<Row>[]) {
|
||||
return columns
|
||||
}
|
||||
|
||||
export function resolveColumns<Row extends TableRow>(
|
||||
columns: BaseTableColumn<Row>[],
|
||||
minWidth?: number | string,
|
||||
): ResolvedTableColumn<Row>[] {
|
||||
const actionColumnIndex = columns.findIndex(column => column.key === 'actions')
|
||||
const fixedColumnIndex = actionColumnIndex >= 0 ? actionColumnIndex : 0
|
||||
const fixedPercentTotal = sumBy(columns, column =>
|
||||
column.width === undefined ? 0 : numericPercentWidth(column.width))
|
||||
const fixedPixelTotal = sumBy(columns, column =>
|
||||
column.width === undefined ? 0 : numericPixelWidth(column.width))
|
||||
const minWidthPixels = minWidth === undefined ? 0 : numericPixelWidth(minWidth)
|
||||
const flexibleColumns = columns.filter(column => column.width === undefined)
|
||||
const flexTotal = sumBy(flexibleColumns, column => column.flex ?? 1)
|
||||
const available = clamp(100 - fixedPercentTotal, 0, Number.POSITIVE_INFINITY)
|
||||
const availablePixels = clamp(minWidthPixels - fixedPixelTotal, 0, Number.POSITIVE_INFINITY)
|
||||
|
||||
return columns.map((column, index) => {
|
||||
const flex = column.flex ?? 1
|
||||
const automaticallyFixed
|
||||
= index === fixedColumnIndex ? (actionColumnIndex >= 0 ? 'right' : 'left') : undefined
|
||||
const width
|
||||
= column.width === undefined
|
||||
? minWidthPixels > 0 && fixedPercentTotal === 0
|
||||
? flexTotal > 0
|
||||
? `${(availablePixels * flex) / flexTotal}px`
|
||||
: `${availablePixels / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}px`
|
||||
: flexTotal > 0
|
||||
? `${(available * flex) / flexTotal}%`
|
||||
: `${available / clamp(flexibleColumns.length, 1, Number.POSITIVE_INFINITY)}%`
|
||||
: normalizeWidth(column.width)
|
||||
const resolved = columns.map((column): ResolvedTableColumn<Row> => {
|
||||
const kind = column.kind ?? 'text'
|
||||
const recipe = columnRecipes[kind]
|
||||
const basisWidth = recipe.basisWidth ?? columnWidths[column.size ?? recipe.size]
|
||||
|
||||
return {
|
||||
...column,
|
||||
fixed: column.fixed === false ? undefined : (column.fixed ?? automaticallyFixed),
|
||||
resolvedWidth: width,
|
||||
resolvedMinWidth: column.minWidth === undefined ? undefined : normalizeWidth(column.minWidth),
|
||||
resolvedMaxWidth: column.maxWidth === undefined ? undefined : normalizeWidth(column.maxWidth),
|
||||
kind,
|
||||
basisWidth,
|
||||
align: column.align ?? recipe.align,
|
||||
truncate: recipe.truncate,
|
||||
contentClass: recipe.contentClass,
|
||||
paddingClass: recipe.paddingClass,
|
||||
sticky: recipe.sticky,
|
||||
}
|
||||
})
|
||||
|
||||
let leftOffset = 0
|
||||
for (const column of resolved) {
|
||||
if (column.sticky !== 'left')
|
||||
continue
|
||||
column.stickyOffset = leftOffset
|
||||
leftOffset += column.basisWidth
|
||||
}
|
||||
|
||||
let rightOffset = 0
|
||||
for (const column of [...resolved].reverse()) {
|
||||
if (column.sticky !== 'right')
|
||||
continue
|
||||
column.stickyOffset = rightOffset
|
||||
rightOffset += column.basisWidth
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
function normalizeWidth(width: number | string) {
|
||||
return typeof width === 'number' ? `${width}px` : width
|
||||
export function minimumTableWidth<Row extends TableRow>(columns: ResolvedTableColumn<Row>[]) {
|
||||
return columns.reduce((total, column) => total + column.basisWidth, 0)
|
||||
}
|
||||
|
||||
export function columnStyle<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
export function tableStyle<Row extends TableRow>(columns: ResolvedTableColumn<Row>[]) {
|
||||
return { width: `max(100%, ${minimumTableWidth(columns)}px)` }
|
||||
}
|
||||
|
||||
export function columnStyle<Row extends TableRow>(
|
||||
column: ResolvedTableColumn<Row>,
|
||||
columns: ResolvedTableColumn<Row>[],
|
||||
) {
|
||||
const tableWidth = minimumTableWidth(columns)
|
||||
const widthPercent = tableWidth > 0 ? (column.basisWidth / tableWidth) * 100 : 0
|
||||
|
||||
return {
|
||||
width: column.resolvedWidth,
|
||||
minWidth: column.resolvedMinWidth,
|
||||
maxWidth: column.resolvedMaxWidth,
|
||||
width: `${widthPercent}%`,
|
||||
minWidth: `${column.basisWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
export function tableStyle(minWidth?: number | string) {
|
||||
if (minWidth === undefined) {
|
||||
export function stickyStyle<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
if (!column.sticky)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return { width: `max(100%, ${normalizeWidth(minWidth)})` }
|
||||
return { [column.sticky]: `${column.stickyOffset ?? 0}px` }
|
||||
}
|
||||
|
||||
export function alignClass<Row extends TableRow>(column: BaseTableColumn<Row>) {
|
||||
if (column.align === 'center') {
|
||||
export function alignClass<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
if (column.align === 'center')
|
||||
return 'text-center'
|
||||
}
|
||||
|
||||
if (column.align === 'right') {
|
||||
if (column.align === 'right')
|
||||
return 'text-right'
|
||||
}
|
||||
|
||||
return 'text-left'
|
||||
}
|
||||
|
||||
@@ -125,21 +248,15 @@ export function cellDisplayValue<Row extends TableRow>(column: BaseTableColumn<R
|
||||
return isEmptyCellValue(value) ? (column.emptyText ?? '—') : value
|
||||
}
|
||||
|
||||
export function cellTitle<Row extends TableRow>(column: BaseTableColumn<Row>, row: Row) {
|
||||
if (column.ellipsis === false || column.key === 'selection' || column.key === 'actions') {
|
||||
export function cellTitle<Row extends TableRow>(column: ResolvedTableColumn<Row>, row: Row) {
|
||||
if (!column.truncate)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const value = cellDisplayValue(column, row)
|
||||
if (isEmptyCellValue(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return typeof value === 'string' || typeof value === 'number' ? String(value) : undefined
|
||||
}
|
||||
|
||||
export function cellContentClass<Row extends TableRow>(column: BaseTableColumn<Row>) {
|
||||
if (column.key === 'selection') {
|
||||
export function cellContentClass<Row extends TableRow>(column: ResolvedTableColumn<Row>) {
|
||||
if (column.kind === 'selection' || column.kind === 'expander') {
|
||||
return [
|
||||
'flex min-w-0 items-center overflow-visible leading-none',
|
||||
column.align === 'right'
|
||||
@@ -149,36 +266,11 @@ export function cellContentClass<Row extends TableRow>(column: BaseTableColumn<R
|
||||
: 'justify-start',
|
||||
]
|
||||
}
|
||||
|
||||
if (column.key === 'actions') {
|
||||
if (column.kind === 'actions')
|
||||
return 'min-w-0 overflow-visible'
|
||||
}
|
||||
|
||||
return ['min-w-0', column.ellipsis === false ? undefined : 'truncate']
|
||||
return ['min-w-0', column.truncate ? 'truncate' : undefined]
|
||||
}
|
||||
|
||||
function numericPercentWidth(width: number | string) {
|
||||
if (typeof width === 'number') {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (width.endsWith('%')) {
|
||||
const parsed = Number.parseFloat(width)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function numericPixelWidth(width: number | string) {
|
||||
if (typeof width === 'number') {
|
||||
return width
|
||||
}
|
||||
|
||||
if (width.endsWith('px')) {
|
||||
const parsed = Number.parseFloat(width)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
return 0
|
||||
export function columnSortKey<Row extends TableRow>(column: BaseTableColumn<Row>) {
|
||||
return typeof column.sortable === 'string' ? column.sortable : column.key
|
||||
}
|
||||
|
||||
@@ -1,154 +1,111 @@
|
||||
<script setup lang="ts" generic="Row extends object = Record<string, unknown>">
|
||||
import type { BaseTableColumn, BaseTableSort, ResolvedTableColumn } from './columns'
|
||||
import type { BaseTablePagination as BaseTablePaginationConfig } from './pagination'
|
||||
import type { BaseTableProps, BaseTableSort, ResolvedTableColumn } from './columns'
|
||||
|
||||
import { Triangle } from '@lucide/vue'
|
||||
import { computed, shallowRef, useSlots, useTemplateRef, watch } from 'vue'
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, shallowRef, useSlots, useTemplateRef, watch } from 'vue'
|
||||
import BaseEmpty from '../BaseEmpty.vue'
|
||||
import BaseScrollbar from '../BaseScrollbar.vue'
|
||||
import BaseTablePagination from './BaseTablePagination.vue'
|
||||
import {
|
||||
alignClass,
|
||||
|
||||
cellContentClass,
|
||||
cellDisplayValue,
|
||||
cellTitle,
|
||||
cellValue,
|
||||
columnSortKey,
|
||||
columnStyle,
|
||||
resolveColumns,
|
||||
|
||||
tableStyle as resolveTableStyle,
|
||||
stickyStyle,
|
||||
tableStyle,
|
||||
} from './columns'
|
||||
import { useHorizontalStickyShadow } from './useHorizontalStickyShadow'
|
||||
|
||||
type RowKey = string | ((row: Row, index: number) => string | number)
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
columns: BaseTableColumn<Row>[]
|
||||
rows: Row[]
|
||||
rowKey?: RowKey
|
||||
selectedRowKeys?: Array<string | number>
|
||||
stripe?: boolean
|
||||
compact?: boolean
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
maxHeight?: string
|
||||
minWidth?: number | string
|
||||
pagination?: BaseTablePaginationConfig
|
||||
expandedRowKeys?: Array<string | number>
|
||||
sort?: BaseTableSort
|
||||
}>(),
|
||||
{
|
||||
rowKey: 'id',
|
||||
selectedRowKeys: () => [],
|
||||
expandedRowKeys: () => [],
|
||||
stripe: true,
|
||||
compact: false,
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
maxHeight: undefined,
|
||||
minWidth: undefined,
|
||||
},
|
||||
)
|
||||
const props = withDefaults(defineProps<BaseTableProps<Row>>(), {
|
||||
rowKey: 'id',
|
||||
selectedRowKeys: () => [],
|
||||
expandedRowKeys: () => [],
|
||||
density: 'default',
|
||||
loading: false,
|
||||
emptyText: '暂无数据',
|
||||
sort: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
pageChange: [page: number]
|
||||
pageSizeChange: [pageSize: number]
|
||||
sortChange: [sort: BaseTableSort | undefined]
|
||||
}>()
|
||||
const slots = useSlots()
|
||||
const computedColumns = computed(() => resolveColumns(props.columns))
|
||||
const resolvedTableStyle = computed(() => tableStyle(computedColumns.value))
|
||||
|
||||
const headerRowClass = computed(() => [
|
||||
props.compact ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
|
||||
'font-bold text-(--cp-text-muted)',
|
||||
])
|
||||
const bodyRowClass = computed(() => (props.compact ? 'h-10' : 'h-13'))
|
||||
const headerCellClass = computed(() => [
|
||||
'min-w-0 bg-(--cp-bg-subtle) first:pl-3 shadow-[0_10px_16px_-18px_#0e172638]',
|
||||
props.compact ? 'px-3' : 'px-4',
|
||||
])
|
||||
const bodyCellClass = computed(() => [
|
||||
'min-w-0 first:pl-3 text-(--cp-text-primary) first:rounded-l-lg',
|
||||
props.compact ? 'px-3 text-[12px]' : 'px-4 text-[13px]',
|
||||
])
|
||||
|
||||
const computedColumns = computed(() => resolveColumns(props.columns, props.minWidth))
|
||||
const tableViewStyle = computed(() => resolveTableStyle(props.minWidth))
|
||||
const retainedRows = shallowRef<Row[]>([])
|
||||
watch(
|
||||
[() => props.rows, () => props.loading],
|
||||
([rows, loading]) => {
|
||||
if (rows.length > 0 || !loading) {
|
||||
if (rows.length > 0 || !loading)
|
||||
retainedRows.value = rows
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const displayRows = computed(() =>
|
||||
props.loading && props.rows.length === 0 ? retainedRows.value : props.rows,
|
||||
)
|
||||
const displayRows = computed(() => props.loading && props.rows.length === 0 ? retainedRows.value : props.rows)
|
||||
const hasRows = computed(() => displayRows.value.length > 0)
|
||||
const headerWrapRef = useTemplateRef<HTMLDivElement>('headerWrap')
|
||||
const bodyScrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('bodyScrollbar')
|
||||
const tableViewRef = useTemplateRef<HTMLTableElement>('tableView')
|
||||
const { horizontalScrolled, horizontalCanScrollRight, handleTableScroll }
|
||||
= useHorizontalStickyShadow({
|
||||
hasRows,
|
||||
headerWrapRef,
|
||||
bodyScrollbarRef,
|
||||
tableViewRef,
|
||||
watchSources: () => [displayRows.value.length, props.minWidth],
|
||||
})
|
||||
|
||||
function fixedHeaderClass(column: ResolvedTableColumn<Row>) {
|
||||
if (!column.fixed) {
|
||||
return undefined
|
||||
const scrollbarRef = useTemplateRef<InstanceType<typeof BaseScrollbar>>('scrollbar')
|
||||
const tableRef = useTemplateRef<HTMLTableElement>('table')
|
||||
const horizontalScrolled = shallowRef(false)
|
||||
const horizontalCanScrollRight = shallowRef(false)
|
||||
|
||||
function measureHorizontalScroll() {
|
||||
const wrap = scrollbarRef.value?.wrapRef
|
||||
if (!wrap) {
|
||||
horizontalScrolled.value = false
|
||||
horizontalCanScrollRight.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const showShadow
|
||||
= column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
|
||||
|
||||
return [
|
||||
'sticky z-30 bg-(--cp-bg-subtle)',
|
||||
column.fixed === 'left' ? 'left-0' : 'right-0',
|
||||
showShadow
|
||||
? column.fixed === 'left'
|
||||
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: undefined,
|
||||
]
|
||||
const range = Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
|
||||
horizontalScrolled.value = wrap.scrollLeft > 1
|
||||
horizontalCanScrollRight.value = wrap.scrollLeft < range - 1
|
||||
}
|
||||
|
||||
function fixedBodyClass(column: ResolvedTableColumn<Row>, row: Row, index: number) {
|
||||
if (!column.fixed) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const showShadow
|
||||
= column.fixed === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
|
||||
|
||||
return [
|
||||
'sticky z-20',
|
||||
column.fixed === 'left' ? 'left-0' : 'right-0',
|
||||
showShadow
|
||||
? column.fixed === 'left'
|
||||
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: undefined,
|
||||
isRowSelected(row, index)
|
||||
? 'bg-(--cp-bg-tertiary)'
|
||||
: props.stripe && index % 2 === 1
|
||||
? 'bg-(--cp-bg-subtle)'
|
||||
: 'bg-(--cp-bg-surface)',
|
||||
]
|
||||
function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
|
||||
const wrap = scrollbarRef.value?.wrapRef
|
||||
if (!wrap)
|
||||
return
|
||||
const range = Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
|
||||
horizontalScrolled.value = payload.scrollLeft > 1
|
||||
horizontalCanScrollRight.value = payload.scrollLeft < range - 1
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScroll()
|
||||
})
|
||||
useResizeObserver(() => [scrollbarRef.value?.wrapRef, tableRef.value].filter(Boolean), measureHorizontalScroll)
|
||||
watch([() => displayRows.value.length, () => props.columns], async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScroll()
|
||||
})
|
||||
|
||||
const headerRowClass = computed(() => [
|
||||
props.density === 'compact' ? 'h-8 text-[11px]' : 'h-10 text-[12px]',
|
||||
'font-bold text-cp-secondary',
|
||||
])
|
||||
const bodyRowClass = computed(() =>
|
||||
props.density === 'compact' ? 'h-cp-table-row-min-compact' : 'h-cp-table-row-min',
|
||||
)
|
||||
const cellPaddingClass = computed(() => props.density === 'compact' ? 'px-3' : 'px-4')
|
||||
const bodyTextClass = computed(() => props.density === 'compact' ? 'text-[12px]' : 'text-[13px]')
|
||||
const bodyCellFrameClass = computed(() => props.density === 'compact'
|
||||
? 'border-y-2 border-transparent bg-clip-padding'
|
||||
: 'border-y-[3px] border-transparent bg-clip-padding')
|
||||
const firstRowTopGapClass = computed(() =>
|
||||
props.density === 'compact' ? 'border-t-4' : 'border-t-[6px]',
|
||||
)
|
||||
const bodyCellContentClass = computed(() => props.density === 'compact'
|
||||
? 'min-h-[calc(var(--cp-table-row-min-height-compact)-4px)]'
|
||||
: 'min-h-[calc(var(--cp-table-row-min-height)-6px)]')
|
||||
|
||||
function getRowKey(row: Row, index: number) {
|
||||
if (typeof props.rowKey === 'function') {
|
||||
if (typeof props.rowKey === 'function')
|
||||
return props.rowKey(row, index)
|
||||
}
|
||||
|
||||
const value = cellValue(row, props.rowKey)
|
||||
return typeof value === 'string' || typeof value === 'number' ? value : index
|
||||
}
|
||||
@@ -161,46 +118,56 @@ function isRowExpanded(row: Row, index: number) {
|
||||
return props.expandedRowKeys.includes(getRowKey(row, index))
|
||||
}
|
||||
|
||||
function rowClass(row: Row, index: number) {
|
||||
function rowBackgroundClass(row: Row, index: number) {
|
||||
if (isRowSelected(row, index))
|
||||
return 'bg-cp-tertiary-bg'
|
||||
if (index % 2 === 1)
|
||||
return 'bg-cp-subtle'
|
||||
return 'bg-cp-surface'
|
||||
}
|
||||
|
||||
function rowClass() {
|
||||
return [
|
||||
bodyRowClass.value,
|
||||
'hover:[&>td]:bg-(--cp-default-bg-hover)',
|
||||
props.stripe && index % 2 === 1 ? 'bg-(--cp-bg-subtle)' : undefined,
|
||||
isRowSelected(row, index) ? 'bg-(--cp-bg-tertiary)' : undefined,
|
||||
'hover:[&>td]:bg-cp-default-hover',
|
||||
]
|
||||
}
|
||||
|
||||
function isLastColumn(index: number) {
|
||||
return index === computedColumns.value.length - 1
|
||||
function stickyClass(column: ResolvedTableColumn<Row>, header = false) {
|
||||
if (!column.sticky)
|
||||
return undefined
|
||||
const showShadow = column.sticky === 'left' ? horizontalScrolled.value : horizontalCanScrollRight.value
|
||||
return [
|
||||
'sticky',
|
||||
header ? 'z-40' : 'z-20',
|
||||
showShadow
|
||||
? column.sticky === 'left'
|
||||
? 'shadow-[8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: 'shadow-[-8px_0_14px_-14px_var(--cp-shadow-sticky)]'
|
||||
: undefined,
|
||||
]
|
||||
}
|
||||
|
||||
function bodyCellTitle(column: BaseTableColumn<Row>, row: Row) {
|
||||
function bodyCellTitle(column: ResolvedTableColumn<Row>, row: Row) {
|
||||
return slots[column.key] ? undefined : cellTitle(column, row)
|
||||
}
|
||||
|
||||
function columnSortKey(column: BaseTableColumn<Row>) {
|
||||
return column.sortKey || column.key
|
||||
}
|
||||
|
||||
function columnSortDirection(column: BaseTableColumn<Row>) {
|
||||
function columnSortDirection(column: ResolvedTableColumn<Row>) {
|
||||
return props.sort?.key === columnSortKey(column) ? props.sort.direction : undefined
|
||||
}
|
||||
|
||||
function toggleColumnSort(column: BaseTableColumn<Row>) {
|
||||
function toggleColumnSort(column: ResolvedTableColumn<Row>) {
|
||||
const key = columnSortKey(column)
|
||||
const direction = columnSortDirection(column)
|
||||
if (!direction) {
|
||||
if (!direction)
|
||||
emit('sortChange', { key, direction: 'asc' })
|
||||
}
|
||||
else if (direction === 'asc') {
|
||||
else if (direction === 'asc')
|
||||
emit('sortChange', { key, direction: 'desc' })
|
||||
}
|
||||
else {
|
||||
else
|
||||
emit('sortChange', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function columnAriaSort(column: BaseTableColumn<Row>) {
|
||||
function columnAriaSort(column: ResolvedTableColumn<Row>) {
|
||||
const direction = columnSortDirection(column)
|
||||
if (direction === 'asc')
|
||||
return 'ascending'
|
||||
@@ -209,7 +176,7 @@ function columnAriaSort(column: BaseTableColumn<Row>) {
|
||||
return column.sortable ? 'none' : undefined
|
||||
}
|
||||
|
||||
function sortButtonLabel(column: BaseTableColumn<Row>) {
|
||||
function sortButtonLabel(column: ResolvedTableColumn<Row>) {
|
||||
const direction = columnSortDirection(column)
|
||||
if (!direction)
|
||||
return `${column.label || column.key}:升序排列`
|
||||
@@ -221,126 +188,101 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 w-full max-w-full flex-col overflow-hidden">
|
||||
<div
|
||||
v-loading="loading"
|
||||
class="relative flex min-h-0 max-w-full flex-1 overflow-hidden pb-3"
|
||||
>
|
||||
<div class="flex min-h-0 max-w-full flex-1 flex-col overflow-hidden">
|
||||
<div ref="headerWrap" class="max-w-full overflow-hidden">
|
||||
<table
|
||||
class="w-full shrink-0 table-fixed border-separate text-left"
|
||||
:class="compact ? 'border-spacing-y-0.5' : 'border-spacing-y-1'"
|
||||
:style="tableViewStyle"
|
||||
>
|
||||
<colgroup>
|
||||
<col
|
||||
v-for="column in computedColumns"
|
||||
:key="column.key"
|
||||
:style="columnStyle(column)"
|
||||
>
|
||||
</colgroup>
|
||||
|
||||
<thead>
|
||||
<tr :class="headerRowClass">
|
||||
<th
|
||||
v-for="(column, index) in computedColumns"
|
||||
:key="column.key"
|
||||
class="bg-(--cp-bg-subtle)"
|
||||
:class="[
|
||||
headerCellClass,
|
||||
alignClass(column),
|
||||
isLastColumn(index) ? 'rounded-r-lg pr-3' : undefined,
|
||||
fixedHeaderClass(column),
|
||||
column.headerClass,
|
||||
]"
|
||||
scope="col"
|
||||
:aria-sort="columnAriaSort(column)"
|
||||
>
|
||||
<div :class="cellContentClass(column)">
|
||||
<button
|
||||
v-if="column.sortable"
|
||||
type="button"
|
||||
class="inline-flex max-w-full cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-(--cp-text-primary) focus-visible:text-(--cp-info)"
|
||||
:aria-label="sortButtonLabel(column)"
|
||||
:title="sortButtonLabel(column)"
|
||||
@click="toggleColumnSort(column)"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot :name="`header-${column.key}`" :column="column">
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</span>
|
||||
<span
|
||||
class="inline-flex shrink-0 -translate-y-px flex-col gap-px"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Triangle
|
||||
class="size-1.25 fill-current"
|
||||
:class="
|
||||
columnSortDirection(column) === 'asc'
|
||||
? 'text-(--cp-info)'
|
||||
: 'text-(--cp-text-tertiary)'
|
||||
"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
<Triangle
|
||||
class="size-1.25 rotate-180 fill-current"
|
||||
:class="
|
||||
columnSortDirection(column) === 'desc'
|
||||
? 'text-(--cp-info)'
|
||||
: 'text-(--cp-text-tertiary)'
|
||||
"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<slot v-else :name="`header-${column.key}`" :column="column">
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<BaseScrollbar
|
||||
v-if="hasRows"
|
||||
ref="bodyScrollbar"
|
||||
class="min-h-0 flex-1"
|
||||
horizontal
|
||||
:max-height="maxHeight"
|
||||
track-inset="none"
|
||||
@scroll="handleTableScroll"
|
||||
<div v-loading="loading" class="relative flex min-h-0 max-w-full flex-1 overflow-hidden">
|
||||
<BaseScrollbar
|
||||
v-if="hasRows"
|
||||
ref="scrollbar"
|
||||
class="min-h-0 flex-1"
|
||||
:class="density === 'compact'
|
||||
? '[--cp-scrollbar-track-top:2.25rem]'
|
||||
: '[--cp-scrollbar-track-top:2.75rem]'"
|
||||
horizontal
|
||||
@scroll="handleTableScroll"
|
||||
>
|
||||
<table
|
||||
ref="table"
|
||||
class="table-fixed border-separate border-spacing-0 text-left"
|
||||
:style="resolvedTableStyle"
|
||||
>
|
||||
<table
|
||||
ref="tableView"
|
||||
class="w-full table-fixed border-separate text-left"
|
||||
:class="compact ? 'border-spacing-y-1' : 'border-spacing-y-2'"
|
||||
:style="tableViewStyle"
|
||||
>
|
||||
<colgroup>
|
||||
<col
|
||||
v-for="column in computedColumns"
|
||||
<colgroup>
|
||||
<col
|
||||
v-for="column in computedColumns"
|
||||
:key="column.key"
|
||||
:style="columnStyle(column, computedColumns)"
|
||||
>
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr :class="headerRowClass">
|
||||
<th
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
:style="columnStyle(column)"
|
||||
class="sticky top-0 z-30 whitespace-nowrap bg-cp-muted shadow-[0_10px_16px_-18px_var(--cp-shadow-sticky)]"
|
||||
:class="[
|
||||
column.paddingClass ?? cellPaddingClass,
|
||||
alignClass(column),
|
||||
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
|
||||
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control' : undefined,
|
||||
columnIndex === computedColumns.length - 1 ? 'pr-6' : undefined,
|
||||
stickyClass(column, true),
|
||||
]"
|
||||
:style="stickyStyle(column)"
|
||||
scope="col"
|
||||
:aria-sort="columnAriaSort(column)"
|
||||
>
|
||||
</colgroup>
|
||||
|
||||
<tbody>
|
||||
<template v-for="(row, index) in displayRows" :key="getRowKey(row, index)">
|
||||
<tr :class="rowClass(row, index)">
|
||||
<td
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
bodyCellClass,
|
||||
alignClass(column),
|
||||
isLastColumn(columnIndex) ? 'rounded-r-lg pr-3' : undefined,
|
||||
fixedBodyClass(column, row, index),
|
||||
column.cellClass,
|
||||
]"
|
||||
<div :class="cellContentClass(column)">
|
||||
<button
|
||||
v-if="column.sortable"
|
||||
type="button"
|
||||
class="inline-flex max-w-full touch-manipulation items-center gap-1 border-0 bg-transparent p-0 text-inherit outline-none transition-colors hover:text-cp-primary focus-visible:text-cp-accent motion-reduce:transition-none"
|
||||
:aria-label="sortButtonLabel(column)"
|
||||
:title="sortButtonLabel(column)"
|
||||
@click="toggleColumnSort(column)"
|
||||
>
|
||||
<span class="truncate">
|
||||
<slot :name="`header-${column.key}`" :column="column">{{ column.label }}</slot>
|
||||
</span>
|
||||
<span class="inline-flex shrink-0 -translate-y-px flex-col gap-px" aria-hidden="true">
|
||||
<Triangle
|
||||
class="size-1.25 fill-current"
|
||||
:class="columnSortDirection(column) === 'asc' ? 'text-cp-accent' : 'text-cp-tertiary'"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
<Triangle
|
||||
class="size-1.25 rotate-180 fill-current"
|
||||
:class="columnSortDirection(column) === 'desc' ? 'text-cp-accent' : 'text-cp-tertiary'"
|
||||
:stroke-width="0"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
<slot v-else :name="`header-${column.key}`" :column="column">
|
||||
{{ column.label }}
|
||||
</slot>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(row, index) in displayRows" :key="getRowKey(row, index)">
|
||||
<tr :class="rowClass()" :aria-selected="isRowSelected(row, index) || undefined">
|
||||
<td
|
||||
v-for="(column, columnIndex) in computedColumns"
|
||||
:key="column.key"
|
||||
class="min-w-0"
|
||||
:class="[
|
||||
column.paddingClass ?? cellPaddingClass,
|
||||
bodyTextClass,
|
||||
bodyCellFrameClass,
|
||||
column.contentClass,
|
||||
alignClass(column),
|
||||
index === 0 ? firstRowTopGapClass : undefined,
|
||||
columnIndex === 0 ? 'rounded-l-cp-control' : undefined,
|
||||
columnIndex === computedColumns.length - 1 ? 'rounded-r-cp-control pr-6' : undefined,
|
||||
stickyClass(column),
|
||||
rowBackgroundClass(row, index),
|
||||
]"
|
||||
:style="stickyStyle(column)"
|
||||
>
|
||||
<div class="grid content-center" :class="bodyCellContentClass">
|
||||
<div :class="cellContentClass(column)" :title="bodyCellTitle(column, row)">
|
||||
<slot
|
||||
:name="column.key"
|
||||
@@ -352,33 +294,25 @@ function sortButtonLabel(column: BaseTableColumn<Row>) {
|
||||
{{ cellDisplayValue(column, row) }}
|
||||
</slot>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="isRowExpanded(row, index)">
|
||||
<td
|
||||
:colspan="computedColumns.length"
|
||||
class="rounded-(--cp-input-radius-base) bg-(--cp-info-bg) p-0"
|
||||
>
|
||||
<slot name="expanded" :row="row" :index="index" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</BaseScrollbar>
|
||||
|
||||
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
|
||||
<BaseEmpty v-if="!loading" :title="emptyText" plain class="w-full max-w-80" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="isRowExpanded(row, index)">
|
||||
<td
|
||||
:colspan="computedColumns.length"
|
||||
class="rounded-cp-control border-y-transparent bg-cp-subtle bg-clip-padding p-0"
|
||||
:class="bodyCellFrameClass"
|
||||
>
|
||||
<slot name="expanded" :row="row" :index="index" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</BaseScrollbar>
|
||||
<div v-else class="grid min-h-0 flex-1 place-items-center overflow-hidden px-4">
|
||||
<BaseEmpty v-if="!loading" :title="emptyText" surface="none" class="w-full max-w-80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseTablePagination
|
||||
v-if="pagination"
|
||||
:pagination="pagination"
|
||||
:loading="loading"
|
||||
@page-change="emit('pageChange', $event)"
|
||||
@page-size-change="emit('pageSizeChange', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type BaseScrollbar from '../BaseScrollbar.vue'
|
||||
|
||||
import { useResizeObserver } from '@vueuse/core'
|
||||
import { nextTick, onMounted, shallowRef, watch } from 'vue'
|
||||
|
||||
interface UseHorizontalStickyShadowOptions {
|
||||
hasRows: Ref<boolean>
|
||||
headerWrapRef: Readonly<Ref<HTMLDivElement | null>>
|
||||
bodyScrollbarRef: Readonly<Ref<InstanceType<typeof BaseScrollbar> | null>>
|
||||
tableViewRef: Readonly<Ref<HTMLTableElement | null>>
|
||||
watchSources: () => unknown[]
|
||||
}
|
||||
|
||||
export function useHorizontalStickyShadow(options: UseHorizontalStickyShadowOptions) {
|
||||
const horizontalScrolled = shallowRef(false)
|
||||
const horizontalCanScrollRight = shallowRef(false)
|
||||
let horizontalScrollRange = 0
|
||||
let scrollLeftPosition = 0
|
||||
|
||||
function maxScrollLeft(wrap: HTMLElement) {
|
||||
return Math.max(wrap.scrollWidth - wrap.clientWidth, 0)
|
||||
}
|
||||
|
||||
function scrollWrap() {
|
||||
return options.bodyScrollbarRef.value?.wrapRef ?? null
|
||||
}
|
||||
|
||||
function resetHorizontalScrollbar() {
|
||||
horizontalScrollRange = 0
|
||||
scrollLeftPosition = 0
|
||||
horizontalScrolled.value = false
|
||||
horizontalCanScrollRight.value = false
|
||||
syncHeaderScroll(0)
|
||||
}
|
||||
|
||||
function syncHeaderScroll(scrollLeft: number) {
|
||||
const headerWrap = options.headerWrapRef.value
|
||||
if (headerWrap && headerWrap.scrollLeft !== scrollLeft) {
|
||||
headerWrap.scrollLeft = scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
function updateHorizontalScrollPosition(scrollLeft: number) {
|
||||
horizontalScrolled.value = scrollLeft > 0
|
||||
horizontalCanScrollRight.value = scrollLeft < horizontalScrollRange - 1
|
||||
}
|
||||
|
||||
function measureHorizontalScrollbar() {
|
||||
const wrap = scrollWrap()
|
||||
if (!wrap || !options.hasRows.value) {
|
||||
resetHorizontalScrollbar()
|
||||
return
|
||||
}
|
||||
|
||||
const scrollRange = maxScrollLeft(wrap)
|
||||
if (scrollRange <= 0) {
|
||||
resetHorizontalScrollbar()
|
||||
return
|
||||
}
|
||||
|
||||
horizontalScrollRange = scrollRange
|
||||
scrollLeftPosition = wrap.scrollLeft
|
||||
syncHeaderScroll(scrollLeftPosition)
|
||||
updateHorizontalScrollPosition(scrollLeftPosition)
|
||||
}
|
||||
|
||||
function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
|
||||
if (payload.scrollLeft === scrollLeftPosition) {
|
||||
return
|
||||
}
|
||||
|
||||
scrollLeftPosition = payload.scrollLeft
|
||||
syncHeaderScroll(scrollLeftPosition)
|
||||
updateHorizontalScrollPosition(scrollLeftPosition)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScrollbar()
|
||||
})
|
||||
|
||||
useResizeObserver(
|
||||
() =>
|
||||
[scrollWrap(), options.tableViewRef.value].filter(
|
||||
(element): element is HTMLDivElement | HTMLTableElement => Boolean(element),
|
||||
),
|
||||
measureHorizontalScrollbar,
|
||||
)
|
||||
|
||||
watch(options.watchSources, async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScrollbar()
|
||||
})
|
||||
|
||||
return {
|
||||
horizontalScrolled,
|
||||
horizontalCanScrollRight,
|
||||
handleTableScroll,
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, inject, useAttrs } from 'vue'
|
||||
import { formFieldKey } from './BaseForm/context'
|
||||
|
||||
type TextareaSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
error?: string
|
||||
size?: TextareaSize
|
||||
name?: string
|
||||
ariaLabel?: string
|
||||
rows?: number
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
disabled: false,
|
||||
error: undefined,
|
||||
size: 'md',
|
||||
name: undefined,
|
||||
ariaLabel: undefined,
|
||||
rows: 5,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<string>({ default: '' })
|
||||
const attrs = useAttrs()
|
||||
const field = inject(formFieldKey, null)
|
||||
|
||||
const controlId = computed(() => typeof attrs.id === 'string' ? attrs.id : field?.controlId.value)
|
||||
const invalid = computed(() => Boolean(
|
||||
field?.invalid.value || attrs['aria-invalid'] === true || attrs['aria-invalid'] === 'true',
|
||||
))
|
||||
const describedBy = computed(() => [
|
||||
typeof attrs['aria-describedby'] === 'string' ? attrs['aria-describedby'] : undefined,
|
||||
field?.describedBy.value,
|
||||
].filter(Boolean).join(' ') || undefined)
|
||||
const rootAttrs = computed(() => ({ class: attrs.class, style: attrs.style }))
|
||||
const controlAttrs = computed(() => Object.fromEntries(
|
||||
Object.entries(attrs).filter(([key]) => ![
|
||||
'class',
|
||||
'style',
|
||||
'id',
|
||||
'aria-describedby',
|
||||
'aria-invalid',
|
||||
'aria-required',
|
||||
].includes(key)),
|
||||
))
|
||||
|
||||
const sizeClasses: Record<TextareaSize, string> = {
|
||||
sm: 'h-28',
|
||||
md: 'h-40',
|
||||
lg: 'h-56',
|
||||
sm: 'px-3 py-2.5 text-xs',
|
||||
md: 'px-3.5 py-3 text-[13px]',
|
||||
lg: 'px-4 py-3.5 text-[14px]',
|
||||
}
|
||||
|
||||
const textareaClasses = computed(() => [
|
||||
'cp-scrollbar w-full resize-none rounded-(--cp-input-radius-base) border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 text-(--cp-text-primary) shadow-(--cp-shadow-input) outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-(--cp-text-muted)',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-(--cp-shadow-input-hover) focus:bg-(--cp-input-soft-bg-focus) focus:shadow-(--cp-shadow-input-focus)',
|
||||
'disabled:cursor-not-allowed disabled:bg-(--cp-disabled-bg) disabled:text-(--cp-disabled-text) disabled:shadow-none',
|
||||
'cp-scrollbar w-full resize-y rounded-cp-control border-0 bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] text-cp-primary shadow-cp-input outline-none transition-[background-color,box-shadow,color] duration-160 placeholder:text-cp-muted-text motion-reduce:transition-none',
|
||||
'hover:bg-[var(--cp-input-current-bg-hover,var(--cp-input-context-bg-hover))] hover:shadow-cp-input-hover focus:bg-(--cp-input-soft-bg-focus) focus:shadow-cp-input-focus',
|
||||
'disabled:cursor-not-allowed disabled:bg-cp-disabled disabled:text-cp-disabled-text disabled:shadow-none',
|
||||
'leading-[1.55] font-emphasis',
|
||||
sizeClasses[props.size],
|
||||
'text-[13px] leading-[1.55] font-emphasis',
|
||||
props.error ? 'bg-(--cp-input-error-soft-bg) shadow-(--cp-shadow-input-error)' : undefined,
|
||||
invalid.value ? 'bg-(--cp-input-error-soft-bg) shadow-cp-input-error' : undefined,
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-2">
|
||||
<div v-bind="rootAttrs" class="min-w-0">
|
||||
<textarea
|
||||
v-bind="controlAttrs"
|
||||
:id="controlId"
|
||||
v-model="model"
|
||||
:class="textareaClasses"
|
||||
:name="name"
|
||||
:rows="rows"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel"
|
||||
:aria-invalid="error ? 'true' : undefined"
|
||||
:required="field?.required.value || undefined"
|
||||
:aria-describedby="describedBy"
|
||||
:aria-invalid="invalid || undefined"
|
||||
:aria-required="field?.required.value || undefined"
|
||||
/>
|
||||
<span v-if="error" class="text-xs leading-[1.15] font-emphasis text-(--cp-danger-text)">
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
|
||||
|
||||
import BaseIconButton from '../BaseIconButton.vue'
|
||||
import { toast } from './toast'
|
||||
|
||||
const iconMap = {
|
||||
@@ -19,35 +20,37 @@ const titleMap = {
|
||||
|
||||
const colorClasses = {
|
||||
success: {
|
||||
iconBg: 'bg-(--cp-success-bg)',
|
||||
icon: 'text-(--cp-success)',
|
||||
iconBg: 'bg-cp-success-bg',
|
||||
icon: 'text-cp-success',
|
||||
},
|
||||
error: {
|
||||
iconBg: 'bg-(--cp-danger-bg)',
|
||||
icon: 'text-(--cp-danger)',
|
||||
iconBg: 'bg-cp-danger-bg',
|
||||
icon: 'text-cp-danger',
|
||||
},
|
||||
warning: {
|
||||
iconBg: 'bg-(--cp-warning-bg)',
|
||||
icon: 'text-(--cp-warning)',
|
||||
iconBg: 'bg-cp-warning-bg',
|
||||
icon: 'text-cp-warning',
|
||||
},
|
||||
info: {
|
||||
iconBg: 'bg-(--cp-info-bg)',
|
||||
icon: 'text-(--cp-info)',
|
||||
iconBg: 'bg-cp-info-bg',
|
||||
icon: 'text-cp-info',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="fixed top-6 right-6 z-9999 flex flex-col gap-3 pointer-events-none">
|
||||
<div class="pointer-events-none fixed inset-x-3 top-3 z-9999 flex flex-col items-end gap-3 sm:inset-x-auto sm:top-6 sm:right-6">
|
||||
<TransitionGroup name="toast" tag="div" class="flex flex-col gap-3">
|
||||
<div
|
||||
<article
|
||||
v-for="message in toast.messages"
|
||||
:key="message.id"
|
||||
class="pointer-events-auto flex min-h-16 w-90 items-center gap-3 rounded-(--cp-card-radius) bg-(--cp-bg-surface) px-3.5 py-3 shadow-(--cp-shadow-popover) transition-all"
|
||||
class="pointer-events-auto flex min-h-16 w-90 max-w-[calc(100vw-1.5rem)] items-center gap-3 rounded-cp-surface bg-cp-surface px-3.5 py-3 shadow-cp-popover"
|
||||
:role="message.type === 'error' ? 'alert' : 'status'"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center shrink-0 w-8.5 h-8.5 rounded-(--cp-icon-button-radius)"
|
||||
class="flex size-8.5 shrink-0 items-center justify-center rounded-cp-control"
|
||||
:class="colorClasses[message.type].iconBg"
|
||||
>
|
||||
<component
|
||||
@@ -58,25 +61,25 @@ const colorClasses = {
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-(--cp-text-primary)">
|
||||
<p class="m-0 truncate text-[13px] leading-[1.15] font-bold text-cp-primary">
|
||||
{{ message.title ?? titleMap[message.type] }}
|
||||
</p>
|
||||
<p
|
||||
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-(--cp-text-secondary)"
|
||||
class="m-0 max-h-8 overflow-hidden text-xs leading-tight font-semibold text-cp-secondary"
|
||||
>
|
||||
{{ message.message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center shrink-0 p-0 border-0 bg-transparent w-7 h-7 rounded-(--cp-button-radius-base) cursor-pointer opacity-60 transition-opacity hover:bg-(--cp-bg-subtle) hover:opacity-100"
|
||||
:aria-label="`关闭${message.title ?? titleMap[message.type]}通知`"
|
||||
<BaseIconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
:label="`关闭${message.title ?? titleMap[message.type]}通知`"
|
||||
@click="toast.remove(message.id)"
|
||||
>
|
||||
<X :size="16" class="text-(--cp-text-muted)" />
|
||||
</button>
|
||||
</div>
|
||||
<X :size="16" class="text-cp-muted-text" />
|
||||
</BaseIconButton>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -91,6 +94,13 @@ const colorClasses = {
|
||||
animation: toast-out 0.2s ease-in;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,14 +1,57 @@
|
||||
// ECharts tooltip 回调参数的通用安全取值:参数形状不受控(数组/对象混合),
|
||||
// 统一在此收敛类型判断。
|
||||
import { isRecord } from '@/utils/object'
|
||||
|
||||
interface ChartTooltipTheme {
|
||||
surface: string
|
||||
textPrimary: string
|
||||
pointer: string
|
||||
}
|
||||
|
||||
interface ChartTooltipStyleOptions {
|
||||
axisPointer?: boolean
|
||||
confine?: boolean
|
||||
padding?: [number, number]
|
||||
fontFamily?: string
|
||||
fontWeight?: number
|
||||
}
|
||||
|
||||
export function chartTooltipStyle(
|
||||
theme: ChartTooltipTheme,
|
||||
options: ChartTooltipStyleOptions = {},
|
||||
) {
|
||||
const base = {
|
||||
confine: options.confine,
|
||||
backgroundColor: theme.surface,
|
||||
borderColor: 'transparent',
|
||||
borderWidth: 0,
|
||||
padding: options.padding ?? [10, 14],
|
||||
textStyle: {
|
||||
color: theme.textPrimary,
|
||||
fontSize: 12,
|
||||
fontFamily: options.fontFamily ?? 'Inter Variable, Inter, system-ui, sans-serif',
|
||||
fontWeight: options.fontWeight ?? 650,
|
||||
},
|
||||
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
|
||||
}
|
||||
|
||||
if (!options.axisPointer)
|
||||
return base
|
||||
|
||||
return {
|
||||
...base,
|
||||
axisPointer: {
|
||||
type: 'line' as const,
|
||||
lineStyle: { color: theme.pointer, type: 'dashed' as const, width: 1 },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function tooltipRows(params: unknown): Record<string, unknown>[] {
|
||||
const values = Array.isArray(params) ? params : [params]
|
||||
return values.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === 'object' && value !== null,
|
||||
)
|
||||
return values.filter(isRecord)
|
||||
}
|
||||
|
||||
export function tooltipIndex(source: unknown) {
|
||||
if (typeof source !== 'object' || source === null || !('dataIndex' in source))
|
||||
if (!isRecord(source))
|
||||
return -1
|
||||
return typeof source.dataIndex === 'number' ? source.dataIndex : -1
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { shallowRef } from 'vue'
|
||||
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { withMinimumDuration } from '@/utils/async'
|
||||
import { errorMessage, withMinimumDuration } from '@/utils/async'
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>
|
||||
type AsyncActionErrorText = string | false | ((error: unknown) => string | false)
|
||||
@@ -13,26 +13,13 @@ interface AsyncActionRunOptions {
|
||||
rethrow?: boolean
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && 'message' in error) {
|
||||
const message = (error as { message?: unknown }).message
|
||||
return typeof message === 'string' ? message : ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function resolveErrorText(error: unknown, errorText: AsyncActionErrorText | undefined) {
|
||||
const fallback = typeof errorText === 'function' ? errorText(error) : errorText
|
||||
if (fallback === false) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return errorMessage(error) || fallback || ''
|
||||
return errorMessage(error, '') || fallback || ''
|
||||
}
|
||||
|
||||
export function useAsyncAction() {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user