release: v0.50.151 — credential_pool provider detection + Ollama Cloud support (PR #820 by @starship-s)
Surfaces providers added via credential_pool in the model dropdown. Ambient gh-cli tokens suppressed. _apply_provider_prefix helper extracted. Ollama Cloud display name + dynamic model list. looksLikeBareOllamaId heuristic tightened. Test isolation fixed. PR #820 by @starship-s.
This commit is contained in:
192
api/config.py
192
api/config.py
@@ -504,6 +504,7 @@ _PROVIDER_DISPLAY = {
|
||||
"huggingface": "HuggingFace",
|
||||
"alibaba": "Alibaba",
|
||||
"ollama": "Ollama",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"lmstudio": "LM Studio",
|
||||
@@ -723,6 +724,70 @@ _PROVIDER_MODELS = {
|
||||
}
|
||||
|
||||
|
||||
_AMBIENT_GH_CLI_MARKERS = frozenset({"gh_cli", "gh auth token"})
|
||||
|
||||
|
||||
def _is_ambient_gh_cli_entry(source: str, label: str, key_source: str) -> bool:
|
||||
"""True when a credential-pool entry is a seeded gh-cli token rather than
|
||||
one the user added explicitly. Filter these so Copilot doesn't appear in
|
||||
the dropdown just because `gh` is installed on the system.
|
||||
"""
|
||||
return (
|
||||
source.strip().lower() in _AMBIENT_GH_CLI_MARKERS
|
||||
or label.strip().lower() == "gh auth token"
|
||||
or key_source.strip().lower() == "gh auth token"
|
||||
)
|
||||
|
||||
|
||||
def _format_ollama_label(mid: str) -> str:
|
||||
"""Turn an Ollama model id (Ollama tag format) into a readable display label.
|
||||
|
||||
Examples: 'kimi-k2.5' → 'Kimi K2.5', 'qwen3-vl:235b-instruct' → 'Qwen3 VL (235B Instruct)'
|
||||
"""
|
||||
name_part, _, variant = mid.partition(":")
|
||||
|
||||
def _fmt(s: str) -> str:
|
||||
tokens = s.replace("-", " ").replace("_", " ").split()
|
||||
out = []
|
||||
for t in tokens:
|
||||
alpha_only = t.replace(".", "")
|
||||
if alpha_only.isalpha() and len(t) <= 3:
|
||||
out.append(t.upper()) # short acronym: glm → GLM, vl → VL, gpt → GPT
|
||||
elif alpha_only.isalnum() and alpha_only and alpha_only[0].isdigit():
|
||||
out.append(t.upper()) # size param: 235b → 235B, 1t → 1T
|
||||
else:
|
||||
out.append(t[0].upper() + t[1:] if t else t) # capitalize: kimi → Kimi
|
||||
return " ".join(out)
|
||||
|
||||
label = _fmt(name_part)
|
||||
if variant:
|
||||
label += f" ({_fmt(variant)})"
|
||||
return label
|
||||
|
||||
|
||||
def _apply_provider_prefix(
|
||||
raw_models: list[dict],
|
||||
provider_id: str,
|
||||
active_provider: str | None,
|
||||
) -> list[dict]:
|
||||
"""Return *raw_models* with @provider: prefixes applied when needed.
|
||||
|
||||
Prefixing is skipped when (a) the provider is already the active one, or
|
||||
(b) a model id already starts with '@' or contains '/' (already routable).
|
||||
"""
|
||||
_active = (active_provider or "").lower()
|
||||
if not _active or provider_id == _active:
|
||||
return list(raw_models)
|
||||
result = []
|
||||
for m in raw_models:
|
||||
mid = m["id"]
|
||||
if mid.startswith("@") or "/" in mid:
|
||||
result.append({"id": mid, "label": m["label"]})
|
||||
else:
|
||||
result.append({"id": f"@{provider_id}:{mid}", "label": m["label"]})
|
||||
return result
|
||||
|
||||
|
||||
def resolve_model_provider(model_id: str) -> tuple:
|
||||
"""Resolve model name, provider, and base_url for AIAgent.
|
||||
|
||||
@@ -1048,24 +1113,28 @@ def get_available_models() -> dict:
|
||||
if active_provider:
|
||||
active_provider = _resolve_provider_alias(active_provider)
|
||||
|
||||
# 2. Try to read auth store for active provider (if hermes is installed)
|
||||
if not active_provider:
|
||||
# 2. Read auth store (active_provider fallback + credential_pool inspection)
|
||||
auth_store = {}
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home as _gah
|
||||
|
||||
auth_store_path = _gah() / "auth.json"
|
||||
except ImportError:
|
||||
auth_store_path = HOME / ".hermes" / "auth.json"
|
||||
if auth_store_path.exists():
|
||||
try:
|
||||
from api.profiles import get_active_hermes_home as _gah
|
||||
import json as _j
|
||||
|
||||
auth_store_path = _gah() / "auth.json"
|
||||
except ImportError:
|
||||
auth_store_path = HOME / ".hermes" / "auth.json"
|
||||
if auth_store_path.exists():
|
||||
try:
|
||||
import json as _j
|
||||
auth_store = _j.loads(auth_store_path.read_text(encoding="utf-8"))
|
||||
if not active_provider:
|
||||
# Re-run alias resolution: auth.json may store an aliased name
|
||||
# (e.g. 'google', 'z.ai') that the prefixing logic compares
|
||||
# against canonical pids.
|
||||
active_provider = _resolve_provider_alias(auth_store.get("active_provider"))
|
||||
except Exception:
|
||||
logger.debug("Failed to load auth store from %s", auth_store_path)
|
||||
|
||||
auth_store = _j.loads(auth_store_path.read_text(encoding="utf-8"))
|
||||
active_provider = auth_store.get("active_provider")
|
||||
except Exception:
|
||||
logger.debug("Failed to load auth store from %s", auth_store_path)
|
||||
|
||||
# 4. Detect available providers.
|
||||
# 3. Detect available providers.
|
||||
# Primary: ask hermes-agent's auth layer — the authoritative source. It checks
|
||||
# auth.json, credential pools, and env vars the same way the agent does at runtime,
|
||||
# so the dropdown reflects exactly what the user has configured.
|
||||
@@ -1073,6 +1142,56 @@ def get_available_models() -> dict:
|
||||
detected_providers = set()
|
||||
if active_provider:
|
||||
detected_providers.add(active_provider)
|
||||
|
||||
# Include providers that have usable credential-pool entries even when no
|
||||
# process env var is present (e.g. service launched without shell env).
|
||||
# Primary: delegate to upstream credential_pool.load_pool() so suppression
|
||||
# and seeding/pruning rules live in one place.
|
||||
# Fallback: manual field inspection when the upstream module is unavailable.
|
||||
try:
|
||||
_pool = auth_store.get("credential_pool", {}) if isinstance(auth_store, dict) else {}
|
||||
if isinstance(_pool, dict) and _pool:
|
||||
try:
|
||||
from agent.credential_pool import load_pool as _load_pool
|
||||
|
||||
# load_pool() does NOT suppress ambient gh-cli tokens — filter
|
||||
# them here so Copilot doesn't reappear just because the agent
|
||||
# seeded 'gh auth token' into the pool.
|
||||
for _pid in list(_pool.keys()):
|
||||
try:
|
||||
_canonical_pid = _resolve_provider_alias(str(_pid))
|
||||
_all_entries = _load_pool(_pid).entries()
|
||||
_explicit = [
|
||||
e for e in _all_entries
|
||||
if not _is_ambient_gh_cli_entry(
|
||||
str(getattr(e, "source", "") or ""),
|
||||
str(getattr(e, "label", "") or ""),
|
||||
str(getattr(e, "key_source", "") or ""),
|
||||
)
|
||||
]
|
||||
if _explicit:
|
||||
detected_providers.add(_canonical_pid)
|
||||
except Exception:
|
||||
logger.debug("credential_pool.load_pool(%s) failed", _pid)
|
||||
except ImportError:
|
||||
# Fallback: inspect raw entry fields for suppression signals.
|
||||
for _pid, _entries in _pool.items():
|
||||
if not isinstance(_entries, list) or len(_entries) == 0:
|
||||
continue
|
||||
_has_explicit_cred = any(
|
||||
isinstance(_entry, dict)
|
||||
and not _is_ambient_gh_cli_entry(
|
||||
str(_entry.get("source", "") or ""),
|
||||
str(_entry.get("label", "") or ""),
|
||||
str(_entry.get("key_source", "") or ""),
|
||||
)
|
||||
for _entry in _entries
|
||||
)
|
||||
if _has_explicit_cred:
|
||||
detected_providers.add(_resolve_provider_alias(str(_pid)))
|
||||
except Exception:
|
||||
logger.debug("Failed to inspect credential_pool from auth store")
|
||||
|
||||
all_env: dict = {} # profile .env keys — populated below, used by custom endpoint auth
|
||||
|
||||
_hermes_auth_used = False
|
||||
@@ -1156,7 +1275,7 @@ def get_available_models() -> dict:
|
||||
if all_env.get("OPENCODE_GO_API_KEY"):
|
||||
detected_providers.add("opencode-go")
|
||||
|
||||
# 3. Fetch models from custom endpoint if base_url is configured
|
||||
# 4. Fetch models from custom endpoint if base_url is configured
|
||||
auto_detected_models = []
|
||||
if cfg_base_url:
|
||||
try:
|
||||
@@ -1287,7 +1406,8 @@ def get_available_models() -> dict:
|
||||
)
|
||||
model_name = model.get("name", "") or model.get("model", "") or model_id
|
||||
if model_id and model_name:
|
||||
auto_detected_models.append({"id": model_id, "label": model_name})
|
||||
label = _format_ollama_label(model_id) if provider in ("ollama", "ollama-cloud") else model_name
|
||||
auto_detected_models.append({"id": model_id, "label": label})
|
||||
detected_providers.add(provider.lower())
|
||||
except Exception:
|
||||
logger.debug("Custom endpoint unreachable or misconfigured for provider: %s", provider)
|
||||
@@ -1378,6 +1498,31 @@ def get_available_models() -> dict:
|
||||
],
|
||||
}
|
||||
)
|
||||
elif pid == "ollama-cloud":
|
||||
# Ollama Cloud list is dynamic; fetch via hermes_cli provider catalog.
|
||||
# When the catalog is unavailable, skip the group rather than emit a
|
||||
# speculative static list — matches the named-custom and unknown-provider
|
||||
# branches below.
|
||||
raw_models = []
|
||||
try:
|
||||
from hermes_cli.models import provider_model_ids as _provider_model_ids
|
||||
|
||||
raw_models = [
|
||||
{"id": mid, "label": _format_ollama_label(mid)}
|
||||
for mid in (_provider_model_ids("ollama-cloud") or [])
|
||||
]
|
||||
except Exception:
|
||||
logger.warning("Failed to load Ollama Cloud models from hermes_cli")
|
||||
|
||||
if raw_models:
|
||||
models = _apply_provider_prefix(raw_models, pid, active_provider)
|
||||
groups.append(
|
||||
{
|
||||
"provider": provider_name,
|
||||
"provider_id": pid,
|
||||
"models": models,
|
||||
}
|
||||
)
|
||||
elif pid in _PROVIDER_MODELS or pid in cfg.get("providers", {}):
|
||||
# For non-default providers, prefix model IDs with @provider:model
|
||||
# so resolve_model_provider() routes through that specific provider
|
||||
@@ -1394,18 +1539,7 @@ def get_available_models() -> dict:
|
||||
raw_models = [{"id": k, "label": k} for k in cfg_models.keys()]
|
||||
elif isinstance(cfg_models, list):
|
||||
raw_models = [{"id": k, "label": k} for k in cfg_models]
|
||||
_active = (active_provider or "").lower()
|
||||
if _active and pid != _active:
|
||||
models = []
|
||||
for m in raw_models:
|
||||
mid = m["id"]
|
||||
# Don't double-prefix; use @provider: hint for bare names
|
||||
if mid.startswith("@") or "/" in mid:
|
||||
models.append({"id": mid, "label": m["label"]})
|
||||
else:
|
||||
models.append({"id": f"@{pid}:{mid}", "label": m["label"]})
|
||||
else:
|
||||
models = list(raw_models)
|
||||
models = _apply_provider_prefix(raw_models, pid, active_provider)
|
||||
groups.append(
|
||||
{
|
||||
"provider": provider_name,
|
||||
|
||||
@@ -2152,9 +2152,15 @@ def _handle_live_models(handler, parsed):
|
||||
if not ids:
|
||||
return j(handler, {"provider": provider, "models": [], "count": 0})
|
||||
|
||||
# Normalise to {id, label} — provider_model_ids() returns plain string IDs
|
||||
# Normalise to {id, label} — provider_model_ids() returns plain string IDs.
|
||||
# For ollama-cloud use the shared Ollama formatter (handles `:variant` suffix).
|
||||
# For all other providers use a simpler hyphen-split capitaliser.
|
||||
from api.config import _format_ollama_label as _fmt_ollama
|
||||
|
||||
def _make_label(mid):
|
||||
"""Best-effort human label from a model ID string."""
|
||||
if provider in ("ollama", "ollama-cloud"):
|
||||
return _fmt_ollama(mid)
|
||||
# Preserve slashes for router IDs like "anthropic/claude-sonnet-4.6"
|
||||
display = mid.split("/")[-1] if "/" in mid else mid
|
||||
parts = display.split("-")
|
||||
|
||||
Reference in New Issue
Block a user