Skip to content

Commit 58c62d5

Browse files
asujithanJamesHWadeclaude
authored
Fix pi pin default model and backup settings.json (#117)
* Pin Pi defaultProvider/defaultModel in settings.json Pi inherits the full environment from ucode, including any env vars that its pi-ai package recognizes as auth for a built-in provider (e.g. HF_TOKEN → huggingface). Pi's findInitialModel then picks the built-in before reaching our databricks-claude provider, routes to the wrong host, and validation 401s. Write settings.json alongside models.json so findInitialModel uses our provider/model directly. Fixes #68. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * backup and restore pi settings.json --------- Co-authored-by: James Wade <jhwade@dow.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4d8a0aa commit 58c62d5

4 files changed

Lines changed: 92 additions & 4 deletions

File tree

src/ucode/agents/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ def validate_tool(tool: str) -> tuple[bool, str]:
386386
def validate_all_tools(state: dict) -> None:
387387
from rich.panel import Panel # local to avoid bumping module-level deps
388388

389+
from ucode.agents.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH
389390
from ucode.config_io import restore_file
390391

391392
console.print()
@@ -411,6 +412,9 @@ def validate_all_tools(state: dict) -> None:
411412
print_err(f"{spec['display']}: {err}")
412413
managed = bool(state.get("managed_configs", {}).get(tool))
413414
restore_file(spec["config_path"], spec["backup_path"], managed)
415+
# Rollback settings.json for Pi
416+
if tool == "pi":
417+
restore_file(PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, managed)
414418
available_tools.remove(tool)
415419
state["available_tools"] = available_tools
416420
save_state(state)

src/ucode/agents/pi.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@
5252
PI_UCODE_HOME = APP_DIR / "pi-home"
5353
PI_CONFIG_DIR = PI_UCODE_HOME / ".pi" / "agent"
5454
PI_CONFIG_PATH = PI_CONFIG_DIR / "models.json"
55+
PI_SETTINGS_PATH = PI_CONFIG_DIR / "settings.json"
5556
PI_BACKUP_PATH = APP_DIR / "pi-models.backup.json"
57+
PI_SETTINGS_BACKUP_PATH = APP_DIR / "pi-settings.backup.json"
5658

5759
SPEC: ToolSpec = {
5860
"binary": "pi",
@@ -184,11 +186,25 @@ def write_tool_config(
184186
providers.pop(stale, None)
185187
merged = deep_merge_dict(existing, overlay)
186188
write_json_file(PI_CONFIG_PATH, merged)
189+
_write_settings(overlay["model"])
187190
state = mark_tool_managed(state, "pi", managed_keys)
188191
save_state(state)
189192
return state, token
190193

191194

195+
def _write_settings(model_selector: str) -> None:
196+
# Pin defaultProvider/defaultModel in settings.json so Pi doesn't fall
197+
# through to an env-key-backed provider (e.g. HF_TOKEN exposing
198+
# huggingface) in `findInitialModel` when no --model is passed.
199+
provider, _, model_id = model_selector.partition("/")
200+
if not model_id:
201+
return
202+
backup_existing_file(PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH)
203+
existing = read_json_safe(PI_SETTINGS_PATH)
204+
merged = deep_merge_dict(existing, {"defaultProvider": provider, "defaultModel": model_id})
205+
write_json_file(PI_SETTINGS_PATH, merged)
206+
207+
192208
def default_model(state: dict) -> str | None:
193209
"""Prefer Claude opus → sonnet → haiku; fall back to codex, gemini."""
194210
claude_models = state.get("claude_models") or {}

src/ucode/cli.py

Lines changed: 5 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.pi import PI_SETTINGS_BACKUP_PATH, PI_SETTINGS_PATH
2829
from ucode.config_io import restore_file, set_dry_run
2930
from ucode.databricks import (
3031
build_shared_base_urls,
@@ -398,12 +399,16 @@ def revert() -> int:
398399
)
399400
for tool, spec in TOOL_SPECS.items()
400401
}
402+
pi_settings_restored = restore_file(
403+
PI_SETTINGS_PATH, PI_SETTINGS_BACKUP_PATH, bool(managed_configs.get("pi"))
404+
)
401405
clear_state()
402406

403407
print_heading("Revert")
404408
print_kv("Workspace", state.get("workspace") or "none")
405409
for tool, spec in TOOL_SPECS.items():
406410
print_kv(f"{spec['display']} config", "restored" if results[tool] else "unchanged")
411+
print_kv("Pi settings", "restored" if pi_settings_restored else "unchanged")
407412
for client, spec in MCP_CLIENTS.items():
408413
print_kv(
409414
f"{spec['display']} MCP config",

tests/test_agent_pi.py

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
from contextlib import nullcontext
67
from unittest.mock import patch
78

89
from ucode.agents import pi
@@ -267,9 +268,13 @@ def _setup(self, tmp_path, monkeypatch):
267268
monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path)
268269
config_file = tmp_path / "models.json"
269270
backup_file = tmp_path / "pi-backup.json"
271+
settings_file = tmp_path / "settings.json"
272+
settings_backup_file = tmp_path / "pi-settings-backup.json"
270273
monkeypatch.setattr(pi_mod, "PI_CONFIG_PATH", config_file)
274+
monkeypatch.setattr(pi_mod, "PI_SETTINGS_PATH", settings_file)
271275
monkeypatch.setattr(pi_mod, "PI_BACKUP_PATH", backup_file)
272-
return pi_mod, config_file
276+
monkeypatch.setattr(pi_mod, "PI_SETTINGS_BACKUP_PATH", settings_backup_file)
277+
return pi_mod, config_file, settings_file, settings_backup_file
273278

274279
def _state(self, **overrides) -> dict:
275280
state = {
@@ -284,7 +289,7 @@ def _state(self, **overrides) -> dict:
284289
return state
285290

286291
def test_stale_managed_providers_removed_before_merge(self, tmp_path, monkeypatch):
287-
pi_mod, config_file = self._setup(tmp_path, monkeypatch)
292+
pi_mod, config_file, _, _ = self._setup(tmp_path, monkeypatch)
288293

289294
stale = {
290295
"providers": {
@@ -312,7 +317,7 @@ def test_legacy_providers_removed_on_upgrade(self, tmp_path, monkeypatch):
312317
"""Earlier ucode versions wrote `databricks-anthropic`, `databricks-codex`,
313318
and `databricks-oss` providers. They must be stripped on the next write
314319
so users don't end up with stale entries pointing at routes that 400."""
315-
pi_mod, config_file = self._setup(tmp_path, monkeypatch)
320+
pi_mod, config_file, _, _ = self._setup(tmp_path, monkeypatch)
316321

317322
config_file.write_text(
318323
json.dumps(
@@ -339,7 +344,7 @@ def test_legacy_providers_removed_on_upgrade(self, tmp_path, monkeypatch):
339344
assert "databricks-claude" in written_providers
340345

341346
def test_config_written_with_correct_model_and_token(self, tmp_path, monkeypatch):
342-
pi_mod, config_file = self._setup(tmp_path, monkeypatch)
347+
pi_mod, config_file, _, _ = self._setup(tmp_path, monkeypatch)
343348

344349
with (
345350
patch("ucode.agents.pi.get_databricks_token", return_value="tok"),
@@ -350,3 +355,61 @@ def test_config_written_with_correct_model_and_token(self, tmp_path, monkeypatch
350355
written = json.loads(config_file.read_text())
351356
assert written["model"] == "databricks-claude/claude-sonnet"
352357
assert written["providers"]["databricks-claude"]["apiKey"] == "tok"
358+
359+
def test_settings_pins_default_provider_and_model(self, tmp_path, monkeypatch):
360+
# Without this, Pi's `findInitialModel` can fall through to a built-in
361+
# provider when an unrelated env var (e.g. HF_TOKEN) makes one look
362+
# auth-configured. Pinning the default keeps Pi on our provider.
363+
pi_mod, _, settings_file, _ = self._setup(tmp_path, monkeypatch)
364+
365+
with (
366+
patch("ucode.agents.pi.get_databricks_token", return_value="tok"),
367+
patch("ucode.agents.pi.save_state"),
368+
):
369+
pi_mod.write_tool_config(self._state(), "claude-sonnet", token="tok")
370+
371+
settings = json.loads(settings_file.read_text())
372+
assert settings["defaultProvider"] == "databricks-claude"
373+
assert settings["defaultModel"] == "claude-sonnet"
374+
375+
def test_pre_existing_settings_are_backed_up_before_first_write(self, tmp_path, monkeypatch):
376+
pi_mod, _, settings_file, settings_backup_file = self._setup(tmp_path, monkeypatch)
377+
378+
original = '{"theme": "Default Dark", "defaultProvider": "openai"}'
379+
settings_file.parent.mkdir(parents=True, exist_ok=True)
380+
settings_file.write_text(original, encoding="utf-8")
381+
382+
with (
383+
patch("ucode.agents.pi.get_databricks_token", return_value="tok"),
384+
patch("ucode.agents.pi.save_state"),
385+
):
386+
pi_mod.write_tool_config(self._state(), "claude-sonnet", token="tok")
387+
388+
assert settings_backup_file.read_text(encoding="utf-8") == original
389+
# The on-disk settings still get the ucode pin applied via deep_merge.
390+
merged = json.loads(settings_file.read_text())
391+
assert merged["defaultProvider"] == "databricks-claude"
392+
assert merged["theme"] == "Default Dark"
393+
394+
395+
class TestValidateAllToolsPiRollback:
396+
def test_failed_pi_validation_rolls_back_settings(self, tmp_path, monkeypatch):
397+
import ucode.agents as agents_mod
398+
import ucode.agents.pi as pi_mod
399+
400+
settings_file = tmp_path / "settings.json"
401+
settings_file.write_text("{}", encoding="utf-8")
402+
monkeypatch.setattr(pi_mod, "PI_SETTINGS_PATH", settings_file)
403+
monkeypatch.setattr(pi_mod, "PI_SETTINGS_BACKUP_PATH", tmp_path / "settings.backup.json")
404+
# Keep the generic models.json rollback off the user's real config dir.
405+
monkeypatch.setitem(agents_mod.TOOL_SPECS["pi"], "config_path", tmp_path / "models.json")
406+
monkeypatch.setitem(
407+
agents_mod.TOOL_SPECS["pi"], "backup_path", tmp_path / "models.backup.json"
408+
)
409+
monkeypatch.setattr(agents_mod, "validate_tool", lambda tool: (False, "boom"))
410+
monkeypatch.setattr(agents_mod, "save_state", lambda s: None)
411+
monkeypatch.setattr(agents_mod, "spinner", lambda *_a, **_kw: nullcontext())
412+
413+
agents_mod.validate_all_tools({"available_tools": ["pi"], "managed_configs": {"pi": True}})
414+
415+
assert not settings_file.exists()

0 commit comments

Comments
 (0)