Skip to content

Commit 4d52048

Browse files
rioyu123rng1995
andauthored
fix(providers): honor registry overrides for CLI providers (#463)
Signed-off-by: Rio Yu <52408936+rioyu123@users.noreply.github.com> Co-authored-by: Narendran Raghavan <32655573+rng1995@users.noreply.github.com>
1 parent f72dddf commit 4d52048

3 files changed

Lines changed: 133 additions & 14 deletions

File tree

‎src/skillspector/providers/_agent_cli_base.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class AgentCLIProviderBase:
4040
#: ``_provider.DEFAULT_MODEL`` lookup has an attribute; never pins a version.
4141
DEFAULT_MODEL: str = ""
4242
#: Optional path to a bundled ``model_registry.yaml`` for token budgets. CLI
43-
#: providers leave this empty and fall back to package-wide default budgets.
43+
#: providers leave this empty, but users can supply a global registry override.
4444
REGISTRY_PATH: str = ""
4545

4646
# -- Credentials ---------------------------------------------------------
@@ -82,13 +82,9 @@ def complete(
8282
# -- Metadata ------------------------------------------------------------
8383

8484
def get_context_length(self, model: str) -> int | None:
85-
if not self.REGISTRY_PATH:
86-
return None # no registry -> caller uses the package-wide default budget
8785
return registry.lookup_context_length(self.REGISTRY_PATH, model)
8886

8987
def get_max_output_tokens(self, model: str) -> int | None:
90-
if not self.REGISTRY_PATH:
91-
return None
9288
return registry.lookup_max_output_tokens(self.REGISTRY_PATH, model)
9389

9490
def resolve_model(self, slot: str = "default") -> str:

‎src/skillspector/providers/registry.py‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ def _load(yaml_path: str) -> dict[str, dict[str, Any]]:
5353
try:
5454
raw = Path(yaml_path).read_text(encoding="utf-8")
5555
data = yaml.safe_load(raw) or {}
56-
return data.get("models") or {}
56+
models = data.get("models")
57+
if models is None:
58+
return {}
59+
if not isinstance(models, dict):
60+
raise ValueError("model registry 'models' must be a mapping")
61+
return models
5762
except Exception:
5863
logger.warning("Could not load model registry at %s", yaml_path, exc_info=True)
5964
return {}
@@ -67,18 +72,33 @@ def _resolve_path(default_yaml_path: str) -> str:
6772

6873
def lookup_context_length(default_yaml_path: str, model: str) -> int | None:
6974
"""Return ``context_length`` for *model* from the resolved YAML registry."""
70-
entry = _load(_resolve_path(default_yaml_path)).get(model)
71-
if entry and entry.get("context_length"):
72-
return int(entry["context_length"])
73-
return None
75+
return _lookup_token_limit(default_yaml_path, model, "context_length")
7476

7577

7678
def lookup_max_output_tokens(default_yaml_path: str, model: str) -> int | None:
7779
"""Return ``max_output_tokens`` for *model* from the resolved YAML registry."""
78-
entry = _load(_resolve_path(default_yaml_path)).get(model)
79-
if entry and entry.get("max_output_tokens"):
80-
return int(entry["max_output_tokens"])
81-
return None
80+
return _lookup_token_limit(default_yaml_path, model, "max_output_tokens")
81+
82+
83+
def _lookup_token_limit(default_yaml_path: str, model: str, field: str) -> int | None:
84+
"""Read a positive budget, falling back for malformed entries or values."""
85+
yaml_path = _resolve_path(default_yaml_path)
86+
entry = _load(yaml_path).get(model)
87+
if entry is None:
88+
return None
89+
try:
90+
if not isinstance(entry, dict):
91+
raise ValueError("model registry entry must be a mapping")
92+
value = entry.get(field)
93+
if value is None:
94+
return None
95+
limit = int(value)
96+
if limit <= 0:
97+
raise ValueError("model token budget must be positive")
98+
return limit
99+
except (TypeError, ValueError, OverflowError):
100+
logger.warning("Invalid %s for model %s in registry %s", field, model, yaml_path)
101+
return None
82102

83103

84104
# Back-compat alias for tests that previously called ``_load_registry``.

‎tests/unit/test_providers.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from __future__ import annotations
2424

2525
import sys
26+
from pathlib import Path
2627

2728
import pytest
2829
from langchain_anthropic import ChatAnthropic
@@ -830,6 +831,108 @@ def test_is_available_reports_not_ready(self) -> None:
830831
assert reason
831832

832833

834+
class TestAgentCLIProviderMetadata:
835+
"""Shared model-registry behavior for supported agent CLI providers."""
836+
837+
@pytest.mark.parametrize(
838+
"provider_type", [ClaudeCLIProvider, CodexCLIProvider, GeminiCLIProvider]
839+
)
840+
@pytest.mark.parametrize(
841+
"contents",
842+
[
843+
"models: [test-model]\n",
844+
"models:\n test-model: 42\n",
845+
"models:\n test-model:\n context_length: lots\n max_output_tokens: lots\n",
846+
"models:\n test-model:\n context_length: -1\n max_output_tokens: -1\n",
847+
"models:\n test-model:\n context_length: 0\n max_output_tokens: 0\n",
848+
"models:\n test-model:\n context_length: .inf\n max_output_tokens: .inf\n",
849+
],
850+
ids=["models-list", "scalar-entry", "bad-string", "negative", "zero", "infinite"],
851+
)
852+
def test_malformed_registry_returns_none(
853+
self,
854+
provider_type: type[ClaudeCLIProvider | CodexCLIProvider | GeminiCLIProvider],
855+
contents: str,
856+
monkeypatch: pytest.MonkeyPatch,
857+
tmp_path: Path,
858+
) -> None:
859+
registry_path = tmp_path / "model_registry.yaml"
860+
registry_path.write_text(contents, encoding="utf-8")
861+
monkeypatch.setenv("SKILLSPECTOR_MODEL_REGISTRY", str(registry_path))
862+
863+
provider = provider_type()
864+
assert provider.get_context_length("test-model") is None
865+
assert provider.get_max_output_tokens("test-model") is None
866+
867+
@pytest.mark.parametrize("invalid_field", ["context_length", "max_output_tokens"])
868+
def test_invalid_budget_preserves_other_field(
869+
self, invalid_field: str, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
870+
) -> None:
871+
valid_field = "max_output_tokens" if invalid_field == "context_length" else "context_length"
872+
registry_path = tmp_path / "model_registry.yaml"
873+
registry_path.write_text(
874+
f'models:\n test-model:\n {invalid_field}: lots\n {valid_field}: "32000"\n',
875+
encoding="utf-8",
876+
)
877+
monkeypatch.setenv("SKILLSPECTOR_MODEL_REGISTRY", str(registry_path))
878+
879+
provider = ClaudeCLIProvider()
880+
assert getattr(provider, f"get_{invalid_field}")("test-model") is None
881+
assert getattr(provider, f"get_{valid_field}")("test-model") == 32_000
882+
883+
@pytest.mark.parametrize(
884+
"provider_type",
885+
[ClaudeCLIProvider, CodexCLIProvider, GeminiCLIProvider],
886+
)
887+
def test_honors_model_registry_override(
888+
self,
889+
provider_type: type[ClaudeCLIProvider | CodexCLIProvider | GeminiCLIProvider],
890+
monkeypatch: pytest.MonkeyPatch,
891+
tmp_path: Path,
892+
) -> None:
893+
registry_path = tmp_path / "model_registry.yaml"
894+
registry_path.write_text(
895+
"models:\n test-model:\n context_length: 200000\n max_output_tokens: 32000\n",
896+
encoding="utf-8",
897+
)
898+
monkeypatch.setenv("SKILLSPECTOR_MODEL_REGISTRY", str(registry_path))
899+
900+
provider = provider_type()
901+
assert provider.get_context_length("test-model") == 200_000
902+
assert provider.get_max_output_tokens("test-model") == 32_000
903+
904+
@pytest.mark.parametrize("registry_value", [None, " "])
905+
def test_returns_none_without_registry(
906+
self,
907+
registry_value: str | None,
908+
monkeypatch: pytest.MonkeyPatch,
909+
) -> None:
910+
if registry_value is None:
911+
monkeypatch.delenv("SKILLSPECTOR_MODEL_REGISTRY", raising=False)
912+
else:
913+
monkeypatch.setenv("SKILLSPECTOR_MODEL_REGISTRY", registry_value)
914+
915+
provider = ClaudeCLIProvider()
916+
assert provider.get_context_length("test-model") is None
917+
assert provider.get_max_output_tokens("test-model") is None
918+
919+
def test_unknown_model_returns_none(
920+
self,
921+
monkeypatch: pytest.MonkeyPatch,
922+
tmp_path: Path,
923+
) -> None:
924+
registry_path = tmp_path / "model_registry.yaml"
925+
registry_path.write_text(
926+
"models:\n known-model:\n context_length: 200000\n max_output_tokens: 32000\n",
927+
encoding="utf-8",
928+
)
929+
monkeypatch.setenv("SKILLSPECTOR_MODEL_REGISTRY", str(registry_path))
930+
931+
provider = ClaudeCLIProvider()
932+
assert provider.get_context_length("unknown-model") is None
933+
assert provider.get_max_output_tokens("unknown-model") is None
934+
935+
833936
class TestClaudeCLIProvider:
834937
"""Claude CLI provider — metadata, availability, and capability detection."""
835938

0 commit comments

Comments
 (0)