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
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **`/model` listed one provider instead of every configured one.** Step 1 of
the picker showed a single row — `anthropic · 22 models` — no matter how many
providers were set up. `model.options` was a stub: it called the
`get_settings` control, which describes only the provider the session is
*currently* on, and synthesized a hardcoded one-element array from it. The
26-entry registry (`PROVIDER_INFO`, hand-written provider classes plus the
data-driven OpenAI-compatible specs) was never enumerated, and no backend
control exposed it, so the row on screen was the active provider echoed back
at itself. A new `list_model_providers` control returns the real catalog,
ordered active-first, then providers that can authenticate, then the rest.

Three paths behind it had never been reachable, and two were broken:

- Selecting a model from another provider was refused. `set_model` will not
point the live provider at a foreign model id — a cross-provider switch
needs the registry rebuild only `set_provider` performs — so its refusal
now carries a machine-readable `provider_mismatch` and the client re-drives
the switch through `set_provider` before re-applying the model. If that
second step fails it rolls back and reports which provider the session
actually ended on.
- `model.save_key` was never wired and fell through to the adapter's default
case, so inline API-key entry always answered "failed to save key". Keys
now persist where `clawcodex login` writes them.
- `model.disconnect` was likewise unwired, so `^d` silently did nothing.

Disconnect deletes only environment-variable names a provider *owns*. The
candidate list is a resolution preference, not a claim of ownership —
`nvidia-nim` accepts `DEEPSEEK_API_KEY` — so treating it as a delete-set
would destroy a credential set for something else, including the provider the
session is running on. Ownership follows the primary candidate, contested
names are reported rather than removed, and a key the live session
authenticates through is never touched. A shell export sitting behind a
config key is detected before deletion, since removing the config copy makes
the provider look disconnected while the export restores it on next launch.

Known limitation: when no provider is configured at all the session fails to
start, and the `init_error` guard short-circuits every control — including
this one — so the picker cannot yet be used to paste a first key. It now
reports that reason instead of inventing a provider row.

- **`--model`/`/model` selection is now resolved from one rule at every
entrypoint.** The persisted `/model` choice was applied only in the
interactive agent-server, and only *after* the provider was constructed
Expand Down
175 changes: 175 additions & 0 deletions src/providers/catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Provider catalog for the ``/model`` picker's step 1.

``get_settings`` reports only the provider the session is *currently* on, so a
picker built on it can never show more than one row. This module enumerates
:data:`src.providers.PROVIDER_INFO` — the merged registry of hand-written
provider classes plus the data-driven OpenAI-compatible specs — which is
exactly the set ``--provider`` accepts.

