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:
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.151] — 2026-04-22
|
||||
|
||||
### Added
|
||||
- **Ollama Cloud support** — added `ollama-cloud` display name + dynamic model-list
|
||||
handler backed by `hermes_cli.models.provider_model_ids()`. Live-models endpoint
|
||||
routes `ollama-cloud` through the same formatter. Server-side `_format_ollama_label()`
|
||||
and matching client-side `_fmtOllamaLabel()` turn Ollama tag IDs into readable
|
||||
labels (e.g. `qwen3-vl:235b-instruct` → `Qwen3 VL (235B Instruct)`). (#820 by @starship-s, #860)
|
||||
|
||||
### Fixed
|
||||
- **`credential_pool` providers now visible in the model dropdown** —
|
||||
`get_available_models()` previously only read `active_provider` from the auth
|
||||
store. Providers added via `credential_pool` (e.g. an Ollama Cloud key stored by
|
||||
the auth layer without a matching shell env var) were silently invisible. The
|
||||
fix loads `credential_pool` entries and adds any provider with at least one
|
||||
non-ambient credential to `detected_providers`. Ambient gh-cli tokens (source
|
||||
`gh_cli` / label `gh auth token`) are explicitly excluded so Copilot doesn't
|
||||
appear merely because `gh` is installed. Two-tier detection: primary via
|
||||
`agent.credential_pool.load_pool()`, fallback via raw field inspection when
|
||||
the upstream module isn't importable. (#820 by @starship-s, #860)
|
||||
- **`_apply_provider_prefix()` helper extracted** — removes ~15 lines of
|
||||
duplicated inline `@provider:` prefixing logic for non-active providers.
|
||||
Semantics unchanged; one fewer place for drift. (#860)
|
||||
- **Model chip shows friendly labels for bare Ollama IDs** —
|
||||
`static/ui.js:getModelLabel()` now routes Ollama tag-format IDs (e.g.
|
||||
`kimi-k2.6` or `@ollama-cloud:glm5.1`) through `_fmtOllamaLabel()`. Custom
|
||||
`<option>` text uses the same helper. `looksLikeBareOllamaId` narrowed to
|
||||
`@ollama*` or colon-tag patterns — does not reformat generic IDs like
|
||||
`gpt-5.4-mini`. `syncModelChip()` is now called after localStorage restore
|
||||
so the chip reflects the saved selection on first paint. (#860)
|
||||
|
||||
## [v0.50.150] — 2026-04-22
|
||||
|
||||
### Fixed
|
||||
|
||||
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("-")
|
||||
|
||||
@@ -847,6 +847,7 @@ function applyBotName(){
|
||||
$('modelSelect').value=savedModel;
|
||||
// If the value didn't take (model not in list), clear the bad pref
|
||||
if($('modelSelect').value!==savedModel) localStorage.removeItem('hermes-webui-model');
|
||||
else if(typeof syncModelChip==='function') syncModelChip();
|
||||
}
|
||||
// Pre-load workspace list so sidebar name is correct from first render
|
||||
await loadWorkspaceList();
|
||||
|
||||
33
static/ui.js
33
static/ui.js
@@ -341,7 +341,7 @@ async function selectModelFromDropdown(value){
|
||||
if(!Array.from(sel.options).some(o=>o.value===value)){
|
||||
const opt=document.createElement('option');
|
||||
opt.value=value;
|
||||
opt.textContent=value.split('/').pop()||value;
|
||||
opt.textContent=getModelLabel(value);
|
||||
opt.dataset.custom='1';
|
||||
// Remove any previous custom option before adding new one
|
||||
sel.querySelectorAll('option[data-custom]').forEach(o=>o.remove());
|
||||
@@ -469,6 +469,23 @@ function scrollToBottom(){
|
||||
if(btn) btn.style.display='none';
|
||||
}
|
||||
|
||||
function _fmtOllamaLabel(mid){
|
||||
const [namePart, ...variantParts] = mid.split(':');
|
||||
const variant = variantParts.join(':');
|
||||
const _fmt = (s) => {
|
||||
const tokens = s.replace(/[-_]/g, ' ').split(' ');
|
||||
return tokens.map(t => {
|
||||
const alphaOnly = t.replace(/\./g, '');
|
||||
if (t.length <= 3 && /^[a-zA-Z.]+$/.test(t)) return t.toUpperCase();
|
||||
if (/^\d/.test(alphaOnly)) return t.toUpperCase();
|
||||
return t.charAt(0).toUpperCase() + t.slice(1);
|
||||
}).join(' ');
|
||||
};
|
||||
let label = _fmt(namePart);
|
||||
if (variant) label += ' (' + _fmt(variant) + ')';
|
||||
return label;
|
||||
}
|
||||
|
||||
function getModelLabel(modelId){
|
||||
if(!modelId) return 'Unknown';
|
||||
// Check dynamic labels first, then fall back to splitting the ID
|
||||
@@ -476,7 +493,19 @@ function getModelLabel(modelId){
|
||||
// Static fallback for common models
|
||||
const STATIC_LABELS={'openai/gpt-5.4-mini':'GPT-5.4 Mini','openai/gpt-4o':'GPT-4o','openai/o3':'o3','openai/o4-mini':'o4-mini','anthropic/claude-sonnet-4.6':'Sonnet 4.6','anthropic/claude-sonnet-4-5':'Sonnet 4.5','anthropic/claude-haiku-3-5':'Haiku 3.5','google/gemini-3.1-pro-preview':'Gemini 3.1 Pro','google/gemini-3-flash-preview':'Gemini 3 Flash','google/gemini-3.1-flash-lite-preview':'Gemini 3.1 Flash Lite','google/gemini-2.5-pro':'Gemini 2.5 Pro','google/gemini-2.5-flash':'Gemini 2.5 Flash','deepseek/deepseek-chat-v3-0324':'DeepSeek V3','meta-llama/llama-4-scout':'Llama 4 Scout'};
|
||||
if(STATIC_LABELS[modelId]) return STATIC_LABELS[modelId];
|
||||
return modelId.split('/').pop()||'Unknown';
|
||||
// Safe Ollama-tag fallback formatter before generic split('/').pop()
|
||||
let _last = modelId.split('/').pop() || modelId;
|
||||
// Strip @provider: prefix if present (e.g. @ollama-cloud:kimi-k2.6)
|
||||
if (_last.startsWith('@') && _last.includes(':')) _last = _last.split(':').slice(1).join(':');
|
||||
const looksLikeOllamaTag = /^[a-z0-9][\w.-]*:[\w.-]+$/i.test(_last);
|
||||
// Narrow: only apply Ollama formatter to IDs with explicit @ollama prefix or colon-tag format.
|
||||
// Avoids reformatting bare provider model IDs like claude-sonnet-4-6 or gpt-4o.
|
||||
const looksLikeBareOllamaId = modelId.startsWith('@ollama') || looksLikeOllamaTag;
|
||||
const ollamaLabel = _fmtOllamaLabel(_last);
|
||||
if ((modelId.startsWith('ollama/') || modelId.startsWith('@ollama') || looksLikeOllamaTag || looksLikeBareOllamaId) && ollamaLabel !== _last) {
|
||||
return ollamaLabel;
|
||||
}
|
||||
return _last || 'Unknown';
|
||||
}
|
||||
|
||||
function _stripXmlToolCallsDisplay(s){
|
||||
|
||||
595
tests/test_credential_pool_providers.py
Normal file
595
tests/test_credential_pool_providers.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""Regression tests for credential_pool provider detection in /api/models."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
import api.config as config
|
||||
import api.profiles as profiles
|
||||
|
||||
_AMBIENT_SOURCES = {"gh_cli", "gh auth token"}
|
||||
|
||||
|
||||
def _install_fake_hermes_cli(monkeypatch, *, with_load_pool: bool = False, pool_data: dict | None = None):
|
||||
"""Stub hermes_cli modules so tests are deterministic and offline.
|
||||
|
||||
When *with_load_pool* is True, also stubs hermes_cli.credential_pool with a
|
||||
suppression-aware load_pool() implementation that mirrors upstream behaviour:
|
||||
entries whose source/label/key_source signals ambient gh-cli auth are filtered out.
|
||||
"""
|
||||
fake_pkg = types.ModuleType("hermes_cli")
|
||||
fake_pkg.__path__ = []
|
||||
|
||||
fake_models = types.ModuleType("hermes_cli.models")
|
||||
fake_models.list_available_providers = lambda: []
|
||||
fake_models.provider_model_ids = lambda pid: (
|
||||
["gpt-oss:20b", "qwen3:30b-a3b"] if pid == "ollama-cloud" else []
|
||||
)
|
||||
|
||||
fake_auth = types.ModuleType("hermes_cli.auth")
|
||||
fake_auth.get_auth_status = lambda _pid: {}
|
||||
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
|
||||
|
||||
# Always remove the real agent.credential_pool so get_available_models() takes
|
||||
# the ImportError fallback path and reads from the monkeypatched auth store,
|
||||
# not the live ~/.hermes/auth.json via the real venv module.
|
||||
monkeypatch.delitem(sys.modules, "agent.credential_pool", raising=False)
|
||||
monkeypatch.delitem(sys.modules, "agent", raising=False)
|
||||
|
||||
if with_load_pool:
|
||||
_pool_data = pool_data or {}
|
||||
|
||||
class _FakeEntry:
|
||||
"""Minimal PooledCredential stand-in with attribute access (matching the real class)."""
|
||||
def __init__(self, d):
|
||||
self.source = d.get("source", "manual")
|
||||
self.label = d.get("label", "")
|
||||
self.key_source = d.get("key_source", "")
|
||||
self.id = d.get("id", "")
|
||||
|
||||
class _FakePool:
|
||||
def __init__(self, entries_list):
|
||||
self._entries = entries_list
|
||||
|
||||
def entries(self):
|
||||
return self._entries
|
||||
|
||||
def _fake_load_pool(pid):
|
||||
# Return ALL entries without filtering — mirrors the real load_pool()
|
||||
# which does NOT suppress ambient gh-cli tokens on its own.
|
||||
# Ambient-source filtering is the webui's responsibility.
|
||||
raw = _pool_data.get(pid, [])
|
||||
return _FakePool([_FakeEntry(e) for e in raw])
|
||||
|
||||
fake_cp = types.ModuleType("agent.credential_pool")
|
||||
fake_cp.load_pool = _fake_load_pool
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", fake_cp)
|
||||
|
||||
|
||||
def _call_get_available_models(monkeypatch, tmp_path, auth_payload, *, with_load_pool: bool = False):
|
||||
"""Call get_available_models() with auth.json pinned to a temp Hermes home."""
|
||||
_install_fake_hermes_cli(
|
||||
monkeypatch,
|
||||
with_load_pool=with_load_pool,
|
||||
pool_data=auth_payload.get("credential_pool", {}),
|
||||
)
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
# Pin mtime to avoid reload_config() clobbering our in-memory cfg patch.
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
config.invalidate_models_cache()
|
||||
try:
|
||||
return config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
config.invalidate_models_cache()
|
||||
|
||||
|
||||
def _group_by_provider(result):
|
||||
return {g["provider"]: g["models"] for g in result.get("groups", [])}
|
||||
|
||||
|
||||
def test_ollama_cloud_manual_credential_shows_group(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"ollama-cloud": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"label": "ollama-manual",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://ollama.com/v1",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "Ollama Cloud" in groups, f"Expected Ollama Cloud in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["Ollama Cloud"]]
|
||||
assert model_ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], model_ids
|
||||
|
||||
|
||||
def test_copilot_gh_cli_only_credential_hidden(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "def456",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot should be hidden when only ambient gh auth token is present; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_copilot_mixed_credential_pool_remains_visible(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "def456",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
{
|
||||
"id": "ghi789",
|
||||
"label": "explicit-copilot",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
|
||||
assert "@copilot:gpt-5.4" in model_ids, model_ids
|
||||
assert "@copilot:claude-opus-4.6" in model_ids, model_ids
|
||||
|
||||
|
||||
def test_copilot_empty_field_entries_are_treated_as_explicit(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "jkl012",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
|
||||
|
||||
def test_copilot_oauth_credential_is_visible(monkeypatch, tmp_path):
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "mno345",
|
||||
"label": "github-oauth",
|
||||
"source": "oauth",
|
||||
"auth_type": "oauth",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, f"Expected GitHub Copilot in {list(groups)}"
|
||||
|
||||
|
||||
# --- load_pool path (suppression-aware) ---
|
||||
|
||||
|
||||
def test_load_pool_copilot_ambient_only_remains_hidden(monkeypatch, tmp_path):
|
||||
"""load_pool path: copilot with only ambient gh-cli entries is suppressed."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp001",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot must be hidden when load_pool returns no usable entries; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_pool_copilot_ambient_key_source_only_remains_hidden(monkeypatch, tmp_path):
|
||||
"""load_pool path: key_source-only ambient markers must also be suppressed."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp001b",
|
||||
"label": "copilot-token",
|
||||
"source": "manual",
|
||||
"key_source": "gh auth token",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" not in groups, (
|
||||
"GitHub Copilot must stay hidden when load_pool entries only differ by key_source ambient markers; "
|
||||
f"got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_pool_alias_provider_key_is_resolved(monkeypatch, tmp_path):
|
||||
"""load_pool path: aliased pool keys should resolve to canonical provider ids."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"google": [
|
||||
{
|
||||
"id": "gp001",
|
||||
"label": "explicit-gemini",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://generativelanguage.googleapis.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
|
||||
assert "Google" not in groups, f"Aliased provider key should not render under raw alias name: {list(groups)}"
|
||||
|
||||
|
||||
def test_load_pool_explicit_credential_shows_provider(monkeypatch, tmp_path):
|
||||
"""load_pool path: provider with at least one explicit entry is visible."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp002",
|
||||
"label": "gh auth token",
|
||||
"source": "gh_cli",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
{
|
||||
"id": "lp003",
|
||||
"label": "explicit-pat",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload, with_load_pool=True)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups, (
|
||||
f"GitHub Copilot must appear when load_pool has at least one usable entry; got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
# --- _apply_provider_prefix helper ---
|
||||
|
||||
|
||||
def test_apply_provider_prefix_ollama_cloud_non_active():
|
||||
"""Bare ollama-cloud model ids get @ollama-cloud: prefix when not active."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-oss:20b", "label": "gpt-oss:20b"}, {"id": "qwen3:30b-a3b", "label": "qwen3:30b-a3b"}]
|
||||
result = _apply_provider_prefix(raw, "ollama-cloud", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@ollama-cloud:gpt-oss:20b", "@ollama-cloud:qwen3:30b-a3b"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_copilot_non_active():
|
||||
"""Bare copilot model ids get @copilot: prefix when not active."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}, {"id": "claude-opus-4.6", "label": "Claude Opus 4.6"}]
|
||||
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@copilot:gpt-5.4", "@copilot:claude-opus-4.6"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_no_double_prefix():
|
||||
"""Already-prefixed or provider/model ids are not double-prefixed."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [
|
||||
{"id": "@copilot:gpt-5.4", "label": "already prefixed"},
|
||||
{"id": "openai/gpt-5.4", "label": "slash form"},
|
||||
{"id": "bare-model", "label": "bare"},
|
||||
]
|
||||
result = _apply_provider_prefix(raw, "copilot", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["@copilot:gpt-5.4", "openai/gpt-5.4", "@copilot:bare-model"], ids
|
||||
|
||||
|
||||
def test_apply_provider_prefix_active_provider_no_prefix():
|
||||
"""No prefix is added when the provider is already the active one."""
|
||||
from api.config import _apply_provider_prefix
|
||||
|
||||
raw = [{"id": "gpt-5.4", "label": "GPT-5.4"}]
|
||||
result = _apply_provider_prefix(raw, "openai-codex", "openai-codex")
|
||||
ids = [m["id"] for m in result]
|
||||
assert ids == ["gpt-5.4"], ids
|
||||
|
||||
|
||||
def test_copilot_mixed_pool_prefixed_models(monkeypatch, tmp_path):
|
||||
"""Copilot with mixed pool and non-active provider has @copilot: prefixed model ids."""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"copilot": [
|
||||
{
|
||||
"id": "lp010",
|
||||
"label": "explicit-copilot",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
"base_url": "https://api.githubcopilot.com",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
assert "GitHub Copilot" in groups
|
||||
model_ids = [m["id"] for m in groups["GitHub Copilot"]]
|
||||
assert all(mid.startswith("@copilot:") for mid in model_ids), model_ids
|
||||
|
||||
|
||||
def test_auth_store_active_provider_alias_is_resolved(monkeypatch, tmp_path):
|
||||
"""active_provider read from auth.json must be alias-normalized.
|
||||
|
||||
Regression: previously the alias table was applied only to config.yaml's
|
||||
active_provider, so an aliased name in auth.json (e.g. 'google') would
|
||||
not match the canonical pid ('gemini') and the prefixing logic would
|
||||
add an unwanted '@gemini:' prefix to the active provider's models.
|
||||
"""
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
# Aliased name: 'google' → 'gemini' per _PROVIDER_ALIASES.
|
||||
"active_provider": "google",
|
||||
"credential_pool": {},
|
||||
}
|
||||
|
||||
result = _call_get_available_models(monkeypatch, tmp_path, auth_payload)
|
||||
groups = _group_by_provider(result)
|
||||
# Gemini should appear under its canonical display name and its model
|
||||
# ids should NOT be prefixed (it's the active provider).
|
||||
assert "Gemini" in groups, f"Expected Gemini in {list(groups)}"
|
||||
model_ids = [m["id"] for m in groups["Gemini"]]
|
||||
assert model_ids, "Gemini group should have models"
|
||||
assert not any(mid.startswith("@") for mid in model_ids), (
|
||||
f"Active provider models must not be prefixed; got {model_ids}"
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_cloud_empty_catalog_skips_group(monkeypatch, tmp_path):
|
||||
"""When hermes_cli returns no models for ollama-cloud, the group is omitted.
|
||||
|
||||
Matches the named-custom and unknown-provider branches: we don't invent a
|
||||
catalog we can't enumerate. The logger.warning in the except branch keeps
|
||||
diagnostics available for operators.
|
||||
"""
|
||||
_install_fake_hermes_cli(monkeypatch)
|
||||
|
||||
# Override the stub to return empty for ollama-cloud.
|
||||
import sys as _sys
|
||||
_sys.modules["hermes_cli.models"].provider_model_ids = lambda pid: []
|
||||
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"ollama-cloud": [
|
||||
{
|
||||
"id": "oc-empty",
|
||||
"label": "ollama-manual",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
groups = _group_by_provider(result)
|
||||
assert "Ollama Cloud" not in groups, (
|
||||
f"Ollama Cloud group should be skipped when catalog is empty; got {list(groups)}"
|
||||
)
|
||||
|
||||
|
||||
# --- _format_ollama_label helper ---
|
||||
|
||||
|
||||
def test_format_ollama_label_simple():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("kimi-k2.5") == "Kimi K2.5"
|
||||
|
||||
|
||||
def test_format_ollama_label_with_variant():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("qwen3-vl:235b-instruct") == "Qwen3 VL (235B Instruct)"
|
||||
|
||||
|
||||
def test_format_ollama_label_short_acronym():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("glm-5.1") == "GLM 5.1"
|
||||
|
||||
|
||||
def test_format_ollama_label_gpt_oss_with_size():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("gpt-oss:20b") == "GPT OSS (20B)"
|
||||
|
||||
|
||||
def test_format_ollama_label_empty_string():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("") == ""
|
||||
|
||||
|
||||
def test_format_ollama_label_no_variant():
|
||||
from api.config import _format_ollama_label
|
||||
|
||||
assert _format_ollama_label("nemotron-3-super") == "Nemotron 3 Super"
|
||||
|
||||
|
||||
# --- Fallback-path (ImportError branch) alias resolution ---
|
||||
|
||||
|
||||
def test_fallback_path_resolves_alias_when_load_pool_unavailable(monkeypatch, tmp_path):
|
||||
"""When agent.credential_pool can't be imported, the manual-inspection
|
||||
branch must still canonicalize pool keys so aliased names (e.g. 'google')
|
||||
end up under their canonical provider id ('gemini')."""
|
||||
_install_fake_hermes_cli(monkeypatch)
|
||||
# Ensure agent.credential_pool is not importable so the fallback branch runs.
|
||||
monkeypatch.setitem(sys.modules, "agent.credential_pool", None)
|
||||
|
||||
auth_payload = {
|
||||
"version": 1,
|
||||
"providers": {},
|
||||
"active_provider": "openai-codex",
|
||||
"credential_pool": {
|
||||
"google": [
|
||||
{
|
||||
"id": "gp-fallback",
|
||||
"label": "explicit-gemini",
|
||||
"source": "manual",
|
||||
"auth_type": "api_key",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
(tmp_path / "auth.json").write_text(json.dumps(auth_payload), encoding="utf-8")
|
||||
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path)
|
||||
|
||||
old_cfg = dict(config.cfg)
|
||||
old_mtime = config._cfg_mtime
|
||||
config.cfg.clear()
|
||||
config.cfg["model"] = {}
|
||||
try:
|
||||
config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime
|
||||
except Exception:
|
||||
config._cfg_mtime = 0.0
|
||||
|
||||
try:
|
||||
result = config.get_available_models()
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
config._cfg_mtime = old_mtime
|
||||
|
||||
groups = _group_by_provider(result)
|
||||
assert "Gemini" in groups, (
|
||||
f"Fallback path must resolve 'google' -> 'gemini'; got {list(groups)}"
|
||||
)
|
||||
assert "Google" not in groups, (
|
||||
f"Raw alias name must not leak when fallback path runs; got {list(groups)}"
|
||||
)
|
||||
51
tests/test_ollama_model_chip_label_regression.py
Normal file
51
tests/test_ollama_model_chip_label_regression.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import pathlib
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
UI_JS = ROOT / "static" / "ui.js"
|
||||
|
||||
|
||||
def _read_ui() -> str:
|
||||
return UI_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_select_model_custom_option_uses_friendly_label_helper():
|
||||
src = _read_ui()
|
||||
start = src.find("async function selectModelFromDropdown(value)")
|
||||
assert start != -1, "selectModelFromDropdown() not found"
|
||||
end = src.find("\nfunction toggleModelDropdown()", start)
|
||||
assert end != -1, "toggleModelDropdown() boundary not found"
|
||||
body = src[start:end]
|
||||
|
||||
assert "opt.textContent=getModelLabel(value);" in body, (
|
||||
"Temporary model options should use getModelLabel(value) so the chip shows a "
|
||||
"friendly label instead of a raw slug when the value is not already in the "
|
||||
"native <select> options."
|
||||
)
|
||||
assert "opt.textContent=value.split('/').pop()||value;" not in body, (
|
||||
"Raw slug fallback in selectModelFromDropdown() regresses the model chip for "
|
||||
"Ollama-tag style model IDs."
|
||||
)
|
||||
|
||||
|
||||
def test_get_model_label_formats_bare_ollama_ids():
|
||||
src = _read_ui()
|
||||
assert "const looksLikeOllamaTag = /^[a-z0-9][\\w.-]*:[\\w.-]+$/i.test(_last);" in src
|
||||
# Tightened heuristic: only apply Ollama formatter to IDs with @ollama prefix or colon-tag format,
|
||||
# avoiding reformatting of bare provider model IDs like claude-sonnet-4-6 or gpt-4o.
|
||||
assert "const looksLikeBareOllamaId = modelId.startsWith('@ollama') || looksLikeOllamaTag;" in src, (
|
||||
"looksLikeBareOllamaId must be restricted to @ollama-prefixed or colon-tagged IDs "
|
||||
"to avoid reformatting generic bare model IDs."
|
||||
)
|
||||
assert "const ollamaLabel = _fmtOllamaLabel(_last);" in src
|
||||
assert "if ((modelId.startsWith('ollama/') || modelId.startsWith('@ollama') || looksLikeOllamaTag || looksLikeBareOllamaId) && ollamaLabel !== _last) {" in src, (
|
||||
"Ollama-tagged ids like 'kimi-k2.6:3b' should still pass through _fmtOllamaLabel() "
|
||||
"when the formatter produces a friendlier label."
|
||||
)
|
||||
|
||||
|
||||
def test_fmt_ollama_label_preserves_dotted_acronyms():
|
||||
src = _read_ui()
|
||||
assert "if (t.length <= 3 && /^[a-zA-Z.]+$/.test(t)) return t.toUpperCase();" in src, (
|
||||
"JS Ollama formatter should preserve dotted acronyms like 'a.b' -> 'A.B'."
|
||||
)
|
||||
Reference in New Issue
Block a user