Skip to content

Commit 562695e

Browse files
authored
resolve managed models from the config alone and skip redundant disco… (#274)
* resolve managed models from the config alone and skip redundant discovery * leave unpinned claude families unset
1 parent 7d0aca2 commit 562695e

5 files changed

Lines changed: 196 additions & 15 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,27 @@ def _managed_pinned_model() -> tuple[Path, str] | None:
184184
return (path, str(env["ANTHROPIC_MODEL"]))
185185

186186

187+
def managed_settings_model_overrides() -> Path | None:
188+
"""Path to enterprise managed settings when they pin a model ucode selects with, else None.
189+
190+
The enterprise scope outranks the ``--settings`` file ucode passes, so a model set there wins
191+
over the one an admin published in the workspace's managed config — and unlike the user and
192+
project scopes it can't be excluded with ``--setting-sources``. Callers surface this as a warning
193+
so a developer whose models don't match their admin's config knows where to look.
194+
195+
Only the keys ucode actually writes count. The ``_NAME`` companions in
196+
:data:`CLAUDE_MANAGED_MODEL_ENV_KEYS` are picker labels that select nothing, so an enterprise
197+
value there can't override anything and warning about it would be noise."""
198+
path = _managed_settings_path()
199+
if path is None or not path.is_file():
200+
return None
201+
env = read_json_safe(path).get("env")
202+
if not isinstance(env, dict):
203+
return None
204+
selecting_keys = (key for key in CLAUDE_MANAGED_MODEL_ENV_KEYS if not key.endswith("_NAME"))
205+
return path if any(env.get(key) for key in selecting_keys) else None
206+
207+
187208
def relayed_proxy_base_url(state: dict) -> str:
188209
"""Loopback base URL for the relayed refresh proxy, allocating a free port
189210
on first call and caching it in state so config and launch agree."""

src/ucode/cli.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,16 @@
5555
resolve_pat_token,
5656
run_databricks_login,
5757
)
58-
from ucode.managed_config import managed_agent_config_enabled, managed_launch_state
59-
from ucode.managed_resolve import managed_default_model, managed_provider_service
58+
from ucode.managed_config import (
59+
load_managed_state,
60+
managed_agent_config_enabled,
61+
managed_launch_state,
62+
)
63+
from ucode.managed_resolve import (
64+
managed_default_model,
65+
managed_provider_service,
66+
managed_supplies_models,
67+
)
6068
from ucode.mcp import (
6169
MCP_CLIENTS,
6270
SKILLS_MCP_KIND,
@@ -1179,6 +1187,10 @@ def _launch_tool(
11791187
# back to whatever `ucode configure` saved for this tool.
11801188
provider = provider or get_provider_service(state, tool)
11811189
routing_agent = _ROUTING_AGENTS.get(tool)
1190+
# Discovery exists to find models and isn't needed for managed config that already names them.
1191+
managed_models_known = managed_agent_config_enabled() and managed_supplies_models(
1192+
load_managed_state(state.get("workspace")), tool
1193+
)
11821194
# Re-fetch model lists on every launch so newly-added Databricks
11831195
# endpoints show up without a manual `ucode configure` (and so that
11841196
# tools like pi which read multiple model bundles never run on
@@ -1188,7 +1200,7 @@ def _launch_tool(
11881200
state["workspace"],
11891201
profile=state.get("profile"),
11901202
tools=[tool],
1191-
skip_model_discovery=bool(provider),
1203+
skip_model_discovery=bool(provider) or managed_models_known,
11921204
skip_preflight=skip_preflight,
11931205
)
11941206
# An admin-published managed config wins over the developer's own settings. Resolved before
@@ -1202,6 +1214,16 @@ def _launch_tool(
12021214
state, managed = managed_launch_state(state, tool, skip_preflight=skip_preflight)
12031215
if managed is not None:
12041216
print_success("Applied your workspace's managed coding agent config")
1217+
# The enterprise scope outranks the --settings file ucode writes, so a model pinned
1218+
# there quietly beats the admin's — point at the file rather than let the mismatch
1219+
# look like a ucode bug.
1220+
if tool == "claude":
1221+
overrides = claude_agent.managed_settings_model_overrides()
1222+
if overrides is not None:
1223+
print_warning(
1224+
f"Default models are set in your enterprise managed settings at "
1225+
f"{overrides}, which may override your admin's managed config."
1226+
)
12051227
else:
12061228
print_note("No managed coding agent config found; using your own settings")
12071229
if managed is not None:

src/ucode/managed_resolve.py

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -60,19 +60,28 @@ def _agent_model_config(managed: dict, tool: str) -> dict[str, object]:
6060
def effective_agent_models(managed: dict, state: dict, tool: str) -> dict | list | None:
6161
"""Resolve ``tool``'s model list/slots from the manifest, falling back to ucode state.
6262
63-
Claude keys its models by family (``opus``/``sonnet``/``haiku``/``fable``) and resolves per
64-
family, so a family the manifest omits keeps the developer's value. Every other agent stores a
65-
flat list, which has no per-key identity — there the manifest's list replaces the local one
66-
outright, or the local one stands when the manifest specifies none.
63+
Once the manifest says anything about ``tool``'s models it is the whole allowlist: the
64+
developer's discovered models drop out entirely, so a launch can only reach models the admin
65+
named. Each claude family resolves only to its own slot — a family the manifest leaves out stays
66+
unset rather than inheriting ``default_model``, so ucode writes no
67+
``ANTHROPIC_DEFAULT_<FAMILY>_MODEL`` for it and the agent falls back to its own default. Omitting
68+
a family is how an admin steers people off it, and filling it in with ``default_model`` would
69+
quietly re-enable what they left out. Every other agent stores a flat list, which has no per-key
70+
identity — there the manifest's list replaces the local one outright. Only when the manifest
71+
names nothing for ``tool`` does the developer's own list stand.
6772
"""
68-
manifest_models = _agent_model_config(managed, tool).get("models")
73+
model_config = _agent_model_config(managed, tool)
74+
manifest_models = model_config.get("models")
6975
if tool == "claude":
70-
local = dict(_as_dict(state.get("claude_models")))
76+
slots: dict[str, str] = {}
7177
for slot, family in _CLAUDE_FAMILY_SLOTS.items():
7278
model = _str(_as_dict(manifest_models).get(slot))
7379
if model:
74-
local[family] = model
75-
return local or None
80+
slots[family] = model
81+
if slots:
82+
return slots
83+
local = _as_dict(state.get("claude_models"))
84+
return dict(local) if local else None
7685
if isinstance(manifest_models, list):
7786
models = [m for m in (_str(item) for item in manifest_models) if m]
7887
if models:
@@ -81,6 +90,24 @@ def effective_agent_models(managed: dict, state: dict, tool: str) -> dict | list
8190
return local_list if local_list else None
8291

8392

93+
def managed_supplies_models(managed: dict | None, tool: str) -> bool:
94+
"""True when the managed config already says which models ``tool`` should use.
95+
96+
Lets the launch path skip Databricks model discovery, whose whole purpose is to find the models
97+
the config has now specified. Any of the three counts: a provider (the agent routes by header and
98+
pins no Databricks model), a ``default_model``, or at least one entry in ``models``.
99+
"""
100+
model_config = _agent_model_config(managed or {}, tool)
101+
if _str(model_config.get("model_provider_service")) or _str(model_config.get("default_model")):
102+
return True
103+
models = model_config.get("models")
104+
if isinstance(models, dict):
105+
return any(_str(value) for value in models.values())
106+
if isinstance(models, list):
107+
return any(_str(item) for item in models)
108+
return False
109+
110+
84111
def managed_provider_service(managed: dict, tool: str) -> str | None:
85112
"""Return only the provider the managed config specifies for ``tool``, ignoring local state."""
86113
return _str(_agent_model_config(managed, tool).get("model_provider_service"))

tests/test_agent_claude.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -864,3 +864,44 @@ def test_disable_removes_only_ucode_hooks(self, tmp_path, monkeypatch):
864864
assert state.get(claude.SMART_ROUTING_STATE_KEY) is None
865865
assert list(doc["hooks"]) == ["PreToolUse"]
866866
assert doc["hooks"]["PreToolUse"][0]["hooks"][0]["command"] == "user-policy"
867+
868+
869+
class TestManagedSettingsModelOverrides:
870+
"""Enterprise managed settings outrank ucode's --settings, so a model pinned there beats the
871+
one an admin published — worth pointing a developer at the file."""
872+
873+
@staticmethod
874+
def _write(monkeypatch, tmp_path, payload):
875+
path = tmp_path / "managed-settings.json"
876+
path.write_text(json.dumps(payload), encoding="utf-8")
877+
monkeypatch.setattr(claude, "_managed_settings_path", lambda: path)
878+
return path
879+
880+
@pytest.mark.parametrize(
881+
"key",
882+
["ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"],
883+
)
884+
def test_reports_the_path_when_a_model_is_pinned(self, monkeypatch, tmp_path, key):
885+
path = self._write(monkeypatch, tmp_path, {"env": {key: "system.ai.claude-opus-5"}})
886+
assert claude.managed_settings_model_overrides() == path
887+
888+
def test_none_for_name_companions_that_select_nothing(self, monkeypatch, tmp_path):
889+
# The `_NAME` keys are picker labels, so an enterprise value there overrides no model.
890+
self._write(monkeypatch, tmp_path, {"env": {"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": "Opus 5"}})
891+
assert claude.managed_settings_model_overrides() is None
892+
893+
def test_none_when_no_model_keys_are_set(self, monkeypatch, tmp_path):
894+
self._write(monkeypatch, tmp_path, {"env": {"SOMETHING_ELSE": "1"}})
895+
assert claude.managed_settings_model_overrides() is None
896+
897+
def test_none_when_env_block_is_absent(self, monkeypatch, tmp_path):
898+
self._write(monkeypatch, tmp_path, {"permissions": {}})
899+
assert claude.managed_settings_model_overrides() is None
900+
901+
def test_none_on_platforms_without_managed_settings(self, monkeypatch):
902+
monkeypatch.setattr(claude, "_managed_settings_path", lambda: None)
903+
assert claude.managed_settings_model_overrides() is None
904+
905+
def test_none_when_the_file_does_not_exist(self, monkeypatch, tmp_path):
906+
monkeypatch.setattr(claude, "_managed_settings_path", lambda: tmp_path / "missing.json")
907+
assert claude.managed_settings_model_overrides() is None

tests/test_managed_resolve.py

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
effective_agent_models,
1414
managed_default_model,
1515
managed_provider_service,
16+
managed_supplies_models,
1617
resolve_state,
1718
)
1819
from ucode.state import MANAGED_OVERLAY_KEY
@@ -58,6 +59,7 @@ def _state(**overrides) -> dict:
5859
class TestClaudeModels:
5960
def test_proto_slots_map_to_families(self):
6061
# The manifest keeps proto spelling (`default_opus_model`); render_overlay reads `opus`.
62+
# `fable` has no slot here, so it stays unset rather than inheriting `default_model`.
6163
models = effective_agent_models(MANAGED, _state(), "claude")
6264
assert models == {
6365
"opus": "system.ai.claude-opus-5",
@@ -70,16 +72,33 @@ def test_manifest_wins_over_local_per_family(self):
7072
models = effective_agent_models(MANAGED, state, "claude")
7173
assert models["opus"] == "system.ai.claude-opus-5"
7274

73-
def test_family_absent_from_manifest_keeps_local_value(self):
74-
# Claude resolves per family, so a family the admin didn't pin keeps the developer's choice.
75+
def test_family_absent_from_manifest_is_dropped(self):
76+
# The manifest is the whole allowlist: a family the admin didn't pin is left unset rather
77+
# than inheriting the developer's model, so a launch can't reach models the admin didn't
78+
# sanction. Nothing is written for it, so the agent uses its own default.
7579
managed = {
7680
"enabled_agents": {
7781
"claude": {"model_config": {"models": {"default_opus_model": "managed-opus"}}}
7882
}
7983
}
8084
state = _state(claude_models={"opus": "local-opus", "fable": "local-fable"})
81-
models = effective_agent_models(managed, state, "claude")
82-
assert models == {"opus": "managed-opus", "fable": "local-fable"}
85+
assert effective_agent_models(managed, state, "claude") == {"opus": "managed-opus"}
86+
87+
def test_unset_families_do_not_inherit_the_default_model(self):
88+
# An admin who names only opus is steering people off the other families, so filling them in
89+
# from `default_model` would quietly re-enable what they left out.
90+
managed = {
91+
"enabled_agents": {
92+
"claude": {
93+
"model_config": {
94+
"default_model": "managed-default",
95+
"models": {"default_opus_model": "managed-opus"},
96+
}
97+
}
98+
}
99+
}
100+
state = _state(claude_models={"sonnet": "local-sonnet"})
101+
assert effective_agent_models(managed, state, "claude") == {"opus": "managed-opus"}
83102

84103
def test_no_manifest_models_falls_back_to_local(self):
85104
state = _state(claude_models={"sonnet": "local-sonnet"})
@@ -307,3 +326,54 @@ def test_survives_a_config_with_no_model_list(self):
307326
# Nothing lands in the model list, so the launch path must pass the default model into
308327
# resolve_launch_model rather than relying on state having one.
309328
assert resolve_state(managed, state, "codex").get("codex_models") is None
329+
330+
331+
class TestManagedSuppliesModels:
332+
"""Whether the config already says which models an agent uses, so discovery can be skipped."""
333+
334+
def test_true_when_a_family_slot_is_pinned(self):
335+
managed = {
336+
"enabled_agents": {
337+
"claude": {
338+
"model_config": {"models": {"default_opus_model": "system.ai.claude-opus-5"}}
339+
}
340+
}
341+
}
342+
assert managed_supplies_models(managed, "claude") is True
343+
344+
def test_true_for_a_default_model(self):
345+
managed = {"enabled_agents": {"codex": {"model_config": {"default_model": "gpt"}}}}
346+
assert managed_supplies_models(managed, "codex") is True
347+
348+
def test_true_for_a_provider(self):
349+
# A provider routes by header and pins no Databricks model, so discovery is moot.
350+
managed = {
351+
"enabled_agents": {
352+
"claude": {"model_config": {"model_provider_service": "main.default.mps"}}
353+
}
354+
}
355+
assert managed_supplies_models(managed, "claude") is True
356+
357+
def test_true_for_a_flat_model_list(self):
358+
managed = {"enabled_agents": {"opencode": {"model_config": {"models": ["a", "b"]}}}}
359+
assert managed_supplies_models(managed, "opencode") is True
360+
361+
def test_false_when_the_config_names_no_models(self):
362+
# Discovery still has to run, or the launch has nothing to pin.
363+
managed = {"enabled_agents": {"claude": {"use_as_global_settings": True}}}
364+
assert managed_supplies_models(managed, "claude") is False
365+
366+
def test_false_for_an_agent_the_config_does_not_cover(self):
367+
assert managed_supplies_models(MANAGED, "gemini") is False
368+
369+
def test_false_for_no_config_at_all(self):
370+
# First launch has no persisted copy yet, so discovery runs exactly as it always did.
371+
assert managed_supplies_models(None, "claude") is False
372+
373+
def test_false_when_slots_are_present_but_blank(self):
374+
managed = {
375+
"enabled_agents": {
376+
"claude": {"model_config": {"models": {"default_opus_model": " "}}}
377+
}
378+
}
379+
assert managed_supplies_models(managed, "claude") is False

0 commit comments

Comments
 (0)