mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat: refine admin controls and overload protection
Refine settings navigation, selective pricing sync, model deletion, and admin interactions. Consolidate unreleased migrations and keep Fast policy scoped to groups. Increase confirmed capacity-rejection feedback without changing ordinary failure recovery. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -86,9 +86,6 @@ API Key 持有者可在同一登录页切换登录身份,进入 `/key-usage`
|
||||
2. 按需建立账号分组,再创建客户端密钥并选择可用分组。**不选分组表示可使用全部账号**。
|
||||
3. 打开密钥的「使用密钥」,复制客户端配置。
|
||||
|
||||
账号自动冻结默认关闭,可在「系统设置 → 运行参数」中启用。启用后,账号频繁命中上游容量错误时
|
||||
会暂停调度并显示为限流中;若开启恢复前探测,冷却到期后需探测成功才恢复。
|
||||
|
||||
## 客户端接入
|
||||
|
||||
**Codex CLI / 桌面端**:在「使用密钥」中按操作系统复制配置,或通过 CCSwitch 导入。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 全局模型定价的管理合同。
|
||||
|
||||
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PricingCatalog {
|
||||
@@ -25,11 +25,21 @@ pub struct PricingSyncPreview {
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct SyncPricing {
|
||||
pub preview: PricingSyncPreview,
|
||||
pub models: BTreeMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
pub type PricingSyncChanges = BTreeMap<String, BTreeMap<String, Option<ModelPriceOverride>>>;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PricingChange {
|
||||
Replace(ModelPriceOverride),
|
||||
Multiplier(u32),
|
||||
Reset,
|
||||
Delete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -18,7 +18,6 @@ pub use gateway_core::account::RotationStrategy;
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub disable_fast: bool,
|
||||
pub config_revision: Revision,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
@@ -51,7 +50,6 @@ pub struct RuntimeSettings {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReplaceRuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub disable_fast: Option<bool>,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
|
||||
@@ -420,7 +420,7 @@ pub trait SettingsStore: Send + Sync {
|
||||
async fn load_pricing(&self) -> AdminStoreResult<crate::model::pricing::StoredPricing>;
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
prices: gateway_core::metering::PricingOverrides,
|
||||
changes: crate::model::pricing::PricingSyncChanges,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<crate::model::Revision>;
|
||||
async fn update_pricing(
|
||||
|
||||
@@ -29,7 +29,7 @@ pub trait SettingsService: Send + Sync {
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
preview: crate::model::pricing::PricingSyncPreview,
|
||||
command: crate::model::pricing::SyncPricing,
|
||||
) -> Result<(), AdminError>;
|
||||
async fn pricing(&self) -> Result<crate::model::pricing::PricingCatalog, AdminError>;
|
||||
async fn update_pricing(
|
||||
@@ -103,17 +103,55 @@ impl SettingsService for DefaultSettingsService {
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
context: &MutationContext,
|
||||
preview: crate::model::pricing::PricingSyncPreview,
|
||||
command: crate::model::pricing::SyncPricing,
|
||||
) -> Result<(), AdminError> {
|
||||
let current = self.pricing_source.fetch().await?;
|
||||
if current != preview {
|
||||
let count = command
|
||||
.models
|
||||
.values()
|
||||
.map(std::collections::BTreeSet::len)
|
||||
.sum::<usize>();
|
||||
if count == 0
|
||||
|| count > 10_000
|
||||
|| command
|
||||
.models
|
||||
.values()
|
||||
.any(std::collections::BTreeSet::is_empty)
|
||||
{
|
||||
return Err(AdminError::invalid("请选择 1 至 10000 个模型"));
|
||||
}
|
||||
let mut current = self.pricing_source.fetch().await?;
|
||||
if current != command.preview {
|
||||
return Err(AdminError::invalid(
|
||||
"models.dev 价目已变化,请重新预览后确认",
|
||||
));
|
||||
}
|
||||
let stored = self
|
||||
.store
|
||||
.load_pricing()
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "model pricing"))?;
|
||||
let mut changes = crate::model::pricing::PricingSyncChanges::new();
|
||||
for (provider, models) in command.models {
|
||||
let selected = changes.entry(provider.clone()).or_default();
|
||||
for model in models {
|
||||
let price = current
|
||||
.prices
|
||||
.get_mut(&provider)
|
||||
.and_then(|prices| prices.remove(&model));
|
||||
if price.is_none()
|
||||
&& !stored
|
||||
.synced
|
||||
.get(&provider)
|
||||
.is_some_and(|prices| prices.contains_key(&model))
|
||||
{
|
||||
return Err(AdminError::invalid("所选模型不在来源价目中,请重新预览"));
|
||||
}
|
||||
selected.insert(model, price);
|
||||
}
|
||||
}
|
||||
let revision = self
|
||||
.store
|
||||
.sync_pricing(current.prices, context)
|
||||
.sync_pricing(changes, context)
|
||||
.await
|
||||
.map_err(|error| map_store_error(error, "model pricing sync"))?;
|
||||
publish_committed(self.snapshot.as_ref(), revision).await
|
||||
@@ -153,6 +191,16 @@ impl SettingsService for DefaultSettingsService {
|
||||
{
|
||||
return Err(AdminError::invalid("Provider、模型 ID 或批量数量不合法"));
|
||||
}
|
||||
if command.change == PricingChange::Delete
|
||||
&& command.models.iter().any(|model| {
|
||||
catalog
|
||||
.defaults
|
||||
.get(&command.provider)
|
||||
.is_some_and(|models| models.contains_key(model))
|
||||
})
|
||||
{
|
||||
return Err(AdminError::invalid("内置价目不能删除,仅支持人工覆盖"));
|
||||
}
|
||||
let defaults = gateway_core::metering::merge_pricing(catalog.defaults, &catalog.synced);
|
||||
let defaults = defaults.get(&command.provider);
|
||||
match &command.change {
|
||||
|
||||
@@ -976,7 +976,7 @@ impl SettingsStore for StaticSettingsStore {
|
||||
}
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
_: gateway_core::metering::PricingOverrides,
|
||||
_: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
panic!("unexpected pricing sync")
|
||||
@@ -991,7 +991,6 @@ impl SettingsStore for StaticSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: false,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
|
||||
@@ -26,7 +26,6 @@ use super::accounts::{FakeAccountStore, FakeProviderAdmin, account_record, event
|
||||
fn runtime_settings(enabled: bool, probe_enabled: bool, adaptive: bool) -> RuntimeSettings {
|
||||
RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: false,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: revision(1),
|
||||
@@ -67,7 +66,7 @@ impl SettingsStore for FreezeSettingsStore {
|
||||
}
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
_: gateway_core::metering::PricingOverrides,
|
||||
_: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
panic!("unexpected pricing sync")
|
||||
|
||||
@@ -711,7 +711,7 @@ impl SettingsStore for UnavailableStore {
|
||||
}
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
_: gateway_core::metering::PricingOverrides,
|
||||
_: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
panic!("unexpected pricing sync")
|
||||
|
||||
@@ -758,7 +758,7 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
}
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
_: gateway_core::metering::PricingOverrides,
|
||||
_: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
panic!("unexpected pricing sync")
|
||||
@@ -773,7 +773,6 @@ impl SettingsStore for FixtureSettingsStore {
|
||||
async fn load_runtime_settings(&self) -> AdminStoreResult<RuntimeSettings> {
|
||||
Ok(RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: false,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(1).expect("revision"),
|
||||
|
||||
@@ -20,7 +20,7 @@ impl SettingsStore for UnusedSettingsStore {
|
||||
}
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
_: gateway_core::metering::PricingOverrides,
|
||||
_: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
panic!("unexpected pricing sync")
|
||||
@@ -79,7 +79,6 @@ async fn settings_should_reject_zero_refresh_margin_before_store_call() {
|
||||
},
|
||||
ReplaceRuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
model_mappings: Default::default(),
|
||||
|
||||
@@ -35,7 +35,6 @@ pub type ModelMappings = BTreeMap<String, String>;
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RuntimeSettingsView {
|
||||
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
pub disable_fast: bool,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -69,7 +68,6 @@ pub struct RuntimeSettingsView {
|
||||
pub struct UpdateRuntimeSettingsRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_profile_update")]
|
||||
pub openai_client_profile: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
pub disable_fast: Option<bool>,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub model_mappings: ModelMappings,
|
||||
@@ -183,7 +181,6 @@ impl UpdateRuntimeSettingsRequest {
|
||||
openai_client_profile: self
|
||||
.openai_client_profile
|
||||
.map(gateway_core::account::OpaqueProviderData::new),
|
||||
disable_fast: self.disable_fast,
|
||||
request_location_enabled: self.request_location_enabled,
|
||||
request_location: self
|
||||
.request_location
|
||||
@@ -228,7 +225,6 @@ impl From<RuntimeSettings> for RuntimeSettingsView {
|
||||
openai_client_profile: settings
|
||||
.openai_client_profile
|
||||
.map(gateway_core::account::OpaqueProviderData::into_inner),
|
||||
disable_fast: settings.disable_fast,
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
model_mappings: wire_model_mappings(settings.model_mappings),
|
||||
@@ -429,6 +425,7 @@ enum PricingChangeRequest {
|
||||
multiplier_bps: u32,
|
||||
},
|
||||
Reset {},
|
||||
Delete {},
|
||||
}
|
||||
|
||||
async fn pricing<S>(
|
||||
@@ -475,7 +472,7 @@ where
|
||||
async fn sync_pricing<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(preview): AdminJson<gateway_admin::model::pricing::PricingSyncPreview>,
|
||||
AdminJson(command): AdminJson<gateway_admin::model::pricing::SyncPricing>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: SessionState + Send + Sync,
|
||||
@@ -483,7 +480,7 @@ where
|
||||
state
|
||||
.admin_services()
|
||||
.settings()
|
||||
.sync_pricing(&auth.context().mutation_context(), preview)
|
||||
.sync_pricing(&auth.context().mutation_context(), command)
|
||||
.await
|
||||
.map_err(map_service_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
@@ -507,6 +504,7 @@ where
|
||||
PricingChange::Multiplier(multiplier_bps)
|
||||
}
|
||||
PricingChangeRequest::Reset {} => PricingChange::Reset,
|
||||
PricingChangeRequest::Delete {} => PricingChange::Delete,
|
||||
};
|
||||
state
|
||||
.admin_services()
|
||||
|
||||
@@ -446,11 +446,21 @@ impl SettingsStore for MemorySettingsStore {
|
||||
}
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
prices: gateway_core::metering::PricingOverrides,
|
||||
changes: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
_: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
let mut pricing = self.pricing.lock().expect("pricing");
|
||||
pricing.synced = prices;
|
||||
for (provider, models) in changes {
|
||||
let stored = pricing.synced.entry(provider).or_default();
|
||||
for (model, price) in models {
|
||||
if let Some(price) = price {
|
||||
stored.insert(model, price);
|
||||
} else {
|
||||
stored.remove(&model);
|
||||
}
|
||||
}
|
||||
}
|
||||
pricing.synced.retain(|_, models| !models.is_empty());
|
||||
pricing.synced_at = Some(Utc::now());
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
settings.config_revision = next_revision(settings.config_revision);
|
||||
@@ -463,12 +473,20 @@ impl SettingsStore for MemorySettingsStore {
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
use gateway_admin::model::pricing::PricingChange;
|
||||
let mut pricing = self.pricing.lock().expect("pricing");
|
||||
let models = pricing.overrides.entry(command.provider).or_default();
|
||||
let gateway_admin::model::pricing::StoredPricing {
|
||||
overrides, synced, ..
|
||||
} = &mut *pricing;
|
||||
let models = overrides.entry(command.provider.clone()).or_default();
|
||||
let source = synced.entry(command.provider).or_default();
|
||||
for model in command.models {
|
||||
match &command.change {
|
||||
PricingChange::Reset => {
|
||||
models.remove(&model);
|
||||
}
|
||||
PricingChange::Delete => {
|
||||
models.remove(&model);
|
||||
source.remove(&model);
|
||||
}
|
||||
PricingChange::Replace(p) => {
|
||||
models.insert(model, p.clone());
|
||||
}
|
||||
@@ -503,7 +521,6 @@ impl SettingsStore for MemorySettingsStore {
|
||||
let mut settings = self.settings.lock().expect("settings");
|
||||
let updated = RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: command.disable_fast.unwrap_or(settings.disable_fast),
|
||||
request_location_enabled: command.request_location_enabled,
|
||||
request_location: command.request_location,
|
||||
config_revision: next_revision(settings.config_revision),
|
||||
@@ -1243,6 +1260,15 @@ impl UnusedProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl ProviderAdmin for UnusedProvider {
|
||||
fn pricing_catalog(&self) -> gateway_admin::model::pricing::ProviderPricingCatalog {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"builtin-model": {
|
||||
"multiplierBps": 10000,
|
||||
"bands": {"standard": {"input": "1", "output": "2", "cacheRead": "0", "cacheWrite": "1"}}
|
||||
}
|
||||
})).unwrap()
|
||||
}
|
||||
|
||||
fn provider_kind(&self) -> &ProviderKind {
|
||||
&self.kind
|
||||
}
|
||||
@@ -1409,7 +1435,6 @@ fn test_runtime_settings() -> RuntimeSettings {
|
||||
]);
|
||||
RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: false,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
|
||||
@@ -37,7 +37,6 @@ async fn response_json(response: axum::response::Response) -> Value {
|
||||
|
||||
fn update_body() -> Value {
|
||||
json!({
|
||||
"disableFast": false,
|
||||
"requestLocationEnabled": false,
|
||||
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
|
||||
"modelMappings": {
|
||||
@@ -104,7 +103,6 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
|
||||
let settings = RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: false,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
@@ -150,7 +148,6 @@ fn settings_response_should_cover_the_full_runtime_settings_contract() {
|
||||
value,
|
||||
json!({
|
||||
"openaiClientProfile": null,
|
||||
"disableFast": false,
|
||||
"requestLocationEnabled": false,
|
||||
"requestLocation": {"country":"US", "region":"Ohio", "city":"Piketon", "timezone":"America/New_York"},
|
||||
"modelMappings": {
|
||||
@@ -205,7 +202,6 @@ fn settings_request_and_response_fields_should_stay_in_lockstep() {
|
||||
.collect();
|
||||
let settings = RuntimeSettings {
|
||||
openai_client_profile: None,
|
||||
disable_fast: false,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
config_revision: Revision::new(7).expect("revision"),
|
||||
@@ -610,17 +606,14 @@ fn decompression_setting_should_reject_invalid_values() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_fast_settings_updates_preserve_omitted_values() {
|
||||
async fn settings_reject_removed_global_fast_policy() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let app = app(fixture.state());
|
||||
for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] {
|
||||
let initial = fixture.settings.settings.lock().unwrap().clone();
|
||||
for value in [json!(true), json!(false), Value::Null] {
|
||||
let mut body = update_body();
|
||||
if let Some(value) = value {
|
||||
body["disableFast"] = json!(value);
|
||||
} else {
|
||||
body.as_object_mut().unwrap().remove("disableFast");
|
||||
}
|
||||
body["disableFast"] = value;
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
@@ -630,17 +623,19 @@ async fn disable_fast_settings_updates_preserve_omitted_values() {
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(Method::GET, "/api/admin/settings", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response_json(response).await["data"]["disableFast"],
|
||||
expected
|
||||
);
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(*fixture.settings.settings.lock().unwrap(), initial);
|
||||
}
|
||||
let response = app
|
||||
.oneshot(request(Method::GET, "/api/admin/settings", None))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
response_json(response).await["data"]
|
||||
.get("disableFast")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -685,7 +680,7 @@ async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source()
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync",
|
||||
Some(approved),
|
||||
Some(json!({"preview": approved, "models": {"openai": ["gpt-5.4"]}})),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -745,6 +740,88 @@ async fn pricing_routes_keep_manual_overrides_during_sync_and_reset_to_source()
|
||||
assert!(result["data"]["synced"]["openai"]["gpt-5.4"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_delete_rejects_builtin_models_even_with_overrides_without_partial_writes() {
|
||||
for overridden in [false, true] {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let app = app(fixture.state());
|
||||
{
|
||||
let mut stored = fixture.settings.pricing.lock().unwrap();
|
||||
stored.synced = serde_json::from_value(json!({
|
||||
"openai": {"builtin-model": custom_pricing(), "custom-model": custom_pricing()}
|
||||
}))
|
||||
.unwrap();
|
||||
if overridden {
|
||||
stored.overrides = stored.synced.clone();
|
||||
}
|
||||
}
|
||||
let before = fixture.settings.pricing.lock().unwrap().clone();
|
||||
let revision = fixture.settings.settings.lock().unwrap().config_revision;
|
||||
for models in [
|
||||
json!(["builtin-model"]),
|
||||
json!(["custom-model", "builtin-model"]),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/update",
|
||||
Some(
|
||||
json!({"provider":"openai", "models":models, "change":{"action":"delete"}}),
|
||||
),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(*fixture.settings.pricing.lock().unwrap(), before);
|
||||
assert_eq!(
|
||||
fixture.settings.settings.lock().unwrap().config_revision,
|
||||
revision
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_delete_removes_selected_nonbuiltin_models_from_both_layers() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let app = app(fixture.state());
|
||||
{
|
||||
let mut stored = fixture.settings.pricing.lock().unwrap();
|
||||
stored.synced = serde_json::from_value(json!({
|
||||
"openai": {"shared":custom_pricing(), "synced-only":custom_pricing(), "untouched":custom_pricing()},
|
||||
"xai": {"shared":custom_pricing()}
|
||||
})).unwrap();
|
||||
stored.overrides = serde_json::from_value(json!({
|
||||
"openai": {"shared":custom_pricing(), "custom-only":custom_pricing(), "untouched":custom_pricing()},
|
||||
"xai": {"shared":custom_pricing()}
|
||||
})).unwrap();
|
||||
}
|
||||
for _ in 0..2 {
|
||||
let response = app.clone().oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/update",
|
||||
Some(json!({
|
||||
"provider":"openai", "models":["shared", "synced-only", "custom-only"], "change":{"action":"delete"}
|
||||
})),
|
||||
)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
let result = response_json(
|
||||
app.oneshot(request(Method::GET, "/api/admin/settings/pricing", None))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let expected =
|
||||
json!({"openai":{"untouched":custom_pricing()},"xai":{"shared":custom_pricing()}});
|
||||
assert_eq!(result["data"]["overrides"], expected);
|
||||
assert_eq!(result["data"]["synced"], expected);
|
||||
assert!(result["data"]["defaults"]["openai"]["builtin-model"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
@@ -787,7 +864,7 @@ async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() {
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync",
|
||||
Some(json!({"prices":{},"skipped":[]})),
|
||||
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -802,6 +879,138 @@ async fn pricing_rejects_invalid_edits_and_tampered_sync_without_writes() {
|
||||
assert_eq!(result["data"]["synced"], json!({}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_sync_only_updates_selected_provider_models() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let old = custom_pricing();
|
||||
{
|
||||
let mut pricing = fixture.settings.pricing.lock().unwrap();
|
||||
pricing.synced = serde_json::from_value(json!({
|
||||
"openai": {"gpt-5.4": old, "untouched": old},
|
||||
"xai": {"gpt-5.4": old}
|
||||
}))
|
||||
.unwrap();
|
||||
pricing.overrides = serde_json::from_value(json!({"openai": {"gpt-5.4": old}})).unwrap();
|
||||
}
|
||||
let app = app(fixture.state());
|
||||
let preview = response_json(
|
||||
app.clone()
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync/preview",
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let response = app
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync",
|
||||
Some(json!({"preview": preview["data"], "models": {"openai": ["gpt-5.4"]}})),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let pricing = fixture.settings.pricing.lock().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&pricing.synced).unwrap(),
|
||||
json!({
|
||||
"openai": {"gpt-5.4": preview["data"]["prices"]["openai"]["gpt-5.4"], "untouched": old},
|
||||
"xai": {"gpt-5.4": old}
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&pricing.overrides).unwrap(),
|
||||
json!({"openai": {"gpt-5.4": old}})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_sync_removes_only_selected_retired_source_prices() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
{
|
||||
let mut pricing = fixture.settings.pricing.lock().unwrap();
|
||||
pricing.synced = serde_json::from_value(json!({
|
||||
"openai": {"retired": custom_pricing(), "untouched": custom_pricing()}
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
let app = app(fixture.state());
|
||||
let preview = response_json(
|
||||
app.clone()
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync/preview",
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
let response = app
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync",
|
||||
Some(json!({"preview": preview["data"], "models": {"openai": ["retired"]}})),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let pricing = fixture.settings.pricing.lock().unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&pricing.synced).unwrap(),
|
||||
json!({
|
||||
"openai": {"untouched": custom_pricing()}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_sync_rejects_empty_unknown_or_oversized_selections_without_writes() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let app = app(fixture.state());
|
||||
let preview = response_json(
|
||||
app.clone()
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync/preview",
|
||||
None,
|
||||
))
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
.await;
|
||||
for models in [
|
||||
json!({}),
|
||||
json!({"openai": []}),
|
||||
json!({"unknown": ["gpt-5.4"]}),
|
||||
json!({"openai": ["gpt-5.4", "missing"]}),
|
||||
json!({"openai": ["gpt-5.4"], "xai": []}),
|
||||
json!({"openai": (0..10_001).map(|index| format!("model-{index}")).collect::<Vec<_>>()}),
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync",
|
||||
Some(json!({"preview": preview["data"], "models": models})),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let pricing = fixture.settings.pricing.lock().unwrap();
|
||||
assert_eq!(
|
||||
*pricing,
|
||||
gateway_admin::model::pricing::StoredPricing::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pricing_endpoints_require_administrator_authentication() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
@@ -821,7 +1030,7 @@ async fn pricing_endpoints_require_administrator_authentication() {
|
||||
(
|
||||
Method::POST,
|
||||
"/api/admin/settings/pricing/sync",
|
||||
Some(json!({"prices":{},"skipped":[]})),
|
||||
Some(json!({"preview":{"prices":{},"skipped":[]},"models":{"openai":["gpt-5.4"]}})),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
|
||||
@@ -112,6 +112,7 @@ pub struct AccountRuntimeSignals {
|
||||
}
|
||||
|
||||
const ACCOUNT_FEEDBACK_EWMA_ALPHA: f64 = 0.2;
|
||||
const ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA: f64 = 0.4;
|
||||
const ACCOUNT_FAILURE_RATE_HALF_LIFE: Duration = Duration::from_secs(15 * 60);
|
||||
const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
|
||||
|
||||
@@ -120,6 +121,7 @@ const EMPTY_FEEDBACK_SAMPLE: u64 = f64::NAN.to_bits();
|
||||
pub enum AccountAttemptFeedback {
|
||||
Succeeded { first_output_ms: Option<u64> },
|
||||
Failed { first_output_ms: Option<u64> },
|
||||
CapacityRejected { first_output_ms: Option<u64> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -150,13 +152,12 @@ impl DecayingAccountFailureRate {
|
||||
* 0.5_f64.powf(elapsed.as_secs_f64() / ACCOUNT_FAILURE_RATE_HALF_LIFE.as_secs_f64())
|
||||
}
|
||||
|
||||
fn report_at(&mut self, sample: f64, now: Instant) {
|
||||
fn report_at(&mut self, sample: f64, alpha: f64, now: Instant) {
|
||||
let now = self
|
||||
.updated_at
|
||||
.map_or(now, |updated_at| updated_at.max(now));
|
||||
let decayed = self.value_at(now);
|
||||
self.value =
|
||||
ACCOUNT_FEEDBACK_EWMA_ALPHA * sample + (1.0 - ACCOUNT_FEEDBACK_EWMA_ALPHA) * decayed;
|
||||
self.value = alpha * sample + (1.0 - alpha) * decayed;
|
||||
self.updated_at = Some(now);
|
||||
}
|
||||
}
|
||||
@@ -173,14 +174,21 @@ impl Default for AccountFeedback {
|
||||
|
||||
impl AccountFeedback {
|
||||
fn report_at(&self, feedback: AccountAttemptFeedback, now: Instant) {
|
||||
let (failure, first_output_ms) = match feedback {
|
||||
AccountAttemptFeedback::Succeeded { first_output_ms } => (0.0, first_output_ms),
|
||||
AccountAttemptFeedback::Failed { first_output_ms } => (1.0, first_output_ms),
|
||||
let (failure, alpha, first_output_ms) = match feedback {
|
||||
AccountAttemptFeedback::Succeeded { first_output_ms } => {
|
||||
(0.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
|
||||
}
|
||||
AccountAttemptFeedback::Failed { first_output_ms } => {
|
||||
(1.0, ACCOUNT_FEEDBACK_EWMA_ALPHA, first_output_ms)
|
||||
}
|
||||
AccountAttemptFeedback::CapacityRejected { first_output_ms } => {
|
||||
(1.0, ACCOUNT_CAPACITY_FAILURE_EWMA_ALPHA, first_output_ms)
|
||||
}
|
||||
};
|
||||
self.failure_rate
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.report_at(failure, now);
|
||||
.report_at(failure, alpha, now);
|
||||
if let Some(first_output_ms) = first_output_ms.filter(|value| *value > 0) {
|
||||
update_feedback_ewma(&self.first_output_ms, first_output_ms as f64);
|
||||
}
|
||||
|
||||
@@ -264,13 +264,17 @@ impl ProviderStreamAccountFeedback {
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.stats.report(
|
||||
&self.provider_kind,
|
||||
&self.account_id,
|
||||
let feedback = if error.kind() == ProviderErrorKind::UpstreamCapacityUnavailable {
|
||||
AccountAttemptFeedback::CapacityRejected {
|
||||
first_output_ms: self.first_output_ms,
|
||||
}
|
||||
} else {
|
||||
AccountAttemptFeedback::Failed {
|
||||
first_output_ms: self.first_output_ms,
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
self.stats
|
||||
.report(&self.provider_kind, &self.account_id, feedback);
|
||||
self.reported = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +537,6 @@ impl ProviderCandidate {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoutingPlan {
|
||||
pricing: Arc<crate::metering::PricingOverrides>,
|
||||
disable_fast: bool,
|
||||
request_location: Option<crate::account::RequestLocation>,
|
||||
config_revision: ConfigRevision,
|
||||
account_selection_policy: AccountSelectionPolicy,
|
||||
@@ -554,8 +553,8 @@ impl RoutingPlan {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn disable_fast(&self) -> bool {
|
||||
self.disable_fast
|
||||
pub fn disable_fast(&self) -> bool {
|
||||
self.account_scope.disable_fast()
|
||||
}
|
||||
|
||||
/// 本次请求冻结的全局位置,重试时沿用同一份配置。
|
||||
|
||||
@@ -30,7 +30,6 @@ const MAXIMUM_CATALOG_STABILITY_ATTEMPTS: usize = 4;
|
||||
pub struct SnapshotSettingsFacts {
|
||||
pricing: Arc<crate::metering::PricingOverrides>,
|
||||
request_profiles: BTreeMap<ProviderKind, crate::account::OpaqueProviderData>,
|
||||
disable_fast: bool,
|
||||
request_location_enabled: bool,
|
||||
request_location: crate::account::RequestLocation,
|
||||
max_concurrent_per_account: u32,
|
||||
@@ -61,12 +60,6 @@ impl SnapshotSettingsFacts {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
|
||||
self.disable_fast = disable_fast;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_responses_max_decompressed_body_bytes(mut self, bytes: u64) -> Self {
|
||||
self.responses_max_decompressed_body_bytes = bytes;
|
||||
@@ -107,7 +100,6 @@ impl SnapshotSettingsFacts {
|
||||
min_codex_cli_version: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
disable_fast: false,
|
||||
request_profiles: BTreeMap::new(),
|
||||
pricing: Arc::default(),
|
||||
request_location_enabled: false,
|
||||
@@ -565,7 +557,6 @@ async fn compile_runtime_snapshot(
|
||||
.map(|snapshot| {
|
||||
snapshot
|
||||
.with_pricing(facts.settings.pricing)
|
||||
.with_disable_fast(facts.settings.disable_fast)
|
||||
.with_request_location(request_location)
|
||||
.with_responses_max_decompressed_body_bytes(decompressed_body_limit)
|
||||
.with_client_queue_policy(client_queue_policy)
|
||||
@@ -580,7 +571,6 @@ async fn compile_runtime_snapshot(
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RuntimeSnapshot {
|
||||
pricing: Arc<crate::metering::PricingOverrides>,
|
||||
disable_fast: bool,
|
||||
responses_max_decompressed_body_bytes: std::num::NonZeroUsize,
|
||||
request_location: Option<crate::account::RequestLocation>,
|
||||
revision: ConfigRevision,
|
||||
@@ -605,12 +595,6 @@ impl RuntimeSnapshot {
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn with_disable_fast(mut self, disable_fast: bool) -> Self {
|
||||
self.disable_fast = disable_fast;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub const fn responses_max_decompressed_body_bytes(&self) -> usize {
|
||||
self.responses_max_decompressed_body_bytes.get()
|
||||
@@ -716,7 +700,6 @@ impl RuntimeSnapshot {
|
||||
responses_max_decompressed_body_bytes: std::num::NonZeroUsize::new(64 * 1024 * 1024)
|
||||
.expect("positive default limit"),
|
||||
pricing: Arc::default(),
|
||||
disable_fast: false,
|
||||
request_location: None,
|
||||
revision,
|
||||
account_selection_policy,
|
||||
@@ -1029,7 +1012,6 @@ impl RuntimeSnapshot {
|
||||
Ok(RoutingPlan {
|
||||
config_revision: self.revision,
|
||||
pricing: Arc::clone(&self.pricing),
|
||||
disable_fast: self.disable_fast || account_scope.disable_fast(),
|
||||
request_location: self.request_location.clone(),
|
||||
account_selection_policy: self.account_selection_policy,
|
||||
operation: operation.kind(),
|
||||
@@ -1073,7 +1055,6 @@ impl RuntimeSnapshot {
|
||||
Ok(RoutingPlan {
|
||||
config_revision: self.revision,
|
||||
pricing: Arc::clone(&self.pricing),
|
||||
disable_fast: self.disable_fast || account_scope.disable_fast(),
|
||||
request_location: self.request_location.clone(),
|
||||
account_selection_policy: self.account_selection_policy,
|
||||
operation: operation.kind(),
|
||||
|
||||
@@ -34,6 +34,58 @@ fn report_failure(
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_rejections_should_raise_failure_rate_faster_than_regular_failures() {
|
||||
let (feedback, provider, account) = feedback_subject();
|
||||
let observed_at = Instant::now();
|
||||
let mut failure_rates = Vec::new();
|
||||
for _ in 0..2 {
|
||||
feedback.report_at(
|
||||
&provider,
|
||||
&account,
|
||||
AccountAttemptFeedback::CapacityRejected {
|
||||
first_output_ms: None,
|
||||
},
|
||||
observed_at,
|
||||
);
|
||||
failure_rates.push(
|
||||
feedback
|
||||
.scheduling_signals_at(&provider, &account, observed_at)
|
||||
.0,
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(failure_rates, [Some(4_000), Some(6_400)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capacity_failure_rate_should_keep_time_decay_and_success_recovery() {
|
||||
let (feedback, provider, account) = feedback_subject();
|
||||
let observed_at = Instant::now();
|
||||
feedback.report_at(
|
||||
&provider,
|
||||
&account,
|
||||
AccountAttemptFeedback::CapacityRejected {
|
||||
first_output_ms: Some(100),
|
||||
},
|
||||
observed_at,
|
||||
);
|
||||
let recovered_at = observed_at + FAILURE_RATE_HALF_LIFE;
|
||||
feedback.report_at(
|
||||
&provider,
|
||||
&account,
|
||||
AccountAttemptFeedback::Succeeded {
|
||||
first_output_ms: Some(200),
|
||||
},
|
||||
recovered_at,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals_at(&provider, &account, recovered_at),
|
||||
(Some(1_600), Some(120)),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_failure_rate_should_halve_after_one_half_life() {
|
||||
let (feedback, provider, account) = feedback_subject();
|
||||
|
||||
@@ -677,7 +677,6 @@ fn plan_with_profiles(
|
||||
Vec::new(),
|
||||
)
|
||||
.expect("snapshot")
|
||||
.with_disable_fast(disable_fast)
|
||||
.with_request_location(Some(request_location))
|
||||
.with_account_directory(Arc::clone(&directory));
|
||||
snapshot
|
||||
@@ -686,6 +685,7 @@ fn plan_with_profiles(
|
||||
operation,
|
||||
Arc::new(
|
||||
FrozenAccountScope::new(directory, ClientRoutingScope::all_accounts())
|
||||
.with_disable_fast(disable_fast)
|
||||
.with_request_profiles(profiles),
|
||||
),
|
||||
&RoutingContext {
|
||||
@@ -4570,7 +4570,7 @@ fn deadline_before_first_event_records_no_provider_circuit_failure() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_location_and_fast_policy_reach_every_account_retry() {
|
||||
fn global_location_and_group_fast_policy_reach_every_account_retry() {
|
||||
let operation = generate_operation();
|
||||
let location = gateway_core::account::RequestLocation {
|
||||
timezone: "Asia/Tokyo".parse().unwrap(),
|
||||
|
||||
@@ -178,7 +178,7 @@ fn reused_client_uses_updated_limits_for_each_execution() {
|
||||
requests_per_minute: 1,
|
||||
};
|
||||
for (revision, limits) in [(2, limited), (3, RateLimits::unlimited())] {
|
||||
snapshots.publish(start_snapshot_with_policy(revision, true, limits));
|
||||
snapshots.publish(start_snapshot_with_policy(revision, true, limits, false));
|
||||
let mut next = request(&service, ClientTransport::WebSocket);
|
||||
next.client = client.clone();
|
||||
let started = block_on(service.start(next)).expect("new execution");
|
||||
@@ -212,6 +212,7 @@ fn reused_client_cannot_start_after_key_disable_or_snapshot_suspension() {
|
||||
2,
|
||||
false,
|
||||
RateLimits::unlimited(),
|
||||
false,
|
||||
));
|
||||
}
|
||||
let result = block_on(service.start(next));
|
||||
@@ -1893,10 +1894,15 @@ fn client_snapshot() -> RuntimeSnapshot {
|
||||
}
|
||||
|
||||
fn start_snapshot() -> RuntimeSnapshot {
|
||||
start_snapshot_with_policy(1, true, RateLimits::unlimited())
|
||||
start_snapshot_with_policy(1, true, RateLimits::unlimited(), false)
|
||||
}
|
||||
|
||||
fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits) -> RuntimeSnapshot {
|
||||
fn start_snapshot_with_policy(
|
||||
revision: u64,
|
||||
enabled: bool,
|
||||
limits: RateLimits,
|
||||
disable_fast: bool,
|
||||
) -> RuntimeSnapshot {
|
||||
let provider = ProviderKind::new("openai").expect("provider kind");
|
||||
let capabilities =
|
||||
ModelCapabilities::new(BTreeSet::from([OperationKind::Generate]), Some(16_000));
|
||||
@@ -1916,7 +1922,12 @@ fn start_snapshot_with_policy(revision: u64, enabled: bool, limits: RateLimits)
|
||||
vec![ClientPolicy::new(
|
||||
ClientApiKeyId::new("key_start_test").expect("client API key ID"),
|
||||
PlaintextClientApiKey::new("sk_start_test").expect("plaintext client API key"),
|
||||
account_scope(&provider, "acct_start"),
|
||||
Arc::new(
|
||||
account_scope(&provider, "acct_start")
|
||||
.as_ref()
|
||||
.clone()
|
||||
.with_disable_fast(disable_fast),
|
||||
),
|
||||
enabled,
|
||||
limits,
|
||||
)],
|
||||
@@ -2013,6 +2024,7 @@ fn account_wait_inherits_the_budget_spent_during_client_admission() {
|
||||
max_concurrency: 1,
|
||||
requests_per_minute: 0,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy {
|
||||
max_waiting: 1,
|
||||
@@ -2123,6 +2135,7 @@ fn queue_service(
|
||||
max_concurrency,
|
||||
requests_per_minute: 0,
|
||||
},
|
||||
false,
|
||||
)
|
||||
.with_client_queue_policy(gateway_core::concurrency::ConcurrencyQueuePolicy {
|
||||
max_waiting,
|
||||
@@ -2249,7 +2262,7 @@ fn cancelling_pending_admission_cleans_up_a_slot_acquired_before_the_reply() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_websocket_client_gets_fast_policy_from_each_new_request_snapshot() {
|
||||
fn reused_websocket_client_gets_group_fast_policy_from_each_new_request_snapshot() {
|
||||
let snapshots = RuntimeSnapshotHandle::new(start_snapshot());
|
||||
let admissions = Arc::new(Admissions::default());
|
||||
let provider = Arc::new(ChargedProvider::default());
|
||||
@@ -2264,10 +2277,12 @@ fn reused_websocket_client_gets_fast_policy_from_each_new_request_snapshot() {
|
||||
);
|
||||
let client = service.authenticate("sk_start_test").unwrap();
|
||||
for (revision, disable_fast) in [(2, true), (3, false)] {
|
||||
snapshots.publish(
|
||||
start_snapshot_with_policy(revision, true, RateLimits::unlimited())
|
||||
.with_disable_fast(disable_fast),
|
||||
);
|
||||
snapshots.publish(start_snapshot_with_policy(
|
||||
revision,
|
||||
true,
|
||||
RateLimits::unlimited(),
|
||||
disable_fast,
|
||||
));
|
||||
let mut next = request(&service, ClientTransport::WebSocket);
|
||||
next.client = client.clone();
|
||||
let mut started = block_on(service.start(next)).unwrap();
|
||||
|
||||
@@ -494,10 +494,10 @@ fn decompression_setting_should_validate_and_remain_frozen_across_publication()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_scope() {
|
||||
fn disable_fast_uses_only_bound_groups_without_changing_account_scope() {
|
||||
use gateway_core::account::ProviderAccountId;
|
||||
use gateway_core::routing::AccountGroupId;
|
||||
for global in [false, true] {
|
||||
for disable_fast in [false, true] {
|
||||
for group_enabled in [false, true] {
|
||||
for bound in [false, true] {
|
||||
let group_id = AccountGroupId::new("grp_00000000000000000000000000000001").unwrap();
|
||||
@@ -507,8 +507,7 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco
|
||||
let facts = SnapshotFacts::new(
|
||||
revision(1),
|
||||
revision(1),
|
||||
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None)
|
||||
.with_disable_fast(global),
|
||||
SnapshotSettingsFacts::new(3, 0, "smart", BTreeMap::new(), None, None),
|
||||
vec![SnapshotClientPolicyFacts::new(
|
||||
ClientApiKeyId::new("key_fast_policy").unwrap(),
|
||||
PlaintextClientApiKey::new("sk_fast_policy").unwrap(),
|
||||
@@ -525,7 +524,7 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco
|
||||
"Restricted".to_owned(),
|
||||
group_enabled,
|
||||
)
|
||||
.with_disable_fast(true),
|
||||
.with_disable_fast(disable_fast),
|
||||
SnapshotAccountGroupFacts::new(
|
||||
open_group_id.clone(),
|
||||
"Open".to_owned(),
|
||||
@@ -566,8 +565,8 @@ fn disable_fast_uses_bound_groups_and_global_policy_without_changing_account_sco
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
plan.disable_fast(),
|
||||
global || bound,
|
||||
"global={global}, enabled={group_enabled}, bound={bound}"
|
||||
disable_fast && bound,
|
||||
"disable_fast={disable_fast}, enabled={group_enabled}, bound={bound}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
|
||||
async fn sync_pricing(
|
||||
&self,
|
||||
prices: gateway_core::metering::PricingOverrides,
|
||||
changes: gateway_admin::model::pricing::PricingSyncChanges,
|
||||
context: &MutationContext,
|
||||
) -> AdminStoreResult<gateway_admin::model::Revision> {
|
||||
let audit = mutation_audit(
|
||||
@@ -36,7 +36,7 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
);
|
||||
let revision = self
|
||||
.control_plane
|
||||
.sync_pricing(prices, audit)
|
||||
.sync_pricing(changes, audit)
|
||||
.await
|
||||
.map_err(|error| admin_store_error("model pricing sync", error))?;
|
||||
admin_revision(revision)
|
||||
@@ -91,7 +91,6 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
refresh_margin_seconds: command.refresh_margin_seconds,
|
||||
refresh_concurrency: command.refresh_concurrency,
|
||||
max_concurrent_per_account: command.max_concurrent_per_account,
|
||||
disable_fast: command.disable_fast,
|
||||
request_location_enabled: command.request_location_enabled,
|
||||
request_location: command.request_location,
|
||||
request_interval_ms: command.request_interval_ms,
|
||||
@@ -123,7 +122,6 @@ impl SettingsStore for AdminSettingsStoreAdapter {
|
||||
"1",
|
||||
vec![
|
||||
"provider_request_profiles_json".to_owned(),
|
||||
"disable_fast".to_owned(),
|
||||
"request_location_enabled".to_owned(),
|
||||
"request_location_json".to_owned(),
|
||||
"model_mappings_json".to_owned(),
|
||||
@@ -235,7 +233,6 @@ pub(crate) fn admin_runtime_settings(
|
||||
Ok(AdminRuntimeSettings {
|
||||
openai_client_profile: settings.openai_client_profile,
|
||||
config_revision: admin_revision(settings.config_revision)?,
|
||||
disable_fast: settings.disable_fast,
|
||||
request_location_enabled: settings.request_location_enabled,
|
||||
request_location: settings.request_location,
|
||||
model_mappings,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! 价格覆盖与请求费用明细的持久化。
|
||||
|
||||
use gateway_admin::model::pricing::{PricingChange, UpdatePricing};
|
||||
use gateway_admin::model::pricing::{PricingChange, PricingSyncChanges, UpdatePricing};
|
||||
use gateway_core::metering::{ModelPriceOverride, PricingOverrides};
|
||||
use sqlx::types::Json;
|
||||
|
||||
@@ -146,16 +146,32 @@ impl PgControlPlaneRepository {
|
||||
|
||||
pub(crate) async fn sync_pricing(
|
||||
&self,
|
||||
prices: PricingOverrides,
|
||||
changes: PricingSyncChanges,
|
||||
audit: AdminAuditEvent,
|
||||
) -> StoreResult<Revision> {
|
||||
validate_pricing(&prices)?;
|
||||
let mut transaction = self
|
||||
.pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin pricing sync"))?;
|
||||
// 同步只替换来源层;不写人工覆盖。运行设置行锁统一串行化配置修订。
|
||||
let Json(mut prices) = sqlx::query_scalar::<_, Json<PricingOverrides>>(
|
||||
"select pricing_synced_json from runtime_settings where id = 1 for update",
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("lock synced model pricing"))?;
|
||||
for (provider, models) in changes {
|
||||
let stored = prices.entry(provider).or_default();
|
||||
for (model, price) in models {
|
||||
if let Some(price) = price {
|
||||
stored.insert(model, price);
|
||||
} else {
|
||||
stored.remove(&model);
|
||||
}
|
||||
}
|
||||
}
|
||||
prices.retain(|_, models| !models.is_empty());
|
||||
validate_pricing(&prices)?;
|
||||
sqlx::query("update runtime_settings set pricing_synced_json = $1, pricing_synced_at = now() where id = 1")
|
||||
.bind(Json(prices)).execute(&mut *transaction).await.map_err(|_| postgres_unavailable("sync model pricing"))?;
|
||||
let revision = bump_config_revision_in_transaction(&mut transaction).await?;
|
||||
@@ -178,18 +194,23 @@ impl PgControlPlaneRepository {
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("begin model pricing update"))?;
|
||||
// 锁定当前配置再更新选中项,避免批量操作覆盖其他管理员已提交的模型。
|
||||
let Json(mut pricing) = sqlx::query_scalar::<_, Json<PricingOverrides>>(
|
||||
"select pricing_overrides_json from runtime_settings where id = 1 for update",
|
||||
let (Json(mut pricing), Json(mut synced)) = sqlx::query_as::<_, (Json<PricingOverrides>, Json<PricingOverrides>)>(
|
||||
"select pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1 for update",
|
||||
)
|
||||
.fetch_one(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("lock model pricing"))?;
|
||||
let models = pricing.entry(command.provider).or_default();
|
||||
let models = pricing.entry(command.provider.clone()).or_default();
|
||||
let source = synced.entry(command.provider).or_default();
|
||||
for model in command.models {
|
||||
match &command.change {
|
||||
PricingChange::Reset => {
|
||||
models.remove(&model);
|
||||
}
|
||||
PricingChange::Delete => {
|
||||
models.remove(&model);
|
||||
source.remove(&model);
|
||||
}
|
||||
PricingChange::Replace(value) => {
|
||||
models.insert(model, value.clone());
|
||||
}
|
||||
@@ -205,9 +226,11 @@ impl PgControlPlaneRepository {
|
||||
}
|
||||
}
|
||||
pricing.retain(|_, models| !models.is_empty());
|
||||
synced.retain(|_, models| !models.is_empty());
|
||||
validate_pricing(&pricing)?;
|
||||
sqlx::query("update runtime_settings set pricing_overrides_json = $1 where id = 1")
|
||||
sqlx::query("update runtime_settings set pricing_overrides_json = $1, pricing_synced_json = $2 where id = 1")
|
||||
.bind(Json(pricing))
|
||||
.bind(Json(synced))
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("update model pricing"))?;
|
||||
|
||||
@@ -20,7 +20,6 @@ use crate::{Revision, StoreError, StoreResult, postgres_unavailable};
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeSettings {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub disable_fast: bool,
|
||||
pub config_revision: Revision,
|
||||
pub admin_api_key: Option<String>,
|
||||
pub refresh_margin_seconds: u64,
|
||||
@@ -66,7 +65,6 @@ impl fmt::Debug for RuntimeSettings {
|
||||
&self.max_concurrent_per_account,
|
||||
)
|
||||
.field("request_interval_ms", &self.request_interval_ms)
|
||||
.field("disable_fast", &self.disable_fast)
|
||||
.field("rotation_strategy", &self.rotation_strategy)
|
||||
.field("request_location_enabled", &self.request_location_enabled)
|
||||
.field("request_location", &self.request_location)
|
||||
@@ -112,7 +110,6 @@ impl fmt::Debug for RuntimeSettings {
|
||||
#[derive(Clone)]
|
||||
pub struct RuntimeSettingsUpdate {
|
||||
pub openai_client_profile: Option<gateway_core::account::OpaqueProviderData>,
|
||||
pub disable_fast: Option<bool>,
|
||||
pub admin_api_key: Option<String>,
|
||||
pub refresh_margin_seconds: u64,
|
||||
pub refresh_concurrency: u32,
|
||||
@@ -148,7 +145,6 @@ impl fmt::Debug for RuntimeSettingsUpdate {
|
||||
"admin_api_key",
|
||||
&self.admin_api_key.as_ref().map(|_| "[REDACTED]"),
|
||||
)
|
||||
.field("disable_fast", &self.disable_fast)
|
||||
.field("rotation_strategy", &self.rotation_strategy)
|
||||
.field("request_location_enabled", &self.request_location_enabled)
|
||||
.field("request_location", &self.request_location)
|
||||
@@ -235,7 +231,7 @@ impl RuntimeSettingsRepository for PgRuntimeSettingsRepository {
|
||||
|
||||
pub(crate) async fn load_runtime_settings_from_pool(pool: &PgPool) -> StoreResult<RuntimeSettings> {
|
||||
let row = sqlx::query_as::<_, RuntimeSettingsRow>(
|
||||
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, disable_fast,
|
||||
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled,
|
||||
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
|
||||
rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days,
|
||||
audit_retention_days, min_codex_desktop_version,
|
||||
@@ -321,7 +317,7 @@ pub(crate) async fn load_runtime_settings_in_transaction(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
) -> StoreResult<RuntimeSettings> {
|
||||
let row = sqlx::query_as::<_, RuntimeSettingsRow>(
|
||||
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled, disable_fast,
|
||||
"select provider_request_profiles_json, config_revision, admin_api_key, refresh_margin_seconds, request_location_json, request_location_enabled,
|
||||
refresh_concurrency, max_concurrent_per_account, request_interval_ms,
|
||||
rotation_strategy, model_mappings_json, usage_retention_days, ops_event_retention_days,
|
||||
audit_retention_days, min_codex_desktop_version,
|
||||
@@ -377,8 +373,7 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
request_location_json = $23,
|
||||
request_location_enabled = $24,
|
||||
responses_max_decompressed_body_bytes = $25,
|
||||
disable_fast = coalesce($26, disable_fast),
|
||||
provider_request_profiles_json = case when $27::jsonb is null then provider_request_profiles_json else jsonb_set(provider_request_profiles_json, '{openai}', $27) end,
|
||||
provider_request_profiles_json = case when $26::jsonb is null then provider_request_profiles_json else jsonb_set(provider_request_profiles_json, '{openai}', $26) end,
|
||||
updated_at = now()
|
||||
where id = 1
|
||||
returning config_revision",
|
||||
@@ -420,7 +415,6 @@ pub(crate) async fn update_runtime_settings_in_transaction(
|
||||
i64::try_from(update.responses_max_decompressed_body_bytes)
|
||||
.map_err(|_| invalid_numeric())?,
|
||||
)
|
||||
.bind(update.disable_fast)
|
||||
.bind(update.openai_client_profile.as_ref().map(|profile| sqlx::types::Json(profile.expose_to_provider())))
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
@@ -474,7 +468,6 @@ struct RuntimeSettingsRow {
|
||||
provider_request_profiles_json: sqlx::types::Json<
|
||||
std::collections::BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
>,
|
||||
disable_fast: bool,
|
||||
config_revision: i64,
|
||||
admin_api_key: Option<String>,
|
||||
refresh_margin_seconds: i64,
|
||||
@@ -518,7 +511,6 @@ fn runtime_settings_from_row(mut row: RuntimeSettingsRow) -> StoreResult<Runtime
|
||||
max_concurrent_per_account: to_u32(row.max_concurrent_per_account)?,
|
||||
request_interval_ms: to_u64(row.request_interval_ms)?,
|
||||
rotation_strategy: row.rotation_strategy,
|
||||
disable_fast: row.disable_fast,
|
||||
request_location_enabled: row.request_location_enabled,
|
||||
request_location: row
|
||||
.request_location_json
|
||||
|
||||
@@ -23,7 +23,6 @@ pub struct SnapshotRuntimeSettings {
|
||||
pub pricing: gateway_core::metering::PricingOverrides,
|
||||
pub request_profiles:
|
||||
BTreeMap<gateway_core::routing::ProviderKind, gateway_core::account::OpaqueProviderData>,
|
||||
pub disable_fast: bool,
|
||||
pub request_location_enabled: bool,
|
||||
pub request_location: gateway_core::account::RequestLocation,
|
||||
pub refresh_margin_seconds: u64,
|
||||
@@ -163,7 +162,6 @@ impl SnapshotStorePort for PgRuntimeSnapshotRepository {
|
||||
.with_responses_max_decompressed_body_bytes(
|
||||
data.settings.responses_max_decompressed_body_bytes,
|
||||
)
|
||||
.with_disable_fast(data.settings.disable_fast)
|
||||
.with_request_profiles(data.settings.request_profiles)
|
||||
.with_pricing(data.settings.pricing)
|
||||
.with_request_location(
|
||||
@@ -266,7 +264,6 @@ struct SnapshotSettingsRow {
|
||||
request_location_json: sqlx::types::Json<gateway_core::account::RequestLocation>,
|
||||
request_location_enabled: bool,
|
||||
responses_max_decompressed_body_bytes: i64,
|
||||
disable_fast: bool,
|
||||
provider_request_profiles_json:
|
||||
sqlx::types::Json<BTreeMap<String, serde_json::Map<String, serde_json::Value>>>,
|
||||
}
|
||||
@@ -275,7 +272,7 @@ async fn load_settings(
|
||||
transaction: &mut Transaction<'_, Postgres>,
|
||||
) -> StoreResult<(Revision, SnapshotRuntimeSettings)> {
|
||||
let row = sqlx::query_as::<_, SnapshotSettingsRow>(
|
||||
"select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, disable_fast, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
|
||||
"select config_revision, refresh_margin_seconds, refresh_concurrency, max_concurrent_per_account, request_interval_ms, rotation_strategy, model_mappings_json, min_codex_desktop_version, min_codex_cli_version, max_waiting_per_key, max_waiting_per_account, concurrency_wait_timeout_seconds, request_location_json, request_location_enabled, responses_max_decompressed_body_bytes, provider_request_profiles_json, pricing_overrides_json, pricing_synced_json from runtime_settings where id = 1",
|
||||
)
|
||||
.fetch_optional(&mut **transaction)
|
||||
.await
|
||||
@@ -296,7 +293,6 @@ async fn load_settings(
|
||||
)
|
||||
},
|
||||
request_profiles: decode_request_profiles(row.provider_request_profiles_json.0)?,
|
||||
disable_fast: row.disable_fast,
|
||||
responses_max_decompressed_body_bytes: to_u64(
|
||||
row.responses_max_decompressed_body_bytes,
|
||||
)?,
|
||||
|
||||
@@ -8,14 +8,9 @@ use serde_json::json;
|
||||
|
||||
use super::TestDatabase;
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_prices() {
|
||||
let Some(redis_url) = crate::support::test_env("CPR_TEST_REDIS_URL") else {
|
||||
return;
|
||||
};
|
||||
let Some(database) = TestDatabase::create("pricing").await else {
|
||||
return;
|
||||
};
|
||||
async fn pricing_store(name: &str) -> Option<(TestDatabase, gateway_store::StoreBundle)> {
|
||||
let redis_url = crate::support::test_env("CPR_TEST_REDIS_URL")?;
|
||||
let database = TestDatabase::create(name).await?;
|
||||
let mut database_url = url::Url::parse(
|
||||
&crate::support::test_env("CPR_TEST_DATABASE_URL").expect("test database URL"),
|
||||
)
|
||||
@@ -36,6 +31,14 @@ async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_
|
||||
let runtime = tempfile::tempdir().unwrap();
|
||||
config.resolve_and_validate(runtime.path()).unwrap();
|
||||
let bundle = gateway_store::initialize(config).await.unwrap();
|
||||
Some((database, bundle))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_prices() {
|
||||
let Some((database, bundle)) = pricing_store("pricing").await else {
|
||||
return;
|
||||
};
|
||||
let settings = bundle.admin_ports().settings();
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
@@ -76,7 +79,21 @@ async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_
|
||||
.unwrap()
|
||||
.input = "9".to_owned().try_into().unwrap();
|
||||
settings
|
||||
.sync_pricing(source.clone(), &context)
|
||||
.sync_pricing(
|
||||
source
|
||||
.iter()
|
||||
.map(|(provider, models)| {
|
||||
(
|
||||
provider.clone(),
|
||||
models
|
||||
.iter()
|
||||
.map(|(model, price)| (model.clone(), Some(price.clone())))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stored = settings.load_pricing().await.unwrap();
|
||||
@@ -123,3 +140,154 @@ async fn concurrent_price_edits_preserve_other_models_and_sync_preserves_manual_
|
||||
drop(bundle);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deleting_pricing_removes_both_layers_and_preserves_other_models_and_frozen_snapshots() {
|
||||
let Some((database, bundle)) = pricing_store("pricing_delete").await else {
|
||||
return;
|
||||
};
|
||||
let settings = bundle.admin_ports().settings();
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "pricing-delete-test".to_owned(),
|
||||
};
|
||||
let value = json!({
|
||||
"multiplierBps":10000,
|
||||
"bands":{"standard":{"input":"2","output":"10","cacheRead":"0","cacheWrite":"1"}}
|
||||
});
|
||||
settings
|
||||
.sync_pricing(
|
||||
serde_json::from_value(json!({
|
||||
"openai":{"shared":value,"synced-only":value,"untouched":value},
|
||||
"xai":{"shared":value}
|
||||
}))
|
||||
.unwrap(),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
settings
|
||||
.update_pricing(
|
||||
UpdatePricing {
|
||||
provider: "openai".to_owned(),
|
||||
models: vec![
|
||||
"shared".to_owned(),
|
||||
"custom-only".to_owned(),
|
||||
"untouched".to_owned(),
|
||||
],
|
||||
change: PricingChange::Replace(serde_json::from_value(value.clone()).unwrap()),
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let repository = PgRuntimeSnapshotRepository::new(database.pool.clone());
|
||||
let frozen = repository.load_runtime_snapshot().await.unwrap();
|
||||
let synced_at = settings.load_pricing().await.unwrap().synced_at;
|
||||
settings
|
||||
.update_pricing(
|
||||
UpdatePricing {
|
||||
provider: "openai".to_owned(),
|
||||
models: vec![
|
||||
"shared".to_owned(),
|
||||
"synced-only".to_owned(),
|
||||
"custom-only".to_owned(),
|
||||
],
|
||||
change: PricingChange::Delete,
|
||||
},
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stored = settings.load_pricing().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&stored.overrides).unwrap(),
|
||||
json!({"openai":{"untouched":value}})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&stored.synced).unwrap(),
|
||||
json!({"openai":{"untouched":value},"xai":{"shared":value}})
|
||||
);
|
||||
assert_eq!(stored.synced_at, synced_at);
|
||||
let current = repository.load_runtime_snapshot().await.unwrap();
|
||||
assert_eq!(current.settings.pricing["openai"].len(), 1);
|
||||
assert!(current.settings.pricing["xai"].contains_key("shared"));
|
||||
assert_eq!(frozen.settings.pricing["openai"].len(), 4);
|
||||
let audits: i64 = sqlx::query_scalar(
|
||||
"select count(*) from admin_audit_events where action = 'pricing.update'",
|
||||
)
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(audits, 2);
|
||||
drop(settings);
|
||||
drop(bundle);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_pricing_syncs_merge_selected_changes_and_remove_only_selected_models() {
|
||||
let Some((database, bundle)) = pricing_store("pricing_selection").await else {
|
||||
return;
|
||||
};
|
||||
let settings = bundle.admin_ports().settings();
|
||||
let context = MutationContext {
|
||||
actor: MutationActor::System,
|
||||
request_id: "pricing-selection-test".to_owned(),
|
||||
};
|
||||
let price = json!({
|
||||
"multiplierBps":10000,
|
||||
"bands":{"standard":{"input":"2","output":"10","cacheRead":"0","cacheWrite":"1"}}
|
||||
});
|
||||
let patch = |value| {
|
||||
serde_json::from_value::<gateway_admin::model::pricing::PricingSyncChanges>(value).unwrap()
|
||||
};
|
||||
let (first, second) = tokio::join!(
|
||||
settings.sync_pricing(patch(json!({"openai": {"model-a": price}})), &context),
|
||||
settings.sync_pricing(
|
||||
patch(json!({"openai": {"model-b": price}, "xai": {"model-a": price}})),
|
||||
&context
|
||||
),
|
||||
);
|
||||
assert_ne!(first.unwrap(), second.unwrap());
|
||||
let stored = settings.load_pricing().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&stored.synced).unwrap(),
|
||||
json!({
|
||||
"openai": {"model-a": price, "model-b": price}, "xai": {"model-a": price}
|
||||
})
|
||||
);
|
||||
settings
|
||||
.sync_pricing(patch(json!({"openai": {"model-a": null}})), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
let stored = settings.load_pricing().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&stored.synced).unwrap(),
|
||||
json!({
|
||||
"openai": {"model-b": price}, "xai": {"model-a": price}
|
||||
})
|
||||
);
|
||||
let before = stored;
|
||||
assert!(
|
||||
settings
|
||||
.sync_pricing(
|
||||
patch(json!({
|
||||
"openai": {"model-b": null}, "invalid": {"model-c": price}
|
||||
})),
|
||||
&context
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(settings.load_pricing().await.unwrap(), before);
|
||||
let audits: i64 =
|
||||
sqlx::query_scalar("select count(*) from admin_audit_events where action = 'pricing.sync'")
|
||||
.fetch_one(&database.pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(audits, 3);
|
||||
drop(settings);
|
||||
drop(bundle);
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use super::TestDatabase;
|
||||
fn settings_with_margin(refresh_margin_seconds: u64) -> RuntimeSettingsUpdate {
|
||||
RuntimeSettingsUpdate {
|
||||
openai_client_profile: None,
|
||||
disable_fast: None,
|
||||
request_location_enabled: false,
|
||||
request_location: Default::default(),
|
||||
admin_api_key: None,
|
||||
@@ -432,32 +431,6 @@ async fn decompression_setting_should_persist_and_reach_snapshot_facts() {
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disable_fast_persists_and_omitted_updates_preserve_the_restriction() {
|
||||
let Some(database) = TestDatabase::create("disable_fast_settings").await else {
|
||||
return;
|
||||
};
|
||||
let repository = PgRuntimeSettingsRepository::new(database.pool.clone());
|
||||
let initial = repository.load_runtime_settings().await.unwrap();
|
||||
assert!(!initial.disable_fast);
|
||||
for (value, expected) in [(Some(true), true), (None, true), (Some(false), false)] {
|
||||
let mut update = settings_with_margin(1_800);
|
||||
update.disable_fast = value;
|
||||
let revision = repository.update_runtime_settings(update).await.unwrap();
|
||||
let reloaded = repository.load_runtime_settings().await.unwrap();
|
||||
assert_eq!(reloaded.disable_fast, expected);
|
||||
assert_eq!(reloaded.config_revision, revision);
|
||||
use gateway_store::postgres::{PgRuntimeSnapshotRepository, RuntimeSnapshotRepository};
|
||||
let snapshot = PgRuntimeSnapshotRepository::new(database.pool.clone())
|
||||
.load_runtime_snapshot()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(snapshot.settings.disable_fast, expected);
|
||||
assert_eq!(snapshot.config_revision, revision);
|
||||
}
|
||||
database.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_profile_initialization_is_idempotent_and_old_updates_preserve_it() {
|
||||
use gateway_core::{
|
||||
|
||||
@@ -210,12 +210,47 @@ fn openai_feedback_should_penalize_capacity_rejections_even_with_transient_retry
|
||||
|
||||
deliver_error_with_openai_feedback(&feedback, &provider, &account, error);
|
||||
|
||||
assert!(
|
||||
feedback
|
||||
.scheduling_signals(&provider, &account)
|
||||
.0
|
||||
.is_some_and(|failure_rate| failure_rate > 0),
|
||||
"capacity rejection did not affect the account score: {code}"
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals(&provider, &account).0,
|
||||
Some(4_000),
|
||||
"capacity rejection should receive the stronger penalty: {code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_feedback_should_keep_regular_server_error_penalty() {
|
||||
let feedback = Arc::new(AccountFeedbackStats::default());
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let account = ProviderAccountId::new("acct_server_error").expect("account");
|
||||
|
||||
deliver_error_with_openai_feedback(
|
||||
&feedback,
|
||||
&provider,
|
||||
&account,
|
||||
sent_error(ProviderErrorKind::Unavailable, Some("server_error")),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals(&provider, &account).0,
|
||||
Some(2_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openai_feedback_should_ignore_unconfirmed_capacity_rejections() {
|
||||
for send_state in [UpstreamSendState::NotSent, UpstreamSendState::Ambiguous] {
|
||||
let feedback = Arc::new(AccountFeedbackStats::default());
|
||||
let provider = ProviderKind::new("openai").expect("provider");
|
||||
let account = ProviderAccountId::new("acct_unconfirmed").expect("account");
|
||||
let error = ProviderError::new(ProviderErrorKind::UpstreamCapacityUnavailable, send_state)
|
||||
.with_upstream_code(OpaqueUpstreamValue::new("server_is_overloaded"));
|
||||
|
||||
deliver_error_with_openai_feedback(&feedback, &provider, &account, error);
|
||||
|
||||
assert_eq!(
|
||||
feedback.scheduling_signals(&provider, &account),
|
||||
(None, None)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,5 +13,4 @@ b20e425c0b062b72d35fdebe294e023eb17ceb44ac93083ff3d48400715bf430 0012_upstream_
|
||||
6e23fceef1096b50c1af3584cb1687eded2ad37e31e65391397ec984bf78c7e3 0013_responses_decompression_limit.sql
|
||||
12a540be31d65c4925cbe63dab42ea2d4ec2864fecf71a6dd4ee942e71726857 0014_disable_fast.sql
|
||||
9bd37bd314b6864816c43cb4fd1869fb415920d4f10f766976e344334a1f60fc 0015_proxy_dual_stack_exit.sql
|
||||
3eb917aab407a497355b21db0faf7974aac757801473b0a13b47b136598b371f 0016_client_request_profiles.sql
|
||||
3569e370689caac3f03e76539bc7df4b22304988f428cb7f8911aba170c59167 0017_model_pricing.sql
|
||||
a1e23b8535ed41de27861833cd2d5ee7498a9bb0e523156ba3f011f9c6b3966b 0016_client_profiles_and_model_pricing.sql
|
||||
|
||||
+11
@@ -9,3 +9,14 @@ alter table client_api_keys
|
||||
add column provider_request_profiles_json jsonb not null default '{}'::jsonb,
|
||||
add constraint client_api_keys_request_profiles_object
|
||||
check (jsonb_typeof(provider_request_profiles_json) = 'object');
|
||||
|
||||
-- 覆盖配置与请求历史明细分离,改价不重算已发生的费用。
|
||||
alter table runtime_settings add column pricing_overrides_json jsonb not null default '{}'
|
||||
check (jsonb_typeof(pricing_overrides_json) = 'object');
|
||||
alter table runtime_settings add column pricing_synced_json jsonb not null default '{}'
|
||||
check (jsonb_typeof(pricing_synced_json) = 'object');
|
||||
alter table runtime_settings add column pricing_synced_at timestamptz;
|
||||
alter table model_requests add column billing_snapshot_json jsonb
|
||||
check (billing_snapshot_json is null or jsonb_typeof(billing_snapshot_json) = 'object');
|
||||
|
||||
alter table runtime_settings drop column disable_fast;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- 覆盖配置与请求历史明细分离,改价不重算已发生的费用。
|
||||
alter table runtime_settings add column pricing_overrides_json jsonb not null default '{}'
|
||||
check (jsonb_typeof(pricing_overrides_json) = 'object');
|
||||
alter table runtime_settings add column pricing_synced_json jsonb not null default '{}'
|
||||
check (jsonb_typeof(pricing_synced_json) = 'object');
|
||||
alter table runtime_settings add column pricing_synced_at timestamptz;
|
||||
alter table model_requests add column billing_snapshot_json jsonb
|
||||
check (billing_snapshot_json is null or jsonb_typeof(billing_snapshot_json) = 'object');
|
||||
+3
-5
@@ -64,7 +64,7 @@ Linux 上应用容器以 `10001:10001` 运行。上述命令将应用数据和
|
||||
配置设为 `0640`,均由当前用户持有、容器组 `10001` 访问。
|
||||
`config.yaml` 通过 Compose `configs` 只读挂载,普通 Compose 保留宿主机文件的 UID/GID 和 mode。
|
||||
|
||||
OpenAI 上游身份在管理端「系统设置 → 上游身份」配置,Key 可选择独立身份覆盖通用值。
|
||||
OpenAI 上游身份在管理端「系统设置 → 上游配置 → 客户端身份」配置,Key 可选择独立身份覆盖通用值。
|
||||
首次默认 macOS Desktop 自动最新,支持范围与字段见 [客户端身份合同](../docs/api.md#openai-上游客户端身份)。
|
||||
旧 `openai.wire_profile` 在数据库尚无选择时一次性导入:默认版本采用自动更新;版本元组、平台或架构
|
||||
与默认值不同则保留为固定版本。导入后从管理端修改,重启不会用 YAML 覆盖数据库。
|
||||
@@ -72,7 +72,7 @@ OpenAI 上游身份在管理端「系统设置 → 上游身份」配置,Key
|
||||
OpenAI 上游地址、WebSocket 池、额度刷新和 OAuth 使用 Provider 默认值;xAI 仍在 YAML 配置启动画像。
|
||||
后台发布检查只更新可重建的版本资料,不回写用户选择或 `config.yaml`;失败继续使用同组合的有效版本。
|
||||
|
||||
全局请求位置在管理端「系统设置 → 运行设置 → 请求时区与位置」配置,默认关闭,保留客户端原有位置和时区。
|
||||
全局请求位置在管理端「系统设置 → 上游配置 → 请求位置覆盖」配置,默认关闭,保留客户端原有位置和时区。
|
||||
开启后使用自定义位置,关闭时保留已保存的自定义值。「代理管理」设置的自定义位置优先于全局设置,
|
||||
全局关闭时代理自定义仍生效。保存后新请求生效,无需重启;关闭代理自定义后恢复全局继承。
|
||||
该设置统一 OpenAI Responses 的 Web Search 位置与带来源标记的环境日期、时区,不修改 epoch 时间戳、
|
||||
@@ -342,12 +342,10 @@ OpenAI 主动额度重置卡及其消费结果由上游持有,不写入 Postgr
|
||||
## 密码语义
|
||||
|
||||
- `admin.default_password` 只在首次创建管理员时使用。
|
||||
- 已有管理员在「系统设置 → 管理员密码」修改登录密码,需要验证当前密码;修改后所有管理员会话失效,使用新密码重新登录。
|
||||
- 已有管理员在「系统设置 → 安全与访问 → 管理员密码」修改登录密码,需要验证当前密码;修改后所有管理员会话失效,使用新密码重新登录。
|
||||
- PostgreSQL 官方镜像只在空数据目录初始化时使用 `database.password`。
|
||||
- Redis 在每次容器创建时使用 `redis.password`。
|
||||
|
||||
管理员会话绑定密码哈希指纹;从尚未包含此字段的版本升级后,管理员需要重新登录一次,密钥身份会话不受影响。
|
||||
|
||||
已有 PostgreSQL 数据目录后,直接修改 `database.password` 不会修改数据库用户密码,只会导致
|
||||
应用无法连接。轮换时必须先在 PostgreSQL 中修改用户密码,再同步更新 `config.yaml`。Redis
|
||||
密码变更后需要用新配置重新创建 Redis 和应用容器,不需要删除 Redis 数据目录。
|
||||
|
||||
+16
-12
@@ -267,7 +267,8 @@ OpenAI 明确返回 `server_is_overloaded`、`slow_down` 或模型容量不足
|
||||
尚未交付输出的前提下,先做最多 3 次同账号指数退避,再通过现有调度换号。默认间隔从 500ms 开始,
|
||||
上游 `Retry-After` 参与退避计算,单次等待不超过 8 秒;重试同时受请求总尝试次数和截止时间约束。
|
||||
`server_is_overloaded`、`slow_down` 等可计分的结构化错误按已发送的失败尝试计入 Smart 账号
|
||||
健康分。失败率使用账号级平滑与时间衰减,影响后续普通选路,已有可用账号的
|
||||
健康分。已确认容量拒绝的平滑权重为 0.4,其他可计分失败与成功样本保持 0.2。
|
||||
失败率使用账号级平滑与时间衰减,影响后续普通选路,已有可用账号的
|
||||
会话亲和仍优先。容量不足不触发 Provider 全局熔断,也不作为账号额度耗尽;启用账号自动冻结时,
|
||||
达到容量失败阈值会另外写入临时冷却。
|
||||
客户端错误兼容由 API 编码出口统一处理:最终交付的 `server_is_overloaded`、`slow_down` 错误码
|
||||
@@ -857,7 +858,13 @@ PostgreSQL 或 Redis。
|
||||
分组是 Provider-neutral 的账号集合;一个组可包含任意 Provider 账号,一个账号也可属于多个组。
|
||||
分组详情和列表返回 `disableFast`,创建时省略默认为 `false`,更新时省略或 `null` 保留现值。
|
||||
Client Key 绑定的任一分组开启此限制(包括已禁用分组)时,该 Key 的 OpenAI Responses 请求关闭 Fast;
|
||||
未绑定分组的 Key 只受全局限制,不按最终所选账号的分组判断。
|
||||
未绑定分组的 Key 不限制 Fast,不按最终所选账号的分组判断。
|
||||
|
||||
关闭 Fast 只将顶层 `service_tier` 的 `priority`(含 `fast` 别名)改为显式 `default`,继续处理请求;
|
||||
不改变 `flex`、`ultrafast`、缺失值、默认档、嵌套字段或其他 Provider。
|
||||
HTTP 和每个 WebSocket `response.create` 均使用请求开始时的分组策略,同一请求重试保持该策略;
|
||||
HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造;复用 WS 时不重发握手头,
|
||||
每个 `response.create` 仍独立应用档位策略,请求档位统计与本地费用估算使用各帧的最终出站档位。
|
||||
|
||||
| 方法 | 路由 | 主要 query/body | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
@@ -971,7 +978,6 @@ HTTP 返回 `429`,`error.code` 为 `key_daily_budget_exceeded` 或 `key_weekly
|
||||
|
||||
```text
|
||||
openaiClientProfile
|
||||
disableFast
|
||||
requestLocationEnabled
|
||||
requestLocation
|
||||
modelMappings
|
||||
@@ -998,13 +1004,6 @@ accountAutoFreezeProbeModel
|
||||
accountAutoFreezeAdaptiveConcurrency
|
||||
```
|
||||
|
||||
`disableFast` 默认 `false`,更新时省略或 `null` 保留现值。全局开启时,所有 Key 的 OpenAI Responses 请求关闭 Fast;
|
||||
全局关闭时仍应用 Key 绑定分组的限制。关闭 Fast 只将顶层 `service_tier` 的 `priority`(含 `fast` 别名)
|
||||
改为显式 `default`,继续处理请求;不改变 `flex`、`ultrafast`、缺失值、默认档、嵌套字段或其他 Provider。
|
||||
HTTP 和每个 WebSocket `response.create` 均使用请求开始时的配置,同一请求重试保持该配置;
|
||||
HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造;复用 WS 时不重发握手头,
|
||||
每个 `response.create` 仍独立应用档位策略,请求档位统计与本地费用估算使用各帧的最终出站档位。
|
||||
|
||||
`requestLocationEnabled` 是必填布尔值,默认 `false`:关闭时不覆盖客户端原有位置和时区;开启时使用已保存的
|
||||
`requestLocation`。关闭不会清空自定义值,代理自定义位置仍优先。
|
||||
`requestLocation` 是必填的完整对象 `{ country, region, city, timezone }`,不接受 `null`;初始保存值为
|
||||
@@ -1038,7 +1037,7 @@ HTTP 请求头及新建 WS 的握手提示按当时的最终出站档位构造
|
||||
| `GET` | `/api/admin/settings/pricing` | 返回 `{ defaults, synced, overrides, syncedAt }` |
|
||||
| `POST` | `/api/admin/settings/pricing/update` | `{ provider, models, change }`,成功返回 `{ saved: true }` |
|
||||
| `POST` | `/api/admin/settings/pricing/sync/preview` | 无 body;返回 `{ prices, skipped }`,不写入配置 |
|
||||
| `POST` | `/api/admin/settings/pricing/sync` | 原样提交确认的 `{ prices, skipped }`;成功返回 `{ saved: true }` |
|
||||
| `POST` | `/api/admin/settings/pricing/sync` | `{ preview: { prices, skipped }, models: { openai: ["gpt-5.4"] } }`;成功返回 `{ saved: true }` |
|
||||
|
||||
价目使用 `Provider → 精确上游模型 ID → { multiplierBps, bands }` 的映射。优先级为人工覆盖、已同步价目、
|
||||
内置价目;按档位合并,不从客户端模型别名或响应模型猜测价格。`syncedAt` 为 ISO 时间或 `null`。
|
||||
@@ -1060,12 +1059,17 @@ OpenAI 长上下文为输入超过 272000 Token,xAI 为输入达到 200000 Tok
|
||||
配置,未提供档位重新继承来源;内置和同步均未登记的模型必须包含 `standard`。
|
||||
- `{ "action": "multiplier", "multiplierBps": 20000 }`:设置目标倍率,保留已有人工单价;重复提交不连续相乘。
|
||||
- `{ "action": "reset" }`:移除人工单价与倍率,恢复同步价或内置价;仅有人工价格的模型恢复为未配置。
|
||||
- `{ "action": "delete" }`:删除非内置模型的同步价目与人工配置;批量包含任何内置模型时整批返回 400,
|
||||
即使该内置模型已有人工覆盖也不能删除。删除不影响历史账单,之后可重新添加或选中同步导入。
|
||||
|
||||
一批更新原子提交并写审计,不覆盖未选中的模型。未知字段、错误类型和非法价格字符串等 JSON 合同错误
|
||||
返回 422;Provider、模型 ID、批量数量、倍率上限和不支持的档位等业务校验错误返回 400。
|
||||
models.dev 同步只导入可表示为当前文本 Token 计价的 OpenAI/xAI 模型;不完整价格、其他输出模态及
|
||||
不匹配的上下文梯度在 `skipped` 中返回 `provider/model`。确认会重新抓取价目;若与预览不同返回 400,
|
||||
需重新预览。来源不可用返回 502,已有价目保持不变。同步只更新来源层,始终保留人工单价与倍率。
|
||||
需重新预览。来源不可用返回 502,已有价目保持不变。`preview` 必须原样提交,`models` 按 Provider
|
||||
指定 1~10000 个待同步模型;空选择或未登记的模型返回 400。同步只更新选中模型的来源层,未选中模型
|
||||
及所有人工单价与倍率保持不变。选中的已同步模型若不再出现在来源价目中,则移除其来源层,恢复内置价格;
|
||||
没有内置价目的模型变为未配置。
|
||||
|
||||
### OpenAI 上游客户端身份
|
||||
|
||||
|
||||
@@ -133,8 +133,8 @@ service;客户端原生对象保存在有界进程缓存中,与套餐 Redis
|
||||
`RuntimeSnapshot → RoutingPlan → AttemptContext` 冻结传递,关闭时保留客户端原有字段;
|
||||
Provider 在选定账号后应用代理位置覆盖。请求期间不额外查询全局设置,配置发布不改变已开始请求的全局值。
|
||||
|
||||
Fast 限制由同一快照链路冻结:全局开关与 Client Key 所有绑定分组的开关取逻辑或;
|
||||
禁用分组仍贡献限制,无分组 Key 只受全局开关影响,不按所选账号的分组重新解释。
|
||||
Fast 限制由同一快照链路冻结:Client Key 所有绑定分组的开关取逻辑或;
|
||||
禁用分组仍贡献限制,无分组 Key 不限制 Fast,不按所选账号的分组重新解释。
|
||||
OpenAI Provider 在独立编码请求上、生成上游头与观测前统一应用顶层档位覆盖,HTTP、WS 与重试共用。
|
||||
该策略不改变选号、会话亲和或共享原始请求;每个新 WS 请求重新取得当前策略。
|
||||
WS 路由提示属于握手,连接复用时不重发;档位变化不重建连接或打断 `previous_response_id` 续写,
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
# 上游客户端身份配置实施计划
|
||||
|
||||
状态:实现与本地验收完成,真实上游账号验收尚未覆盖。当前实现位于 `feat/upstream-client-profiles` 独立工作区。
|
||||
代码核对基线:`3328a435`。
|
||||
|
||||
## 1. 目标与已确定的规则
|
||||
|
||||
为 OpenAI 上游请求提供 macOS、Linux、Windows 与 Desktop、CLI 组合的六套客户端身份预设,
|
||||
支持自定义,并允许管理员在 Client Key 中单独选择,覆盖通用设置。
|
||||
|
||||
配置只保留两层:
|
||||
|
||||
```text
|
||||
Key 有独立配置 → 使用 Key 配置
|
||||
Key 跟随通用设置 → 使用通用设置
|
||||
```
|
||||
|
||||
- 新安装的通用设置默认使用 **macOS · Desktop · 自动最新**,管理员可以修改。
|
||||
- 新建及升级前已有的 Key 默认「跟随通用设置」。
|
||||
- 内置默认值只用于初始化通用设置,不参与运行时配置优先级。
|
||||
- 修改通用设置后,跟随它的 Key 在新请求中自动生效;独立配置的 Key 保持自己的选择。
|
||||
- Key 切回「跟随通用设置」时清除独立覆盖,不保留参与运行时合并的隐藏字段。
|
||||
- 独立覆盖以完整配置为单位,不逐字段混合 Key 与通用设置,避免组合出不一致的身份。
|
||||
|
||||
本文中的客户端身份指网关发给上游的 User-Agent、originator、平台和版本等信息。
|
||||
该功能不改变接入客户端的最低版本门禁、客户端安装方式或网关宿主操作系统。
|
||||
|
||||
## 2. 当前实现与可复用入口
|
||||
|
||||
| 现有能力 | 代码入口 | 本次需要补齐的部分 |
|
||||
| --- | --- | --- |
|
||||
| 启动时的 Desktop 身份配置,默认 macOS | [OpenAI 配置](../../backend/crates/providers/openai/src/config.rs) | 将用户选择纳入持久化通用设置,支持客户端类型与平台选择 |
|
||||
| 共享请求画像、Desktop 发布检查及版本元组更新 | [请求画像](../../backend/crates/providers/openai/src/transport/profile/mod.rs) | 按类型与平台解析版本,支持固定版本以及每请求独立身份 |
|
||||
| 模型、账号及 Desktop 专属请求的头部组装 | [请求头](../../backend/crates/providers/openai/src/transport/headers.rs) | 按调用场景消费解析后的身份,保留不同接口的协议边界 |
|
||||
| 通用设置管理 | [管理模型](../../backend/crates/gateway-admin/src/model/settings.rs)、[持久化](../../backend/crates/gateway-store/src/postgres/runtime_settings.rs) | 保存全局选择,复用事务、审计及配置发布 |
|
||||
| Key 管理 | [管理模型](../../backend/crates/gateway-admin/src/model/client_keys.rs)、[管理用例](../../backend/crates/gateway-admin/src/use_case/client_keys.rs) | 保存可空的独立覆盖,并提供实际生效配置的投影 |
|
||||
| 请求策略冻结与发布 | [运行快照](../../backend/crates/gateway-core/src/routing/snapshot.rs)、[快照发布](../../backend/crates/gateway-core/src/runtime/mod.rs) | 携带配置选择,保持同一请求及重试的一致性 |
|
||||
| 上游连接池已有身份区分与精确续写;模型目录已有缓存隔离 | [WebSocket](../../backend/crates/providers/openai/src/transport/websocket/)、[账号目录](../../backend/crates/providers/openai/src/credential/catalog.rs) | 复用现有机制,核对新增身份字段是否完整进入隔离维度 |
|
||||
| 设置页及首页身份展示 | [设置页](../../frontend/src/views/settings/)、[身份卡片](../../frontend/src/views/dashboard/components/WireProfileCard.vue) | 增加配置入口,明确全局展示与 Key 实际生效值的区别 |
|
||||
|
||||
目前画像类型包含 Desktop 专属字段,自动更新通过同一 Desktop 制品取得应用版本、Core 版本及构建号。
|
||||
因此,六套配置需要按官方身份语义扩展,不能仅替换现有 UA 的操作系统名称。
|
||||
|
||||
## 3. 预设、自定义与版本策略
|
||||
|
||||
### 3.1 六套预设
|
||||
|
||||
| 平台 | Desktop | CLI |
|
||||
| --- | --- | --- |
|
||||
| macOS | macOS Desktop | macOS CLI |
|
||||
| Linux | Linux Desktop | Linux CLI |
|
||||
| Windows | Windows Desktop | Windows CLI |
|
||||
|
||||
平台与客户端类型决定预设;系统版本、CPU 架构及终端信息是预设参数,不再展开成额外的顶级选项。
|
||||
预设值由后端统一提供,前端不维护另一份默认值和 UA 拼接规则。
|
||||
|
||||
实施前逐项核对官方源码或官方制品,记录版本、提交或制品标识、请求格式和更新来源。
|
||||
尤其需要核实 Linux Desktop 的可用制品及身份依据,不能因为有六个组合就宣称六套均已获得官方验证。
|
||||
没有可靠官方依据的组合保留自定义配置入口,标明核验状态;不伪造官方发布版本或自动更新来源。
|
||||
|
||||
### 3.2 自定义
|
||||
|
||||
通用设置和 Key 独立配置共用一套编辑模型:选择预设后,可以调整客户端标识、系统版本、
|
||||
架构、终端及适用的版本字段,并预览最终请求身份。
|
||||
|
||||
- 首期将自定义内容保存在对应通用设置或 Key 中;命名模板库、共享模板引用及模板管理不属于本次范围。
|
||||
- 后端按客户端类型生成 UA 与相关身份头;不增加任意请求头透传或任意发布源 URL 配置。
|
||||
- 校验必填项、字段长度、HTTP 头合法性、控制字符及客户端类型对应的版本格式。
|
||||
- Desktop 与 CLI 使用各自适用的字段;CLI 不携带由 Desktop 默认值遗留的应用版本后缀。
|
||||
- 自定义值与「官方核验」状态分别展示,用户填写的值不能自行获得官方核验标记。
|
||||
- 认证信息、账号 ID、Cookie、请求位置及数据驻留仍由现有职责管理,不纳入身份自定义。
|
||||
|
||||
### 3.3 自动最新与固定版本
|
||||
|
||||
| 模式 | 行为 |
|
||||
| --- | --- |
|
||||
| 自动最新(默认) | 跟随该客户端、平台及适用架构对应的最新已核验稳定发布;后台更新,不在业务请求中访问发布源 |
|
||||
| 固定版本 | 使用保存的版本;后台发布检查不能覆盖固定值 |
|
||||
|
||||
自动最新只更新版本资料,不覆盖用户的平台、架构、终端等选择。手动修改版本时转为固定版本,
|
||||
避免保存后又被后台任务覆盖。恢复自动最新后,重新采用对应发布源的有效版本。
|
||||
|
||||
- Desktop 应用版本、内嵌 Core 版本和构建号作为同一制品的完整元组更新;不拼接独立 CLI 的最新版本。
|
||||
- CLI 使用对应官方 CLI 发布信息,不借用 Desktop 发布号。
|
||||
- 不假设不同平台的 Desktop 同步发布或共享构建号。
|
||||
- 更新失败保留同一预设上次成功的版本,并展示检查失败及检查时间;不得切到其他平台或客户端。
|
||||
- 首次离线启动可使用同一预设随版本附带的已核验版本资料,并标明未完成最新检查。
|
||||
这些资料属于版本解析器的种子数据,不是第三层用户配置。
|
||||
- 没有有效发布源或启动资料的组合不能进入可用的「自动最新」状态;提供原因与固定自定义入口。
|
||||
- Redis 中的发布缓存至少区分客户端类型、平台和影响制品选择的架构,不再仅凭 Provider 区分。
|
||||
缓存失效和重启后的恢复也不得覆盖用户的固定配置。
|
||||
|
||||
## 4. 配置合同与职责
|
||||
|
||||
### 4.1 持久化与管理 API
|
||||
|
||||
拟增加的概念字段如下,最终名称在实施时与现有 API 风格统一:
|
||||
|
||||
| 位置 | 字段 | 语义 |
|
||||
| --- | --- | --- |
|
||||
| 通用设置 | `openaiClientProfile` | 必须存在的完整配置选择,包含预设或自定义参数、版本模式 |
|
||||
| Client Key | `openaiClientProfileOverride` | `null` 表示跟随通用设置;对象表示完整独立配置 |
|
||||
| 只读投影 | `effectiveOpenaiClientProfile` | 返回当前解析结果、配置来源、版本来源及检查状态,不作为另一份可写配置 |
|
||||
|
||||
新建 Key 省略覆盖字段等同于 `null`;更新 Key 省略字段保留已有覆盖,显式 `null` 才清除。
|
||||
旧调用方更新通用设置时省略新增字段,应保留现值,不能重置管理员已保存的选择。
|
||||
通用设置不接受以 `null` 制造第三层回退的语义。
|
||||
|
||||
配置由 PostgreSQL 保存。管理写入沿用现有事务、脱敏审计、revision 和快照发布流程。
|
||||
官方发布结果继续作为 Provider 的派生缓存,不将每次版本检查写成用户配置或推进全局配置 revision。
|
||||
|
||||
职责依照 [系统架构](../architecture.md):
|
||||
|
||||
- Admin 负责全局与 Key 的管理规则和继承选择,复用同一解析入口生成生效投影。
|
||||
- Core 负责请求策略的携带与冻结,不依赖具体 Provider,也不解释 UA、官方制品或请求头。
|
||||
- OpenAI Provider 负责预设、官方版本解析、字段语义校验及上游头部生成,通过现有端口风格提供能力。
|
||||
- Store 实现配置、迁移与缓存存取,不解释客户端身份的业务规则。
|
||||
- API 负责 wire 校验及投影,前端负责编辑与展示,不重复推导最终身份。
|
||||
|
||||
### 4.2 生效范围
|
||||
|
||||
本次配置作用于 OpenAI Provider。Key 同时授权其他 Provider 时,不把 OpenAI 身份套用到 xAI。
|
||||
|
||||
由 Client Key 发起的 OpenAI 模型请求及其相关目录读取,消费该 Key 的有效身份;
|
||||
HTTP/SSE、WebSocket 以及 Images、Search 等入口按实际调用链逐一核对覆盖。
|
||||
OAuth 与 API Key 上游认证模式继续共用适用的身份规则,认证字段仍按各自协议生成。
|
||||
|
||||
后台令牌刷新、额度查询、账号管理及 Desktop 专属接口没有 Client Key 上下文,不能被某个 Key 的选择污染。
|
||||
实施时按官方接口要求划分共用身份与 Desktop 专属身份,保留必要的独立请求格式;
|
||||
全局选为 CLI 时也不能让 Desktop 专属接口意外携带缺失或错误的应用版本。
|
||||
|
||||
### 4.3 请求一致性与资源隔离
|
||||
|
||||
本节以接入现有机制为主。当前鉴权已持有 `RuntimeSnapshot`,重试通过原路由计划传递冻结策略;
|
||||
WebSocket 连接池已将 `originator`、`user-agent` 等纳入连接身份,且已有
|
||||
[画像更新后保留精确续写所属连接的测试](../../backend/crates/providers/openai/tests/transport/websocket_pool.rs)。
|
||||
实施沿用这些快照、重试和续写机制,补齐新增配置的数据传递与必要隔离维度。
|
||||
|
||||
- 将通用与 Key 的身份选择接入已有配置快照,沿现有请求上下文传入 Provider,
|
||||
同一逻辑请求的重试与换号继续使用冻结的选择。
|
||||
- 自动发布版本目前属于 Provider 的共享运行时画像,`response_headers` 在组装头部时读取
|
||||
`self.profile.snapshot()`,尚未由 `RuntimeSnapshot` 冻结。
|
||||
为满足跨重试也使用同一解析版本的目标,在已有请求生命周期内保存首次解析结果并复用;
|
||||
这部分与用户配置的快照继承分别验证,不改动重试决策和状态机。
|
||||
- 禁止通过临时修改共享 `CodexWireProfileState` 实现 Key 覆盖;并发 Key 使用独立的不可变请求身份。
|
||||
- 同一入站 WebSocket 的每个 `response.create` 按现有规则获取新策略。
|
||||
普通连接获取复用现有身份匹配规则;精确续写继续使用持有响应状态的原连接,
|
||||
保留已有的画像更新例外,不能因版本刷新强制断开或重建这类连接。
|
||||
- 检查新增字段是否已被现有连接身份和模型目录缓存键覆盖,仅补齐影响协议结果的缺失维度。
|
||||
Key 授权边界沿用现有机制;身份切换继续遵循原有续写和重放限制,避免上下文丢失或越界重放。
|
||||
- 「当前生效」投影显示解析结果;请求观测使用本次冻结的摘要,不能事后用最新全局值替代。
|
||||
|
||||
## 5. 管理端交互
|
||||
|
||||
### 通用设置
|
||||
|
||||
新增「上游客户端身份」配置区,包含平台、客户端类型、预设/自定义、版本策略以及相应字段。
|
||||
默认展示 macOS Desktop 自动最新;提供最终 UA、相关版本、发布检查状态及字段错误提示。
|
||||
保存后通过现有设置发布机制对新请求生效,无需重启。
|
||||
|
||||
### Key 创建与编辑
|
||||
|
||||
默认选择「跟随通用设置」,同时展示当前通用配置摘要。
|
||||
选择「独立配置」后使用与通用设置相同的编辑组件;首次切换可用当前通用值填充表单草稿,
|
||||
只有保存后才形成独立配置。摘要明确标出「通用设置」或「Key 独立配置」。
|
||||
|
||||
### 现有展示
|
||||
|
||||
首页身份卡片标明展示的是通用配置,不将其描述成所有 Key 的唯一身份。
|
||||
Key 详情展示该 Key 当前有效身份;复用后端投影,不在浏览器拼装 UA 或解析版本。
|
||||
复用现有基础组件及 [管理端主题](../theme.md),提供保存失败、版本检查失败、无可用发布源等状态。
|
||||
|
||||
## 6. 升级与迁移
|
||||
|
||||
- 按 [迁移规则](../../backend/migrations/README.md) 新增编号迁移,更新冻结清单,不修改历史迁移。
|
||||
- 新安装在初始化阶段写入通用默认配置;存量 Key 的覆盖初始化为 `null`。
|
||||
- 升级需识别既有 `openai.wire_profile` 自定义值,首次导入通用配置时保留有效的环境与身份选择。
|
||||
标准配置映射到 macOS Desktop 自动最新;显式自定义版本映射到固定自定义,并在升级说明交代。
|
||||
- SQL 迁移不负责读取部署文件;必要的一次性导入由启动组装流程经正式管理/存储端口完成,
|
||||
在设置尚未初始化时原子写入。初始化标记避免重启或更新重新覆盖数据库中的设置。
|
||||
- 实施前核实配置加载器能否区分显式自定义与默认填充值,必要时在加载阶段保留该来源信息。
|
||||
- 初始化完成后,相关用户选择以数据库为唯一权威;清理旧配置的运行时覆盖路径并同步配置样例。
|
||||
其他仍有效的 Provider 配置字段继续归原 owner 管理。
|
||||
- 备份与恢复包含通用配置和 Key 覆盖;发布缓存仍可重建,不成为配置恢复的前置依赖。
|
||||
|
||||
## 7. 实施顺序
|
||||
|
||||
1. **核对官方依据与接口范围。** 完成六个组合的来源矩阵,明确可自动更新的组合、
|
||||
Desktop 专属接口、CLI 格式及旧配置来源识别方式,记录无法核验的项目。
|
||||
2. **定义合同并打通持久化。** 确定完整配置对象、继承语义及只读投影;实现新增迁移、
|
||||
一次性初始化、设置与 Key 管理 API,复用审计和快照发布。
|
||||
3. **扩展 Provider 解析。** 复用现有 Desktop 版本核验,补充 CLI 及平台来源、固定版本、
|
||||
自定义校验和分维度缓存;确保官方资料与用户配置分别存储。
|
||||
4. **接入实际请求链路。** 复用现有快照与请求上下文携带有效选择,补齐解析版本的请求级复用,
|
||||
覆盖相关传输与目录入口;复用连接池身份匹配与精确续写规则,仅补齐必要的隔离维度。
|
||||
5. **实现设置与 Key 界面。** 复用一套编辑组件和服务端投影,调整首页身份卡片含义,
|
||||
完成浏览器交互验收。
|
||||
6. **验证并同步正式文档。** 完成下列矩阵;在行为可用后就地更新 API、架构、部署和用户入口的必要说明,
|
||||
不提前将本计划中的拟定合同写成已发布能力。
|
||||
|
||||
## 8. 验收条件
|
||||
|
||||
| 范围 | 必须覆盖的场景与结果 |
|
||||
| --- | --- |
|
||||
| 默认与继承 | 新安装采用约定默认;新旧 Key 跟随;通用修改只影响跟随者;独立配置不受影响;显式清除后恢复继承 |
|
||||
| API 更新语义 | 省略覆盖字段保留原值,显式 `null` 清除;旧设置调用不重置新配置;非法配置不留下部分提交 |
|
||||
| 预设与自定义 | 六个组合均有明确支持状态;已核验预设与对应官方证据一致;自定义可保存、重启恢复并生成合法头部 |
|
||||
| 版本解析 | 自动最新按来源更新;固定值不被刷新;Desktop 元组一致;检查失败保留同组合旧版本;无来源状态如实显示 |
|
||||
| 并发与冻结 | 两个不同配置的 Key 并发请求不串用身份;配置或发布刷新期间,已开始请求及其重试保持一致 |
|
||||
| 上游传输 | 在可用的真实测试环境检查 HTTP/SSE、WebSocket 及受影响接口发出的身份与结果;API Key 认证不混入 OAuth 字段 |
|
||||
| 缓存与续写 | 不同有效身份不误用目录结果或连接;配置切换后的新请求应用新身份;旧 continuation 无上下文丢失或越界重放 |
|
||||
| 管理与后台 | Key 覆盖不影响其他 Key、后台任务及 Desktop 专属操作;全局 CLI 与混合 Provider 授权场景可用 |
|
||||
| 升级与恢复 | 标准配置、自定义配置、重复启动、数据库备份恢复和发布缓存缺失均按约定处理;历史迁移校验不变 |
|
||||
| 界面 | 创建/编辑/清除覆盖、保存与加载失败、自动与固定切换、来源及核验状态正确;检查窄屏、深浅色和键盘操作 |
|
||||
|
||||
后端补充能验证上述真实行为的用例,按 [贡献与审查](../../CONTRIBUTING.md#验证) 和
|
||||
[架构验收入口](../architecture.md#12-修改与验收) 执行适用的 Rustfmt、Clippy、测试、架构边界与迁移检查。
|
||||
数据库与 Redis 使用专用测试实例,因环境缺失跳过的测试明确记录为缺口。
|
||||
|
||||
前端执行 `pnpm --dir frontend format:check`、`pnpm --dir frontend build`,并提供实际浏览器运行证据;
|
||||
不为本功能引入项目当前不维护的独立前端测试体系。所有变更执行 `git diff --check`。
|
||||
构建、单元测试或模拟上游通过不能替代真实协议验收;测试账号和上游环境不足时列明未覆盖组合。
|
||||
|
||||
## 9. 已核对的官方依据与实现边界
|
||||
|
||||
- CLI UA 与 originator:官方 `openai/codex` 提交 `4d205c7a4dc36b719679a0356a45b23133732265`,
|
||||
`codex-rs/login/src/auth/default_client.rs`。CLI 不添加 Desktop 应用后缀。
|
||||
- CLI 版本:`https://registry.npmjs.org/@openai%2Fcodex/latest`,2026-09-18 返回 `0.155.0`,
|
||||
darwin/linux/win32 × arm64/x64 的依赖版本均配套。实现验证包名、稳定 SemVer 和六个依赖,逐制品缓存。
|
||||
- macOS Desktop:沿用官方 appcast 与同一 ZIP 的内嵌 Core 核验路径,支持 arm64。
|
||||
- Windows Desktop:分别读取官方 `ChatGPT-x64.msix`、`ChatGPT-arm64.msix`,从 `app.asar/package.json`
|
||||
获取应用版本和构建号,从同包 `codex.exe` 核验架构和 Core 版本;MSIX 清单版本不作为应用版本。
|
||||
- Linux Desktop:依据[官方安装说明](https://learn.chatgpt.com/docs/linux/linux-app),分别读取 amd64/arm64 DEB,
|
||||
按 XZ 索引跳读 tar,核验同包 `app.asar/package.json` 与 ELF Core。Windows/Linux 的 Range 请求锁定 ETag,
|
||||
未变更时只检查一个字节,变更后完整核验版本元组;独立缓存、检查失败保留已核验版本。
|
||||
|
||||
身份预览采用独立只读接口,由后端解析全局或所提交的完整覆盖;Key 列表保存并返回覆盖配置,
|
||||
编辑器按预览结果显示当前生效身份,不在前端生成 UA。
|
||||
|
||||
## 10. 本地验收记录
|
||||
|
||||
2026-09-18,独立 PostgreSQL 18、Redis 8 与 `127.0.0.1:28080` 测试实例,使用合成账号与 Key。
|
||||
|
||||
- `RUST_MIN_STACK=16777216 cargo +1.97.0 test --manifest-path backend/Cargo.toml --test main --locked`
|
||||
全部 9 个测试套件通过,合计 2420 项,PostgreSQL/Redis 使用上述专用实例。
|
||||
覆盖配置继承、首次导入、旧调用保留字段、清除覆盖、跨重试版本冻结、并发请求身份隔离、制品缓存隔离,
|
||||
以及 Windows/Linux 归档解析、架构不符、损坏/截断、ETag 变化、XZ 跨块跳读和发布回退。
|
||||
- `cargo +1.97.0 fmt --all --manifest-path backend/Cargo.toml -- --check`、严格 Clippy(所有 target/feature)通过。
|
||||
- `pnpm --dir frontend format:check`、`pnpm --dir frontend build`、`git diff --check` 通过。
|
||||
- `backend/migrations/.frozen-sha256` 全部校验通过,历史迁移未修改。
|
||||
- Chrome 实测通用设置切换并保存 Linux CLI、创建默认继承的 Key、保存 Windows CLI 固定版本覆盖、
|
||||
重载回显与清除覆盖后恢复继承;检查 1920 px 与 390 px、深浅色、Key 弹窗和键盘关闭下拉框。
|
||||
新增表单宽度与相邻设置对齐,文案仅保留短标签与必要状态,浏览器无错误或警告。
|
||||
- Chrome 实测 Windows/Linux Desktop 自动模式与各自 UA,Windows 保存后重载回显;验收后恢复原 MacOS Desktop 通用配置。
|
||||
下拉显示 `MacOS`,内部枚举仍为 `macos`,UA 的系统类型仍为 `Mac OS`。
|
||||
- 官方发布检查实际成功:macOS Desktop `26.915.31029` / Core `0.155.0-alpha.9`,CLI `0.155.0`。
|
||||
Windows 两种架构为 Desktop `26.908.70816` / Core `0.154.0-alpha.6.2` / Build `9275`;
|
||||
Linux 两种架构为 Desktop `26.915.31029` / Core `0.155.0-alpha.9` / Build `9771`。
|
||||
四个官方包首次核验耗时约 17~30 秒,重复检查约 67~74 毫秒,未重新扫描制品。
|
||||
- `cargo audit --file backend/Cargo.lock` 未通过:已有固定依赖 `rustls 0.23.36` 命中
|
||||
`RUSTSEC-2026-0285`,另有原有 `chacha20`、`wnaf` 撤回警告;新增归档依赖未报告漏洞。
|
||||
本次未改变受官方 TLS 指纹约束的依赖版本。
|
||||
|
||||
验证边界:没有真实上游账号,真实认证下的 HTTP/SSE、WebSocket、Images/Search 未进行端到端验收;
|
||||
本地传输测试不能替代该验收。Windows/Linux Desktop 的 x86_64、arm64 官方制品解析和未变化复用均已实测,真实账号请求仍未覆盖。
|
||||
完整备份恢复、生产部署环境及发布源故障下的界面交互尚未实测。
|
||||
@@ -28,12 +28,18 @@ export type PricingChange
|
||||
= | { action: 'replace', pricing: ModelPricing }
|
||||
| { action: 'multiplier', multiplierBps: number }
|
||||
| { action: 'reset' }
|
||||
| { action: 'delete' }
|
||||
|
||||
export interface PricingSyncPreview {
|
||||
prices: PricingMap
|
||||
skipped: string[]
|
||||
}
|
||||
|
||||
export interface PricingSyncRequest {
|
||||
preview: PricingSyncPreview
|
||||
models: Record<string, string[]>
|
||||
}
|
||||
|
||||
interface UpdatePricingParam {
|
||||
provider: string
|
||||
models: string[]
|
||||
@@ -68,7 +74,7 @@ export function previewPricingSync() {
|
||||
})
|
||||
}
|
||||
|
||||
export function syncPricing(data: PricingSyncPreview) {
|
||||
export function syncPricing(data: PricingSyncRequest) {
|
||||
return request<PricingMutationResponse>({
|
||||
url: '/api/admin/settings/pricing/sync',
|
||||
method: 'POST',
|
||||
|
||||
@@ -8,7 +8,6 @@ export type RotationStrategy = 'smart' | 'quota_reset_priority' | 'round_robin'
|
||||
export interface RuntimeSettings {
|
||||
openaiClientProfile: ClientProfileSelection
|
||||
|
||||
disableFast: boolean
|
||||
requestLocationEnabled: boolean
|
||||
requestLocation: RequestLocation
|
||||
modelMappings: Record<string, string>
|
||||
|
||||
@@ -47,6 +47,6 @@ function updateGroup(groupId: string, selected: boolean) {
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="m-0 rounded-cp bg-cp-fill-quaternary px-3.5 py-3 text-cp-sm font-emphasis text-cp-text-secondary">
|
||||
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建。' }}
|
||||
{{ loading ? '正在加载分组...' : '暂无分组,请先在分组管理中创建' }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, shallowRef, useTemplateRef } from 'vue'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import markdownStyles from './markdown.css?inline'
|
||||
|
||||
const props = defineProps<{
|
||||
source?: string | null
|
||||
}>()
|
||||
|
||||
const host = useTemplateRef<HTMLElement>('host')
|
||||
const contentRoot = shallowRef<HTMLElement | null>(null)
|
||||
const html = computed(() => renderMarkdown(props.source))
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value)
|
||||
return
|
||||
|
||||
const shadowRoot = host.value.attachShadow({ mode: 'open' })
|
||||
const style = document.createElement('style')
|
||||
const content = document.createElement('div')
|
||||
style.textContent = markdownStyles
|
||||
shadowRoot.append(style, content)
|
||||
contentRoot.value = content
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="host" class="min-w-0">
|
||||
<Teleport v-if="contentRoot" :to="contentRoot">
|
||||
<div class="markdown-body" v-html="html" />
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
.markdown-body {
|
||||
color: var(--cp-color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--cp-font-size-sm);
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.markdown-body * {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
.markdown-body h4,
|
||||
.markdown-body h5,
|
||||
.markdown-body h6 {
|
||||
margin: 12px 0 6px;
|
||||
color: var(--cp-color-text);
|
||||
font-size: inherit;
|
||||
font-weight: var(--font-weight-heavy);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.markdown-body p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.markdown-body ul,
|
||||
.markdown-body ol {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-body ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-body ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-body li {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.markdown-body a {
|
||||
color: var(--cp-color-link);
|
||||
font-weight: var(--font-weight-heavy);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.markdown-body a:hover {
|
||||
color: var(--cp-color-link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-body a:focus-visible {
|
||||
border-radius: var(--cp-border-radius-sm);
|
||||
outline: 2px solid var(--cp-control-outline);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.markdown-body code {
|
||||
border-radius: var(--cp-border-radius-sm);
|
||||
background: var(--cp-color-fill-tertiary);
|
||||
color: var(--cp-color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--cp-font-size-xs);
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.markdown-body pre {
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--cp-border-radius);
|
||||
background: var(--cp-color-bg-container);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.markdown-body pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.markdown-body blockquote {
|
||||
margin: 8px 0;
|
||||
border-left: 3px solid var(--cp-color-split);
|
||||
color: var(--cp-color-text-secondary);
|
||||
padding-left: 10px;
|
||||
}
|
||||
+1
-1
@@ -7,7 +7,7 @@ const marked = new Marked({
|
||||
gfm: true,
|
||||
})
|
||||
|
||||
export function renderReleaseNotes(source?: string | null) {
|
||||
export function renderMarkdown(source?: string | null) {
|
||||
const value = source?.trim()
|
||||
if (!value)
|
||||
return ''
|
||||
@@ -102,10 +102,17 @@ function closeModal() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function focusableElements() {
|
||||
return Array.from(panel.value?.querySelectorAll<HTMLElement>(focusableSelector) ?? []).filter(
|
||||
element => !element.hidden,
|
||||
)
|
||||
function focusableElements(root: ParentNode | null = panel.value): HTMLElement[] {
|
||||
if (!root)
|
||||
return []
|
||||
|
||||
const elements: HTMLElement[] = []
|
||||
for (const element of root.children) {
|
||||
if (element instanceof HTMLElement && element.matches(focusableSelector) && !element.hidden)
|
||||
elements.push(element)
|
||||
elements.push(...focusableElements(element.shadowRoot ?? element))
|
||||
}
|
||||
return elements
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
@@ -125,17 +132,20 @@ function handleKeydown(event: KeyboardEvent) {
|
||||
}
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
let activeElement = document.activeElement
|
||||
while (activeElement?.shadowRoot?.activeElement)
|
||||
activeElement = activeElement.shadowRoot.activeElement
|
||||
if (!panel.value?.contains(document.activeElement)) {
|
||||
event.preventDefault()
|
||||
if (event.shiftKey)
|
||||
last?.focus()
|
||||
else first?.focus()
|
||||
}
|
||||
else if (event.shiftKey && document.activeElement === first) {
|
||||
else if (event.shiftKey && activeElement === first) {
|
||||
event.preventDefault()
|
||||
last?.focus()
|
||||
}
|
||||
else if (!event.shiftKey && document.activeElement === last) {
|
||||
else if (!event.shiftKey && activeElement === last) {
|
||||
event.preventDefault()
|
||||
first?.focus()
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ const tableRef = useTemplateRef<HTMLTableElement>('table')
|
||||
const horizontalScrolled = shallowRef(false)
|
||||
const horizontalCanScrollRight = shallowRef(false)
|
||||
|
||||
function measureHorizontalScroll() {
|
||||
function updateScrollLayout() {
|
||||
scrollbarRef.value?.update()
|
||||
const wrap = scrollbarRef.value?.wrapRef
|
||||
if (!wrap) {
|
||||
horizontalScrolled.value = false
|
||||
@@ -78,12 +79,12 @@ function handleTableScroll(payload: { scrollTop: number, scrollLeft: number }) {
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScroll()
|
||||
updateScrollLayout()
|
||||
})
|
||||
useResizeObserver(() => [scrollbarRef.value?.wrapRef, tableRef.value].filter(Boolean), measureHorizontalScroll)
|
||||
useResizeObserver(() => [scrollbarRef.value?.wrapRef, tableRef.value].filter(Boolean), updateScrollLayout)
|
||||
watch([() => displayRows.value.length, () => props.columns], async () => {
|
||||
await nextTick()
|
||||
measureHorizontalScroll()
|
||||
updateScrollLayout()
|
||||
})
|
||||
|
||||
const headerRowClass = computed(() => [
|
||||
|
||||
@@ -7,10 +7,12 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseSelect from '@/components/base/BaseSelect.vue'
|
||||
import BaseSkeleton from '@/components/base/BaseSkeleton.vue'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
import { formatDateTime } from '@/utils/date'
|
||||
|
||||
withDefaults(defineProps<{ disabled?: boolean, allowInherit?: boolean }>(), {
|
||||
const props = withDefaults(defineProps<{ active?: boolean, disabled?: boolean, allowInherit?: boolean }>(), {
|
||||
active: true,
|
||||
disabled: false,
|
||||
allowInherit: false,
|
||||
})
|
||||
@@ -22,7 +24,6 @@ const loading = shallowRef(true)
|
||||
const loadError = shallowRef('')
|
||||
const previewError = shallowRef('')
|
||||
const previewing = shallowRef(false)
|
||||
const custom = shallowRef(false)
|
||||
const platforms = { macos: 'MacOS', linux: 'Linux', windows: 'Windows' }
|
||||
const presetOptions = computed(() => presets.value.map(({ configuration }) => ({
|
||||
value: `${configuration.platform}-${configuration.client}`,
|
||||
@@ -46,10 +47,8 @@ const selectedPreset = computed({
|
||||
get: () => model.value ? `${model.value.platform}-${model.value.client}` : '',
|
||||
set: (value: string) => {
|
||||
const preset = presets.value.find(({ configuration }) => `${configuration.platform}-${configuration.client}` === value)
|
||||
if (preset) {
|
||||
if (preset)
|
||||
model.value = { ...preset.configuration, versionMode: preset.automaticAvailable ? 'latest' : 'fixed' }
|
||||
custom.value = false
|
||||
}
|
||||
},
|
||||
})
|
||||
const versionMode = computed({
|
||||
@@ -95,12 +94,12 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
watch(model, (configuration, _, onCleanup) => {
|
||||
watch([model, () => props.active], ([configuration, active], _, onCleanup) => {
|
||||
let cancelled = false
|
||||
preview.value = undefined
|
||||
previewError.value = ''
|
||||
previewing.value = !needsVersionInput.value
|
||||
if (needsVersionInput.value)
|
||||
previewing.value = active && !needsVersionInput.value
|
||||
if (!active || needsVersionInput.value)
|
||||
return
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
@@ -141,10 +140,10 @@ onMounted(load)
|
||||
<BaseSegmented
|
||||
v-if="allowInherit"
|
||||
v-model="profileSource"
|
||||
label="上游身份来源"
|
||||
label="客户端身份来源"
|
||||
class="justify-self-start"
|
||||
:options="[
|
||||
{ label: '通用设置', value: 'global' },
|
||||
{ label: '全局配置', value: 'global' },
|
||||
{ label: '独立配置', value: 'independent' },
|
||||
]"
|
||||
:disabled="disabled"
|
||||
@@ -154,13 +153,13 @@ onMounted(load)
|
||||
<BaseFormItem label="客户端预设">
|
||||
<BaseSelect v-model="selectedPreset" class="w-full" :options="presetOptions" :disabled="disabled" />
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="版本模式">
|
||||
<BaseFormItem label="版本策略">
|
||||
<BaseSelect
|
||||
v-model="versionMode"
|
||||
class="w-full"
|
||||
:options="[
|
||||
{ label: '自动最新', value: 'latest', disabled: !currentPreset?.automaticAvailable },
|
||||
{ label: '固定版本', value: 'fixed' },
|
||||
{ label: '跟随最新版本', value: 'latest', disabled: !currentPreset?.automaticAvailable },
|
||||
{ label: '自定义版本', value: 'fixed' },
|
||||
]"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
@@ -170,7 +169,7 @@ onMounted(load)
|
||||
{{ currentPreset.reason }}
|
||||
</p>
|
||||
<div v-if="model.versionMode === 'fixed'" class="grid gap-4 sm:grid-cols-2">
|
||||
<BaseFormItem label="Core 版本" required>
|
||||
<BaseFormItem label="Codex Core 版本" required>
|
||||
<BaseInput :model-value="model.codexVersion ?? ''" :disabled="disabled" placeholder="例如 0.155.0" @update:model-value="updateField('codexVersion', $event)" />
|
||||
</BaseFormItem>
|
||||
<template v-if="model.client === 'desktop'">
|
||||
@@ -181,38 +180,36 @@ onMounted(load)
|
||||
<BaseInput :model-value="model.desktopBuild ?? ''" :disabled="disabled" placeholder="填写该制品的构建号" @update:model-value="updateField('desktopBuild', $event)" />
|
||||
</BaseFormItem>
|
||||
</template>
|
||||
<BaseFormItem v-for="field in customFields" :key="field.key" :label="field.label">
|
||||
<BaseInput
|
||||
:model-value="model[field.key] ?? ''"
|
||||
:placeholder="currentPreset?.defaults[field.key] ?? ''"
|
||||
:disabled="disabled"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
</div>
|
||||
<details :open="custom" @toggle="custom = ($event.target as HTMLDetailsElement).open">
|
||||
<summary class="cursor-pointer text-cp-sm text-cp-text-secondary">
|
||||
自定义身份参数
|
||||
</summary>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<BaseFormItem v-for="field in customFields" :key="field.key" :label="field.label">
|
||||
<BaseInput
|
||||
:model-value="model[field.key] ?? ''"
|
||||
:placeholder="currentPreset?.defaults[field.key] ?? ''"
|
||||
:disabled="disabled"
|
||||
@update:model-value="updateField(field.key, $event)"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
</template>
|
||||
<div class="grid min-w-0 gap-2 rounded-cp bg-cp-fill-quaternary p-4" aria-live="polite">
|
||||
<div class="grid min-h-20 min-w-0 content-start gap-2 rounded-cp bg-cp-fill-quaternary p-4" aria-live="polite" :aria-busy="previewing">
|
||||
<p v-if="needsVersionInput" class="m-0 text-cp-sm text-cp-text-tertiary">
|
||||
填写版本后预览
|
||||
</p>
|
||||
<p v-else-if="previewing" class="m-0 text-cp-sm text-cp-text-tertiary">
|
||||
正在解析身份…
|
||||
</p>
|
||||
<div v-else-if="previewing" class="grid gap-2" role="status" aria-label="正在解析客户端身份">
|
||||
<div class="flex h-lh items-center text-cp-sm" aria-hidden="true">
|
||||
<BaseSkeleton shape="text" class="w-4/5" />
|
||||
</div>
|
||||
<div class="flex h-lh items-center text-cp-xs" aria-hidden="true">
|
||||
<BaseSkeleton shape="text" class="w-52 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="previewError" role="alert" class="m-0 text-cp-sm text-cp-error">
|
||||
{{ previewError }}
|
||||
</p>
|
||||
<template v-else-if="preview">
|
||||
<code class="break-all text-cp-sm text-cp-text">{{ preview.userAgent }}</code>
|
||||
<p class="m-0 text-cp-xs text-cp-text-tertiary">
|
||||
{{ preview.versionSource === 'custom' ? '固定版本' : '自动更新' }}
|
||||
{{ preview.versionSource === 'custom' ? '自定义版本' : '自动更新' }}
|
||||
<template v-if="preview.versionSource === 'official'">
|
||||
· {{ preview.checkedAt ? `检查于 ${formatDateTime(preview.checkedAt)}` : '待检查' }}
|
||||
</template>
|
||||
|
||||
@@ -14,13 +14,13 @@ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseMarkdown from '@/components/base/BaseMarkdown/index.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { normalizeSystemVersion, useSystemUpdateStore } from '@/stores/modules/system-update'
|
||||
import { errorMessage } from '@/utils/async'
|
||||
import { formatTime } from '@/utils/date'
|
||||
import { renderReleaseNotes } from './markdown'
|
||||
import {
|
||||
resolveSystemUpdateLogClasses,
|
||||
resolveSystemUpdatePresentation,
|
||||
@@ -77,7 +77,7 @@ const updateLogRows = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const renderedReleaseNotes = computed(() => renderReleaseNotes(updateInfo.value?.notes))
|
||||
const hasReleaseNotes = computed(() => Boolean(updateInfo.value?.notes?.trim()))
|
||||
|
||||
const showUpdateProgress = computed(
|
||||
() => hasUpdate.value || updating.value || restarting.value || updateLogRows.value.length > 0,
|
||||
@@ -256,7 +256,7 @@ watch(
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="renderedReleaseNotes"
|
||||
v-if="hasReleaseNotes"
|
||||
class="grid gap-2 rounded-cp-card bg-cp-fill-quaternary px-4 py-3.5"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -269,7 +269,7 @@ watch(
|
||||
</div>
|
||||
<BaseScrollbar class="-mx-4" max-height="160px">
|
||||
<div class="px-4">
|
||||
<div class="release-notes" v-html="renderedReleaseNotes" />
|
||||
<BaseMarkdown :source="updateInfo?.notes" />
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
</section>
|
||||
@@ -401,89 +401,3 @@ watch(
|
||||
</div>
|
||||
</BaseConfirmModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-notes {
|
||||
color: var(--cp-color-text);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
line-height: 1.65;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.release-notes :deep(*) {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.release-notes :deep(:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.release-notes :deep(:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.release-notes :deep(h1),
|
||||
.release-notes :deep(h2),
|
||||
.release-notes :deep(h3),
|
||||
.release-notes :deep(h4) {
|
||||
margin: 12px 0 6px;
|
||||
color: var(--cp-color-text);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-heavy);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.release-notes :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.release-notes :deep(ul),
|
||||
.release-notes :deep(ol) {
|
||||
margin: 0 0 8px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.release-notes :deep(li) {
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.release-notes :deep(a) {
|
||||
color: var(--cp-color-link);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.release-notes :deep(a:hover) {
|
||||
color: var(--cp-color-link-hover);
|
||||
}
|
||||
|
||||
.release-notes :deep(code) {
|
||||
border-radius: 5px;
|
||||
background: var(--cp-color-fill-tertiary);
|
||||
color: var(--cp-color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
|
||||
.release-notes :deep(pre) {
|
||||
margin: 8px 0;
|
||||
overflow-x: auto;
|
||||
border-radius: var(--cp-border-radius);
|
||||
background: var(--cp-color-bg-container);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.release-notes :deep(pre code) {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.release-notes :deep(blockquote) {
|
||||
margin: 8px 0;
|
||||
border-left: 3px solid var(--cp-color-split);
|
||||
color: var(--cp-color-text-secondary);
|
||||
padding-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,6 +55,16 @@ export const routes: RouteRecordRaw[] = [
|
||||
name: 'settings',
|
||||
component: () => import('@/views/settings/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/upstream',
|
||||
name: 'settings-upstream',
|
||||
component: () => import('@/views/settings/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/access',
|
||||
name: 'settings-access',
|
||||
component: () => import('@/views/settings/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'settings/backup',
|
||||
name: 'settings-backup',
|
||||
|
||||
@@ -43,7 +43,7 @@ const form = defineModel<AccountCreateForm>({ required: true })
|
||||
v-model="form.notes"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
placeholder="最多 500 字,可不填。"
|
||||
placeholder="最多 500 字,可不填"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -84,7 +84,7 @@ function resolveModal(
|
||||
else if (input.form.mode === 'oauth')
|
||||
description = '通过浏览器授权导入 OpenAI 账号'
|
||||
else if (input.form.mode === 'access_token')
|
||||
description = '逐行粘贴 Access Token;未包含 Refresh Token 时无法自动续期'
|
||||
description = '逐行粘贴 Access Token,未包含 Refresh Token 时无法自动续期'
|
||||
else if (input.form.mode === 'refresh_token')
|
||||
description = '逐行粘贴 Refresh Token,导入时将自动换取 Access Token'
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ const selectedGroupIds = defineModel<string[]>('selectedGroupIds', { required: t
|
||||
v-model="notes"
|
||||
:rows="3"
|
||||
:maxlength="500"
|
||||
placeholder="最多 500 字,留空可清除备注。"
|
||||
placeholder="最多 500 字,留空可清除备注"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -47,7 +47,7 @@ const monthLabels = computed(() =>
|
||||
<BaseEmpty
|
||||
v-if="dailyUsage === null"
|
||||
title="暂无 Token 活动"
|
||||
description="本次未获取到每日 Token 活动。"
|
||||
description="本次未获取到每日 Token 活动"
|
||||
size="sm"
|
||||
surface="none"
|
||||
/>
|
||||
|
||||
@@ -47,7 +47,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo(a
|
||||
<BaseEmpty
|
||||
v-if="profile.hasStatsError"
|
||||
title="个人统计暂不可用"
|
||||
description="本次未获取到统计数据,可以点击刷新信息重试。"
|
||||
description="本次未获取到统计数据,可以点击刷新信息重试"
|
||||
:icon="TriangleAlert"
|
||||
surface="none"
|
||||
/>
|
||||
@@ -56,7 +56,7 @@ const { profile, subscription, loading, error, load } = useAccountPersonalInfo(a
|
||||
<AccountProfileActivityInsights :insights="profile.activityInsights" />
|
||||
</template>
|
||||
<p v-if="error" role="status" class="m-0 text-cp-sm text-cp-warning">
|
||||
本次信息刷新未完成,保留已获取的结果。
|
||||
本次信息刷新未完成,保留已获取的结果
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -29,13 +29,13 @@ const { report, loading, refreshing, error, load, refresh } = useAccountQuotaFor
|
||||
)
|
||||
const { now, pause, resume } = useNow({ interval: 30_000, controls: true })
|
||||
const options = [
|
||||
{ label: '周额度', value: 'weekly' },
|
||||
{ label: '7日额度', value: 'weekly' },
|
||||
{ label: '月额度', value: 'monthly' },
|
||||
]
|
||||
const forecast = computed(() => report.value?.forecasts.find(item => item.period === period.value))
|
||||
const unavailableReason = computed(() => {
|
||||
if (forecast.value?.source && new Date(forecast.value.source.resetAt) <= now.value)
|
||||
return '额度窗口已过期,请刷新账号额度后重试。'
|
||||
return '额度窗口已过期,请刷新账号额度后重试'
|
||||
return forecast.value?.unavailableReason ?? null
|
||||
})
|
||||
|
||||
@@ -85,7 +85,7 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
<div aria-live="polite" :aria-busy="loading || refreshing">
|
||||
<ForecastSkeleton v-if="loading || refreshing" />
|
||||
<div v-else-if="error && !report" class="grid rounded-cp-card bg-cp-fill-tertiary/70 [html[data-theme=light]_&]:bg-cp-fill-quaternary/70">
|
||||
<BaseEmpty title="预测加载失败" description="暂时无法取得预测数据,请重新加载。" surface="none" class="min-h-80 content-center">
|
||||
<BaseEmpty title="预测加载失败" description="暂时无法取得预测数据,请重新加载" surface="none" class="min-h-80 content-center">
|
||||
<template #action>
|
||||
<BaseButton variant="secondary" @click="load">
|
||||
重新加载
|
||||
@@ -122,16 +122,16 @@ function handleExplanationKeydown(event: KeyboardEvent) {
|
||||
仅供参考
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
按已记录数据估算,缺失的用量或费用可能使结果偏低。结果会随使用的模型和方式变化,并非官方承诺额度。
|
||||
按已记录数据估算,缺失的用量或费用可能使结果偏低,结果会随使用的模型和方式变化,并非官方承诺额度
|
||||
</p>
|
||||
<p v-if="forecast?.lowSample" class="m-0">
|
||||
目前数据还较少,结果可能有较大波动。
|
||||
目前数据还较少,结果可能有较大波动
|
||||
</p>
|
||||
<p v-if="forecast?.extrapolated" class="m-0">
|
||||
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算。
|
||||
本页预测为折算值,剩余量仍按{{ forecast.source?.label ?? '当前周期' }}计算
|
||||
</p>
|
||||
<p class="m-0">
|
||||
剩余量以数据更新时间为准;等价费用不是实际账单或账户余额。
|
||||
剩余量以数据更新时间为准,等价费用不是实际账单或账户余额
|
||||
</p>
|
||||
</section>
|
||||
</BasePopover>
|
||||
|
||||
@@ -156,7 +156,7 @@ function handleRequestConsume(creditId: string) {
|
||||
上次操作结果待确认
|
||||
</p>
|
||||
<p class="mt-1 mb-0 text-cp-xs leading-normal font-emphasis text-cp-text-secondary">
|
||||
请继续确认上次重置结果。
|
||||
请继续确认上次重置结果
|
||||
</p>
|
||||
</div>
|
||||
<BaseButton size="sm" variant="soft" :disabled="loading || consuming || !canRequestConsume" @click="requestConsume">
|
||||
@@ -200,7 +200,7 @@ function handleRequestConsume(creditId: string) {
|
||||
class="mx-4 mt-0 mb-4 rounded-cp bg-cp-error-container px-4 py-3 text-cp-xs leading-normal font-emphasis text-cp-error-on-container"
|
||||
role="status"
|
||||
>
|
||||
{{ loadError }},请刷新重试。
|
||||
{{ loadError }},请刷新重试
|
||||
</p>
|
||||
<p
|
||||
v-else-if="loading && !hasSnapshot"
|
||||
|
||||
@@ -40,7 +40,7 @@ const additionalEntryCount = computed(() => Math.max(summaryEntries.value.length
|
||||
</span>
|
||||
<div class="grid min-w-0 gap-1.5">
|
||||
<span class="text-[10px] leading-3 font-bold text-cp-text-quaternary">{{ account.usage.windowLabelDisplay }}</span>
|
||||
<div class="h-1 w-full rounded-full bg-cp-success" title="上游额度未提供;绿色条不表示剩余额度" aria-hidden="true" />
|
||||
<div class="h-1 w-full rounded-full bg-cp-success" title="上游额度未提供,绿色条不表示剩余额度" aria-hidden="true" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="summaryEntries.length > 0">
|
||||
|
||||
@@ -90,53 +90,53 @@ const displayDefinitions: Record<AccountStatusDisplayMode, AccountStatusDisplayD
|
||||
tone: 'warning',
|
||||
label: '退避中',
|
||||
title: 'OAuth 刷新退避',
|
||||
description: '刷新失败,系统正在等待下一次自动尝试。',
|
||||
recoveryHint: '系统会在计划时间自动重试。',
|
||||
description: '刷新失败,系统正在等待下一次自动尝试',
|
||||
recoveryHint: '系统会在计划时间自动重试',
|
||||
icon: Timer,
|
||||
},
|
||||
normal: {
|
||||
tone: statusTones.normal,
|
||||
label: statusLabels.normal,
|
||||
description: '该账号当前状态需要关注。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
description: '该账号当前状态需要关注',
|
||||
recoveryHint: '重新测试连接以获取最新状态',
|
||||
icon: CircleCheck,
|
||||
},
|
||||
quota_exhausted: {
|
||||
tone: statusTones.quota_exhausted,
|
||||
label: statusLabels.quota_exhausted,
|
||||
description: '该账号当前状态需要关注。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
description: '该账号当前状态需要关注',
|
||||
recoveryHint: '重新测试连接以获取最新状态',
|
||||
icon: Gauge,
|
||||
},
|
||||
rate_limited: {
|
||||
tone: statusTones.rate_limited,
|
||||
label: statusLabels.rate_limited,
|
||||
description: '上游暂时限制了请求频率,系统正在冷却该账号。',
|
||||
recoveryHint: '冷却结束后,系统会自动恢复调度。',
|
||||
description: '上游暂时限制了请求频率,系统正在冷却该账号',
|
||||
recoveryHint: '冷却结束后,系统会自动恢复调度',
|
||||
icon: Timer,
|
||||
},
|
||||
disabled: {
|
||||
tone: statusTones.disabled,
|
||||
label: statusLabels.disabled,
|
||||
description: '该账号当前状态需要关注。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
description: '该账号当前状态需要关注',
|
||||
recoveryHint: '重新测试连接以获取最新状态',
|
||||
icon: Power,
|
||||
},
|
||||
error: {
|
||||
tone: statusTones.error,
|
||||
label: statusLabels.error,
|
||||
description: '该账号暂不参与调度,处理凭据后可重新测试连接。',
|
||||
recoveryHint: '重新测试连接以获取最新状态。',
|
||||
description: '该账号暂不参与调度,处理凭据后可重新测试连接',
|
||||
recoveryHint: '重新测试连接以获取最新状态',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
}
|
||||
|
||||
const errorRecoveryHints: Record<AccountErrorReason, string> = {
|
||||
account_unverified: '重新授权后,账号会重新参与调度。',
|
||||
access_token_expired: '重新授权后,账号会重新参与调度。',
|
||||
credential_expired: '重新授权后,账号会重新参与调度。',
|
||||
credential_invalid: '请更新或重新导入凭据,再次测试连接。',
|
||||
account_banned: '请确认上游账号状态,解除限制后再启用。',
|
||||
account_unverified: '重新授权后,账号会重新参与调度',
|
||||
access_token_expired: '重新授权后,账号会重新参与调度',
|
||||
credential_expired: '重新授权后,账号会重新参与调度',
|
||||
credential_invalid: '请更新或重新导入凭据,再次测试连接',
|
||||
account_banned: '请确认上游账号状态,解除限制后再启用',
|
||||
}
|
||||
|
||||
export function resolveAccountStatusPresentation(
|
||||
@@ -154,10 +154,10 @@ export function resolveAccountStatusPresentation(
|
||||
const definition = isFreeze
|
||||
? {
|
||||
...displayDefinitions.rate_limited,
|
||||
description: '容量类请求失败累计达到阈值,系统已暂停该账号的调度。',
|
||||
description: '容量类请求失败累计达到阈值,系统已暂停该账号的调度',
|
||||
recoveryHint: waitsForProbe
|
||||
? '冷却结束后进行恢复探测,成功后恢复调度;也可手动恢复账号。'
|
||||
: '冷却结束后自动恢复调度,也可手动恢复账号。',
|
||||
? '冷却结束后进行恢复探测,成功后恢复调度,也可手动恢复账号'
|
||||
: '冷却结束后自动恢复调度,也可手动恢复账号',
|
||||
}
|
||||
: displayDefinitions[mode]
|
||||
const nextRefreshDisplay = isBackoff ? formatDateTime(nextRefreshTimestamp) : null
|
||||
@@ -175,8 +175,8 @@ export function resolveAccountStatusPresentation(
|
||||
? errorRecoveryHints[input.errorReason]
|
||||
: definition.recoveryHint
|
||||
const hasDetail = input.status === 'error' || mode === 'rate_limited' || isBackoff
|
||||
const retry = nextRefreshDisplay ? ` 下次尝试:${nextRefreshDisplay}。` : ''
|
||||
const triggerLabel = `${title}。${definition.description}${retry} 点击或聚焦查看详情。`
|
||||
const retry = nextRefreshDisplay ? `,下次尝试:${nextRefreshDisplay}` : ''
|
||||
const triggerLabel = `${title},${definition.description}${retry},点击或聚焦查看详情`
|
||||
|
||||
return {
|
||||
mode,
|
||||
|
||||
@@ -223,7 +223,7 @@ export function useAccountResetCredits(options: {
|
||||
...operation,
|
||||
hasTransportFailure: true,
|
||||
}
|
||||
toast.warning('消费结果暂不确定;重试会复用同一个请求标识', { duration: 5000 })
|
||||
toast.warning('消费结果暂不确定,重试会复用同一个请求标识', { duration: 5000 })
|
||||
}
|
||||
else {
|
||||
target.pendingOperation = null
|
||||
|
||||
@@ -126,7 +126,7 @@ const metricItems = outcomeMeta
|
||||
</div>
|
||||
|
||||
<p class="m-0 border-t border-cp-split pt-3 text-[10px] leading-[1.45] font-emphasis text-cp-text-quaternary">
|
||||
客户端取消与调用方错误单独记录,不计入可用性。
|
||||
客户端取消与调用方错误单独记录,不计入可用性
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -9,7 +9,7 @@ import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseSwitch from '@/components/base/BaseSwitch.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseTextarea from '@/components/base/BaseTextarea.vue'
|
||||
import { ACCOUNT_GROUP_COLOR_PRESETS } from '../constants'
|
||||
|
||||
@@ -24,8 +24,8 @@ const open = defineModel<boolean>({ required: true })
|
||||
const form = defineModel<AccountGroupFormValue>('form', { required: true })
|
||||
const title = computed(() => props.group ? '编辑分组' : '创建分组')
|
||||
const description = computed(() => props.group
|
||||
? '修改分组名称、用途说明和 Fast 限制。'
|
||||
: '创建后,可在账号管理中将账号加入这个分组。')
|
||||
? '修改分组名称、用途说明和 Fast 模式'
|
||||
: '创建后,可在账号管理中将账号加入这个分组')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -53,6 +53,19 @@ const description = computed(() => props.group
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="Fast 模式" description="关闭后按标准模式处理">
|
||||
<BaseSegmented
|
||||
:model-value="form.disableFast ? 'disabled' : 'default'"
|
||||
class="w-48 max-w-full"
|
||||
label="Fast 模式"
|
||||
:options="[
|
||||
{ label: '默认', value: 'default' },
|
||||
{ label: '关闭', value: 'disabled' },
|
||||
]"
|
||||
:disabled="saving"
|
||||
@update:model-value="form.disableFast = $event === 'disabled'"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="描述(可选)">
|
||||
<BaseTextarea
|
||||
v-model="form.description"
|
||||
@@ -62,14 +75,6 @@ const description = computed(() => props.group
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem description="开启后,绑定本分组的密钥将以普通模式处理 Fast 请求。任一绑定分组或全局关闭 Fast 时,限制均生效。">
|
||||
<BaseSwitch
|
||||
v-model="form.disableFast"
|
||||
label="关闭 Fast"
|
||||
show-label
|
||||
:disabled="saving"
|
||||
/>
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
|
||||
<template #footer>
|
||||
|
||||
@@ -187,14 +187,14 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
@confirm="confirmBatchDelete"
|
||||
>
|
||||
<p class="m-0">
|
||||
确定删除选中的 {{ selectedIds.size }} 个分组吗?账号本身不会被删除。
|
||||
确定删除选中的 {{ selectedIds.size }} 个分组吗?账号本身不会被删除
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
|
||||
<BaseConfirmModal
|
||||
v-model="showDisableModal"
|
||||
title="禁用账号分组"
|
||||
description="禁用后,使用该分组的 API 密钥将无法再使用其中的账号。"
|
||||
description="禁用后,使用该分组的 API 密钥将无法再使用其中的账号"
|
||||
confirm-text="确认禁用"
|
||||
:loading="disabling"
|
||||
@confirm="confirmDisable"
|
||||
@@ -206,7 +206,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
将影响 {{ referencedKeyNames.length }} 个 API 密钥:{{ referencedKeyNames.join('、') }}
|
||||
</p>
|
||||
<p v-else-if="pendingDisableGroup?.clientKeyCount" class="mt-2 mb-0 text-cp-warning-text">
|
||||
将影响 {{ pendingDisableGroup.clientKeyCount }} 个 API 密钥。
|
||||
将影响 {{ pendingDisableGroup.clientKeyCount }} 个 API 密钥
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
|
||||
@@ -220,7 +220,7 @@ const { allSelected, indeterminate, selectedRowKeys, toggleSelection, toggleAll
|
||||
@confirm="confirmDelete"
|
||||
>
|
||||
<p class="m-0">
|
||||
确定删除“{{ pendingDeleteGroup?.name || '该分组' }}”吗?账号本身不会被删除。
|
||||
确定删除“{{ pendingDeleteGroup?.name || '该分组' }}”吗?账号本身不会被删除
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { keyUsageTime, money } from '../utils/format'
|
||||
const props = defineProps<{ budget: KeyUsageBudget }>()
|
||||
const windows = computed(() => [
|
||||
{ label: '今日额度', limit: props.budget.dailyLimitUsd, used: props.budget.dailyUsedUsd, reset: props.budget.dailyResetsAt },
|
||||
{ label: '七日额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt },
|
||||
{ label: '7日额度', limit: props.budget.weeklyLimitUsd, used: props.budget.weeklyUsedUsd, reset: props.budget.weeklyResetsAt },
|
||||
].map(window => ({
|
||||
...window,
|
||||
limited: Number(window.limit) > 0,
|
||||
|
||||
@@ -39,7 +39,7 @@ const columns = computed(() => defineTableColumns<KeyUsageRecord>([
|
||||
<BaseSegmented v-model="kind" label="请求结果" :options="[{ label: '成功请求', value: 'success' }, { label: '错误记录', value: 'error' }]" />
|
||||
</template>
|
||||
<p v-if="error || stale" role="status" class="mt-0 mb-3 text-cp-sm text-cp-error-text">
|
||||
{{ error || '请求日志刷新失败,暂时保留上次结果。' }}
|
||||
{{ error || '请求日志刷新失败,暂时保留上次结果' }}
|
||||
</p>
|
||||
<div class="flex h-120 min-h-0 overflow-hidden">
|
||||
<BaseTable class="min-w-0 flex-1" :columns="columns" :rows="rows" :loading="loading" scrollbar-always-visible :empty-text="error ? '请求日志加载失败,请点击顶部刷新重试' : '所选条件下暂无记录'">
|
||||
|
||||
@@ -56,7 +56,7 @@ const metrics = [
|
||||
<BaseCard title="额度概览" aria-busy="true">
|
||||
<div class="flex flex-1 flex-col justify-between gap-6" aria-hidden="true">
|
||||
<div class="grid flex-1 gap-6 sm:grid-cols-2">
|
||||
<div v-for="label in ['今日额度', '七日额度']" :key="label" class="flex min-w-0 flex-col justify-between gap-4">
|
||||
<div v-for="label in ['今日额度', '7日额度']" :key="label" class="flex min-w-0 flex-col justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-cp-sm text-cp-text-secondary">
|
||||
{{ label }}
|
||||
|
||||
@@ -30,7 +30,7 @@ const { items, currentPage, pageSize, total, loading: recordsLoading, error: rec
|
||||
</BaseInput>
|
||||
</div>
|
||||
<p v-if="overviewError" role="alert" class="m-0 rounded-cp-lg bg-cp-error-container px-4 py-3 text-cp-sm text-cp-error-text">
|
||||
{{ overviewError }}{{ overview ? ',暂时保留上次结果。' : ',请点击顶部刷新重试。' }}
|
||||
{{ overviewError }}{{ overview ? ',暂时保留上次结果' : ',请点击顶部刷新重试' }}
|
||||
</p>
|
||||
<template v-if="overview">
|
||||
<KeyUsageSummary :summary="overview.summary" />
|
||||
|
||||
@@ -7,7 +7,7 @@ import { formatDateTime } from '@/utils/date'
|
||||
const props = defineProps<{ apiKey: ApiKey }>()
|
||||
const windows = computed(() => [
|
||||
{ label: '日', heading: '日用量', used: props.apiKey.dailyUsedUsd, limit: props.apiKey.dailyLimitUsd, reset: props.apiKey.dailyResetsAt },
|
||||
{ label: '周', heading: '周用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
|
||||
{ label: '7日', heading: '7日用量', used: props.apiKey.weeklyUsedUsd, limit: props.apiKey.weeklyLimitUsd, reset: props.apiKey.weeklyResetsAt },
|
||||
])
|
||||
function amount(value: string) {
|
||||
// 列表最多显示两位小数,不补末尾零;明细保留原始金额的全部精度。
|
||||
@@ -26,7 +26,7 @@ function amount(value: string) {
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<span v-for="window in windows" :key="window.label" class="flex min-w-0 items-center gap-2">
|
||||
<span class="shrink-0 text-cp-text-tertiary">{{ window.label }}</span>
|
||||
<span class="w-6 shrink-0 text-cp-text-tertiary">{{ window.label }}</span>
|
||||
<span class="truncate" :class="Number(window.limit) > 0 && Number(window.used) >= Number(window.limit) ? 'text-cp-error' : 'text-cp-text'">
|
||||
${{ amount(window.used) }} / {{ Number(window.limit) === 0 ? '∞' : `$${amount(window.limit)}` }}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { ApiKey } from '@/api'
|
||||
import { shallowRef, watch } from 'vue'
|
||||
import { ArrowRight } from '@lucide/vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import { resetApiKeyBudget } from '@/api'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import { toast } from '@/components/base/BaseToast'
|
||||
import { useAsyncAction } from '@/composables/useAsyncAction'
|
||||
@@ -15,8 +17,14 @@ const { loading, run } = useAsyncAction()
|
||||
const periods = [
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '日额度', value: 'daily' },
|
||||
{ label: '周额度', value: 'weekly' },
|
||||
{ label: '7日额度', value: 'weekly' },
|
||||
]
|
||||
const windows = computed(() => props.apiKey
|
||||
? [
|
||||
{ value: 'daily', label: '日额度', used: props.apiKey.dailyUsedUsd },
|
||||
{ value: 'weekly', label: '7日额度', used: props.apiKey.weeklyUsedUsd },
|
||||
].map(window => ({ ...window, selected: period.value === 'all' || period.value === window.value }))
|
||||
: [])
|
||||
|
||||
watch(open, (value) => {
|
||||
if (value)
|
||||
@@ -38,23 +46,56 @@ async function resetBudget() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseConfirmModal
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
title="重置已用额度"
|
||||
description="选择要清零的日额度、周额度或全部额度"
|
||||
confirm-text="确认重置"
|
||||
:loading="loading"
|
||||
:confirm-disabled="!apiKey"
|
||||
@confirm="resetBudget"
|
||||
description="清零所选周期的已用金额,原重置时间保持不变"
|
||||
size="sm"
|
||||
:dismissible="!loading"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<p class="m-0 break-words text-cp-text">
|
||||
{{ apiKey?.name }}
|
||||
</p>
|
||||
<BaseSegmented v-model="period" class="w-full" label="重置范围" :options="periods" :disabled="loading" />
|
||||
<p class="m-0 text-cp leading-relaxed text-cp-text-secondary">
|
||||
限额上限、原重置时间和历史使用记录保持不变,正在进行的请求会在完成后计入额度
|
||||
</p>
|
||||
<div class="grid gap-5">
|
||||
<div class="grid min-w-0 gap-1.5">
|
||||
<span class="text-cp-xs text-cp-text-quaternary">目标密钥</span>
|
||||
<p class="m-0 break-words text-cp text-cp-text">
|
||||
{{ apiKey?.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset class="m-0 min-w-0 border-0 p-0" :disabled="loading">
|
||||
<legend class="mb-2.5 p-0 text-cp-sm text-cp-text-secondary">
|
||||
重置范围
|
||||
</legend>
|
||||
<BaseSegmented v-model="period" class="w-full" label="重置范围" :options="periods" :disabled="loading" />
|
||||
</fieldset>
|
||||
|
||||
<div v-if="apiKey" class="rounded-cp bg-cp-fill-quaternary p-3.5" aria-live="polite" aria-atomic="true">
|
||||
<div class="mb-3 grid grid-cols-[3.5rem_minmax(0,1fr)_1rem_4.5rem] items-center gap-2 text-cp-xs text-cp-text-quaternary">
|
||||
<span class="col-span-2 text-right">当前已用</span>
|
||||
<span class="col-start-4 text-right">重置后</span>
|
||||
</div>
|
||||
<dl class="m-0 grid gap-3">
|
||||
<div v-for="window in windows" :key="window.value" class="grid grid-cols-[3.5rem_minmax(0,1fr)_1rem_4.5rem] items-center gap-2">
|
||||
<dt class="text-cp-sm text-cp-text-secondary">
|
||||
{{ window.label }}
|
||||
</dt>
|
||||
<dd class="col-span-3 m-0 grid grid-cols-subgrid items-center">
|
||||
<span class="min-w-0 break-all text-right font-mono text-cp-sm text-cp-text tabular-nums">${{ window.used }}</span>
|
||||
<ArrowRight class="size-3.5 text-cp-text-quaternary" aria-hidden="true" />
|
||||
<span v-if="window.selected" class="text-right font-mono text-cp-sm text-cp-primary-text tabular-nums">$0</span>
|
||||
<span v-else class="text-right text-cp-xs text-cp-text-quaternary">保持不变</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</BaseConfirmModal>
|
||||
|
||||
<template #footer>
|
||||
<BaseButton variant="secondary" :disabled="loading" @click="open = false">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="loading" :disabled="!apiKey" @click="resetBudget">
|
||||
确认重置
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
@@ -28,13 +28,14 @@ const emit = defineEmits<{
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
const createdOpen = defineModel<boolean>('createdOpen', { default: false })
|
||||
const form = defineModel<ApiKeyFormValue>('form', { required: true })
|
||||
const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key')
|
||||
const title = computed(() => props.editing ? '编辑密钥' : '创建 API Key')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
v-model="open"
|
||||
:title="title"
|
||||
description="配置密钥信息、分组与使用限制"
|
||||
tone="info"
|
||||
size="lg"
|
||||
:dismissible="!saving"
|
||||
@@ -106,13 +107,13 @@ const title = computed(() => props.editing ? '编辑 API Key' : '创建 API Key'
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="周限额">
|
||||
<BaseFormItem label="7日限额">
|
||||
<BaseInput
|
||||
v-model="form.weeklyLimitUsd"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
aria-label="周限额(美元)"
|
||||
aria-label="7日限额(美元)"
|
||||
placeholder="不限制"
|
||||
:disabled="saving"
|
||||
>
|
||||
|
||||
@@ -9,12 +9,12 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="truncate text-cp font-bold text-cp-text" :title="apiKey.name">
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<span class="truncate text-cp text-cp-text" :title="apiKey.name">
|
||||
{{ apiKey.name }}
|
||||
</span>
|
||||
<span v-if="apiKey.label" class="truncate text-cp-sm font-emphasis text-cp-text-tertiary" :title="apiKey.label">
|
||||
{{ apiKey.label }}
|
||||
<span class="truncate text-cp-xs font-emphasis text-cp-text-quaternary" :title="apiKey.label || undefined">
|
||||
{{ apiKey.label || '未填写备注' }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,7 +77,7 @@ const visibleFiles = computed(() => [
|
||||
</div>
|
||||
|
||||
<p class="text-cp-sm text-cp-text-secondary">
|
||||
配置含密钥,请勿分享,覆盖前请备份。
|
||||
配置含密钥,请勿分享,覆盖前请备份
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useApiKeyMutations(options: {
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['周限额', form.value.weeklyLimitUsd]]) {
|
||||
for (const [label, value] of [['日限额', form.value.dailyLimitUsd], ['7日限额', form.value.weeklyLimitUsd]]) {
|
||||
if (value.trim() && !/^\d{1,10}(?:\.\d{1,10})?$/.test(value.trim())) {
|
||||
toast.warning(`${label}必须是非负金额,最多 10 位小数`)
|
||||
return false
|
||||
|
||||
@@ -246,13 +246,13 @@ watch(
|
||||
<BaseConfirmModal
|
||||
v-model="showAllAccountsConfirm"
|
||||
title="授予全部账号权限"
|
||||
description="保存后,该密钥可以使用所有账号。"
|
||||
description="保存后,该密钥可以使用所有账号"
|
||||
confirm-text="确认授予全部账号"
|
||||
:loading="savingKey"
|
||||
@confirm="confirmAllAccountsScope"
|
||||
>
|
||||
<p class="m-0">
|
||||
该密钥可以使用所有账号,包括以后新增和未分组的账号。
|
||||
该密钥可以使用所有账号,包括以后新增和未分组的账号
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ watch([open, () => props.proxy?.id], () => {
|
||||
</BaseModal>
|
||||
<BaseConfirmModal v-model="showRemove" title="移除关联账号" confirm-text="移除" :loading="removing" @confirm="confirmRemove">
|
||||
<p class="m-0 break-words">
|
||||
将“{{ pendingRemove?.name }}”从“{{ proxy?.name }}”移除后,该账号将改为直连。
|
||||
将“{{ pendingRemove?.name }}”从“{{ proxy?.name }}”移除后,该账号将改为直连
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
</template>
|
||||
|
||||
@@ -29,8 +29,8 @@ const showSecret = shallowRef(false)
|
||||
const busy = computed(() => props.saving || props.testing)
|
||||
const title = computed(() => props.proxy ? '编辑代理' : '新增代理')
|
||||
const connectionDescription = computed(() => props.proxy
|
||||
? '留空保留当前连接和认证信息;填写新地址时,请包含所需的用户名和密码。'
|
||||
: '支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H,可在地址中包含用户名和密码。')
|
||||
? '留空保留当前连接和认证信息,填写新地址时,请包含所需的用户名和密码'
|
||||
: '支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H,可在地址中包含用户名和密码')
|
||||
|
||||
watch(open, () => {
|
||||
showSecret.value = false
|
||||
@@ -63,7 +63,7 @@ watch(open, () => {
|
||||
<BaseSwitch v-model="customLocation" label="自定义时区位置" show-label :disabled="busy" />
|
||||
<RequestLocationFields v-if="customLocation" v-model="location" :disabled="busy" />
|
||||
<p v-if="proxy?.accountCount && proxyUrl.trim()" class="m-0 text-cp-sm text-cp-warning-text">
|
||||
将更新 {{ proxy.accountCount }} 个关联账号的出口。
|
||||
将更新 {{ proxy.accountCount }} 个关联账号的出口
|
||||
</p>
|
||||
</BaseForm>
|
||||
<template #footer>
|
||||
|
||||
@@ -212,7 +212,7 @@ onMounted(() => void query.execute())
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<BaseTable class="min-h-0 flex-1" :columns="columns" :rows="proxies" :loading="loading" :empty-text="search.trim() ? '没有找到匹配的代理,请尝试其他名称' : '暂无代理,请点击新增代理添加'">
|
||||
<template #name="{ row }">
|
||||
<strong class="block truncate text-cp text-cp-text" :title="row.name">{{ row.name }}</strong>
|
||||
<span class="block truncate text-cp text-cp-text" :title="row.name">{{ row.name }}</span>
|
||||
</template>
|
||||
<template #address="{ row }">
|
||||
<div class="grid min-w-0 gap-1">
|
||||
|
||||
@@ -22,25 +22,25 @@ const adaptiveConcurrencyHintId = useId()
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
title="过载策略"
|
||||
description="上游繁忙或服务不可用时,使用过载策略保护"
|
||||
title="过载保护"
|
||||
description="上游容量类错误累计达到阈值时,暂停账号调度并进入冷却"
|
||||
>
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseSwitch
|
||||
v-model="enabled"
|
||||
class="col-span-full justify-self-start"
|
||||
label="启用策略"
|
||||
label="启用过载保护"
|
||||
show-label
|
||||
/>
|
||||
|
||||
<BaseFormItem
|
||||
label="触发阈值"
|
||||
description="窗口内累计失败尝试达到此次数后触发"
|
||||
label="失败次数阈值"
|
||||
description="统计窗口内累计容量类失败达到此次数后触发"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="threshold"
|
||||
:disabled="!enabled"
|
||||
aria-label="触发阈值"
|
||||
aria-label="失败次数阈值"
|
||||
type="number"
|
||||
min="2"
|
||||
max="1000"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LockKeyhole } from '@lucide/vue'
|
||||
import { shallowRef } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseMotionIcon from '@/components/base/BaseMotionIcon.vue'
|
||||
import ChangePasswordModal from './ChangePasswordModal.vue'
|
||||
|
||||
const open = shallowRef(false)
|
||||
@@ -13,14 +14,25 @@ const open = shallowRef(false)
|
||||
<template #actions>
|
||||
<BaseButton variant="secondary" @click="open = true">
|
||||
<template #icon>
|
||||
<LockKeyhole class="size-4" />
|
||||
<LockKeyhole class="size-4" aria-hidden="true" />
|
||||
</template>
|
||||
修改密码
|
||||
</BaseButton>
|
||||
</template>
|
||||
<p class="m-0 text-cp leading-relaxed text-cp-text-secondary">
|
||||
修改后,所有已登录的管理员都需要使用新密码重新登录。
|
||||
</p>
|
||||
|
||||
<div class="flex min-h-16 max-w-6xl items-center gap-3 rounded-cp bg-cp-fill-quaternary px-4 py-3">
|
||||
<BaseMotionIcon class="inline-flex size-9 shrink-0 items-center justify-center rounded-cp bg-cp-bg-container text-cp-primary-text shadow-cp-tertiary">
|
||||
<LockKeyhole class="size-4" aria-hidden="true" />
|
||||
</BaseMotionIcon>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-cp leading-[1.15] font-bold text-cp-text">
|
||||
验证当前密码
|
||||
</p>
|
||||
<p class="mt-1.5 mb-0 text-cp-sm leading-snug font-emphasis text-cp-text-secondary">
|
||||
修改后,所有管理员需重新登录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</BaseCard>
|
||||
<ChangePasswordModal v-model="open" />
|
||||
</template>
|
||||
|
||||
@@ -63,9 +63,6 @@ async function submit() {
|
||||
<FormItem label="确认新密码" :error="confirmationError" required>
|
||||
<BaseInput v-model="form.confirmation" type="password" autocomplete="new-password" placeholder="再次输入新密码" :disabled="loading" maxlength="1024" />
|
||||
</FormItem>
|
||||
<p class="m-0 rounded-cp bg-cp-fill-quaternary px-3 py-2.5 text-cp-sm leading-relaxed text-cp-text-secondary">
|
||||
保存后,当前及其他设备上的管理员登录都会失效,需要重新登录。
|
||||
</p>
|
||||
</form>
|
||||
<template #footer>
|
||||
<BaseButton variant="secondary" :disabled="loading" @click="open = false">
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseForm from '@/components/base/BaseForm/index.vue'
|
||||
import BaseSwitch from '@/components/base/BaseSwitch.vue'
|
||||
|
||||
defineProps<{ disabled: boolean }>()
|
||||
|
||||
const model = defineModel<boolean>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="服务档位" description="Fast 模式下的请求将按普通档处理,关闭后仍遵循分组限制">
|
||||
<BaseForm class="max-w-6xl">
|
||||
<BaseFormItem>
|
||||
<BaseSwitch v-model="model" label="关闭" show-label :disabled="disabled" />
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
</BaseCard>
|
||||
</template>
|
||||
@@ -25,7 +25,7 @@ const emit = defineEmits<{
|
||||
<template>
|
||||
<BaseCard
|
||||
title="模型映射"
|
||||
description="配置请求模型与上游模型的映射关系"
|
||||
description="将客户端请求的模型名称映射为实际使用的上游模型"
|
||||
>
|
||||
<div class="grid gap-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
|
||||
@@ -11,10 +11,10 @@ const enabled = defineModel<boolean>('enabled', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="请求时区与位置" description="开启后覆盖 Codex 请求的时区与位置,代理自定义配置优先">
|
||||
<BaseCard title="请求位置覆盖" description="覆盖 Codex 请求中的时区与位置字段,账号出站代理的自定义位置优先">
|
||||
<template #body>
|
||||
<div>
|
||||
<BaseSwitch v-model="enabled" label="启用" show-label :disabled="disabled" />
|
||||
<BaseSwitch v-model="enabled" label="启用位置覆盖" show-label :disabled="disabled" />
|
||||
</div>
|
||||
<BaseForm class="mt-4 max-w-6xl">
|
||||
<RequestLocationFields v-model="location" :disabled="disabled || !enabled" />
|
||||
|
||||
@@ -10,16 +10,16 @@ const concurrencyWaitTimeoutSeconds = defineModel<string>('concurrencyWaitTimeou
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="请求排队">
|
||||
<BaseCard title="并发队列">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem label="每个密钥最大排队数" description="各密钥独立计数;0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerKey" aria-label="每个密钥最大排队数" type="number" min="0" max="1000" step="1" />
|
||||
<BaseFormItem label="密钥队列容量" description="每个客户端密钥允许等待的最大请求数,0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerKey" aria-label="密钥队列容量" type="number" min="0" max="1000" step="1" />
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="单账号最大排队数" description="各账号独立计数;0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerAccount" aria-label="单账号最大排队数" type="number" min="0" max="1000" step="1" />
|
||||
<BaseFormItem label="账号队列容量" description="每个上游账号允许等待的最大请求数,0 表示不排队">
|
||||
<BaseInput v-model="maxWaitingPerAccount" aria-label="账号队列容量" type="number" min="0" max="1000" step="1" />
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="最长排队秒数" description="密钥和账号共用,从首次入队起计时,1~120 秒">
|
||||
<BaseInput v-model="concurrencyWaitTimeoutSeconds" aria-label="最长排队秒数" type="number" min="1" max="120" step="1" />
|
||||
<BaseFormItem label="排队超时(秒)" description="密钥队列与账号队列共用的等待时限,从首次入队起计时,1~120 秒">
|
||||
<BaseInput v-model="concurrencyWaitTimeoutSeconds" aria-label="排队超时(秒)" type="number" min="1" max="120" step="1" />
|
||||
</BaseFormItem>
|
||||
</BaseForm>
|
||||
</BaseCard>
|
||||
|
||||
@@ -8,10 +8,10 @@ const model = defineModel<string>({ required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="请求体解压上限">
|
||||
<BaseCard title="请求体限制" description="限制 Responses 请求解压后的请求体大小">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2 sm:items-end">
|
||||
<BaseFormItem label="最大解压大小" description="调高可接收更大的图片和历史上下文,也会增加内存占用">
|
||||
<BaseInput v-model="model" aria-label="请求体解压上限" type="number" min="1" step="1">
|
||||
<BaseFormItem label="解压后大小上限" description="调高可接收更大的图片和历史上下文,也会增加内存占用">
|
||||
<BaseInput v-model="model" aria-label="解压后请求体大小上限" type="number" min="1" step="1">
|
||||
<template #suffix>
|
||||
MiB
|
||||
</template>
|
||||
|
||||
@@ -11,15 +11,15 @@ const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: t
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="并发与请求间隔">
|
||||
<BaseCard title="并发控制">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem
|
||||
label="单账号默认最大并发"
|
||||
label="默认账号并发上限"
|
||||
description="账号未单独设置时使用的并发上限"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="maxConcurrentPerAccount"
|
||||
aria-label="单账号默认最大并发"
|
||||
aria-label="默认账号并发上限"
|
||||
type="number"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -29,19 +29,19 @@ const requestIntervalMs = defineModel<string>('requestIntervalMs', { required: t
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem
|
||||
label="请求间隔"
|
||||
label="最小请求间隔"
|
||||
description="控制同一账号两次调度之间的最小等待时间"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="requestIntervalMs"
|
||||
aria-label="请求间隔 ms"
|
||||
aria-label="最小请求间隔(毫秒)"
|
||||
type="number"
|
||||
>
|
||||
<template #prefix>
|
||||
<Timer class="size-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
ms
|
||||
毫秒
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import { useAdminApiKey } from '../composables/useAdminApiKey'
|
||||
import AdminApiKeyCard from './AdminApiKeyCard.vue'
|
||||
import AdminPasswordCard from './AdminPasswordCard.vue'
|
||||
import ClientVersionSettings from './client-version/index.vue'
|
||||
import ResponseBodyLimitCard from './ResponseBodyLimitCard.vue'
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean
|
||||
loading: boolean
|
||||
desktopError: string
|
||||
cliError: string
|
||||
}>()
|
||||
|
||||
const minCodexDesktopVersion = defineModel<string>('minCodexDesktopVersion', { required: true })
|
||||
const minCodexCliVersion = defineModel<string>('minCodexCliVersion', { required: true })
|
||||
const responsesMaxDecompressedBodyMiB = defineModel<string>('responsesMaxDecompressedBodyMiB', { required: true })
|
||||
|
||||
const {
|
||||
loading: adminKeyLoading,
|
||||
regenerating,
|
||||
deleting,
|
||||
showDeleteModal,
|
||||
generatedKey,
|
||||
status,
|
||||
regenerate,
|
||||
remove,
|
||||
copyGeneratedKey,
|
||||
loadStatus,
|
||||
} = useAdminApiKey()
|
||||
|
||||
onMounted(loadStatus)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid min-w-0 gap-5">
|
||||
<AdminPasswordCard />
|
||||
|
||||
<AdminApiKeyCard
|
||||
:status="status"
|
||||
:loading="adminKeyLoading"
|
||||
:regenerating="regenerating"
|
||||
:deleting="deleting"
|
||||
:generated-key="generatedKey"
|
||||
@regenerate="regenerate"
|
||||
@request-delete="showDeleteModal = true"
|
||||
@copy="copyGeneratedKey"
|
||||
/>
|
||||
|
||||
<fieldset :disabled="disabled" class="m-0 grid min-w-0 gap-5 border-0 p-0" aria-label="访问限制">
|
||||
<ClientVersionSettings
|
||||
v-model:min-codex-desktop-version="minCodexDesktopVersion"
|
||||
v-model:min-codex-cli-version="minCodexCliVersion"
|
||||
:loading="loading"
|
||||
:desktop-error="desktopError"
|
||||
:cli-error="cliError"
|
||||
/>
|
||||
<ResponseBodyLimitCard v-model="responsesMaxDecompressedBodyMiB" />
|
||||
</fieldset>
|
||||
|
||||
<BaseConfirmModal
|
||||
v-model="showDeleteModal"
|
||||
title="删除管理员 API Key"
|
||||
description="删除后外部系统将无法继续使用该 Key 调用管理接口"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="deleting"
|
||||
@confirm="remove"
|
||||
>
|
||||
<p class="m-0">
|
||||
确定要删除当前管理员 API Key 吗?此操作会立即生效
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,17 +10,17 @@ const refreshConcurrency = defineModel<string>('refreshConcurrency', { required:
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseCard title="凭据自动刷新">
|
||||
<BaseCard title="凭据刷新">
|
||||
<BaseForm class="max-w-6xl sm:grid-cols-2">
|
||||
<BaseFormItem label="提前刷新秒数" description="Token 过期前多少秒触发刷新">
|
||||
<BaseInput v-model="refreshMarginSeconds" aria-label="提前刷新秒数" type="number">
|
||||
<BaseFormItem label="提前刷新时间(秒)" description="访问令牌到期前提前触发自动刷新的时间">
|
||||
<BaseInput v-model="refreshMarginSeconds" aria-label="提前刷新时间(秒)" type="number">
|
||||
<template #prefix>
|
||||
<Timer class="size-4" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="刷新并发数" description="同时刷新 Token 的最大请求数,减小可避免限流">
|
||||
<BaseInput v-model="refreshConcurrency" aria-label="刷新并发数" type="number">
|
||||
<BaseFormItem label="刷新并发上限" description="同时执行凭据刷新的最大请求数,降低上限可减少限流风险">
|
||||
<BaseInput v-model="refreshConcurrency" aria-label="刷新并发上限" type="number">
|
||||
<template #prefix>
|
||||
<Zap class="size-4" />
|
||||
</template>
|
||||
|
||||
@@ -40,8 +40,8 @@ const TIMEZONE_OPTIONS = [
|
||||
|
||||
<template>
|
||||
<BaseCard
|
||||
title="定时备份"
|
||||
description="配置自动定时备份"
|
||||
title="备份计划"
|
||||
description="配置自动备份的执行时间与保留策略"
|
||||
>
|
||||
<template #actions>
|
||||
<BaseButton variant="primary" :loading="saving" :disabled="loading" @click="emit('save')">
|
||||
@@ -63,7 +63,7 @@ const TIMEZONE_OPTIONS = [
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseFormItem label="时区" description="显式 IANA 时区">
|
||||
<BaseFormItem label="调度时区" description="用于解释 Cron 表达式的 IANA 时区">
|
||||
<BaseSelect v-model="schedule.scheduleTimezone" :options="TIMEZONE_OPTIONS" />
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -78,13 +78,13 @@ const TIMEZONE_OPTIONS = [
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem label="备份过期天数" description="超过此天数自动删除,0 = 永不过期">
|
||||
<BaseInput v-model="schedule.retentionDays" aria-label="备份过期天数" type="number" min="0" />
|
||||
<BaseFormItem label="保留天数" description="超过此天数自动删除,0 表示不按天数清理,仍受最大保留份数限制">
|
||||
<BaseInput v-model="schedule.retentionDays" aria-label="备份保留天数" type="number" min="0" />
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem
|
||||
label="最大保留份数"
|
||||
description="最多保留的备份数量,0 = 不限制"
|
||||
description="最多保留的备份数量,0 表示不按份数清理,仍受保留天数限制"
|
||||
>
|
||||
<BaseInput v-model="schedule.retentionCount" aria-label="最大保留份数" type="number" min="0" />
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -104,7 +104,7 @@ function toggleSecretVisible(): void {
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem label="区域" description="R2 使用固定值 auto;其它服务按提供方填写">
|
||||
<BaseFormItem label="区域" description="R2 使用固定值 auto,其它服务按提供方填写">
|
||||
<BaseInput v-model="storage.region" aria-label="区域" />
|
||||
</BaseFormItem>
|
||||
|
||||
@@ -112,8 +112,8 @@ function toggleSecretVisible(): void {
|
||||
<BaseInput v-model="storage.bucket" aria-label="存储桶" />
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem label="Key 前缀" description="对象前缀,历史归档已保存完整旧前缀">
|
||||
<BaseInput v-model="storage.prefix" aria-label="Key 前缀" />
|
||||
<BaseFormItem label="对象键前缀" description="备份对象的存储路径前缀,不影响已有备份">
|
||||
<BaseInput v-model="storage.prefix" aria-label="对象键前缀" />
|
||||
</BaseFormItem>
|
||||
|
||||
<BaseFormItem label="Access Key ID" description="对象存储专用凭据">
|
||||
@@ -161,7 +161,7 @@ function toggleSecretVisible(): void {
|
||||
</BaseFormItem>
|
||||
|
||||
<div class="col-span-2 flex items-center gap-4 @max-[640px]:col-span-1">
|
||||
<BaseCheckbox v-model="storage.forcePathStyle" label="强制路径风格" show-label />
|
||||
<BaseCheckbox v-model="storage.forcePathStyle" label="强制路径式访问" show-label />
|
||||
</div>
|
||||
</BaseForm>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ const open = defineModel<boolean>({ default: false })
|
||||
</h3>
|
||||
<p class="m-0">
|
||||
在 Cloudflare Dashboard 进入 R2,创建一个私有存储桶(例如
|
||||
<code class="rounded bg-cp-fill-quaternary px-1.5 py-0.5">codex-proxy-backups</code>)。
|
||||
桶必须保持私有,不要开放公共读取。
|
||||
<code class="rounded bg-cp-fill-quaternary px-1.5 py-0.5">codex-proxy-backups</code>),
|
||||
桶必须保持私有,不要开放公共读取
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -33,7 +33,7 @@ const open = defineModel<boolean>({ default: false })
|
||||
<code class="rounded bg-cp-fill-quaternary px-1.5 py-0.5">对象写</code>、
|
||||
<code class="rounded bg-cp-fill-quaternary px-1.5 py-0.5">对象查看</code> 与
|
||||
<code class="rounded bg-cp-fill-quaternary px-1.5 py-0.5">对象删除</code> 权限,
|
||||
不要授予整个账号权限。
|
||||
不要授予整个账号权限
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -56,8 +56,8 @@ const open = defineModel<boolean>({ default: false })
|
||||
4. 完成连接测试
|
||||
</h3>
|
||||
<p class="m-0">
|
||||
保存配置后点击「测试连接」,系统会写入一个探针对象并校验读写权限。测试通过后
|
||||
才能启用自动计划或手动创建备份。凭据只保存在服务端,不会写入浏览器。
|
||||
保存配置后点击「测试连接」,系统会写入一个探针对象并校验读写权限,测试通过后
|
||||
才能启用自动计划或手动创建备份,凭据只保存在服务端,不会写入浏览器
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ const cliCommands = [
|
||||
安装或升级 Codex CLI
|
||||
</h3>
|
||||
<p class="mt-1.5 mb-0 text-cp-sm leading-[1.45] font-semibold text-cp-text-secondary">
|
||||
按当前安装来源执行一种命令;官方安装器会自动选择当前平台架构。
|
||||
按当前安装来源执行一种命令,官方安装器会自动选择当前平台架构
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +53,7 @@ const cliCommands = [
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-1 text-cp-sm font-semibold text-cp-text-secondary">
|
||||
<span>重新执行对应命令即可升级到当前发布版本。</span>
|
||||
<span>重新执行对应命令即可升级到当前发布版本</span>
|
||||
<a
|
||||
href="https://learn.chatgpt.com/docs/codex/cli"
|
||||
target="_blank"
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@ const desktopCommands = [
|
||||
在线安装与升级
|
||||
</h3>
|
||||
<p class="mt-1.5 mb-0 text-cp-sm leading-[1.45] font-semibold text-cp-text-secondary">
|
||||
桌面应用通常会自动更新,也可以从官方渠道重新安装。
|
||||
桌面应用通常会自动更新,也可以从官方渠道重新安装
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ function formatFileSize(value: number | null): string {
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1.5 mb-0 text-cp-sm leading-[1.45] font-semibold text-cp-text-secondary">
|
||||
后端实时校验 Microsoft CDN 直链;请选择与设备一致的架构。
|
||||
后端实时校验 Microsoft CDN 直链,请选择与设备一致的架构
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ function formatFileSize(value: number | null): string {
|
||||
class="m-0 rounded-cp bg-cp-error-container px-3 py-2.5 text-cp-sm leading-[1.45] font-bold text-cp-error-on-container"
|
||||
role="alert"
|
||||
>
|
||||
{{ error }},当前仍显示上次成功结果。
|
||||
{{ error }},当前仍显示上次成功结果
|
||||
</p>
|
||||
|
||||
<div v-if="loading && !downloads" class="grid gap-3 sm:grid-cols-2" aria-label="正在提取离线安装包">
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useBackupSettings() {
|
||||
retentionCount: Number(schedule.retentionCount) || 0,
|
||||
})
|
||||
applySettings(data)
|
||||
toast.success('调度配置已保存')
|
||||
toast.success('备份计划已保存')
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -23,7 +23,6 @@ export function useSettingsForm() {
|
||||
const savedRequestLocation = shallowRef<RequestLocation>()
|
||||
const form = reactive({
|
||||
openaiClientProfile: null as ClientProfileSelection | null,
|
||||
disableFast: false,
|
||||
requestLocationEnabled: false,
|
||||
requestLocation: { country: '', region: '', city: '', timezone: '' },
|
||||
refreshMarginSeconds: null as number | null,
|
||||
@@ -105,7 +104,6 @@ export function useSettingsForm() {
|
||||
|
||||
function applySettings(data: Awaited<ReturnType<typeof getSettings>>) {
|
||||
savedRequestLocation.value = { ...data.requestLocation }
|
||||
form.disableFast = data.disableFast
|
||||
form.requestLocationEnabled = data.requestLocationEnabled
|
||||
form.requestLocation = { ...data.requestLocation }
|
||||
form.refreshMarginSeconds = data.refreshMarginSeconds
|
||||
@@ -189,7 +187,7 @@ export function useSettingsForm() {
|
||||
return
|
||||
const { refreshMarginSeconds, refreshConcurrency, maxConcurrentPerAccount, requestIntervalMs, rotationStrategy, maxWaitingPerKey, maxWaitingPerAccount, concurrencyWaitTimeoutSeconds, responsesMaxDecompressedBodyMiB, accountAutoFreezeThreshold, accountAutoFreezeWindowSeconds, accountAutoFreezeDurationSeconds } = form
|
||||
if (refreshMarginSeconds === null || refreshConcurrency === null || maxConcurrentPerAccount === null || requestIntervalMs === null || !rotationStrategy || maxWaitingPerKey === null || maxWaitingPerAccount === null || concurrencyWaitTimeoutSeconds === null) {
|
||||
toast.warning('请完整填写运行参数和调度策略')
|
||||
toast.warning('请完整填写并发、队列、凭据刷新参数和调度策略')
|
||||
return
|
||||
}
|
||||
if (responsesMaxDecompressedBodyMiB === null || !Number.isInteger(responsesMaxDecompressedBodyMiB) || responsesMaxDecompressedBodyMiB < 1
|
||||
@@ -199,7 +197,7 @@ export function useSettingsForm() {
|
||||
}
|
||||
if (![maxWaitingPerKey, maxWaitingPerAccount].every(value => Number.isInteger(value) && value >= 0 && value <= 1000)
|
||||
|| !Number.isInteger(concurrencyWaitTimeoutSeconds) || concurrencyWaitTimeoutSeconds < 1 || concurrencyWaitTimeoutSeconds > 120) {
|
||||
toast.warning('最大排队数应为 0~1000 的整数,最长排队时间应为 1~120 秒的整数')
|
||||
toast.warning('队列容量应为 0~1000 的整数,排队超时应为 1~120 秒的整数')
|
||||
return
|
||||
}
|
||||
if (minCodexDesktopVersionError.value || minCodexCliVersionError.value) {
|
||||
@@ -216,13 +214,13 @@ export function useSettingsForm() {
|
||||
return
|
||||
}
|
||||
if (accountAutoFreezeThreshold === null || accountAutoFreezeWindowSeconds === null || accountAutoFreezeDurationSeconds === null) {
|
||||
toast.warning('请完整填写账号自动冻结参数')
|
||||
toast.warning('请完整填写过载保护参数')
|
||||
return
|
||||
}
|
||||
if (!Number.isInteger(accountAutoFreezeThreshold) || accountAutoFreezeThreshold < 2 || accountAutoFreezeThreshold > 1000
|
||||
|| !Number.isInteger(accountAutoFreezeWindowSeconds) || accountAutoFreezeWindowSeconds < 60 || accountAutoFreezeWindowSeconds > 3600
|
||||
|| !Number.isInteger(accountAutoFreezeDurationSeconds) || accountAutoFreezeDurationSeconds < 300 || accountAutoFreezeDurationSeconds > 604800) {
|
||||
toast.warning('自动冻结阈值应为 2~1000,统计窗口为 60~3600 秒,冻结时长为 300~604800 秒')
|
||||
toast.warning('失败次数阈值应为 2~1000,统计窗口为 60~3600 秒,冷却时长为 300~604800 秒')
|
||||
return
|
||||
}
|
||||
const probeModel = form.accountAutoFreezeProbeModel.trim()
|
||||
@@ -234,7 +232,6 @@ export function useSettingsForm() {
|
||||
await saveAction.run(async () => {
|
||||
const result = await updateSettings({
|
||||
openaiClientProfile,
|
||||
disableFast: form.disableFast,
|
||||
requestLocationEnabled: form.requestLocationEnabled,
|
||||
requestLocation,
|
||||
modelMappings: mappingPayload(),
|
||||
|
||||
@@ -10,12 +10,12 @@ export const rotationOptions = [
|
||||
description: '优先选择额度窗口更快重置的账号,适合在重置前消耗剩余额度',
|
||||
},
|
||||
{
|
||||
label: '轮询',
|
||||
label: '轮询调度',
|
||||
value: 'round_robin',
|
||||
description: '在可用候选账号间按顺序轮转,分配结果最可预测',
|
||||
},
|
||||
{
|
||||
label: '粘滞',
|
||||
label: '粘性调度',
|
||||
value: 'sticky',
|
||||
description: '优先复用最近使用的账号,直到不可用后再切换',
|
||||
},
|
||||
|
||||
@@ -1,38 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { Save, Undo2 } from '@lucide/vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, reactive, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BasePageHeader from '@/components/base/BasePageHeader.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseSelect from '@/components/base/BaseSelect.vue'
|
||||
import ClientProfileEditor from '@/components/client-profile/ClientProfileEditor.vue'
|
||||
|
||||
import AccountAutoFreezeCard from './components/AccountAutoFreezeCard.vue'
|
||||
import AdminApiKeyCard from './components/AdminApiKeyCard.vue'
|
||||
import AdminPasswordCard from './components/AdminPasswordCard.vue'
|
||||
import SettingsBackupSection from './components/backup/SettingsBackupSection.vue'
|
||||
import ClientVersionSettings from './components/client-version/index.vue'
|
||||
import FastPolicyCard from './components/FastPolicyCard.vue'
|
||||
import ModelAliasesCard from './components/ModelAliasesCard.vue'
|
||||
import RequestLocationCard from './components/RequestLocationCard.vue'
|
||||
import RequestQueueCard from './components/RequestQueueCard.vue'
|
||||
import ResponseBodyLimitCard from './components/ResponseBodyLimitCard.vue'
|
||||
import RotationStrategyCard from './components/RotationStrategyCard.vue'
|
||||
import RuntimeSettingsCard from './components/RuntimeSettingsCard.vue'
|
||||
import SettingsAccessSection from './components/SettingsAccessSection.vue'
|
||||
import TokenRefreshCard from './components/TokenRefreshCard.vue'
|
||||
import { useAdminApiKey } from './composables/useAdminApiKey'
|
||||
import { useSettingsForm } from './composables/useSettingsForm'
|
||||
import { rotationOptions } from './constants'
|
||||
import PricingSection from './pricing/index.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const pricingVisited = ref(false)
|
||||
const section = computed(() => route.name === 'settings-pricing' ? 'pricing' : route.name === 'settings-backup' ? 'backup' : 'runtime')
|
||||
const sectionOptions = [
|
||||
{ label: '网关调度', value: 'runtime' },
|
||||
{ label: '上游配置', value: 'upstream' },
|
||||
{ label: '模型定价', value: 'pricing' },
|
||||
{ label: '安全与访问', value: 'access' },
|
||||
{ label: '数据备份', value: 'backup' },
|
||||
]
|
||||
const section = computed(() => {
|
||||
switch (route.name) {
|
||||
case 'settings-upstream': return 'upstream'
|
||||
case 'settings-access': return 'access'
|
||||
case 'settings-pricing': return 'pricing'
|
||||
case 'settings-backup': return 'backup'
|
||||
default: return 'runtime'
|
||||
}
|
||||
})
|
||||
const isBasicSection = computed(() => section.value !== 'pricing' && section.value !== 'backup')
|
||||
const visited = reactive(new Set<string>())
|
||||
const settingsVisited = shallowRef(false)
|
||||
|
||||
function switchSection(value: string): void {
|
||||
void router.push(value === 'runtime' ? '/settings' : `/settings/${value}`)
|
||||
@@ -67,65 +79,51 @@ const {
|
||||
loadSettings,
|
||||
} = useSettingsForm()
|
||||
|
||||
const {
|
||||
loading: adminKeyLoading,
|
||||
regenerating: adminKeyRegenerating,
|
||||
deleting: adminKeyDeleting,
|
||||
showDeleteModal: showDeleteAdminKeyModal,
|
||||
generatedKey: generatedAdminApiKey,
|
||||
status: adminApiKeyStatus,
|
||||
regenerate: handleRegenerateAdminApiKey,
|
||||
remove: handleDeleteAdminApiKey,
|
||||
copyGeneratedKey: copyAdminApiKey,
|
||||
loadStatus: loadAdminApiKeyStatus,
|
||||
} = useAdminApiKey()
|
||||
const disabled = computed(() => saving.value || loading.value || !!error.value)
|
||||
|
||||
const runtimeVisited = ref(false)
|
||||
watch(section, (value) => {
|
||||
if (value === 'pricing') {
|
||||
pricingVisited.value = true
|
||||
}
|
||||
else if (!runtimeVisited.value) {
|
||||
runtimeVisited.value = true
|
||||
visited.add(value)
|
||||
if (isBasicSection.value && !settingsVisited.value) {
|
||||
settingsVisited.value = true
|
||||
void loadSettings()
|
||||
void loadAdminApiKeyStatus()
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full" :class="section === 'pricing' ? 'flex h-[calc(100dvh-2rem)] flex-none! flex-col min-[961px]:h-[calc(100dvh-3rem)]' : undefined">
|
||||
<BasePageHeader title="系统设置" description="管理运行参数、模型定价、管理员凭据与备份配置" />
|
||||
<BasePageHeader title="系统设置" description="管理网关调度、上游配置、模型定价、安全访问与数据备份" />
|
||||
|
||||
<div class="mt-4 flex min-h-cp-control shrink-0 flex-wrap items-center justify-between gap-3">
|
||||
<BaseSegmented
|
||||
:model-value="section"
|
||||
label="设置分区"
|
||||
class="bg-(--cp-input-bg)!"
|
||||
:options="[
|
||||
{ label: '运行设置', value: 'runtime' },
|
||||
{ label: '模型定价', value: 'pricing' },
|
||||
{ label: '备份', value: 'backup' },
|
||||
]"
|
||||
class="hidden! bg-(--cp-input-bg)! sm:inline-grid!"
|
||||
:options="sectionOptions"
|
||||
@update:model-value="switchSection"
|
||||
/>
|
||||
<div v-if="section === 'runtime'" class="flex items-center justify-end gap-2">
|
||||
<BaseSelect
|
||||
:model-value="section"
|
||||
aria-label="设置分区"
|
||||
class="w-full sm:hidden"
|
||||
:options="sectionOptions"
|
||||
@update:model-value="switchSection"
|
||||
/>
|
||||
<div v-if="isBasicSection || hasChanges" class="ml-auto flex items-center justify-end gap-2">
|
||||
<span v-if="hasChanges" class="mr-1 size-1.5 shrink-0 rounded-full bg-cp-warning" aria-hidden="true" />
|
||||
<BaseIconButton v-if="hasChanges" label="撤销更改" variant="filled" :disabled="saving || loading" @click="resetSettings">
|
||||
<BaseIconButton v-if="hasChanges" label="撤销全部基础设置更改" variant="filled" :disabled="saving || loading" @click="resetSettings">
|
||||
<Undo2 class="size-4" />
|
||||
</BaseIconButton>
|
||||
<BaseButton variant="primary" :loading="saving" :disabled="loading || !hasChanges || !!error" @click="saveSettings">
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
{{ saving ? '保存中...' : '保存' }}
|
||||
{{ saving ? '保存中...' : '保存基础设置' }}
|
||||
</BaseButton>
|
||||
<span class="sr-only" role="status">{{ hasChanges ? '有未保存更改' : '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 切换页签只隐藏内容,保留运行设置和备份配置的未保存草稿。 -->
|
||||
<div v-if="runtimeVisited" v-show="section === 'runtime'" class="mt-5 grid w-full gap-5">
|
||||
<div v-if="settingsVisited" v-show="isBasicSection" class="mt-5 grid w-full gap-5">
|
||||
<div v-if="error" role="alert" class="flex flex-wrap items-center justify-between gap-3 rounded-cp-card bg-cp-error-container p-5 text-cp-error-on-container">
|
||||
<p class="m-0 text-cp">
|
||||
设置加载失败:{{ error }}
|
||||
@@ -138,81 +136,59 @@ watch(section, (value) => {
|
||||
正在加载设置…
|
||||
</div>
|
||||
|
||||
<AdminPasswordCard />
|
||||
<AdminApiKeyCard
|
||||
:status="adminApiKeyStatus"
|
||||
:loading="adminKeyLoading"
|
||||
:regenerating="adminKeyRegenerating"
|
||||
:deleting="adminKeyDeleting"
|
||||
:generated-key="generatedAdminApiKey"
|
||||
@regenerate="handleRegenerateAdminApiKey"
|
||||
@request-delete="showDeleteAdminKeyModal = true"
|
||||
@copy="copyAdminApiKey"
|
||||
<SettingsAccessSection
|
||||
v-if="visited.has('access')"
|
||||
v-show="section === 'access'"
|
||||
v-model:min-codex-desktop-version="form.minCodexDesktopVersion"
|
||||
v-model:min-codex-cli-version="form.minCodexCliVersion"
|
||||
v-model:responses-max-decompressed-body-mi-b="responsesMaxDecompressedBodyMiBValue"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
:desktop-error="minCodexDesktopVersionError"
|
||||
:cli-error="minCodexCliVersionError"
|
||||
/>
|
||||
|
||||
<fieldset :disabled="saving || loading || !!error" class="m-0 grid min-w-0 gap-5 border-0 p-0" aria-label="运行设置">
|
||||
<RuntimeSettingsCard
|
||||
v-model:max-concurrent-per-account="maxConcurrentPerAccountValue"
|
||||
v-model:request-interval-ms="requestIntervalMsValue"
|
||||
/>
|
||||
<TokenRefreshCard v-model:refresh-margin-seconds="refreshMarginSecondsValue" v-model:refresh-concurrency="refreshConcurrencyValue" />
|
||||
<ResponseBodyLimitCard v-model="responsesMaxDecompressedBodyMiBValue" />
|
||||
<RequestQueueCard
|
||||
v-model:max-waiting-per-key="maxWaitingPerKeyValue"
|
||||
v-model:max-waiting-per-account="maxWaitingPerAccountValue"
|
||||
v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue"
|
||||
/>
|
||||
<FastPolicyCard v-model="form.disableFast" :disabled="saving || loading || !!error" />
|
||||
<fieldset v-show="section !== 'access'" :disabled="disabled" class="m-0 grid min-w-0 gap-5 border-0 p-0" aria-label="基础设置">
|
||||
<template v-if="section === 'runtime'">
|
||||
<RuntimeSettingsCard
|
||||
v-model:max-concurrent-per-account="maxConcurrentPerAccountValue"
|
||||
v-model:request-interval-ms="requestIntervalMsValue"
|
||||
/>
|
||||
<RotationStrategyCard v-model="form.rotationStrategy" :options="rotationOptions" />
|
||||
<RequestQueueCard
|
||||
v-model:max-waiting-per-key="maxWaitingPerKeyValue"
|
||||
v-model:max-waiting-per-account="maxWaitingPerAccountValue"
|
||||
v-model:concurrency-wait-timeout-seconds="concurrencyWaitTimeoutSecondsValue"
|
||||
/>
|
||||
<AccountAutoFreezeCard
|
||||
v-model:enabled="form.accountAutoFreezeEnabled"
|
||||
v-model:threshold="accountAutoFreezeThresholdValue"
|
||||
v-model:window-seconds="accountAutoFreezeWindowSecondsValue"
|
||||
v-model:duration-seconds="accountAutoFreezeDurationSecondsValue"
|
||||
v-model:probe-enabled="form.accountAutoFreezeProbeEnabled"
|
||||
v-model:probe-model="form.accountAutoFreezeProbeModel"
|
||||
v-model:adaptive-concurrency="form.accountAutoFreezeAdaptiveConcurrency"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="saving || loading || !!error" />
|
||||
<AccountAutoFreezeCard
|
||||
v-model:enabled="form.accountAutoFreezeEnabled"
|
||||
v-model:threshold="accountAutoFreezeThresholdValue"
|
||||
v-model:window-seconds="accountAutoFreezeWindowSecondsValue"
|
||||
v-model:duration-seconds="accountAutoFreezeDurationSecondsValue"
|
||||
v-model:probe-enabled="form.accountAutoFreezeProbeEnabled"
|
||||
v-model:probe-model="form.accountAutoFreezeProbeModel"
|
||||
v-model:adaptive-concurrency="form.accountAutoFreezeAdaptiveConcurrency"
|
||||
/>
|
||||
|
||||
<BaseCard title="上游身份">
|
||||
<ClientProfileEditor v-if="form.openaiClientProfile" v-model="form.openaiClientProfile" :disabled="saving || loading || !!error" class="max-w-6xl" />
|
||||
</BaseCard>
|
||||
|
||||
<ClientVersionSettings
|
||||
v-model:min-codex-desktop-version="form.minCodexDesktopVersion"
|
||||
v-model:min-codex-cli-version="form.minCodexCliVersion"
|
||||
:loading="loading"
|
||||
:desktop-error="minCodexDesktopVersionError"
|
||||
:cli-error="minCodexCliVersionError"
|
||||
/>
|
||||
|
||||
<ModelAliasesCard
|
||||
:mappings="mappings"
|
||||
:loading="loading"
|
||||
@add-mapping="addMapping"
|
||||
@update-mapping="updateMapping"
|
||||
@remove-mapping="removeMapping"
|
||||
/>
|
||||
<RotationStrategyCard v-model="form.rotationStrategy" :options="rotationOptions" />
|
||||
<div v-if="visited.has('upstream')" v-show="section === 'upstream'" class="grid min-w-0 gap-5">
|
||||
<TokenRefreshCard v-model:refresh-margin-seconds="refreshMarginSecondsValue" v-model:refresh-concurrency="refreshConcurrencyValue" />
|
||||
<BaseCard title="客户端身份" description="配置网关向上游声明的客户端类型、版本与请求头">
|
||||
<ClientProfileEditor v-if="form.openaiClientProfile" v-model="form.openaiClientProfile" :active="section === 'upstream'" :disabled="disabled" class="max-w-6xl" />
|
||||
</BaseCard>
|
||||
<RequestLocationCard v-model="form.requestLocation" v-model:enabled="form.requestLocationEnabled" :disabled="disabled" />
|
||||
<ModelAliasesCard
|
||||
:mappings="mappings"
|
||||
:loading="loading"
|
||||
@add-mapping="addMapping"
|
||||
@update-mapping="updateMapping"
|
||||
@remove-mapping="removeMapping"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<SettingsBackupSection v-show="section === 'backup'" class="mt-5" :active="section === 'backup'" />
|
||||
<PricingSection v-if="pricingVisited" v-show="section === 'pricing'" class="mt-5 min-h-0 flex-1" />
|
||||
|
||||
<BaseConfirmModal
|
||||
v-model="showDeleteAdminKeyModal"
|
||||
title="删除管理员 API Key"
|
||||
description="删除后外部系统将无法继续使用该 Key 调用管理接口"
|
||||
destructive
|
||||
confirm-text="确认删除"
|
||||
:loading="adminKeyDeleting"
|
||||
@confirm="handleDeleteAdminApiKey"
|
||||
>
|
||||
<p class="m-0">
|
||||
确定要删除当前管理员 API Key 吗?此操作会立即生效
|
||||
</p>
|
||||
</BaseConfirmModal>
|
||||
<SettingsBackupSection v-if="visited.has('backup')" v-show="section === 'backup'" class="mt-5" :active="section === 'backup'" />
|
||||
<PricingSection v-if="visited.has('pricing')" v-show="section === 'pricing'" class="mt-5 min-h-0 flex-1" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@ import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseFormItem from '@/components/base/BaseForm/FormItem.vue'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import { multiplierText, parseMultiplier } from './model'
|
||||
|
||||
defineProps<{ rows: PricingRow[], reset: boolean, saving: boolean }>()
|
||||
@@ -20,26 +21,28 @@ watch(open, (value) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" :title="reset ? '清除人工覆盖' : '批量设置倍率'" :description="`仅修改下列 ${rows.length} 个模型;不重算历史账单`" :dismissible="!saving" tone="warning">
|
||||
<BaseModal v-model="open" :title="reset ? '清除人工覆盖' : '批量设置倍率'" :description="`仅修改下列 ${rows.length} 个模型,不重算历史账单`" :dismissible="!saving" tone="warning">
|
||||
<div class="grid gap-4">
|
||||
<p v-if="reset" class="m-0 text-cp leading-relaxed text-cp-text-secondary">
|
||||
将移除选中模型的人工单价和倍率,回到同步价目或内置价目。仅有人工价格的模型将恢复为未配置。
|
||||
恢复来源或内置价格,无可用价格时设为未配置
|
||||
</p>
|
||||
<BaseFormItem v-else label="目标倍率" description="设置为此倍率,不与已有倍率相乘;0~100,最多四位小数">
|
||||
<BaseFormItem v-else label="目标倍率" description="设置为此倍率,不与已有倍率相乘,0~100,最多四位小数">
|
||||
<BaseInput v-model="multiplier" aria-label="目标倍率" inputmode="decimal" :disabled="saving">
|
||||
<template #suffix>
|
||||
×
|
||||
</template>
|
||||
</BaseInput>
|
||||
</BaseFormItem>
|
||||
<ul class="m-0 max-h-64 list-none overflow-auto rounded-cp bg-cp-fill-quaternary p-3 text-cp-sm">
|
||||
<li v-for="row in rows" :key="row.model" class="flex items-start justify-between gap-3 py-2">
|
||||
<span class="min-w-0 break-all font-mono">{{ row.model }}</span>
|
||||
<span class="shrink-0 font-mono text-cp-text-secondary">{{ multiplierText(row.effective.multiplierBps) }} → {{ reset ? '1×' : bps === undefined ? '—' : multiplierText(bps) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<BaseScrollbar max-height="16rem" class="rounded-cp bg-cp-fill-quaternary">
|
||||
<ul class="m-0 list-none p-3 text-cp-sm">
|
||||
<li v-for="row in rows" :key="row.model" class="flex items-start justify-between gap-3 py-2">
|
||||
<span class="min-w-0 break-all font-mono">{{ row.model }}</span>
|
||||
<span class="shrink-0 font-mono text-cp-text-secondary">{{ multiplierText(row.effective.multiplierBps) }} → {{ reset ? '1×' : bps === undefined ? '—' : multiplierText(bps) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</BaseScrollbar>
|
||||
<p v-if="!reset && bps === undefined" role="alert" class="m-0 text-cp-sm text-cp-error">
|
||||
请输入合法倍率。
|
||||
请输入合法倍率
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
|
||||
@@ -67,22 +67,22 @@ function updatePrice(field: keyof TokenPrices, value: string) {
|
||||
}
|
||||
function submit() {
|
||||
if (!model.value || new TextEncoder().encode(model.value).length > 128 || /\s/.test(model.value) || [...model.value].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) {
|
||||
validation.value = '模型 ID 必须为 1~128 字节,不含空白或控制字符。'
|
||||
validation.value = '模型 ID 必须为 1~128 字节,不含空白或控制字符'
|
||||
return
|
||||
}
|
||||
if (bps.value === undefined) {
|
||||
validation.value = '倍率范围为 0~100,最多四位小数。'
|
||||
validation.value = '倍率范围为 0~100,最多四位小数'
|
||||
return
|
||||
}
|
||||
for (const [band, rates] of Object.entries(draft.value)) {
|
||||
if (Object.values(rates).some(price => !validPrice(price))) {
|
||||
validation.value = '请完整填写该档位的四项单价:0~1000000,最多四位小数。'
|
||||
validation.value = '请完整填写该档位的四项单价:0~1000000,最多四位小数'
|
||||
selectedBand.value = band as PriceBand
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!props.row?.base.bands.standard && !draft.value.standard) {
|
||||
validation.value = '新模型需要配置标准档单价。'
|
||||
validation.value = '新模型需要配置标准档单价'
|
||||
selectedBand.value = 'standard'
|
||||
return
|
||||
}
|
||||
@@ -141,13 +141,13 @@ function submit() {
|
||||
</template>
|
||||
<div :id="helpId" class="grid w-72 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
|
||||
<p class="m-0">
|
||||
模型 ID 使用实际发送给上游的名称,不是客户端别名。
|
||||
模型 ID 使用实际发送给上游的名称,不是客户端别名
|
||||
</p>
|
||||
<p class="m-0">
|
||||
自定义档位需填写四项单价,0 表示免费;单价上限 1000000,倍率范围 0~100,最多四位小数。
|
||||
自定义档位需填写四项单价,0 表示免费,单价上限 1000000,倍率范围 0~100,最多四位小数
|
||||
</p>
|
||||
<p class="m-0">
|
||||
仅调整本地估算与金额限额,不代表订阅实际扣费。保存后仅新请求生效,历史账单不变。
|
||||
仅调整本地估算与金额限额,不代表订阅实际扣费,保存后仅新请求生效,历史账单不变
|
||||
</p>
|
||||
</div>
|
||||
</BasePopover>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { PricingCatalog, PricingSyncPreview } from '@/api'
|
||||
import { computed } from 'vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
|
||||
import BaseTag from '@/components/base/BaseTag.vue'
|
||||
import { bands, effectivePrice, priceFields } from './model'
|
||||
|
||||
const props = defineProps<{ preview?: PricingSyncPreview, catalog: PricingCatalog, saving: boolean }>()
|
||||
defineEmits<{ close: [], confirm: [] }>()
|
||||
const emit = defineEmits<{ close: [], confirm: [models: Record<string, string[]>] }>()
|
||||
const selected = shallowRef(new Set<string>())
|
||||
const changes = computed(() => {
|
||||
const items: { id: string, custom: boolean, details: { label: string, before: string, after: string }[] }[] = []
|
||||
const items: { id: string, provider: string, model: string, custom: boolean, details: { label: string, before: string, after: string }[] }[] = []
|
||||
const providers = new Set([...Object.keys(props.catalog.synced), ...Object.keys(props.preview?.prices ?? {})])
|
||||
for (const provider of providers) {
|
||||
const previous = props.catalog.synced[provider] ?? {}
|
||||
@@ -32,37 +34,84 @@ const changes = computed(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
items.push({ id: `${provider}/${model}`, details, custom: !!props.catalog.overrides[provider]?.[model] })
|
||||
items.push({ id: `${provider}/${model}`, provider, model, details, custom: !!props.catalog.overrides[provider]?.[model] })
|
||||
}
|
||||
}
|
||||
return items
|
||||
})
|
||||
const selectedChanges = computed(() => changes.value.filter(item => selected.value.has(item.id)))
|
||||
const allSelected = computed(() => changes.value.length > 0 && selectedChanges.value.length === changes.value.length)
|
||||
const partiallySelected = computed(() => selectedChanges.value.length > 0 && !allSelected.value)
|
||||
|
||||
watch(() => props.preview, () => {
|
||||
selected.value = new Set(props.preview ? changes.value.map(item => item.id) : [])
|
||||
}, { immediate: true })
|
||||
|
||||
function toggle(id: string, checked: boolean) {
|
||||
const next = new Set(selected.value)
|
||||
if (checked)
|
||||
next.add(id)
|
||||
else
|
||||
next.delete(id)
|
||||
selected.value = next
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.saving || !selectedChanges.value.length)
|
||||
return
|
||||
const models: Record<string, string[]> = {}
|
||||
for (const item of selectedChanges.value)
|
||||
(models[item.provider] ??= []).push(item.model)
|
||||
emit('confirm', models)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :model-value="!!preview" title="确认同步来源价目" description="models.dev · USD / 1M Tokens" size="lg" :dismissible="!saving" @update:model-value="!$event && $emit('close')">
|
||||
<BaseModal :model-value="!!preview" title="确认同步来源价目" description="USD / 1M Tokens" size="lg" :dismissible="!saving" @update:model-value="!$event && $emit('close')">
|
||||
<div class="grid gap-4">
|
||||
<p class="m-0 text-cp text-cp-text-secondary">
|
||||
{{ changes.length }} 个模型的来源价格将变化。人工单价和倍率保留;未覆盖的档位会采用新的来源价格。models.dev 是社区价目,不代表订阅实际扣费。
|
||||
仅同步所选模型的来源价格,保留自定义单价和倍率
|
||||
</p>
|
||||
<div class="max-h-80 overflow-auto rounded-cp bg-cp-fill-quaternary p-4">
|
||||
<div v-for="item in changes" :key="item.id" class="grid gap-2 py-3 text-cp-sm">
|
||||
<div class="min-w-0">
|
||||
<div class="break-all font-mono">
|
||||
{{ item.id }}
|
||||
</div><span v-if="item.custom" class="text-cp-xs text-cp-primary-text">保留人工覆盖</span>
|
||||
</div>
|
||||
<div v-for="detail in item.details" :key="detail.label" class="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-cp-xs">
|
||||
<span class="text-cp-text-secondary">{{ detail.label }}</span>
|
||||
<span class="flex items-center gap-2 font-mono tabular-nums">
|
||||
<span class="text-cp-text-tertiary">{{ detail.before }}</span><span aria-hidden="true">→</span><span class="text-cp-primary-text">{{ detail.after }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!changes.length" class="m-0 text-cp-sm text-cp-text-secondary">
|
||||
来源价格没有变化。
|
||||
</p>
|
||||
<div v-if="changes.length" class="flex items-center justify-between gap-3">
|
||||
<BaseCheckbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="partiallySelected"
|
||||
label="全选"
|
||||
show-label
|
||||
:disabled="saving"
|
||||
@update:model-value="selected = new Set($event ? changes.map(item => item.id) : [])"
|
||||
/>
|
||||
<span class="text-cp-sm text-cp-text-tertiary" aria-live="polite">已选 {{ selectedChanges.length }} / {{ changes.length }}</span>
|
||||
</div>
|
||||
<BaseScrollbar max-height="20rem" class="rounded-cp bg-cp-fill-quaternary">
|
||||
<div class="p-4">
|
||||
<div v-for="item in changes" :key="item.id" class="grid gap-2 py-3 text-cp-sm">
|
||||
<BaseCheckbox
|
||||
:model-value="selected.has(item.id)"
|
||||
:label="item.id"
|
||||
show-label
|
||||
:disabled="saving"
|
||||
@update:model-value="toggle(item.id, $event)"
|
||||
>
|
||||
<template #label>
|
||||
<span class="break-all font-mono text-cp-sm font-normal">{{ item.id }}</span>
|
||||
</template>
|
||||
</BaseCheckbox>
|
||||
<span v-if="item.custom" class="pl-6.5 text-cp-xs text-cp-primary-text">保留人工覆盖</span>
|
||||
<div v-if="item.details.length" class="ml-6.5 grid gap-1">
|
||||
<div v-for="detail in item.details" :key="detail.label" class="grid grid-cols-2 items-center gap-x-4 rounded-cp-sm bg-cp-bg-container px-2 py-1.5 text-cp-xs">
|
||||
<span class="text-cp-text-secondary">{{ detail.label }}</span>
|
||||
<span class="flex items-center gap-2 font-mono tabular-nums">
|
||||
<span class="text-cp-text-tertiary">{{ detail.before }}</span><span aria-hidden="true">→</span><span class="text-cp-primary-text">{{ detail.after }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!changes.length" class="m-0 text-cp-sm text-cp-text-secondary">
|
||||
来源价格没有变化
|
||||
</p>
|
||||
</div>
|
||||
</BaseScrollbar>
|
||||
<details v-if="preview?.skipped.length" class="text-cp-sm text-cp-text-secondary">
|
||||
<summary class="cursor-pointer">
|
||||
跳过 {{ preview.skipped.length }} 个缺少完整价格或计价方式不匹配的模型
|
||||
@@ -81,7 +130,7 @@ const changes = computed(() => {
|
||||
<template #footer>
|
||||
<BaseButton :disabled="saving" @click="$emit('close')">
|
||||
取消
|
||||
</BaseButton><BaseButton variant="primary" :loading="saving" @click="$emit('confirm')">
|
||||
</BaseButton><BaseButton variant="primary" :loading="saving" :disabled="!selectedChanges.length" @click="confirm">
|
||||
确认同步
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PricingRow } from './model'
|
||||
import type { BaseTableColumn } from '@/components/base/BaseTable/columns'
|
||||
import { Pencil, Trash2 } from '@lucide/vue'
|
||||
import BaseCheckbox from '@/components/base/BaseCheckbox.vue'
|
||||
import BaseIconButton from '@/components/base/BaseIconButton.vue'
|
||||
import BaseTable from '@/components/base/BaseTable/index.vue'
|
||||
import { effectivePrice, multiplierText, sourceLabels } from './model'
|
||||
import PricingUnit from './PricingUnit.vue'
|
||||
|
||||
defineProps<{ rows: PricingRow[], selected: string[], loading: boolean, disabled: boolean }>()
|
||||
defineEmits<{ toggle: [model: string, checked: boolean], togglePage: [checked: boolean], edit: [row: PricingRow] }>()
|
||||
defineEmits<{ toggle: [model: string, checked: boolean], togglePage: [checked: boolean], edit: [row: PricingRow], delete: [row: PricingRow] }>()
|
||||
const columns: BaseTableColumn<PricingRow>[] = [
|
||||
{ key: 'select', kind: 'selection' },
|
||||
{ key: 'model', label: '模型', kind: 'identity' },
|
||||
@@ -15,7 +17,7 @@ const columns: BaseTableColumn<PricingRow>[] = [
|
||||
{ key: 'output', label: '输出', kind: 'numeric' },
|
||||
{ key: 'cacheRead', label: '缓存读取', kind: 'numeric' },
|
||||
{ key: 'multiplier', label: '倍率', kind: 'numeric', align: 'center' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions', align: 'center' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions' },
|
||||
]
|
||||
|
||||
function summaryBand(row: PricingRow) {
|
||||
@@ -68,14 +70,14 @@ function summaryBand(row: PricingRow) {
|
||||
<span class="font-mono tabular-nums">{{ multiplierText(row.effective.multiplierBps) }}</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-cp-control shrink-0 cursor-pointer touch-manipulation items-center justify-center rounded-cp-sm border-0 bg-transparent p-0 text-cp leading-none font-bold text-cp-text-secondary outline-none transition-colors enabled:hover:text-cp-primary-text focus-visible:ring-2 focus-visible:ring-cp-control-outline disabled:cursor-not-allowed disabled:text-cp-text-disabled motion-reduce:transition-none"
|
||||
:disabled="disabled"
|
||||
@click="$emit('edit', row)"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<div class="flex items-center gap-1">
|
||||
<BaseIconButton size="sm" label="编辑价格" :disabled="disabled" @click="$emit('edit', row)">
|
||||
<Pencil class="size-3.5 text-cp-link" aria-hidden="true" />
|
||||
</BaseIconButton>
|
||||
<BaseIconButton v-if="row.canDelete" size="sm" label="删除价目" :disabled="disabled" @click="$emit('delete', row)">
|
||||
<Trash2 class="size-3.5 text-cp-error" aria-hidden="true" />
|
||||
</BaseIconButton>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
|
||||
@@ -71,7 +71,7 @@ const providerOptions = [
|
||||
</template>
|
||||
<div :id="syncInfoId" class="grid w-64 gap-2 p-3 text-cp-xs leading-relaxed text-cp-text-secondary">
|
||||
<p class="m-0">
|
||||
从 models.dev 获取社区价目,保留人工单价与倍率。
|
||||
从 models.dev 获取社区价目,保留人工单价与倍率
|
||||
</p>
|
||||
<p class="m-0 text-cp-text-tertiary">
|
||||
最近同步:{{ syncTime }}
|
||||
|
||||
@@ -26,10 +26,10 @@ const infoId = useId()
|
||||
单价单位:USD / 1M Tokens
|
||||
</p>
|
||||
<p class="m-0">
|
||||
人工覆盖优先于同步价目,再继承内置价格。仅影响本地估算与金额限额。
|
||||
人工覆盖优先于同步价目,再继承内置价格,仅影响本地估算与金额限额
|
||||
</p>
|
||||
<p class="m-0">
|
||||
表中为标准档生效价;图像模型显示图像 Token 价格。划线为来源价,其他档位在编辑中查看。
|
||||
表中为标准档生效价,图像模型显示图像 Token 价格,划线为来源价,其他档位在编辑中查看
|
||||
</p>
|
||||
</div>
|
||||
</BasePopover>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user