Skip to content

Commit 6e23cd8

Browse files
authored
Dedup Claude /model list (#165)
dedup model list for claude
1 parent 4ce5ae9 commit 6e23cd8

2 files changed

Lines changed: 165 additions & 17 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ def _resolve_web_search_model(state: dict) -> str | None:
7676
"MLFLOW_EXPERIMENT_ID",
7777
"MLFLOW_TRACING_SQL_WAREHOUSE_ID",
7878
)
79+
# Model-selection env keys ucode owns end-to-end. Anything in this tuple that
80+
# isn't written by render_overlay gets actively pruned from settings.json on
81+
# every launch, so stale values from older ucode versions never linger.
82+
CLAUDE_MANAGED_MODEL_ENV_KEYS = (
83+
"ANTHROPIC_MODEL",
84+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
85+
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
86+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
87+
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME",
88+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
89+
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME",
90+
)
7991
CLAUDE_TRACING_STOP_HOOK_SUFFIX = " autolog claude stop-hook"
8092
# Tracing is driven by an `mlflow autolog claude stop-hook` Stop hook, run by
8193
# the `mlflow` CLI on each session end. Pin to 3.11.x: 3.12 dropped the Unity
@@ -135,13 +147,23 @@ def render_overlay(
135147
]
136148
)
137149
env: dict[str, str] = {
138-
"ANTHROPIC_MODEL": _maybe_add_1m_suffix(model),
139150
"ANTHROPIC_BASE_URL": base_url,
140151
"ANTHROPIC_CUSTOM_HEADERS": custom_headers,
141152
"CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1",
142153
"CLAUDE_CODE_API_KEY_HELPER_TTL_MS": "900000",
143154
}
155+
# Intentionally NOT setting ANTHROPIC_MODEL. Setting it produces a duplicate
156+
# catalog row in Claude Code's /model picker (e.g. "Opus 4.8 (1M context) ✓")
157+
# on top of the family-alias row from ANTHROPIC_DEFAULT_OPUS_MODEL. Without
158+
# it, Default resolves through the pinned family alias and the picker shows
159+
# only one row per model. `ucode claude -- --model X` still overrides for a
160+
# single session via Claude Code's own --model flag.
161+
_ = model # API stability; no longer pinned via env.
144162
if claude_models:
163+
# Picker rows show the raw routable id (e.g. "system.ai.claude-opus-4-8[1m]")
164+
# so users can see which gateway-routable model is behind each shortcut.
165+
# We deliberately don't set the `_NAME` companion env vars — the raw id
166+
# is more useful than a friendly label for debugging gateway routing.
145167
if claude_models.get("opus"):
146168
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = _maybe_add_1m_suffix(claude_models["opus"])
147169
if claude_models.get("sonnet"):
@@ -258,6 +280,14 @@ def write_tool_config(state: dict, model: str) -> dict:
258280
env_block.pop(key, None)
259281
# Strip only ucode's tracing Stop hook so user hooks stay intact.
260282
_remove_tracing_stop_hook(merged)
283+
# Prune ucode-managed model env keys we deliberately don't write this run
284+
# (e.g. ANTHROPIC_MODEL — see render_overlay).
285+
overlay_env = overlay.get("env", {})
286+
merged_env = merged.get("env")
287+
if isinstance(merged_env, dict):
288+
for key in CLAUDE_MANAGED_MODEL_ENV_KEYS:
289+
if key not in overlay_env:
290+
merged_env.pop(key, None)
261291
write_json_file(CLAUDE_SETTINGS_PATH, merged)
262292

263293
if web_search_model:

tests/test_agent_claude.py

Lines changed: 134 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,33 +21,53 @@ def test_display(self):
2121

2222

2323
class TestRenderOverlay:
24-
def test_sets_anthropic_model(self):
25-
overlay, _ = claude.render_overlay(WS, "databricks-claude-sonnet-4")
26-
assert overlay["env"]["ANTHROPIC_MODEL"] == "databricks-claude-sonnet-4"
24+
def test_does_not_set_anthropic_model_env(self):
25+
# We deliberately don't pin ANTHROPIC_MODEL: when set, Claude Code's
26+
# /model picker surfaces a duplicate catalog row on top of the family
27+
# alias from ANTHROPIC_DEFAULT_OPUS_MODEL. Default falls back to the
28+
# active family alias instead.
29+
overlay, _ = claude.render_overlay(
30+
WS, "databricks-claude-opus-4-7", claude_models={"opus": "databricks-claude-opus-4-7"}
31+
)
32+
assert "ANTHROPIC_MODEL" not in overlay["env"]
2733

