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
61 changes: 57 additions & 4 deletions src/brigade/model_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from __future__ import annotations

import re
import subprocess
import tempfile
from dataclasses import dataclass
from typing import Literal

Expand All @@ -15,6 +17,8 @@
"grok": ["grok", "models"],
"ollama": ["ollama", "list"],
}
_INVENTORY_TIMEOUT_SECONDS = 15.0
_CURSOR_COMPLETION_PREFIX = "Tip: use --model <id>"
_EFFORT_SUFFIX = re.compile(r"-(?:none|low|medium|high|xhigh|extra-high|max)$")
_OLLAMA_OPERATIONAL_ERRORS = (
"authentication",
Expand Down Expand Up @@ -107,12 +111,17 @@ def _inventory(self, cli_ref: str) -> _HarnessInventory:
cached = self._inventories.get(cli_ref)
if cached is not None:
return cached
result = proc.run(_LIST_COMMANDS[cli_ref], timeout=15.0)
result = (
_run_cursor_inventory()
if cli_ref == "cursor"
else proc.run(_LIST_COMMANDS[cli_ref], timeout=_INVENTORY_TIMEOUT_SECONDS)
)
if result.code != 0:
diagnostic = result.stderr.strip() or result.stdout.strip() or f"exit {result.code}"
inventory = _HarnessInventory(error=diagnostic[:160])
else:
recognized, models = _parse_model_list(cli_ref, f"{result.stdout}\n{result.stderr}")
output = result.stdout if cli_ref == "cursor" else f"{result.stdout}\n{result.stderr}"
recognized, models = _parse_model_list(cli_ref, output)
if not recognized:
inventory = _HarnessInventory(error="command returned an unrecognized inventory shape")
elif not models and cli_ref != "ollama":
Expand Down Expand Up @@ -161,17 +170,61 @@ def _inspect_ollama(self, requested: str) -> ModelInventoryResult:
return ModelInventoryResult(state, requested, (), diagnostic[:200])


def _run_cursor_inventory() -> proc.Result:
"""Capture Cursor inventory through a file because its pipe output truncates at 8 KiB."""
with tempfile.TemporaryFile() as stdout:
try:
completed = subprocess.run(
_LIST_COMMANDS["cursor"],
stdout=stdout,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
timeout=_INVENTORY_TIMEOUT_SECONDS,
check=False,
)
except subprocess.TimeoutExpired as exc:
stdout.seek(0)
output = stdout.read().decode("utf-8", errors="replace")
diagnostic = _decode_subprocess_output(exc.stderr)
if diagnostic and not diagnostic.endswith("\n"):
diagnostic += "\n"
return proc.Result(
124,
output,
f"{diagnostic}timeout after {_INVENTORY_TIMEOUT_SECONDS}s",
)
except OSError as exc:
return proc.Result(127 if isinstance(exc, FileNotFoundError) else 126, "", str(exc))
stdout.seek(0)
output = stdout.read().decode("utf-8", errors="replace")
return proc.Result(completed.returncode, output, _decode_subprocess_output(completed.stderr))


def _decode_subprocess_output(value: str | bytes | None) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
return value.decode("utf-8", errors="replace")


def _parse_model_list(cli_ref: str, output: str) -> tuple[bool, tuple[str, ...]]:
lines = output.splitlines()
header_index = _inventory_header_index(cli_ref, lines)
if header_index is None:
return False, ()
models: set[str] = set()
complete = cli_ref != "cursor"
for line in lines[header_index + 1 :]:
if cli_ref == "cursor":
if line.startswith("Tip:"):
if not line.strip():
continue
if line.startswith(_CURSOR_COMPLETION_PREFIX):
complete = True
break
match = re.match(r"^([a-z0-9][a-z0-9._:/\[\],=-]*)\s+-\s+.+$", line)
if match is None:
return False, ()
elif cli_ref == "grok":
match = re.match(r"^\s*\*\s+([^\s(]+)", line)
else:
Expand All @@ -184,7 +237,7 @@ def _parse_model_list(cli_ref: str, output: str) -> tuple[bool, tuple[str, ...]]
continue
if match is not None:
models.add(match.group(1))
return True, tuple(sorted(models))
return complete, tuple(sorted(models))


def _inventory_header_index(cli_ref: str, lines: list[str]) -> int | None:
Expand Down
152 changes: 112 additions & 40 deletions tests/test_model_inventory.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import subprocess

from brigade import agents, model_inventory


def _cursor_listing(*ids: str) -> str:
lines = ["Available models", ""]
lines.extend(f"{model_id} - Label for {model_id}" for model_id in ids)
lines.extend(["", "Tip: use --model <id> to switch."])
return "\n".join(lines) + "\n"


Expand All @@ -19,12 +22,12 @@ def _ollama_listing(*ids: str) -> str:
return "\n".join(lines) + "\n"


def _patch_cursor_inventory(monkeypatch, result) -> None:
monkeypatch.setattr(model_inventory, "_run_cursor_inventory", lambda: result, raising=False)


def test_cursor_inventory_exact_match(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing("composer-2.5"), ""),
)
_patch_cursor_inventory(monkeypatch, agents.proc.Result(0, _cursor_listing("composer-2.5"), ""))

