mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
Fix hinted model alias resolution (#849)
This commit is contained in:
@@ -2657,15 +2657,24 @@ export class ClaudeAcpAgent {
|
||||
// For model options, fall back to resolveModelPreference when the exact
|
||||
// value doesn't match. This lets callers use human-friendly aliases like
|
||||
// "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6".
|
||||
// Resolve against session.modelInfos first: those entries carry
|
||||
// `resolvedModel`, so a full model id (in either hint spelling) lands on
|
||||
// the right row via the exact tier instead of a fuzzier one picking a
|
||||
// same-family sibling from a different context lane. The options-derived
|
||||
// list (which never carries `resolvedModel`) remains as a fallback for
|
||||
// resolutions that don't map back onto a selectable option (e.g. a fuzzy
|
||||
// hit on an out-of-picker verbatim entry).
|
||||
if (!validValue && params.configId === MODEL_CONFIG_ID) {
|
||||
const modelInfos: ModelInfo[] = allValues.map((o) => ({
|
||||
value: o.value,
|
||||
displayName: o.name,
|
||||
description: o.description ?? "",
|
||||
}));
|
||||
const resolved = resolveModelPreference(modelInfos, params.value);
|
||||
if (resolved) {
|
||||
validValue = allValues.find((o) => o.value === resolved.value);
|
||||
const toOptionValue = (resolved: ModelInfo | null) =>
|
||||
resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
|
||||
validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value));
|
||||
if (!validValue) {
|
||||
const optionInfos: ModelInfo[] = allValues.map((o) => ({
|
||||
value: o.value,
|
||||
displayName: o.name,
|
||||
description: o.description ?? "",
|
||||
}));
|
||||
validValue = toOptionValue(resolveModelPreference(optionInfos, params.value));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3833,22 +3842,21 @@ export class ClaudeAcpAgent {
|
||||
// so every modelInfos consumer (buildConfigOptions' effort lookup, later
|
||||
// rebuilds via session.modelInfos) agrees with the gating below. The
|
||||
// picker options themselves come from `models.availableModels`, so this
|
||||
// adds no selectable entry. Capability flags only: the fuzzy-matched
|
||||
// sibling's resolvedModel/displayName/description can describe a
|
||||
// different context lane and would poison later resolvedModel matching
|
||||
// (syncModelAfterRefusalFallback) and context-window inference
|
||||
// adds no selectable entry. The spread keeps every capability flag
|
||||
// (current and future); the identity fields are overridden because the
|
||||
// fuzzy-matched sibling's resolvedModel/displayName/description can
|
||||
// describe a different context lane and would poison later resolvedModel
|
||||
// matching (syncModelAfterRefusalFallback) and context-window inference
|
||||
// (applyConfigOptionValue) if they traveled under this id.
|
||||
const modelInfos = fallbackModelInfo
|
||||
? [
|
||||
...allowedModels,
|
||||
{
|
||||
...fallbackModelInfo,
|
||||
value: models.currentModelId,
|
||||
displayName: models.currentModelId,
|
||||
description: "",
|
||||
supportsAutoMode: fallbackModelInfo.supportsAutoMode,
|
||||
supportsFastMode: fallbackModelInfo.supportsFastMode,
|
||||
supportsEffort: fallbackModelInfo.supportsEffort,
|
||||
supportedEffortLevels: fallbackModelInfo.supportedEffortLevels,
|
||||
resolvedModel: undefined,
|
||||
},
|
||||
]
|
||||
: allowedModels;
|
||||
@@ -4402,6 +4410,12 @@ function canonicalizeModelId(s: string): string {
|
||||
return s.trim().toLowerCase().replace(CONTEXT_HINT_SUFFIX_PATTERN, "[$1]");
|
||||
}
|
||||
|
||||
/** The context hint a model string carries ("1m" for either spelling), or
|
||||
* null for a bare id. */
|
||||
function contextHintOf(s: string): string | null {
|
||||
return canonicalizeModelId(s).match(MODEL_CONTEXT_HINT_PATTERN)?.[1] ?? null;
|
||||
}
|
||||
|
||||
// Captures a model family version: `4-6`/`4.7` for dated generations, or a
|
||||
// bare `5` for single-number ones like "Sonnet 5". Used to keep a pinned
|
||||
// `claude-opus-4-6` from matching the `opus` alias once it points at 4.7.
|
||||
@@ -4462,11 +4476,13 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
// Exact match on value or display name
|
||||
// Exact match on value or display name. Values compare on the canonical
|
||||
// hint spelling so "opus-1m" hits an "opus[1m]" row (and vice versa).
|
||||
const canonicalPreference = canonicalizeModelId(trimmed);
|
||||
const directMatch = models.find(
|
||||
(model) =>
|
||||
model.value === trimmed ||
|
||||
model.value.toLowerCase() === lower ||
|
||||
canonicalizeModelId(model.value) === canonicalPreference ||
|
||||
model.displayName.toLowerCase() === lower,
|
||||
);
|
||||
if (directMatch) return directMatch;
|
||||
@@ -4480,7 +4496,6 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
// since it shares a resolvedModel with whichever alias the CLI currently
|
||||
// recommends — a specific pin should land on that named alias, not
|
||||
// "default".
|
||||
const canonicalPreference = canonicalizeModelId(trimmed);
|
||||
const matchesResolved = (model: ModelInfo) =>
|
||||
model.resolvedModel != null && canonicalizeModelId(model.resolvedModel) === canonicalPreference;
|
||||
const resolvedMatch =
|
||||
@@ -4488,9 +4503,14 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
models.find(matchesResolved);
|
||||
if (resolvedMatch) return resolvedMatch;
|
||||
|
||||
// Substring match
|
||||
// Substring match. Skips candidates whose context hint disagrees with the
|
||||
// preference's — a bare row must not absorb a 1M-hinted preference (nor
|
||||
// vice versa); such pairs fall through to the tokenized tier, which
|
||||
// weighs hints in its scoring and still finds the best same-family row.
|
||||
const preferenceHint = contextHintOf(trimmed);
|
||||
const includesMatch = models.find((model) => {
|
||||
if (!modelVersionsCompatible(trimmed, model)) return false;
|
||||
if (contextHintOf(model.value) !== preferenceHint) return false;
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
|
||||
@@ -92,6 +92,25 @@ describe("resolveModelPreference - resolvedModel matching", () => {
|
||||
];
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
|
||||
});
|
||||
|
||||
it("matches a '-1m'-suffix alias directly against a '[1m]'-spelled value", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "opus-1m")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
// Rows without resolvedModel (older SDKs, allowlist-synthesized entries,
|
||||
// option-derived lists) can't use the exact resolved tier, so the hinted
|
||||
// preference must not be absorbed by the bare sibling in the substring
|
||||
// tier — the tokenized tier scores the hinted row correctly instead.
|
||||
it("does not let a bare sibling absorb a hinted preference when resolvedModel is absent", () => {
|
||||
const models: ModelInfo[] = [
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "" },
|
||||
{ value: "sonnet[1m]", displayName: "Sonnet", description: "" },
|
||||
];
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5[1m]")?.value).toBe("sonnet[1m]");
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
|
||||
// Bare preferences still land on the bare row via the substring tier.
|
||||
expect(resolveModelPreference(models, "claude-sonnet-5")?.value).toBe("sonnet");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchResumedModel", () => {
|
||||
|
||||
@@ -819,6 +819,62 @@ describe("session config options", () => {
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
// Option entries carry no resolvedModel, so alias resolution must consult
|
||||
// session.modelInfos — otherwise a full model id in either hint spelling
|
||||
// ("[1m]"/"-1m") falls to the substring tier and lands on the bare 200k
|
||||
// sibling, silently downgrading the session's context lane.
|
||||
it("resolves a full model id onto its hinted row via session.modelInfos", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.models = {
|
||||
currentModelId: "sonnet",
|
||||
availableModels: [
|
||||
{ modelId: "sonnet", name: "Sonnet", description: "" },
|
||||
{ modelId: "sonnet[1m]", name: "Sonnet", description: "" },
|
||||
],
|
||||
};
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "sonnet",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
{
|
||||
value: "sonnet[1m]",
|
||||
resolvedModel: "claude-sonnet-5[1m]",
|
||||
displayName: "Sonnet",
|
||||
description: "",
|
||||
},
|
||||
];
|
||||
session.configOptions = session.configOptions.map((o: { id: string }) =>
|
||||
o.id === "model"
|
||||
? {
|
||||
...o,
|
||||
currentValue: "sonnet",
|
||||
options: [
|
||||
{ value: "sonnet", name: "Sonnet" },
|
||||
{ value: "sonnet[1m]", name: "Sonnet" },
|
||||
],
|
||||
}
|
||||
: o,
|
||||
);
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-5[1m]",
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
|
||||
|
||||
setModelSpy.mockClear();
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-5-1m",
|
||||
});
|
||||
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
|
||||
});
|
||||
|
||||
// A session can be running a model with no picker entry (resumed onto a
|
||||
// model excluded by the availableModels allowlist, or a refusal
|
||||
// fallback); its verbatim id is then the option's currentValue. A client
|
||||
|
||||
Reference in New Issue
Block a user