fix(aliases): stop treating display names as aliases

#CLOSE #259
This commit is contained in:
lzt404 committed 2026-09-18 18:17:31 +08:00
1 parent e7f96e12d7
commit be7284f0f1
12 files changed
+277 -32

No files matched your search

+13 -4
View File
@@ -498,7 +498,7 @@ pub(crate) async fn get_model_alias_edit_source(
alias: String,
) -> Result<ModelAliasEditContext, String> {
let config = gui_config_state.snapshot()?;
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let alias = existing_thinking_alias_model_id(&alias, "别名模型")?;
let content = fetch_management_config_yaml(&config).await?;
let definitions = fetch_oauth_model_definitions(&config).await;
model_alias_edit_context(&content, &alias, &definitions)
@@ -519,7 +519,16 @@ pub(crate) async fn create_thinking_alias(
if source_id.is_empty() {
return Err("请先选择原模型".to_string());
}
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let original_alias = original_alias
.as_deref()
.map(|original| existing_thinking_alias_model_id(original, "别名模型"))
.transpose()?;
let alias = match original_alias.as_deref() {
Some(original) if original.eq_ignore_ascii_case(alias.trim()) => {
existing_thinking_alias_model_id(&alias, "别名模型")?
}
_ => validate_thinking_alias_model_id(&alias, "别名模型")?,
};
let effort = if effort.trim().is_empty() {
String::new()
} else {
@@ -594,7 +603,7 @@ pub(crate) async fn delete_thinking_alias(
oauth_channel: Option<String>,
) -> Result<Vec<ThinkingAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let alias = existing_thinking_alias_model_id(&alias, "别名模型")?;
let content = fetch_management_config_yaml(&config).await?;
let updated =
remove_thinking_alias_from_yaml_for_channel(&content, &alias, oauth_channel.as_deref())?;
@@ -690,7 +699,7 @@ pub(crate) async fn delete_speed_alias(
oauth_channel: Option<String>,
) -> Result<Vec<SpeedAliasEntry>, String> {
let config = gui_config_state.snapshot()?;
let alias = validate_thinking_alias_model_id(&alias, "别名模型")?;
let alias = existing_thinking_alias_model_id(&alias, "别名模型")?;
let content = fetch_management_config_yaml(&config).await?;
let updated =
remove_speed_alias_from_yaml_for_channel(&content, &alias, oauth_channel.as_deref())?;
+13 -6
View File
@@ -185,19 +185,26 @@ pub(crate) async fn fetch_active_oauth_alias_channels(
.collect())
}
pub(crate) fn validate_thinking_alias_model_id(value: &str, label: &str) -> Result<String, String> {
pub(crate) fn existing_thinking_alias_model_id(value: &str, label: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
return Err(format!("{label}不能为空"));
}
if value.len() > 240
|| value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
if value.len() > 240 || value.chars().any(char::is_control) {
return Err(format!("{label}格式无效"));
}
Ok(value.to_string())
}
pub(crate) fn validate_thinking_alias_model_id(value: &str, label: &str) -> Result<String, String> {
let value = existing_thinking_alias_model_id(value, label)?;
if value
.chars()
.any(|character| character.is_whitespace() || character.is_control())
{
return Err(format!("{label}格式无效,不能包含空白字符"));
}
Ok(value.to_string())
Ok(value)
}
pub(crate) fn validate_thinking_alias_effort(value: &str) -> Result<String, String> {
+33
View File
@@ -562,6 +562,39 @@ fn thinking_alias_effort_accepts_provider_defined_levels() {
assert!(validate_thinking_alias_effort("32768").is_err());
}
#[test]
fn existing_aliases_with_spaces_can_be_loaded_and_deleted() {
assert_eq!(
existing_thinking_alias_model_id(" Codex Auto Review ", "别名模型").unwrap(),
"Codex Auto Review"
);
assert!(validate_thinking_alias_model_id("Codex Auto Review", "别名模型").is_err());
let content = "codex-api-key:\n - name: provider\n base-url: https://www.loomex.cc\n models:\n - name: codex-auto-review\n alias: Codex Auto Review\n - name: keep-me\n";
let entries = thinking_aliases_from_yaml(content).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "Codex Auto Review");
assert_eq!(entries[0].source_model, "codex-auto-review");
let context = model_alias_edit_context(content, "Codex Auto Review", &[]).unwrap();
assert_eq!(context.source.model, "codex-auto-review");
let deleted = remove_thinking_alias_from_yaml(content, "Codex Auto Review").unwrap();
assert!(thinking_aliases_from_yaml(&deleted).unwrap().is_empty());
assert!(deleted.contains("name: keep-me"));
assert!(!deleted.contains("Codex Auto Review"));
let source = resolve_model_alias_edit_source(content, "Codex Auto Review", &[]).unwrap();
let renamed = edit_model_alias_in_yaml(
content,
"Codex Auto Review",
&source,
"codex-auto-review-alias",
"",
false,
)
.unwrap();
let renamed_entries = thinking_aliases_from_yaml(&renamed).unwrap();
assert_eq!(renamed_entries.len(), 1);
assert_eq!(renamed_entries[0].alias, "codex-auto-review-alias");
}
#[test]
fn thinking_alias_removal_keeps_other_models_in_grouped_rule() {
let input = "oauth-model-alias:\n codex:\n - name: gpt-5.5\n alias: gpt-5.5-xhigh\n fork: true\n - name: gpt-5.4\n alias: gpt-5.4-xhigh\n fork: true\npayload:\n override:\n - models:\n - name: gpt-5.5-xhigh\n protocol: codex\n - name: gpt-5.4-xhigh\n protocol: codex\n params:\n reasoning.effort: xhigh\n";
+3 -3
View File
@@ -1,7 +1,7 @@
import { useId, useMemo, useRef, useState } from 'react';
import { ArrowRight, Search, X } from 'lucide-react';
import { useI18n } from '../i18n';
import type { ModelOption } from '../services/modelService';
import { modelSearchText, type ModelOption } from '../services/modelService';
type ModelSelectionPanelProps = {
models: ModelOption[];
@@ -17,7 +17,7 @@ export function ModelSelectionPanel({ models, selected, loading, onMove }: Model
const [search, setSearch] = useState('');
const query = search.trim().toLowerCase();
const visibleModels = useMemo(() => models.filter((model) =>
`${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query),
modelSearchText(model).includes(query),
), [models, query]);
const searchLabel = t(selected ? 'apiAccess.modelDialog.searchSelected' : 'apiAccess.modelDialog.searchUnselected');
@@ -63,7 +63,7 @@ export function ModelSelectionPanel({ models, selected, loading, onMove }: Model
onMove([model], !selected);
}}
>
<span><strong title={model.name}>{model.name}</strong>{model.alias ? <small title={model.alias}>{model.alias}</small> : null}</span>
<span><strong title={model.name}>{model.name}</strong>{model.alias || model.displayName ? <small title={model.alias || model.displayName}>{model.alias || model.displayName}</small> : null}</span>
{selected ? <X size={16} aria-hidden="true" /> : <ArrowRight size={16} aria-hidden="true" />}
</button>
)) : (
+1
View File
@@ -525,6 +525,7 @@ export const jaOverrides = {
'aliases.source.available': '現在コアで利用可能なモデル',
'aliases.error.selectModel': '先に元のモデルを選択してください',
'aliases.error.emptyAlias': 'エイリアスモデル名を入力してください',
'aliases.error.invalidAlias': 'エイリアスモデル名に空白文字は使えません',
'aliases.error.emptyEffort': '推論レベルを入力してください',
'aliases.error.unsupportedEffort': 'このモデルソースには利用可能な推論レベルがありません',
'aliases.created': '{alias} を作成し、推論レベルを {effort} に固定しました',
+1
View File
@@ -513,6 +513,7 @@ export const en: Record<MessageKey, string> = {
'aliases.source.available': 'Models currently available from the core',
'aliases.error.selectModel': 'Select an original model first',
'aliases.error.emptyAlias': 'The model alias cannot be empty',
'aliases.error.invalidAlias': 'The model alias is invalid and cannot contain whitespace',
'aliases.error.emptyEffort': 'The reasoning effort cannot be empty',
'aliases.error.unsupportedEffort': 'This model source has no available reasoning-effort levels',
'aliases.error.unsupportedFast': 'Fast is only available for Codex API, OpenAI-compatible, and Codex OAuth model sources',
+1
View File
@@ -512,6 +512,7 @@ export const zhCN = {
'aliases.source.available': '内核当前可用模型',
'aliases.error.selectModel': '请先选择原模型',
'aliases.error.emptyAlias': '别名模型不能为空',
'aliases.error.invalidAlias': '别名模型格式无效,不能包含空白字符',
'aliases.error.emptyEffort': '思考强度不能为空',
'aliases.error.unsupportedEffort': '该模型来源没有可用的思考强度等级',
'aliases.error.unsupportedFast': 'Fast 仅支持 Codex API、OpenAI 兼容和 Codex OAuth 模型源',
+22 -8
View File
@@ -56,9 +56,11 @@ import {
DEEPSEEK_BASE_URL,
fetchModels,
mergeModelOptions,
modelSearchText,
modelsFromRecord,
normalizeBaseUrl,
reconcileModelSelection,
usableModelAlias,
type ModelOption,
type ModelProvider,
} from '../services/modelService';
@@ -381,9 +383,23 @@ const mergeModelRecords = (current: unknown, selected: ModelOption[]) => {
);
const next: Record<string, unknown> = isRecord(matched) ? { ...matched } : {};
next.name = name;
const alias = model.alias?.trim();
if (alias && alias !== name) next.alias = alias;
else delete next.alias;
const requested = (model.alias ?? '').trim();
const storedAlias = readString(next, 'alias');
const alias = usableModelAlias(requested);
if (alias && alias !== name) {
next.alias = alias;
} else if (
requested
&& storedAlias
&& requested.toLowerCase() === storedAlias.toLowerCase()
&& requested.toLowerCase() !== name.toLowerCase()
) {
next.alias = storedAlias;
} else if (requested && requested !== name) {
throw new Error(translate(getCurrentLocale(), 'aliases.error.invalidAlias'));
} else {
delete next.alias;
}
if (model.thinking) next.thinking = { ...model.thinking };
models.push(next);
return models;
@@ -1418,9 +1434,7 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
const visibleModels = useMemo(() => {
const query = search.trim().toLowerCase();
if (!query) return models;
return models.filter((model) =>
`${model.name} ${model.alias ?? ''}`.toLowerCase().includes(query),
);
return models.filter((model) => modelSearchText(model).includes(query));
}, [models, search]);
const resultValues = Object.values(results);
@@ -1532,8 +1546,8 @@ function ProviderHealthDialog({ row, onClose }: ProviderHealthDialogProps) {
<strong title={model.name}>{model.name}</strong>
{error
? <small className="error" title={error}>{error}</small>
: model.alias
? <small title={model.alias}>{model.alias}</small>
: model.alias || model.displayName
? <small title={model.alias || model.displayName}>{model.alias || model.displayName}</small>
: null}
</div>
<span
+8
View File
@@ -9,6 +9,7 @@ import {
useState,
} from 'react';
import { invoke } from '@tauri-apps/api/core';
import { usableModelAlias } from '../services/modelService';
import {
ArrowRight,
BrainCircuit,
@@ -398,6 +399,13 @@ export function ThinkingAliasesPage() {
setError(t('aliases.error.emptyAlias'));
return;
}
const keepingExistingAlias = Boolean(
editingEntry && editingEntry.alias.trim().toLowerCase() === normalizedAlias.toLowerCase(),
);
if (!keepingExistingAlias && !usableModelAlias(normalizedAlias)) {
setError(t('aliases.error.invalidAlias'));
return;
}
if (fastEnabled && !selectedSource.supportsFast) {
setError(t('aliases.error.unsupportedFast'));
return;
+42 -9
View File
@@ -6,6 +6,7 @@ const modelText = (key: Parameters<typeof translate>[1]) => translate(getCurrent
export type ModelOption = {
name: string;
alias?: string;
displayName?: string;
isAlias?: boolean;
contextWindow?: number;
inputModalities?: Array<'text' | 'image'>;
@@ -21,12 +22,36 @@ export const DEEPSEEK_BASE_URL = 'https://api.deepseek.com';
const modelKey = (name: string) => name.trim().toLowerCase();
export function usableModelAlias(value: string | undefined | null): string {
const alias = value?.trim() ?? '';
if (!alias || alias.length > 240) return '';
for (const character of alias) {
const code = character.charCodeAt(0);
if (character.trim() === '' || code < 32 || code === 127) return '';
}
return alias;
}
export function modelSearchText(
model: Pick<ModelOption, 'name' | 'alias' | 'displayName'>,
): string {
return [model.name, model.alias, model.displayName].filter(Boolean).join(' ').toLowerCase();
}
export function mergeModelOptions(...groups: ModelOption[][]): ModelOption[] {
const merged = new Map<string, ModelOption>();
groups.flat().forEach((model) => {
const name = model.name.trim();
if (!name) return;
merged.set(modelKey(name), { ...model, name });
const previous = merged.get(modelKey(name));
const next: ModelOption = { ...previous, ...model, name };
const alias = (model.alias ?? '').trim() || previous?.alias;
const displayName = (model.displayName ?? '').trim() || previous?.displayName;
if (alias && alias !== name) next.alias = alias;
else delete next.alias;
if (displayName && displayName !== name) next.displayName = displayName;
else delete next.displayName;
merged.set(modelKey(name), next);
});
return Array.from(merged.values());
}
@@ -102,7 +127,7 @@ export const modelEndpointCandidates = (provider: ModelProvider, baseUrl: string
return [/\/v1$/i.test(base) ? `${base}/models` : `${base}/v1/models`];
};
const normalizeModelList = (payload: unknown): ModelOption[] => {
const normalizeModelList = (payload: unknown, preserveExistingAlias = false): ModelOption[] => {
const parsed = typeof payload === 'string' ? (() => {
try { return JSON.parse(payload) as unknown; } catch { return payload; }
})() : payload;
@@ -114,13 +139,17 @@ const normalizeModelList = (payload: unknown): ModelOption[] => {
const name = typeof item === 'string' ? item : isRecord(item) ? readString(item, 'id', 'name', 'model', 'value') : '';
if (!name || seen.has(name.toLowerCase())) return null;
seen.add(name.toLowerCase());
const alias = typeof item === 'object' && isRecord(item) ? readString(item, 'alias', 'display_name', 'displayName') : '';
const thinking = typeof item === 'object' && isRecord(item) && isRecord(item.thinking)
? { ...item.thinking }
const record = typeof item === 'object' && isRecord(item) ? item : null;
const rawAlias = record ? readString(record, 'alias') : '';
const alias = preserveExistingAlias ? rawAlias : usableModelAlias(rawAlias);
const displayName = record ? readString(record, 'display-name', 'display_name', 'displayName') : '';
const thinking = record && isRecord(record.thinking)
? { ...record.thinking }
: undefined;
return {
name,
...(alias && alias !== name ? { alias } : {}),
...(displayName && displayName !== name ? { displayName } : {}),
...(thinking ? { thinking } : {}),
};
}).filter((item): item is ModelOption => item !== null);
@@ -128,7 +157,11 @@ const normalizeModelList = (payload: unknown): ModelOption[] => {
export function modelsFromRecord(value: unknown): ModelOption[] {
if (!Array.isArray(value)) return [];
return normalizeModelList(value);
return normalizeModelList(value, true);
}
export function modelsFromDiscoveredPayload(payload: unknown): ModelOption[] {
return normalizeModelList(payload);
}
export async function fetchModels(
@@ -186,7 +219,7 @@ export async function fetchModels(
}
const payload = response.body ?? response.bodyText;
normalizeModelList(payload).forEach((model) => {
modelsFromDiscoveredPayload(payload).forEach((model) => {
const name = provider === 'gemini' ? model.name.replace(/^models\//i, '') : model.name;
const dedupeKey = name.toLowerCase();
if (!name || seen.has(dedupeKey)) return;
@@ -216,7 +249,7 @@ export async function fetchModels(
}, { timeoutMs });
const status = Number(response.status_code ?? response.statusCode ?? 0);
if (status >= 200 && status < 300) {
const models = normalizeModelList(response.body ?? response.bodyText);
const models = modelsFromDiscoveredPayload(response.body ?? response.bodyText);
if (models.length) return models;
}
}
@@ -230,7 +263,7 @@ export async function fetchModels(
}, { timeoutMs });
const status = Number(response.status_code ?? response.statusCode ?? 0);
if (status >= 200 && status < 300) {
const models = normalizeModelList(response.body ?? response.bodyText);
const models = modelsFromDiscoveredPayload(response.body ?? response.bodyText);
if (models.length) return models;
}
} catch {
+68
View File
@@ -1,7 +1,11 @@
import { describe, expect, it } from 'bun:test';
import {
mergeModelOptions,
modelSearchText,
modelsFromDiscoveredPayload,
modelsFromRecord,
reconcileModelSelection,
usableModelAlias,
} from '../src/services/modelService';
describe('model discovery selection', () => {
@@ -43,4 +47,68 @@ describe('model discovery selection', () => {
[{ name: ' DEEPSEEK-CHAT ', alias: 'Chat' }],
)).toEqual([{ name: 'DEEPSEEK-CHAT', alias: 'Chat' }]);
});
it('keeps display names for search without treating them as aliases', () => {
expect(mergeModelOptions(
[{ name: 'codex-auto-review', displayName: 'Codex Auto Review' }],
[{ name: 'codex-auto-review', alias: 'review-alias' }],
)).toEqual([{
name: 'codex-auto-review',
alias: 'review-alias',
displayName: 'Codex Auto Review',
}]);
expect(modelSearchText({
name: 'codex-auto-review',
displayName: 'Codex Auto Review',
})).toContain('codex auto review');
});
});
describe('model catalog parsing', () => {
it('does not treat display names as aliases', () => {
expect(modelsFromRecord([
{
name: 'codex-auto-review',
display_name: 'Codex Auto Review',
displayName: 'Ignored Fallback',
},
{
id: 'gpt-5.6-terra',
'display-name': 'GPT-5.6 Terra',
},
])).toEqual([
{ name: 'codex-auto-review', displayName: 'Codex Auto Review' },
{ name: 'gpt-5.6-terra', displayName: 'GPT-5.6 Terra' },
]);
});
it('preserves existing aliases from saved configuration, including spaced ones', () => {
expect(modelsFromRecord([
{ name: 'codex-auto-review', alias: 'Codex Auto Review' },
{ name: 'gpt-test', alias: 'review-alias' },
])).toEqual([
{ name: 'codex-auto-review', alias: 'Codex Auto Review' },
{ name: 'gpt-test', alias: 'review-alias' },
]);
});
it('rejects whitespace in newly entered aliases', () => {
expect(usableModelAlias('Codex Auto Review')).toBe('');
expect(usableModelAlias(' review-alias ')).toBe('review-alias');
expect(usableModelAlias('')).toBe('');
});
it('does not import spaced display names as aliases when discovering models', () => {
expect(modelsFromDiscoveredPayload({
data: [
{ id: 'codex-auto-review', display_name: 'Codex Auto Review' },
{ id: 'gpt-test', alias: 'Codex Auto Review' },
{ id: 'gpt-alias', alias: 'review-alias' },
],
})).toEqual([
{ name: 'codex-auto-review', displayName: 'Codex Auto Review' },
{ name: 'gpt-test' },
{ name: 'gpt-alias', alias: 'review-alias' },
]);
});
});
+72 -2
View File
@@ -25,13 +25,83 @@ it('saves non-empty custom model names and removes duplicate or blank entries',
baseUrl: 'https://api.example.com',
priority: '',
models: [
{ name: ' custom-model ', alias: ' Custom Alias ' },
{ name: ' custom-model ', alias: ' custom-alias ' },
{ name: ' ' },
{ name: 'CUSTOM-MODEL', alias: 'duplicate' },
],
});
expect(result.models).toEqual([{ name: 'custom-model', alias: 'Custom Alias' }]);
expect(result.models).toEqual([{ name: 'custom-model', alias: 'custom-alias' }]);
});
it('does not persist display names as model aliases', () => {
const result = buildProviderRecord('codex-api-key', {
name: '',
apiKey: 'codex-key',
baseUrl: 'https://www.loomex.cc',
priority: '',
models: [
{ name: 'codex-auto-review', displayName: 'Codex Auto Review' },
{ name: 'gpt-test', alias: 'review-alias', displayName: 'GPT Test' },
],
});
expect(result.models).toEqual([
{ name: 'codex-auto-review' },
{ name: 'gpt-test', alias: 'review-alias' },
]);
});
it('rejects newly entered aliases that contain whitespace', () => {
expect(() => buildProviderRecord('codex-api-key', {
name: '',
apiKey: 'codex-key',
baseUrl: 'https://www.loomex.cc',
priority: '',
models: [{ name: 'codex-auto-review', alias: 'Codex Auto Review' }],
})).toThrow(/空白|whitespace|空白文字/);
});
it('preserves an existing spaced alias until the user changes it', () => {
const result = buildProviderRecord(
'codex-api-key',
{
name: '',
apiKey: 'codex-key',
baseUrl: 'https://www.loomex.cc',
priority: '',
models: [{ name: 'codex-auto-review', alias: 'Codex Auto Review' }],
},
{
'api-key': 'codex-key',
models: [{ name: 'codex-auto-review', alias: 'Codex Auto Review' }],
},
);
expect(result.models).toEqual([
{ name: 'codex-auto-review', alias: 'Codex Auto Review' },
]);
});
it('keeps an existing spaced alias when only the letter case changes', () => {
const result = buildProviderRecord(
'codex-api-key',
{
name: '',
apiKey: 'codex-key',
baseUrl: 'https://www.loomex.cc',
priority: '',
models: [{ name: 'codex-auto-review', alias: 'codex auto review' }],
},
{
'api-key': 'codex-key',
models: [{ name: 'codex-auto-review', alias: 'Codex Auto Review' }],
},
);
expect(result.models).toEqual([
{ name: 'codex-auto-review', alias: 'Codex Auto Review' },
]);
});
it('parses multiline API keys into unique trimmed entries', () => {