feat(accounts): export the Codex model catalog of an account

Codex loads `model_catalog_json` as a `ModelsResponse`, the same
`{"models":[...]}` wire its `/models` endpoint returns, so the native
catalog documents already kept per model are a valid catalog file. Add a
read-only provider capability that collects the documents entitled to the
account's plan, a `GET /api/admin/accounts/models/catalog` endpoint, and a
row action that downloads the file, instead of requiring another tool to
assemble it.

The export reuses the process catalog snapshot and adds no upstream request
of its own. Entries stay byte-identical to the upstream objects, so display
name, reasoning levels, context window and service tiers reach the client
unchanged; aliases are not rewritten. Accounts whose catalog only carries
model IDs are rejected rather than downgraded, and the body never includes
credentials.

Closes #243
This commit is contained in:
jiahao6635
2026-09-24 02:58:50 +00:00
parent 60e0d4ee92
commit 02eb3967eb
16 changed files with 340 additions and 21 deletions
@@ -8,6 +8,7 @@ use chrono::{DateTime, NaiveDate, Utc};
use futures::Stream;
use gateway_core::{
account::{OpaqueProviderData, ProviderAccountId, ProviderAccountIdentity},
operation::RawJsonPayload,
routing::{ProviderKind, UpstreamModelId},
};
use serde::Deserialize;
@@ -1060,6 +1061,14 @@ pub struct ProviderModels {
pub observed_at: Option<DateTime<Utc>>,
}
/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderModelCatalogDocument {
pub document: RawJsonPayload,
pub model_count: usize,
pub observed_at: DateTime<Utc>,
}
/// Provider 执行 refresh 时所需的当前公共账号事实。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrepareCredentialRefresh {
@@ -17,9 +17,10 @@ use crate::model::provider_credentials::{
AuthorizationStarted, CompleteAuthorization, ConsumeProviderResetCredit,
PendingAuthorizationMutation, PrepareCredentialImport, PrepareCredentialRefresh,
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedCredentialImport,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput, ProviderModels,
ProviderProfileAvatar, ProviderProfileStatistics, ProviderQuota, ProviderQuotaRequest,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, explicit_plan_type,
PreparedCredentialRotation, ProviderExport, ProviderExportCredentialInput,
ProviderModelCatalogDocument, ProviderModels, ProviderProfileAvatar, ProviderProfileStatistics,
ProviderQuota, ProviderQuotaRequest, ProviderResetCreditResult, ProviderResetCredits,
ProviderSubscription, explicit_plan_type,
};
use crate::model::{
provider_credentials::{ProviderDocument, ProviderQuotaWindow},
@@ -266,6 +267,14 @@ pub trait ProviderAdmin: Send + Sync {
refresh: bool,
) -> Result<ProviderModels, ProviderAdminError>;
/// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝。
async fn model_catalog_document(
&self,
_account_id: &ProviderAccountId,
) -> Result<ProviderModelCatalogDocument, ProviderAdminError> {
Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported))
}
async fn export_credentials(
&self,
credentials: Vec<ProviderExportCredentialInput>,
@@ -24,9 +24,9 @@ use crate::{
provider_credentials::{
AccountDirectoryItem, AccountDirectoryPage, AccountExportBundle, AccountPersonalInfo,
AccountRefreshResult, ConsumeProviderResetCredit, PrepareCredentialRefresh,
ProviderModels, ProviderProfileAvatar, ProviderQuota, ProviderQuotaRequest,
ProviderQuotaWindow, ProviderResetCreditResult, ProviderResetCredits,
QuotaLocalUsageAttribution,
ProviderModelCatalogDocument, ProviderModels, ProviderProfileAvatar, ProviderQuota,
ProviderQuotaRequest, ProviderQuotaWindow, ProviderResetCreditResult,
ProviderResetCredits, QuotaLocalUsageAttribution,
},
quota_forecast::{AccountQuotaForecastReport, account_quota_forecasts},
quota_forecast_sampling::{QuotaForecastPoint, select_forecast_sample},
@@ -140,6 +140,11 @@ pub trait AccountsService: Send + Sync {
refresh: bool,
) -> Result<ProviderModels, AdminError>;
async fn model_catalog_document(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderModelCatalogDocument, AdminError>;
async fn test_connection(
&self,
account_id: ProviderAccountId,
@@ -887,6 +892,17 @@ impl AccountsService for DefaultAccountsService {
.map_err(|error| map_provider_error(error, "provider model catalog"))
}
async fn model_catalog_document(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderModelCatalogDocument, AdminError> {
let (_, provider) = self.provider_for_account(account_id).await?;
provider
.model_catalog_document(account_id)
.await
.map_err(|error| map_provider_error(error, "provider model catalog document"))
}
async fn test_connection(
&self,
account_id: ProviderAccountId,
@@ -46,6 +46,10 @@ where
post(refresh_account_quota::<S>),
)
.route("/api/admin/accounts/models", get(account_models::<S>))
.route(
"/api/admin/accounts/models/catalog",
get(account_model_catalog::<S>),
)
.route(
"/api/admin/accounts/models/refresh",
post(refresh_account_models::<S>),
@@ -565,6 +569,25 @@ where
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn account_model_catalog<S>(
_auth: AdminAuth,
State(state): State<S>,
AdminQuery(query): AdminQuery<AccountIdQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let account_id = query.into_id().map_err(map_wire_error)?;
let result = state
.admin_services()
.accounts()
.model_catalog_document(&account_id)
.await
.map_err(map_service_error)?;
let data = AccountModelCatalogData::try_from(result).map_err(|_| AdminError::internal())?;
Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data)))
}
async fn refresh_account_models<S>(
_auth: AdminAuth,
State(state): State<S>,
@@ -29,11 +29,11 @@ use gateway_admin::model::{
AccountRefreshResult, AccountUsagePeriod, AuthorizationStarted, CompleteAuthorization,
ConsumeProviderResetCredit, CredentialDeletion, CredentialDeletionResult,
CredentialImportResult, CredentialMutation, CredentialMutationResult, ImportCredentials,
ProviderDocument, ProviderModels, ProviderProfileActivityInsights, ProviderProfileAvatar,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaWindow, ProviderResetCredit,
ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription, RotateCredential,
StartAuthorization,
ProviderDocument, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaWindow, ProviderResetCredit, ProviderResetCreditResult,
ProviderResetCredits, ProviderSubscription, RotateCredential, StartAuthorization,
},
};
use gateway_core::{
@@ -675,6 +675,37 @@ pub struct AccountModelsData {
pub models: Vec<AccountModelView>,
}
/// 账号模型目录文件;`catalog` 直接作为 Codex `model_catalog_json` 的文件正文落盘。
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountModelCatalogData {
pub model_count: usize,
pub observed_at: String,
pub catalog: Value,
}
/// Provider 未给出 Codex 原生目录正文;该结果不能作为客户端目录文件返回。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UnsupportedModelCatalogDocument;
impl TryFrom<ProviderModelCatalogDocument> for AccountModelCatalogData {
type Error = UnsupportedModelCatalogDocument;
/// 目录正文由 Provider 按官方 wire 组装;这里只解析一次用于 JSON 响应,不改写字段。
fn try_from(result: ProviderModelCatalogDocument) -> Result<Self, Self::Error> {
if result.document.protocol() != "codex" {
return Err(UnsupportedModelCatalogDocument);
}
let catalog = serde_json::from_slice(result.document.body())
.map_err(|_| UnsupportedModelCatalogDocument)?;
Ok(Self {
model_count: result.model_count,
observed_at: china_rfc3339(&result.observed_at),
catalog,
})
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountRefreshData {
@@ -1,8 +1,12 @@
use bytes::Bytes;
use gateway_admin::model::{
provider_credentials::AccountUsagePeriod,
provider_credentials::{AccountUsagePeriod, ProviderModelCatalogDocument},
quota_forecast::{AccountQuotaForecast, AccountQuotaForecastReport, QuotaForecastSource},
};
use gateway_api::admin::accounts::AccountQuotaForecastData;
use gateway_api::admin::accounts::{
AccountModelCatalogData, AccountQuotaForecastData, UnsupportedModelCatalogDocument,
};
use gateway_core::operation::RawJsonPayload;
#[test]
fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
@@ -67,3 +71,41 @@ fn quota_forecast_projection_only_exposes_capacity_and_preserves_null_zero() {
assert_eq!(value["forecasts"][1]["extrapolated"], true);
assert!(value.get("account").is_none());
}
#[test]
fn model_catalog_projection_keeps_upstream_document_and_rejects_non_codex_wire() {
let observed_at = "2026-09-12T08:00:00Z".parse().unwrap();
// 上游原生对象里的元数据必须原样到达客户端文件,否则 Codex 读不到推理强度和上下文窗口。
let body = serde_json::json!({
"models": [{
"slug": "gpt-5.6-luna",
"display_name": "Luna",
"context_window": 272_000,
"supported_reasoning_levels": [{ "effort": "high" }],
}],
});
let document = RawJsonPayload::new("codex", Bytes::from(serde_json::to_vec(&body).unwrap()))
.expect("codex payload");
let data = AccountModelCatalogData::try_from(ProviderModelCatalogDocument {
document,
model_count: 1,
observed_at,
})
.expect("codex catalog is projectable");
assert_eq!(data.model_count, 1);
assert_eq!(data.catalog, body);
assert_eq!(data.observed_at, "2026-09-12T16:00:00+08:00");
// 只有模型 ID 的 API 目录拼不出合法的 model_catalog_json,不能降格返回给客户端。
let adapted = RawJsonPayload::new("openai", Bytes::from_static(br#"{"models":[]}"#))
.expect("openai payload");
assert_eq!(
AccountModelCatalogData::try_from(ProviderModelCatalogDocument {
document: adapted,
model_count: 0,
observed_at,
})
.err(),
Some(UnsupportedModelCatalogDocument)
);
}
+42 -7
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use std::time::SystemTime;
use async_trait::async_trait;
use bytes::Bytes;
use chrono::{DateTime, Utc};
use futures::TryStreamExt as _;
use gateway_admin::model::Revision;
@@ -20,12 +21,12 @@ use gateway_admin::model::provider_credentials::{
PrepareCredentialRotation, PreparedAuthorizationCommit, PreparedAuthorizationCredential,
PreparedCredentialCreate, PreparedCredentialImport, PreparedCredentialRotation,
PreparedCredentialRotationFacts, ProviderDocument, ProviderExport,
ProviderExportCredentialInput, ProviderModel, ProviderModels, ProviderProfileActivityInsights,
ProviderProfileAvatar, ProviderProfileAvatarStreamError, ProviderProfileDailyUsage,
ProviderProfileInvocation, ProviderProfileStatistics, ProviderProfileStatisticsSummary,
ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow, ProviderQuotaWindowRole,
ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits, ProviderSubscription,
QuotaLocalUsageAttribution,
ProviderExportCredentialInput, ProviderModel, ProviderModelCatalogDocument, ProviderModels,
ProviderProfileActivityInsights, ProviderProfileAvatar, ProviderProfileAvatarStreamError,
ProviderProfileDailyUsage, ProviderProfileInvocation, ProviderProfileStatistics,
ProviderProfileStatisticsSummary, ProviderQuota, ProviderQuotaRequest, ProviderQuotaWindow,
ProviderQuotaWindowRole, ProviderResetCredit, ProviderResetCreditResult, ProviderResetCredits,
ProviderSubscription, QuotaLocalUsageAttribution,
};
use gateway_admin::model::quota_forecast_sampling::QuotaForecastObservation;
use gateway_admin::ports::provider::{ProviderAdmin, ProviderAdminError, ProviderAdminErrorKind};
@@ -36,7 +37,7 @@ use gateway_core::account::{
};
use gateway_core::error::StoreErrorKind;
use gateway_core::metering::Money;
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload};
use gateway_core::operation::{GenerateRequest, Operation, ProtocolPayload, RawJsonPayload};
use gateway_core::provider_ports::{
NewOAuthPendingFlow, OAuthPendingBinding, OAuthPendingClaimOutcome, OAuthPendingConsumeOutcome,
OAuthPendingFlowPort, OAuthPendingPutOutcome, OAuthPendingReleaseOutcome, ProviderStoreError,
@@ -807,6 +808,40 @@ impl ProviderAdmin for OpenAiAdminProvider {
})
}
async fn model_catalog_document(
&self,
account_id: &ProviderAccountId,
) -> Result<ProviderModelCatalogDocument, ProviderAdminError> {
let account = self.account(account_id).await?;
let (models, observed_at) = self
.catalog
.account_catalog_documents(&account)
.await
.map_err(map_catalog_error)?;
// 只有 Codex 原生对象带齐推理强度、上下文窗口等元数据;API 目录只有模型 ID,
// 拼出来的文件不满足 Codex `model_catalog_json` 的加载要求,这里直接拒绝而不降格。
let mut entries = Vec::with_capacity(models.len());
for model in &models {
if model.document().protocol() != "codex" {
return Err(provider_admin_error(ProviderAdminErrorKind::Unsupported)
.with_public_message("该账号没有可导出的 Codex 原生模型目录"));
}
let entry: serde_json::Value = serde_json::from_slice(model.document().body())
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Internal))?;
entries.push(entry);
}
let model_count = entries.len();
let body = serde_json::to_vec(&serde_json::json!({ "models": entries }))
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Internal))?;
let document = RawJsonPayload::new("codex", Bytes::from(body))
.map_err(|_| provider_admin_error(ProviderAdminErrorKind::Internal))?;
Ok(ProviderModelCatalogDocument {
document,
model_count,
observed_at: DateTime::<Utc>::from(observed_at),
})
}
async fn export_credentials(
&self,
credentials: Vec<ProviderExportCredentialInput>,
@@ -534,6 +534,34 @@ impl CodexCredentialCatalogService {
Ok(catalog)
}
/// 读取该账号所属套餐的原生目录条目;供管理端按账号导出目录文件使用。
///
/// 复用进程内快照,不单独向上游发起请求;快照缺失时按 `synchronize` 的常规路径补齐。
/// 返回顺序沿用上游目录顺序,条目内容保持上游原样,不做别名改写或字段裁剪。
pub async fn account_catalog_documents(
&self,
account: &ProviderAccount,
) -> Result<(Vec<CodexCatalogModel>, SystemTime), CodexCredentialCatalogError> {
let snapshot = self.synchronize().await?;
let Some(entitlement) = snapshot.account_models(account)? else {
return Err(CodexCredentialCatalogError::NoEligibleCredential);
};
let entitlement = entitlement
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>();
let models = snapshot
.models()
.iter()
.filter(|model| entitlement.contains(model.request_model().as_str()))
.cloned()
.collect::<Vec<_>>();
if models.is_empty() {
return Err(CodexCredentialCatalogError::NoEligibleCredential);
}
Ok((models, snapshot.observed_at()))
}
/// 读取当前账号所属套餐的目录 cache,不触发上游请求。
pub async fn read_account_catalog(
&self,
@@ -1365,3 +1365,46 @@ async fn slow_api_key_catalogs_share_a_deadline_and_preserve_healthy_catalogs()
Err(CodexCredentialCatalogError::Upstream { .. })
));
}
#[tokio::test]
async fn account_catalog_documents_keep_native_objects_of_the_account_plan() {
let store = Arc::new(MemoryAccountStore::default());
let plus = seed_account_with_plan(&store, "acct_catalog_plus", "plus").await;
let pro = seed_account_with_plan(&store, "acct_catalog_pro", "pro").await;
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/codex/models"))
.respond_with(SequencedCatalogResponder {
calls: Arc::new(AtomicUsize::new(0)),
bodies: [
br#"{"models":[{"slug":"gpt-plus","display_name":"Plus","context_window":128000}]}"#,
br#"{"models":[{"slug":"gpt-pro","display_name":"Pro","context_window":272000}]}"#,
],
})
.expect(2)
.mount(&server)
.await;
let service = service_with_catalog_cache(&store, server.uri(), catalog_cache());
let (models, _) = service
.account_catalog_documents(&pro)
.await
.expect("pro catalog");
assert_eq!(models.len(), 1);
assert_eq!(models[0].request_model().as_str(), "gpt-pro");
// 导出的目录文件靠原生对象携带上下文窗口等元数据,正文必须原样保留。
assert_eq!(models[0].document().protocol(), "codex");
let document: serde_json::Value =
serde_json::from_slice(models[0].document().body()).expect("native document");
assert_eq!(document["context_window"], 272_000);
// 另一套餐的账号不能拿到别的套餐条目,否则客户端会列出自己用不了的模型。
let (plus_models, _) = service
.account_catalog_documents(&plus)
.await
.expect("plus catalog");
assert_eq!(plus_models.len(), 1);
assert_eq!(plus_models[0].request_model().as_str(), "gpt-plus");
server.verify().await;
}
+16
View File
@@ -187,6 +187,22 @@ goals = true
`experimental_bearer_token` 读取代理密钥,可与仅含 API Key 的 `auth.json` 共存。
真实 OpenAI OAuth 账号文件用于管理端账号导入,不要当作代理配置分发给客户端。
### 模型目录
部分客户端只使用 `config.toml` 里指定的模型,不显示可选模型列表。需要在客户端里切换模型时,
在管理端「账号管理」的账号行展开「更多操作 → 下载模型目录」,导出该账号的目录文件,
保存到本机任意可读路径,例如 `~/.codex/models.json`,再在 `config.toml` 中按绝对路径指定:
```toml
model_catalog_json = "/home/<user>/.codex/models.json"
```
目录只在客户端启动时加载,改完要完全退出并重启客户端。文件只含模型元数据,不含账号凭据。
导出的是该账号所属套餐的上游原样目录,保留上游的模型标识、展示名称、推理强度、上下文窗口与服务档位。
配置了模型别名时,`model` 仍应填网关对外的模型标识;上游目录变化或账号换套餐后需要重新下载。
xAI 账号不提供这种原生目录,菜单只对 OpenAI 账号显示。
### 生图和 WebSocket
模板启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`,
+13
View File
@@ -439,10 +439,23 @@ config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前
| `POST` | `/api/admin/accounts/reset-credits` | `{ accountId, creditId?, redeemRequestId }` | 使用 UUIDv4 幂等键消费一张 OpenAI 上游重置卡 |
| `GET` | `/api/admin/accounts/models` | `accountId` | 优先读取该 Provider + 套餐的模型 cache,缺失时有限实时拉取 |
| `POST` | `/api/admin/accounts/models/refresh` | `{ accountId }` | 强制拉取最新模型并覆盖 cache |
| `GET` | `/api/admin/accounts/models/catalog` | `accountId` | 导出该账号的 Codex 原生模型目录,供客户端 `model_catalog_json` 加载 |
| `GET` | `/api/admin/accounts/connection-test` | `accountId`、`modelId` | 通过 SSE 返回实时连接测试事件,不作为业务 Responses 用量记录 |
| `POST` | `/api/admin/accounts/oauth/start` | `{ provider, name, accountId?, outboundProxyId?, outboundProxyUrl? }` | 创建 OpenAI 或 xAI OAuth flow;`accountId` 表示重新授权 |
| `POST` | `/api/admin/accounts/oauth/complete` | `{ provider, flowId, callbackUrl, settings? }` | 消费 OAuth callback;首次授权可附带账号设置,重新授权保留原设置 |
`/api/admin/accounts/models/catalog` 返回 `{ modelCount, observedAt, catalog }`,其中 `catalog` 就是
Codex `model_catalog_json` 要求的 `{"models":[...]}` 文件正文,直接落盘即可,不需要再加工。条目取自该账号
所属套餐的原生目录快照,保留上游的模型标识、展示名称、推理强度、上下文窗口与服务档位,不做别名改写或
字段裁剪;因此配置了模型别名时,客户端应使用网关对外的模型标识,导出的原生 slug 只反映上游目录。
目录正文只包含模型元数据,不含 access token、refresh token 或任何账号凭据。只有能提供 Codex 原生目录的
账号可以导出:xAI 账号与只返回模型 ID 的 API 目录不满足 `model_catalog_json` 的加载要求,返回 `400` 并
说明该账号没有可导出的原生目录,不降格成只含模型 ID 的文件。
客户端使用时把文件保存到本地任意可读路径,在 `config.toml` 写
`model_catalog_json = "/path/to/cpr-model-catalog-<账号>.json"`,重启客户端后才会重新加载目录。
账号列表支持以下稳定值:
- `provider`: `all`、`openai`、`xai`;
+16
View File
@@ -298,6 +298,13 @@ export interface AccountModelsResponse {
models: Array<{ id: string, label: string }>
}
export interface AccountModelCatalogResponse {
modelCount: number
observedAt: string
/** Codex `model_catalog_json` 的文件正文,原样落盘即可被客户端加载。 */
catalog: unknown
}
export interface AccountImportResponse {
importedCount: number
accountIds: string[]
@@ -547,6 +554,15 @@ export function getAccountModels(data: AccountIdParam, options: RequestOptions =
})
}
export function getAccountModelCatalog(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountModelCatalogResponse>({
url: '/api/admin/accounts/models/catalog',
method: 'GET',
params: data,
...options,
})
}
export function refreshAccountModels(data: AccountIdParam, options: RequestOptions = {}) {
return request<AccountModelsResponse>({
url: '/api/admin/accounts/models/refresh',
@@ -1,6 +1,6 @@
<script setup lang="ts">
import type { AccountRow } from '../constants'
import { KeyRound, MoreHorizontal, Pencil, RefreshCw, RotateCcw, Trash2, Wifi } from '@lucide/vue'
import { Download, KeyRound, MoreHorizontal, Pencil, RefreshCw, RotateCcw, Trash2, Wifi } from '@lucide/vue'
import BaseIconButton from '@/components/base/BaseIconButton.vue'
import BaseMenuItem from '@/components/base/BaseMenuItem.vue'
@@ -9,6 +9,7 @@ import BasePopover from '@/components/base/BasePopover.vue'
defineProps<{
account: AccountRow
deleting: boolean
downloadingCatalog: boolean
recovering: boolean
refreshing: boolean
testing: boolean
@@ -21,6 +22,7 @@ const emit = defineEmits<{
test: [account: AccountRow]
refresh: [accountId: string]
reauthorize: [account: AccountRow]
downloadModelCatalog: [account: AccountRow]
}>()
</script>
@@ -84,6 +86,20 @@ const emit = defineEmits<{
</template>
重新授权
</BaseMenuItem>
<BaseMenuItem
v-if="account.provider === 'openai'"
:loading="downloadingCatalog"
:disabled="downloadingCatalog"
@click.stop="(close(), emit('downloadModelCatalog', account))"
>
<template #loading>
<RefreshCw class="size-3.5 animate-spin text-cp-text-quaternary motion-reduce:animate-none" />
</template>
<template #icon>
<Download class="size-3.5 text-cp-text-quaternary" />
</template>
下载模型目录
</BaseMenuItem>
<BaseMenuItem
:loading="recovering"
:disabled="recovering"
@@ -6,6 +6,7 @@ import { ref, watch } from 'vue'
import {
deleteAccounts,
exportAccounts,
getAccountModelCatalog,
recoverAccount,
refreshAccount,
refreshAccountQuota,
@@ -40,12 +41,14 @@ export function useAccountMutations(options: {
const recoveringAccounts = useIdSet<string>()
const refreshingAccounts = useIdSet<string>()
const refreshingQuotaAccounts = useIdSet<string>()
const downloadingCatalogAccounts = useIdSet<string>()
const deletingAccountAction = useAsyncAction()
const batchDeletingAction = useAsyncAction()
const exportingAccountsAction = useAsyncAction()
const recoveringAccountIds = recoveringAccounts.ids
const refreshingAccountIds = refreshingAccounts.ids
const refreshingQuotaAccountIds = refreshingQuotaAccounts.ids
const downloadingCatalogAccountIds = downloadingCatalogAccounts.ids
const deletingAccount = deletingAccountAction.loading
const batchDeleting = batchDeletingAction.loading
const exportingAccounts = exportingAccountsAction.loading
@@ -147,6 +150,19 @@ export function useAccountMutations(options: {
)
}
async function handleDownloadModelCatalog(account: AccountRow) {
await downloadingCatalogAccounts.run(account.id, async () => {
try {
const result = await getAccountModelCatalog({ accountId: account.id })
// 文件名带账号名便于区分多账号目录;账号名可能含空格等字符,统一收敛为安全片段。
const slug = account.name.replace(/[^\w-]/g, '_') || account.id
await downloadJson(result.catalog, `cpr-model-catalog-${slug}.json`)
toast.success(`已导出 ${result.modelCount} 个模型,配置 model_catalog_json 后重启客户端生效`)
}
catch {}
})
}
async function handleRefresh(accountId: string) {
await refreshingAccounts.run(accountId, async () => {
try {
@@ -260,6 +276,7 @@ export function useAccountMutations(options: {
recoveringAccountIds,
refreshingAccountIds,
refreshingQuotaAccountIds,
downloadingCatalogAccountIds,
deletingAccount,
batchDeleting,
exportingAccounts,
@@ -267,6 +284,7 @@ export function useAccountMutations(options: {
handleDelete,
handleBatchDelete,
handleExportAccounts,
handleDownloadModelCatalog,
handleRecover,
handleRefresh,
handleRefreshQuota,
+4
View File
@@ -85,6 +85,7 @@ const {
recoveringAccountIds,
refreshingAccountIds,
refreshingQuotaAccountIds,
downloadingCatalogAccountIds,
deletingAccount,
creatingAccount,
authorizingOAuth,
@@ -100,6 +101,7 @@ const {
handleDelete,
handleBatchDelete,
handleExportAccounts,
handleDownloadModelCatalog,
handleRecover,
handleRefresh,
handleRefreshQuota,
@@ -321,11 +323,13 @@ const {
<AccountTableActions
:account="row"
:deleting="deletingAccount"
:downloading-catalog="downloadingCatalogAccountIds.has(row.id)"
:recovering="recoveringAccountIds.has(row.id)"
:refreshing="refreshingAccountIds.has(row.id)"
:testing="testingConnectionIds.has(row.id)"
@edit="openAccountEdit"
@delete="requestDeleteAccount"
@download-model-catalog="handleDownloadModelCatalog"
@recover="handleRecover"
@refresh="handleRefresh"
@reauthorize="openReauthorizeAccount"