fix: reject a set_config_option model that names no entry with -32602

resolveModelPreference matched preference tokens as substrings, so
"not-a-model" resolved to Default ("a" is inside "default") and
"son" or "foo-sonnet" to Sonnet. configOptionValue, which v1, the v2
bridge and the native v2 core share, then accepted the value. Words now
match whole, and the fuzzy tiers need every model word of the
preference; provider ids, dates, -fast and context hints are dropped
with normalizeModelId first, so aliases and provider spellings still
resolve.
This commit is contained in:
Ben Brandt
2026-11-09 15:19:43 +01:00
parent 4dc6e656a6
commit 51ab74538c
5 changed files with 164 additions and 69 deletions

View File

@@ -201,7 +201,11 @@ the history cannot place stays unexposed, as the RFD forbids guessing a parent.
that does not support it. A model switch that the adapter did not make (`PostModelSwitch`, such
as a typed `/model`, or a refusal fallback) updates the options. Claude Code's
`fast_mode_state` (on `init` and on results of prompts) updates the Fast mode option, with a
notice when it turns off a Fast mode the user had on.
notice when it turns off a Fast mode the user had on. A value that names no entry is rejected
with -32602 ("Invalid value for config option model: not-a-model") and changes nothing, as on
the bridge and v1, which share `configOptionValue`. A model alias names an entry only by whole
words (`resolveModelPreference`): `opus`, `opusplan`, `claude-sonnet-5[1m]` and provider ids
such as `us.anthropic.claude-sonnet-5-v1:0` do, `not-a-model`, `son` and `foo-sonnet` do not.
- `src/v2-native/clear-context.ts`: ExitPlanMode's "Yes, clear context" option, offered to the
root's plan as on the bridge. The answer interrupts the cycle, and the turn stays open: the
session sends Claude Code `/clear`, then sets the chosen mode and sends "Implement the following

View File

