fix(core): show no effort level for a Claude model that takes none

A new Claude session names its model but not its effort level, so the
hook fell back to the settings' level - and a session started on Haiku
showed "High" until its first turn ended. The hook now checks Claude
Code's own model catalog (its cache, read only) first: a model it lists
without effort options is reported with an empty level. A model it does
not list, or no catalog at all, falls back to the settings as before.
This commit is contained in:
l0ng-ai
2026-10-01 15:28:00 +08:00
parent 15bcf292b2
commit 2f4d9820ff
+79 -8
View File
@@ -242,12 +242,13 @@ fn claude_readout(event: &str, payload: &serde_json::Value, body: &mut serde_jso
// Claude says the level it is running at, when it says it — and at the
// end of a turn it says it whenever the model takes one, so a finished
// turn without it ran on a model that does not: that is reported as an
// empty level. Otherwise the environment wins over the settings file, as
// it does for Claude — read only for a model this event names, since the
// file may set it per model.
// empty level. So is one for a model Claude's own model catalog lists
// without effort levels. Otherwise the environment wins over the settings
// file, as it does for Claude — read only for a model this event names,
// since the file may set it per model.
let effort = str_of(payload.get("effort").and_then(|e| e.get("level"))).or_else(|| {
let model = model.as_deref()?;
if event == "stop" {
if event == "stop" || catalog_says_no_effort(&claude_model_catalogs(), model) {
return Some(String::new());
}
std::env::var("CLAUDE_CODE_EFFORT_LEVEL")
@@ -282,16 +283,57 @@ fn configured_effort(settings: &serde_json::Value, model: Option<&str>) -> Optio
.or_else(|| level(Some(settings)))
}
fn claude_settings() -> serde_json::Value {
let dir = std::env::var_os("CLAUDE_CONFIG_DIR")
fn claude_config_dir() -> Option<PathBuf> {
std::env::var_os("CLAUDE_CONFIG_DIR")
.filter(|d| !d.is_empty())
.map(PathBuf::from)
.or_else(|| home_dir().map(|h| h.join(".claude")));
dir.and_then(|d| std::fs::read(d.join("settings.json")).ok())
.or_else(|| home_dir().map(|h| h.join(".claude")))
}
fn claude_settings() -> serde_json::Value {
claude_config_dir()
.and_then(|d| std::fs::read(d.join("settings.json")).ok())
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or(serde_json::json!({}))
}
/// The model catalogs Claude Code keeps in its cache, one per account it
/// has signed in with, as parsed JSON. None, if it has not fetched any.
fn claude_model_catalogs() -> Vec<serde_json::Value> {
let Some(dir) = claude_config_dir().map(|d| d.join("cache").join("model-catalog")) else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
entries
.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter(|e| e.metadata().is_ok_and(|m| m.len() <= MAX_CONFIG_BYTES))
.filter_map(|e| std::fs::read(e.path()).ok())
.filter_map(|b| serde_json::from_slice(&b).ok())
.collect()
}
/// Whether Claude's model catalogs list `model` as taking no effort level —
/// Haiku, say. Only a model they list counts, and only when every catalog
/// that lists it agrees: a model they do not know may well take one.
fn catalog_says_no_effort(catalogs: &[serde_json::Value], model: &str) -> bool {
let model = model.trim_end_matches("[1m]");
let mut listed = catalogs
.iter()
.filter_map(|c| c.pointer("/catalog/config/models")?.as_array())
.flatten()
.filter(|m| m.get("id").and_then(|id| id.as_str()) == Some(model))
.peekable();
listed.peek().is_some()
&& listed.all(|m| {
m.pointer("/thinking/effort_options")
.and_then(|o| o.as_array())
.is_none_or(|o| o.is_empty())
})
}
/// The model that wrote `reply` to the prompt `prompt_id`, once the
/// transcript has it as its latest words. Claude Code runs `Stop` before
/// that write is always on disk, so this waits a little for it.
@@ -2291,6 +2333,35 @@ mod tests {
);
}
#[test]
fn a_model_the_catalog_lists_without_effort_levels_takes_none() {
let catalog = serde_json::json!({
"version": 2,
"catalog": { "config": { "models": [
{
"id": "claude-opus-5-5",
"thinking": {
"type": "effort",
"effort_options": [{ "id": "low" }, { "id": "high" }],
},
},
{ "id": "claude-haiku-4-5-20251001", "thinking": { "type": "none" } },
] } },
});
let catalogs = [catalog];
assert!(catalog_says_no_effort(
&catalogs,
"claude-haiku-4-5-20251001"
));
assert!(!catalog_says_no_effort(&catalogs, "claude-opus-5-5"));
assert!(!catalog_says_no_effort(&catalogs, "claude-opus-5-5[1m]"));
assert!(
!catalog_says_no_effort(&catalogs, "claude-new-model"),
"a model the catalog does not know may take a level"
);
assert!(!catalog_says_no_effort(&[], "claude-haiku-4-5-20251001"));
}
#[test]
fn an_effort_set_for_the_model_wins_over_the_general_one() {
let settings = serde_json::json!({