mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
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:
committed by
Zyy
co-authored by
Codex
Zyy
parent
63036a3694
commit
b4aea0ea09
@@ -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;
|
||||
|
||||
@@ -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
@@ -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`,直到探测成功或手动恢复。
|
||||
|
||||
@@ -336,6 +336,8 @@ export interface AccountResetCreditResultResponse {
|
||||
|
||||
export interface AccountModelsResponse {
|
||||
models: Array<{ id: string, label: string }>
|
||||
observedAt?: string | null
|
||||
observedAtDisplay?: string
|
||||
}
|
||||
|
||||
export interface AccountModelCatalogResponse {
|
||||
|
||||
@@ -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)">
|
||||
|
||||
Reference in New Issue
Block a user