@@ -84,35 +84,54 @@ function versionedModelDisplayName(model: ModelInfo): string {
return `${withoutContext} ${version}${contextSuffix[0]}`;
}
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
const lower = model.trim().toLowerCase();
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
const tokens = lower
.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ")
function modelWords(value: string): string[] {
return value
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter(Boolean)
.map((token) => {
if (token === "opusplan") return "opus";
if (token === "best" || token === "default") return "";
return token;
})
.filter((token) => token && token !== "claude")
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
return { tokens, contextHint };
.filter(Boolean);
}
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
let score = 0;
let nonHintMatched = false;
for (const token of tokens) {
if (!haystack.includes(token)) continue;
if (token !== contextHint) nonHintMatched = true;
score += token === contextHint ? 3 : 1;
/** Whether `needle`'s words appear in `haystack`'s words, adjacent and in
* order: "opus" in "claude-opus-4-5", but not "son" in "sonnet" or "a" in
* "Default (recommended)". */
function containsWords(haystack: string, needle: string): boolean {
const words = modelWords(haystack);
const wanted = modelWords(needle);
if (wanted.length === 0) return false;
for (let start = 0; start + wanted.length <= words.length; start++) {
if (wanted.every((word, offset) => words[start + offset] === word)) return true;
}
return contextHint && !nonHintMatched ? 0 : score;
return false;
}
/** The words of `preference` that name a model: without what
* `normalizeModelId` drops (provider prefixes, dates, `-v1:0`, `-fast`,
* context hints), version numbers, "claude", and "best"/"default".
* "us.anthropic.claude-sonnet-4-5-v1:0" gives ["sonnet"], "opusplan" gives
* ["opus"]. */
function modelPreferenceTokens(preference: string): string[] {
return modelWords(normalizeModelId(preference))
.map((token) => (token === "opusplan" ? "opus" : token))
.filter((token) => /[a-z]/.test(token) && !["claude", "best", "default"].includes(token));
}
/** Whether every one of `tokens` is a word of `model`'s value or display name. */
function modelCoversTokens(model: ModelInfo, tokens: string[]): boolean {
const words = new Set(modelWords(`${model.value} ${model.displayName}`));
return tokens.length > 0 && tokens.every((token) => words.has(token));
}
/**
* The entry of `models` that `preference` names, or null when it names none.
* In order: an entry whose value, display name or `resolvedModel` equals it
* (in either context-hint spelling); an entry whose value or display name
* contains its words ("opus" for "claude-opus-4-5"), or whose value it
* contains while every model word of the preference is the entry's
* ("sonnet" for "us.anthropic.claude-sonnet-5-v1:0"); then any entry with all
* of its model words, preferring one with the same context hint ("sonnet[1m]"
* for "claude-sonnet-5-1m"). The fuzzy tiers need a compatible version, and
* words match whole: "not-a-model", "son" and "foo-sonnet" name no entry.
*/
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
const trimmed = preference.trim();
if (!trimmed) return null;
@@ -134,28 +153,26 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
if (resolvedMatch) return resolvedMatch;
const preferenceHint = contextHintOf(trimmed);
const tokens = modelPreferenceTokens(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);
return (
containsWords(model.value, trimmed) ||
containsWords(model.displayName, trimmed) ||
(containsWords(trimmed, model.value) && modelCoversTokens(model, tokens))
);
});
if (includesMatch) return includesMatch;
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
if (tokens.length === 0) return null;
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 (score > 0 && (!bestMatch || score > bestScore)) {
bestMatch = model;
bestScore = score;
}
}
return bestMatch;
const candidates = models.filter(
(model) => modelVersionsCompatible(trimmed, model) && modelCoversTokens(model, tokens),
);
return (
candidates.find((model) => contextHintOf(model.value) === preferenceHint) ??
candidates[0] ??
null
);
}
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {

View File

@@ -963,21 +963,67 @@ describe("ACP v2 sessions", () => {
});
it.each([
["a select value for a boolean option", { configId: "fast", type: "id", value: "on" }],
["a boolean for a select option", { configId: "effort", type: "boolean", value: true }],
["a level the model does not offer", { configId: "effort", type: "id", value: "max" }],
] as const)("rejects %s", async (_case, request) => {
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await expect(
agent.request(v2.methods.agent.session.setConfigOption, { sessionId, ...request }),
).rejects.toMatchObject({ code: -32602 });
await client.authUpdate(1);
});
expect(sdk.flagSettings).toEqual([]);
});
[
"a select value for a boolean option",
{ configId: "fast", type: "id", value: "on" },
'Config option fast takes a boolean value, but received: "on"',
],
[
"a boolean for a select option",
{ configId: "effort", type: "boolean", value: true },
"Config option effort takes a string value, but received: true",
],
[
"a level the model does not offer",
{ configId: "effort", type: "id", value: "max" },
"Invalid value for config option effort: max",
],
// "a" is a substring of "claude": a substring match would pick Opus.
[
"a model the session does not offer",
{ configId: "model", type: "id", value: "not-a-model" },
"Invalid value for config option model: not-a-model",
],
[
"a mode the session does not offer",
{ configId: "mode", type: "id", value: "not-a-mode" },
"Invalid value for config option mode: not-a-mode",
],
[
"an agent the session does not offer",
{ configId: "agent", type: "id", value: "not-an-agent" },
"Invalid value for config option agent: not-an-agent",
],
] as const)(
"rejects %s and leaves the options as they were",
async (_case, request, message) => {
const models: Array<string | undefined> = [];
sdk.setModel = async (model) => {
models.push(model);
};
const client = v2Client();
const { created, listed } = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const created = await agent.request(v2.methods.agent.session.new, { cwd });
const { sessionId } = created;
await expect(
agent.request(v2.methods.agent.session.setConfigOption, { sessionId, ...request }),
).rejects.toMatchObject({ code: -32602, message: expect.stringContaining(message) });
// Setting a valid value again returns every option's current value.
const listed = await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId: "agent",
type: "id",
value: "default",
});
await client.authUpdate(1);
return { created, listed };
});
expect(sdk.flagSettings).toEqual([{ agent: null }]);
expect(models).toEqual([]);
expect(values(listed.configOptions)).toEqual(values(created.configOptions));
},
);
it("rebuilds the options for a model switch, and clears an effort the new model lacks", async () => {
const client = v2Client();

View File

@@ -73,8 +73,26 @@ describe("resolveModelPreference - resolvedModel matching", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]");
});
it("returns null for a preference that matches nothing", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
// Words match whole: "a" is inside "Default", "son" inside "sonnet".
it.each(["claude-gpt-99", "not-a-model", "a", "son", "foo-sonnet", "not-default-model", "best"])(
"returns null for %s, which names no model",
(preference) => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)).toBeNull();
},
);
it.each([
["us.anthropic.claude-sonnet-5-v1:0", "sonnet"],
["global.anthropic.claude-haiku-4-5-20251001-v1:0", "haiku"],
["anthropic/claude-sonnet-5:batch", "sonnet"],
["claude-opus-4-8@20260101", "opus[1m]"],
["claude-sonnet-5-fast", "sonnet"],
["claude-haiku-4-5", "haiku"],
["opusplan", "opus[1m]"],
["opus", "opus[1m]"],
["Sonnet 5", "sonnet"],
])("resolves the spelling %s to %s", (preference, value) => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)?.value).toBe(value);
});
it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => {

View File

@@ -330,18 +330,28 @@ describe("session config options", () => {
expect(modelOption?.currentValue).toBe("claude-sonnet-4-6");
});
it("throws for completely invalid model value", async () => {
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "gpt-4",
}),
).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining("Invalid value for config option model: gpt-4"),
});
});
// "a" is a substring of "Claude", "son" of "sonnet": only whole words match.
it.each(["gpt-4", "not-a-model", "son", "foo-sonnet"])(
"rejects the model %s and leaves the model as it was",
async (value) => {
await expect(
agent.setSessionConfigOption({ sessionId: SESSION_ID, configId: "model", value }),
).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining(`Invalid value for config option model: ${value}`),
});
expect(setModelSpy).not.toHaveBeenCalled();
const session = (
agent as unknown as {
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
}
).sessions[SESSION_ID];
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
"claude-opus-4-5",
);
},
);
it("rejects a non-string value for a select option with invalid params", async () => {
await expect(