diff --git a/backend/crates/gateway-admin/src/model/provider_credentials.rs b/backend/crates/gateway-admin/src/model/provider_credentials.rs index f3cb67400..afb3910d3 100644 --- a/backend/crates/gateway-admin/src/model/provider_credentials.rs +++ b/backend/crates/gateway-admin/src/model/provider_credentials.rs @@ -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>, } +/// Provider 原生模型目录正文;wire 语义由对应 Provider 拥有,公共层只搬运不解释字段。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderModelCatalogDocument { + pub document: RawJsonPayload, + pub model_count: usize, + pub observed_at: DateTime, +} + /// Provider 执行 refresh 时所需的当前公共账号事实。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct PrepareCredentialRefresh { diff --git a/backend/crates/gateway-admin/src/ports/provider.rs b/backend/crates/gateway-admin/src/ports/provider.rs index a13f0de48..bfffc3d1f 100644 --- a/backend/crates/gateway-admin/src/ports/provider.rs +++ b/backend/crates/gateway-admin/src/ports/provider.rs @@ -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; + /// 导出该账号的 Provider 原生模型目录正文;不提供原生目录的 Provider 使用默认拒绝。 + async fn model_catalog_document( + &self, + _account_id: &ProviderAccountId, + ) -> Result { + Err(ProviderAdminError::new(ProviderAdminErrorKind::Unsupported)) + } + async fn export_credentials( &self, credentials: Vec, diff --git a/backend/crates/gateway-admin/src/use_case/accounts.rs b/backend/crates/gateway-admin/src/use_case/accounts.rs index 5ce26298b..c689f0020 100644 --- a/backend/crates/gateway-admin/src/use_case/accounts.rs +++ b/backend/crates/gateway-admin/src/use_case/accounts.rs @@ -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; + async fn model_catalog_document( + &self, + account_id: &ProviderAccountId, + ) -> Result; + 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 { + 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, diff --git a/backend/crates/gateway-api/src/admin/accounts/handlers.rs b/backend/crates/gateway-api/src/admin/accounts/handlers.rs index 9440d2318..2e923d21f 100644 --- a/backend/crates/gateway-api/src/admin/accounts/handlers.rs +++ b/backend/crates/gateway-api/src/admin/accounts/handlers.rs @@ -46,6 +46,10 @@ where post(refresh_account_quota::), ) .route("/api/admin/accounts/models", get(account_models::)) + .route( + "/api/admin/accounts/models/catalog", + get(account_model_catalog::), + ) .route( "/api/admin/accounts/models/refresh", post(refresh_account_models::), @@ -565,6 +569,25 @@ where Ok(AdminResponse::new(StatusCode::OK, AdminEnvelope::ok(data))) } +async fn account_model_catalog( + _auth: AdminAuth, + State(state): State, + AdminQuery(query): AdminQuery, +) -> Result +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( _auth: AdminAuth, State(state): State, diff --git a/backend/crates/gateway-api/src/admin/accounts/mod.rs b/backend/crates/gateway-api/src/admin/accounts/mod.rs index 5681138f0..e8d23f727 100644 --- a/backend/crates/gateway-api/src/admin/accounts/mod.rs +++ b/backend/crates/gateway-api/src/admin/accounts/mod.rs @@ -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::{ diff --git a/backend/crates/gateway-api/src/admin/accounts/wire.rs b/backend/crates/gateway-api/src/admin/accounts/wire.rs index cee3b5eab..0c82643ce 100644 --- a/backend/crates/gateway-api/src/admin/accounts/wire.rs +++ b/backend/crates/gateway-api/src/admin/accounts/wire.rs @@ -675,6 +675,37 @@ pub struct AccountModelsData { pub models: Vec, } +/// 账号模型目录文件;`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 for AccountModelCatalogData { + type Error = UnsupportedModelCatalogDocument; + + /// 目录正文由 Provider 按官方 wire 组装;这里只解析一次用于 JSON 响应,不改写字段。 + fn try_from(result: ProviderModelCatalogDocument) -> Result { + 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 { diff --git a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs index 555495d64..74d950522 100644 --- a/backend/crates/gateway-api/tests/admin/accounts/presenter.rs +++ b/backend/crates/gateway-api/tests/admin/accounts/presenter.rs @@ -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) + ); +} diff --git a/backend/crates/providers/openai/src/admin.rs b/backend/crates/providers/openai/src/admin.rs index 6142e3677..119f25804 100644 --- a/backend/crates/providers/openai/src/admin.rs +++ b/backend/crates/providers/openai/src/admin.rs @@ -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 { + 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::::from(observed_at), + }) + } + async fn export_credentials( &self, credentials: Vec, diff --git a/backend/crates/providers/openai/src/credential/catalog.rs b/backend/crates/providers/openai/src/credential/catalog.rs index cd4c88d84..e1487a66e 100644 --- a/backend/crates/providers/openai/src/credential/catalog.rs +++ b/backend/crates/providers/openai/src/credential/catalog.rs @@ -534,6 +534,34 @@ impl CodexCredentialCatalogService { Ok(catalog) } + /// 读取该账号所属套餐的原生目录条目;供管理端按账号导出目录文件使用。 + /// + /// 复用进程内快照,不单独向上游发起请求;快照缺失时按 `synchronize` 的常规路径补齐。 + /// 返回顺序沿用上游目录顺序,条目内容保持上游原样,不做别名改写或字段裁剪。 + pub async fn account_catalog_documents( + &self, + account: &ProviderAccount, + ) -> Result<(Vec, 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::>(); + let models = snapshot + .models() + .iter() + .filter(|model| entitlement.contains(model.request_model().as_str())) + .cloned() + .collect::>(); + if models.is_empty() { + return Err(CodexCredentialCatalogError::NoEligibleCredential); + } + Ok((models, snapshot.observed_at())) + } + /// 读取当前账号所属套餐的目录 cache,不触发上游请求。 pub async fn read_account_catalog( &self, diff --git a/backend/crates/providers/openai/tests/credential/catalog.rs b/backend/crates/providers/openai/tests/credential/catalog.rs index 41b2e7280..ccd5b5d66 100644 --- a/backend/crates/providers/openai/tests/credential/catalog.rs +++ b/backend/crates/providers/openai/tests/credential/catalog.rs @@ -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; +} diff --git a/deploy/README.md b/deploy/README.md index 53a90885e..59da137db 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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//.codex/models.json" +``` + +目录只在客户端启动时加载,改完要完全退出并重启客户端。文件只含模型元数据,不含账号凭据。 + +导出的是该账号所属套餐的上游原样目录,保留上游的模型标识、展示名称、推理强度、上下文窗口与服务档位。 +配置了模型别名时,`model` 仍应填网关对外的模型标识;上游目录变化或账号换套餐后需要重新下载。 +xAI 账号不提供这种原生目录,菜单只对 OpenAI 账号显示。 + ### 生图和 WebSocket 模板启用原生生图。Codex 可在任务需要图片时调用 `image_gen.imagegen`, diff --git a/docs/api.md b/docs/api.md index 50293a7c0..85c72bca1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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`; diff --git a/frontend/src/api/modules/accounts.ts b/frontend/src/api/modules/accounts.ts index 7dafccf77..d7391c51f 100644 --- a/frontend/src/api/modules/accounts.ts +++ b/frontend/src/api/modules/accounts.ts @@ -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({ + url: '/api/admin/accounts/models/catalog', + method: 'GET', + params: data, + ...options, + }) +} + export function refreshAccountModels(data: AccountIdParam, options: RequestOptions = {}) { return request({ url: '/api/admin/accounts/models/refresh', diff --git a/frontend/src/views/accounts/components/AccountTableActions.vue b/frontend/src/views/accounts/components/AccountTableActions.vue index f96ffeb48..064e7f3b9 100644 --- a/frontend/src/views/accounts/components/AccountTableActions.vue +++ b/frontend/src/views/accounts/components/AccountTableActions.vue @@ -1,6 +1,6 @@ @@ -84,6 +86,20 @@ const emit = defineEmits<{ 重新授权 + + + + 下载模型目录 + () const refreshingAccounts = useIdSet() const refreshingQuotaAccounts = useIdSet() + const downloadingCatalogAccounts = useIdSet() 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, diff --git a/frontend/src/views/accounts/index.vue b/frontend/src/views/accounts/index.vue index edc4695f7..16ec31366 100644 --- a/frontend/src/views/accounts/index.vue +++ b/frontend/src/views/accounts/index.vue @@ -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 {