Skip to content
Closed
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
84 changes: 68 additions & 16 deletions backend/app/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@
# source of truth for newly released IDs.
KNOWN_MODELS = {
"claude": [
"claude-fable-5",
"claude-sonnet-5",
# Anthropic switched to dateless pinned IDs starting with 4.6;
# the dated entries below stay listed because existing chats
# persist them in agent_settings_json and the API still resolves
Expand Down Expand Up @@ -84,6 +86,8 @@
# Codex's models() returns slugs only), so labels come from this map
# when present and fall back to the raw ID for newly released models.
MODEL_LABELS: dict[str, str] = {
"claude-fable-5": "Fable 5",
"claude-sonnet-5": "Sonnet 5",
"claude-opus-4-8": "Opus 4.8",
"claude-opus-4-7": "Opus 4.7",
"claude-opus-4-6": "Opus 4.6",
Expand All @@ -107,8 +111,42 @@
# registry carries it to every shell/app picker as data.
MODEL_EFFORT_LEVELS: dict[str, list[str]] = {}

# Runtime recovery defaults are intentionally independent of picker order.
# Fable is presented first in the interactive picker, but a stale/mismatched
# saved value must not silently opt an unattended retry into usage credits.
DEFAULT_MODELS = {
provider: models[0] for provider, models in KNOWN_MODELS.items()
"claude": "claude-opus-4-8",
"codex": "gpt-5.6-sol",
}

# Curated first-run model visibility. The registry remains broader so an
# existing chat can keep rendering an older saved model and the owner can
# reveal any hidden row from Settings. An explicit owner preference (including
# an explicit empty hidden list) always wins over this starter set.
DEFAULT_VISIBLE_MODEL_ORDER: dict[str, tuple[str, ...]] = {
"claude": (
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-sonnet-4-6",
),
"codex": (
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
),
}
DEFAULT_VISIBLE_MODELS: dict[str, frozenset[str]] = {
provider_id: frozenset(models)
for provider_id, models in DEFAULT_VISIBLE_MODEL_ORDER.items()
}

# Unattended work gets deliberately conservative provider-specific defaults,
# independent of the first model shown in the interactive chat picker.
DEFAULT_BACKGROUND_MODELS = {
"claude": "claude-opus-4-8",
"codex": "gpt-5.6-terra",
}

# Initial effort when no global default exists. Aligns with the
Expand Down Expand Up @@ -173,6 +211,23 @@ def _load_agent_settings(data_dir: str) -> dict:
return {}


def hidden_model_ids(model_prefs: Any) -> list[str]:
"""Resolve model-picker visibility for an owner.

Missing preferences use the curated starter set above. Once the owner saves
Manage models, even ``{"hidden_ids": []}`` is explicit and means show all.
"""
if isinstance(model_prefs, dict) and "hidden_ids" in model_prefs:
raw = model_prefs.get("hidden_ids")
return [entry for entry in (raw or []) if isinstance(entry, str)]
return [
model_id
for provider_id, models in KNOWN_MODELS.items()
for model_id in models
if model_id not in DEFAULT_VISIBLE_MODELS.get(provider_id, frozenset())
]


def skills_enabled(data_dir: str) -> bool:
"""Whether SDK skills are offered to the Claude agent (default OFF).

Expand Down Expand Up @@ -304,7 +359,7 @@ def _background_default_choice(
) -> dict:
return {
"provider": provider,
"model": model,
"model": model if model is not None else DEFAULT_BACKGROUND_MODELS.get(provider),
"effort": DEFAULT_EFFORT,
"enabled": enabled,
}
Expand All @@ -328,14 +383,14 @@ def _clean_background_choice(
if isinstance(raw_model, str) and raw_model.strip():
model = raw_model.strip()
if _model_belongs_to_other_provider(model, provider):
model = DEFAULT_MODELS.get(provider)
model = DEFAULT_BACKGROUND_MODELS.get(provider)
elif "model" in raw:
# Explicit null/empty means "let this provider use its native default".
model = None
else:
# Legacy provider-only choices predate nullable model defaults; keep them
# concrete so background runners do not inherit the chat model by accident.
model = DEFAULT_MODELS.get(provider)
model = DEFAULT_BACKGROUND_MODELS.get(provider)
out["model"] = model
effort = raw.get("effort")
out["effort"] = effort.strip() if isinstance(effort, str) and effort.strip() else None
Expand Down Expand Up @@ -369,14 +424,6 @@ def background_agent_settings(data_dir: str, default_provider: str | None = None
file_layer = _load_agent_settings(data_dir)
raw = file_layer.get("background_agents")
bg = raw if isinstance(raw, dict) else {}
# When the owner has already picked a chat model, synthesize a concrete
# provider-native background model rather than inheriting that chat default.
# With no manual model choice at all, keep the background model nullable so
# the provider SDK can use its own default until the owner saves a row.
synthetic_default_model = (
DEFAULT_MODELS.get(provider) if "model" in file_layer else None
)

rows: list[dict[str, Any]] = []
seen: set[str] = set()

Expand Down Expand Up @@ -410,7 +457,6 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None:
primary = _background_default_choice(
provider,
enabled=True,
model=synthetic_default_model,
)
add_row(primary, enabled_default=True)
add_row(_clean_background_choice(bg.get("fallback")), enabled_default=True)
Expand All @@ -420,7 +466,6 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None:
_background_default_choice(
provider,
enabled=True,
model=synthetic_default_model,
),
enabled_default=True,
)
Expand All @@ -431,7 +476,7 @@ def add_row(choice: dict | None, *, enabled_default: bool) -> None:
_background_default_choice(
provider_id,
enabled=False,
model=DEFAULT_MODELS.get(provider_id),
model=DEFAULT_BACKGROUND_MODELS.get(provider_id),
)
)

Expand Down Expand Up @@ -835,6 +880,13 @@ def _live_model_entries(
succeeds, the provider SDK/CLI is the source of truth; labels are a
cosmetic map with raw-ID fallback.
"""
# The curated compatibility aliases are an owner-chosen product surface, not
# a mirror of one catalog response. Keep them available even when a provider
# temporarily omits an older-but-still-supported alias (Sonnet 4.6 / GPT-5.5)
# from discovery, then append every genuinely live extra in provider order.
preferred = DEFAULT_VISIBLE_MODEL_ORDER.get(provider_id, ())
ordered_ids = list(preferred)
ordered_ids.extend(model_id for model_id in live_ids if model_id not in preferred)
return [
{
"id": mid,
Expand All @@ -844,7 +896,7 @@ def _live_model_entries(
**({"effort_levels": MODEL_EFFORT_LEVELS[mid]}
if mid in MODEL_EFFORT_LEVELS else {}),
}
for mid in live_ids
for mid in ordered_ids
]


Expand Down
7 changes: 2 additions & 5 deletions backend/app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,11 +622,8 @@ async def providers_models(
from app.providers import list_models
data_dir = get_settings().data_dir
registry = await list_models(data_dir)
prefs = owner.model_prefs_json or {}
hidden_ids = {
entry for entry in (prefs.get("hidden_ids") or [])
if isinstance(entry, str)
}
from app.providers import hidden_model_ids
hidden_ids = set(hidden_model_ids(owner.model_prefs_json))
out: dict[str, list[dict[str, str]]] = {}
for provider_id, entries in registry.items():
rows: list[dict[str, str]] = []
Expand Down
11 changes: 4 additions & 7 deletions backend/app/routes/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,14 +373,11 @@ def get_model_prefs(
) -> dict:
"""Returns the owner's model-picker preferences.

Default shape is `{"hidden_ids": []}` — absent prefs and empty
prefs are equivalent (the picker shows every registry entry).
Owners without a saved preference receive the curated default hidden set.
An explicitly saved `{"hidden_ids": []}` is distinct and shows every
registry entry.
"""
prefs = owner.model_prefs_json or {}
hidden = prefs.get("hidden_ids") or []
# Defensive normalize: any persisted non-string falls out here so
# the client never sees a malformed entry.
return {"hidden_ids": [s for s in hidden if isinstance(s, str)]}
return {"hidden_ids": providers.hidden_model_ids(owner.model_prefs_json)}


@owner_router.patch("/model-prefs", dependencies=[Depends(reject_cross_site)])
Expand Down
2 changes: 2 additions & 0 deletions backend/recovery/recovery_chat_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@
# picker always allows "CLI default" (no --model) too.
RECOVERY_MODELS: dict[str, tuple[str, ...]] = {
"claude": (
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
Expand Down
23 changes: 15 additions & 8 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ def test_providers_models_accepts_app_token(client, auth):
app_id = r0.json()["id"]

from app.auth import create_access_token
from app.providers import KNOWN_MODELS, invalidate_model_cache
from app.providers import DEFAULT_VISIBLE_MODELS, invalidate_model_cache
invalidate_model_cache()
app_token = create_access_token({
"sub": "test", "scope": "app", "app_id": app_id,
Expand All @@ -160,9 +160,10 @@ def test_providers_models_accepts_app_token(client, auth):
)
assert r.status_code == 200, r.text
body = r.json()
# Full per-provider list, not a one-model FALLBACK_GROUPS stub.
assert [m["id"] for m in body["claude"]] == KNOWN_MODELS["claude"]
assert [m["id"] for m in body["codex"]] == KNOWN_MODELS["codex"]
# The same curated defaults the owner sees, not a one-model fallback stub.
assert {m["id"] for m in body["claude"]} == DEFAULT_VISIBLE_MODELS["claude"]
assert {m["id"] for m in body["codex"]} == DEFAULT_VISIBLE_MODELS["codex"]
assert len(body["claude"]) > 1 and len(body["codex"]) > 1


def test_providers_status_accepts_app_token(client, auth):
Expand Down Expand Up @@ -339,22 +340,28 @@ def test_providers_models_returns_known_models_on_missing_creds(
`list_models` falls back to KNOWN_MODELS — exercise that path and
pin the response shape mini-apps depend on (id + name, plus a
tier on Claude rows)."""
from app.providers import KNOWN_MODELS, invalidate_model_cache
from app.providers import DEFAULT_VISIBLE_MODELS, KNOWN_MODELS, invalidate_model_cache
invalidate_model_cache()
r = client.get("/api/auth/providers/models", headers=auth)
assert r.status_code == 200
body = r.json()
assert set(body) == {"claude", "codex"}
claude_ids = [m["id"] for m in body["claude"]]
assert claude_ids == KNOWN_MODELS["claude"]
assert claude_ids == [
"claude-fable-5", "claude-sonnet-5",
"claude-opus-4-8", "claude-sonnet-4-6",
]
codex_ids = [m["id"] for m in body["codex"]]
assert codex_ids == KNOWN_MODELS["codex"]
assert codex_ids == [
"gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5",
]
assert set(claude_ids) == DEFAULT_VISIBLE_MODELS["claude"]
assert set(codex_ids) == DEFAULT_VISIBLE_MODELS["codex"]
# Claude rows carry a tier derived from the id.
by_id = {m["id"]: m for m in body["claude"]}
assert by_id["claude-opus-4-8"]["name"] == "Opus 4.8"
assert by_id["claude-opus-4-8"]["tier"] == "opus"
assert by_id["claude-sonnet-4-6"]["tier"] == "sonnet"
assert by_id["claude-haiku-4-5-20251001"]["tier"] == "haiku"
# Codex rows intentionally omit `tier` — the field doesn't apply.
for row in body["codex"]:
assert "tier" not in row
Expand Down
28 changes: 27 additions & 1 deletion backend/tests/test_model_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def test_known_models_fallback_lists_current_claude_and_codex():
# asserting them by name catches a wrong date suffix that a startswith
# check would miss.
for model_id in (
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
Expand All @@ -58,7 +60,13 @@ def test_known_models_fallback_lists_current_claude_and_codex():
"claude-haiku-4-5-20251001",
):
assert model_id in claude, f"{model_id} missing from KNOWN_MODELS[claude]"
assert claude[0] == "claude-opus-4-8", "Opus 4.8 must be the default"
assert claude[:4] == [
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-opus-4-7",
]
assert providers.DEFAULT_MODELS["claude"] == "claude-opus-4-8"
# Current Codex family — each canonical id present by name.
for model_id in (
"gpt-5.6-sol",
Expand Down Expand Up @@ -100,6 +108,24 @@ def test_recovery_models_match_platform_fallback_registry():
assert chat_runner.RECOVERY_MODELS == expected


def test_default_model_visibility_is_curated_until_owner_saves_preferences():
hidden = set(providers.hidden_model_ids(None))
for model_id in (
"claude-fable-5",
"claude-sonnet-5",
"claude-opus-4-8",
"claude-sonnet-4-6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
):
assert model_id not in hidden
assert "claude-opus-4-7" in hidden
assert "gpt-5.4" in hidden
assert providers.hidden_model_ids({"hidden_ids": []}) == []


def test_fallback_models_shape_matches_registry_entries():
"""`_fallback_models` returns the same {id,label,provider,available}
shape the live path produces, so the picker renders identically whether
Expand Down
Loading
Loading