Files
EasyCLIProxyAPI/src/services/claudeDesktopModels.ts
T
lzt404 81597fcf91 feat(claude-desktop): support custom model aliases
- Add editable model lists, alias suggestions, and usage guidance
- Ignore unselected rows and validate alias conflicts and naming rules
- Fix alias routing through disabled or excluded providers
- Preserve model routes when restoring legacy backups
- Repair legacy metadata and remove unsupported context fields
- Add regression tests for validation and configuration recovery
2026-09-18 18:17:31 +08:00

100 lines
4.5 KiB
TypeScript

import type { AgentModelMappings } from './agentConfigurationDraft';
import type { ModelOption } from './modelService';
import claudeDesktopModelRules from './claudeDesktopModelRules.json';
export type ClaudeDesktopModelMapping = { model: string; alias: string; context1m: boolean };
export const desktopModelId = (entry: ClaudeDesktopModelMapping) => entry.alias.trim() || entry.model.trim();
export const claudeDesktopDefaultAliases = ['claude-opus-5', 'claude-sonnet-5', 'claude-haiku-4-5'];
export const createDefaultDesktopModels = (): ClaudeDesktopModelMapping[] =>
claudeDesktopDefaultAliases.map((alias) => ({ model: '', alias, context1m: false }));
export const selectedDesktopModelEntries = (entries: ClaudeDesktopModelMapping[]) =>
entries.filter((entry) => entry.model.trim());
export const claudeDesktopAliasSuggestions = [
'claude-fable-5-1', 'claude-opus-5', 'claude-sonnet-5', 'claude-fable-5',
'claude-opus-4-8', 'claude-opus-4-7',
'claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5',
'claude-opus-4-5', 'claude-sonnet-4-5', 'claude-haiku-4-5-20251001',
];
export const validClaudeDesktopAlias = (alias: string) => alias.trim().length <= 128
&& /^claude-[a-z0-9]+(?:[.-][a-z0-9]+)*$/.test(alias.trim())
&& isClaudeDesktopModel(alias);
const modelKey = (id: string) => id.trim().toLowerCase();
const otherModelFamily = new RegExp(claudeDesktopModelRules.otherModelFamilyPattern);
export function isClaudeDesktopModel(id: string) {
const name = modelKey(id);
return !/\s/.test(name) && !otherModelFamily.test(name)
&& (name.startsWith('claude-') || /\banthropic[/.]/.test(name)
|| /^(opus|sonnet|haiku|fable|mythos)(-[\d.]+)?$/.test(name));
}
export function desktopEntryValidation(entry: ClaudeDesktopModelMapping) {
const model = entry.model.trim();
const alias = entry.alias.trim();
if (!model) return 'model' as const;
if (new TextEncoder().encode(model).length > 240 || /[\s\u0000-\u001f\u007f]/.test(model)) return 'modelFormat' as const;
if (alias && modelKey(alias) !== modelKey(model) && !validClaudeDesktopAlias(alias)) return 'alias' as const;
return null;
}
export function desktopAliasNotice(
entry: ClaudeDesktopModelMapping,
entries: ClaudeDesktopModelMapping[],
models: ModelOption[],
appliedEntries: ClaudeDesktopModelMapping[] = [],
) {
const alias = modelKey(entry.alias);
if (!alias) return null;
if (alias === modelKey(entry.model)) return 'sameModel' as const;
const appliedAlias = appliedEntries.some((applied) => modelKey(applied.alias) === alias
&& applied.model.trim() && modelKey(applied.model) !== alias);
if (models.some((model) => modelKey(model.name) === alias && (!model.isAlias || !appliedAlias))
|| entries.some((other) => modelKey(other.model) === alias)) return 'aliasExists' as const;
return null;
}
export function desktopModelEntries(mappings: AgentModelMappings): ClaudeDesktopModelMapping[] {
if (mappings.desktopModels) return mappings.desktopModels.map((entry) => {
const model = entry.model.trim() || entry.alias.trim();
return { ...entry, model, alias: modelKey(entry.alias) === modelKey(model) ? '' : entry.alias };
});
const aliases = ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-haiku-4-5'];
const entries = (['opus', 'sonnet', 'haiku'] as const).flatMap((role, index) => {
const model = mappings[role];
if (!model) return [];
return [{ model, alias: isClaudeDesktopModel(model) ? '' : aliases[index], context1m: Boolean(mappings[`${role}1m`]) }];
});
const unique: ClaudeDesktopModelMapping[] = [];
for (const entry of entries) {
const existing = unique.find((other) => desktopModelId(other) === desktopModelId(entry));
if (existing) existing.context1m ||= entry.context1m;
else unique.push(entry);
}
return unique;
}
export function desktopModelValidation(
entries: ClaudeDesktopModelMapping[],
models: ModelOption[],
appliedEntries: ClaudeDesktopModelMapping[] = [],
) {
const selectedEntries = selectedDesktopModelEntries(entries);
if (!selectedEntries.length) return 'empty' as const;
for (const entry of selectedEntries) {
const error = desktopEntryValidation(entry);
if (error) return error;
}
const aliases = selectedEntries.map((entry) => desktopModelId(entry).toLowerCase());
if (new Set(aliases).size !== aliases.length) return 'duplicate' as const;
if (selectedEntries.some((entry) => desktopAliasNotice(entry, selectedEntries, models, appliedEntries) === 'aliasExists')) return 'aliasExists' as const;
return null;
}