fix: scope API access remarks to provider records

Fixes #267
This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent bd57545c7f
commit abfe65e656
7 files changed
+533 -71

No files matched your search

+387 -43
View File
@@ -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,
+33 -12
View File
@@ -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
View File
@@ -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,
}
+23
View File
@@ -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");
+47 -9
View File
@@ -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
View File
@@ -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 {
+21
View File
@@ -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([