Skip to content

Commit eea5ffb

Browse files
authored
Add support for fable via ucode configure --enable-fable or ucode configure --disable-fable (#213)
1 parent d29d9f3 commit eea5ffb

6 files changed

Lines changed: 234 additions & 7 deletions

File tree

src/ucode/agents/claude.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ def _resolve_web_search_model(state: dict) -> str | None:
8484
# every launch, so stale values from older ucode versions never linger.
8585
CLAUDE_MANAGED_MODEL_ENV_KEYS = (
8686
"ANTHROPIC_MODEL",
87+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
88+
"ANTHROPIC_DEFAULT_FABLE_MODEL_NAME",
8789
"ANTHROPIC_DEFAULT_OPUS_MODEL",
8890
"ANTHROPIC_DEFAULT_OPUS_MODEL_NAME",
8991
"ANTHROPIC_DEFAULT_SONNET_MODEL",
@@ -134,6 +136,7 @@ def render_overlay(
134136
use_pat: bool = False,
135137
provider: str | None = None,
136138
provider_models: dict[str, str] | None = None,
139+
fable_enabled: bool = False,
137140
) -> tuple[dict, list[list[str]]]:
138141
"""Return (overlay, managed_key_paths) for Claude settings.json.
139142
@@ -192,6 +195,14 @@ def render_overlay(
192195
# so users can see which gateway-routable model is behind each shortcut.
193196
# We deliberately don't set the `_NAME` companion env vars — the raw id
194197
# is more useful than a friendly label for debugging gateway routing.
198+
#
199+
# Fable is opt-in only (`ucode configure --enable-fable`): it's a premium
200+
# model, so we don't pin the family alias unless the user asked for it.
201+
# When off, ANTHROPIC_DEFAULT_FABLE_MODEL is simply never written — and
202+
# since it's in CLAUDE_MANAGED_MODEL_ENV_KEYS, any stale value from a
203+
# prior `--enable-fable` run is pruned from settings.json on next launch.
204+
if fable_enabled and claude_models.get("fable"):
205+
env["ANTHROPIC_DEFAULT_FABLE_MODEL"] = claude_models["fable"]
195206
if claude_models.get("opus"):
196207
env["ANTHROPIC_DEFAULT_OPUS_MODEL"] = _maybe_add_1m_suffix(claude_models["opus"])
197208
if claude_models.get("sonnet"):
@@ -296,6 +307,7 @@ def write_tool_config(
296307
use_pat=bool(state.get("use_pat")),
297308
provider=provider,
298309
provider_models=provider_models,
310+
fable_enabled=bool(state.get("fable_enabled")),
299311
)
300312
tracing_env_vars = tracing_env(state, "claude")
301313
stop_hook_command = claude_tracing_stop_hook_command() if tracing_env_vars else None

src/ucode/cli.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ def configure_shared_state(
217217
use_pat: bool | None = None,
218218
skip_model_discovery: bool = False,
219219
skip_preflight: bool = False,
220+
fable_enabled: bool | None = None,
220221
) -> dict:
221222
"""Log into Databricks, enforce AI Gateway v2, fetch model lists, persist state.
222223
@@ -236,12 +237,18 @@ def configure_shared_state(
236237
in ``_launch_tool``) and the gateway was verified by that earlier configure.
237238
Only the local profile resolution and the shared state assembly still run;
238239
the saved model lists are preserved.
240+
``fable_enabled`` opts the premium Claude Fable family into Claude Code's
241+
``ANTHROPIC_DEFAULT_FABLE_MODEL`` pin (default off). ``None`` means "inherit":
242+
a launch re-run keeps whatever the workspace was configured with; ``True``/
243+
``False`` come from an explicit ``configure --enable-fable``/``--disable-fable``.
239244
"""
240245
workspace = normalize_workspace_url(workspace)
241246
prior_state = load_state()
242247
previous_workspace = prior_state.get("workspace")
243248
if use_pat is None:
244249
use_pat = bool(prior_state.get("use_pat")) and previous_workspace == workspace
250+
if fable_enabled is None:
251+
fable_enabled = bool(prior_state.get("fable_enabled")) and previous_workspace == workspace
245252
fetch_all = tools is None
246253

247254
# Assemble the shared workspace state that doesn't depend on model discovery:
@@ -265,6 +272,12 @@ def configure_shared_state(
265272
state["use_pat"] = True
266273
else:
267274
state.pop("use_pat", None)
275+
# Persist the Fable opt-in so launches keep pinning the family; an explicit
276+
# `configure --disable-fable` (fable_enabled=False) clears it.
277+
if fable_enabled:
278+
state["fable_enabled"] = True
279+
else:
280+
state.pop("fable_enabled", None)
268281
state["base_urls"] = build_shared_base_urls(workspace)
269282

270283
if skip_preflight:
@@ -360,6 +373,12 @@ def configure_shared_state(
360373
claude_models, claude_reason = ms_claude, ms_reason
361374
if not claude_models:
362375
claude_models, claude_reason = discover_claude_models(workspace, token)
376+
# Fable is opt-in (`configure --enable-fable`). Unless enabled,
377+
# drop it from the discovered bundle entirely so it never becomes
378+
# part of any agent's config — not claude's family pins, nor the
379+
# opencode/pi/copilot model lists built from claude_models.
380+
if not fable_enabled:
381+
claude_models.pop("fable", None)
363382
if want_gemini:
364383
gemini_models, gemini_reason = ms_gemini, ms_reason
365384
if not gemini_models:
@@ -416,6 +435,7 @@ def _configure_shared_workspace_states(
416435
*,
417436
force_login: bool,
418437
use_pat: bool = False,
438+
fable_enabled: bool | None = None,
419439
) -> list[dict]:
420440
if not workspaces:
421441
raise RuntimeError("At least one workspace must be provided.")
@@ -428,6 +448,7 @@ def _configure_shared_workspace_states(
428448
tools=tools,
429449
force_login=force_login,
430450
use_pat=use_pat,
451+
fable_enabled=fable_enabled,
431452
)
432453
)
433454
return states
@@ -507,6 +528,7 @@ def configure_workspace_command(
507528
prompt_optional_updates: bool = True,
508529
use_pat: bool = False,
509530
skip_validate: bool = False,
531+
fable_enabled: bool | None = None,
510532
) -> int:
511533
if tool is not None and selected_tools is not None:
512534
raise RuntimeError("Use either --agent or --agents, not both.")
@@ -524,6 +546,7 @@ def configure_workspace_command(
524546
[tool],
525547
force_login=True,
526548
use_pat=use_pat,
549+
fable_enabled=fable_enabled,
527550
)
528551
state = states[0]
529552
state = configure_single_tool(tool, state)
@@ -560,6 +583,7 @@ def configure_workspace_command(
560583
selected_tools,
561584
force_login=True,
562585
use_pat=use_pat,
586+
fable_enabled=fable_enabled,
563587
)
564588
state = states[0]
565589
save_state(state)
@@ -1066,6 +1090,18 @@ def configure(
10661090
"freshly discovered models.",
10671091
),
10681092
] = False,
1093+
enable_fable: Annotated[
1094+
bool | None,
1095+
typer.Option(
1096+
"--enable-fable/--disable-fable",
1097+
help="Pin the premium Claude Fable family via ANTHROPIC_DEFAULT_FABLE_MODEL "
1098+
"for Claude Code (opt-in; off by default). Only takes effect when the "
1099+
"workspace's AI Gateway actually advertises a Claude Fable model. "
1100+
"--disable-fable clears a prior opt-in. Omitting both keeps the "
1101+
"workspace's existing setting. Passed on its own (no --agent/--agents), "
1102+
"it configures Claude Code directly since Fable is Claude-only.",
1103+
),
1104+
] = None,
10691105
tracing: Annotated[
10701106
bool,
10711107
typer.Option(
@@ -1121,6 +1157,16 @@ def configure(
11211157
skip_kwargs["use_pat"] = True
11221158
if skip_validate:
11231159
skip_kwargs["skip_validate"] = True
1160+
# Only forward the Fable opt-in when the user passed the flag; `None`
1161+
# (neither flag given) lets configure_shared_state inherit the prior
1162+
# workspace setting instead of clobbering it.
1163+
if enable_fable is not None:
1164+
skip_kwargs["fable_enabled"] = enable_fable
1165+
# Fable is a Claude-only model family, so `--enable-fable`/`--disable-fable`
1166+
# only makes sense for Claude Code. When passed on its own, implicitly
1167+
# target claude instead of dropping into the interactive agent picker.
1168+
if enable_fable is not None and agent is None and agents is None:
1169+
agent = "claude"
11241170
if agent is not None:
11251171
tool = normalize_tool(agent)
11261172
install_tool_binary(

src/ucode/databricks.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,12 @@ def build_auth_shell_command(
11001100
# support a new family.
11011101
_OSS_MODEL_FAMILIES = ("kimi-", "glm-")
11021102

1103+
# Claude model families ucode buckets, newest tier first. Each maps to a
1104+
# Claude Code family alias (ANTHROPIC_DEFAULT_<FAMILY>_MODEL). Add an entry to
1105+
# support a new family in both discovery paths (`claude-<family>-*` via the
1106+
# model-services listing and `databricks-claude-<family>-*` via the AI Gateway).
1107+
ANTHROPIC_FAMILIES = ("fable", "opus", "sonnet", "haiku")
1108+
11031109
# Per-family token limits (context window + max output tokens). These are a
11041110
# property of the model + its `/ai-gateway/mlflow/v1` route (the gateway rejects
11051111
# requests whose output exceeds the cap), not of any one agent — so every agent
@@ -1226,8 +1232,9 @@ def discover_model_services(
12261232
12271233
Returns (claude_models, codex_models, gemini_models, oss_models, reason):
12281234
1229-
- ``claude_models`` maps ``opus``/``sonnet``/``haiku`` to the newest
1230-
matching ``system.ai.claude-*`` id (mirrors ``discover_claude_models``).
1235+
- ``claude_models`` maps ``fable``/``opus``/``sonnet``/``haiku`` to the
1236+
newest matching ``system.ai.claude-*`` id (mirrors
1237+
``discover_claude_models``).
12311238
- ``codex_models`` is the list of ``system.ai.*gpt-*`` ids.
12321239
- ``gemini_models`` is the list of ``system.ai.*gemini-*`` ids, newest first.
12331240
- ``oss_models`` is the list of OSS-model ``system.ai.*`` ids.
@@ -1241,7 +1248,7 @@ def discover_model_services(
12411248
return {}, [], [], [], reason
12421249

12431250
claude_models: dict[str, str] = {}
1244-
for family in ("opus", "sonnet", "haiku"):
1251+
for family in ANTHROPIC_FAMILIES:
12451252
candidates = sorted(
12461253
[m for m in ids if f"claude-{family}-" in m],
12471254
reverse=True,
@@ -1797,21 +1804,22 @@ def discover_claude_models(workspace: str, token: str) -> tuple[dict[str, str],
17971804
]
17981805

17991806
result: dict[str, str] = {}
1800-
for family, key in [("opus", "opus"), ("sonnet", "sonnet"), ("haiku", "haiku")]:
1807+
for family in ANTHROPIC_FAMILIES:
18011808
candidates = sorted(
18021809
[m for m in raw_ids if f"databricks-claude-{family}-" in m],
18031810
reverse=True,
18041811
)
18051812
if candidates:
1806-
result[key] = candidates[0]
1813+
result[family] = candidates[0]
18071814
if result:
18081815
return result, None
18091816
if not raw_ids:
18101817
return {}, "AI Gateway returned no Claude model ids"
18111818
sample = ", ".join(raw_ids[:5])
1819+
families = ",".join(ANTHROPIC_FAMILIES)
18121820
return {}, (
18131821
"AI Gateway returned model ids but none matched "
1814-
f"`databricks-claude-{{opus,sonnet,haiku}}-*` (got: {sample})"
1822+
f"`databricks-claude-{{{families}}}-*` (got: {sample})"
18151823
)
18161824

18171825

tests/test_agent_claude.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,37 @@ def test_model_overrides_not_set_when_no_models(self):
113113
env = overlay["env"]
114114
assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in env
115115

116+
def test_fable_not_pinned_by_default(self):
117+
# Fable is opt-in: even when the workspace advertises it, the env var is
118+
# absent unless fable_enabled is passed.
119+
models = {"fable": "databricks-claude-fable-5", "opus": "databricks-claude-opus-4-8"}
120+
overlay, _ = claude.render_overlay(WS, "s4", claude_models=models)
121+
env = overlay["env"]
122+
assert "ANTHROPIC_DEFAULT_FABLE_MODEL" not in env
123+
assert env["ANTHROPIC_DEFAULT_OPUS_MODEL"] == "databricks-claude-opus-4-8[1m]"
124+
125+
def test_fable_pinned_when_enabled_and_discovered(self):
126+
models = {"fable": "system.ai.claude-fable-5"}
127+
overlay, _ = claude.render_overlay(WS, "s4", claude_models=models, fable_enabled=True)
128+
env = overlay["env"]
129+
# Fable 5 is 1M-context by default, so no `[1m]` suffix is appended.
130+
assert env["ANTHROPIC_DEFAULT_FABLE_MODEL"] == "system.ai.claude-fable-5"
131+
132+
def test_fable_not_pinned_when_enabled_but_not_discovered(self):
133+
# --enable-fable is a no-op when the workspace advertises no fable model,
134+
# mirroring the opus/sonnet/haiku "only if discovered" behavior.
135+
models = {"opus": "databricks-claude-opus-4-8"}
136+
overlay, _ = claude.render_overlay(WS, "s4", claude_models=models, fable_enabled=True)
137+
assert "ANTHROPIC_DEFAULT_FABLE_MODEL" not in overlay["env"]
138+
139+
def test_fable_not_pinned_under_provider(self):
140+
# A Model Provider Service routes by header and pins no Databricks model.
141+
models = {"fable": "databricks-claude-fable-5"}
142+
overlay, _ = claude.render_overlay(
143+
WS, "s4", claude_models=models, fable_enabled=True, provider="main.x.claude-svc"
144+
)
145+
assert "ANTHROPIC_DEFAULT_FABLE_MODEL" not in overlay["env"]
146+
116147
def test_provider_adds_routing_header(self):
117148
overlay, _ = claude.render_overlay(WS, "s4", provider="main.aarushi.aarushi-claude")
118149
assert (

0 commit comments

Comments
 (0)