2834
def test_adds_1m_suffix_for_opus_4_6_and_later(self):
29-
overlay, _ = claude.render_overlay(WS, "databricks-claude-opus-4-7")
30-
assert overlay["env"]["ANTHROPIC_MODEL"] == "databricks-claude-opus-4-7[1m]"
35+
overlay, _ = claude.render_overlay(
36+
WS, "s4", claude_models={"opus": "databricks-claude-opus-4-7"}
37+
)
38+
assert overlay["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "databricks-claude-opus-4-7[1m]"
3139

3240
def test_adds_1m_suffix_for_sonnet_4_6_and_later(self):
33-
overlay, _ = claude.render_overlay(WS, "databricks-claude-sonnet-4-7")
34-
assert overlay["env"]["ANTHROPIC_MODEL"] == "databricks-claude-sonnet-4-7[1m]"
41+
overlay, _ = claude.render_overlay(
42+
WS, "s4", claude_models={"sonnet": "databricks-claude-sonnet-4-7"}
43+
)
44+
assert (
45+
overlay["env"]["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "databricks-claude-sonnet-4-7[1m]"
46+
)
3547

36-
def test_does_not_add_1m_suffix_for_other_models(self):
37-
overlay, _ = claude.render_overlay(WS, "databricks-claude-haiku-4-6")
38-
assert overlay["env"]["ANTHROPIC_MODEL"] == "databricks-claude-haiku-4-6"
48+
def test_does_not_add_1m_suffix_for_haiku(self):
49+
overlay, _ = claude.render_overlay(
50+
WS, "s4", claude_models={"haiku": "databricks-claude-haiku-4-6"}
51+
)
52+
assert overlay["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "databricks-claude-haiku-4-6"
3953

4054
def test_does_not_duplicate_1m_suffix(self):
41-
overlay, _ = claude.render_overlay(WS, "databricks-claude-opus-4-7[1m]")
42-
assert overlay["env"]["ANTHROPIC_MODEL"] == "databricks-claude-opus-4-7[1m]"
55+
overlay, _ = claude.render_overlay(
56+
WS, "s4", claude_models={"opus": "databricks-claude-opus-4-7[1m]"}
57+
)
58+
assert overlay["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "databricks-claude-opus-4-7[1m]"
4359

4460
def test_adds_1m_suffix_for_model_services_name(self):
45-
overlay, _ = claude.render_overlay(WS, "system.ai.claude-opus-4-8")
46-
assert overlay["env"]["ANTHROPIC_MODEL"] == "system.ai.claude-opus-4-8[1m]"
61+
overlay, _ = claude.render_overlay(
62+
WS, "s4", claude_models={"opus": "system.ai.claude-opus-4-8"}
63+
)
64+
assert overlay["env"]["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "system.ai.claude-opus-4-8[1m]"
4765

4866
def test_no_1m_suffix_for_model_services_haiku(self):
49-
overlay, _ = claude.render_overlay(WS, "system.ai.claude-haiku-4-6")
50-
assert overlay["env"]["ANTHROPIC_MODEL"] == "system.ai.claude-haiku-4-6"
67+
overlay, _ = claude.render_overlay(
68+
WS, "s4", claude_models={"haiku": "system.ai.claude-haiku-4-6"}
69+
)
70+
assert overlay["env"]["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "system.ai.claude-haiku-4-6"
5171

5272
def test_sets_anthropic_base_url(self):
5373
overlay, _ = claude.render_overlay(WS, "s4")
@@ -90,6 +110,25 @@ def test_model_overrides_not_set_when_no_models(self):
90110
env = overlay["env"]
91111
assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in env
92112

113+
def test_picker_labels_show_raw_routable_id(self):
114+
# We deliberately don't set the `_NAME` companion env vars. Showing the
115+
# raw `system.ai.…` / `databricks-…` id in the picker label tells users
116+
# exactly which gateway-routable model is behind each shortcut, which is
117+
# more useful than a friendly catalog label for Databricks routing.
118+
models = {
119+
"opus": "system.ai.claude-opus-4-8",
120+
"sonnet": "databricks-claude-sonnet-4-6",
121+
"haiku": "system.ai.claude-haiku-4-5",
122+
}
123+
overlay, _ = claude.render_overlay(WS, "s4", claude_models=models)
124+
env = overlay["env"]
125+
assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "system.ai.claude-opus-4-8[1m]"
126+
assert "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME" not in env
127+
assert env["ANTHROPIC_DEFAULT_SONNET_MODEL"] == "databricks-claude-sonnet-4-6[1m]"
128+
assert "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME" not in env
129+
assert env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] == "system.ai.claude-haiku-4-5"
130+
assert "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME" not in env
131+
93132
def test_managed_keys_include_api_key_helper(self):
94133
_, keys = claude.render_overlay(WS, "s4")
95134
assert ["apiKeyHelper"] in keys
@@ -357,3 +396,82 @@ def fake_execvp(binary: str, args: list[str]) -> None:
357396
["claude", "--settings", str(claude.CLAUDE_SETTINGS_PATH), "--debug"],
358397
)
359398
]
399+
400+
401+
class TestWriteToolConfigPrunesStaleModelEnv:
402+
"""Stale ucode-managed model env keys (ANTHROPIC_MODEL, etc.) from earlier
403+
ucode versions must be removed on every launch — otherwise they linger in
404+
settings.json and re-introduce the duplicate /model picker row that this
405+
change is meant to remove.
406+
"""
407+
408+
def _patch(self, monkeypatch, existing_settings):
409+
monkeypatch.setattr(claude, "backup_existing_file", lambda *a, **kw: True)
410+
monkeypatch.setattr(claude, "read_json_safe", lambda path: existing_settings)
411+
written: dict = {}
412+
413+
def fake_write(path, payload):
414+
written["payload"] = payload
415+
416+
monkeypatch.setattr(claude, "write_json_file", fake_write)
417+
monkeypatch.setattr(claude, "save_state", lambda state: None)
418+
monkeypatch.setattr(claude, "_register_web_search_mcp", lambda *a, **kw: True)
419+
return written
420+
421+
def test_prunes_stale_anthropic_model_from_prior_run(self, monkeypatch):
422+
existing = {
423+
"env": {
424+
"ANTHROPIC_MODEL": "system.ai.claude-opus-4-8[1m]",
425+
"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-4-8[1m]",
426+
"MY_CUSTOM_VAR": "keep-me",
427+
}
428+
}
429+
written = self._patch(monkeypatch, existing)
430+
state = {
431+
"workspace": WS,
432+
"claude_models": {"opus": "system.ai.claude-opus-4-8"},
433+
}
434+
claude.write_tool_config(state, "system.ai.claude-opus-4-8")
435+
env = written["payload"]["env"]
436+
assert "ANTHROPIC_MODEL" not in env
437+
# Family default we still write this run is preserved.
438+
assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "system.ai.claude-opus-4-8[1m]"
439+
# User-owned keys are untouched.
440+
assert env["MY_CUSTOM_VAR"] == "keep-me"
441+
442+
def test_prunes_unused_family_default_when_models_change(self, monkeypatch):
443+
# Earlier launch wrote a sonnet default; the new state only has opus.
444+
# The stale sonnet keys should be removed.
445+
existing = {
446+
"env": {
447+
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-4-6[1m]",
448+
}
449+
}
450+
written = self._patch(monkeypatch, existing)
451+
state = {"workspace": WS, "claude_models": {"opus": "system.ai.claude-opus-4-8"}}
452+
claude.write_tool_config(state, "system.ai.claude-opus-4-8")
453+
env = written["payload"]["env"]
454+
assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in env
455+
assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "system.ai.claude-opus-4-8[1m]"
456+
457+
def test_prunes_stale_name_companion_keys_from_older_ucode(self, monkeypatch):
458+
# An older ucode build briefly wrote `_NAME` companion env vars to give
459+
# the picker friendly labels. The current build only writes the raw id,
460+
# so any leftover `_NAME` keys must be pruned — otherwise users who
461+
# tested the in-between version would see stale labels.
462+
existing = {
463+
"env": {
464+
"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-4-8[1m]",
465+
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Opus 4.8 (1M)",
466+
"ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": "Sonnet 4.6 (1M)",
467+
"ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": "Haiku 4.5",
468+
}
469+
}
470+
written = self._patch(monkeypatch, existing)
471+
state = {"workspace": WS, "claude_models": {"opus": "system.ai.claude-opus-4-8"}}
472+
claude.write_tool_config(state, "system.ai.claude-opus-4-8")
473+
env = written["payload"]["env"]
474+
assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "system.ai.claude-opus-4-8[1m]"
475+
assert "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME" not in env
476+
assert "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME" not in env
477+
assert "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME" not in env

0 commit comments

Comments
 (0)