1 parent
bd57545c7f
commit
abfe65e656
7 files changed
+533
-71
No files matched your search
+387
-43
@@ -45,21 +45,7 @@ pub(crate) fn resolve_api_access_remarks(
|
||||
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())
|
||||
})
|
||||
.map(|query| resolve_api_access_remark(&config, &query))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -68,44 +54,402 @@ pub(crate) 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
|
||||
gui_config_state.update(|config| apply_api_access_remark_update(config, update))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn api_access_locator_identity(
|
||||
provider_section: &str,
|
||||
locator: &ApiAccessRemarkLocator,
|
||||
) -> Option<(String, Vec<String>)> {
|
||||
let mut api_key_hashes = locator
|
||||
.api_keys
|
||||
.iter()
|
||||
.filter_map(|key| api_access_key_hash(key))
|
||||
.collect::<HashSet<_>>();
|
||||
let hashes_to_replace = previous_hashes
|
||||
.union(&next_hashes)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
api_key_hashes.sort();
|
||||
api_key_hashes.dedup();
|
||||
if api_key_hashes.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut identity = Vec::new();
|
||||
for component in [
|
||||
provider_section.trim(),
|
||||
locator.provider_name.trim(),
|
||||
locator.base_url.trim(),
|
||||
] {
|
||||
identity.extend_from_slice(&(component.len() as u64).to_be_bytes());
|
||||
identity.extend_from_slice(component.as_bytes());
|
||||
}
|
||||
identity.extend_from_slice(&(api_key_hashes.len() as u64).to_be_bytes());
|
||||
for hash in &api_key_hashes {
|
||||
identity.extend_from_slice(&(hash.len() as u64).to_be_bytes());
|
||||
identity.extend_from_slice(hash.as_bytes());
|
||||
}
|
||||
Some((sha256_bytes(&identity), api_key_hashes))
|
||||
}
|
||||
|
||||
fn resolve_api_access_remark(
|
||||
config: &GuiConfigFile,
|
||||
query: &ApiAccessRemarkQuery,
|
||||
) -> Result<String, String> {
|
||||
validate_api_access_provider_section(&query.provider_section)?;
|
||||
let Some((record_hash, api_key_hashes)) =
|
||||
api_access_locator_identity(&query.provider_section, &query.locator)
|
||||
else {
|
||||
return Ok(String::new());
|
||||
};
|
||||
|
||||
let exact_entries = config.api_access_remarks.iter().filter(|entry| {
|
||||
entry.provider_section == query.provider_section
|
||||
&& entry.record_hash == record_hash
|
||||
&& api_key_hashes.contains(&entry.api_key_hash)
|
||||
});
|
||||
let mut exact_found = false;
|
||||
let mut exact_remark = String::new();
|
||||
for entry in exact_entries {
|
||||
exact_found = true;
|
||||
if !entry.remark.is_empty() {
|
||||
exact_remark = entry.remark.clone();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if exact_found {
|
||||
return Ok(exact_remark);
|
||||
}
|
||||
|
||||
Ok(api_key_hashes
|
||||
.iter()
|
||||
.find_map(|hash| {
|
||||
config.api_access_remarks.iter().find(|entry| {
|
||||
entry.provider_section == query.provider_section
|
||||
&& entry.record_hash.is_empty()
|
||||
&& entry.api_key_hash == *hash
|
||||
})
|
||||
})
|
||||
.map(|entry| entry.remark.clone())
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn apply_api_access_remark_update(
|
||||
config: &mut GuiConfigFile,
|
||||
update: ApiAccessRemarkUpdate,
|
||||
) -> Result<(), String> {
|
||||
validate_api_access_provider_section(&update.provider_section)?;
|
||||
let remark = update.remark.trim().to_string();
|
||||
validate_api_key_remark(&remark)?;
|
||||
let previous_records = update
|
||||
.previous_records
|
||||
.iter()
|
||||
.filter_map(|locator| api_access_locator_identity(&update.provider_section, locator))
|
||||
.collect::<Vec<_>>();
|
||||
let next_records = update
|
||||
.records
|
||||
.iter()
|
||||
.filter_map(|locator| api_access_locator_identity(&update.provider_section, locator))
|
||||
.collect::<Vec<_>>();
|
||||
let all_records = update
|
||||
.all_records
|
||||
.iter()
|
||||
.filter_map(|locator| api_access_locator_identity(&update.provider_section, locator))
|
||||
.collect::<Vec<_>>();
|
||||
let record_hashes_to_replace = previous_records
|
||||
.iter()
|
||||
.chain(next_records.iter())
|
||||
.map(|(record_hash, _)| record_hash.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
let provider_section = update.provider_section;
|
||||
let legacy_entries = config
|
||||
.api_access_remarks
|
||||
.iter()
|
||||
.filter(|entry| entry.provider_section == provider_section && entry.record_hash.is_empty())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let legacy_migrations = all_records
|
||||
.iter()
|
||||
.filter_map(|(record_hash, api_key_hashes)| {
|
||||
let remark = api_key_hashes.iter().find_map(|api_key_hash| {
|
||||
legacy_entries
|
||||
.iter()
|
||||
.find(|entry| entry.api_key_hash == *api_key_hash)
|
||||
.map(|entry| entry.remark.clone())
|
||||
})?;
|
||||
Some((record_hash.clone(), api_key_hashes.clone(), remark))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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(),
|
||||
}));
|
||||
config.api_access_remarks.retain(|entry| {
|
||||
entry.provider_section != provider_section
|
||||
|| (!entry.record_hash.is_empty()
|
||||
&& !record_hashes_to_replace.contains(&entry.record_hash))
|
||||
});
|
||||
let mut inserted_record_hashes = config
|
||||
.api_access_remarks
|
||||
.iter()
|
||||
.filter(|entry| entry.provider_section == provider_section && !entry.record_hash.is_empty())
|
||||
.map(|entry| entry.record_hash.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
for (record_hash, api_key_hashes) in next_records {
|
||||
if !inserted_record_hashes.insert(record_hash.clone()) {
|
||||
continue;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
config
|
||||
.api_access_remarks
|
||||
.extend(
|
||||
api_key_hashes
|
||||
.into_iter()
|
||||
.map(|api_key_hash| GuiApiAccessRemark {
|
||||
provider_section: provider_section.clone(),
|
||||
api_key_hash,
|
||||
record_hash: record_hash.clone(),
|
||||
remark: remark.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (record_hash, api_key_hashes, legacy_remark) in legacy_migrations {
|
||||
if !inserted_record_hashes.insert(record_hash.clone()) {
|
||||
continue;
|
||||
}
|
||||
config
|
||||
.api_access_remarks
|
||||
.extend(
|
||||
api_key_hashes
|
||||
.into_iter()
|
||||
.map(|api_key_hash| GuiApiAccessRemark {
|
||||
provider_section: provider_section.clone(),
|
||||
api_key_hash,
|
||||
record_hash: record_hash.clone(),
|
||||
remark: legacy_remark.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn locator(base_url: &str, api_keys: &[&str]) -> ApiAccessRemarkLocator {
|
||||
ApiAccessRemarkLocator {
|
||||
provider_name: String::new(),
|
||||
base_url: base_url.to_string(),
|
||||
api_keys: api_keys.iter().map(|key| (*key).to_string()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn query(locator: ApiAccessRemarkLocator) -> ApiAccessRemarkQuery {
|
||||
ApiAccessRemarkQuery {
|
||||
provider_section: "codex-api-key".to_string(),
|
||||
locator,
|
||||
}
|
||||
}
|
||||
|
||||
fn update(
|
||||
previous_records: Vec<ApiAccessRemarkLocator>,
|
||||
records: Vec<ApiAccessRemarkLocator>,
|
||||
all_records: Vec<ApiAccessRemarkLocator>,
|
||||
remark: &str,
|
||||
) -> ApiAccessRemarkUpdate {
|
||||
ApiAccessRemarkUpdate {
|
||||
provider_section: "codex-api-key".to_string(),
|
||||
previous_records,
|
||||
records,
|
||||
all_records,
|
||||
remark: remark.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remarks_are_scoped_to_provider_records_that_share_an_api_key() {
|
||||
let mut config = GuiConfigFile::default();
|
||||
let first = locator("https://first.example/v1", &["shared-key"]);
|
||||
let second = locator("https://second.example/v1", &["shared-key"]);
|
||||
|
||||
apply_api_access_remark_update(
|
||||
&mut config,
|
||||
update(
|
||||
Vec::new(),
|
||||
vec![first.clone()],
|
||||
vec![first.clone()],
|
||||
"first",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
apply_api_access_remark_update(
|
||||
&mut config,
|
||||
update(
|
||||
Vec::new(),
|
||||
vec![second.clone()],
|
||||
vec![first.clone(), second.clone()],
|
||||
"second",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(first.clone())).unwrap(),
|
||||
"first"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(second.clone())).unwrap(),
|
||||
"second"
|
||||
);
|
||||
|
||||
apply_api_access_remark_update(
|
||||
&mut config,
|
||||
update(vec![first.clone()], Vec::new(), vec![second.clone()], ""),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(second)).unwrap(),
|
||||
"second"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_scoped_remarks_override_legacy_key_scoped_remarks() {
|
||||
let mut config = GuiConfigFile::default();
|
||||
let first = locator("https://first.example/v1", &["shared-key"]);
|
||||
let second = locator("https://second.example/v1", &["shared-key"]);
|
||||
config.api_access_remarks.push(GuiApiAccessRemark {
|
||||
provider_section: "codex-api-key".to_string(),
|
||||
api_key_hash: api_access_key_hash("shared-key").unwrap(),
|
||||
record_hash: String::new(),
|
||||
remark: "legacy".to_string(),
|
||||
});
|
||||
|
||||
apply_api_access_remark_update(
|
||||
&mut config,
|
||||
update(
|
||||
vec![first.clone()],
|
||||
vec![first.clone()],
|
||||
vec![first.clone(), second.clone()],
|
||||
"updated",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(first.clone())).unwrap(),
|
||||
"updated"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(second.clone())).unwrap(),
|
||||
"legacy"
|
||||
);
|
||||
|
||||
apply_api_access_remark_update(
|
||||
&mut config,
|
||||
update(
|
||||
vec![first.clone()],
|
||||
vec![first.clone()],
|
||||
vec![first.clone(), second.clone()],
|
||||
"",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(first)).unwrap(),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(second)).unwrap(),
|
||||
"legacy"
|
||||
);
|
||||
assert!(config
|
||||
.api_access_remarks
|
||||
.iter()
|
||||
.all(|entry| !entry.record_hash.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updating_a_single_legacy_record_removes_the_stale_key_mapping() {
|
||||
let mut config = GuiConfigFile::default();
|
||||
let record = locator("https://api.example/v1", &["shared-key"]);
|
||||
config.api_access_remarks.push(GuiApiAccessRemark {
|
||||
provider_section: "codex-api-key".to_string(),
|
||||
api_key_hash: api_access_key_hash("shared-key").unwrap(),
|
||||
record_hash: String::new(),
|
||||
remark: "legacy".to_string(),
|
||||
});
|
||||
|
||||
apply_api_access_remark_update(
|
||||
&mut config,
|
||||
update(
|
||||
vec![record.clone()],
|
||||
vec![record.clone()],
|
||||
vec![record.clone()],
|
||||
"updated",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolve_api_access_remark(&config, &query(record)).unwrap(),
|
||||
"updated"
|
||||
);
|
||||
assert_eq!(
|
||||
config.api_access_remark_for_source("codex", "shared-key"),
|
||||
Some("updated")
|
||||
);
|
||||
assert!(config
|
||||
.api_access_remarks
|
||||
.iter()
|
||||
.all(|entry| !entry.record_hash.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_remark_entries_deserialize_without_a_record_hash() {
|
||||
let entry = toml::from_str::<GuiApiAccessRemark>(
|
||||
r#"provider-section = "codex-api-key"
|
||||
api-key-hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
remark = "legacy"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(entry.record_hash.is_empty());
|
||||
assert_eq!(entry.remark, "legacy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remark_command_payloads_accept_record_locators() {
|
||||
let query = serde_json::from_value::<ApiAccessRemarkQuery>(serde_json::json!({
|
||||
"providerSection": "openai-compatibility",
|
||||
"providerName": "first",
|
||||
"baseUrl": "https://api.example/v1",
|
||||
"apiKeys": ["shared-key"]
|
||||
}))
|
||||
.unwrap();
|
||||
let update = serde_json::from_value::<ApiAccessRemarkUpdate>(serde_json::json!({
|
||||
"providerSection": "openai-compatibility",
|
||||
"previousRecords": [{
|
||||
"providerName": "first",
|
||||
"baseUrl": "https://api.example/v1",
|
||||
"apiKeys": ["shared-key"]
|
||||
}],
|
||||
"records": [{
|
||||
"providerName": "second",
|
||||
"baseUrl": "https://api.example/v1",
|
||||
"apiKeys": ["shared-key"]
|
||||
}],
|
||||
"allRecords": [{
|
||||
"providerName": "second",
|
||||
"baseUrl": "https://api.example/v1",
|
||||
"apiKeys": ["shared-key"]
|
||||
}],
|
||||
"remark": "second"
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(query.locator.provider_name, "first");
|
||||
assert_eq!(update.previous_records[0].provider_name, "first");
|
||||
assert_eq!(update.records[0].provider_name, "second");
|
||||
assert_eq!(update.all_records[0].provider_name, "second");
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn set_app_locale(
|
||||
app: tauri::AppHandle,
|
||||
|
||||
@@ -57,19 +57,28 @@ impl GuiConfigFile {
|
||||
) -> 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())
|
||||
let resolve = |section: Option<&str>| {
|
||||
let mut resolved: Option<Option<&str>> = None;
|
||||
for entry in self.api_access_remarks.iter().filter(|entry| {
|
||||
section.is_none_or(|section| entry.provider_section == section)
|
||||
&& 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())
|
||||
}) {
|
||||
let candidate = (!entry.remark.is_empty()).then_some(entry.remark.as_str());
|
||||
match resolved {
|
||||
None => resolved = Some(candidate),
|
||||
Some(current) if current == candidate => {}
|
||||
Some(_) => return Some(None),
|
||||
}
|
||||
}
|
||||
resolved
|
||||
};
|
||||
|
||||
if let Some(section) = preferred_section {
|
||||
if let Some(remark) = resolve(Some(section)) {
|
||||
return remark;
|
||||
}
|
||||
}
|
||||
resolve(None).flatten()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1815,6 +1824,9 @@ pub(crate) fn write_gui_config_to_path(
|
||||
Value::from(entry.provider_section.as_str()),
|
||||
);
|
||||
table.insert("api-key-hash", Value::from(entry.api_key_hash.as_str()));
|
||||
if !entry.record_hash.is_empty() {
|
||||
table.insert("record-hash", Value::from(entry.record_hash.as_str()));
|
||||
}
|
||||
table.insert("remark", Value::from(entry.remark.as_str()));
|
||||
api_access_remarks.push(Value::InlineTable(table));
|
||||
}
|
||||
@@ -1864,6 +1876,15 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String>
|
||||
{
|
||||
return Err("API 接入备注的密钥指纹无效".to_string());
|
||||
}
|
||||
if !entry.record_hash.is_empty()
|
||||
&& (entry.record_hash.len() != 64
|
||||
|| !entry
|
||||
.record_hash
|
||||
.chars()
|
||||
.all(|character| character.is_ascii_hexdigit()))
|
||||
{
|
||||
return Err("API 接入备注的条目指纹无效".to_string());
|
||||
}
|
||||
validate_api_key_remark(&entry.remark)?;
|
||||
}
|
||||
validate_strong_management_secret_key(&config.management_secret_key)?;
|
||||
|
||||
+18
-6
@@ -860,22 +860,34 @@ struct GuiApiKeyEntry {
|
||||
struct GuiApiAccessRemark {
|
||||
provider_section: String,
|
||||
api_key_hash: String,
|
||||
#[serde(default)]
|
||||
record_hash: String,
|
||||
remark: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiAccessRemarkQuery {
|
||||
provider_section: String,
|
||||
struct ApiAccessRemarkLocator {
|
||||
provider_name: String,
|
||||
base_url: String,
|
||||
api_keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiAccessRemarkQuery {
|
||||
provider_section: String,
|
||||
#[serde(flatten)]
|
||||
locator: ApiAccessRemarkLocator,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ApiAccessRemarkUpdate {
|
||||
provider_section: String,
|
||||
previous_api_keys: Vec<String>,
|
||||
api_keys: Vec<String>,
|
||||
previous_records: Vec<ApiAccessRemarkLocator>,
|
||||
records: Vec<ApiAccessRemarkLocator>,
|
||||
all_records: Vec<ApiAccessRemarkLocator>,
|
||||
remark: String,
|
||||
}
|
||||
|
||||
|
||||
@@ -4742,6 +4742,7 @@ mod tests {
|
||||
config.api_access_remarks.push(crate::GuiApiAccessRemark {
|
||||
provider_section: "codex-api-key".to_string(),
|
||||
api_key_hash: hash_text(key),
|
||||
record_hash: String::new(),
|
||||
remark: "生产环境".to_string(),
|
||||
});
|
||||
|
||||
@@ -4756,6 +4757,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_source_masks_shared_keys_with_different_record_remarks() {
|
||||
let key = "sk-1234567890abcdefghijklmnopqrstuvwxyz";
|
||||
let mut config = GuiConfigFile::default();
|
||||
for (record_hash, remark) in [
|
||||
("a".repeat(64), "生产环境"),
|
||||
("b".repeat(64), "测试环境"),
|
||||
] {
|
||||
config.api_access_remarks.push(crate::GuiApiAccessRemark {
|
||||
provider_section: "codex-api-key".to_string(),
|
||||
api_key_hash: hash_text(key),
|
||||
record_hash,
|
||||
remark: remark.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
usage_source_display(&config, "codex", key),
|
||||
"sk-1••••wxyz"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_hour_key_uses_year_month_day_and_hour() {
|
||||
let record = sample_record("id", "2026-07-17T20:30:00+08:00", "model");
|
||||
|
||||
@@ -112,6 +112,12 @@ type ProviderRow = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export type ApiAccessRemarkLocator = {
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
apiKeys: string[];
|
||||
};
|
||||
|
||||
const providerDragId = (
|
||||
row: Pick<ProviderRow, 'section' | 'name' | 'apiKey' | 'baseUrl'>,
|
||||
) => {
|
||||
@@ -321,8 +327,28 @@ const rowFromRecord = (
|
||||
};
|
||||
};
|
||||
|
||||
const providerRemarkIdentity = (section: ProviderSection, apiKeys: string[]) =>
|
||||
`${section}\u0000${apiKeys.join('\u0000')}`;
|
||||
export const providerRemarkIdentity = (
|
||||
section: ProviderSection,
|
||||
locator: ApiAccessRemarkLocator,
|
||||
) => JSON.stringify([section, locator.providerName, locator.baseUrl, locator.apiKeys]);
|
||||
|
||||
export const apiAccessRemarkLocatorFromRecord = (
|
||||
section: ProviderSection,
|
||||
record: Record<string, unknown>,
|
||||
): ApiAccessRemarkLocator => {
|
||||
const row = rowFromRecord(section, record, 0);
|
||||
return {
|
||||
providerName: readString(record, 'name'),
|
||||
baseUrl: row.baseUrl,
|
||||
apiKeys: row.apiKeys,
|
||||
};
|
||||
};
|
||||
|
||||
const apiAccessRemarkLocatorFromRow = (row: ProviderRow): ApiAccessRemarkLocator => ({
|
||||
providerName: readString(row.record, 'name'),
|
||||
baseUrl: row.baseUrl,
|
||||
apiKeys: row.apiKeys,
|
||||
});
|
||||
|
||||
const providerHealthIdentity = (row: ProviderRow) => [
|
||||
row.section,
|
||||
@@ -922,12 +948,12 @@ export function ApiAccessPage() {
|
||||
void invoke<string[]>('resolve_api_access_remarks', {
|
||||
queries: providerRows.map((row) => ({
|
||||
providerSection: row.section,
|
||||
apiKeys: row.apiKeys,
|
||||
...apiAccessRemarkLocatorFromRow(row),
|
||||
})),
|
||||
}).then((remarks) => {
|
||||
if (disposed) return;
|
||||
setApiAccessRemarks(Object.fromEntries(providerRows.map((row, index) => [
|
||||
providerRemarkIdentity(row.section, row.apiKeys),
|
||||
providerRemarkIdentity(row.section, apiAccessRemarkLocatorFromRow(row)),
|
||||
remarks[index] ?? '',
|
||||
])));
|
||||
}).catch(() => {
|
||||
@@ -947,7 +973,9 @@ export function ApiAccessPage() {
|
||||
name: activeCategory === 'deepseek'
|
||||
? t('apiAccess.provider.deepseek')
|
||||
: row.name,
|
||||
remark: apiAccessRemarks[providerRemarkIdentity(row.section, row.apiKeys)] ?? '',
|
||||
remark: apiAccessRemarks[
|
||||
providerRemarkIdentity(row.section, apiAccessRemarkLocatorFromRow(row))
|
||||
] ?? '',
|
||||
}))
|
||||
.filter((row) => providerCategoryMatchesRecord(activeCategory, row.record, activeSection))
|
||||
.filter((row) => {
|
||||
@@ -1107,8 +1135,13 @@ export function ApiAccessPage() {
|
||||
await invoke('save_api_access_remark', {
|
||||
update: {
|
||||
providerSection: activeSection,
|
||||
previousApiKeys: editingRow?.apiKeys ?? [],
|
||||
apiKeys: parsedApiKeys,
|
||||
previousRecords: editingRow ? [apiAccessRemarkLocatorFromRow(editingRow)] : [],
|
||||
records: recordsToSave.map((record) => (
|
||||
apiAccessRemarkLocatorFromRecord(activeSection, record)
|
||||
)),
|
||||
allRecords: nextList.map((record) => (
|
||||
apiAccessRemarkLocatorFromRecord(activeSection, record)
|
||||
)),
|
||||
remark: draftToSave.remark,
|
||||
},
|
||||
});
|
||||
@@ -1135,11 +1168,16 @@ export function ApiAccessPage() {
|
||||
query: { 'api-key': row.apiKey, 'base-url': row.baseUrl },
|
||||
});
|
||||
}
|
||||
const latestConfig = await managementApi.get('/config');
|
||||
const remainingRecords = sectionRecordsFromConfig(latestConfig, row.section);
|
||||
await invoke('save_api_access_remark', {
|
||||
update: {
|
||||
providerSection: row.section,
|
||||
previousApiKeys: row.apiKeys,
|
||||
apiKeys: [],
|
||||
previousRecords: [apiAccessRemarkLocatorFromRow(row)],
|
||||
records: [],
|
||||
allRecords: remainingRecords.map((record) => (
|
||||
apiAccessRemarkLocatorFromRecord(row.section, record)
|
||||
)),
|
||||
remark: '',
|
||||
},
|
||||
});
|
||||
|
||||
+4
-1
@@ -8129,9 +8129,12 @@ textarea:focus-visible {
|
||||
.usage-overview-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.65fr);
|
||||
grid-template-rows: auto minmax(180px, 1fr);
|
||||
grid-template-rows: auto minmax(250px, 1fr);
|
||||
gap: 14px;
|
||||
align-items: stretch;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.usage-stat-grid {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
apiAccessRemarkLocatorFromRecord,
|
||||
applyProviderRemarkIdentity,
|
||||
applyProviderPreset,
|
||||
buildProviderRecord,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
parseProviderApiKeys,
|
||||
providerCategoryMatchesRecord,
|
||||
providerRecordWithDisabledState,
|
||||
providerRemarkIdentity,
|
||||
providerSectionOrder,
|
||||
reorderProviderRecords,
|
||||
resolveProviderRecordIndex,
|
||||
@@ -139,6 +141,25 @@ it('parses multiline API keys into unique trimmed entries', () => {
|
||||
expect(parseProviderApiKeys(' key-a\n\nkey-b\r\nkey-a ')).toEqual(['key-a', 'key-b']);
|
||||
});
|
||||
|
||||
it('keeps remark identities separate for records that share an API key', () => {
|
||||
const first = apiAccessRemarkLocatorFromRecord('codex-api-key', {
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://first.example/v1',
|
||||
});
|
||||
const second = apiAccessRemarkLocatorFromRecord('codex-api-key', {
|
||||
'api-key': 'shared-key',
|
||||
'base-url': 'https://second.example/v1',
|
||||
});
|
||||
|
||||
expect(first.apiKeys).toEqual(second.apiKeys);
|
||||
expect(first.baseUrl).not.toBe(second.baseUrl);
|
||||
expect(providerRemarkIdentity('codex-api-key', first))
|
||||
.not.toBe(providerRemarkIdentity('codex-api-key', second));
|
||||
expect(providerRemarkIdentity('codex-api-key', first)).toBe(
|
||||
providerRemarkIdentity('codex-api-key', { ...first, apiKeys: [...first.apiKeys] }),
|
||||
);
|
||||
});
|
||||
|
||||
describe('API 接入配置合并', () => {
|
||||
it('固定使用 Codex、OpenAI、DeepSeek、Claude、Gemini 顺序且不包含 Vertex', () => {
|
||||
expect(providerSectionOrder).toEqual([
|
||||
|
||||
Reference in new issue
Block a user