feat: add API access remarks functionality and validation across the application
This commit is contained in:
1 parent
027bc0b73b
commit
47cd4b5bd2
6 files changed
+337
-12
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 互換プリセット',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user