From b4aea0ea09c5de97c56d83f14250ac1c36009eac Mon Sep 17 00:00:00 2001 From: Ke Wang Date: Fri, 30 Oct 2026 23:11:27 +0800 Subject: [PATCH] fix(accounts): add missing model catalog fetch timestamps and capability hints (#560) * fix(admin): clarify upstream model catalog evidence * test(admin): cover model catalog timestamps over HTTP Co-authored-by: Codex * docs(api): define model catalog observation semantics Co-authored-by: Codex * fix(accounts): refine model catalog help and transport labels Co-authored-by: Codex --------- Co-authored-by: Codex Co-authored-by: Zyy --- .../src/admin/accounts/handlers.rs | 6 +- .../src/admin/accounts/presenter.rs | 9 ++- .../gateway-api/src/admin/accounts/wire.rs | 3 + .../tests/admin/accounts/handlers.rs | 80 +++++++++++++++++++ backend/crates/gateway-api/tests/admin/mod.rs | 14 +++- docs/api.md | 9 ++- frontend/src/api/modules/accounts.ts | 2 + frontend/src/i18n/locales/en/accounts.json | 5 +- .../src/i18n/locales/zh-Hans/accounts.json | 5 +- .../src/i18n/locales/zh-Hant/accounts.json | 5 +- .../components/AccountApiKeyFields.vue | 2 +- .../components/AccountModelAccessField.vue | 52 +++++++++++- 12 files changed, 179 insertions(+), 13 deletions(-) diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 7c6cff2f2..105f8ea66 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -569,7 +569,8 @@ where .models(&account_id, false) .await .map_err(map_service_error)?; - let data = account_models_data(result); + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); + let data = account_models_data(result, time); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } @@ -607,7 +608,8 @@ where .models(&account_id, true) .await .map_err(map_service_error)?; - let data = account_models_data(result); + let time = crate::time::TimePresenter::new(state.admin_services().timezone()); + let data = account_models_data(result, time); Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } diff --git a/backend/crates/gateway-api/src/admin/accounts/presenter.rs b/backend/crates/gateway-api/src/admin/accounts/presenter.rs index db046e243..60a76b6c8 100644 --- a/backend/crates/gateway-api/src/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/src/admin/accounts/presenter.rs @@ -43,7 +43,11 @@ pub(super) fn account_refresh_data( } } -pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData { +pub(super) fn account_models_data( + result: ProviderModels, + time: crate::time::TimePresenter, +) -> AccountModelsData { + let observed_at = result.observed_at; AccountModelsData { models: result .models @@ -56,6 +60,9 @@ pub(super) fn account_models_data(result: ProviderModels) -> AccountModelsData { } }) .collect(), + observed_at: observed_at.map(|value| time.rfc3339(&value)), + observed_at_display: observed_at + .map_or_else(|| "—".to_owned(), |value| time.datetime(&value)), } } diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index 0e8d7cd12..0cd0da5a0 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -722,8 +722,11 @@ pub struct AccountModelView { } #[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] pub struct AccountModelsData { pub models: Vec, + pub observed_at: Option, + pub observed_at_display: String, } /// 账号模型目录文件;`catalog` 直接作为 Codex `model_catalog_json` 的文件正文落盘 diff --git a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs index 199f559c7..3249d0b1c 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/handlers.rs @@ -9,6 +9,86 @@ use tower::ServiceExt as _; use super::super::{AdminTestFixture, AdminTestState}; +#[tokio::test] +async fn account_model_reads_and_refreshes_preserve_provider_observation_time() { + use chrono::{DateTime, Utc}; + use gateway_admin::model::provider_credentials::{ProviderModel, ProviderModels}; + use gateway_core::{ + account::{AccountStatusFacts, CredentialState, QuotaState}, + routing::UpstreamModelId, + }; + use serde_json::json; + + let fixture = AdminTestFixture::new().await; + fixture.auth.insert_session("valid-session"); + let observed_at: DateTime = "2026-09-12T08:00:00Z".parse().unwrap(); + *fixture.account.lock().unwrap() = Some(account_page_item( + AccountStatusFacts { + enabled: true, + credential_state: CredentialState::Ready, + access_token_expires_at: None, + quota: QuotaState::unknown(), + cooldown: None, + last_error_reason: None, + last_error_message: None, + }, + observed_at, + "api_key", + )); + let router = admin::accounts::router::().with_state(fixture.state()); + for observed_at in [Some(observed_at), None] { + *fixture.provider_models.lock().unwrap() = Some(ProviderModels { + models: vec![ProviderModel { + id: UpstreamModelId::new("model-a").unwrap(), + name: "Model A".to_owned(), + }], + observed_at, + }); + for (method, uri, body) in [ + ( + "GET", + "/api/admin/accounts/models?accountId=acct_cooldown", + "", + ), + ( + "POST", + "/api/admin/accounts/models/refresh", + r#"{"accountId":"acct_cooldown"}"#, + ), + ] { + let response = router + .clone() + .oneshot( + Request::builder() + .method(method) + .uri(uri) + .header("x-request-id", "req_model_catalog") + .header(header::COOKIE, "cpr_session=valid-session") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{method} {uri}"); + let body = to_bytes(response.into_body(), 8192).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + value["data"]["models"], + json!([{ "id": "model-a", "label": "model-a" }]) + ); + assert_eq!( + value["data"]["observedAt"], + json!(observed_at.map(|_| "2026-09-12T16:00:00+08:00")) + ); + assert_eq!( + value["data"]["observedAtDisplay"], + observed_at.map_or("—", |_| "2026-09-12 16:00:00") + ); + } + } +} + #[tokio::test] async fn account_proxy_binding_is_distinct_from_the_redacted_endpoint() { use chrono::Utc; diff --git a/backend/crates/gateway-api/tests/admin/mod.rs b/backend/crates/gateway-api/tests/admin/mod.rs index aece37af1..50bc832c5 100644 --- a/backend/crates/gateway-api/tests/admin/mod.rs +++ b/backend/crates/gateway-api/tests/admin/mod.rs @@ -113,6 +113,7 @@ pub(super) struct AdminTestFixture { pub dashboard_summary_range: Arc>>, pub provider_error: Arc>>, pub provider_quota: Arc>>, + pub provider_models: Arc>>, pub account: Arc>>, pub account_usage: Arc>>, } @@ -189,16 +190,19 @@ impl AdminTestFixture { plugin_ports.clone(), ); let provider_quota = Arc::new(Mutex::new(None)); + let provider_models = Arc::new(Mutex::new(None)); let providers: Vec> = vec![ Arc::new(UnusedProvider::new( "openai", Arc::clone(&provider_error), Arc::clone(&provider_quota), + Arc::clone(&provider_models), )), Arc::new(UnusedProvider::new( "xai", Arc::clone(&provider_error), Arc::clone(&provider_quota), + Arc::clone(&provider_models), )), ]; let bundle = gateway_admin::initialize( @@ -247,6 +251,7 @@ impl AdminTestFixture { dashboard_summary_range, provider_error, provider_quota, + provider_models, account, account_usage, } @@ -1607,6 +1612,7 @@ struct UnusedProvider { kind: ProviderKind, error: Arc>>, quota: Arc>>, + models: Arc>>, } impl UnusedProvider { @@ -1614,11 +1620,13 @@ impl UnusedProvider { kind: &str, error: Arc>>, quota: Arc>>, + models: Arc>>, ) -> Self { Self { kind: ProviderKind::new(kind).expect("provider kind"), error, quota, + models, } } } @@ -1729,7 +1737,11 @@ impl ProviderAdmin for UnusedProvider { _: &ProviderAccountId, _: bool, ) -> Result { - Err(unsupported_provider()) + self.models + .lock() + .expect("provider models") + .clone() + .ok_or_else(unsupported_provider) } async fn export_credentials( diff --git a/docs/api.md b/docs/api.md index ac49ca29a..fcd9fbfca 100644 --- a/docs/api.md +++ b/docs/api.md @@ -880,9 +880,9 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 | `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 Provider 重置卡 | | `GET` | `/api/admin/accounts/reset-credits/automation` | `accountId` | 读取自动使用开关与本地计划,不查询上游 | | `POST` | `/api/admin/accounts/reset-credits/automation` | `{ accountId, enabled }` | 设置账号自动使用开关,默认关闭 | -| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 | +| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取 Provider 模型目录缓存,缺失时有限实时拉取;返回 `{ models, observedAt, observedAtDisplay }` | | `GET` | `/api/admin/accounts/models/catalog` | `accountId` | 读取指定账号的完整 Codex 原生模型目录,返回 `{ modelCount, observedAt, catalog }` | -| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache | +| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖缓存,响应结构与模型目录查询相同 | | `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 | | `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyId?, outboundProxyUrl? }` | 为支持登录的 Provider 创建 flow;`accountId` 表示重新授权 | | `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl, settings? }` | 消费 OAuth callback;首次授权可附带账号设置,重新授权保留原设置 | @@ -911,6 +911,11 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 响应中的 `catalog` 可直接保存为 JSON 文件,用于 Codex 的 `model_catalog_json` 配置。 账号设置保存不等待上游模型目录刷新完成 +模型目录查询和刷新响应中的 `observedAt` 表示 Provider 最近一次成功获取目录的时间,`observedAtDisplay` 按部署时区格式化;Provider 未提供观察时间时分别返回 `null` 和 `—` + +`models` 只表示上游目录返回了这些模型 ID,不表示 Responses、Chat Completions、Embeddings 等所有端点均已验证可用。 +连接测试仅验证所选模型在 Provider 测试路径上的请求,其他端点需通过对应请求确认;目录观察时间不改变路由或账号能力判断 + 账号限流详情在 `quota` 中返回:`rateLimitReason` 为 `upstream_rate_limit`(上游临时限流)、 `error_protection`(自定义错误规则触发保护)或 `null`。`recoveryProbeRequired` 表示解除冻结是否需要成功探测; 此时 `rateLimitedUntil` 是最早探测时间,到期后仍保持 `rate_limited`,直到探测成功或手动恢复。 diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 4a106c72c..6f0fd1c37 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -336,6 +336,8 @@ export interface AccountResetCreditResultResponse { export interface AccountModelsResponse { models: Array<{ id: string, label: string }> + observedAt?: string | null + observedAtDisplay?: string } export interface AccountModelCatalogResponse { diff --git a/frontend/src/i18n/locales/en/accounts.json b/frontend/src/i18n/locales/en/accounts.json index ad995ab10..4546af395 100644 --- a/frontend/src/i18n/locales/en/accounts.json +++ b/frontend/src/i18n/locales/en/accounts.json @@ -186,9 +186,12 @@ "reserve": "Reserve", "allowlist": "Allowlist", "blocklist": "Blocklist", - "modelLimitations": "Model limitations", + "modelCatalog": "Model catalog", "refreshModel": "Refresh model", "modelRestrictionMode": "Model restriction mode", + "catalogHelpTitle": "Model catalog details", + "catalogObservedAtHelp": "The time shows when the upstream catalog was last successfully fetched", + "catalogDescription": "The catalog lists model IDs, verify endpoint support with an actual request — a connection test only checks the endpoint it uses", "searchOrAddModels": "Search or add models", "searchOrEnterIdAndPressEnter": "Search or enter ID and press Enter to add", "selectModel": "Select model", diff --git a/frontend/src/i18n/locales/zh-Hans/accounts.json b/frontend/src/i18n/locales/zh-Hans/accounts.json index a502d8b6b..e1b907e57 100644 --- a/frontend/src/i18n/locales/zh-Hans/accounts.json +++ b/frontend/src/i18n/locales/zh-Hans/accounts.json @@ -186,9 +186,12 @@ "reserve": "保留", "allowlist": "白名单", "blocklist": "黑名单", - "modelLimitations": "模型限制", + "modelCatalog": "模型目录", "refreshModel": "刷新模型", "modelRestrictionMode": "模型限制模式", + "catalogHelpTitle": "模型目录说明", + "catalogObservedAtHelp": "时间表示最近一次成功获取上游目录的时间", + "catalogDescription": "目录仅提供模型 ID,端点能力请通过实际请求确认,连接测试仅验证测试所用端点", "searchOrAddModels": "搜索或添加模型", "searchOrEnterIdAndPressEnter": "搜索或输入 ID,回车添加", "selectModel": "选择模型", diff --git a/frontend/src/i18n/locales/zh-Hant/accounts.json b/frontend/src/i18n/locales/zh-Hant/accounts.json index 7ea10065a..9e5f4be76 100644 --- a/frontend/src/i18n/locales/zh-Hant/accounts.json +++ b/frontend/src/i18n/locales/zh-Hant/accounts.json @@ -186,9 +186,12 @@ "reserve": "保留", "allowlist": "白名單", "blocklist": "黑名單", - "modelLimitations": "模型限制", + "modelCatalog": "模型目錄", "refreshModel": "刷新模型", "modelRestrictionMode": "模型限制模式", + "catalogHelpTitle": "模型目錄說明", + "catalogObservedAtHelp": "時間表示最近一次成功取得上游目錄的時間", + "catalogDescription": "目錄僅提供模型 ID,端點能力請透過實際請求確認,連線測試僅驗證測試所用端點", "searchOrAddModels": "搜索或添加模型", "searchOrEnterIdAndPressEnter": "搜索或輸入 ID,回車添加", "selectModel": "選擇模型", diff --git a/frontend/src/views/accounts/components/AccountApiKeyFields.vue b/frontend/src/views/accounts/components/AccountApiKeyFields.vue index 935f2f6fb..633193153 100644 --- a/frontend/src/views/accounts/components/AccountApiKeyFields.vue +++ b/frontend/src/views/accounts/components/AccountApiKeyFields.vue @@ -11,7 +11,7 @@ const { t } = useI18n({ useScope: 'global' }) const form = defineModel({ required: true }) const transportOptions = [ - { label: 'HTTP / SSE', value: 'http' }, + { label: 'SSE', value: 'http' }, { label: 'WS', value: 'prefer_websocket' }, ] diff --git a/frontend/src/views/accounts/components/AccountModelAccessField.vue b/frontend/src/views/accounts/components/AccountModelAccessField.vue index 54681561b..d6db8b183 100644 --- a/frontend/src/views/accounts/components/AccountModelAccessField.vue +++ b/frontend/src/views/accounts/components/AccountModelAccessField.vue @@ -3,7 +3,7 @@ import type { AccountModelAccess } from '@/api' import { ZCheckbox, ZEmpty, ZFormItem, ZIconButton, ZInput, ZPopover, ZScrollbar, ZSegmented } from '@codex-proxy/ui' -import { RefreshCw, Search, TriangleAlert } from '@lucide/vue' +import { CircleAlert, RefreshCw, Search, TriangleAlert } from '@lucide/vue' import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { getAccountModels, refreshAccountModels } from '@/api' @@ -21,6 +21,7 @@ const { t } = useI18n({ useScope: 'global' }) const model = defineModel({ required: true }) // 未取得目录时不能判断缺失,成功返回空目录则可以 const catalog = ref>() +const catalogObservedAtDisplay = ref() const search = ref('') const inputError = ref('') const request = useRequestState() @@ -113,6 +114,15 @@ function addModel() { search.value = '' } +function handleCatalogHelpKeydown(event: KeyboardEvent, open: boolean, close: () => void) { + if (event.key === 'Escape' && open) { + // 先关闭说明浮层,避免同一次按键也关闭编辑弹窗 + event.preventDefault() + event.stopPropagation() + close() + } +} + async function load(refresh = false) { const accountId = props.accountId if (!accountId || !restricted.value) @@ -123,8 +133,10 @@ async function load(refresh = false) { { accountId }, { signal: request.signal }, ) - if (request.isCurrent(requestId)) + if (request.isCurrent(requestId)) { catalog.value = result.models + catalogObservedAtDisplay.value = result.observedAt ? result.observedAtDisplay : undefined + } } catch (cause) { request.fail(requestId, cause) @@ -140,6 +152,7 @@ watch([ ], () => { request.invalidate() catalog.value = undefined + catalogObservedAtDisplay.value = undefined search.value = '' inputError.value = '' error.value = '' @@ -153,7 +166,40 @@ watch(search, () => {