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 <noreply@openai.com>

* docs(api): define model catalog observation semantics

Co-authored-by: Codex <noreply@openai.com>

* fix(accounts): refine model catalog help and transport labels

Co-authored-by: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Zyy <zyycn@users.noreply.github.com>
This commit is contained in:
Ke Wang
2026-10-30 23:15:05 +08:00
committed by Zyy
co-authored by Codex Zyy
parent 63036a3694
commit b4aea0ea09
12 changed files with 179 additions and 13 deletions
@@ -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)))
}
@@ -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)),
}
}
@@ -722,8 +722,11 @@ pub struct AccountModelView {
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountModelsData {
pub models: Vec<AccountModelView>,
pub observed_at: Option<String>,
pub observed_at_display: String,
}
/// 账号模型目录文件;`catalog` 直接作为 Codex `model_catalog_json` 的文件正文落盘
@@ -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<Utc> = "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::<AdminTestState>().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;
+13 -1
View File
@@ -113,6 +113,7 @@ pub(super) struct AdminTestFixture {
pub dashboard_summary_range: Arc<Mutex<Option<TimeRange>>>,
pub provider_error: Arc<Mutex<Option<ProviderAdminError>>>,
pub provider_quota: Arc<Mutex<Option<ProviderQuota>>>,
pub provider_models: Arc<Mutex<Option<ProviderModels>>>,
pub account: Arc<Mutex<Option<AccountPageItem>>>,
pub account_usage: Arc<Mutex<Vec<AccountUsage>>>,
}
@@ -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<Arc<dyn ProviderAdmin>> = 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<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
models: Arc<Mutex<Option<ProviderModels>>>,
}
impl UnusedProvider {
@@ -1614,11 +1620,13 @@ impl UnusedProvider {
kind: &str,
error: Arc<Mutex<Option<ProviderAdminError>>>,
quota: Arc<Mutex<Option<ProviderQuota>>>,
models: Arc<Mutex<Option<ProviderModels>>>,
) -> Self {
Self {
kind: ProviderKind::new(kind).expect("provider kind"),
error,
quota,
models,
}
}
}
@@ -1729,7 +1737,11 @@ impl ProviderAdmin for UnusedProvider {
_: &ProviderAccountId,
_: bool,
) -> Result<ProviderModels, ProviderAdminError> {
Err(unsupported_provider())
self.models
.lock()
.expect("provider models")
.clone()
.ok_or_else(unsupported_provider)
}
async fn export_credentials(
+7 -2
View File
@@ -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`,直到探测成功或手动恢复。
+2
View File
@@ -336,6 +336,8 @@ export interface AccountResetCreditResultResponse {
export interface AccountModelsResponse {
models: Array<{ id: string, label: string }>
observedAt?: string | null
observedAtDisplay?: string
}
export interface AccountModelCatalogResponse {
+4 -1
View File
@@ -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",
@@ -186,9 +186,12 @@
"reserve": "保留",
"allowlist": "白名单",
"blocklist": "黑名单",
"modelLimitations": "模型限制",
"modelCatalog": "模型目录",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"catalogHelpTitle": "模型目录说明",
"catalogObservedAtHelp": "时间表示最近一次成功获取上游目录的时间",
"catalogDescription": "目录仅提供模型 ID,端点能力请通过实际请求确认,连接测试仅验证测试所用端点",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或输入 ID,回车添加",
"selectModel": "选择模型",
@@ -186,9 +186,12 @@
"reserve": "保留",
"allowlist": "白名單",
"blocklist": "黑名單",
"modelLimitations": "模型限制",
"modelCatalog": "模型目錄",
"refreshModel": "刷新模型",
"modelRestrictionMode": "模型限制模式",
"catalogHelpTitle": "模型目錄說明",
"catalogObservedAtHelp": "時間表示最近一次成功取得上游目錄的時間",
"catalogDescription": "目錄僅提供模型 ID,端點能力請透過實際請求確認,連線測試僅驗證測試所用端點",
"searchOrAddModels": "搜索或添加模型",
"searchOrEnterIdAndPressEnter": "搜索或輸入 ID,回車添加",
"selectModel": "選擇模型",
@@ -11,7 +11,7 @@ const { t } = useI18n({ useScope: 'global' })
const form = defineModel<ApiKeyAccountForm>({ required: true })
const transportOptions = [
{ label: 'HTTP / SSE', value: 'http' },
{ label: 'SSE', value: 'http' },
{ label: 'WS', value: 'prefer_websocket' },
]
</script>
@@ -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<AccountModelAccess | undefined>({ required: true })
// 未取得目录时不能判断缺失,成功返回空目录则可以
const catalog = ref<Array<{ id: string, label: string }>>()
const catalogObservedAtDisplay = ref<string>()
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, () => {
<template>
<div class="grid gap-3">
<ZFormItem :label="t('accounts.modelAccess.modelLimitations')">
<ZFormItem>
<template #label>
<span class="inline-flex flex-wrap items-end gap-x-2 gap-y-1">
<span>{{ t('accounts.modelAccess.modelCatalog') }}</span>
<span v-if="restricted && catalogObservedAtDisplay" class="text-cp-xs leading-none font-normal text-cp-text-tertiary tabular-nums">
{{ catalogObservedAtDisplay }}
</span>
</span>
</template>
<template v-if="restricted" #label-extra>
<ZPopover class="-my-1 -ml-1.5 shrink-0" trigger="hover-click" placement="top-start" role="tooltip">
<template #reference="{ open, id, close }">
<ZIconButton
variant="link"
class="size-6!"
:aria-label="t('accounts.modelAccess.catalogHelpTitle')"
:aria-describedby="open ? id : undefined"
:aria-expanded="open"
:title="undefined"
@keydown="handleCatalogHelpKeydown($event, open, close)"
>
<CircleAlert class="size-[1em] text-cp-xs" aria-hidden="true" />
</ZIconButton>
</template>
<div class="max-w-72 space-y-2 px-3 py-2 text-cp-sm leading-relaxed text-cp-text-secondary">
<p class="m-0">
{{ t('accounts.modelAccess.catalogObservedAtHelp') }}
</p>
<p class="m-0">
{{ t('accounts.modelAccess.catalogDescription') }}
</p>
</div>
</ZPopover>
</template>
<div class="flex min-w-0 items-center justify-between gap-2">
<ZSegmented v-model="mode" class="w-80 min-w-0 max-w-full" :aria-label="t('accounts.modelAccess.modelRestrictionMode')" :options="modes" :disabled="disabled" />
<ZIconButton v-if="restricted && accountId" :aria-label="t('accounts.modelAccess.refreshModel')" variant="plain" :loading="loading" :disabled="disabled" @click="load(true)">