Each row carries what the picker renders: the ○/●/* auth mark
(``authenticated`` / ``is_current``), the model list, and the fields that drive
the inline API-key stage (``auth_type`` == ``"api_key"`` plus ``key_env``).

Credential probing is read-only and best-effort: a provider whose key cannot be
resolved is reported unauthenticated rather than raising, so one bad config
block cannot blank the whole picker.
"""

from __future__ import annotations

from typing import Any


def exclusive_env_vars(pid: str) -> tuple[list[str], list[str]]:
"""Split a provider's key env-vars into ``(exclusively-own, shared)``.

``provider_env_vars`` is a *resolution preference* list — "any of these
will authenticate me" — not an ownership list, and several providers
legitimately name another vendor's variable: ``nvidia-nim`` accepts
``DEEPSEEK_API_KEY``, ``siliconflow`` and ``siliconflow-cn`` share
``SILICONFLOW_API_KEY``, ``huggingface`` reads ``HF_TOKEN``, ``gemini``
reads ``GOOGLE_API_KEY``.

The config ``env`` block is one global secret namespace, so treating that
preference list as a delete-set destroys credentials the user set for a
*different* provider — including, in the ``nvidia-nim`` → ``DEEPSEEK``
case, the provider the session is actively running on, which would route
straight around the active-provider guard.

Ownership follows the PRIMARY candidate, not mere membership. A plain
"does anyone else list this name" test over-corrects into uselessness:
``DEEPSEEK_API_KEY`` is DeepSeek's own primary variable but also
``nvidia-nim``'s last-resort fallback, so membership alone would make
DeepSeek — the provider this project is built around — permanently
undisconnectable. A name is this provider's when it heads its own list and
no other provider also leads with it, or when nobody else names it at all.
Genuinely contested names (``siliconflow`` and ``siliconflow-cn`` both lead
with ``SILICONFLOW_API_KEY``) stay shared and are reported, not deleted.
"""
from . import PROVIDER_INFO, provider_env_vars

mine = list(provider_env_vars(pid))
primary = mine[0] if mine else None
others_all: set[str] = set()
others_primary: set[str] = set()
for other in PROVIDER_INFO:
if other == pid:
continue
candidates = provider_env_vars(other)
others_all.update(candidates)
if candidates:
others_primary.add(candidates[0])

owned: list[str] = []
shared: list[str] = []
for name in mine:
if (name == primary and name not in others_primary) or name not in others_all:
owned.append(name)
else:
shared.append(name)
return owned, shared


def provider_catalog(
current: str | None = None,
current_models: list[str] | None = None,
current_ready: bool = False,
) -> list[dict[str, Any]]:
"""Every known provider, ordered for the picker.

``current`` is the session's active provider id (its row gets
``is_current``). ``current_models`` overrides that provider's static model
list with the session's live one, so endpoint-discovered catalogues
(Ollama / vLLM / SGLang) show their real models instead of the static stub.
``current_ready`` marks the active provider authenticated because the
session demonstrably constructed it.

Credential probing deliberately mirrors ``get_provider_config``'s literal
lookup rather than trying to be cleverer than it, because
``_do_set_provider`` resolves the same way: a row reported here as
authenticated but which ``set_provider`` then refuses would be a picker
offering a provider you cannot actually select.

The ACTIVE provider is the one exception. A session launched as
``--provider glm`` has a working ``[providers.glm]`` block that a canonical
``zai`` lookup cannot see — the packaged defaults seed an empty ``zai``
block that wins the literal lookup — so probing it would render the user's
own running provider as needing a key.

Ordering: the active provider first, then providers that can authenticate,
then the rest alphabetically — so the useful rows are above the fold and
the unconfigured long tail sorts predictably.
"""
from . import (
PROVIDER_INFO,
canonical_provider_name,
provider_env_vars,
provider_has_credentials,
provider_requires_api_key,
resolve_api_key,
)

current_id = canonical_provider_name(current) if current else None

rows: list[dict[str, Any]] = []
for pid, info in PROVIDER_INFO.items():
is_current = pid == current_id
try:
api_key = resolve_api_key(pid)
except Exception: # noqa: BLE001 — one bad block must not blank the picker
api_key = ""
try:
authenticated = provider_has_credentials(pid, api_key)
except Exception: # noqa: BLE001
authenticated = bool(api_key)
if is_current and current_ready:
authenticated = True

env_vars = provider_env_vars(pid)
key_env = env_vars[0] if env_vars else ""

models = [str(m) for m in (info.get("available_models") or [])]
if is_current and current_models:
models = [str(m) for m in current_models]

row: dict[str, Any] = {
"slug": pid,
"name": info.get("label") or pid,
"models": models,
"total_models": len(models),
"authenticated": authenticated,
# Only providers that actually take a key get the picker's inline
# key stage; local servers (Ollama / vLLM / SGLang) need none.
"auth_type": "api_key" if provider_requires_api_key(pid) else "none",
"is_current": is_current,
}
if key_env:
row["key_env"] = key_env
# What ^d would actually delete, so the confirm screen can name it
# rather than saying a vague "removes saved credentials". Several of
# these are conventional variables other tooling reads (HF_TOKEN,
# GOOGLE_API_KEY), so the user deserves to see the list before
# confirming — this only ever clears clawcodex's own config, never a
# shell profile, but an informed choice beats a narrower rule.
owned_env, _shared_env = exclusive_env_vars(pid)
if current_id:
# Subtract what the live session authenticates through: the
# handler declines to delete those, so listing them here would
# promise a removal the confirm screen then does not perform —
# in the field this whole disclosure exists to make honest.
in_use = set(provider_env_vars(current_id))
owned_env = [e for e in owned_env if e not in in_use]
if owned_env:
row["removes_env"] = owned_env
if not authenticated:
row["warning"] = (
f"paste {key_env} to activate"
if key_env
else "run `clawcodex login` to configure"
)
rows.append(row)

rows.sort(
key=lambda r: (not r["is_current"], not r["authenticated"], str(r["name"]).lower())
)
return rows
Loading
Loading