Skip to content

Commit ce52ffc

Browse files
Fix ucode revert for legacy Codex shared-config edits (#150)
`ucode revert` only restored the per-profile ~/.codex/ucode.config.toml, so the legacy in-place edits to the user's shared ~/.codex/config.toml (profile = "ucode", [profiles.ucode], [model_providers.ucode-databricks]) were left behind — every bare `codex` invocation kept routing through the gateway. - revert now surgically strips the three ucode-namespaced keys from the shared config (safer than a whole-file backup restore, which would clobber later edits) - _remove_legacy_ucode_profile also drops the inert ucode-databricks provider block - default_model only considers GPT-parseable ids, so a non-GPT workspace (e.g. moonshotai/kimi-k2.5) no longer pins an unroutable model Co-authored-by: Isaac
1 parent 678bd8b commit ce52ffc

4 files changed

Lines changed: 176 additions & 22 deletions

File tree

src/ucode/agents/codex.py

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -160,30 +160,84 @@ def _legacy_backup_path() -> Path:
160160
return CODEX_BACKUP_PATH.with_name("codex-legacy-config.backup.toml")
161161

162162

163-
def _remove_legacy_ucode_profile() -> None:
164-
"""Remove ucode's old [profiles.ucode] entry from shared Codex config."""
165-
path = _legacy_config_path()
166-
if path == CODEX_CONFIG_PATH or not path.exists():
167-
return
163+
def _has_legacy_ucode_entries(doc: dict) -> bool:
164+
profiles = doc.get("profiles")
165+
providers = doc.get("model_providers")
166+
return (
167+
doc.get("profile") == CODEX_PROFILE_NAME
168+
or (isinstance(profiles, dict) and CODEX_PROFILE_NAME in profiles)
169+
or (isinstance(providers, dict) and CODEX_MODEL_PROVIDER_NAME in providers)
170+
)
171+
172+
173+
def _strip_legacy_ucode_entries(path: Path) -> bool:
174+
"""Surgically remove ucode's keys from a shared Codex config.
175+
176+
Drops the top-level ``profile = "ucode"`` selector, ``[profiles.ucode]``,
177+
and ``[model_providers.ucode-databricks]`` while leaving everything else the
178+
user has in the file untouched. Returns True if anything was removed.
179+
180+
Surgical removal beats restoring the backup: ``backup_existing_file`` only
181+
keeps the first-ever snapshot, so a whole-file restore would clobber edits
182+
made since ucode first ran.
183+
"""
184+
if not path.exists():
185+
return False
168186

169187
doc = read_toml_safe(path)
170188
changed = False
171189

190+
if doc.get("profile") == CODEX_PROFILE_NAME:
191+
doc.pop("profile", None)
192+
changed = True
193+
172194
profiles = doc.get("profiles")
173195
if isinstance(profiles, dict) and CODEX_PROFILE_NAME in profiles:
174-
backup_existing_file(path, _legacy_backup_path())
175196
profiles.pop(CODEX_PROFILE_NAME, None)
176197
if not profiles:
177198
doc.pop("profiles", None)
178199
changed = True
179200

180-
if doc.get("profile") == CODEX_PROFILE_NAME:
181-
backup_existing_file(path, _legacy_backup_path())
182-
doc.pop("profile", None)
201+
providers = doc.get("model_providers")
202+
if isinstance(providers, dict) and CODEX_MODEL_PROVIDER_NAME in providers:
203+
providers.pop(CODEX_MODEL_PROVIDER_NAME, None)
204+
if not providers:
205+
doc.pop("model_providers", None)
183206
changed = True
184207

185208
if changed:
186209
write_toml_file(path, doc)
210+
return changed
211+
212+
213+
def _remove_legacy_ucode_profile() -> None:
214+
"""Remove ucode's old shared-config entries when configuring modern Codex.
215+
216+
Strips the legacy ``profile``/``[profiles.ucode]`` selector and the
217+
``[model_providers.ucode-databricks]`` provider block that older ucode
218+
versions deep-merged into ``~/.codex/config.toml``.
219+
"""
220+
path = _legacy_config_path()
221+
if path == CODEX_CONFIG_PATH or not path.exists():
222+
return
223+
224+
if _has_legacy_ucode_entries(read_toml_safe(path)):
225+
backup_existing_file(path, _legacy_backup_path())
226+
_strip_legacy_ucode_entries(path)
227+
228+
229+
def revert_legacy_shared_config() -> bool:
230+
"""Undo legacy in-place edits to ``~/.codex/config.toml`` on revert.
231+
232+
Codex CLI < 0.134.0 had ucode deep-merge ``profile = "ucode"``,
233+
``[profiles.ucode]``, and ``[model_providers.ucode-databricks]`` into the
234+
user's real shared config, which routes every bare ``codex`` invocation
235+
through the workspace gateway. ``ucode revert`` only restored the
236+
per-profile file, leaving those edits in place. Surgically strip them here.
237+
238+
Returns True if anything was removed.
239+
"""
240+
return _strip_legacy_ucode_entries(_legacy_config_path())
187241

188242

189243
def _openai_model_id(model: str | None) -> str | None:
@@ -256,22 +310,26 @@ def default_model(state: dict) -> str | None:
256310
257311
The discovery list is alphabetically sorted, which can put
258312
"databricks-gpt-5" ahead of "databricks-gpt-5-5". Prefer the
259-
highest semantic version instead. Falls back to the first
260-
discovered entry when parsing fails.
313+
highest semantic version instead.
314+
315+
Only GPT-parseable ids are considered. Codex routes the chosen ``model``
316+
through the gateway as-is, so a non-GPT entry (e.g. ``moonshotai/kimi-k2.5``)
317+
would be rejected with a Unity Catalog endpoint-name error. When no
318+
candidate parses as GPT we return None rather than pinning an unroutable id.
261319
"""
262320
codex_models = state.get("codex_models") or []
263-
if not codex_models:
321+
parsed: list[tuple[str, tuple[int, int | None, int | None, str]]] = [
322+
(mid, gpt) for mid in codex_models if (gpt := _parse_gpt(mid)) is not None
323+
]
324+
if not parsed:
264325
return None
265326

266-
def _gpt_version_key(mid: str) -> tuple[int, int, int, int]:
267-
parsed = _parse_gpt(mid)
268-
if parsed is None:
269-
return (0, 0, 0, 0)
270-
major, minor, patch, suffix = parsed
327+
def _gpt_version_key(entry: tuple[str, tuple[int, int | None, int | None, str]]):
328+
major, minor, patch, suffix = entry[1]
271329
base_bonus = 1 if not suffix else 0
272330
return (major, minor or 0, patch or 0, base_bonus)
273331

274-
return max(codex_models, key=_gpt_version_key)
332+
return max(parsed, key=_gpt_version_key)[0]
275333

276334

277335
def launch(state: dict, tool_args: list[str]) -> None:

src/ucode/cli.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from ucode.agents import (
2626
launch as launch_agent,
2727
)
28+
from ucode.agents.codex import revert_legacy_shared_config
2829
from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH
2930
from ucode.config_io import restore_file, set_dry_run
3031
from ucode.databricks import (
@@ -440,12 +441,17 @@ def revert() -> int:
440441
pi_settings_restored = restore_file(
441442
PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, bool(managed_configs.get("pi"))
442443
)
444+
# Older Codex (< 0.134.0) had ucode edit the shared ~/.codex/config.toml in
445+
# place; restoring the per-profile file above does not undo that.
446+
legacy_codex_stripped = revert_legacy_shared_config()
443447
clear_state()
444448

445449
print_heading("Revert")
446450
print_kv("Workspace", state.get("workspace") or "none")
447451
for tool, spec in TOOL_SPECS.items():
448452
print_kv(f"{spec['display']} config", "restored" if results[tool] else "unchanged")
453+
if legacy_codex_stripped:
454+
print_kv("Codex shared config", "ucode entries removed")
449455
print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged")
450456
for client, spec in MCP_CLIENTS.items():
451457
print_kv(

tests/test_agent_codex.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,83 @@ def test_unknown_version_uses_modern_layout(self, monkeypatch):
219219
assert codex._use_legacy_layout() is False
220220

221221

222+
class TestCodexRemoveLegacyProfile:
223+
def test_drops_provider_block_on_modern_path(self, tmp_path, monkeypatch):
224+
config_dir = tmp_path / ".codex"
225+
config_dir.mkdir()
226+
profile_path = config_dir / "ucode.config.toml"
227+
legacy_path = config_dir / "config.toml"
228+
legacy_path.write_text(
229+
'profile = "ucode"\n\n'
230+
"[profiles.ucode]\n"
231+
'model_provider = "ucode-databricks"\n\n'
232+
"[model_providers.ucode-databricks]\n"
233+
'name = "Databricks AI Gateway"\n\n'
234+
"[model_providers.other]\n"
235+
'name = "keep"\n',
236+
encoding="utf-8",
237+
)
238+
backup_path = tmp_path / "codex-ucode-config.backup.toml"
239+
monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", profile_path)
240+
monkeypatch.setattr(codex, "CODEX_BACKUP_PATH", backup_path)
241+
monkeypatch.setattr(codex, "agent_version", lambda binary: "0.134.0")
242+
monkeypatch.setattr(codex, "save_state", lambda state: None)
243+
244+
codex.write_tool_config({"workspace": WS, "codex_models": ["gpt-5"]})
245+
246+
doc = read_toml_safe(legacy_path)
247+
assert "profile" not in doc
248+
assert "ucode" not in doc.get("profiles", {})
249+
assert "ucode-databricks" not in doc["model_providers"]
250+
assert doc["model_providers"]["other"]["name"] == "keep"
251+
252+
253+
class TestCodexRevertLegacySharedConfig:
254+
def test_strips_all_ucode_entries(self, tmp_path, monkeypatch):
255+
config_dir = tmp_path / ".codex"
256+
config_dir.mkdir()
257+
profile_path = config_dir / "ucode.config.toml"
258+
legacy_path = config_dir / "config.toml"
259+
legacy_path.write_text(
260+
'profile = "ucode"\n\n'
261+
"[profiles.ucode]\n"
262+
'model_provider = "ucode-databricks"\n\n'
263+
"[profiles.other]\n"
264+
'model_provider = "keep"\n\n'
265+
"[model_providers.ucode-databricks]\n"
266+
'name = "Databricks AI Gateway"\n',
267+
encoding="utf-8",
268+
)
269+
monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", profile_path)
270+
271+
assert codex.revert_legacy_shared_config() is True
272+
273+
doc = read_toml_safe(legacy_path)
274+
assert "profile" not in doc
275+
assert "ucode" not in doc["profiles"]
276+
assert doc["profiles"]["other"]["model_provider"] == "keep"
277+
assert "model_providers" not in doc
278+
279+
def test_returns_false_when_no_ucode_entries(self, tmp_path, monkeypatch):
280+
config_dir = tmp_path / ".codex"
281+
config_dir.mkdir()
282+
profile_path = config_dir / "ucode.config.toml"
283+
legacy_path = config_dir / "config.toml"
284+
legacy_path.write_text('[profiles.other]\nmodel_provider = "keep"\n', encoding="utf-8")
285+
monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", profile_path)
286+
287+
assert codex.revert_legacy_shared_config() is False
288+
289+
doc = read_toml_safe(legacy_path)
290+
assert doc["profiles"]["other"]["model_provider"] == "keep"
291+
292+
def test_returns_false_when_no_shared_config(self, tmp_path, monkeypatch):
293+
profile_path = tmp_path / ".codex" / "ucode.config.toml"
294+
monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", profile_path)
295+
296+
assert codex.revert_legacy_shared_config() is False
297+
298+
222299
class TestCodexDefaultModel:
223300
def test_picks_highest_semver_over_alpha(self):
224301
state = {"codex_models": ["databricks-gpt-5", "databricks-gpt-5-5"]}
@@ -228,6 +305,18 @@ def test_picks_highest_semver_over_alpha(self):
228305
def test_none_when_no_models(self):
229306
assert codex.default_model({}) is None
230307

308+
def test_none_when_no_gpt_parseable_models(self):
309+
# A workspace whose responses-capable models aren't GPT (e.g. kimi)
310+
# must not pin an unroutable id as the Codex model.
311+
state = {"codex_models": ["moonshotai/kimi-k2.5", "claude-sonnet-4"]}
312+
313+
assert codex.default_model(state) is None
314+
315+
def test_ignores_non_gpt_candidates(self):
316+
state = {"codex_models": ["moonshotai/kimi-k2.5", "databricks-gpt-5-5"]}
317+
318+
assert codex.default_model(state) == "databricks-gpt-5-5"
319+
231320
def test_prefers_base_over_suffixed_same_version(self):
232321
models = ["gpt-5-5-mini", "gpt-5-5", "gpt-5"]
233322

tests/test_agents_init.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,9 @@ def test_pi_unavailable_when_no_models(self):
107107

108108

109109
class TestDefaultModelForTool:
110-
def test_codex_returns_first_model(self):
111-
assert default_model_for_tool("codex", {"codex_models": ["c1", "c2"]}) == "c1"
110+
def test_codex_returns_highest_gpt_model(self):
111+
models = ["databricks-gpt-5", "databricks-gpt-5-5"]
112+
assert default_model_for_tool("codex", {"codex_models": models}) == "databricks-gpt-5-5"
112113

113114
def test_codex_returns_none_when_no_models(self):
114115
assert default_model_for_tool("codex", {}) is None
@@ -161,9 +162,9 @@ def test_pi_returns_none_when_no_models(self):
161162

162163
class TestResolveLaunchModel:
163164
def test_codex_default_model_used_when_no_explicit(self):
164-
state = {"codex_models": ["c1"]}
165+
state = {"codex_models": ["databricks-gpt-5"]}
165166
_, model = resolve_launch_model("codex", state, None)
166-
assert model == "c1"
167+
assert model == "databricks-gpt-5"
167168

168169
def test_explicit_model_used_when_provided(self):
169170
_, model = resolve_launch_model("claude", {}, "my-model")

0 commit comments

Comments
 (0)