Skip to content

Commit d415585

Browse files
committed
refactor(models): improve model deduplication and display naming
- Deduplicate models by name in configuration and by ID in the provider. - Prevent redundant model enumeration by skipping unscoped group queries. - Update display names to include vendor prefixes for coding-plans models.
1 parent b01d154 commit d415585

7 files changed

Lines changed: 198 additions & 52 deletions

File tree

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
]
1414
}
1515
],
16-
"coding-plans.commitMessage.language": "zh-cn",
16+
"coding-plans.commitMessage.language": "en",
1717
"chat.tools.terminal.autoApprove": {
1818
"/^node \\.\\\\scripts\\\\fetch-openrouter-provider-plans\\.js$/": {
1919
"approve": true,

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "coding-plans-for-copilot",
33
"displayName": "%displayName%",
44
"description": "%description%",
5-
"version": "0.8.7",
5+
"version": "0.8.8",
66
"publisher": "techfetch-dev",
77
"repository": {
88
"type": "git",

src/commitMessageGenerator.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1119,13 +1119,28 @@ function isDistinctDisplayValue(value: string | undefined, ...others: Array<stri
11191119
return others.every(other => normalizeValue(other) !== target);
11201120
}
11211121

1122+
function hasCodingPlansVendorPrefixedName(model: vscode.LanguageModelChat): boolean {
1123+
const vendorName = getCodingPlansVendorName(model);
1124+
if (!vendorName) {
1125+
return false;
1126+
}
1127+
return normalizeValue(model.name).startsWith(`${normalizeValue(vendorName)}/`);
1128+
}
1129+
11221130
function toVendorScopedModelQuickPickItem(model: vscode.LanguageModelChat): {
11231131
label: string;
11241132
description?: string;
11251133
detail?: string;
11261134
model: vscode.LanguageModelChat;
11271135
} {
1128-
const description = isDistinctDisplayValue(model.family, model.name) ? model.family : undefined;
1136+
const codingPlansVendorName = getCodingPlansVendorName(model);
1137+
const description = hasCodingPlansVendorPrefixedName(model)
1138+
? undefined
1139+
: isDistinctDisplayValue(codingPlansVendorName, model.name)
1140+
? codingPlansVendorName
1141+
: isDistinctDisplayValue(model.family, model.name)
1142+
? model.family
1143+
: undefined;
11291144
const detail = isDistinctDisplayValue(model.id, model.name, model.family) ? model.id : undefined;
11301145
return {
11311146
label: model.name,
@@ -1185,14 +1200,18 @@ function toGlobalModelQuickPickItem(model: vscode.LanguageModelChat): {
11851200
detail?: string;
11861201
model: vscode.LanguageModelChat;
11871202
} {
1188-
const descriptionParts = [getModelVendorLabel(model)];
1203+
const descriptionParts = hasCodingPlansVendorPrefixedName(model) ? [] : [getModelVendorLabel(model)];
1204+
const codingPlansVendorName = getCodingPlansVendorName(model);
1205+
if (isDistinctDisplayValue(codingPlansVendorName, model.name) && codingPlansVendorName !== descriptionParts[0]) {
1206+
descriptionParts.push(codingPlansVendorName!);
1207+
}
11891208
if (isDistinctDisplayValue(model.family, model.name) && model.family !== descriptionParts[0]) {
11901209
descriptionParts.push(model.family);
11911210
}
11921211
const detail = isDistinctDisplayValue(model.id, model.name, model.family) ? model.id : undefined;
11931212
return {
11941213
label: model.name,
1195-
description: descriptionParts.join(' · '),
1214+
description: descriptionParts.length > 0 ? descriptionParts.join(' · ') : undefined,
11961215
detail,
11971216
model
11981217
};

src/config/configStore.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -503,13 +503,29 @@ export class ConfigStore implements vscode.Disposable {
503503
const useModelsEndpoint = typeof obj.useModelsEndpoint === 'boolean' ? obj.useModelsEndpoint : false;
504504
const defaultVision = typeof obj.defaultVision === 'boolean' ? obj.defaultVision : false;
505505
const models = Array.isArray(obj.models)
506-
? obj.models
507-
.map(m => this.normalizeModel(m, defaultVision, defaultApiStyle))
508-
.filter((m): m is VendorModelConfig => m !== undefined)
506+
? this.dedupeModelsByName(
507+
obj.models
508+
.map(m => this.normalizeModel(m, defaultVision, defaultApiStyle))
509+
.filter((m): m is VendorModelConfig => m !== undefined)
510+
)
509511
: [];
510512
return { name, baseUrl, apiKey, usageUrl, defaultApiStyle, defaultTemperature, defaultTopP, useModelsEndpoint, defaultVision, models };
511513
}
512514

515+
private dedupeModelsByName(models: VendorModelConfig[]): VendorModelConfig[] {
516+
const seen = new Set<string>();
517+
const deduped: VendorModelConfig[] = [];
518+
for (const model of models) {
519+
const key = model.name.trim().toLowerCase();
520+
if (!key || seen.has(key)) {
521+
continue;
522+
}
523+
seen.add(key);
524+
deduped.push(model);
525+
}
526+
return deduped;
527+
}
528+
513529
private getConfiguredVendorApiKey(vendorName: string): string {
514530
const normalizedVendorName = vendorName.trim().toLowerCase();
515531
if (normalizedVendorName.length === 0) {

src/providers/lmChatProviderAdapter.ts

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,37 @@ interface ExtendedLanguageModelChatInformation extends vscode.LanguageModelChatI
3636
isUserSelectable?: boolean;
3737
}
3838

39+
function getCodingPlansVendorNameFromModelId(modelId: string): string | undefined {
40+
const slashIndex = modelId.indexOf('/');
41+
if (slashIndex <= 0) {
42+
return undefined;
43+
}
44+
return modelId.substring(0, slashIndex);
45+
}
46+
47+
function getDisplayModelName(model: BaseLanguageModel): string {
48+
if (model.vendor !== 'coding-plans') {
49+
return model.name;
50+
}
51+
52+
const vendorName = getCodingPlansVendorNameFromModelId(model.id) || model.family;
53+
const trimmedVendorName = vendorName.trim();
54+
const trimmedModelName = model.name.trim();
55+
if (!trimmedVendorName || !trimmedModelName) {
56+
return model.name;
57+
}
58+
59+
if (trimmedModelName.toLowerCase().startsWith(`${trimmedVendorName.toLowerCase()}/`)) {
60+
return model.name;
61+
}
62+
63+
return `${trimmedVendorName}/${model.name}`;
64+
}
65+
3966
function toLanguageModelInfo(model: BaseLanguageModel): vscode.LanguageModelChatInformation {
4067
const info: ExtendedLanguageModelChatInformation = {
4168
id: model.id,
42-
name: model.name,
69+
name: getDisplayModelName(model),
4370
family: model.family,
4471
tooltip: model.description,
4572
detail: model.version,
@@ -189,11 +216,19 @@ export class LMChatProviderAdapter implements vscode.LanguageModelChatProvider,
189216
): Promise<vscode.LanguageModelChatInformation[]> {
190217
const pickerOptions = options as PrepareLanguageModelChatModelOptionsWithConfiguration;
191218
const hasConfigurationPayload = this.hasConfigurationPayload(pickerOptions.configuration);
219+
const hasGroupQuery = typeof pickerOptions.group === 'string' && pickerOptions.group.trim().length > 0;
192220

193221
if (hasConfigurationPayload) {
194222
await this.applyPickerConfiguration(pickerOptions);
195223
}
196224

225+
if (hasGroupQuery && !hasConfigurationPayload) {
226+
logger.debug('Skipping unscoped grouped model enumeration to avoid duplicate model entries', {
227+
group: pickerOptions.group
228+
});
229+
return [];
230+
}
231+
197232
return this.buildModelInformation(pickerOptions.configuration);
198233
}
199234

@@ -208,7 +243,7 @@ export class LMChatProviderAdapter implements vscode.LanguageModelChatProvider,
208243
const vendorForFiltering = resolvedVendor || requestedVendor;
209244
let models = this.provider.getAvailableModels();
210245
let filteredModels = vendorForFiltering
211-
? models.filter(model => model.family.toLowerCase() === vendorForFiltering.toLowerCase())
246+
? models.filter(model => this.matchesVendorFilter(model, vendorForFiltering))
212247
: models;
213248

214249
// Settings updates and model picker queries can race each other.
@@ -217,7 +252,7 @@ export class LMChatProviderAdapter implements vscode.LanguageModelChatProvider,
217252
await this.provider.refreshModels();
218253
models = this.provider.getAvailableModels();
219254
filteredModels = vendorForFiltering
220-
? models.filter(model => model.family.toLowerCase() === vendorForFiltering.toLowerCase())
255+
? models.filter(model => this.matchesVendorFilter(model, vendorForFiltering))
221256
: models;
222257
}
223258

@@ -240,7 +275,39 @@ export class LMChatProviderAdapter implements vscode.LanguageModelChatProvider,
240275
return [getNoModelsPlaceholderModel(this.provider.getVendor())];
241276
}
242277

243-
return filteredModels.map(model => toLanguageModelInfo(model));
278+
return this.dedupeModelsById(filteredModels).map(model => toLanguageModelInfo(model));
279+
}
280+
281+
private matchesVendorFilter(model: BaseLanguageModel, vendorName: string): boolean {
282+
const normalizedVendor = vendorName.trim().toLowerCase();
283+
if (normalizedVendor.length === 0) {
284+
return true;
285+
}
286+
287+
if (model.vendor !== 'coding-plans') {
288+
return model.family.toLowerCase() === normalizedVendor;
289+
}
290+
291+
const vendorFromId = getCodingPlansVendorNameFromModelId(model.id);
292+
if (vendorFromId && vendorFromId.toLowerCase() === normalizedVendor) {
293+
return true;
294+
}
295+
296+
return model.family.toLowerCase() === normalizedVendor;
297+
}
298+
299+
private dedupeModelsById(models: BaseLanguageModel[]): BaseLanguageModel[] {
300+
const seen = new Set<string>();
301+
const deduped: BaseLanguageModel[] = [];
302+
for (const model of models) {
303+
const key = model.id.trim().toLowerCase();
304+
if (!key || seen.has(key)) {
305+
continue;
306+
}
307+
seen.add(key);
308+
deduped.push(model);
309+
}
310+
return deduped;
244311
}
245312

246313
private async hasAnyConfiguredApiKey(): Promise<boolean> {

src/test/runTest.ts

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -771,27 +771,27 @@ async function runTestCase(configStoreCtor: ConfigStoreCtor, testCase: TestCase)
771771
}
772772

773773
async function runConfigNormalizationTests(configStoreCtor: ConfigStoreCtor): Promise<void> {
774-
activeState = createState([{
775-
name: 'Vendor',
776-
baseUrl: 'https://example.test/v1',
777-
apiStyle: 'anthropic',
778-
defaultVision: true,
774+
activeState = createState([{
775+
name: 'Vendor',
776+
baseUrl: 'https://example.test/v1',
777+
apiStyle: 'anthropic',
778+
defaultVision: true,
779779
models: [{ name: 'claude-3' }]
780780
}]);
781781

782-
let configStore = new configStoreCtor(createExtensionContext() as never);
783-
try {
784-
const vendor = configStore.getVendors()[0];
785-
assert.equal(vendor?.defaultApiStyle, 'anthropic');
786-
assert.equal(vendor?.models[0]?.apiStyle, 'anthropic');
787-
assert.deepEqual(vendor?.models[0]?.capabilities, { tools: true, vision: true });
788-
assert.equal(vendor?.models[0]?.maxOutputTokens, 0);
789-
assert.equal(vendor?.defaultTemperature, undefined);
790-
assert.equal(vendor?.defaultTopP, undefined);
791-
console.log('PASS 兼容旧 apiStyle、补齐模型默认能力并将 maxOutputTokens 默认归一化为 0');
792-
} finally {
793-
configStore.dispose();
794-
}
782+
let configStore = new configStoreCtor(createExtensionContext() as never);
783+
try {
784+
const vendor = configStore.getVendors()[0];
785+
assert.equal(vendor?.defaultApiStyle, 'anthropic');
786+
assert.equal(vendor?.models[0]?.apiStyle, 'anthropic');
787+
assert.deepEqual(vendor?.models[0]?.capabilities, { tools: true, vision: true });
788+
assert.equal(vendor?.models[0]?.maxOutputTokens, 0);
789+
assert.equal(vendor?.defaultTemperature, undefined);
790+
assert.equal(vendor?.defaultTopP, undefined);
791+
console.log('PASS 兼容旧 apiStyle、补齐模型默认能力并将 maxOutputTokens 默认归一化为 0');
792+
} finally {
793+
configStore.dispose();
794+
}
795795

796796
activeState = createState([{
797797
name: 'Vendor',
@@ -974,6 +974,31 @@ async function runConfigNormalizationTests(configStoreCtor: ConfigStoreCtor): Pr
974974
} finally {
975975
configStore.dispose();
976976
}
977+
978+
activeState = createState([{
979+
name: 'Vendor',
980+
baseUrl: 'https://example.test/v1',
981+
defaultApiStyle: 'openai-chat',
982+
defaultVision: false,
983+
models: [
984+
{ name: 'claude-4-sonnet' },
985+
{ name: ' claude-4-sonnet ' },
986+
{ name: 'CLAUDE-4-SONNET' },
987+
{ name: 'claude-4-opus' }
988+
]
989+
}]);
990+
991+
configStore = new configStoreCtor(createExtensionContext() as never);
992+
try {
993+
const vendor = configStore.getVendors()[0];
994+
assert.deepEqual(
995+
vendor?.models.map(model => model.name),
996+
['claude-4-sonnet', 'claude-4-opus']
997+
);
998+
console.log('PASS vendors[].models 在归一化阶段按名称去重,避免模型列表重复显示');
999+
} finally {
1000+
configStore.dispose();
1001+
}
9771002
}
9781003

9791004
async function runConfigStoreVendorApiKeyPriorityTests(configStoreCtor: ConfigStoreCtor): Promise<void> {
@@ -3533,6 +3558,7 @@ async function runLMChatProviderAdapterModelFilteringTests(
35333558
const models = [
35343559
{
35353560
id: 'Vendor/coder',
3561+
vendor: 'coding-plans',
35363562
name: 'coder',
35373563
family: 'Vendor',
35383564
description: 'Vendor coder',
@@ -3541,8 +3567,20 @@ async function runLMChatProviderAdapterModelFilteringTests(
35413567
maxOutputTokens: 16000,
35423568
capabilities: { toolCalling: true, imageInput: false }
35433569
},
3570+
{
3571+
id: 'Vendor/coder',
3572+
vendor: 'coding-plans',
3573+
name: 'coder',
3574+
family: 'Vendor',
3575+
description: 'Vendor coder duplicate',
3576+
version: 'Vendor',
3577+
maxInputTokens: 32000,
3578+
maxOutputTokens: 16000,
3579+
capabilities: { toolCalling: true, imageInput: false }
3580+
},
35443581
{
35453582
id: 'Other/coder',
3583+
vendor: 'coding-plans',
35463584
name: 'coder',
35473585
family: 'Other',
35483586
description: 'Other coder',
@@ -3579,10 +3617,11 @@ async function runLMChatProviderAdapterModelFilteringTests(
35793617
try {
35803618
const stableApiModels = await adapter.provideLanguageModelChatInformation({ silent: false } as never, {} as never);
35813619
assert.deepEqual(stableApiModels.map(model => model.id), ['Vendor/coder', 'Other/coder']);
3620+
assert.deepEqual(stableApiModels.map(model => model.name), ['Vendor/coder', 'Other/coder']);
35823621
assert.equal((stableApiModels[0] as { isUserSelectable?: boolean }).isUserSelectable, true);
35833622

3584-
const allModels = await adapter.provideLanguageModelChatInformation({ group: 'Group' } as never, {} as never);
3585-
assert.deepEqual(allModels.map(model => model.id), ['Vendor/coder', 'Other/coder']);
3623+
const groupedModels = await adapter.provideLanguageModelChatInformation({ group: 'Group' } as never, {} as never);
3624+
assert.deepEqual(groupedModels.map(model => model.id), []);
35863625

35873626
const vendorModels = await adapter.provideLanguageModelChatInformation({
35883627
group: 'Group',
@@ -3595,7 +3634,7 @@ async function runLMChatProviderAdapterModelFilteringTests(
35953634
const placeholderModels = await adapter.provideLanguageModelChatInformation({ silent: true } as never, {} as never);
35963635
assert.equal(refreshCount, 1);
35973636
assert.deepEqual(placeholderModels.map(model => model.id), ['coding-plans__setup_api_key__']);
3598-
console.log('PASS LMChatProviderAdapter 兼容 stable picker 调用、标记 user-selectable,并保留旧 vendorName 过滤');
3637+
console.log('PASS LMChatProviderAdapter 避免无范围 group 查询重复枚举模型,并保留 vendorName 过滤');
35993638
} finally {
36003639
adapter.dispose();
36013640
configStore.dispose();

0 commit comments

Comments
 (0)