diff --git a/backend/crates/gateway-admin/src/model/upstream_info.rs b/backend/crates/gateway-admin/src/model/upstream_info.rs index bd2e60d14..3ed12e289 100644 --- a/backend/crates/gateway-admin/src/model/upstream_info.rs +++ b/backend/crates/gateway-admin/src/model/upstream_info.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderUpstreamInfo { - /// 当前连接曾成功识别互联,失权时保留身份但清除观测内容 + /// 当前连接的互联识别结果,明确关闭共享或不支持协议时清除 pub discovered: bool, pub observed_at: Option>, pub stale: bool, diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/cache.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/cache.rs index 7ad146e7d..6676f8169 100644 --- a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/cache.rs +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/cache.rs @@ -56,8 +56,7 @@ impl PeerInfoService { checked_at: now, scheduling: None, summary: None, - unsupported: false, - unavailable: None, + stored_failure: None, }, )); } @@ -93,21 +92,19 @@ impl PeerInfoService { ( connection.clone(), cached.and_then(|cached| cached.summary.clone()), - cached.and_then(|cached| cached.unavailable), - cached.is_some_and(|cached| cached.unsupported), + cached.and_then(|cached| cached.stored_failure), ) }) .collect::>() }; Ok(summaries .into_iter() - .map(|(connection, peer, unavailable, unsupported)| { + .map(|(connection, peer, stored_failure)| { let quota = Self::project_peer( &connection, peer.as_deref(), - unavailable, - self.last_error(&connection) - .or(unsupported.then_some(UpstreamInfoFailure::Unsupported)), + stored_failure, + self.last_error(&connection), now, ); (connection, quota) @@ -138,10 +135,8 @@ impl PeerInfoService { checked_at: now, scheduling, summary, - unsupported: observation - .is_some_and(|value| snapshot::is_unsupported(&value.quota, connection)), - unavailable: observation - .and_then(|value| snapshot::unavailable_reason(&value.quota, connection)), + stored_failure: observation + .and_then(|value| snapshot::stored_failure(&value.quota, connection)), }; let previous = self .observations @@ -156,6 +151,9 @@ impl PeerInfoService { .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .get(&connection.account_id) - .is_some_and(|cached| cached.connection == *connection && cached.unsupported) + .is_some_and(|cached| { + cached.connection == *connection + && cached.stored_failure == Some(UpstreamInfoFailure::Unsupported) + }) } } diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/projection.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/projection.rs index f3ab5ebd4..9fdf76499 100644 --- a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/projection.rs +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/projection.rs @@ -40,7 +40,7 @@ impl PeerInfoService { pub(super) fn project_peer( connection: &UpstreamConnection, peer: Option<&PeerLink>, - unavailable: Option, + stored_failure: Option, last_error: Option, now: SystemTime, ) -> ProviderQuota { @@ -52,12 +52,12 @@ impl PeerInfoService { }); let limits = peer.and_then(|peer| peer.key_limits); let info = ProviderUpstreamInfo { - discovered: peer.is_some() || unavailable.is_some(), + discovered: peer.is_some() || stored_failure == Some(UpstreamInfoFailure::Unauthorized), observed_at, stale, connection_revision: connection.revision.clone(), last_error: last_error - .or(unavailable) + .or(stored_failure) .map(|error| error.as_str().to_owned()), max_concurrency: limits.and_then(|limits| limits.max_concurrency), requests_per_minute: limits.and_then(|limits| limits.requests_per_minute), diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/refresh.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/refresh.rs index d382d8b2a..5648fcd05 100644 --- a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/refresh.rs +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/refresh.rs @@ -105,8 +105,8 @@ impl PeerInfoService { { let error = state.last_error; drop(state); - if let Some(error) = error.filter(|error| *error != UpstreamInfoFailure::Unsupported) { - return Err(refresh_error(error)); + if let Some(error) = error.and_then(refresh_error) { + return Err(error); } return self .read_quota(id) @@ -130,7 +130,9 @@ impl PeerInfoService { let failure = result.as_ref().err().copied(); let delay = match failure { None => Duration::from_secs(240 + stable_jitter(id.as_str())), - Some(UpstreamInfoFailure::Unauthorized) => Duration::from_secs(1800), + Some(UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::SharingDisabled) => { + Duration::from_secs(1800) + } Some(_) => Duration::from_secs(30), } .max(retry_after.unwrap_or_default()); @@ -141,7 +143,11 @@ impl PeerInfoService { state.last_error = failure; let document = match result { Ok(document) => Some(document), - Err(error @ (UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::Unsupported)) => { + Err(UpstreamInfoFailure::Unsupported) => Some(snapshot::unsupported(&connection)), + Err(error @ UpstreamInfoFailure::SharingDisabled) => { + Some(snapshot::unavailable(&connection, error)) + } + Err(error @ UpstreamInfoFailure::Unauthorized) => { let discovered = self .read_summary(id) .await @@ -153,8 +159,6 @@ impl PeerInfoService { // 首次探测失败不能证明对方是互联节点;已确认的连接只留下无共享内容的识别记录 Some(if discovered { snapshot::unavailable(&connection, error) - } else if error == UpstreamInfoFailure::Unsupported { - snapshot::unsupported(&connection) } else { OpaqueProviderData::new(Default::default()) }) @@ -199,8 +203,8 @@ impl PeerInfoService { } drop(state); // 不支持协议是正常发现结果,管理端保留分类,后台不重复报警 - if let Some(error) = failure.filter(|error| *error != UpstreamInfoFailure::Unsupported) { - return Err(refresh_error(error)); + if let Some(error) = failure.and_then(refresh_error) { + return Err(error); } self.read_quota(id) .await @@ -215,12 +219,13 @@ fn stable_jitter(id: &str) -> u64 { }) % 60 } -fn refresh_error(error: UpstreamInfoFailure) -> ProviderAdminError { - ProviderAdminError::new(ProviderAdminErrorKind::BadGateway).with_public_message(match error { +fn refresh_error(error: UpstreamInfoFailure) -> Option { + let message = match error { UpstreamInfoFailure::Unauthorized => "上游未授权读取互联信息", - UpstreamInfoFailure::Unsupported => "上游不支持互联信息查询", + UpstreamInfoFailure::Unsupported | UpstreamInfoFailure::SharingDisabled => return None, UpstreamInfoFailure::RateLimited => "上游互联查询已限流", UpstreamInfoFailure::InvalidResponse => "上游互联响应无效", UpstreamInfoFailure::Unavailable => "上游互联服务暂不可用", - }) + }; + Some(ProviderAdminError::new(ProviderAdminErrorKind::BadGateway).with_public_message(message)) } diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/service.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/service.rs index 0db9344ab..4e1ac7d2f 100644 --- a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/service.rs +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/service.rs @@ -34,8 +34,7 @@ pub(super) struct CachedObservation { pub checked_at: SystemTime, pub scheduling: Option>, pub summary: Option>, - pub unsupported: bool, - pub unavailable: Option, + pub stored_failure: Option, } pub(crate) struct PeerInfoService { @@ -173,21 +172,17 @@ impl PeerInfoService { let peer = observation .as_ref() .and_then(|value| snapshot::decode(&value.quota)); - let unsupported = observation + let stored_failure = observation .as_ref() - .is_some_and(|value| snapshot::is_unsupported(&value.quota, &connection)); - let unavailable = observation - .as_ref() - .and_then(|value| snapshot::unavailable_reason(&value.quota, &connection)); + .and_then(|value| snapshot::stored_failure(&value.quota, &connection)); if self.current_connection(id).await?.as_ref() != Some(&connection) { return Ok(None); } Ok(Some(Self::project_peer( &connection, peer.as_ref(), - unavailable, - self.last_error(&connection) - .or(unsupported.then_some(UpstreamInfoFailure::Unsupported)), + stored_failure, + self.last_error(&connection), SystemTime::now(), ))) } diff --git a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/snapshot.rs b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/snapshot.rs index 1224cc6e5..a73e7bff7 100644 --- a/backend/crates/gateway-admin/src/use_case/peer_link/upstream/snapshot.rs +++ b/backend/crates/gateway-admin/src/use_case/peer_link/upstream/snapshot.rs @@ -28,21 +28,7 @@ pub(super) fn unsupported(connection: &UpstreamConnection) -> OpaqueProviderData ])) } -pub(super) fn is_unsupported(data: &OpaqueProviderData, connection: &UpstreamConnection) -> bool { - let fields = data.expose_to_provider(); - (fields.get("object").and_then(serde_json::Value::as_str) == Some("cpr.peer.unsupported") - && fields - .get("schema_version") - .and_then(serde_json::Value::as_u64) - == Some(1) - && fields - .get("connection_revision") - .and_then(serde_json::Value::as_str) - == Some(connection.revision.as_str())) - || unavailable_reason(data, connection) == Some(UpstreamInfoFailure::Unsupported) -} - -// 失权后只保留同一连接已确认互联的事实,不保留已撤销的池、预算与模型数据 +// 授权撤销或共享关闭后只保存连接与失败原因,丢弃全部共享数据 pub(super) fn unavailable( connection: &UpstreamConnection, reason: UpstreamInfoFailure, @@ -58,20 +44,24 @@ pub(super) fn unavailable( ])) } -pub(super) fn unavailable_reason( +pub(super) fn stored_failure( data: &OpaqueProviderData, connection: &UpstreamConnection, ) -> Option { let fields = data.expose_to_provider(); - if fields.get("object")?.as_str()? != "cpr.peer.unavailable" - || fields.get("schema_version")?.as_u64()? != 1 + if fields.get("schema_version")?.as_u64()? != 1 || fields.get("connection_revision")?.as_str()? != connection.revision { return None; } - match fields.get("reason")?.as_str()? { - "unauthorized" => Some(UpstreamInfoFailure::Unauthorized), - "unsupported" => Some(UpstreamInfoFailure::Unsupported), + match fields.get("object")?.as_str()? { + "cpr.peer.unsupported" => Some(UpstreamInfoFailure::Unsupported), + "cpr.peer.unavailable" => match fields.get("reason")?.as_str()? { + "unauthorized" => Some(UpstreamInfoFailure::Unauthorized), + "sharing_disabled" => Some(UpstreamInfoFailure::SharingDisabled), + "unsupported" => Some(UpstreamInfoFailure::Unsupported), + _ => None, + }, _ => None, } } diff --git a/backend/crates/gateway-admin/tests/use_case/peer_link/upstream/refresh.rs b/backend/crates/gateway-admin/tests/use_case/peer_link/upstream/refresh.rs index 2d84a377d..39fee2e7c 100644 --- a/backend/crates/gateway-admin/tests/use_case/peer_link/upstream/refresh.rs +++ b/backend/crates/gateway-admin/tests/use_case/peer_link/upstream/refresh.rs @@ -176,10 +176,11 @@ async fn uncertain_discovery_failures_retry_without_becoming_unsupported() { } #[tokio::test(start_paused = true)] -async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_data() { +async fn known_peer_identity_changes_only_on_definitive_detection() { for failure in [ UpstreamInfoFailure::Unauthorized, UpstreamInfoFailure::Unsupported, + UpstreamInfoFailure::SharingDisabled, UpstreamInfoFailure::Unavailable, UpstreamInfoFailure::RateLimited, UpstreamInfoFailure::InvalidResponse, @@ -191,14 +192,15 @@ async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_d tokio::time::advance(Duration::from_secs(15)).await; *transport.failure.lock().unwrap() = Some(failure.into()); let result = bundle.service().refresh_quota(&id).await; - assert_eq!(result.is_ok(), failure == UpstreamInfoFailure::Unsupported); - let clears_data = matches!( + let clears_identity = matches!( failure, - UpstreamInfoFailure::Unauthorized | UpstreamInfoFailure::Unsupported + UpstreamInfoFailure::Unsupported | UpstreamInfoFailure::SharingDisabled ); + assert_eq!(result.is_ok(), clears_identity); + let clears_data = clears_identity || failure == UpstreamInfoFailure::Unauthorized; let quota = bundle.service().read_quota(&id).await.unwrap().unwrap(); let info = quota.upstream.as_ref().unwrap(); - assert!(info.discovered); + assert_eq!(info.discovered, !clears_identity); assert_eq!(info.last_error.as_deref(), Some(failure.as_str())); assert_eq!(quota.windows.is_empty(), clears_data); assert_eq!(info.observed_at.is_none(), clears_data); @@ -221,10 +223,24 @@ async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_d clears_data ); + if failure == UpstreamInfoFailure::Unsupported { + // 已运行 v3.40.0 的节点可能持有这种不支持记录,读取时应直接显示为普通 API Key + accounts.records.lock().unwrap().get_mut(&id).unwrap().quota = + serde_json::from_value(serde_json::json!({ + "object": "cpr.peer.unavailable", + "schema_version": 1, + "connection_revision": info.connection_revision, + "reason": "unsupported", + })) + .unwrap(); + } drop(bundle); let restarted = initialize(accounts, transport); let restored = restarted.service().read_quota(&id).await.unwrap().unwrap(); - assert!(restored.upstream.as_ref().unwrap().discovered); + assert_eq!( + restored.upstream.as_ref().unwrap().discovered, + !clears_identity + ); assert_eq!(restored.windows.is_empty(), clears_data); if clears_data { assert_eq!( @@ -246,7 +262,7 @@ async fn known_peer_failures_keep_identity_and_only_revocation_discards_shared_d } #[tokio::test(start_paused = true)] -async fn known_peer_identity_is_scoped_to_the_connection_after_revocation() { +async fn peer_detection_after_revocation_is_scoped_to_the_connection() { for failure in [ UpstreamInfoFailure::Unauthorized, UpstreamInfoFailure::Unsupported, @@ -261,7 +277,7 @@ async fn known_peer_identity_is_scoped_to_the_connection_after_revocation() { let edited = initial.clone().with_notes(Some("保留连接身份".into())); *accounts.accounts.lock().unwrap() = vec![edited.clone()]; bundle.service().account_changed(&id).await.unwrap(); - assert!( + assert_eq!( bundle .service() .read_summary(&id) @@ -270,7 +286,8 @@ async fn known_peer_identity_is_scoped_to_the_connection_after_revocation() { .unwrap() .upstream .unwrap() - .discovered + .discovered, + failure == UpstreamInfoFailure::Unauthorized ); // 地址和密钥修改推进凭据版本,出口变化则改变同一版本的连接身份 diff --git a/backend/crates/gateway-api/src/openai/peer_link/mod.rs b/backend/crates/gateway-api/src/openai/peer_link/mod.rs index f613c1391..495b4f153 100644 --- a/backend/crates/gateway-api/src/openai/peer_link/mod.rs +++ b/backend/crates/gateway-api/src/openai/peer_link/mod.rs @@ -85,7 +85,7 @@ async fn peer_response(state: ApiState, client: AuthenticatedClient, uri: Uri) - StatusCode::FORBIDDEN, "CPR Link sharing is not enabled for this API key.", "permission_error", - "peer_sharing_disabled", + gateway_protocol::peer::SHARING_DISABLED_CODE, ) .into_response(), Err(PeerLinkError::RateLimited { retry_after }) => { diff --git a/backend/crates/gateway-core/src/account/upstream.rs b/backend/crates/gateway-core/src/account/upstream.rs index df9468c4d..b603bef09 100644 --- a/backend/crates/gateway-core/src/account/upstream.rs +++ b/backend/crates/gateway-core/src/account/upstream.rs @@ -54,6 +54,7 @@ impl UpstreamConnection { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpstreamInfoFailure { Unauthorized, + SharingDisabled, Unsupported, RateLimited, InvalidResponse, @@ -65,6 +66,7 @@ impl UpstreamInfoFailure { pub const fn as_str(self) -> &'static str { match self { Self::Unauthorized => "unauthorized", + Self::SharingDisabled => "sharing_disabled", Self::Unsupported => "unsupported", Self::RateLimited => "rate_limited", Self::InvalidResponse => "invalid_response", diff --git a/backend/crates/gateway-host/src/peer.rs b/backend/crates/gateway-host/src/peer.rs index eb6ae31d1..4f3b1b285 100644 --- a/backend/crates/gateway-host/src/peer.rs +++ b/backend/crates/gateway-host/src/peer.rs @@ -61,12 +61,6 @@ impl PeerTransport for HttpPeerTransport { .await .map_err(|_| UpstreamInfoFailure::Unavailable)?; if !response.status().is_success() { - let kind = match response.status().as_u16() { - 401 | 403 => UpstreamInfoFailure::Unauthorized, - 404 | 405 | 501 => UpstreamInfoFailure::Unsupported, - 429 => UpstreamInfoFailure::RateLimited, - _ => UpstreamInfoFailure::Unavailable, - }; let retry_after = response .headers() .get(reqwest::header::RETRY_AFTER) @@ -87,23 +81,48 @@ impl PeerTransport for HttpPeerTransport { }) }) .map(|value| value.min(Duration::from_secs(24 * 60 * 60))); + let kind = match response.status().as_u16() { + 401 => UpstreamInfoFailure::Unauthorized, + 403 => forbidden_reason(response).await, + 404 | 405 | 501 => UpstreamInfoFailure::Unsupported, + 429 => UpstreamInfoFailure::RateLimited, + _ => UpstreamInfoFailure::Unavailable, + }; return Err(PeerFetchFailure { kind, retry_after }); } - if response - .content_length() - .is_some_and(|length| length > BODY_LIMIT as u64) - { - return Err(UpstreamInfoFailure::InvalidResponse.into()); - } - let mut bytes = Vec::new(); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|_| UpstreamInfoFailure::Unavailable)?; - if bytes.len().saturating_add(chunk.len()) > BODY_LIMIT { - return Err(UpstreamInfoFailure::InvalidResponse.into()); - } - bytes.extend_from_slice(&chunk); - } - Ok(bytes) + read_body(response).await.map_err(Into::into) } } + +// 普通 403 只说明当前请求失权,上游明确关闭共享才清除互联身份 +async fn forbidden_reason(response: reqwest::Response) -> UpstreamInfoFailure { + if let Ok(bytes) = read_body(response).await + && let Ok(body) = serde_json::from_slice::(&bytes) + && body + .pointer("/error/code") + .and_then(serde_json::Value::as_str) + == Some(gateway_protocol::peer::SHARING_DISABLED_CODE) + { + return UpstreamInfoFailure::SharingDisabled; + } + UpstreamInfoFailure::Unauthorized +} + +async fn read_body(response: reqwest::Response) -> Result, UpstreamInfoFailure> { + if response + .content_length() + .is_some_and(|length| length > BODY_LIMIT as u64) + { + return Err(UpstreamInfoFailure::InvalidResponse); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| UpstreamInfoFailure::Unavailable)?; + if bytes.len().saturating_add(chunk.len()) > BODY_LIMIT { + return Err(UpstreamInfoFailure::InvalidResponse); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} diff --git a/backend/crates/gateway-protocol/src/peer.rs b/backend/crates/gateway-protocol/src/peer.rs index 3b4226335..db744f1a7 100644 --- a/backend/crates/gateway-protocol/src/peer.rs +++ b/backend/crates/gateway-protocol/src/peer.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +pub const SHARING_DISABLED_CODE: &str = "peer_sharing_disabled"; pub const OBJECT: &str = "cpr.peer"; pub const SCHEMA_VERSION: u32 = 3; pub const MAX_ACCOUNTS: usize = 3_000; diff --git a/backend/crates/providers/openai/tests/admin/peer_info.rs b/backend/crates/providers/openai/tests/admin/peer_info.rs index d95bc42f5..e87ef95ad 100644 --- a/backend/crates/providers/openai/tests/admin/peer_info.rs +++ b/backend/crates/providers/openai/tests/admin/peer_info.rs @@ -663,7 +663,35 @@ async fn transient_failure_preserves_observation_and_revocation_erases_it() { cycle(&worker).await; let revoked = quota(&bundle, &id).await; assert!(revoked.windows.is_empty()); - assert!(revoked.upstream.unwrap().observed_at.is_none()); + let info = revoked.upstream.unwrap(); + assert!(info.observed_at.is_none()); + assert!(info.discovered); + + // 同为 403,仅共享关闭的协议码能改变识别结果 + server.reset().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({ + "error": { "code": "peer_sharing_disabled" } + }))) + .mount(&server) + .await; + bundle.admin_provider().forget(&id); + cycle(&worker).await; + let disabled = quota(&bundle, &id).await; + let info = disabled.upstream.unwrap(); + assert!(!info.discovered); + assert_eq!(info.last_error.as_deref(), Some("sharing_disabled")); + assert!(disabled.windows.is_empty()); + + server.reset().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(peer_document())) + .mount(&server) + .await; + bundle.admin_provider().forget(&id); + let recovered = bundle.admin_provider().refresh_quota(&id).await.unwrap(); + assert!(recovered.upstream.unwrap().discovered); + assert!(!recovered.windows.is_empty()); } async fn wait_for_request(server: &MockServer) { diff --git a/deploy/README.md b/deploy/README.md index 652442085..bb2c863f5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -407,7 +407,7 @@ PostgreSQL/Redis 启动密码。日常校验使用 `config --quiet` 在下游导入或编辑 OpenAI API Key 账号,填写上游 API 地址和该 Key,后台自动发现 CPR 互联能力。 地址包含自定义前缀时保留完整前缀,后台在该地址下请求 `cpr/link`。 -列表与详情显示已保存的观测。展开账号可查看上游密钥限制,普通账号并发留空时跟随上游有效的正数上限。手动刷新等待上游查询完成,旧观测和查询失败分别提示 +列表与详情显示已保存的观测。展开账号可查看上游密钥限制,普通账号并发留空时跟随上游有效的正数上限。启用的 OpenAI API Key 账号可点击「刷新额度」重新识别。上游明确关闭共享或不支持互联时,账号显示为 API,重新识别成功后显示 CPR 第三方网关通过插件实现相同协议也可被识别,不按软件名称判定兼容。 上游明确返回 Key 预算耗尽时,下游暂停该账号;后续同步确认允许使用后自动恢复,普通限流冷却独立保留。 diff --git a/docs/api.md b/docs/api.md index 3486d38db..e04391e2c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1169,11 +1169,11 @@ OpenAI 主动额度刷新和正常响应携带的明确套餐会同步到账号 | 字段 | 含义 | | --- | --- | -| `discovered` | 当前连接曾成功识别为互联;首次识别前为 `false`,识别后的同步失败或授权撤销不清除此身份,也不代表当前仍有共享授权或可用快照 | +| `discovered` | 收到有效互联快照后为 `true`,明确关闭共享或不支持协议后为 `false`。其他失败保留当前识别结果 | | `observedAt`、`observedAtDisplay`、`observedAtRelative` | 上游观测时间及服务端格式化结果,不代表本次页面查询时间 | | `stale` | 快照是否已过期;过期值仅供查看 | | `connectionRevision` | 当前连接身份的不可解释标识,用于隔离地址、密钥或出口变化前的展示结果 | -| `lastError` | 当前实例最近同步失败分类:`unauthorized`、`unsupported`、`rate_limited`、`invalid_response`、`unavailable`;已保存的不支持结论及已确认互联的失权记录重启后仍返回对应分类,同步成功时为 `null` | +| `lastError` | 当前实例最近同步失败分类:`unauthorized`、`sharing_disabled`、`unsupported`、`rate_limited`、`invalid_response`、`unavailable`;已保存的不支持、共享关闭或互联失权记录重启后仍返回对应分类,同步成功时为 `null` | | `maxConcurrency`、`requestsPerMinute` | 上游密钥的并发上限和 RPM,零表示不限,未观测到的项为 `null` | | `pool` | 上游授权公开的账号数量、状态数量及套餐数量,未公开时为 `null` | @@ -1187,15 +1187,15 @@ Key 消费窗口的 `localUsage` 按各自 `[resetAt - windowSeconds, resetAt)` 互联窗口返回独立的 `observedAtDisplay` 和 `stale` 手动刷新复用 `POST /api/admin/accounts/quota/refresh`,请求 `{ accountId }`,与原生额度刷新一样等待查询结束并返回账号视图。 -手动刷新允许重新探测已确认不支持的连接;不支持协议作为正常识别结果返回账号视图和 `lastError: unsupported`,不作为后台刷新失败报警。 +手动刷新允许重新探测已确认不支持的连接。不支持协议和明确关闭共享均返回账号视图,`lastError` 分别为 `unsupported` 和 `sharing_disabled`。 其他查询失败通过现有管理错误返回;失败后可回读账号取得更新后的身份与观测。 并发刷新合并,连续手动刷新至少间隔 15 秒,并遵守上游 `Retry-After`,不继承自动重试的长退避。 自动刷新由后台任务执行,刷新节流与临时失败提示属于当前实例的运行期状态,重启后重新建立。 -网络错误、限流或无效响应保留旧观测;401/403 清除已撤销内容,但已确认的互联连接保留识别身份和失败分类。 -已确认互联后变为不支持协议时,也只保留识别身份和失败分类,不保留池、预算或模型数据。 -无观测内容时 `observedAt` 与 `pool` 为 `null`,互联额度窗口为空,识别身份不作为调度或额度恢复证据。 -首次探测失败不能证明上游支持互联,管理端继续按普通 API Key 展示,不显示互联告警;已确认的连接显示同步失败原因,可通过现有刷新额度入口重试。 -除明确不支持协议外,待识别及已确认支持的连接仍退避重试。 + +- 收到有效快照时显示 CPR 互联。明确返回不支持协议(HTTP 404、405、501),或返回 `403 peer_sharing_disabled` 时,清除互联身份和共享数据,显示普通 API Key +- 普通 401/403 保留原识别结果并清除已撤销的共享内容。超时、限流、服务异常或无效响应保留原识别结果和已有观测。无观测时 `observedAt` 与 `pool` 为 `null`,额度窗口为空 +- 展开可探测的 API Key 后,点击「刷新额度」可重新识别互联。卡片显示已有更新时间,手动刷新结果通过通知反馈。明确不支持协议的连接停止周期探测,共享关闭的连接按授权退避周期复查 + 账号地址、密钥或出口变更使旧观测与识别身份失效,重新识别当前连接。 账号停用后停止同步,重新启用后按当前连接的识别结果恢复后台任务 diff --git a/docs/architecture.md b/docs/architecture.md index c7cc9b0b9..e0b3e00e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1045,8 +1045,7 @@ Provider 声明可连接的认证种类,并从本轮凭据中提取连接材 Host 执行有界 HTTP 请求,复用组合根注入的证书策略和账号出口;Store 通过通用账号额度端口保存不透明观测,不解释互联或 Provider 协议。 互联与原生额度共用 `provider_quota_json`、`quota_observed_at`、凭据版本和额度访问事实,每个账号仅保存当前来源的观测。 Admin 将明确不支持互联的结果绑定连接身份保存于同一观测,不伪造额度快照或改变额度访问状态。 -已确认互联的连接失权或变为不支持时,同一存储只保留连接识别记录和失败分类,清除共享内容; -列表与详情复用该记录展示身份并保留额度刷新入口,调度只消费实际快照。首次探测失败不建立互联身份。 +明确不支持协议或关闭共享时清除互联身份,普通鉴权失败保留原身份,两者均清除共享数据。列表与详情从同一观测投影识别结果,调度只消费实际快照。首次探测失败不建立互联身份。 OAuth 额度解析属于 Provider,CPR 发现、解析与投影属于 Admin;账号用例按能力分派读写与刷新,不将两种协议合并为同一解析服务。 凭据新建、导入或授权提交后,后台首查复用同一额度分派,不等待周期同步或阻塞保存请求。 互联首查、手动刷新和周期刷新共用服务级并发上限,排队后读取当前凭据;失败或取消释放名额。 diff --git a/frontend/src/api/modules/accounts/types.ts b/frontend/src/api/modules/accounts/types.ts index 429b6cd35..ee650322c 100644 --- a/frontend/src/api/modules/accounts/types.ts +++ b/frontend/src/api/modules/accounts/types.ts @@ -181,14 +181,14 @@ export interface Account { } export interface AccountUpstream { - // 同一连接曾确认支持互联,失权时仍为 true,观测内容则清空 + // 明确关闭共享或不支持互联时为 false,普通鉴权失败保留已识别身份 discovered: boolean observedAt: string | null observedAtDisplay: string observedAtRelative: RelativeTimeView | null stale: boolean connectionRevision: string - lastError: 'unauthorized' | 'unsupported' | 'rate_limited' | 'invalid_response' | 'unavailable' | null + lastError: 'unauthorized' | 'sharing_disabled' | 'unsupported' | 'rate_limited' | 'invalid_response' | 'unavailable' | null maxConcurrency: number | null requestsPerMinute: number | null pool: { diff --git a/frontend/src/components/account/AccountUpstreamStatus.vue b/frontend/src/components/account/AccountUpstreamStatus.vue deleted file mode 100644 index 69c849419..000000000 --- a/frontend/src/components/account/AccountUpstreamStatus.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index f84fbc0cd..47b1d7a8d 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -626,6 +626,7 @@ "observedAt": "Updated {time}", "stale": "Data expired", "unauthorized": "Upstream access denied", + "sharingDisabled": "Upstream sharing is disabled", "unsupported": "Upstream does not support CPR Link", "rateLimited": "Upstream query rate limited", "invalidResponse": "Invalid upstream data", diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index ac0b2156e..70429c016 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -626,6 +626,7 @@ "observedAt": "{time}更新", "stale": "数据已过期", "unauthorized": "上游未授权", + "sharingDisabled": "上游已关闭互联共享", "unsupported": "上游暂不支持互联", "rateLimited": "上游查询受限", "invalidResponse": "上游数据异常", diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index bd680cce9..45d6fe173 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -626,6 +626,7 @@ "observedAt": "{time}更新", "stale": "資料已過期", "unauthorized": "上游未授權", + "sharingDisabled": "上游已關閉互聯共享", "unsupported": "上游暫不支援互聯", "rateLimited": "上游查詢受限", "invalidResponse": "上游資料異常", diff --git a/frontend/src/utils/account-upstream.ts b/frontend/src/utils/account-upstream.ts index cc40c9c72..aa4057634 100644 --- a/frontend/src/utils/account-upstream.ts +++ b/frontend/src/utils/account-upstream.ts @@ -6,6 +6,7 @@ export function accountUpstreamErrorMessage(reason: AccountUpstream['lastError'] return '' return { unauthorized: t('accounts.upstream.unauthorized'), + sharing_disabled: t('accounts.upstream.sharingDisabled'), unsupported: t('accounts.upstream.unsupported'), rate_limited: t('accounts.upstream.rateLimited'), invalid_response: t('accounts.upstream.invalidResponse'), diff --git a/frontend/src/views/accounts/components/account-quota-panel/index.vue b/frontend/src/views/accounts/components/account-quota-panel/index.vue index c5425c7b3..af3c302db 100644 --- a/frontend/src/views/accounts/components/account-quota-panel/index.vue +++ b/frontend/src/views/accounts/components/account-quota-panel/index.vue @@ -8,7 +8,6 @@ import { ChartNoAxesCombined, RefreshCw, UserRound } from '@lucide/vue' import { computed, shallowRef, watch } from 'vue' import { useI18n } from 'vue-i18n' import AccountPlanBadge from '@/components/account/AccountPlanBadge.vue' -import AccountUpstreamStatus from '@/components/account/AccountUpstreamStatus.vue' import { formatRelativeTime } from '@/utils/relative-time' import { usePoolQuotaWindows } from '../../composables/usePoolQuotaWindows' import { groupedAccountQuotaWindows, orderedPanelQuotaWindows } from '../../constants' @@ -46,19 +45,18 @@ const forecastOpen = shallowRef(false) const hasPlanBadge = computed(() => Boolean(props.account.planType) || props.account.authenticationKind === 'api_key') const hasForecast = computed(() => props.account.authenticationKind !== 'api_key') const hasPersonalInfo = computed(() => props.account.capabilities.profile || props.account.capabilities.subscription) -const canRefresh = computed(() => props.account.capabilities.quotaRefresh || props.account.upstream?.discovered) +const canRefresh = computed(() => props.account.capabilities.quotaRefresh || props.account.upstream != null) const hasActions = computed(() => hasPersonalInfo.value || hasForecast.value || canRefresh.value || props.account.capabilities.resetCredits) const lastRefreshed = computed(() => props.account.upstream?.discovered && !props.account.upstream.observedAt - ? null + ? undefined : t('accounts.quota.lastRefreshed', { refreshedAtDisplay: formatRelativeTime(props.account.upstream?.discovered ? props.account.upstream.observedAtRelative : props.account.quota.refreshedAtRelative), })) -const showUpstreamStatus = computed(() => props.account.upstream?.discovered && ( - (props.account.upstream.observedAt && props.account.upstream.stale) - || props.account.upstream.lastError -)) +const refreshTitle = computed(() => props.account.upstream?.stale && props.account.upstream.observedAt + ? `${props.account.upstream.observedAtDisplay} · ${t('accounts.upstream.stale')}` + : lastRefreshed.value) watch(hasPersonalInfo, (available) => { if (!available) profileOpen.value = false @@ -76,10 +74,7 @@ watch(hasPersonalInfo, (available) => { class="col-span-2 row-start-2 m-0 flex min-w-0 items-center gap-1.5 text-z-xs font-emphasis text-z-text-secondary" > - + {{ lastRefreshed }}