fix: Avoid cross-version model alias matches (#702)

Partially addresses #639

Deduping may reintroduce issues from #620 so going to hold off on that
for now.
This commit is contained in:
Ben Brandt
2026-05-21 22:49:29 +02:00
committed by GitHub
parent 72875b248c
commit e1e1c69029
2 changed files with 85 additions and 0 deletions

View File

@@ -2413,6 +2413,27 @@ function buildConfigOptions(
// but the SDK model list uses IDs like "claude-opus-4-6-1m".
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
// Captures a model family version such as `4-6` or `4.7` so we can keep
// `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that
// alias currently resolves to a different family version (e.g. Opus 4.7).
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/;
function extractModelFamilyVersion(s: string): string | null {
const match = s.match(MODEL_FAMILY_VERSION_PATTERN);
return match ? `${match[1]}.${match[2]}` : null;
}
function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean {
const preferred = extractModelFamilyVersion(preference);
if (!preferred) return true;
const candidateVersion =
extractModelFamilyVersion(candidate.value) ??
extractModelFamilyVersion(candidate.displayName) ??
extractModelFamilyVersion(candidate.description);
if (!candidateVersion) return true;
return preferred === candidateVersion;
}
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
const lower = model.trim().toLowerCase();
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
@@ -2459,6 +2480,7 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI
// Substring match
const includesMatch = models.find((model) => {
if (!modelVersionsCompatible(trimmed, model)) return false;
const value = model.value.toLowerCase();
const display = model.displayName.toLowerCase();
return value.includes(lower) || display.includes(lower) || lower.includes(value);
@@ -2472,6 +2494,7 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI
let bestMatch: ModelInfo | null = null;
let bestScore = 0;
for (const model of models) {
if (!modelVersionsCompatible(trimmed, model)) continue;
const score = scoreModelMatch(model, tokens, contextHint);
if (0 < score && (!bestMatch || bestScore < score)) {
bestMatch = model;

View File

@@ -578,6 +578,68 @@ describe("ClaudeAcpAgent settings", () => {
expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5");
expect(response.models.currentModelId).toBe("claude-haiku-4-5");
});
it("does not inherit display info across mismatched model family versions", async () => {
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
// when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry
// of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match
// `opus` and inherit the "Opus 4.7" display info. With version-aware
// matching, these entries fall back to showing their literal ID rather
// than a misleading newer name.
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: [
"claude-opus-4-6",
"claude-opus-4-6[1m]",
"claude-opus-4-7",
"claude-opus-4-7[1m]",
],
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQueryWithModels([
{ value: "default", displayName: "Default", description: "Default model" },
{
value: "opus",
displayName: "Opus 4.7",
description: "Claude Opus 4.7 — complex tasks, higher cost",
},
{
value: "opus[1m]",
displayName: "Opus 4.7 (1M context)",
description: "Opus 4.7 with 1M context",
},
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modelOption = response.configOptions.find((o: any) => o.id === "model");
const byValue: Record<string, { name: string; description?: string }> = {};
for (const opt of modelOption.options) {
byValue[opt.value] = { name: opt.name, description: opt.description };
}
// 4-6 entries must NOT inherit the 4.7 SDK alias display info.
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
expect(byValue["claude-opus-4-6"].description).toBe("");
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
expect(byValue["claude-opus-4-6[1m]"].description).toBe("");
// 4-7 entries continue to inherit display info from a 4.7 SDK alias.
expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7");
expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/);
});
});
it("resolves model aliases like opus[1m] to the correct model", async () => {