result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5")

Expand All @@ -34,10 +37,9 @@ def test_cursor_inventory_exact_match(monkeypatch):


def test_cursor_inventory_narrow_fuzzy_match(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(
_patch_cursor_inventory(
monkeypatch,
agents.proc.Result(
0,
_cursor_listing("cursor-grok-4.5-low", "cursor-grok-4.5-high", "cursor-grok-4.6-high"),
"",
Expand All @@ -52,10 +54,9 @@ def test_cursor_inventory_narrow_fuzzy_match(monkeypatch):


def test_cursor_inventory_different_version_is_missing(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing("cursor-grok-4.6-high"), ""),
_patch_cursor_inventory(
monkeypatch,
agents.proc.Result(0, _cursor_listing("cursor-grok-4.6-high"), ""),
)

result = model_inventory.ModelInventoryInspector().inspect("cursor", "grok-4.5-xhigh")
Expand All @@ -66,10 +67,9 @@ def test_cursor_inventory_different_version_is_missing(monkeypatch):


def test_cursor_inventory_requires_versioned_family_for_fuzzy_match(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing("cursor-auto-low"), ""),
_patch_cursor_inventory(
monkeypatch,
agents.proc.Result(0, _cursor_listing("cursor-auto-low"), ""),
)

result = model_inventory.ModelInventoryInspector().inspect("cursor", "auto-high")
Expand All @@ -80,11 +80,7 @@ def test_cursor_inventory_requires_versioned_family_for_fuzzy_match(monkeypatch)


def test_cursor_inventory_command_failure_is_unavailable(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(1, "", "not logged in"),
)
_patch_cursor_inventory(monkeypatch, agents.proc.Result(1, "", "not logged in"))

result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5")

Expand All @@ -94,11 +90,7 @@ def test_cursor_inventory_command_failure_is_unavailable(monkeypatch):


def test_cursor_inventory_unrecognized_output_is_unavailable(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(0, "model output changed\n", ""),
)
_patch_cursor_inventory(monkeypatch, agents.proc.Result(0, "model output changed\n", ""))

result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5")

Expand All @@ -108,11 +100,7 @@ def test_cursor_inventory_unrecognized_output_is_unavailable(monkeypatch):


def test_cursor_inventory_error_shaped_success_is_unavailable(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(0, "Warning - authentication required\n", ""),
)
_patch_cursor_inventory(monkeypatch, agents.proc.Result(0, "Warning - authentication required\n", ""))

result = model_inventory.ModelInventoryInspector().inspect("cursor", "composer-2.5")

Expand All @@ -122,11 +110,7 @@ def test_cursor_inventory_error_shaped_success_is_unavailable(monkeypatch):

def test_cursor_inventory_accepts_parameterized_exact_model(monkeypatch):
base_model = "claude-opus-4-8-thinking-high"
monkeypatch.setattr(
model_inventory.proc,
"run",
lambda argv, **kwargs: agents.proc.Result(0, _cursor_listing(base_model), ""),
)
_patch_cursor_inventory(monkeypatch, agents.proc.Result(0, _cursor_listing(base_model), ""))

result = model_inventory.ModelInventoryInspector().inspect(
"cursor",
Expand All @@ -141,18 +125,106 @@ def test_cursor_inventory_accepts_parameterized_exact_model(monkeypatch):
def test_cursor_inventory_is_loaded_once_for_repeated_seats(monkeypatch):
calls = []

def fake_run(argv, **kwargs):
calls.append(argv)
def fake_run():
calls.append(["cursor-agent", "models"])
return agents.proc.Result(0, _cursor_listing("composer-2.5", "gpt-5.5-high"), "")

monkeypatch.setattr(model_inventory.proc, "run", fake_run)
monkeypatch.setattr(model_inventory, "_run_cursor_inventory", fake_run, raising=False)
inspector = model_inventory.ModelInventoryInspector()

assert inspector.inspect("cursor", "composer-2.5").state == "exact"
assert inspector.inspect("cursor", "gpt-5.5-high").state == "exact"
assert calls == [["cursor-agent", "models"]]


def test_cursor_inventory_uses_regular_file_capture(monkeypatch):
listing = _cursor_listing("composer-2.5", "kimi-k2.7-code", "glm-5.2-high")

def fake_run(argv, **kwargs):
assert kwargs["stdout"] is not subprocess.PIPE
kwargs["stdout"].write(listing.encode())
return subprocess.CompletedProcess(argv, 0, stderr=b"")

monkeypatch.setattr(subprocess, "run", fake_run)

result = model_inventory._run_cursor_inventory()

assert result.code == 0
assert result.stdout == listing


def test_cursor_inventory_is_stable_across_repeated_probes(monkeypatch):
calls = []
listing = _cursor_listing("composer-2.5", "kimi-k2.7-code", "glm-5.2-high")

def fake_run(argv, **kwargs):
calls.append(argv)
assert kwargs["stdout"] is not subprocess.PIPE
kwargs["stdout"].write(listing.encode())
return subprocess.CompletedProcess(argv, 0, stderr=b"")

monkeypatch.setattr(subprocess, "run", fake_run)

for requested in ("kimi-k2.7-code", "glm-5.2-high"):
for _ in range(3):
result = model_inventory.ModelInventoryInspector().inspect("cursor", requested)
assert result is not None
assert result.state == "exact"

assert calls == [["cursor-agent", "models"]] * 6


def test_cursor_inventory_truncated_listing_is_unavailable(monkeypatch):
_patch_cursor_inventory(
monkeypatch,
agents.proc.Result(
0,
"Available models\n\ncomposer-2.5 - Composer 2.5\ngpt-5.",
"",
),
)

result = model_inventory.ModelInventoryInspector().inspect("cursor", "kimi-k2.7-code")

assert result is not None
assert result.state == "unavailable"
assert "unrecognized inventory shape" in result.detail


def test_cursor_inventory_completed_listing_with_malformed_row_is_unavailable(monkeypatch):
_patch_cursor_inventory(
monkeypatch,
agents.proc.Result(
0,
"Available models\n\ncomposer-2.5 - Composer 2.5\nbroken row\n\nTip: use --model <id> to switch.\n",
"",
),
)

result = model_inventory.ModelInventoryInspector().inspect("cursor", "kimi-k2.7-code")

assert result is not None
assert result.state == "unavailable"
assert "unrecognized inventory shape" in result.detail


def test_cursor_inventory_stderr_cannot_complete_truncated_stdout(monkeypatch):
_patch_cursor_inventory(
monkeypatch,
agents.proc.Result(
0,
"Available models\n\ncomposer-2.5 - Composer 2.5\ngpt-5.",
"Tip: use --model <id> to switch.\n",
),
)

result = model_inventory.ModelInventoryInspector().inspect("cursor", "kimi-k2.7-code")

assert result is not None
assert result.state == "unavailable"
assert "unrecognized inventory shape" in result.detail


def test_grok_inventory_parses_exact_and_fuzzy_models(monkeypatch):
monkeypatch.setattr(
model_inventory.proc,
Expand Down
11 changes: 7 additions & 4 deletions tests/test_roster_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,15 +390,18 @@ def test_roster_doctor_reuses_inventory_for_repeated_harness_seats(monkeypatch,
monkeypatch.setattr(agents.proc, "which", lambda cmd: "/x/" + cmd)
calls = []

def fake_run(argv, **kwargs):
calls.append(argv)
def fake_run():
calls.append(["cursor-agent", "models"])
return agents.proc.Result(
0,
"Available models\n\ncomposer-2.5 - Composer 2.5\ngpt-5.5-high - GPT-5.5 High\n",
"Available models\n\n"
"composer-2.5 - Composer 2.5\n"
"gpt-5.5-high - GPT-5.5 High\n\n"
"Tip: use --model <id> to switch.\n",
"",
)

monkeypatch.setattr(model_inventory.proc, "run", fake_run)
monkeypatch.setattr(model_inventory, "_run_cursor_inventory", fake_run)

assert roster_cmd.doctor(tmp_target) == 0
assert capsys.readouterr().out.count("model inventory") == 2
Expand Down
Loading