Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ read back, in fifteen minutes.

[@futrime](https://github.com/futrime), [@SihaoLiu](https://github.com/SihaoLiu), [@lyken17](https://github.com/lyken17).

This project was initiated by Sihao Liu at UCLA [PolyArch/humanize](https://github.com/PolyArch/humanize), then contributed
This project was initiated by Sihao Liu at UCLA [PolyArch/humanize](https://github.com/PolyArch/humanize), then contributed
by NVIDIA Research, MIT HAN LAB, NUNCHAKU and many community members.

## Contributing
Expand Down
5 changes: 5 additions & 0 deletions docs/features/backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ what it says is kept:
to ask. A backend that would not answer leaves the account made — an account whose models are
not known yet is one to ask again, not one that failed.

Claude Code's subscription picker hides the `fable` alias even when the account can run it.
For subscription accounts, humanize uses Claude's official `ANTHROPIC_CUSTOM_MODEL_OPTION`
hook while asking for the catalogue, and keeps the `fable` alias so a turn can pass it through
as `--model fable`. Key, gateway and cloud accounts are left to their own catalogue.

## The efforts are a vocabulary, so they are written down

An effort is the backend's own word for how hard to think, and a ladder keeps in a way a
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,12 @@ one that will not answer says why, under the list, and leaves the sheet up.
Choosing a model you were not already on starts the effort at the hardest that model takes —
the one to reach for. Choosing the one you are on leaves the effort where you had it.

For a Claude Code subscription, the list also includes the `fable` alias through Claude's
custom-model option, even when Claude's ordinary `/model` list hides it. The alias is passed
unchanged to the turn, so selecting it is equivalent to entering `/model fable` in Claude. If
the account's catalogue was cached before this option was added, press `r` once to ask Claude
again.

## Agents kept under a name

`/agents` is not the flow's agents. It is the agents written down under a name, to be imported
Expand Down
72 changes: 70 additions & 2 deletions src/hmz/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from hmz.backends import Model, named, speaking

if TYPE_CHECKING:
from collections.abc import Callable
from collections.abc import Callable, Mapping
from pathlib import Path

from hmz.backends import Profile
Expand All @@ -51,6 +51,34 @@
#: The id the one thing said to Claude Code is sent under, which it answers by.
_ASKS = "models"

#: Claude Code hides the Fable model from the ordinary subscription catalogue. Its documented
#: custom-model hook makes the same model available to the model-list request, which lets the
#: picker offer the alias that a turn can pass to ``--model``.
_CLAUDE_CUSTOM_MODEL_ENV = "ANTHROPIC_CUSTOM_MODEL_OPTION"
_CLAUDE_CUSTOM_MODEL = "fable"

#: The ways that identify a Claude subscription, rather than a vendor key or a gateway.
_CLAUDE_SUBSCRIPTION_WAYS = frozenset({"login", "token"})

#: Variables that move Claude Code onto a non-subscription account when no named provider is
#: being used. A value is enough: Claude treats these switches as enabled when they are set.
_CLAUDE_EXTERNAL_ACCOUNT_ENV = (
"ANTHROPIC_API_KEY",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"CLAUDE_CODE_USE_BEDROCK",
"CLAUDE_CODE_USE_FOUNDRY",
"CLAUDE_CODE_USE_GATEWAY",
"CLAUDE_CODE_USE_VERTEX",
)

#: An OAuth token supplied through an arbitrary environment provider still identifies a
#: subscription, even though its provider way is not named `login` or `token`.
_CLAUDE_SUBSCRIPTION_ENV = (
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CODE_OAUTH_REFRESH_TOKEN",
)

#: What Claude Code writes on the end of a model to mean that model at its largest window. A
#: way of running the model rather than a model, so it comes off the id: the backend asked for
#: one under that spelling answers that there is no such model.
Expand Down Expand Up @@ -215,6 +243,11 @@ def _asking(profile: Profile, provider: str, seconds: float) -> Callable[..., st
)
environ = {name: value for name, value in os.environ.items() if name not in hushed}
environ |= dict(held.env) if held is not None else {}
if profile.name == "claude" and _claude_subscription(held, environ):
# Claude Code's subscription-only Fable alias is intentionally absent from its normal
# picker. Asking through its custom-model hook makes the CLI report it to us, while
# leaving the environment of an actual turn untouched.
environ.setdefault(_CLAUDE_CUSTOM_MODEL_ENV, _CLAUDE_CUSTOM_MODEL)

def run(args: list[str], said: str = "") -> str:
argv = [profile.name, *args]
Expand Down Expand Up @@ -307,14 +340,49 @@ def _claude(profile: Profile, run: Callable[..., str]) -> list[Model]:
)
return [
Model(
_WINDOW.sub("", str(one.get("resolvedModel") or one.get("value") or "")),
_claude_name(one),
_rungs(profile, one.get("supportedEffortLevels")),
profile.swarms,
)
for one in _answered(said)
]


def _claude_name(model: dict[str, Any]) -> str:
"""The name a Claude model may be asked for, preserving an explicit custom alias.

Claude normally gives a stable canonical id in ``resolvedModel`` and a short alias in
``value``. For its custom-model option the alias is the only name the user supplied, so
retaining it is important: ``--model fable`` is the supported way to select the hidden
subscription model even though Claude resolves it internally to ``claude-fable-5``.
"""
value = model.get("value")
description = model.get("description")
custom = isinstance(value, str) and _WINDOW.sub("", value) == _CLAUDE_CUSTOM_MODEL
chosen = (
value
if isinstance(value, str)
and value
and (
custom
or (isinstance(description, str) and description.startswith("Custom model"))
)
else model.get("resolvedModel") or value
)
return _WINDOW.sub("", str(chosen or ""))


def _claude_subscription(
held: providers.Provider | None, environ: Mapping[str, str]
) -> bool:
"""Whether a Claude account is a subscription that may use the Fable alias."""
if held is not None:
if held.way in _CLAUDE_SUBSCRIPTION_WAYS:
return True
return any(held.env.get(name) for name in _CLAUDE_SUBSCRIPTION_ENV)
return not any(environ.get(name) for name in _CLAUDE_EXTERNAL_ACCOUNT_ENV)


def _answered(said: str) -> list[dict[str, Any]]:
"""The models out of a stream of control responses, which is what Claude Code answers in.

Expand Down
69 changes: 69 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,29 @@
}
)

#: Claude's official custom-model hook reports a hidden subscription model by its alias. The
#: resolved id is deliberately different: hmz must keep the alias because that is what a user
#: can pass to `/model` and to `--model`.
CLAUDE_FABLE = json.dumps(
{
"type": "control_response",
"response": {
"subtype": "success",
"request_id": "models",
"response": {
"models": [
{
"value": "fable",
"resolvedModel": "claude-fable-5",
"description": "Custom model (fable)",
"supportedEffortLevels": ["high", "max"],
}
]
},
},
}
)

#: What `codex debug models` renders: the efforts per model, and the ones it does not offer.
CODEX = json.dumps(
{
Expand Down Expand Up @@ -184,6 +207,52 @@ def test_every_backend_is_asked_the_way_that_backend_answers(
assert [model.name for model in found] == wanted


def test_claude_keeps_the_alias_of_a_custom_model_and_requests_fable(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The alias is what Claude accepts, even though its response has a canonical id too."""
bin_ = tmp_path / "bin"
stands_in(monkeypatch, bin_, "claude", CLAUDE_FABLE)

found = models.ask("claude")

assert [model.name for model in found] == ["fable"]
environment = seen(bin_, "claude")["env"]
assert isinstance(environment, dict)
assert environment["ANTHROPIC_CUSTOM_MODEL_OPTION"] == "fable"


def test_claude_does_not_replace_an_existing_custom_model_option(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A caller's custom model remains the value Claude is asked to list."""
monkeypatch.setenv("ANTHROPIC_CUSTOM_MODEL_OPTION", "my-model")
bin_ = tmp_path / "bin"
stands_in(monkeypatch, bin_, "claude", CLAUDE)

models.ask("claude")

environment = seen(bin_, "claude")["env"]
assert isinstance(environment, dict)
assert environment["ANTHROPIC_CUSTOM_MODEL_OPTION"] == "my-model"


def test_claude_key_accounts_are_not_given_the_subscription_alias(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Fable is opt-in for a subscription, not a guess for a Claude API key."""
bin_ = tmp_path / "bin"
stands_in(monkeypatch, bin_, "claude", CLAUDE)
providers.add("claude", "key", "key", {"ANTHROPIC_API_KEY": "sk-x"})

found = models.ask("claude", "key")

assert "fable" not in [model.name for model in found]
environment = seen(bin_, "claude")["env"]
assert isinstance(environment, dict)
assert "ANTHROPIC_CUSTOM_MODEL_OPTION" not in environment


def test_an_antigravity_model_takes_the_effort_its_own_name_carries(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down
26 changes: 26 additions & 0 deletions tests/tui/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,32 @@ def says(cli: str, provider: str = "", seconds: float = 0.0) -> tuple[Model, ...
)


@pytest.mark.timeout(60)
@unittest.mock.patch(
"hmz.tui.app.installed",
return_value={"claude": (Model("fable", ("max", "high")),)},
)
async def test_fable_is_selectable_as_the_claude_model(
_installed: unittest.mock.MagicMock, # noqa: PT019 -- patch hands the catalogue over
flows: Path,
) -> None:
"""The alias selected in the catalogue is the one Claude receives on a turn."""
app = Humanize()
async with app.run_test() as driver:
await _to_the_models(app, driver)
await until(lambda: _rows(app) == 1, driver)
assert "fable" in str(
app.screen.query_one("#choices", OptionList).get_option_at_index(0).prompt
)

await driver.press("enter")
await until(lambda: isinstance(app.screen, Agent), driver)
await keeps(app, driver)
await keeps(app, driver)

assert app._models == [Runs("claude/fable:high")]


@pytest.mark.timeout(60)
@unittest.mock.patch("hmz.tui.app.installed", return_value=CLAUDE)
async def test_a_cli_that_will_not_say_says_so_under_the_list(
Expand Down