feat: add API access remarks functionality and validation across the application

This commit is contained in:
lzt404 committed 2026-07-27 21:50:25 +08:00
1 parent 027bc0b73b
commit 47cd4b5bd2
6 files changed
+337 -12

No files matched your search

+174
View File
@@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::{
collections::HashSet,
env, fs,
fs::File,
io::{self, Read, Seek, SeekFrom, Write},
@@ -436,6 +437,7 @@ struct GuiConfigFile {
auth_dir: String,
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec<GuiApiKeyEntry>,
api_access_remarks: Vec<GuiApiAccessRemark>,
management_secret_key: String,
usage_statistics_enabled: bool,
plugins_enabled: bool,
@@ -460,6 +462,30 @@ struct GuiApiKeyEntry {
remark: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
struct GuiApiAccessRemark {
provider_section: String,
api_key_hash: String,
remark: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiAccessRemarkQuery {
provider_section: String,
api_keys: Vec<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiAccessRemarkUpdate {
provider_section: String,
previous_api_keys: Vec<String>,
api_keys: Vec<String>,
remark: String,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum GuiApiKeyInput {
@@ -500,6 +526,7 @@ impl Default for GuiConfigFile {
.map(|path| path_to_string(&path))
.unwrap_or_else(|| OAUTH_DIR_NAME.to_string()),
api_keys: vec![default_api_key_entry()],
api_access_remarks: Vec::new(),
// Keep plaintext here for management API auth. Core hashes the
// value written into config.yaml on startup.
management_secret_key: DEFAULT_MANAGEMENT_SECRET_KEY.to_string(),
@@ -523,6 +550,7 @@ struct GuiConfigPresence {
host: Option<String>,
auth_dir: Option<String>,
api_keys: Option<Vec<GuiApiKeyInput>>,
api_access_remarks: Option<Vec<GuiApiAccessRemark>>,
management_secret_key: Option<String>,
codex_session_repair_on_launch: Option<bool>,
usage_statistics_enabled: Option<bool>,
@@ -1391,6 +1419,75 @@ fn get_gui_settings(
Ok(GuiSettings::from(&config))
}
#[tauri::command]
fn resolve_api_access_remarks(
queries: Vec<ApiAccessRemarkQuery>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<String>, String> {
let config = gui_config_state.snapshot()?;
queries
.into_iter()
.map(|query| {
validate_api_access_provider_section(&query.provider_section)?;
Ok(query
.api_keys
.iter()
.filter_map(|key| api_access_key_hash(key))
.find_map(|hash| {
config.api_access_remarks.iter().find(|entry| {
entry.provider_section == query.provider_section
&& entry.api_key_hash == hash
})
})
.map(|entry| entry.remark.clone())
.unwrap_or_default())
})
.collect()
}
#[tauri::command]
fn save_api_access_remark(
update: ApiAccessRemarkUpdate,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<(), String> {
validate_api_access_provider_section(&update.provider_section)?;
let remark = update.remark.trim().to_string();
validate_api_key_remark(&remark)?;
let previous_hashes = update
.previous_api_keys
.iter()
.filter_map(|key| api_access_key_hash(key))
.collect::<HashSet<_>>();
let next_hashes = update
.api_keys
.iter()
.filter_map(|key| api_access_key_hash(key))
.collect::<HashSet<_>>();
let hashes_to_replace = previous_hashes
.union(&next_hashes)
.cloned()
.collect::<HashSet<_>>();
let provider_section = update.provider_section;
gui_config_state.update(|config| {
config.api_access_remarks.retain(|entry| {
entry.provider_section != provider_section
|| !hashes_to_replace.contains(&entry.api_key_hash)
});
if !remark.is_empty() {
config
.api_access_remarks
.extend(next_hashes.iter().map(|hash| GuiApiAccessRemark {
provider_section: provider_section.clone(),
api_key_hash: hash.clone(),
remark: remark.clone(),
}));
}
Ok(())
})?;
Ok(())
}
#[tauri::command]
fn set_app_locale(
app: tauri::AppHandle,
@@ -10328,6 +10425,52 @@ fn validate_api_key_remark(remark: &str) -> Result<(), String> {
Ok(())
}
fn validate_api_access_provider_section(section: &str) -> Result<(), String> {
if matches!(
section,
"gemini-api-key" | "codex-api-key" | "claude-api-key" | "openai-compatibility"
) {
Ok(())
} else {
Err("API 接入类型无效".to_string())
}
}
fn api_access_key_hash(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| sha256_bytes(value.as_bytes()))
}
fn usage_provider_section(provider: &str) -> Option<&'static str> {
match provider.trim().to_ascii_lowercase().as_str() {
"codex" => Some("codex-api-key"),
"claude" => Some("claude-api-key"),
"gemini" | "aistudio" => Some("gemini-api-key"),
"openai" | "openai-compatibility" => Some("openai-compatibility"),
_ => None,
}
}
impl GuiConfigFile {
fn api_access_remark_for_source(&self, provider: &str, source: &str) -> Option<&str> {
let hash = api_access_key_hash(source)?;
let preferred_section = usage_provider_section(provider);
self.api_access_remarks
.iter()
.find(|entry| {
preferred_section == Some(entry.provider_section.as_str())
&& entry.api_key_hash == hash
&& !entry.remark.is_empty()
})
.or_else(|| {
self.api_access_remarks
.iter()
.find(|entry| entry.api_key_hash == hash && !entry.remark.is_empty())
})
.map(|entry| entry.remark.as_str())
}
}
fn validate_management_secret_key(secret_key: &str) -> Result<(), String> {
if secret_key.chars().count() > 512 {
return Err("管理密钥不能超过 512 个字符".to_string());
@@ -11184,6 +11327,22 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul
api_keys.push(Value::InlineTable(table));
}
set_codex_table_item(root, "api-keys", Item::Value(Value::Array(api_keys)));
let mut api_access_remarks = Array::new();
for entry in &config.api_access_remarks {
let mut table = InlineTable::new();
table.insert(
"provider-section",
Value::from(entry.provider_section.as_str()),
);
table.insert("api-key-hash", Value::from(entry.api_key_hash.as_str()));
table.insert("remark", Value::from(entry.remark.as_str()));
api_access_remarks.push(Value::InlineTable(table));
}
set_codex_table_item(
root,
"api-access-remarks",
Item::Value(Value::Array(api_access_remarks)),
);
let content = document.to_string();
toml::from_str::<GuiConfigFile>(&content)
@@ -11208,6 +11367,18 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> {
validate_core_api_key(&entry.key)?;
validate_api_key_remark(&entry.remark)?;
}
for entry in &config.api_access_remarks {
validate_api_access_provider_section(&entry.provider_section)?;
if entry.api_key_hash.len() != 64
|| !entry
.api_key_hash
.chars()
.all(|character| character.is_ascii_hexdigit())
{
return Err("API 接入备注的密钥指纹无效".to_string());
}
validate_api_key_remark(&entry.remark)?;
}
validate_management_secret_key(&config.management_secret_key)?;
validate_routing_strategy(config.routing_strategy.trim())?;
if config.proxy_url.chars().any(char::is_control) {
@@ -13397,6 +13568,8 @@ fn main() {
detect_core_platform,
get_core_status,
get_gui_settings,
resolve_api_access_remarks,
save_api_access_remark,
set_app_locale,
resolve_windows_close_request,
get_agent_config_statuses,
@@ -15768,6 +15941,7 @@ custom_option = "keep-original"
remark: "测试密钥".to_string(),
},
],
api_access_remarks: Vec::new(),
management_secret_key: String::new(),
usage_statistics_enabled: false,
plugins_enabled: true,
+83 -8
View File
@@ -98,6 +98,8 @@ pub(crate) struct UsageRecord {
#[serde(default)]
source: String,
#[serde(default)]
source_display: String,
#[serde(default)]
auth_index: String,
#[serde(default)]
failed: bool,
@@ -769,6 +771,7 @@ fn normalize_usage_record(value: Value, config: &GuiConfigFile) -> Result<UsageR
latency_ms: u64_field(object, "latency_ms"),
ttft_ms: optional_u64_field(object, "ttft_ms"),
source: string_field(object, "source").unwrap_or_default(),
source_display: String::new(),
auth_index: string_field(object, "auth_index").unwrap_or_default(),
failed: object
.get("failed")
@@ -959,23 +962,40 @@ fn load_usage_overview(
}
#[tauri::command]
pub(crate) fn get_usage_analysis(query: UsageQuery) -> Result<UsageAnalysis, String> {
pub(crate) fn get_usage_analysis(
query: UsageQuery,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<UsageAnalysis, String> {
let connection = open_usage_database()?;
load_usage_analysis(&connection, &query)
let config = gui_config_state.snapshot()?;
load_usage_analysis(&connection, &query, &config)
}
fn load_usage_analysis(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<UsageAnalysis, String> {
Ok(UsageAnalysis {
models: load_simple_categories(connection, query, "model", "unknown")?,
providers: load_simple_categories(connection, query, "provider", "未知 Provider")?,
sources: load_simple_categories(connection, query, "source", "未知来源")?,
sources: load_source_categories(connection, query, config)?,
api_keys: load_api_key_categories(connection, query)?,
})
}
fn load_source_categories(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<Vec<UsageCategory>, String> {
let mut categories = load_simple_categories(connection, query, "source", "未知来源")?;
for category in &mut categories {
category.label = usage_source_display(config, "", &category.key);
}
Ok(categories)
}
fn load_simple_categories(
connection: &Connection,
query: &UsageQuery,
@@ -1073,14 +1093,19 @@ fn load_api_key_categories(
}
#[tauri::command]
pub(crate) fn get_usage_events(query: UsageQuery) -> Result<UsageEventPage, String> {
pub(crate) fn get_usage_events(
query: UsageQuery,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<UsageEventPage, String> {
let connection = open_usage_database()?;
load_usage_events(&connection, &query)
let config = gui_config_state.snapshot()?;
load_usage_events(&connection, &query, &config)
}
fn load_usage_events(
connection: &Connection,
query: &UsageQuery,
config: &GuiConfigFile,
) -> Result<UsageEventPage, String> {
let filter = build_usage_filter(query);
let total_sql = format!("SELECT COUNT(*) FROM usage_events{}", filter.clause);
@@ -1117,11 +1142,14 @@ fn load_usage_events(
let mut statement = connection
.prepare(&sql)
.map_err(|error| format!("准备 SQLite 使用事件查询失败: {error}"))?;
let items = statement
let mut items = statement
.query_map(params_from_iter(values.iter()), usage_record_from_row)
.map_err(|error| format!("查询 SQLite 使用事件失败: {error}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("读取 SQLite 使用事件失败: {error}"))?;
for item in &mut items {
item.source_display = usage_source_display(config, &item.provider, &item.source);
}
Ok(UsageEventPage {
items,
total,
@@ -1138,6 +1166,7 @@ fn usage_record_from_row(row: &Row<'_>) -> rusqlite::Result<UsageRecord> {
latency_ms: from_sql_i64(row.get(2)?),
ttft_ms: row.get::<_, Option<i64>>(3)?.map(from_sql_i64),
source: row.get(4)?,
source_display: String::new(),
auth_index: row.get(5)?,
failed: row.get::<_, i64>(6)? != 0,
provider: row.get(7)?,
@@ -1296,6 +1325,28 @@ fn mask_api_key(value: &str) -> String {
format!("{start}••••{end}")
}
fn usage_source_display(config: &GuiConfigFile, provider: &str, source: &str) -> String {
let source = source.trim();
if source.is_empty() {
return "未知来源".to_string();
}
if let Some(remark) = config.api_access_remark_for_source(provider, source) {
return remark.to_string();
}
let character_count = source.chars().count();
let looks_like_secret = !source.contains('@')
&& !source.chars().any(char::is_whitespace)
&& (source.starts_with("sk-")
|| source.starts_with("AIza")
|| source.starts_with("key-")
|| character_count >= 48);
if looks_like_secret {
mask_api_key(source)
} else {
source.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1331,6 +1382,7 @@ mod tests {
latency_ms: 100,
ttft_ms: Some(20),
source: "source".to_string(),
source_display: String::new(),
auth_index: "auth".to_string(),
failed: false,
provider: "openai".to_string(),
@@ -1363,6 +1415,27 @@ mod tests {
assert_eq!(mask_api_key("sk-1234567890"), "sk-1••••7890");
}
#[test]
fn usage_source_prefers_api_access_remark_and_masks_secret_fallbacks() {
let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
let mut config = GuiConfigFile::default();
config.api_access_remarks.push(crate::GuiApiAccessRemark {
provider_section: "codex-api-key".to_string(),
api_key_hash: hash_text(key),
remark: "生产环境".to_string(),
});
assert_eq!(usage_source_display(&config, "codex", key), "生产环境");
assert_eq!(
usage_source_display(&GuiConfigFile::default(), "codex", key),
"sk-1••••wxyz"
);
assert_eq!(
usage_source_display(&config, "codex", "account@example.com"),
"account@example.com"
);
}
#[test]
fn local_hour_key_uses_year_month_day_and_hour() {
let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model");
@@ -1381,6 +1454,7 @@ mod tests {
window_height: None,
auth_dir: String::new(),
api_keys: Vec::new(),
api_access_remarks: Vec::new(),
management_secret_key: "123456".to_string(),
usage_statistics_enabled: true,
plugins_enabled: false,
@@ -1522,8 +1596,9 @@ mod tests {
};
let overview = load_usage_overview(&connection, &query).unwrap();
let analysis = load_usage_analysis(&connection, &query).unwrap();
let events = load_usage_events(&connection, &query).unwrap();
let config = GuiConfigFile::default();
let analysis = load_usage_analysis(&connection, &query, &config).unwrap();
let events = load_usage_events(&connection, &query, &config).unwrap();
assert_eq!(overview.total_requests, 1);
assert_eq!(overview.failure_count, 1);
+4
View File
@@ -529,6 +529,7 @@ export const jaOverrides = {
'apiAccess.error.noModels': '公開可能なモデルが見つかりません。Base URL と API キーを確認してください',
'apiAccess.error.stale': '接続設定が別の操作で変更されました。更新してから再試行してください',
'apiAccess.error.duplicate': '同じ接続設定がすでに存在します',
'apiAccess.error.remarkInvalid': 'メモは 80 文字以内で、改行や制御文字を含めないでください',
'apiAccess.notice.updated': '接続を更新しました',
'apiAccess.notice.added': '接続を追加しました',
'apiAccess.deleteConfirm': '「{name}」を削除しますか?',
@@ -547,6 +548,7 @@ export const jaOverrides = {
'apiAccess.empty.addFirst': '右上の「追加」から最初の接続を追加してください',
'apiAccess.keys.count': 'キー {count} 件',
'apiAccess.keys.summary': '{key} · 合計 {count} 件',
'apiAccess.remarkValue': 'メモ:{remark}',
'apiAccess.defaultUrl': '既定のアドレスを使用',
'apiAccess.models.summary': 'モデル {count} 件 · {models}',
'apiAccess.priorityValue': '優先度 {priority}',
@@ -572,6 +574,8 @@ export const jaOverrides = {
'apiAccess.field.name': '名前',
'apiAccess.field.keysMany': 'API キー(1 行に 1 件)',
'apiAccess.field.key': 'API キー',
'apiAccess.field.remark': 'メモ',
'apiAccess.remarkPlaceholder': '例:本番環境、チームのメインアカウント',
'apiAccess.baseRequiredPlaceholder': '必須、例:https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '任意。空欄の場合は既定のアドレスを使用',
'apiAccess.preset.title': 'OpenAI 互換プリセット',
+8
View File
@@ -532,6 +532,7 @@ export const zhCN = {
'apiAccess.error.noModels': '没有发现可放行的模型,请确认 Base URL 和 API 密钥',
'apiAccess.error.stale': '接入配置已被其他操作修改,请刷新后重试',
'apiAccess.error.duplicate': '相同的接入配置已经存在',
'apiAccess.error.remarkInvalid': '备注不能超过 80 个字符,且不能包含换行或控制字符',
'apiAccess.notice.updated': '接入已更新',
'apiAccess.notice.added': '接入已新增',
'apiAccess.deleteConfirm': '确定删除“{name}”吗?',
@@ -550,6 +551,7 @@ export const zhCN = {
'apiAccess.empty.addFirst': '点击右上角“新增”添加第一个接入',
'apiAccess.keys.count': '{count} 个密钥',
'apiAccess.keys.summary': '{key} · 共 {count} 个密钥',
'apiAccess.remarkValue': '备注:{remark}',
'apiAccess.defaultUrl': '使用默认地址',
'apiAccess.models.summary': '模型 {count} 个 · {models}',
'apiAccess.priorityValue': '优先级 {priority}',
@@ -575,6 +577,8 @@ export const zhCN = {
'apiAccess.field.name': '名称',
'apiAccess.field.keysMany': 'API 密钥(每行一个)',
'apiAccess.field.key': 'API 密钥',
'apiAccess.field.remark': '备注',
'apiAccess.remarkPlaceholder': '例如:生产环境、团队主账号',
'apiAccess.baseRequiredPlaceholder': '必填,例如 https://api.example.com',
'apiAccess.baseOptionalPlaceholder': '可选,留空使用默认地址',
'apiAccess.preset.title': 'OpenAI 兼容预设',
@@ -1344,6 +1348,7 @@ export const en: Record<MessageKey, string> = {
'apiAccess.error.noModels': 'No models were discovered. Check the Base URL and API key.',
'apiAccess.error.stale': 'The connection configuration changed elsewhere. Refresh and try again.',
'apiAccess.error.duplicate': 'An identical connection already exists',
'apiAccess.error.remarkInvalid': 'The remark cannot exceed 80 characters or contain line breaks or control characters',
'apiAccess.notice.updated': 'Connection updated',
'apiAccess.notice.added': 'Connection added',
'apiAccess.deleteConfirm': 'Delete “{name}”?',
@@ -1362,6 +1367,7 @@ export const en: Record<MessageKey, string> = {
'apiAccess.empty.addFirst': 'Click “Add” in the upper-right to create your first connection',
'apiAccess.keys.count': '{count} keys',
'apiAccess.keys.summary': '{key} · {count} keys total',
'apiAccess.remarkValue': 'Remark: {remark}',
'apiAccess.defaultUrl': 'Using default address',
'apiAccess.models.summary': '{count} models · {models}',
'apiAccess.priorityValue': 'Priority {priority}',
@@ -1387,6 +1393,8 @@ export const en: Record<MessageKey, string> = {
'apiAccess.field.name': 'Name',
'apiAccess.field.keysMany': 'API Keys (one per line)',
'apiAccess.field.key': 'API Key',
'apiAccess.field.remark': 'Remark',
'apiAccess.remarkPlaceholder': 'For example, Production or Team primary account',
'apiAccess.baseRequiredPlaceholder': 'Required, for example https://api.example.com',
'apiAccess.baseOptionalPlaceholder': 'Optional; leave blank to use the default address',
'apiAccess.preset.title': 'OpenAI-Compatible Preset',
+66 -3
View File
@@ -1,4 +1,5 @@
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
Check,
Edit3,
@@ -67,11 +68,13 @@ type ProviderRow = {
disabled: boolean;
priority: number | null;
authIndex: string;
remark: string;
};
export type ProviderDraft = {
name: string;
apiKey: string;
remark: string;
baseUrl: string;
priority: string;
models: ModelOption[];
@@ -182,9 +185,13 @@ const rowFromRecord = (
authIndex: entry
? readString(entry, 'auth-index', 'authIndex')
: readString(record, 'auth-index', 'authIndex'),
remark: '',
};
};
const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) =>
`${section}\u0000${apiKeys.join('\u0000')}`;
export const stripResponseFields = (record: Record<string, unknown>) => {
const next = { ...record };
delete next['auth-index'];
@@ -316,6 +323,7 @@ const thinkingLevelsFromModels = (models: ModelOption[]): string[] => {
const draftFromRow = (row: ProviderRow): ProviderDraft => ({
name: row.name,
apiKey: definitionFor(row.section).openAi ? row.apiKeys.join('\n') : row.apiKey,
remark: row.remark,
baseUrl: row.baseUrl,
priority: row.priority === null ? '' : String(row.priority),
models: row.models,
@@ -351,6 +359,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => ({
const emptyProviderDraft = (): ProviderDraft => ({
name: '',
apiKey: '',
remark: '',
baseUrl: '',
priority: '',
models: [],
@@ -604,6 +613,7 @@ export function ApiAccessPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingRow, setEditingRow] = useState<ProviderRow | null>(null);
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
const activeDefinition = definitionFor(activeCategory);
const activeSection = activeDefinition.section;
@@ -647,20 +657,51 @@ export function ApiAccessPage() {
void loadProviders();
}, [loadProviders]);
useEffect(() => {
const providerRows = (Object.entries(records) as [ProviderSection, Record<string, unknown>[]][])
.flatMap(([section, items]) => items.map((record, index) => rowFromRecord(section, record, index)));
if (providerRows.length === 0) {
setApiAccessRemarks({});
return;
}
let disposed = false;
void invoke<string[]>('resolve_api_access_remarks', {
queries: providerRows.map((row) => ({
providerSection: row.section,
apiKeys: row.apiKeys,
})),
}).then((remarks) => {
if (disposed) return;
setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [
providerRemarkIdentity(row.section, row.apiKeys),
remarks[index] ?? '',
])));
}).catch(() => {
if (!disposed) setApiAccessRemarks({});
});
return () => {
disposed = true;
};
}, [records]);
const rows = useMemo(
() =>
records[activeSection]
.map((record, index) => rowFromRecord(activeSection, record, index))
.map((row) => ({
...row,
remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '',
}))
.filter((row) => providerCategoryMatchesRecord(activeCategory, row.record))
.filter((row) => {
const query = filter.trim().toLowerCase();
if (!query) return true;
return [row.name, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
return [row.name, row.remark, row.apiKey, row.baseUrl, row.models.map((model) => model.name).join(' ')]
.join(' ')
.toLowerCase()
.includes(query);
}),
[activeCategory, activeSection, filter, records],
[activeCategory, activeSection, apiAccessRemarks, filter, records],
);
const openCreate = () => {
@@ -700,6 +741,10 @@ export function ApiAccessPage() {
);
return false;
}
if (Array.from(preparedDraft.remark.trim()).length > 80 || /[\u0000-\u001f\u007f]/.test(preparedDraft.remark)) {
setError(t('apiAccess.error.remarkInvalid'));
return false;
}
let baseUrl = preparedDraft.baseUrl.trim();
let providerHeaders: Record<string, string> = {};
try {
@@ -762,6 +807,14 @@ export function ApiAccessPage() {
}
await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields));
await invoke('save_api_access_remark', {
update: {
providerSection: activeSection,
previousApiKeys: editingRow?.apiKeys ?? [],
apiKeys: parsedApiKeys,
remark: draftToSave.remark,
},
});
setNotice(editingRow ? t('apiAccess.notice.updated') : t('apiAccess.notice.added'));
await loadProviders();
return true;
@@ -785,6 +838,14 @@ export function ApiAccessPage() {
query: { 'api-key': row.apiKey, 'base-url': row.baseUrl },
});
}
await invoke('save_api_access_remark', {
update: {
providerSection: row.section,
previousApiKeys: row.apiKeys,
apiKeys: [],
remark: '',
},
});
setNotice(t('apiAccess.notice.deleted'));
await loadProviders();
} catch (requestError) {
@@ -915,6 +976,7 @@ export function ApiAccessPage() {
? t('apiAccess.keys.summary', { key: maskSecret(row.apiKey), count: row.apiKeys.length })
: maskSecret(row.apiKey)}
</code>
{row.remark ? <span className="provider-row-remark" title={row.remark}>{t('apiAccess.remarkValue', { remark: row.remark })}</span> : null}
<span className="provider-row-url" title={row.baseUrl || undefined}>{row.baseUrl || t('apiAccess.defaultUrl')}</span>
{row.models.length > 0 ? <span className="provider-row-models" title={row.models.map((model) => model.name).join(', ')}>{t('apiAccess.models.summary', { count: row.models.length, models: row.models.slice(0, 3).map((model) => model.name).join(', ') })}</span> : null}
</div>
@@ -1016,7 +1078,7 @@ function ApiProviderDialog({
&& visibleModelOptions.every((model) => selectedModelNames.has(model.name.toLowerCase()));
const updateTextField = (
field: 'name' | 'apiKey' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
field: 'name' | 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText',
value: string,
) => {
setDraft((current) => ({ ...current, [field]: value }));
@@ -1189,6 +1251,7 @@ function ApiProviderDialog({
<input autoFocus type="password" value={draft.apiKey} onChange={(event) => updateTextField('apiKey', event.currentTarget.value)} placeholder="sk-..." />
)}
</label>
<label><span>{t('apiAccess.field.remark')}</span><input value={draft.remark} maxLength={80} onChange={(event) => updateTextField('remark', event.currentTarget.value)} placeholder={t('apiAccess.remarkPlaceholder')} /></label>
<label><span>Base URL</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
{activeCategory === 'deepseek' ? (
<div className="provider-preset-summary">
+2 -1
View File
@@ -60,6 +60,7 @@ type UsageRecord = {
latency_ms: number;
ttft_ms: number | null;
source: string;
source_display: string;
failed: boolean;
provider: string;
model: string;
@@ -384,7 +385,7 @@ function CategoryPanel({ title, items, compactLabels = false }: { title: string;
function EventsView({ events, onPage }: { events: UsageEventPage; onPage: (page: number) => void }) {
const { t } = useI18n();
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source}>{record.source || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
return <section className="panel usage-events-panel"><div className="usage-events-summary"><span>{t('usage.events.total', { count: compactNumber(events.total) })}</span><span>{t('usage.events.page', { page: events.page, total: events.totalPages })}</span></div>{events.items.length ? <div className="usage-table-wrap"><table className="usage-events-table"><thead><tr><th>{t('usage.column.time')}</th><th>{t('usage.column.model')}</th><th>Provider</th><th>{t('usage.column.source')}</th><th>API Key</th><th>{t('usage.column.result')}</th><th>{t('usage.column.latency')}</th><th>TTFT</th><th>{t('usage.column.input')}</th><th>{t('usage.column.output')}</th><th>{t('usage.column.reasoning')}</th><th>{t('usage.column.cache')}</th><th>{t('usage.column.total')}</th></tr></thead><tbody>{events.items.map((record) => <tr key={record.id}><td>{formatTime(record.timestamp)}</td><td className="usage-stacked-cell"><strong title={record.alias || record.model}>{record.alias || record.model}</strong>{record.alias || record.reasoning_effort ? <small title={record.model}>{record.alias ? record.model : ''}{record.alias && record.reasoning_effort ? ' · ' : ''}{record.reasoning_effort}</small> : null}</td><td title={record.provider}>{record.provider || '—'}</td><td title={record.source_display || undefined}>{record.source_display || '—'}</td><td className="usage-stacked-cell"><strong title={record.api_key_remark}>{record.api_key_remark || t('usage.key.noRemark')}</strong><small>{record.api_key_display || '—'}</small></td><td><span className={`usage-result ${record.failed ? 'failed' : 'success'}`}>{record.failed ? t('usage.result.failed') : t('usage.result.success')}</span></td><td>{record.latency_ms} ms</td><td>{record.ttft_ms == null ? '—' : `${record.ttft_ms} ms`}</td><td>{compactNumber(record.tokens.input_tokens)}</td><td>{compactNumber(record.tokens.output_tokens)}</td><td>{compactNumber(record.tokens.reasoning_tokens)}</td><td>{compactNumber(record.tokens.cache_read_tokens)}</td><td><strong>{compactNumber(record.tokens.total_tokens)}</strong></td></tr>)}</tbody></table></div> : <UsageEmpty />}<div className="usage-pagination"><button type="button" className="secondary-button" disabled={events.page <= 1} onClick={() => onPage(events.page - 1)}>{t('usage.previous')}</button><button type="button" className="secondary-button" disabled={events.page >= events.totalPages} onClick={() => onPage(events.page + 1)}>{t('usage.next')}</button></div></section>;
}
function UsageEmpty() {