Compare commits

..
10 Commits
Author SHA1 Message Date
ZyyandCodex cf0b14c5fd chore: bump version to 3.2.10
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:40:35 +08:00
ZyyandCodex 056561de41 refactor(frontend): clarify component boundaries
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 15:33:36 +08:00
dependabot[bot] f2215df72e chore(deps): bump the minor-and-patch group in /frontend with 4 updates
Bumps the minor-and-patch group in /frontend with 4 updates: [@lucide/vue](https://github.com/lucide-icons/lucide/tree/HEAD/packages/vue), [pinia](https://github.com/vuejs/pinia), [eslint-plugin-vuejs-accessibility](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility) and [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc).


Updates `@lucide/vue` from 1.30.0 to 1.31.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.31.0/packages/vue)

Updates `pinia` from 4.0.2 to 4.0.3
- [Release notes](https://github.com/vuejs/pinia/releases)
- [Commits](https://github.com/vuejs/pinia/compare/v4.0.2...v4.0.3)

Updates `eslint-plugin-vuejs-accessibility` from 2.5.0 to 2.6.0
- [Release notes](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/releases)
- [Changelog](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/compare/v2.5.0...v2.6.0)

Updates `vue-tsc` from 3.3.9 to 3.3.10
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v3.3.10/packages/tsc)

---
updated-dependencies:
- dependency-name: "@lucide/vue"
  dependency-version: 1.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pinia
  dependency-version: 4.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: eslint-plugin-vuejs-accessibility
  dependency-version: 2.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vue-tsc
  dependency-version: 3.3.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 72f8957163 chore(deps): bump the minor-and-patch group in /backend with 5 updates
Bumps the minor-and-patch group in /backend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [thiserror](https://github.com/dtolnay/thiserror) | `2.0.19` | `2.0.20` |
| [async-trait](https://github.com/dtolnay/async-trait) | `0.1.91` | `0.1.92` |
| [futures](https://github.com/rust-lang/futures-rs) | `0.3.33` | `0.3.34` |
| [aws-smithy-types](https://github.com/smithy-lang/smithy-rs) | `1.6.1` | `1.6.2` |
| [cookie](https://github.com/SergioBenitez/cookie-rs) | `0.18.1` | `0.18.2` |


Updates `thiserror` from 2.0.19 to 2.0.20
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.19...2.0.20)

Updates `async-trait` from 0.1.91 to 0.1.92
- [Release notes](https://github.com/dtolnay/async-trait/releases)
- [Commits](https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92)

Updates `futures` from 0.3.33 to 0.3.34
- [Release notes](https://github.com/rust-lang/futures-rs/releases)
- [Changelog](https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34)

Updates `aws-smithy-types` from 1.6.1 to 1.6.2
- [Release notes](https://github.com/smithy-lang/smithy-rs/releases)
- [Changelog](https://github.com/smithy-lang/smithy-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/smithy-lang/smithy-rs/commits)

Updates `cookie` from 0.18.1 to 0.18.2
- [Changelog](https://github.com/rwf2/cookie-rs/blob/0.18.2/CHANGELOG.md)
- [Commits](https://github.com/SergioBenitez/cookie-rs/compare/0.18.1...0.18.2)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: async-trait
  dependency-version: 0.1.92
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: futures
  dependency-version: 0.3.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: aws-smithy-types
  dependency-version: 1.6.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: cookie
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
dependabot[bot] 73f0c15790 chore(deps): bump github/codeql-action/upload-sarif
Bumps the minor-and-patch group with 1 update: [github/codeql-action/upload-sarif](https://github.com/github/codeql-action).


Updates `github/codeql-action/upload-sarif` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/upload-sarif
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 14:05:19 +08:00
ZyyandCodex 0558693949 fix(openai): harden refresh recovery handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 13:37:54 +08:00
ZyyandCodex 2a9cb02cc9 fix(openai): align OAuth refresh error handling
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 12:30:05 +08:00
ZyyandCodex 5ce36931a8 chore: bump version to 3.2.9
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:38:49 +08:00
ZyyandCodex 0d826d9eba fix(openai): align OAuth token refresh
Co-authored-by: Codex <noreply@openai.com>
2026-08-17 11:14:03 +08:00
ZyyandCodex 304242957a fix(accounts): preserve upstream error messages
Co-authored-by: Codex <noreply@openai.com>
2026-08-16 13:28:28 +08:00
111 changed files with 4020 additions and 2425 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
${{ hashFiles('trivy-results.sarif') != '' &&
(github.event_name != 'pull_request' ||
github.event.pull_request.head.repo.full_name == github.repository) }}
uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/upload-sarif@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
sarif_file: trivy-results.sarif
category: runtime-container
+29 -29
View File
@@ -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",
@@ -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",
@@ -3633,18 +3633,18 @@ dependencies = [
[[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();
@@ -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,
@@ -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
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,111 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
database.close().await;
}
#[tokio::test]
async fn quota_observation_touch_only_advances_the_refresh_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(30))
.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, 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 +401,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 +1350,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 +1395,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 +1407,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
+29 -17
View File
@@ -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",
}
}
@@ -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> {
@@ -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))
}
}
@@ -19,8 +19,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::{
@@ -646,7 +646,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(());
}
@@ -930,8 +948,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 +1066,7 @@ impl CodexCredentialQuotaService {
}
}
fn quota_refresh_can_update(
fn quota_refresh_allows_recovery(
account: &ProviderAccount,
refreshed: &CodexAccountQuotaSnapshot,
previous: Option<&CodexAccountQuotaSnapshot>,
@@ -151,6 +151,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 {
@@ -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(&current.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,
}
}
}
@@ -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;
@@ -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()),
@@ -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)),
})
}
}
@@ -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());
}
}
@@ -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"))
@@ -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,
+7 -5
View File
@@ -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));
}
+42 -9
View File
@@ -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,
+4 -4
View File
@@ -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"
}
}
+33 -33
View File
@@ -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):
-176
View File
@@ -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,
}
}
@@ -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>
+3 -1
View File
@@ -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,8 +18,8 @@ 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>
@@ -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')
@@ -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))
}
@@ -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>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import BaseButton from './BaseButton.vue'
import BaseModal from './BaseModal.vue'
import BaseModal from './BaseModal/index.vue'
const props = withDefaults(
defineProps<{
@@ -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
}
@@ -2,9 +2,9 @@
import { AlertCircle, AlertTriangle, CheckCircle2, Info, X } from '@lucide/vue'
import { nextTick, onBeforeUnmount, useId, useTemplateRef, watch } from 'vue'
import { lockBodyScroll, unlockBodyScroll } from '@/utils/body-scroll-lock'
import BaseIconButton from './BaseIconButton.vue'
import BaseScrollbar from './BaseScrollbar.vue'
import BaseIconButton from '../BaseIconButton.vue'
import BaseScrollbar from '../BaseScrollbar.vue'
import { lockBodyScroll, unlockBodyScroll } from './bodyScrollLock'
type ModalSize = 'sm' | 'md' | 'lg' | 'xl'
type ModalTone = 'neutral' | 'info' | 'warning' | 'danger' | 'success'
+49 -6
View File
@@ -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
}
+2 -15
View File
@@ -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() {
@@ -0,0 +1,26 @@
import { computed } from 'vue'
import { useThemeColor } from '@/composables/useThemeColor'
export function useChartPalette() {
const color = useThemeColor()
const palette = computed(() => ({
textPrimary: color('--cp-text-primary', '#0E1726'),
textSecondary: color('--cp-text-secondary', '#64748B'),
textMuted: color('--cp-text-muted', '#94A3B8'),
surface: color('--cp-bg-surface', '#FFFFFF'),
surfaceMuted: color('--cp-bg-muted', '#F1F5F9'),
grid: color('--cp-bg-muted', '#F1F5F9'),
divider: color('--cp-divider-subtle', '#E2E8F0'),
border: color('--cp-default-border', '#E2E8F0'),
pointer: color('--cp-default-border-hover', '#CBD5E1'),
info: color('--cp-info', '#2563EB'),
success: color('--cp-success', '#10B981'),
warning: color('--cp-warning', '#F59E0B'),
danger: color('--cp-danger', '#EF4444'),
normal: color('--cp-normal', '#0F9F9A'),
reasoning: color('--cp-reasoning', '#8B5CF6'),
}))
return { color, palette }
}
+6 -1
View File
@@ -1,7 +1,6 @@
import { storeToRefs } from 'pinia'
import { useUiStore } from '@/stores/modules/ui'
import { readCssVariable } from '@/utils/css'
// 主题感知的 CSS 变量读取:themeRevision 作为响应式依赖,主题切换后
// 引用该函数的 computed 会重算并读到新变量值。
@@ -12,3 +11,9 @@ export function useThemeColor() {
return readCssVariable(name, fallback)
}
}
function readCssVariable(name: string, fallback: string) {
if (typeof document === 'undefined')
return fallback
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
+5
View File
@@ -0,0 +1,5 @@
import { createSharedComposable, useNow } from '@vueuse/core'
export const useUiClock = createSharedComposable(() =>
useNow({ interval: 30_000 }),
)
@@ -4,7 +4,7 @@ import { ExternalLink } from '@lucide/vue'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
const open = defineModel<boolean>({ default: false })
@@ -8,6 +8,7 @@ import {
useStorage,
useWindowSize,
} from '@vueuse/core'
import { clamp } from 'es-toolkit'
import {
computed,
nextTick,
@@ -179,10 +180,6 @@ function applyPosition(position: StoredPosition) {
storedPosition.value = next
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function positiveNumber(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? value : fallback
}
@@ -1,13 +1,12 @@
<script setup lang="ts">
import type { SystemUpdateDetail } from '@/api'
import {
ArrowUpCircle,
CheckCircle2,
Circle,
ExternalLink,
Power,
RefreshCw,
Terminal,
XCircle,
} from '@lucide/vue'
import { storeToRefs } from 'pinia'
@@ -15,12 +14,17 @@ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { toast } from '@/components/base/BaseToast'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
import { errorMessage } from '@/utils/async'
import { renderMarkdown } from '@/utils/markdown'
import { formatTime } from '@/utils/date'
import { renderReleaseNotes } from './markdown'
import {
resolveSystemUpdateLogClasses,
resolveSystemUpdatePresentation,
} from './presenter'
const open = defineModel<boolean>({ default: false })
@@ -46,177 +50,39 @@ const { loadSystem, checkUpdates, updateNow, restartNow } = systemUpdateStore
const updateLogScrollbar = useTemplateRef<InstanceType<typeof BaseScrollbar>>('updateLogScrollbar')
const updateConfirmOpen = shallowRef(false)
const updateConfirmInfo = shallowRef<any>(null)
const updateConfirmInfo = shallowRef<SystemUpdateDetail | null>(null)
const updateConfirmPreviousTarget = shallowRef('')
const preparingUpdate = shallowRef(false)
const statusView = computed(() => {
if (restarting.value) {
return {
label: '重启中',
icon: RefreshCw,
badge: 'bg-cp-info-bg text-cp-info-text',
iconClass: 'text-cp-info',
}
}
if (updating.value) {
return {
label: '更新中',
icon: RefreshCw,
badge: 'bg-cp-info-bg text-cp-info-text',
iconClass: 'text-cp-info',
}
}
if (updateError.value || updateInfo.value?.warning) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-cp-danger-bg text-cp-danger-text',
iconClass: 'text-cp-danger',
}
}
if (updateSuccess.value) {
return {
label: '已更新',
icon: CheckCircle2,
badge: 'bg-cp-success-bg text-cp-success-text',
iconClass: 'text-cp-success',
}
}
if (hasUpdate.value) {
return {
label: '有新版本',
icon: ArrowUpCircle,
badge: 'bg-cp-success-bg text-cp-success-text',
iconClass: 'text-cp-success',
}
}
if (updateInfo.value) {
return {
label: '已是最新',
icon: CheckCircle2,
badge: 'bg-cp-success-bg text-cp-success-text',
iconClass: 'text-cp-success',
}
}
return {
label: '未检查',
icon: CheckCircle2,
badge: 'bg-cp-muted text-cp-secondary',
iconClass: 'text-cp-muted-text',
}
})
const summaryItems = computed(() => [
{
key: 'current',
label: '当前版本',
value: loading.value ? '...' : version.value?.version ? `v${version.value.version}` : '-',
title: version.value?.version,
},
{
key: 'latest',
label: '最新版本',
value: updateInfo.value?.latestVersion ? `v${updateInfo.value.latestVersion}` : '-',
title: updateInfo.value?.latestVersion,
releaseUrl: updateInfo.value?.releaseUrl,
},
{
key: 'build',
label: '构建',
value: displayValue(updateInfo.value?.buildTypeLabel),
title: updateInfo.value?.buildType,
},
{
key: 'deployment',
label: '部署',
value: displayValue(version.value?.deploymentModeLabel),
title: version.value?.deploymentMode,
},
])
const presentation = computed(() => resolveSystemUpdatePresentation({
version: version.value,
updateInfo: updateInfo.value,
loading: loading.value,
restarting: restarting.value,
updating: updating.value,
updateError: updateError.value,
updateSuccess: updateSuccess.value,
hasUpdate: hasUpdate.value,
updateStreaming: updateStreaming.value,
updateStreamError: updateStreamError.value,
previousTargetVersion: updateConfirmPreviousTarget.value,
confirmedTargetVersion: updateConfirmInfo.value?.latestVersion ?? null,
}))
const updateLogRows = computed(() =>
updateLogs.value.map(item => ({
...item,
time: formatLogTime(item.at),
time: formatTime(item.at, '--:--:--'),
classes: resolveSystemUpdateLogClasses(item.level),
})),
)
const renderedReleaseNotes = computed(() => renderMarkdown(updateInfo.value?.notes))
const renderedReleaseNotes = computed(() => renderReleaseNotes(updateInfo.value?.notes))
const showUpdateProgress = computed(
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
)
const updateConfirmRows = computed(() => [
{
key: 'current',
label: '当前版本',
value: version.value?.version ? `v${version.value.version}` : '-',
},
{
key: 'previous',
label: '已显示目标',
value: updateConfirmPreviousTarget.value ? `v${updateConfirmPreviousTarget.value}` : '-',
},
{
key: 'target',
label: '远端最新目标',
value: updateConfirmInfo.value?.latestVersion
? `v${updateConfirmInfo.value.latestVersion}`
: '-',
},
])
const streamStatusLabel = computed(() => {
if (updateStreaming.value)
return '实时'
if (updateStreamError.value)
return '断开'
return '待连接'
})
const restartButtonLabel = computed(() => {
return restarting.value ? '重启中' : '立即重启'
})
function displayValue(value: unknown) {
return value ? String(value) : '-'
}
function formatLogTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return '--:--:--'
return date.toLocaleTimeString('zh-CN', { hour12: false })
}
function logMarkerClass(level: string) {
if (level === 'success')
return 'text-cp-success'
if (level === 'warning')
return 'text-cp-warning'
if (level === 'error')
return 'text-cp-danger'
return 'text-cp-info'
}
function logTextClass(level: string) {
if (level === 'success')
return 'text-cp-success'
if (level === 'warning')
return 'text-cp-warning'
if (level === 'error')
return 'text-cp-danger'
return 'text-cp-primary'
}
function normalizeUpdateVersion(value: unknown) {
return String(value ?? '')
.trim()
.replace(/^v/i, '')
}
async function scrollUpdateLogsToBottom() {
await nextTick()
await updateLogScrollbar.value?.scrollToBottom()
@@ -242,7 +108,7 @@ async function handleUpdateRequest() {
if (preparingUpdate.value || updating.value)
return
const previousTargetVersion = normalizeUpdateVersion(updateInfo.value?.latestVersion)
const previousTargetVersion = normalizeSystemVersion(updateInfo.value?.latestVersion)
preparingUpdate.value = true
try {
const data = await checkUpdates(true)
@@ -250,7 +116,7 @@ async function handleUpdateRequest() {
toast.success('当前已是最新版本')
return
}
const remoteTargetVersion = normalizeUpdateVersion(data.latestVersion)
const remoteTargetVersion = normalizeSystemVersion(data.latestVersion)
if (!remoteTargetVersion) {
toast.error('远端目标版本为空')
return
@@ -284,7 +150,7 @@ async function runConfirmedUpdate(targetVersion: string) {
}
async function handleConfirmUpdate() {
const targetVersion = normalizeUpdateVersion(updateConfirmInfo.value?.latestVersion)
const targetVersion = normalizeSystemVersion(updateConfirmInfo.value?.latestVersion)
if (!targetVersion)
return
@@ -344,16 +210,20 @@ watch(
</div>
<span
class="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[12px] font-heavy"
:class="statusView.badge"
:class="presentation.status.badge"
>
<component :is="statusView.icon" class="size-3.5" :class="statusView.iconClass" />
{{ statusView.label }}
<component
:is="presentation.status.icon"
class="size-3.5"
:class="presentation.status.iconClass"
/>
{{ presentation.status.label }}
</span>
</div>
<div class="grid gap-2.5 sm:grid-cols-4">
<div
v-for="item in summaryItems"
v-for="item in presentation.summaryItems"
:key="item.key"
class="min-w-0 rounded-cp-control bg-cp-surface px-3 py-2.5"
>
@@ -398,7 +268,7 @@ watch(
发布说明
</p>
<span class="font-mono text-[11px] font-emphasis text-cp-muted-text">
{{ displayValue(updateInfo?.latestVersion) }}
{{ presentation.releaseVersion }}
</span>
</div>
<BaseScrollbar class="-mx-4" max-height="160px">
@@ -421,13 +291,13 @@ watch(
</div>
<span
class="inline-flex h-6 items-center gap-1.5 rounded-full bg-cp-subtle px-2 text-[11px] leading-none font-bold text-cp-secondary"
:title="updateStreamError || streamStatusLabel"
:title="updateStreamError || presentation.streamStatusLabel"
>
<i
class="size-1.5 rounded-full"
:class="updateStreaming ? 'bg-cp-success' : 'bg-cp-muted-text'"
/>
{{ streamStatusLabel }}
{{ presentation.streamStatusLabel }}
</span>
</header>
@@ -445,10 +315,10 @@ watch(
<span class="tabular-nums text-cp-muted-text">{{ log.time }}</span>
<Circle
class="mt-1 size-2.5"
:class="logMarkerClass(log.level)"
:class="log.classes.marker"
fill="currentColor"
/>
<p class="m-0 min-w-0 wrap-break-word" :class="logTextClass(log.level)">
<p class="m-0 min-w-0 wrap-break-word" :class="log.classes.text">
<span v-if="log.step" class="mr-1 text-cp-muted-text">[{{ log.step }}]</span>
{{ log.message }}
</p>
@@ -486,7 +356,7 @@ watch(
<template #icon>
<Power class="size-4" />
</template>
{{ restartButtonLabel }}
{{ presentation.restartButtonLabel }}
</BaseButton>
<BaseButton
v-else
@@ -515,7 +385,7 @@ watch(
<div class="grid gap-3">
<div class="grid gap-2 rounded-cp-control bg-cp-subtle p-3">
<div
v-for="item in updateConfirmRows"
v-for="item in presentation.confirmRows"
:key="item.key"
class="flex min-w-0 items-center justify-between gap-3"
>
@@ -7,7 +7,7 @@ const marked = new Marked({
gfm: true,
})
export function renderMarkdown(source?: string | null) {
export function renderReleaseNotes(source?: string | null) {
const value = source?.trim()
if (!value)
return ''
@@ -0,0 +1,122 @@
import type { SystemUpdateDetail, SystemVersion } from '@/api'
import { ArrowUpCircle, CheckCircle2, RefreshCw, XCircle } from '@lucide/vue'
interface SystemUpdatePresentationInput {
version: SystemVersion | null
updateInfo: SystemUpdateDetail | null
loading: boolean
restarting: boolean
updating: boolean
updateError: string
updateSuccess: boolean
hasUpdate: boolean
updateStreaming: boolean
updateStreamError: string
previousTargetVersion: string
confirmedTargetVersion: string | null
}
export function resolveSystemUpdatePresentation(input: SystemUpdatePresentationInput) {
return {
status: resolveStatus(input),
summaryItems: resolveSummaryItems(input),
confirmRows: resolveConfirmRows(input),
releaseVersion: displayValue(input.updateInfo?.latestVersion),
streamStatusLabel: input.updateStreaming
? '实时'
: input.updateStreamError ? '断开' : '待连接',
restartButtonLabel: input.restarting ? '重启中' : '立即重启',
}
}
export function resolveSystemUpdateLogClasses(level: string) {
switch (level) {
case 'success':
return { marker: 'text-cp-success', text: 'text-cp-success' }
case 'warning':
return { marker: 'text-cp-warning', text: 'text-cp-warning' }
case 'error':
return { marker: 'text-cp-danger', text: 'text-cp-danger' }
default:
return { marker: 'text-cp-info', text: 'text-cp-primary' }
}
}
function resolveStatus(input: SystemUpdatePresentationInput) {
if (input.restarting || input.updating) {
return {
label: input.restarting ? '重启中' : '更新中',
icon: RefreshCw,
badge: 'bg-cp-info-bg text-cp-info-text',
iconClass: 'text-cp-info',
}
}
if (input.updateError || input.updateInfo?.warning) {
return {
label: '异常',
icon: XCircle,
badge: 'bg-cp-danger-bg text-cp-danger-text',
iconClass: 'text-cp-danger',
}
}
if (input.updateSuccess || input.hasUpdate || input.updateInfo) {
return {
label: input.updateSuccess ? '已更新' : input.hasUpdate ? '有新版本' : '已是最新',
icon: input.hasUpdate ? ArrowUpCircle : CheckCircle2,
badge: 'bg-cp-success-bg text-cp-success-text',
iconClass: 'text-cp-success',
}
}
return {
label: '未检查',
icon: CheckCircle2,
badge: 'bg-cp-muted text-cp-secondary',
iconClass: 'text-cp-muted-text',
}
}
function resolveSummaryItems(input: SystemUpdatePresentationInput) {
return [
{
key: 'current',
label: '当前版本',
value: input.loading ? '...' : versionLabel(input.version?.version),
title: input.version?.version,
},
{
key: 'latest',
label: '最新版本',
value: versionLabel(input.updateInfo?.latestVersion),
title: input.updateInfo?.latestVersion,
releaseUrl: input.updateInfo?.releaseUrl,
},
{
key: 'build',
label: '构建',
value: displayValue(input.updateInfo?.buildTypeLabel),
title: input.updateInfo?.buildType,
},
{
key: 'deployment',
label: '部署',
value: displayValue(input.version?.deploymentModeLabel),
title: input.version?.deploymentMode,
},
]
}
function resolveConfirmRows(input: SystemUpdatePresentationInput) {
return [
{ key: 'current', label: '当前版本', value: versionLabel(input.version?.version) },
{ key: 'previous', label: '已显示目标', value: versionLabel(input.previousTargetVersion) },
{ key: 'target', label: '远端最新目标', value: versionLabel(input.confirmedTargetVersion) },
]
}
function versionLabel(value?: string | null) {
return value ? `v${value}` : '-'
}
function displayValue(value: unknown) {
return value ? String(value) : '-'
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { useUiStore } from '@/stores/modules/ui'
import AppAboutModal from './components/AppAboutModal.vue'
import AppSidebar from './components/AppSidebar.vue'
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
import SystemUpdateModal from './components/SystemUpdateModal.vue'
import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
const uiStore = useUiStore()
const systemUpdateStore = useSystemUpdateStore()
+3 -3
View File
@@ -242,7 +242,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
const result = await performSystemUpdate({ targetVersion: confirmedTargetVersion })
updateSuccess.value = true
needRestart.value = result.needRestart
restartTargetVersion.value = result.needRestart ? normalizeVersion(result.targetVersion) : ''
restartTargetVersion.value = result.needRestart ? normalizeSystemVersion(result.targetVersion) : ''
updateInfo.value = {
...currentInfo,
latestVersion: result.targetVersion,
@@ -271,7 +271,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
while (Date.now() < deadline) {
try {
const readyVersion = await getSystemVersion(restartProbeTimeoutMs)
if (normalizeVersion(readyVersion.version) === expectedVersion) {
if (normalizeSystemVersion(readyVersion.version) === expectedVersion) {
window.location.reload()
return
}
@@ -348,7 +348,7 @@ export const useSystemUpdateStore = defineStore('system-update', () => {
}
})
function normalizeVersion(value: unknown) {
export function normalizeSystemVersion(value: unknown) {
return String(value ?? '')
.trim()
.replace(/^v/i, '')
+6 -1
View File
@@ -1,7 +1,12 @@
import { delay } from 'es-toolkit'
export function errorMessage(error: unknown, fallback = '请求失败') {
return error instanceof Error && error.message ? error.message : fallback
const message = error instanceof Error
? error.message
: error && typeof error === 'object' && 'message' in error
? (error as { message?: unknown }).message
: undefined
return typeof message === 'string' && message ? message : fallback
}
export async function withMinimumDuration<T>(
-16
View File
@@ -1,16 +0,0 @@
import { useScrollLock } from '@vueuse/core'
const bodyScrollLocked = useScrollLock(document.body)
let lockCount = 0
export function lockBodyScroll() {
lockCount += 1
if (lockCount === 1)
bodyScrollLocked.value = true
}
export function unlockBodyScroll() {
lockCount = Math.max(0, lockCount - 1)
if (lockCount === 0)
bodyScrollLocked.value = false
}
+3 -114
View File
@@ -1,117 +1,6 @@
const HEX_COLOR_PATTERN = /^#[0-9A-F]{8}$/i
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
const RGBA_HEX_COLOR_PATTERN = /^#[0-9A-F]{8}$/i
export interface HsvColor {
h: number
s: number
v: number
a: number
}
export interface RgbaColor {
r: number
g: number
b: number
a: number
}
export function normalizeHexColor(value: string) {
export function normalizeRgbaHexColor(value: string) {
const normalized = value.trim().toUpperCase()
return HEX_COLOR_PATTERN.test(normalized) ? normalized : null
}
export function hexToHsv(value: string): HsvColor | null {
const normalized = normalizeHexColor(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 = normalizeHexColor(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(2))
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value))
return RGBA_HEX_COLOR_PATTERN.test(normalized) ? normalized : null
}
-7
View File
@@ -1,7 +0,0 @@
// @env browser
export function readCssVariable(name: string, fallback: string) {
if (typeof document === 'undefined')
return fallback
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback
}
+16 -5
View File
@@ -4,19 +4,26 @@ import dayjs from 'dayjs'
const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss'
const TIME_FORMAT = 'HH:mm:ss'
export function formatDateTime(value: ConfigType = new Date()): string {
return dayjs(value).format(DATE_TIME_FORMAT)
export function formatDateTime(value: ConfigType = new Date(), fallback = '—'): string {
const timestamp = normalizedDate(value)
return timestamp.isValid() ? timestamp.format(DATE_TIME_FORMAT) : fallback
}
export function formatTime(value: ConfigType = new Date()): string {
return dayjs(value).format(TIME_FORMAT)
export function formatTime(value: ConfigType = new Date(), fallback = '—'): string {
const timestamp = normalizedDate(value)
return timestamp.isValid() ? timestamp.format(TIME_FORMAT) : fallback
}
export function parseTimestamp(value: ConfigType): number | null {
const timestamp = normalizedDate(value)
return timestamp.isValid() ? timestamp.valueOf() : null
}
export function formatRelativeTime(
value: ConfigType,
now: ConfigType = new Date(),
): string {
const timestamp = dayjs(value)
const timestamp = normalizedDate(value)
if (!timestamp.isValid())
return '—'
@@ -34,3 +41,7 @@ export function formatRelativeTime(
return `${Math.floor(elapsedHours / 24)} 天前`
}
function normalizedDate(value: ConfigType) {
return dayjs(typeof value === 'string' ? value.replace(' ', 'T') : value)
}
+39
View File
@@ -0,0 +1,39 @@
const integerFormatter = new Intl.NumberFormat('zh-CN')
const localizedCompactFormatter = new Intl.NumberFormat('zh-CN', {
notation: 'compact',
maximumFractionDigits: 1,
})
const metricUnits = [
['P', 1_000_000_000_000_000],
['T', 1_000_000_000_000],
['B', 1_000_000_000],
['M', 1_000_000],
['K', 1_000],
] as const
export function formatInteger(value: number) {
return integerFormatter.format(value)
}
export function formatLocalizedCompactNumber(value: number) {
return localizedCompactFormatter.format(Number.isFinite(value) ? value : 0)
}
export function formatCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
if (normalized < 1_000)
return formatInteger(normalized)
for (const [unit, threshold] of metricUnits) {
if (normalized < threshold)
continue
const scaled = normalized / threshold
const rounded = scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)
return `${rounded.replace(/\.?0+$/, '')}${unit}`
}
return formatInteger(normalized)
}
+3
View File
@@ -0,0 +1,3 @@
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+18 -7
View File
@@ -1,8 +1,19 @@
// 平台标识到展示名的唯一映射;未知平台返回 undefined,由调用方决定兜底文案。
export function providerDisplayName(value?: string | null): 'OpenAI' | 'xAI' | undefined {
if (value === 'openai')
return 'OpenAI'
if (value === 'xai')
return 'xAI'
return undefined
export const PROVIDER_DISPLAY_NAMES = {
openai: 'OpenAI',
xai: 'xAI',
} as const
export type SupportedProvider = keyof typeof PROVIDER_DISPLAY_NAMES
export function isSupportedProvider(value: unknown): value is SupportedProvider {
return typeof value === 'string' && value in PROVIDER_DISPLAY_NAMES
}
export function providerDisplayName(value?: string | null) {
return isSupportedProvider(value) ? PROVIDER_DISPLAY_NAMES[value] : undefined
}
export function formatProviderLabel(value?: string | null, fallback = '—') {
const normalized = value?.trim()
return providerDisplayName(normalized?.toLowerCase()) ?? (normalized || fallback)
}
@@ -1,12 +0,0 @@
export const DEFAULT_ACCOUNT_GROUP_COLOR = '#60A5FA80'
export const ACCOUNT_GROUP_COLOR_PRESETS = [
'#60A5FA80',
'#22D3EE80',
'#2DD4BF80',
'#4ADE8080',
'#FBBF2480',
'#FB718580',
'#A78BFA80',
'#94A3B880',
] as const
@@ -4,13 +4,13 @@ import type { AccountGroup } from '@/api'
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseColorPicker from '@/components/base/BaseColorPicker.vue'
import BaseColorPicker from '@/components/base/BaseColorPicker/index.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import { ACCOUNT_GROUP_COLOR_PRESETS } from '../colors'
import { ACCOUNT_GROUP_COLOR_PRESETS } from '../constants'
const props = defineProps<{
group: AccountGroup | null
@@ -16,9 +16,9 @@ import { useAsyncAction } from '@/composables/useAsyncAction'
import { useIdSet } from '@/composables/useIdSet'
import { usePagedQuery } from '@/composables/usePagedQuery'
import { errorMessage } from '@/utils/async'
import { normalizeHexColor } from '@/utils/color'
import { normalizeRgbaHexColor } from '@/utils/color'
import { formatDateTime } from '@/utils/date'
import { DEFAULT_ACCOUNT_GROUP_COLOR } from '../colors'
import { DEFAULT_ACCOUNT_GROUP_COLOR } from '../constants'
export interface AccountGroupFormValue {
name: string
@@ -124,7 +124,7 @@ export function useAccountGroups() {
toast.warning('请输入分组名称')
return
}
const color = normalizeHexColor(form.value.color)
const color = normalizeRgbaHexColor(form.value.color)
if (!color) {
toast.warning('请选择有效的分组颜色')
return
@@ -1,6 +1,19 @@
import type { AccountGroup } from '@/api'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
export const DEFAULT_ACCOUNT_GROUP_COLOR = '#60A5FA80'
export const ACCOUNT_GROUP_COLOR_PRESETS = [
'#60A5FA80',
'#22D3EE80',
'#2DD4BF80',
'#4ADE8080',
'#FBBF2480',
'#FB718580',
'#A78BFA80',
'#94A3B880',
] as const
export const accountGroupColumns = defineTableColumns<AccountGroup>([
{ key: 'selection', kind: 'selection' },
{ key: 'identity', label: '分组', kind: 'identity' },
@@ -5,7 +5,7 @@ import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
defineProps<{
@@ -4,11 +4,11 @@ import type { useAccountConnectionTest } from '../composables/useAccountConnecti
import { RefreshCw } from '@lucide/vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSelect from '@/components/base/BaseSelect.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
import AccountStatusBadge from './AccountStatusBadge.vue'
import AccountStatusBadge from './AccountStatusBadge/index.vue'
type ConnectionTest = ReturnType<typeof useAccountConnectionTest>
@@ -62,6 +62,7 @@ function connectionLogClass(tone: string) {
:status="account.status"
:error-reason="account.errorReason"
:error-message="account.errorMessage"
:next-refresh-at="account.nextRefreshAt"
variant="pill"
/>
</section>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { Openai, Xai } from '@boxicons/vue'
import { LayoutGrid } from '@lucide/vue'
import { PROVIDER_DISPLAY_NAMES } from '@/utils/providers'
withDefaults(
defineProps<{
@@ -23,12 +24,12 @@ const providers = [
},
{
value: 'openai' as const,
label: 'OpenAI',
label: PROVIDER_DISPLAY_NAMES.openai,
icon: Openai,
},
{
value: 'xai' as const,
label: 'xAI',
label: PROVIDER_DISPLAY_NAMES.xai,
icon: Xai,
},
]
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { useAccountOnboarding } from '../composables/useAccountOnboarding'
import type { AccountRow } from '../constants'
import type { AccountRow } from '../../constants'
import type { AccountCreateForm } from './model'
import type { AccountCreateProvider } from './presenter'
import { Openai, Xai } from '@boxicons/vue'
import { Copy, KeyRound, LayoutGrid, Upload } from '@lucide/vue'
@@ -10,16 +11,13 @@ import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseTextarea from '@/components/base/BaseTextarea.vue'
import { useCopyText } from '@/composables/useCopyText'
import { accountProviderModeOptions } from '../composables/useAccountOnboarding'
import AccountProviderChooser from './AccountProviderChooser.vue'
type AccountOnboarding = ReturnType<typeof useAccountOnboarding>
type CreateForm = AccountOnboarding['createForm']['value']
import { resolveAccountCreatePresentation } from './presenter'
const props = withDefaults(
defineProps<{
@@ -41,7 +39,7 @@ const emit = defineEmits<{
generateOauth: []
}>()
const open = defineModel<boolean>({ default: false })
const form = defineModel<CreateForm>('form', { required: true })
const form = defineModel<AccountCreateForm>('form', { required: true })
const copyWithToast = useCopyText()
const fileError = ref('')
@@ -51,9 +49,13 @@ const { open: openImportFile, onChange: onImportFileChange } = useFileDialog({
reset: true,
})
const modeOptions = computed(() => accountProviderModeOptions(form.value.provider))
const isProviderSelected = computed(() => ['openai', 'xai', 'batch'].includes(form.value.provider))
const isChoosingProvider = computed(() => !props.reauthorizing && !isProviderSelected.value)
const view = computed(() => resolveAccountCreatePresentation({
form: form.value,
account: props.account,
saving: props.saving,
oauthLoading: props.oauthLoading,
reauthorizing: props.reauthorizing,
}))
const provider = computed({
get: () => form.value.provider,
@@ -90,82 +92,7 @@ const oauthCallback = computed({
},
})
const oauthAuthUrl = computed(() => form.value.oauthAuthUrl || '')
const isXai = computed(() => form.value.provider === 'xai')
const isBatch = computed(() => form.value.provider === 'batch')
const importFileLabel = computed(() => {
if (isBatch.value)
return '批量账号文件'
return mode.value === 'agent_identity' ? 'Agent 身份文件' : '账号文件'
})
const importFilePlaceholder = computed(() => mode.value === 'agent_identity'
? '粘贴 Agent 身份文件内容'
: isBatch.value
? '粘贴 CPR 多平台导出文件内容'
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容')
const accountName = computed(() => {
return props.account?.email || props.account?.accountId || props.account?.id || '该账号'
})
const modalTitle = computed(() => {
if (props.reauthorizing)
return '重新授权账号'
return isChoosingProvider.value ? '选择账号平台' : '导入账号'
})
const oauthPanelTitle = computed(() => {
if (props.reauthorizing)
return accountName.value
return isXai.value ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权'
})
const oauthPanelDescription = computed(() => {
return isXai.value
? '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址、查询字符串或授权码'
: '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址'
})
const canGenerateOauth = computed(() =>
isProviderSelected.value
&& !props.saving
&& !props.oauthLoading,
)
const canSubmit = computed(() => {
if (!isProviderSelected.value || props.saving || props.oauthLoading)
return false
if (mode.value === 'oauth') {
if (!form.value.oauthFlowId || !oauthAuthUrl.value)
return false
return oauthCallback.value.trim().length > 0
}
return importText.value.trim().length > 0
})
const description = computed<string | undefined>(() => {
if (isChoosingProvider.value)
return undefined
if (props.reauthorizing) {
return isXai.value
? '完成新的 xAI 授权并替换此账号凭据'
: '完成新的 OpenAI 授权并替换此账号凭据'
}
if (isBatch.value)
return '粘贴或上传 CPR 账号包,一次导入多个平台账号'
if (isXai.value) {
return mode.value === 'oauth'
? '通过浏览器授权导入 xAI 账号'
: '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
}
if (mode.value === 'oauth')
return '通过浏览器授权导入 OpenAI 账号'
if (mode.value === 'agent_identity')
return '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
return '粘贴或上传 CPR、Sub2API 或 CPA 账号文件,匹配已有账号时更新凭据'
})
function selectProvider(value: 'openai' | 'xai' | 'batch') {
function selectProvider(value: AccountCreateProvider) {
provider.value = value
}
@@ -195,30 +122,30 @@ async function copyText(value: string, successText: string) {
<template>
<BaseModal
v-model="open"
:title="modalTitle"
:description="description"
:tone="isChoosingProvider ? 'neutral' : 'info'"
:size="isChoosingProvider ? 'sm' : 'md'"
:title="view.modal.title"
:description="view.modal.description"
:tone="view.modal.tone"
:size="view.modal.size"
:dismissible="!saving"
>
<template #icon>
<LayoutGrid v-if="isBatch" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
<Xai v-else-if="isXai" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
<LayoutGrid v-if="view.isBatch" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
<Xai v-else-if="view.isXai" class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
<Openai v-else class="text-cp-primary" aria-hidden="true" :width="20" :height="20" />
</template>
<AccountProviderChooser
v-if="isChoosingProvider"
v-if="view.choosingProvider"
:disabled="saving || oauthLoading"
@select="selectProvider"
/>
<div v-else class="flex flex-col gap-4">
<BaseSegmented
v-if="!reauthorizing && !isBatch"
v-if="!reauthorizing && !view.isBatch"
v-model="mode"
label="账号添加方式"
:options="modeOptions"
:options="view.modeOptions"
class="w-full"
/>
@@ -232,10 +159,10 @@ async function copyText(value: string, successText: string) {
</div>
<div class="min-w-0 flex-1">
<p class="m-0 text-[13px] font-bold text-cp-primary">
{{ oauthPanelTitle }}
{{ view.oauth.panelTitle }}
</p>
<p class="m-0 mt-1 text-[12px] leading-[1.55] font-medium text-cp-secondary">
{{ oauthPanelDescription }}
{{ view.oauth.panelDescription }}
</p>
</div>
</div>
@@ -245,14 +172,14 @@ async function copyText(value: string, successText: string) {
<BaseButton
variant="secondary"
:loading="oauthLoading"
:disabled="!canGenerateOauth"
:disabled="!view.canGenerateOauth"
@click="emit('generateOauth')"
>
{{ reauthorizing ? '重新生成授权链接' : '生成授权链接' }}
</BaseButton>
</div>
<BaseForm v-if="oauthAuthUrl">
<BaseForm v-if="view.oauth.authUrl">
<BaseFormItem label="授权链接">
<template #extra>
<BaseIconButton
@@ -261,7 +188,7 @@ async function copyText(value: string, successText: string) {
title="复制链接"
label="复制链接"
:disabled="saving || oauthLoading"
@click="copyText(oauthAuthUrl, '授权链接已复制')"
@click="copyText(view.oauth.authUrl, '授权链接已复制')"
>
<Copy class="size-3.5" />
</BaseIconButton>
@@ -272,7 +199,7 @@ async function copyText(value: string, successText: string) {
<div class="rounded-cp-control bg-[var(--cp-input-current-bg,var(--cp-input-context-bg))] px-3.5 py-3 shadow-cp-input">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.6] font-emphasis text-cp-secondary"
v-text="oauthAuthUrl"
v-text="view.oauth.authUrl"
/>
</div>
</BaseScrollbar>
@@ -280,12 +207,12 @@ async function copyText(value: string, successText: string) {
</BaseForm>
<BaseForm>
<BaseFormItem :label="isXai ? '回调地址或授权码' : '回调地址'" required>
<BaseFormItem :label="view.oauth.callbackLabel" required>
<BaseTextarea
v-model="oauthCallback"
:aria-label="isXai ? '回调地址或授权码' : '回调地址'"
:aria-label="view.oauth.callbackLabel"
:rows="4"
:placeholder="isXai ? '回调地址、?code=...&state=... 或授权码' : 'http://localhost:1455/auth/callback?code=...&state=...'"
:placeholder="view.oauth.callbackPlaceholder"
:disabled="saving"
/>
</BaseFormItem>
@@ -294,7 +221,7 @@ async function copyText(value: string, successText: string) {
<BaseForm v-else>
<BaseFormItem
:label="importFileLabel"
:label="view.importFile.label"
required
:error="fileError || undefined"
>
@@ -308,26 +235,26 @@ async function copyText(value: string, successText: string) {
</template>
<BaseTextarea
v-model="importText"
:aria-label="importFileLabel"
:aria-label="view.importFile.label"
:rows="9"
:placeholder="importFilePlaceholder"
:placeholder="view.importFile.placeholder"
:disabled="saving"
/>
</BaseFormItem>
</BaseForm>
</div>
<template v-if="!isChoosingProvider" #footer>
<template v-if="!view.choosingProvider" #footer>
<BaseButton variant="ghost" :disabled="saving" @click="open = false">
取消
</BaseButton>
<BaseButton
variant="primary"
:loading="saving"
:disabled="!canSubmit"
:disabled="!view.canSubmit"
@click="emit('create')"
>
{{ reauthorizing ? '完成重新授权' : mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入' }}
{{ view.submitLabel }}
</BaseButton>
</template>
</BaseModal>
@@ -0,0 +1,21 @@
export interface AccountCreateForm {
provider: string
name: string
mode: string
importText: string
oauthFlowId: string
oauthAuthUrl: string
oauthCallback: string
}
export function emptyAccountCreateForm(): AccountCreateForm {
return {
provider: '',
name: '',
mode: 'oauth',
importText: '',
oauthFlowId: '',
oauthAuthUrl: '',
oauthCallback: '',
}
}
@@ -0,0 +1,127 @@
import type { AccountRow } from '../../constants'
import type { AccountCreateForm } from './model'
export type AccountCreateProvider = 'batch' | 'openai' | 'xai'
interface AccountCreatePresentationInput {
form: AccountCreateForm
account: AccountRow | null
saving: boolean
oauthLoading: boolean
reauthorizing: boolean
}
const modeOptions = {
openai: [
{ label: 'OAuth', value: 'oauth' },
{ label: '账号文件', value: 'json' },
{ label: 'Agent 身份', value: 'agent_identity' },
],
xai: [
{ label: 'OAuth', value: 'oauth' },
{ label: '账号文件', value: 'json' },
],
} satisfies Record<Exclude<AccountCreateProvider, 'batch'>, Array<{ label: string, value: string }>>
export function resolveAccountCreatePresentation(input: AccountCreatePresentationInput) {
const { form } = input
const providerSelected = isAccountCreateProvider(form.provider)
const choosingProvider = !input.reauthorizing && !providerSelected
const isXai = form.provider === 'xai'
const isBatch = form.provider === 'batch'
const oauthAuthUrl = form.oauthAuthUrl || ''
const accountName = input.account?.email
|| input.account?.accountId
|| input.account?.id
|| '该账号'
return {
choosingProvider,
isXai,
isBatch,
modeOptions: form.provider === 'openai' || form.provider === 'xai'
? modeOptions[form.provider]
: [],
modal: {
title: input.reauthorizing
? '重新授权账号'
: choosingProvider ? '选择账号平台' : '导入账号',
description: modalDescription(input, { isBatch, isXai, choosingProvider }),
tone: choosingProvider ? 'neutral' as const : 'info' as const,
size: choosingProvider ? 'sm' as const : 'md' as const,
},
oauth: {
authUrl: oauthAuthUrl,
panelTitle: input.reauthorizing
? accountName
: isXai ? 'xAI OAuth 授权' : 'OpenAI OAuth 授权',
panelDescription: isXai
? '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址、查询字符串或授权码'
: '生成并打开授权链接 → 完成浏览器授权 → 粘贴回调地址',
callbackLabel: isXai ? '回调地址或授权码' : '回调地址',
callbackPlaceholder: isXai
? '回调地址、?code=...&state=... 或授权码'
: 'http://localhost:1455/auth/callback?code=...&state=...',
},
importFile: {
label: isBatch
? '批量账号文件'
: form.mode === 'agent_identity' ? 'Agent 身份文件' : '账号文件',
placeholder: form.mode === 'agent_identity'
? '粘贴 Agent 身份文件内容'
: isBatch
? '粘贴 CPR 多平台导出文件内容'
: '粘贴 CPR、Sub2API 或 CPA 账号文件内容',
},
canGenerateOauth: providerSelected && !input.saving && !input.oauthLoading,
canSubmit: canSubmit(input, providerSelected, oauthAuthUrl),
submitLabel: input.reauthorizing
? '完成重新授权'
: form.mode === 'oauth' ? '完成授权导入' : isBatch ? '批量导入' : '导入',
}
}
function modalDescription(
input: AccountCreatePresentationInput,
state: { isBatch: boolean, isXai: boolean, choosingProvider: boolean },
) {
if (state.choosingProvider)
return undefined
if (input.reauthorizing) {
return state.isXai
? '完成新的 xAI 授权并替换此账号凭据'
: '完成新的 OpenAI 授权并替换此账号凭据'
}
if (state.isBatch)
return '粘贴或上传 CPR 账号包,一次导入多个平台账号'
if (state.isXai) {
return input.form.mode === 'oauth'
? '通过浏览器授权导入 xAI 账号'
: '粘贴或上传 xAI 账号文件,匹配已有账号时更新凭据'
}
if (input.form.mode === 'oauth')
return '通过浏览器授权导入 OpenAI 账号'
if (input.form.mode === 'agent_identity')
return '粘贴或上传 Agent 身份文件,匹配已有账号时更新凭据'
return '粘贴或上传 CPR、Sub2API 或 CPA 账号文件,匹配已有账号时更新凭据'
}
function canSubmit(
input: AccountCreatePresentationInput,
providerSelected: boolean,
oauthAuthUrl: string,
) {
if (!providerSelected || input.saving || input.oauthLoading)
return false
if (input.form.mode !== 'oauth')
return input.form.importText.trim().length > 0
return Boolean(
input.form.oauthFlowId
&& oauthAuthUrl
&& input.form.oauthCallback.trim(),
)
}
function isAccountCreateProvider(value: string): value is AccountCreateProvider {
return value === 'openai' || value === 'xai' || value === 'batch'
}
@@ -6,7 +6,7 @@ import AccountGroupCheckboxGrid from '@/components/AccountGroupCheckboxGrid.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseSwitch from '@/components/base/BaseSwitch.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import AccountIdentityCell from './AccountIdentityCell.vue'
@@ -5,6 +5,7 @@ import { AlertTriangle, Gauge, ShieldCheck, Users } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
import { formatInteger } from '@/utils/number'
const props = defineProps<{
summary: Awaited<ReturnType<typeof getAccounts>>['summary']
@@ -13,38 +14,34 @@ const props = defineProps<{
const overviewItems = computed(() => [
{
label: '总账号',
value: formatCount(props.summary.total),
value: formatInteger(props.summary.total),
caption: '账号池规模',
tone: 'info',
icon: Users,
},
{
label: '正常账号',
value: formatCount(props.summary.normal),
value: formatInteger(props.summary.normal),
caption: '可参与调度',
tone: 'success',
icon: ShieldCheck,
},
{
label: '额度受限',
value: formatCount((props.summary.quotaExhausted ?? 0) + (props.summary.rateLimited ?? 0)),
value: formatInteger((props.summary.quotaExhausted ?? 0) + (props.summary.rateLimited ?? 0)),
caption: '等待额度恢复',
tone: 'warning',
icon: Gauge,
},
{
label: '待处理',
value: formatCount((props.summary.disabled ?? 0) + (props.summary.error ?? 0)),
value: formatInteger((props.summary.disabled ?? 0) + (props.summary.error ?? 0)),
caption: '已停用 / 错误',
tone: 'danger',
icon: AlertTriangle,
},
])
function formatCount(value: number) {
return value.toLocaleString('zh-CN')
}
function overviewIconClass(tone: string) {
if (tone === 'success') {
return 'bg-cp-success-bg text-cp-success-text'
@@ -6,7 +6,7 @@ import { computed } from 'vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import { orderedPanelQuotaWindows } from '../constants'
import AccountPlanBadge from './AccountPlanBadge.vue'
import AccountUsageWindow from './AccountUsageWindow.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
const props = defineProps<{
account: AccountRow
@@ -3,7 +3,7 @@ import type { AccountRow } from '../constants'
import { computed } from 'vue'
import { visibleSummaryQuotaWindows } from '../constants'
import AccountUsageWindow from './AccountUsageWindow.vue'
import AccountUsageWindow from './AccountUsageWindow/index.vue'
const props = defineProps<{
account: AccountRow
@@ -1,234 +0,0 @@
<script setup lang="ts">
import type { AccountErrorReason, AccountStatus } from '@/api'
import { AlertTriangle, CircleCheck, Gauge, Power, Timer } from '@lucide/vue'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { errorReasonLabels, statusLabels, statusTones } from '../constants'
const props = withDefaults(
defineProps<{
status: AccountStatus
errorReason?: AccountErrorReason | null
errorMessage?: string | null
rateLimitedUntil?: string | null
variant?: 'inline' | 'pill'
}>(),
{
errorReason: null,
errorMessage: null,
rateLimitedUntil: null,
variant: 'inline',
},
)
const statusStyles = {
success: {
text: 'text-cp-success-text',
dot: 'bg-cp-success',
badge: 'bg-cp-success-bg text-cp-success-text',
icon: 'bg-cp-success-bg text-cp-success-text',
},
danger: {
text: 'text-cp-danger-text',
dot: 'bg-cp-danger',
badge: 'bg-cp-danger-bg text-cp-danger-text',
icon: 'bg-cp-danger-bg text-cp-danger-text',
},
warning: {
text: 'text-cp-warning-text',
dot: 'bg-cp-warning',
badge: 'bg-cp-warning-bg text-cp-warning-text',
icon: 'bg-cp-warning-bg text-cp-warning-text',
},
info: {
text: 'text-cp-info-text',
dot: 'bg-cp-info',
badge: 'bg-cp-info-bg text-cp-info-text',
icon: 'bg-cp-info-bg text-cp-info-text',
},
normal: {
text: 'text-cp-secondary',
dot: 'bg-cp-muted-text',
badge: 'bg-cp-subtle text-cp-secondary',
icon: 'bg-cp-subtle text-cp-secondary',
},
} as const
const tone = computed(() => statusTones[props.status])
const statusStyle = computed(() => statusStyles[tone.value])
const label = computed(() => statusLabels[props.status])
const reasonLabel = computed(() =>
props.errorReason
? errorReasonLabels[props.errorReason]
: null,
)
const errorText = computed(() => props.errorMessage || null)
const detailTitle = computed(() => reasonLabel.value || label.value)
const isRateLimited = computed(() =>
props.status === 'rate_limited' && Boolean(props.rateLimitedUntil),
)
const rateLimitedRelative = computed(() => {
const rateLimitedUntil = props.rateLimitedUntil
if (!rateLimitedUntil)
return null
const until = new Date(rateLimitedUntil.replace(' ', 'T')).getTime()
const diffMinutes = Math.round((until - Date.now()) / 60_000)
if (Number.isNaN(diffMinutes) || diffMinutes < 1)
return null
if (diffMinutes < 60)
return `剩余 ${diffMinutes} 分钟`
const hours = Math.floor(diffMinutes / 60)
const mins = diffMinutes % 60
if (mins === 0)
return `剩余 ${hours} 小时`
return `剩余 ${hours} 小时 ${mins} 分`
})
const hasDetail = computed(() =>
props.status === 'error' || isRateLimited.value,
)
const detailDescription = computed(() => {
if (props.status === 'rate_limited')
return '上游暂时限制了请求频率,系统正在冷却该账号。'
if (props.status === 'error')
return '该账号暂不参与调度,处理凭据后可重新测试连接。'
return '该账号当前状态需要关注。'
})
const recoveryHint = computed(() => {
if (props.status === 'rate_limited')
return '冷却结束后,系统会自动恢复调度。'
switch (props.errorReason) {
case 'account_unverified':
case 'access_token_expired':
case 'credential_expired':
return '重新授权后,账号会重新参与调度。'
case 'credential_invalid':
return '请更新或重新导入凭据,再次测试连接。'
case 'account_banned':
return '请确认上游账号状态,解除限制后再启用。'
}
return '重新测试连接以获取最新状态。'
})
const triggerLabel = computed(() =>
`${detailTitle.value}。${detailDescription.value} 点击或聚焦查看详情。`,
)
const cardIcon = computed(() => {
switch (props.status) {
case 'error': return AlertTriangle
case 'rate_limited': return Timer
case 'quota_exhausted': return Gauge
case 'disabled': return Power
default: return CircleCheck
}
})
</script>
<template>
<BasePopover
:disabled="!hasDetail"
trigger="hover-click"
placement="top-start"
>
<template #trigger="{ open }">
<component
:is="hasDetail ? 'button' : 'span'"
:type="hasDetail ? 'button' : undefined"
:aria-label="hasDetail ? triggerLabel : undefined"
:aria-expanded="hasDetail ? open : undefined"
:aria-haspopup="hasDetail ? 'dialog' : undefined"
class="group inline-flex min-w-0 cursor-default border-0 bg-transparent p-0 text-left outline-none"
>
<span
v-if="variant === 'pill'"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] leading-none font-heavy transition-colors duration-150 motion-reduce:transition-none"
:class="statusStyle.text"
>
{{ label }}
</span>
<span
v-else
class="inline-flex min-w-16 items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none"
:class="statusStyle.text"
>
<span aria-hidden="true" class="size-1.5 rounded-full" :class="statusStyle.dot" />
<span>{{ label }}</span>
</span>
</component>
</template>
<section class="w-88 overflow-hidden rounded-cp-overlay">
<header class="flex items-start gap-3 bg-cp-subtle px-4 py-3">
<span
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control"
:class="statusStyle.icon"
>
<component :is="cardIcon" class="size-4.5" />
</span>
<div class="min-w-0 flex-1">
<p class="m-0 text-[11px] leading-none font-heavy tracking-[0.08em] text-cp-tertiary">
账号状态
</p>
<h3 class="mt-1.5 mb-0 text-[14px] leading-5 font-heavy text-cp-primary text-balance">
{{ detailTitle }}
</h3>
</div>
<span
class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[10px] leading-none font-heavy"
:class="statusStyle.badge"
>
{{ label }}
</span>
</header>
<div class="grid gap-3 px-4 py-3 text-[12px] leading-5">
<p class="m-0 text-pretty font-emphasis text-cp-secondary">
{{ detailDescription }}
</p>
<div
v-if="isRateLimited"
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
>
<span class="font-heavy text-cp-tertiary">预计恢复</span>
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
{{ rateLimitedRelative ?? props.rateLimitedUntil }}
</span>
</div>
<div class="rounded-cp-control bg-cp-subtle px-3 py-2.5">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-tertiary">
建议操作
</p>
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-cp-primary">
{{ recoveryHint }}
</p>
</div>
<div
v-if="errorText"
class="overflow-hidden rounded-cp-control bg-cp-muted"
>
<div class="flex items-center justify-between gap-3 px-3 pt-2.5 pb-1.5">
<span class="text-[11px] leading-none font-heavy text-cp-tertiary">上游原始反馈</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">仅供排查</span>
</div>
<BaseScrollbar class="bg-cp-subtle" max-height="124px">
<div class="px-3 py-2">
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-cp-secondary">{{ errorText }}</pre>
</div>
</BaseScrollbar>
</div>
</div>
</section>
</BasePopover>
</template>
@@ -0,0 +1,150 @@
<script setup lang="ts">
import type { AccountErrorReason, AccountStatus } from '@/api'
import { computed } from 'vue'
import BasePopover from '@/components/base/BasePopover.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { useUiClock } from '@/composables/useUiClock'
import { resolveAccountStatusPresentation } from './presenter'
const props = withDefaults(
defineProps<{
status: AccountStatus
errorReason?: AccountErrorReason | null
errorMessage?: string | null
rateLimitedUntil?: string | null
nextRefreshAt?: string | null
variant?: 'inline' | 'pill'
}>(),
{
errorReason: null,
errorMessage: null,
rateLimitedUntil: null,
nextRefreshAt: null,
variant: 'inline',
},
)
const now = useUiClock()
const presentation = computed(() => resolveAccountStatusPresentation({
status: props.status,
errorReason: props.errorReason,
errorMessage: props.errorMessage,
rateLimitedUntil: props.rateLimitedUntil,
nextRefreshAt: props.nextRefreshAt,
now: now.value.getTime(),
}))
</script>
<template>
<BasePopover
:disabled="!presentation.hasDetail"
trigger="hover-click"
placement="top-start"
>
<template #trigger="{ open }">
<component
:is="presentation.hasDetail ? 'button' : 'span'"
:type="presentation.hasDetail ? 'button' : undefined"
:aria-label="presentation.hasDetail ? presentation.triggerLabel : undefined"
:aria-expanded="presentation.hasDetail ? open : undefined"
:aria-haspopup="presentation.hasDetail ? 'dialog' : undefined"
class="group inline-flex min-w-0 cursor-default border-0 bg-transparent p-0 text-left outline-none"
>
<span
v-if="variant === 'pill'"
class="inline-flex h-7 shrink-0 items-center rounded-full px-2.5 text-[12px] leading-none font-heavy transition-colors duration-150 motion-reduce:transition-none"
:class="presentation.statusStyle.text"
>
{{ presentation.label }}
</span>
<span
v-else
class="inline-flex min-w-16 items-center gap-1.5 rounded-md px-1.5 py-1 text-[12px] leading-none font-emphasis transition-colors duration-150 motion-reduce:transition-none"
:class="presentation.statusStyle.text"
>
<span aria-hidden="true" class="size-1.5 rounded-full" :class="presentation.statusStyle.dot" />
<span>{{ presentation.label }}</span>
</span>
</component>
</template>
<section class="w-88 overflow-hidden rounded-cp-overlay">
<header class="flex items-start gap-3 bg-cp-subtle px-4 py-3">
<span
aria-hidden="true"
class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp-control"
:class="presentation.statusStyle.icon"
>
<component :is="presentation.icon" class="size-4.5" />
</span>
<div class="min-w-0 flex-1">
<p class="m-0 text-[11px] leading-none font-heavy tracking-[0.08em] text-cp-tertiary">
账号状态
</p>
<h3 class="mt-1.5 mb-0 text-[14px] leading-5 font-heavy text-cp-primary text-balance">
{{ presentation.title }}
</h3>
</div>
<span
class="inline-flex h-5 shrink-0 items-center rounded-full px-2 text-[10px] leading-none font-heavy"
:class="presentation.statusStyle.badge"
>
{{ presentation.label }}
</span>
</header>
<div class="grid gap-3 px-4 py-3 text-[12px] leading-5">
<p class="m-0 text-pretty font-emphasis text-cp-secondary">
{{ presentation.description }}
</p>
<div
v-if="presentation.rateLimitRecovery"
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
>
<span class="font-heavy text-cp-tertiary">预计恢复</span>
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
{{ presentation.rateLimitRecovery }}
</span>
</div>
<div
v-if="presentation.nextRefreshDisplay"
class="flex items-center justify-between gap-3 rounded-cp-control bg-cp-subtle px-3 py-2"
>
<span class="font-heavy text-cp-tertiary">下次尝试</span>
<span class="font-mono font-emphasis tabular-nums text-cp-primary">
{{ presentation.nextRefreshDisplay }}
</span>
</div>
<div class="rounded-cp-control bg-cp-subtle px-3 py-2.5">
<p class="m-0 text-[11px] leading-none font-heavy text-cp-tertiary">
建议操作
</p>
<p class="mt-1.5 mb-0 text-pretty font-emphasis text-cp-primary">
{{ presentation.recoveryHint }}
</p>
</div>
<div
v-if="presentation.errorText"
class="overflow-hidden rounded-cp-control bg-cp-muted"
>
<div class="flex items-center justify-between gap-3 px-3 pt-2.5 pb-1.5">
<span class="text-[11px] leading-none font-heavy text-cp-tertiary">上游原始反馈</span>
<span class="shrink-0 text-[10px] leading-none font-emphasis text-cp-muted-text">仅供排查</span>
</div>
<BaseScrollbar class="bg-cp-subtle" max-height="124px">
<div class="px-3 py-2">
<pre class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[11px] leading-[1.55] font-emphasis text-cp-secondary">{{ presentation.errorText }}</pre>
</div>
</BaseScrollbar>
</div>
</div>
</section>
</BasePopover>
</template>
@@ -0,0 +1,200 @@
import type { Component } from 'vue'
import type { AccountErrorReason, AccountStatus } from '@/api'
import { AlertTriangle, CircleCheck, Gauge, Power, Timer } from '@lucide/vue'
import { formatDateTime, parseTimestamp } from '@/utils/date'
import { errorReasonLabels, statusLabels, statusTones } from '../../constants'
export type AccountStatusDisplayMode = AccountStatus | 'refresh_backoff'
type AccountStatusTone = (typeof statusTones)[AccountStatus]
interface AccountStatusDisplayDefinition {
tone: AccountStatusTone
label: string
title?: string
description: string
recoveryHint: string
icon: Component
}
interface AccountStatusStyle {
text: string
dot: string
badge: string
icon: string
}
export interface AccountStatusPresentation {
mode: AccountStatusDisplayMode
statusStyle: AccountStatusStyle
label: string
title: string
description: string
recoveryHint: string
icon: Component
hasDetail: boolean
errorText: string | null
nextRefreshDisplay: string | null
rateLimitRecovery: string | null
triggerLabel: string
}
export interface AccountStatusPresentationInput {
status: AccountStatus
errorReason: AccountErrorReason | null
errorMessage: string | null
rateLimitedUntil: string | null
nextRefreshAt: string | null
now: number
}
const statusStyles: Record<AccountStatusTone, AccountStatusStyle> = {
success: {
text: 'text-cp-success-text',
dot: 'bg-cp-success',
badge: 'bg-cp-success-bg text-cp-success-text',
icon: 'bg-cp-success-bg text-cp-success-text',
},
danger: {
text: 'text-cp-danger-text',
dot: 'bg-cp-danger',
badge: 'bg-cp-danger-bg text-cp-danger-text',
icon: 'bg-cp-danger-bg text-cp-danger-text',
},
warning: {
text: 'text-cp-warning-text',
dot: 'bg-cp-warning',
badge: 'bg-cp-warning-bg text-cp-warning-text',
icon: 'bg-cp-warning-bg text-cp-warning-text',
},
info: {
text: 'text-cp-info-text',
dot: 'bg-cp-info',
badge: 'bg-cp-info-bg text-cp-info-text',
icon: 'bg-cp-info-bg text-cp-info-text',
},
normal: {
text: 'text-cp-secondary',
dot: 'bg-cp-muted-text',
badge: 'bg-cp-subtle text-cp-secondary',
icon: 'bg-cp-subtle text-cp-secondary',
},
}
const displayDefinitions: Record<AccountStatusDisplayMode, AccountStatusDisplayDefinition> = {
refresh_backoff: {
tone: 'warning',
label: '退避中',
title: 'OAuth 刷新退避',
description: '刷新失败,系统正在等待下一次自动尝试。',
recoveryHint: '系统会在计划时间自动重试。',
icon: Timer,
},
normal: {
tone: statusTones.normal,
label: statusLabels.normal,
description: '该账号当前状态需要关注。',
recoveryHint: '重新测试连接以获取最新状态。',
icon: CircleCheck,
},
quota_exhausted: {
tone: statusTones.quota_exhausted,
label: statusLabels.quota_exhausted,
description: '该账号当前状态需要关注。',
recoveryHint: '重新测试连接以获取最新状态。',
icon: Gauge,
},
rate_limited: {
tone: statusTones.rate_limited,
label: statusLabels.rate_limited,
description: '上游暂时限制了请求频率,系统正在冷却该账号。',
recoveryHint: '冷却结束后,系统会自动恢复调度。',
icon: Timer,
},
disabled: {
tone: statusTones.disabled,
label: statusLabels.disabled,
description: '该账号当前状态需要关注。',
recoveryHint: '重新测试连接以获取最新状态。',
icon: Power,
},
error: {
tone: statusTones.error,
label: statusLabels.error,
description: '该账号暂不参与调度,处理凭据后可重新测试连接。',
recoveryHint: '重新测试连接以获取最新状态。',
icon: AlertTriangle,
},
}
const errorRecoveryHints: Record<AccountErrorReason, string> = {
account_unverified: '重新授权后,账号会重新参与调度。',
access_token_expired: '重新授权后,账号会重新参与调度。',
credential_expired: '重新授权后,账号会重新参与调度。',
credential_invalid: '请更新或重新导入凭据,再次测试连接。',
account_banned: '请确认上游账号状态,解除限制后再启用。',
}
export function resolveAccountStatusPresentation(
input: AccountStatusPresentationInput,
): AccountStatusPresentation {
const nextRefreshTimestamp = input.nextRefreshAt === null
? null
: parseTimestamp(input.nextRefreshAt)
const isBackoff = input.status !== 'disabled'
&& nextRefreshTimestamp !== null
&& nextRefreshTimestamp > input.now
const mode: AccountStatusDisplayMode = isBackoff ? 'refresh_backoff' : input.status
const definition = displayDefinitions[mode]
const nextRefreshDisplay = isBackoff ? formatDateTime(nextRefreshTimestamp) : null
const reasonLabel = input.errorReason ? errorReasonLabels[input.errorReason] : null
const title = definition.title ?? reasonLabel ?? definition.label
const rateLimitRecovery = input.status === 'rate_limited'
? remainingTime(input.rateLimitedUntil, input.now)
: null
const recoveryHint = mode !== 'refresh_backoff'
&& mode !== 'rate_limited'
&& input.errorReason
? errorRecoveryHints[input.errorReason]
: definition.recoveryHint
const hasDetail = input.status === 'error' || rateLimitRecovery !== null || isBackoff
const retry = nextRefreshDisplay ? ` 下次尝试:${nextRefreshDisplay}。` : ''
const triggerLabel = `${title}。${definition.description}${retry} 点击或聚焦查看详情。`
return {
mode,
statusStyle: statusStyles[definition.tone],
label: definition.label,
title,
description: definition.description,
recoveryHint,
icon: definition.icon,
hasDetail,
errorText: input.errorMessage || null,
nextRefreshDisplay,
rateLimitRecovery,
triggerLabel,
}
}
function remainingTime(value: string | null, now: number) {
if (!value)
return null
const until = parseTimestamp(value)
if (until === null)
return value
const minutes = Math.round((until - now) / 60_000)
if (minutes < 1)
return value
if (minutes < 60)
return `剩余 ${minutes} 分钟`
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return remainingMinutes === 0
? `剩余 ${hours} 小时`
: `剩余 ${hours} 小时 ${remainingMinutes} 分`
}
@@ -1,282 +0,0 @@
<script setup lang="ts">
import type { AccountLocalUsage, AccountQuotaWindow } from '../constants'
import { computed } from 'vue'
import {
quotaWindowBarClass,
quotaWindowBarStyle,
quotaWindowPercentTextClass,
} from '../constants'
type UsageWindowVariant = 'compact' | 'detail' | 'metric'
type UsageWindowMode = 'quota' | 'local' | 'unknown'
const props = withDefaults(
defineProps<{
window?: AccountQuotaWindow
variant?: UsageWindowVariant
showLocalValue?: boolean
}>(),
{
variant: 'detail',
showLocalValue: true,
},
)
const isCompact = computed(() => props.variant === 'compact')
const isMetric = computed(() => props.variant === 'metric')
const localUsage = computed<AccountLocalUsage | null>(() => {
const value = props.window?.localUsage
if (!value || typeof value !== 'object' || Array.isArray(value))
return null
return value as AccountLocalUsage
})
const mode = computed<UsageWindowMode>(() => {
if (typeof props.window?.usedPercent === 'number')
return 'quota'
if (localUsage.value)
return 'local'
if (props.window)
return 'quota'
return 'unknown'
})
const quotaValueVisible = computed(() =>
typeof props.window?.usedPercent === 'number' && props.window.usedPercent > 0,
)
const quotaLocalUsageDisplay = computed(() => {
const display = localUsage.value?.totalTokensDisplay
if (typeof display === 'string' && display.trim())
return display.trim()
const total = localUsage.value?.totalTokens
return typeof total === 'number' && total > 0 ? total.toLocaleString() : ''
})
const quotaLocalUsageVisible = computed(() =>
props.showLocalValue
&& typeof localUsage.value?.totalTokens === 'number'
&& localUsage.value.totalTokens > 0,
)
function variantValue(metric: string, compact: string, detail: string) {
if (isMetric.value)
return metric
if (isCompact.value)
return compact
return detail
}
const rootClass = computed(() =>
variantValue(
'grid min-w-0 grid-rows-[14px_14px] gap-1',
'flex min-w-0 flex-col',
'rounded-lg bg-cp-subtle p-2',
),
)
const headerClass = computed(() =>
variantValue(
'flex items-center justify-between gap-2 text-[11px] leading-[14px] font-bold',
'mb-1 flex items-baseline justify-between gap-2 text-[11px] leading-[14px] font-bold',
'flex items-center justify-between gap-3 text-[12px] font-bold',
),
)
const labelClass = computed(() => {
if (isMetric.value)
return 'truncate text-cp-muted-text'
if (isCompact.value)
return 'truncate text-cp-muted-text'
return 'text-cp-secondary'
})
const valueClass = computed(() =>
variantValue(
'text-[11px] leading-none font-bold',
'text-[10px] leading-[14px] font-heavy',
'text-[12px] font-heavy',
),
)
const trackOffsetClass = computed(() =>
variantValue('self-center', 'mt-auto', 'mt-2'),
)
const trackShapeClass = computed(() =>
variantValue(
'h-1.5 w-full overflow-hidden rounded-full',
'h-[3px] w-full overflow-hidden rounded-full',
'h-2 overflow-hidden rounded-full',
),
)
const trackClass = computed(() => `${trackShapeClass.value} bg-cp-default-border`)
const barStyle = computed(() => {
if (!props.window)
return undefined
return quotaWindowBarStyle(
props.window,
variantValue('6px', '3px', '8px'),
)
})
const barClass = computed(() => props.window ? quotaWindowBarClass(props.window) : undefined)
const percentTextClass = computed(() =>
props.window ? quotaWindowPercentTextClass(props.window) : undefined,
)
const localRequestLabel = '日请求'
const localRequestDisplay = computed(() => {
const display = localUsage.value?.requestCountDisplay
if (typeof display === 'string' && display.trim())
return display.trim()
const count = localUsage.value?.requestCount
return typeof count === 'number' ? count.toLocaleString() : '0'
})
const localRequestValueVisible = computed(() => {
const count = localUsage.value?.requestCount
return props.showLocalValue && typeof count === 'number' && count > 0
})
const requestTimelineTitle = computed(() => `${localRequestLabel} ${localRequestDisplay.value} 次`)
const requestBars = computed(() => {
const buckets = localUsage.value?.requestBuckets ?? []
const hourMilliseconds = 60 * 60 * 1_000
const currentHour = Math.floor(Date.now() / hourMilliseconds) * hourMilliseconds
const bucketCounts = new Map(
buckets.map((bucket) => {
const bucketHour = Math.floor(new Date(bucket.bucketStart).getTime() / hourMilliseconds)
* hourMilliseconds
return [bucketHour, Math.max(0, bucket.requestCount)] as const
}),
)
const slots = Array.from({ length: 24 }, (_, index) => {
const startTime = currentHour - (23 - index) * hourMilliseconds
return {
startTime,
requestCount: bucketCounts.get(startTime) ?? 0,
}
})
const maximum = Math.max(1, ...slots.map(slot => slot.requestCount))
const formatter = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
return slots.map((slot) => {
const start = new Date(slot.startTime)
const end = new Date(slot.startTime + hourMilliseconds)
return {
key: start.toISOString(),
requestCount: slot.requestCount,
height: slot.requestCount === 0
? '0'
: `${Math.max(25, Math.round(slot.requestCount / maximum * 100))}%`,
title: `${formatter.format(start)}–${formatter.format(end)} · ${slot.requestCount} 次请求`,
}
})
})
</script>
<template>
<div :class="rootClass">
<template v-if="mode === 'quota' && window">
<div :class="headerClass">
<span
class="min-w-0"
:class="labelClass"
:title="isCompact || isMetric ? window.labelDisplay : undefined"
>
{{ window.labelDisplay }}
</span>
<span
v-if="isCompact"
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
>
<span
v-if="quotaLocalUsageVisible"
class="text-right text-cp-muted-text"
:class="valueClass"
:title="`本地 Token 用量:${quotaLocalUsageDisplay}`"
>
{{ quotaLocalUsageDisplay }}
</span>
<span
v-if="quotaValueVisible"
class="text-right"
:class="[valueClass, percentTextClass]"
:title="`额度已使用:${window.usedPercentDisplay}`"
>
{{ window.usedPercentDisplay }}
</span>
</span>
<span
v-else
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
>
<span
v-if="quotaLocalUsageVisible"
class="text-cp-muted-text"
:class="valueClass"
>
{{ quotaLocalUsageDisplay }}
</span>
<span
v-if="quotaValueVisible"
:class="[valueClass, percentTextClass]"
>
{{ window.usedPercentDisplay }}
</span>
</span>
</div>
<div
:class="[trackClass, trackOffsetClass]"
role="progressbar"
:aria-label="window.labelDisplay"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="window.usedPercent ?? undefined"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200"
:class="barClass"
:style="barStyle"
/>
</div>
<div
v-if="!isCompact && window.resetAtDisplay !== '—'"
class="mt-3 text-[12px] font-emphasis text-cp-secondary"
>
重置时间: {{ window.resetAtDisplay }}
</div>
</template>
<template v-else-if="mode === 'local'">
<div :class="headerClass">
<span class="min-w-0" :class="labelClass">
{{ localRequestLabel }}
</span>
<strong
v-if="localRequestValueVisible"
class="shrink-0 font-mono tabular-nums text-cp-primary"
:class="valueClass"
>
{{ localRequestDisplay }}
</strong>
</div>
<div
class="flex items-stretch gap-px"
:class="[trackShapeClass, trackOffsetClass]"
role="img"
:aria-label="requestTimelineTitle"
:title="requestTimelineTitle"
>
<span
v-for="bar in requestBars"
:key="bar.key"
class="relative min-w-0 flex-1 bg-cp-default-border"
:title="bar.title"
>
<span
class="absolute inset-x-0 bottom-0 bg-cp-success"
:style="{ height: bar.height }"
/>
</span>
</div>
</template>
<div v-else :class="headerClass">
<span class="min-w-0 text-cp-secondary">额度待观测</span>
<span class="shrink-0 font-mono text-cp-muted-text" :class="valueClass">—</span>
</div>
</div>
</template>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { AccountQuotaWindow } from '../../constants'
import type { AccountUsageWindowVariant } from './presenter'
import { computed } from 'vue'
import { useUiClock } from '@/composables/useUiClock'
import { resolveAccountUsageWindowPresentation } from './presenter'
const props = withDefaults(
defineProps<{
window?: AccountQuotaWindow
variant?: AccountUsageWindowVariant
showLocalValue?: boolean
}>(),
{
variant: 'detail',
showLocalValue: true,
},
)
const now = useUiClock()
const view = computed(() => resolveAccountUsageWindowPresentation({
window: props.window,
variant: props.variant,
showLocalValue: props.showLocalValue,
now: now.value.getTime(),
}))
</script>
<template>
<div :class="view.classes.root">
<template v-if="view.mode === 'quota' && window">
<div :class="view.classes.header">
<span
class="min-w-0"
:class="view.classes.label"
:title="view.labelTooltip"
>
{{ window.labelDisplay }}
</span>
<span
v-if="view.compactValues"
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
>
<span
v-if="view.quota.localUsageVisible"
class="text-right text-cp-muted-text"
:class="view.classes.value"
:title="`本地 Token 用量:${view.quota.localUsageDisplay}`"
>
{{ view.quota.localUsageDisplay }}
</span>
<span
v-if="view.quota.valueVisible"
class="text-right"
:class="[view.classes.value, view.quota.percentTextClass]"
:title="`额度已使用:${window.usedPercentDisplay}`"
>
{{ window.usedPercentDisplay }}
</span>
</span>
<span
v-else
class="flex shrink-0 items-baseline justify-end gap-1.5 font-mono tabular-nums"
>
<span
v-if="view.quota.localUsageVisible"
class="text-cp-muted-text"
:class="view.classes.value"
>
{{ view.quota.localUsageDisplay }}
</span>
<span
v-if="view.quota.valueVisible"
:class="[view.classes.value, view.quota.percentTextClass]"
>
{{ window.usedPercentDisplay }}
</span>
</span>
</div>
<div
:class="[view.classes.track, view.classes.trackOffset]"
role="progressbar"
:aria-label="window.labelDisplay"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="window.usedPercent ?? undefined"
>
<div
class="h-full rounded-full transition-[width,background-color] duration-200"
:class="view.quota.barClass"
:style="view.quota.barStyle"
/>
</div>
<div
v-if="view.quota.resetVisible"
class="mt-3 text-[12px] font-emphasis text-cp-secondary"
>
重置时间: {{ window.resetAtDisplay }}
</div>
</template>
<template v-else-if="view.mode === 'local'">
<div :class="view.classes.header">
<span class="min-w-0" :class="view.classes.label">
{{ view.local.label }}
</span>
<strong
v-if="view.local.requestValueVisible"
class="shrink-0 font-mono tabular-nums text-cp-primary"
:class="view.classes.value"
>
{{ view.local.requestDisplay }}
</strong>
</div>
<div
class="flex items-stretch gap-px"
:class="[view.classes.trackShape, view.classes.trackOffset]"
role="img"
:aria-label="view.local.timelineTitle"
:title="view.local.timelineTitle"
>
<span
v-for="bar in view.local.requestBars"
:key="bar.key"
class="relative min-w-0 flex-1 bg-cp-default-border"
:title="bar.title"
>
<span
class="absolute inset-x-0 bottom-0 bg-cp-success"
:style="{ height: bar.height }"
/>
</span>
</div>
</template>
<div v-else :class="view.classes.header">
<span class="min-w-0 text-cp-secondary">额度待观测</span>
<span class="shrink-0 font-mono text-cp-muted-text" :class="view.classes.value">—</span>
</div>
</div>
</template>
@@ -0,0 +1,295 @@
import type { AccountQuotaWindow } from '../../constants'
import { clamp } from 'es-toolkit'
import { formatInteger } from '@/utils/number'
import { isRecord } from '@/utils/object'
export type AccountUsageWindowVariant = 'compact' | 'detail' | 'metric'
type AccountUsageWindowMode = 'quota' | 'local' | 'unknown'
interface AccountRequestBucket {
bucketStart: string
requestCount: number
}
interface AccountLocalUsage {
requestCount?: number
requestCountDisplay?: string
totalTokens?: number
totalTokensDisplay?: string
requestBuckets?: AccountRequestBucket[]
}
interface AccountUsageWindowVariantDefinition {
rootClass: string
headerClass: string
labelClass: string
valueClass: string
trackOffsetClass: string
trackShapeClass: string
minimumBarWidth: string
compactValues: boolean
labelTooltip: boolean
showResetTime: boolean
}
type QuotaWindowTone = 'danger' | 'success' | 'unknown' | 'warning'
export interface AccountUsageWindowPresentationInput {
window: AccountQuotaWindow | undefined
variant: AccountUsageWindowVariant
showLocalValue: boolean
now: number
}
const variantDefinitions: Record<
AccountUsageWindowVariant,
AccountUsageWindowVariantDefinition
> = {
metric: {
rootClass: 'grid min-w-0 grid-rows-[14px_14px] gap-1',
headerClass: 'flex items-center justify-between gap-2 text-[11px] leading-[14px] font-bold',
labelClass: 'truncate text-cp-muted-text',
valueClass: 'text-[11px] leading-none font-bold',
trackOffsetClass: 'self-center',
trackShapeClass: 'h-1.5 w-full overflow-hidden rounded-full',
minimumBarWidth: '6px',
compactValues: false,
labelTooltip: true,
showResetTime: true,
},
compact: {
rootClass: 'flex min-w-0 flex-col',
headerClass: 'mb-1 flex items-baseline justify-between gap-2 text-[11px] leading-[14px] font-bold',
labelClass: 'truncate text-cp-muted-text',
valueClass: 'text-[10px] leading-[14px] font-heavy',
trackOffsetClass: 'mt-auto',
trackShapeClass: 'h-[3px] w-full overflow-hidden rounded-full',
minimumBarWidth: '3px',
compactValues: true,
labelTooltip: true,
showResetTime: false,
},
detail: {
rootClass: 'rounded-lg bg-cp-subtle p-2',
headerClass: 'flex items-center justify-between gap-3 text-[12px] font-bold',
labelClass: 'text-cp-secondary',
valueClass: 'text-[12px] font-heavy',
trackOffsetClass: 'mt-2',
trackShapeClass: 'h-2 overflow-hidden rounded-full',
minimumBarWidth: '8px',
compactValues: false,
labelTooltip: false,
showResetTime: true,
},
}
const hourMilliseconds = 60 * 60 * 1_000
const hourFormatter = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
const quotaToneClasses: Record<
QuotaWindowTone,
{ bar: string, text: string }
> = {
unknown: {
bar: 'bg-cp-default-border-hover',
text: 'text-cp-muted-text',
},
danger: {
bar: 'bg-cp-danger',
text: 'text-cp-danger-text',
},
warning: {
bar: 'bg-cp-warning',
text: 'text-cp-warning-text',
},
success: {
bar: 'bg-cp-success',
text: 'text-cp-success-text',
},
}
export function resolveAccountUsageWindowPresentation(
input: AccountUsageWindowPresentationInput,
) {
const definition = variantDefinitions[input.variant]
const localUsage = accountLocalUsage(input.window?.localUsage)
const mode = usageWindowMode(input.window, localUsage)
const quotaLocalUsageDisplay = localTokenDisplay(localUsage)
const quotaLocalUsageVisible = input.showLocalValue
&& typeof localUsage?.totalTokens === 'number'
&& localUsage.totalTokens > 0
const localRequestDisplay = requestCountDisplay(localUsage)
const localRequestValueVisible = input.showLocalValue
&& typeof localUsage?.requestCount === 'number'
&& localUsage.requestCount > 0
const localRequestLabel = '日请求'
const quota = input.window
? quotaWindowPresentation(input.window, definition.minimumBarWidth)
: null
return {
mode,
compactValues: definition.compactValues,
labelTooltip: definition.labelTooltip ? input.window?.labelDisplay : undefined,
classes: {
root: definition.rootClass,
header: definition.headerClass,
label: definition.labelClass,
value: definition.valueClass,
trackOffset: definition.trackOffsetClass,
trackShape: definition.trackShapeClass,
track: `${definition.trackShapeClass} bg-cp-default-border`,
},
quota: {
valueVisible: typeof input.window?.usedPercent === 'number'
&& input.window.usedPercent > 0,
localUsageDisplay: quotaLocalUsageDisplay,
localUsageVisible: quotaLocalUsageVisible,
barStyle: quota?.barStyle,
barClass: quota?.barClass,
percentTextClass: quota?.percentTextClass,
resetVisible: Boolean(input.window)
&& definition.showResetTime
&& input.window?.resetAtDisplay !== '—',
},
local: {
label: localRequestLabel,
requestDisplay: localRequestDisplay,
requestValueVisible: localRequestValueVisible,
timelineTitle: `${localRequestLabel} ${localRequestDisplay} 次`,
requestBars: requestTimeline(localUsage?.requestBuckets ?? [], input.now),
},
}
}
function accountLocalUsage(value: unknown): AccountLocalUsage | null {
if (!isRecord(value))
return null
const localUsage: AccountLocalUsage = {
requestBuckets: requestBuckets(value.requestBuckets),
}
if (finiteNumber(value.requestCount))
localUsage.requestCount = value.requestCount
if (typeof value.requestCountDisplay === 'string')
localUsage.requestCountDisplay = value.requestCountDisplay
if (finiteNumber(value.totalTokens))
localUsage.totalTokens = value.totalTokens
if (typeof value.totalTokensDisplay === 'string')
localUsage.totalTokensDisplay = value.totalTokensDisplay
return localUsage
}
function finiteNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value)
}
function requestBuckets(value: unknown) {
if (!Array.isArray(value))
return []
return value.flatMap((item) => {
if (!isRecord(item))
return []
if (
typeof item.bucketStart !== 'string'
|| !finiteNumber(item.requestCount)
|| !Number.isFinite(new Date(item.bucketStart).getTime())
) {
return []
}
return [{
bucketStart: item.bucketStart,
requestCount: item.requestCount,
}]
})
}
function usageWindowMode(
window: AccountQuotaWindow | undefined,
localUsage: AccountLocalUsage | null,
): AccountUsageWindowMode {
if (typeof window?.usedPercent === 'number')
return 'quota'
if (localUsage)
return 'local'
return window ? 'quota' : 'unknown'
}
function localTokenDisplay(localUsage: AccountLocalUsage | null) {
const display = localUsage?.totalTokensDisplay
if (typeof display === 'string' && display.trim())
return display.trim()
const total = localUsage?.totalTokens
return typeof total === 'number' && total > 0 ? formatInteger(total) : ''
}
function requestCountDisplay(localUsage: AccountLocalUsage | null) {
const display = localUsage?.requestCountDisplay
if (typeof display === 'string' && display.trim())
return display.trim()
const count = localUsage?.requestCount
return typeof count === 'number' ? formatInteger(count) : '0'
}
function quotaWindowPresentation(window: AccountQuotaWindow, minimumWidth: string) {
const percent = clamp(window.usedPercent ?? 0, 0, 100)
const tone = quotaWindowTone(window.usedPercent)
return {
barStyle: {
width: `${percent}%`,
minWidth: percent > 0 ? minimumWidth : '0',
},
barClass: quotaToneClasses[tone].bar,
percentTextClass: quotaToneClasses[tone].text,
}
}
function quotaWindowTone(usedPercent: number | null): QuotaWindowTone {
if (usedPercent === null)
return 'unknown'
if (usedPercent >= 95)
return 'danger'
if (usedPercent >= 80)
return 'warning'
return 'success'
}
function requestTimeline(
buckets: NonNullable<AccountLocalUsage['requestBuckets']>,
now: number,
) {
const currentHour = Math.floor(now / hourMilliseconds) * hourMilliseconds
const bucketCounts = new Map(
buckets.map((bucket) => {
const bucketHour = Math.floor(new Date(bucket.bucketStart).getTime() / hourMilliseconds)
* hourMilliseconds
return [bucketHour, Math.max(0, bucket.requestCount)] as const
}),
)
const slots = Array.from({ length: 24 }, (_, index) => {
const startTime = currentHour - (23 - index) * hourMilliseconds
return {
startTime,
requestCount: bucketCounts.get(startTime) ?? 0,
}
})
const maximum = Math.max(1, ...slots.map(slot => slot.requestCount))
return slots.map((slot) => {
const start = new Date(slot.startTime)
const end = new Date(slot.startTime + hourMilliseconds)
return {
key: start.toISOString(),
requestCount: slot.requestCount,
height: slot.requestCount === 0
? '0'
: `${Math.max(25, Math.round(slot.requestCount / maximum * 100))}%`,
title: `${hourFormatter.format(start)}–${hourFormatter.format(end)} · ${slot.requestCount} 次请求`,
}
})
}
@@ -42,26 +42,13 @@ interface ConnectionTestEvent {
model?: string
payload?: ConnectionTestRequestPayload
success?: boolean
upstreamBody?: string
upstreamStatus?: number
error?: string
}
function connectionTestErrorText(event: ConnectionTestEvent) {
if (typeof event.upstreamBody === 'string' && event.upstreamBody.length > 0)
return event.upstreamBody
if (Number.isInteger(event.upstreamStatus))
return `HTTP ${event.upstreamStatus}`
return event.error || '测试连接失败'
}
function connectionTestFailureLabel(event: ConnectionTestEvent) {
const hasUpstreamResponse
= (typeof event.upstreamBody === 'string' && event.upstreamBody.length > 0)
|| Number.isInteger(event.upstreamStatus)
return hasUpstreamResponse
? '上游返回错误'
: '未能向上游发起请求'
if (typeof event.error === 'string' && event.error.trim().length > 0)
return event.error
return '测试连接失败'
}
export function useAccountConnectionTest(options: { reload: () => Promise<unknown> }) {
@@ -284,11 +271,7 @@ export function useAccountConnectionTest(options: { reload: () => Promise<unknow
}
if (event.type === 'error') {
connectionTestError.value = connectionTestErrorText(event)
appendConnectionTestLog(
connectionTestFailureLabel(event),
'danger',
connectionTestError.value,
)
appendConnectionTestLog(connectionTestError.value, 'danger')
finishConnectionTest('error')
clearConnectionTestRun()
void options.reload()
@@ -8,7 +8,10 @@ import {
} from '@/api'
import { toast } from '@/components/base/BaseToast'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { providerDisplayName } from '@/utils/providers'
import { errorMessage } from '@/utils/async'
import { isRecord } from '@/utils/object'
import { formatProviderLabel, isSupportedProvider } from '@/utils/providers'
import { emptyAccountCreateForm } from '../components/AccountCreateModal/model'
type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
type ImportProvider = 'openai' | 'xai'
@@ -27,7 +30,7 @@ export function useAccountOnboarding(options: {
const authorizingOAuthAction = useAsyncAction()
const creatingAccount = creatingAccountAction.loading
const authorizingOAuth = authorizingOAuthAction.loading
const createForm = ref(emptyCreateForm())
const createForm = ref(emptyAccountCreateForm())
const showCreateModal = computed({
get: () => createModalOpen.value,
@@ -35,7 +38,7 @@ export function useAccountOnboarding(options: {
createModalOpen.value = value
if (!value) {
reauthorizingAccount.value = null
createForm.value = emptyCreateForm()
createForm.value = emptyAccountCreateForm()
}
},
})
@@ -124,7 +127,7 @@ export function useAccountOnboarding(options: {
function openCreateAccount() {
reauthorizingAccount.value = null
createForm.value = emptyCreateForm()
createForm.value = emptyAccountCreateForm()
showCreateModal.value = true
}
@@ -133,7 +136,7 @@ export function useAccountOnboarding(options: {
return
reauthorizingAccount.value = account
createForm.value = {
...emptyCreateForm(),
...emptyAccountCreateForm(),
provider: account.provider,
name: account.name,
mode: 'oauth',
@@ -164,7 +167,7 @@ export function useAccountOnboarding(options: {
})
importedCount += result.importedCount
}
return `${providerDisplayName(provider) ?? provider} 账号已导入 ${importedCount} 个`
return `${formatProviderLabel(provider)} 账号已导入 ${importedCount} 个`
}
async function importMixedAccountDocument() {
@@ -181,10 +184,8 @@ export function useAccountOnboarding(options: {
importedCount += result.importedCount
}
catch (error) {
const message = error instanceof Error && error.message
? error.message
: '导入失败'
failures.push(`${providerDisplayName(entry.provider) ?? 'OpenAI'}:${message}`)
const message = errorMessage(error, '导入失败')
failures.push(`${formatProviderLabel(entry.provider, 'OpenAI')}:${message}`)
}
}
@@ -230,34 +231,6 @@ export function useAccountOnboarding(options: {
}
}
function emptyCreateForm() {
return {
provider: '',
name: '',
mode: 'oauth',
importText: '',
oauthFlowId: '',
oauthAuthUrl: '',
oauthCallback: '',
}
}
export function accountProviderModeOptions(provider: string) {
if (provider === 'xai') {
return [
{ label: 'OAuth', value: 'oauth' },
{ label: '账号文件', value: 'json' },
]
}
if (provider !== 'openai')
return []
return [
{ label: 'OAuth', value: 'oauth' },
{ label: '账号文件', value: 'json' },
{ label: 'Agent 身份', value: 'agent_identity' },
]
}
function parseImportJson(value: string) {
try {
return JSON.parse(value)
@@ -268,7 +241,7 @@ function parseImportJson(value: string) {
}
function requireImportProvider(value: string): ImportProvider {
if (value === 'openai' || value === 'xai')
if (isSupportedProvider(value))
return value
throw new Error('请选择要导入的账号平台')
}
@@ -278,7 +251,7 @@ function providerImportDocuments(value: unknown, provider: ImportProvider): Mixe
const documents = parseMixedImportDocuments(value)
.filter(entry => entry.provider === provider)
if (documents.length === 0) {
const label = providerDisplayName(provider) ?? provider
const label = formatProviderLabel(provider)
throw new Error(`批量导入文件不包含 ${label} 账号文档`)
}
return documents
@@ -297,7 +270,7 @@ function parseMixedImportDocuments(value: unknown): MixedImportDocument[] {
if (!isRecord(entry))
throw new Error('批量导入文件包含无效的 Provider 文档')
const provider = entry.provider
if (provider !== 'openai' && provider !== 'xai')
if (!isSupportedProvider(provider))
throw new Error('批量导入文件包含无效的 Provider 文档')
if (!isRecord(entry.document))
throw new Error('批量导入文件包含无效的 Provider 文档')
@@ -308,7 +281,3 @@ function parseMixedImportDocuments(value: unknown): MixedImportDocument[] {
throw new Error('批量文件没有可导入的账号文档')
return documents
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
+2 -48
View File
@@ -1,24 +1,10 @@
import type { AccountErrorReason, AccountStatus, getAccounts } from '@/api'
import { clamp } from 'es-toolkit'
import { defineTableColumns } from '@/components/base/BaseTable/columns'
import { providerDisplayName } from '@/utils/providers'
import { formatProviderLabel } from '@/utils/providers'
export type AccountRow = Awaited<ReturnType<typeof getAccounts>>['items'][number]
export type AccountQuotaWindow = AccountRow['quota']['windows'][number]
export interface AccountRequestBucket {
bucketStart: string
requestCount: number
}
export interface AccountLocalUsage {
requestCount?: number
requestCountDisplay?: string
totalTokens?: number
totalTokensDisplay?: string
requestBuckets?: AccountRequestBucket[]
}
const quotaGroupOrder = new Map([
['shortTerm', 0],
['monthly', 1],
@@ -118,38 +104,6 @@ export function orderedPanelQuotaWindows(windows: AccountQuotaWindow[]) {
return [...windows].sort(compareQuotaWindows)
}
function quotaWindowPercent(window: AccountQuotaWindow) {
return clamp(window.usedPercent ?? 0, 0, 100)
}
export function quotaWindowBarStyle(window: AccountQuotaWindow, minimumWidth = '8px') {
const percent = quotaWindowPercent(window)
return {
width: `${percent}%`,
minWidth: percent > 0 ? minimumWidth : '0',
}
}
export function quotaWindowBarClass(window: AccountQuotaWindow) {
if (window.usedPercent === null)
return 'bg-cp-default-border-hover'
if (window.usedPercent >= 95)
return 'bg-cp-danger'
if (window.usedPercent >= 80)
return 'bg-cp-warning'
return 'bg-cp-success'
}
export function quotaWindowPercentTextClass(window: AccountQuotaWindow) {
if (window.usedPercent === null)
return 'text-cp-muted-text'
if (window.usedPercent >= 95)
return 'text-cp-danger-text'
if (window.usedPercent >= 80)
return 'text-cp-warning-text'
return 'text-cp-success-text'
}
export function modelSuccessRateTextClass(successRate: number | null) {
if (successRate === null)
return 'text-cp-muted-text'
@@ -181,7 +135,7 @@ function groupOrder(window: AccountQuotaWindow) {
}
function accountProviderLabel(value?: string | null) {
return providerDisplayName(value) ?? (value || '—')
return formatProviderLabel(value)
}
function optionalAccountCell(value: unknown) {
+3 -2
View File
@@ -14,7 +14,7 @@ import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { useAccountGroupCatalog } from '@/composables/useAccountGroupCatalog'
import AccountBatchEditModal from './components/AccountBatchEditModal.vue'
import AccountConnectionTestModal from './components/AccountConnectionTestModal.vue'
import AccountCreateModal from './components/AccountCreateModal.vue'
import AccountCreateModal from './components/AccountCreateModal/index.vue'
import AccountEditModal from './components/AccountEditModal.vue'
import AccountFilters from './components/AccountFilters.vue'
import AccountIdentityCell from './components/AccountIdentityCell.vue'
@@ -22,7 +22,7 @@ import AccountOverviewCards from './components/AccountOverviewCards.vue'
import AccountPlanBadge from './components/AccountPlanBadge.vue'
import AccountQuotaPanel from './components/AccountQuotaPanel.vue'
import AccountQuotaSummaryCell from './components/AccountQuotaSummaryCell.vue'
import AccountStatusBadge from './components/AccountStatusBadge.vue'
import AccountStatusBadge from './components/AccountStatusBadge/index.vue'
import AccountTableActions from './components/AccountTableActions.vue'
import AccountUsagePanel from './components/AccountUsagePanel.vue'
import { useAccountBatchEditor } from './composables/useAccountBatchEditor'
@@ -248,6 +248,7 @@ const {
:error-reason="row.errorReason"
:error-message="row.errorMessage"
:rate-limited-until="row.quota.rateLimitedUntil"
:next-refresh-at="row.nextRefreshAt"
/>
</template>
@@ -10,7 +10,7 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
import BaseForm from '@/components/base/BaseForm/index.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
const props = defineProps<{
groups: AccountGroup[]
@@ -4,7 +4,7 @@ import { computed, shallowRef } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
@@ -1,14 +1,14 @@
<script setup lang="ts">
import type { dashboardSnapshotView, MetricTone } from '../composables/presenter'
import type { dashboardSnapshotView, MetricTone } from '../composables/useDashboard'
import { CircleCheck, RefreshCw, ShieldAlert, TriangleAlert } from '@lucide/vue'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import ProviderIconGroup from '@/components/ProviderIconGroup.vue'
import { formatCompactNumber } from '@/utils/number'
import AccountIdentityCell from '@/views/accounts/components/AccountIdentityCell.vue'
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow.vue'
import { formatDashboardCompactNumber } from '../composables/presenter'
import AccountUsageWindow from '@/views/accounts/components/AccountUsageWindow/index.vue'
import { metricToneIconClasses, metricToneValueClasses } from '../constants'
type DashboardSnapshot = ReturnType<typeof dashboardSnapshotView>
@@ -24,7 +24,7 @@ const scheduleStats = computed(() => {
const cap = props.capacity
const display = (value: number | null | undefined) => value === null || value === undefined
? '—'
: formatDashboardCompactNumber(value)
: formatCompactNumber(value)
return [
{ label: '默认并发', value: display(cap?.maxConcurrentPerAccount) },
{ label: '总槽位', value: display(cap?.totalSlots) },
@@ -48,8 +48,8 @@ const usedRatio = computed(() => {
if (!cap || cap.totalSlots === null || cap.totalSlots === undefined)
return '— / —'
if (cap.usedSlots === null || cap.usedSlots === undefined)
return `— / ${formatDashboardCompactNumber(cap.totalSlots)}`
return `${formatDashboardCompactNumber(cap.usedSlots)} / ${formatDashboardCompactNumber(cap.totalSlots)}`
return `— / ${formatCompactNumber(cap.totalSlots)}`
return `${formatCompactNumber(cap.usedSlots)} / ${formatCompactNumber(cap.totalSlots)}`
})
const strategyLabel = computed(() => {
@@ -225,9 +225,7 @@ const statusBars = computed(() => {
>
<BaseEmpty
v-if="accounts.length === 0"
size="sm"
title="暂无账号用量"
description="暂无账号请求记录"
title="暂无账号请求记录"
class="min-h-40 place-content-center xl:h-full"
/>
<template v-else>
@@ -247,7 +245,6 @@ const statusBars = computed(() => {
<ProviderIconGroup
:provider="account.provider"
:authentication-kind="account.authenticationKind"
size="sm"
/>
</template>
</AccountIdentityCell>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { EChartsOption } from 'echarts'
import type { MetricCardView, MetricTone } from '../composables/presenter'
import type { MetricCardView, MetricTone } from '../composables/useDashboard'
import { computed } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../composables/presenter'
import type { dashboardTrendView, normalizeDashboardTrendKind } from '../composables/useDashboard'
import { toRef } from 'vue'
import BaseCard from '@/components/base/BaseCard.vue'
@@ -97,10 +97,8 @@ const {
<BaseChart v-if="hasSamples" :option="chartOption" :height="220" />
<BaseEmpty
v-if="!hasSamples"
size="sm"
surface="none"
title="暂无趋势数据"
description="当日暂无请求日志"
class="h-full place-content-center"
/>
</div>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { dashboardSnapshotView } from '../composables/presenter'
import type { dashboardSnapshotView } from '../composables/useDashboard'
import BaseCard from '@/components/base/BaseCard.vue'
import UsageRecordsTable from '@/views/usage/components/UsageRecordsTable.vue'
@@ -7,7 +7,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import { formatDateTime } from '@/utils/date'
import { providerDisplayName } from '@/utils/providers'
import { formatProviderLabel } from '@/utils/providers'
interface WireProfile {
provider: string
@@ -174,7 +174,7 @@ watch(
)
function providerLabel(provider: string) {
return providerDisplayName(provider) ?? provider
return formatProviderLabel(provider)
}
</script>
@@ -198,7 +198,6 @@ function providerLabel(provider: string) {
<template #body>
<BaseEmpty
v-if="!profile"
size="sm"
title="暂无请求身份"
class="min-h-71.75 flex-1 place-content-center"
/>
@@ -1,461 +0,0 @@
import type { Component } from 'vue'
import type { getDashboardSummary, getDashboardTrend } from '@/api'
import { Activity, FileText, Timer, Users } from '@lucide/vue'
import { clamp } from 'es-toolkit'
import { normalizeUsageRecord } from '@/api'
type DashboardSummary = Awaited<ReturnType<typeof getDashboardSummary>>
type DashboardTrend = Awaited<ReturnType<typeof getDashboardTrend>>
type DashboardTrendPoint = DashboardTrend['points'][number]
type DashboardTrendKind = DashboardTrend['kind']
export type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
export interface MetricCardView {
title: string
value: string
valueRaw?: number | null
valueFormatter?: (value: number) => string
icon: Component
tone: MetricTone
sparkline?: {
values: number[]
tone: MetricTone
}
trend?: {
direction: 'up' | 'down' | 'flat'
tone: MetricTone
}
details: Array<{
label: string
value: string
tone?: MetricTone
}>
}
const metricSparklineBuckets = 12
const emptyCards: DashboardSummary['cards'] = {
credentials: {
total: '0',
totalValue: 0,
available: '0',
availableValue: 0,
unavailable: '0',
unavailableValue: 0,
},
traffic: {
todayRequests: '0',
todayRequestsValue: 0,
yesterdayRequestsValue: 0,
totalRequests: '0',
},
tokens: {
todayTokens: '0',
todayTokensValue: 0,
yesterdayTokensValue: 0,
totalTokens: '0',
totalBillingAmountUsd: '—',
},
cache: {
todayHitRate: '—',
todayHitRateValue: null,
yesterdayHitRateValue: null,
totalHitRate: '—',
totalCachedTokens: '0',
averageFirstTokenLatencyMs: '—',
},
}
const emptyHealthTimeline: DashboardSummary['healthTimeline'] = {
title: '请求健康时间线',
description: '有效请求可用性',
reliabilityDisplay: '-',
status: 'no_data',
successRequests: 0,
failedRequests: 0,
cancelledRequests: 0,
incompleteRequests: 0,
callerErrorRequests: 0,
points: [],
}
const emptyCapacityInfo = {
maxConcurrentPerAccount: null,
totalSlots: null,
usedSlots: null,
availableSlots: null,
}
export function dashboardSnapshotView(summary: DashboardSummary | null) {
const trendPoints = summary?.trend.points ?? []
return {
metrics: metricCards(summary?.cards ?? emptyCards, trendPoints),
healthTimeline: summary?.healthTimeline ?? emptyHealthTimeline,
accountUsage: (summary?.accountUsage ?? []).map(accountUsageItem),
wireProfiles: summary?.wireProfiles ?? [],
usageRecords: (summary?.usageRecords ?? []).map(normalizeUsageRecord),
poolSummary: summary?.poolSummary ?? null,
capacityInfo: summary?.capacityInfo ?? emptyCapacityInfo,
rotationStrategy: summary?.rotationStrategy ?? null,
}
}
export function dashboardTrendView(trend: DashboardTrend | null) {
if (!trend) {
const points: ReturnType<typeof aggregateUsageTrend> = []
const summary: ReturnType<typeof usageTrendSummary> = []
return { points, summary }
}
if (trend.kind === 'usage') {
const points = aggregateUsageTrend(trend.points)
return { points, summary: usageTrendSummary(points) }
}
const summary = []
for (const item of trend.summary) {
summary.push({
label: item.label,
value: trend.kind === 'errors' && item.ratio !== null ? item.ratio : item.value,
tone: trendSummaryTone(item.label),
colorVar: trendSummaryColorVar(trend.kind, item.label),
})
}
return { points: trend.points, summary }
}
export function normalizeDashboardTrendKind(kind: string): DashboardTrendKind {
if (kind === 'latency' || kind === 'errors')
return kind
return 'usage'
}
function metricCards(
cards: DashboardSummary['cards'],
points: DashboardTrendPoint[],
): MetricCardView[] {
const { credentials, traffic, tokens, cache } = cards
const recentPoints = recentTrendWindow(points)
return [
{
title: '账号',
value: credentials.total,
valueRaw: credentials.totalValue,
valueFormatter: formatDashboardCompactNumber,
icon: Users,
tone: 'normal',
details: [
{
label: '可用',
value: credentials.available,
tone: credentials.availableValue > 0 ? 'success' : 'normal',
},
{
label: '不可用',
value: credentials.unavailable,
tone: credentials.unavailableValue > 0 ? 'danger' : 'normal',
},
],
},
{
title: '请求次数',
value: traffic.todayRequests,
valueRaw: traffic.todayRequestsValue,
valueFormatter: formatDashboardCompactNumber,
icon: Activity,
tone: 'info',
sparkline: sparkline(
recentPoints.map(point => point.requestsValue),
'info',
),
trend: trendState(traffic.todayRequestsValue, traffic.yesterdayRequestsValue, 'info'),
details: [
{ label: '总请求', value: traffic.totalRequests, tone: 'info' },
{ label: '首字均值', value: cache.averageFirstTokenLatencyMs, tone: 'info' },
],
},
{
title: 'Token',
value: tokens.todayTokens,
valueRaw: tokens.todayTokensValue,
valueFormatter: formatDashboardCompactNumber,
icon: FileText,
tone: 'success',
sparkline: sparkline(
recentPoints.map(point => point.tokensValue),
'success',
),
trend: trendState(tokens.todayTokensValue, tokens.yesterdayTokensValue, 'success'),
details: [
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
{ label: '总计费', value: formatDashboardUsd(tokens.totalBillingAmountUsd), tone: 'success' },
],
},
{
title: '缓存命中',
value: cache.todayHitRate,
valueRaw: cache.todayHitRateValue,
valueFormatter: formatDashboardRate,
icon: Timer,
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
sparkline: sparkline(
recentPoints.map(point => point.cacheHitRateValue),
'warning',
),
trend: trendState(
cache.todayHitRateValue ?? 0,
cache.yesterdayHitRateValue ?? 0,
'warning',
),
details: [
{ label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' },
{ label: '总缓存', value: cache.totalCachedTokens, tone: 'warning' },
],
},
]
}
function sparkline(values: number[], tone: MetricTone) {
return values.some(value => value > 0) ? { values, tone } : undefined
}
function recentTrendWindow(points: DashboardTrendPoint[]) {
let lastActiveIndex = points.length - 1
while (lastActiveIndex >= 0 && points[lastActiveIndex].requestsValue <= 0) lastActiveIndex -= 1
if (lastActiveIndex < 0)
return []
return points.slice(
Math.max(0, lastActiveIndex - (metricSparklineBuckets - 1)),
lastActiveIndex + 1,
)
}
export function formatDashboardCompactNumber(value: number) {
const normalized = Math.max(0, Math.round(value))
if (normalized < 1_000)
return new Intl.NumberFormat('zh-CN').format(normalized)
for (const [unit, threshold] of [
['P', 1_000_000_000_000_000],
['T', 1_000_000_000_000],
['B', 1_000_000_000],
['M', 1_000_000],
['K', 1_000],
] as const) {
if (normalized >= threshold) {
const scaled = normalized / threshold
const rounded = scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)
return `${rounded.replace(/\.?0+$/, '')}${unit}`
}
}
return new Intl.NumberFormat('zh-CN').format(normalized)
}
function formatDashboardRate(value: number) {
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
}
function aggregateUsageTrend(points: DashboardTrendPoint[]) {
return Array.from({ length: Math.ceil(points.length / 2) }, (_, groupIndex) => {
const group = points.slice(groupIndex * 2, groupIndex * 2 + 2)
const first = group[0]
const requestsValue = sum(group, point => point.requestsValue)
const errorsValue = sum(group, point => point.errorsValue)
const inputTokensValue = sum(group, point => point.inputTokensValue)
const outputTokensValue = sum(group, point => point.outputTokensValue)
const cachedTokensValue = Math.min(
inputTokensValue,
sum(group, point => point.cachedTokensValue),
)
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null
const successRateValue = requestsValue
? ((requestsValue - errorsValue) / requestsValue) * 100
: null
return {
...first,
requests: formatDashboardCompactNumber(requestsValue),
requestsValue,
inputTokens: formatDashboardCompactNumber(inputTokensValue),
inputTokensValue,
outputTokens: formatDashboardCompactNumber(outputTokensValue),
outputTokensValue,
cachedTokens: formatDashboardCompactNumber(cachedTokensValue),
cachedTokensValue,
uncachedInputTokens: formatDashboardCompactNumber(uncachedInputTokensValue),
uncachedInputTokensValue,
effectiveTokens: formatDashboardCompactNumber(effectiveTokensValue),
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue),
cacheHitRateValue,
tokensValue: inputTokensValue + outputTokensValue,
errors: formatDashboardCompactNumber(errorsValue),
errorsValue,
successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`,
successRateValue,
}
})
}
function sum(points: DashboardTrendPoint[], selector: (point: DashboardTrendPoint) => number) {
return points.reduce((total, point) => total + selector(point), 0)
}
function usageTrendSummary(points: ReturnType<typeof aggregateUsageTrend>) {
const inputTokens = points.reduce((total, point) => total + point.inputTokensValue, 0)
const outputTokens = points.reduce((total, point) => total + point.outputTokensValue, 0)
const cachedTokens = Math.min(
inputTokens,
points.reduce((total, point) => total + point.cachedTokensValue, 0),
)
return [
{
label: '输入',
value: formatDashboardCompactNumber(inputTokens),
tone: 'info',
colorVar: '--cp-info',
},
{
label: '输出',
value: formatDashboardCompactNumber(outputTokens),
tone: 'success',
colorVar: '--cp-success',
},
{
label: '缓存',
value: formatDashboardCompactNumber(cachedTokens),
tone: 'normal',
colorVar: '--cp-text-tertiary',
},
]
}
function accountUsageItem(item: DashboardSummary['accountUsage'][number]) {
const usageWindow = dashboardUsageWindow(item)
const requestCount = usageWindow.localUsage?.requestCount
const usesDailyRequests = typeof requestCount === 'number'
return {
id: item.id,
provider: item.provider,
authenticationKind: item.authenticationKind,
email: item.email,
planType: item.planType,
tokens: item.tokens,
lastUsed: item.lastUsed,
metricLabel: usesDailyRequests ? '次数' : '今日 Token',
metricValue: usesDailyRequests
? requestCount > 0 ? usageWindow.localUsage?.requestCountDisplay : '—'
: item.tokens,
usageWindow,
}
}
function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
const provider = item.provider.trim().toLowerCase()
const planType = item.planType?.trim().toLowerCase() || 'free'
if (provider !== 'xai' || planType !== 'free') {
const usedPercent = quotaUsedPercent(item.quotaUsedPercent)
return {
key: 'quota',
group: 'other',
labelDisplay: '额度',
usedPercent,
usedPercentDisplay: usedPercent === null ? '—' : `${usedPercent}%`,
limitReached: (usedPercent ?? 0) >= 100,
windowSeconds: null,
resetAtDisplay: '—',
}
}
const requestCount = typeof item.requestCount === 'number' && Number.isFinite(item.requestCount)
? Math.max(0, item.requestCount)
: 0
const requestBuckets = Array.isArray(item.requestBuckets)
? item.requestBuckets.map((bucket: { bucketStart: string, requestCount: number }) => ({
bucketStart: bucket.bucketStart,
requestCount: bucket.requestCount,
}))
: []
return {
key: 'dailyRequests',
group: 'other',
labelDisplay: '日请求',
usedPercent: null,
usedPercentDisplay: '—',
limitReached: false,
windowSeconds: 86_400,
resetAtDisplay: '—',
localUsage: {
requestCount,
requestCountDisplay: formatDashboardCompactNumber(requestCount),
requestBuckets,
},
}
}
function quotaUsedPercent(value: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value))
return null
return clamp(Math.round(value), 0, 100)
}
function formatDashboardUsd(value: string) {
const normalized = value.trim()
if (!normalized.startsWith('$'))
return value
const amount = Number(normalized.slice(1).replaceAll(',', ''))
return Number.isFinite(amount) ? `$${amount.toFixed(2)}` : value
}
function trendState(
current: number,
previous: number,
fallbackTone: MetricTone,
): MetricCardView['trend'] {
if (current > previous)
return { direction: 'up', tone: 'success' }
if (current < previous)
return { direction: 'down', tone: 'danger' }
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
}
function trendSummaryTone(label: string) {
if (label.includes('错误'))
return 'danger'
if (label.includes('最高'))
return 'warning'
if (label.includes('输出') || label.includes('最低') || label.includes('成功'))
return 'success'
if (label.includes('缓存'))
return 'normal'
return 'info'
}
function trendSummaryColorVar(kind: DashboardTrendKind, label: string) {
if (kind === 'latency') {
if (label.includes('最高'))
return '--cp-warning'
if (label.includes('最低'))
return '--cp-success'
return '--cp-normal'
}
if (kind === 'errors') {
if (label.includes('错误'))
return '--cp-danger'
if (label.includes('成功'))
return '--cp-success'
return '--cp-info'
}
if (label.includes('输出'))
return '--cp-success'
if (label.includes('缓存'))
return '--cp-text-tertiary'
return '--cp-info'
}
@@ -1,15 +1,14 @@
import type { Component } from 'vue'
import { Activity, FileText, Timer, Users } from '@lucide/vue'
import { useIntervalFn } from '@vueuse/core'
import { clamp } from 'es-toolkit'
import { computed, onMounted, shallowRef } from 'vue'
import { getDashboardSummary, getDashboardTrend } from '@/api'
import { withMinimumDuration } from '@/utils/async'
import { formatDateTime } from '@/utils/date'
import {
dashboardSnapshotView,
dashboardTrendView,
normalizeDashboardTrendKind,
} from './presenter'
import { formatCompactNumber } from '@/utils/number'
import { normalizeUsageRecord } from '@/views/usage/utils/records'
export function useDashboard() {
const activeTrendKind = shallowRef(normalizeDashboardTrendKind('usage'))
@@ -125,3 +124,436 @@ export function useDashboard() {
loadTrend,
}
}
type DashboardSummary = Awaited<ReturnType<typeof getDashboardSummary>>
type DashboardTrend = Awaited<ReturnType<typeof getDashboardTrend>>
type DashboardTrendPoint = DashboardTrend['points'][number]
type DashboardTrendKind = DashboardTrend['kind']
export type MetricTone = 'normal' | 'info' | 'success' | 'warning' | 'danger'
export interface MetricCardView {
title: string
value: string
valueRaw?: number | null
valueFormatter?: (value: number) => string
icon: Component
tone: MetricTone
sparkline?: {
values: number[]
tone: MetricTone
}
trend?: {
direction: 'up' | 'down' | 'flat'
tone: MetricTone
}
details: Array<{
label: string
value: string
tone?: MetricTone
}>
}
const metricSparklineBuckets = 12
const emptyCards: DashboardSummary['cards'] = {
credentials: {
total: '0',
totalValue: 0,
available: '0',
availableValue: 0,
unavailable: '0',
unavailableValue: 0,
},
traffic: {
todayRequests: '0',
todayRequestsValue: 0,
yesterdayRequestsValue: 0,
totalRequests: '0',
},
tokens: {
todayTokens: '0',
todayTokensValue: 0,
yesterdayTokensValue: 0,
totalTokens: '0',
totalBillingAmountUsd: '—',
},
cache: {
todayHitRate: '—',
todayHitRateValue: null,
yesterdayHitRateValue: null,
totalHitRate: '—',
totalCachedTokens: '0',
averageFirstTokenLatencyMs: '—',
},
}
const emptyHealthTimeline: DashboardSummary['healthTimeline'] = {
title: '请求健康时间线',
description: '有效请求可用性',
reliabilityDisplay: '-',
status: 'no_data',
successRequests: 0,
failedRequests: 0,
cancelledRequests: 0,
incompleteRequests: 0,
callerErrorRequests: 0,
points: [],
}
const emptyCapacityInfo = {
maxConcurrentPerAccount: null,
totalSlots: null,
usedSlots: null,
availableSlots: null,
}
export function dashboardSnapshotView(summary: DashboardSummary | null) {
const trendPoints = summary?.trend.points ?? []
return {
metrics: metricCards(summary?.cards ?? emptyCards, trendPoints),
healthTimeline: summary?.healthTimeline ?? emptyHealthTimeline,
accountUsage: (summary?.accountUsage ?? []).map(accountUsageItem),
wireProfiles: summary?.wireProfiles ?? [],
usageRecords: (summary?.usageRecords ?? []).map(normalizeUsageRecord),
poolSummary: summary?.poolSummary ?? null,
capacityInfo: summary?.capacityInfo ?? emptyCapacityInfo,
rotationStrategy: summary?.rotationStrategy ?? null,
}
}
export function dashboardTrendView(trend: DashboardTrend | null) {
if (!trend) {
const points: ReturnType<typeof aggregateUsageTrend> = []
const summary: ReturnType<typeof usageTrendSummary> = []
return { points, summary }
}
if (trend.kind === 'usage') {
const points = aggregateUsageTrend(trend.points)
return { points, summary: usageTrendSummary(points) }
}
const summary = []
for (const item of trend.summary) {
summary.push({
label: item.label,
value: trend.kind === 'errors' && item.ratio !== null ? item.ratio : item.value,
tone: trendSummaryTone(item.label),
colorVar: trendSummaryColorVar(trend.kind, item.label),
})
}
return { points: trend.points, summary }
}
export function normalizeDashboardTrendKind(kind: string): DashboardTrendKind {
if (kind === 'latency' || kind === 'errors')
return kind
return 'usage'
}
function metricCards(
cards: DashboardSummary['cards'],
points: DashboardTrendPoint[],
): MetricCardView[] {
const { credentials, traffic, tokens, cache } = cards
const recentPoints = recentTrendWindow(points)
return [
{
title: '账号',
value: credentials.total,
valueRaw: credentials.totalValue,
valueFormatter: formatCompactNumber,
icon: Users,
tone: 'normal',
details: [
{
label: '可用',
value: credentials.available,
tone: credentials.availableValue > 0 ? 'success' : 'normal',
},
{
label: '不可用',
value: credentials.unavailable,
tone: credentials.unavailableValue > 0 ? 'danger' : 'normal',
},
],
},
{
title: '请求次数',
value: traffic.todayRequests,
valueRaw: traffic.todayRequestsValue,
valueFormatter: formatCompactNumber,
icon: Activity,
tone: 'info',
sparkline: sparkline(
recentPoints.map(point => point.requestsValue),
'info',
),
trend: trendState(traffic.todayRequestsValue, traffic.yesterdayRequestsValue, 'info'),
details: [
{ label: '总请求', value: traffic.totalRequests, tone: 'info' },
{ label: '首字均值', value: cache.averageFirstTokenLatencyMs, tone: 'info' },
],
},
{
title: 'Token',
value: tokens.todayTokens,
valueRaw: tokens.todayTokensValue,
valueFormatter: formatCompactNumber,
icon: FileText,
tone: 'success',
sparkline: sparkline(
recentPoints.map(point => point.tokensValue),
'success',
),
trend: trendState(tokens.todayTokensValue, tokens.yesterdayTokensValue, 'success'),
details: [
{ label: '总 Token', value: tokens.totalTokens, tone: 'success' },
{ label: '总计费', value: formatDashboardUsd(tokens.totalBillingAmountUsd), tone: 'success' },
],
},
{
title: '缓存命中',
value: cache.todayHitRate,
valueRaw: cache.todayHitRateValue,
valueFormatter: formatDashboardRate,
icon: Timer,
tone: cache.todayHitRateValue && cache.todayHitRateValue > 0 ? 'warning' : 'normal',
sparkline: sparkline(
recentPoints.map(point => point.cacheHitRateValue),
'warning',
),
trend: trendState(
cache.todayHitRateValue ?? 0,
cache.yesterdayHitRateValue ?? 0,
'warning',
),
details: [
{ label: '总缓存命中', value: cache.totalHitRate, tone: 'warning' },
{ label: '总缓存', value: cache.totalCachedTokens, tone: 'warning' },
],
},
]
}
function sparkline(values: number[], tone: MetricTone) {
return values.some(value => value > 0) ? { values, tone } : undefined
}
function recentTrendWindow(points: DashboardTrendPoint[]) {
let lastActiveIndex = points.length - 1
while (lastActiveIndex >= 0 && points[lastActiveIndex].requestsValue <= 0) lastActiveIndex -= 1
if (lastActiveIndex < 0)
return []
return points.slice(
Math.max(0, lastActiveIndex - (metricSparklineBuckets - 1)),
lastActiveIndex + 1,
)
}
function formatDashboardRate(value: number) {
return Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '—'
}
function aggregateUsageTrend(points: DashboardTrendPoint[]) {
return Array.from({ length: Math.ceil(points.length / 2) }, (_, groupIndex) => {
const group = points.slice(groupIndex * 2, groupIndex * 2 + 2)
const first = group[0]
const requestsValue = sum(group, point => point.requestsValue)
const errorsValue = sum(group, point => point.errorsValue)
const inputTokensValue = sum(group, point => point.inputTokensValue)
const outputTokensValue = sum(group, point => point.outputTokensValue)
const cachedTokensValue = Math.min(
inputTokensValue,
sum(group, point => point.cachedTokensValue),
)
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null
const successRateValue = requestsValue
? ((requestsValue - errorsValue) / requestsValue) * 100
: null
return {
...first,
requests: formatCompactNumber(requestsValue),
requestsValue,
inputTokens: formatCompactNumber(inputTokensValue),
inputTokensValue,
outputTokens: formatCompactNumber(outputTokensValue),
outputTokensValue,
cachedTokens: formatCompactNumber(cachedTokensValue),
cachedTokensValue,
uncachedInputTokens: formatCompactNumber(uncachedInputTokensValue),
uncachedInputTokensValue,
effectiveTokens: formatCompactNumber(effectiveTokensValue),
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue),
cacheHitRateValue,
tokensValue: inputTokensValue + outputTokensValue,
errors: formatCompactNumber(errorsValue),
errorsValue,
successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`,
successRateValue,
}
})
}
function sum(points: DashboardTrendPoint[], selector: (point: DashboardTrendPoint) => number) {
return points.reduce((total, point) => total + selector(point), 0)
}
function usageTrendSummary(points: ReturnType<typeof aggregateUsageTrend>) {
const inputTokens = points.reduce((total, point) => total + point.inputTokensValue, 0)
const outputTokens = points.reduce((total, point) => total + point.outputTokensValue, 0)
const cachedTokens = Math.min(
inputTokens,
points.reduce((total, point) => total + point.cachedTokensValue, 0),
)
return [
{
label: '输入',
value: formatCompactNumber(inputTokens),
tone: 'info',
colorVar: '--cp-info',
},
{
label: '输出',
value: formatCompactNumber(outputTokens),
tone: 'success',
colorVar: '--cp-success',
},
{
label: '缓存',
value: formatCompactNumber(cachedTokens),
tone: 'normal',
colorVar: '--cp-text-tertiary',
},
]
}
function accountUsageItem(item: DashboardSummary['accountUsage'][number]) {
const usageWindow = dashboardUsageWindow(item)
const requestCount = usageWindow.localUsage?.requestCount
const usesDailyRequests = typeof requestCount === 'number'
return {
id: item.id,
provider: item.provider,
authenticationKind: item.authenticationKind,
email: item.email,
planType: item.planType,
tokens: item.tokens,
lastUsed: item.lastUsed,
metricLabel: usesDailyRequests ? '次数' : '今日 Token',
metricValue: usesDailyRequests
? requestCount > 0 ? usageWindow.localUsage?.requestCountDisplay : '—'
: item.tokens,
usageWindow,
}
}
function dashboardUsageWindow(item: DashboardSummary['accountUsage'][number]) {
const provider = item.provider.trim().toLowerCase()
const planType = item.planType?.trim().toLowerCase() || 'free'
if (provider !== 'xai' || planType !== 'free') {
const usedPercent = quotaUsedPercent(item.quotaUsedPercent)
return {
key: 'quota',
group: 'other',
labelDisplay: '额度',
usedPercent,
usedPercentDisplay: usedPercent === null ? '—' : `${usedPercent}%`,
limitReached: (usedPercent ?? 0) >= 100,
windowSeconds: null,
resetAtDisplay: '—',
}
}
const requestCount = typeof item.requestCount === 'number' && Number.isFinite(item.requestCount)
? Math.max(0, item.requestCount)
: 0
const requestBuckets = Array.isArray(item.requestBuckets)
? item.requestBuckets.map((bucket: { bucketStart: string, requestCount: number }) => ({
bucketStart: bucket.bucketStart,
requestCount: bucket.requestCount,
}))
: []
return {
key: 'dailyRequests',
group: 'other',
labelDisplay: '日请求',
usedPercent: null,
usedPercentDisplay: '—',
limitReached: false,
windowSeconds: 86_400,
resetAtDisplay: '—',
localUsage: {
requestCount,
requestCountDisplay: formatCompactNumber(requestCount),
requestBuckets,
},
}
}
function quotaUsedPercent(value: number | null) {
if (typeof value !== 'number' || !Number.isFinite(value))
return null
return clamp(Math.round(value), 0, 100)
}
function formatDashboardUsd(value: string) {
const normalized = value.trim()
if (!normalized.startsWith('$'))
return value
const amount = Number(normalized.slice(1).replaceAll(',', ''))
return Number.isFinite(amount) ? `$${amount.toFixed(2)}` : value
}
function trendState(
current: number,
previous: number,
fallbackTone: MetricTone,
): MetricCardView['trend'] {
if (current > previous)
return { direction: 'up', tone: 'success' }
if (current < previous)
return { direction: 'down', tone: 'danger' }
return previous > 0 || current > 0 ? { direction: 'flat', tone: fallbackTone } : undefined
}
function trendSummaryTone(label: string) {
if (label.includes('错误'))
return 'danger'
if (label.includes('最高'))
return 'warning'
if (label.includes('输出') || label.includes('最低') || label.includes('成功'))
return 'success'
if (label.includes('缓存'))
return 'normal'
return 'info'
}
function trendSummaryColorVar(kind: DashboardTrendKind, label: string) {
if (kind === 'latency') {
if (label.includes('最高'))
return '--cp-warning'
if (label.includes('最低'))
return '--cp-success'
return '--cp-normal'
}
if (kind === 'errors') {
if (label.includes('错误'))
return '--cp-danger'
if (label.includes('成功'))
return '--cp-success'
return '--cp-info'
}
if (label.includes('输出'))
return '--cp-success'
if (label.includes('缓存'))
return '--cp-text-tertiary'
return '--cp-info'
}
@@ -1,11 +1,11 @@
import type { BarSeriesOption, EChartsOption, LineSeriesOption } from 'echarts'
import type { Ref } from 'vue'
import type { dashboardTrendView, normalizeDashboardTrendKind } from './presenter'
import type { dashboardTrendView, normalizeDashboardTrendKind } from './useDashboard'
import { usePreferredReducedMotion } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { tooltipIndex } from '@/components/charts/tooltip'
import { useThemeColor } from '@/composables/useThemeColor'
import { chartTooltipStyle, tooltipIndex } from '@/components/charts/tooltip'
import { useChartPalette } from '@/composables/useChartPalette'
type TrendKind = ReturnType<typeof normalizeDashboardTrendKind>
type TrendView = ReturnType<typeof dashboardTrendView>
@@ -51,7 +51,7 @@ export function useRequestTrendChart(options: {
onTrendChange: (kind: TrendKind) => void
}) {
const { points, summary, activeKind } = options
const themeColor = useThemeColor()
const { color: themeColor, palette } = useChartPalette()
const preferredMotion = usePreferredReducedMotion()
const pinnedSummaryLabel = shallowRef<string>()
@@ -84,34 +84,21 @@ export function useRequestTrendChart(options: {
animationEasingUpdate: 'cubicOut',
tooltip: {
trigger: 'axis',
confine: true,
backgroundColor: themeColor('--cp-bg-surface', '#fff'),
borderColor: 'transparent',
borderWidth: 0,
padding: [10, 14],
textStyle: {
color: themeColor('--cp-text-primary', '#334155'),
fontSize: 12,
...chartTooltipStyle(palette.value, {
axisPointer: true,
confine: true,
fontFamily: 'Inter, system-ui, sans-serif',
fontWeight: 600,
},
extraCssText: 'border-radius: 12px; box-shadow: var(--cp-shadow-popover);',
axisPointer: {
type: 'line',
lineStyle: {
color: themeColor('--cp-default-border-hover', '#E2E8F0'),
type: 'dashed',
},
},
}),
formatter: formatTooltip,
},
}
})
function getCoordinateSystem(times: string[]) {
const muted = themeColor('--cp-text-muted', '#94A3B8')
const gridLine = themeColor('--cp-divider-subtle', '#E2E8F0')
const axisLine = themeColor('--cp-default-border', '#E2E8F0')
const muted = palette.value.textMuted
const gridLine = palette.value.divider
const axisLine = palette.value.border
return {
grid: {
left: 8,
@@ -207,7 +194,7 @@ export function useRequestTrendChart(options: {
trendPointText(point, 'cacheHitRate'),
trendColor('缓存', '--cp-text-tertiary', '#94A3B8'),
),
tooltipItem('请求', point?.requests, themeColor('--cp-text-secondary', '#64748B')),
tooltipItem('请求', point?.requests, palette.value.textSecondary),
]
.filter(Boolean)
.join('<br/>')
+3 -2
View File
@@ -1,4 +1,5 @@
import type { MetricTone } from './composables/presenter'
import type { MetricTone } from './composables/useDashboard'
import { formatInteger } from '@/utils/number'
// tone 到 Tailwind 类的唯一映射;仪表盘各卡片共用。
export const metricToneIconClasses: Record<MetricTone, string> = {
@@ -105,5 +106,5 @@ export function healthReliabilityValueClass(successRequests: number, failedReque
}
export function formatHealthCount(value: number) {
return Math.max(0, value).toLocaleString('zh-CN')
return formatInteger(Math.max(0, value))
}
@@ -1,6 +1,6 @@
<script setup lang="ts">
import BaseButton from '@/components/base/BaseButton.vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
const open = defineModel<boolean>({ default: false })
</script>
@@ -2,8 +2,9 @@
import type { getOpsErrors } from '@/api'
import { computed } from 'vue'
import BaseModal from '@/components/base/BaseModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { displayValue } from '../utils/detail'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
import UsageStatusCodeBadge from './UsageStatusCodeBadge.vue'
const props = defineProps<{
@@ -37,10 +38,6 @@ const metadataText = computed(() => {
return JSON.stringify(metadata, null, 2)
})
function display(value: unknown) {
return value === undefined || value === null || value === '' ? '—' : String(value)
}
function latencyDisplay(value: unknown) {
return typeof value === 'number' ? `${value} ms` : '—'
}
@@ -67,14 +64,14 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
<div class="flex flex-wrap items-center gap-3">
<UsageStatusCodeBadge :status-code="record.clientStatusCode" />
<span class="text-[12px] font-bold text-cp-secondary">
客户端 {{ display(record.clientStatusCode) }}
客户端 {{ displayValue(record.clientStatusCode) }}
</span>
<span class="text-[12px] font-bold text-cp-secondary">
上游 {{ display(record.upstreamStatusCode) }}
上游 {{ displayValue(record.upstreamStatusCode) }}
</span>
</div>
<p class="mt-3 mb-0 text-[13px] leading-relaxed font-bold text-cp-primary">
{{ display(record.message) }}
{{ displayValue(record.message) }}
</p>
</section>
@@ -92,9 +89,9 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
</dt>
<dd
class="mt-1.5 mb-0 truncate font-mono text-[12px] leading-normal font-emphasis text-cp-primary"
:title="display(field.value)"
:title="displayValue(field.value)"
>
{{ display(field.value) }}
{{ displayValue(field.value) }}
</dd>
</div>
</dl>
@@ -103,20 +100,7 @@ function providerKindLabel(record: Awaited<ReturnType<typeof getOpsErrors>>['ite
v-if="metadataText"
class="mt-3 rounded-cp-surface bg-cp-subtle px-4 py-3.5"
>
<h3 class="m-0 text-[12px] leading-none font-heavy text-cp-secondary">
Metadata
</h3>
<BaseScrollbar
class="mt-3"
max-height="260px"
>
<div class="rounded-cp-control bg-cp-surface px-3 py-2.5">
<pre
class="m-0 whitespace-pre-wrap wrap-break-word font-mono text-[12px] leading-[1.65] text-cp-primary"
v-text="metadataText"
/>
</div>
</BaseScrollbar>
<UsageDetailCodePanel title="Metadata" max-height="260px" :content="metadataText" />
</section>
</template>
</BaseModal>
@@ -7,27 +7,28 @@ import BaseCard from '@/components/base/BaseCard.vue'
import BaseEmpty from '@/components/base/BaseEmpty.vue'
import BaseSegmented from '@/components/base/BaseSegmented.vue'
import BaseChart from '@/components/charts/BaseChart.vue'
import { useUsageChartPalette } from '../composables/useUsageChartPalette'
import { useChartPalette } from '@/composables/useChartPalette'
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
import {
tooltipIndex,
tooltipRows,
usageCategoryAxis,
usageLegend,
usageLineSeries,
usageTooltip,
usageValueAxis,
} from '../utils/chart'
import {
decimalDisplayNumber,
escapeTooltip,
formatCompactNumber,
formatPercent,
formatUsd,
formatUsdAxis,
} from '../utils/format'
type Cost = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['cost']
type UsageChartPalette = ReturnType<typeof useUsageChartPalette>['palette']['value']
type UsageChartPalette = ReturnType<typeof useChartPalette>['palette']['value']
const props = withDefaults(
defineProps<{
@@ -40,7 +41,7 @@ const props = withDefaults(
)
const activeView = shallowRef('cost')
const { palette } = useUsageChartPalette()
const { palette } = useChartPalette()
const points = computed(() => props.cost.points)
const hasStandardCost = computed(() =>
points.value.some(point => point.standardCost != null),
@@ -107,35 +108,35 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
const chartPoints = points.value
if (activeView.value === 'tokens') {
return [
lineSeries(
usageLineSeries(
'未缓存输入',
chartPoints.map(point => Math.max(0, point.inputTokens - point.cachedTokens)),
theme.info,
'tokens',
{ stack: 'tokens' },
),
lineSeries(
usageLineSeries(
'缓存输入',
chartPoints.map(point => point.cachedTokens),
theme.normal,
'tokens',
{ stack: 'tokens' },
),
lineSeries(
usageLineSeries(
'输出',
chartPoints.map(point => point.outputTokens),
theme.success,
'tokens',
{ stack: 'tokens' },
),
]
}
if (activeView.value === 'cache') {
return [
lineSeries(
usageLineSeries(
'缓存 Token 占比',
chartPoints.map(point => point.cachedTokenRate),
theme.normal,
),
lineSeries(
usageLineSeries(
'命中请求率',
chartPoints.map(point => point.cacheHitRequestRate),
theme.success,
@@ -144,17 +145,16 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
}
const series = [
lineSeries(
usageLineSeries(
'预估费用',
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
theme.success,
undefined,
true,
{ area: 'strong' },
),
]
if (hasStandardCost.value) {
series.push(
lineSeries(
usageLineSeries(
'标准费用',
chartPoints.map(point => decimalDisplayNumber(point.standardCost)),
theme.textMuted,
@@ -164,43 +164,6 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
return series
}
function lineSeries(
name: string,
data: Array<number | null>,
color: string,
stack?: string,
area = Boolean(stack),
): LineSeriesOption {
return {
name,
type: 'line',
data: data.map(value => value ?? null),
connectNulls: false,
stack,
smooth: true,
showSymbol: data.length <= 12,
symbol: 'circle',
symbolSize: 5,
lineStyle: { color, width: 2.2 },
itemStyle: { color },
areaStyle: area
? {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: `${color}30` },
{ offset: 1, color: `${color}08` },
],
},
}
: undefined,
}
}
function formatTooltip(params: unknown) {
const rows = tooltipRows(params)
const point = points.value[tooltipIndex(rows[0])]

Some files were not shown because too many files have changed in this diff Show More