diff --git a/docs/change/requests/CR-114-local-backend-metadata-probe.md b/docs/change/requests/CR-114-local-backend-metadata-probe.md new file mode 100644 index 0000000..f0a1dbe --- /dev/null +++ b/docs/change/requests/CR-114-local-backend-metadata-probe.md @@ -0,0 +1,109 @@ +# CR-114: Read-Only Local Backend Metadata Probe + +## Status + +Implemented in branch; pending review/merge + +Implements the read-only local backend metadata probe slice bounded by the +CR-113 design brief (`docs/operations/local-backend-telemetry.md`). This is the +first non-deterministic contact point in the runtime-strategy/telemetry lane: it +observes local backend availability and model/runtime identity via metadata +endpoints only. It adds no model generation, no routing changes, no +`ResilienceRouteInput` wiring, no circuit breakers, no background polling, no +ledger writes, and no default artifact writes. It is also the observation +foundation the daily-driver M1 milestone will later consume. + +## Scope + +- Add `triage_core/local_backend_probe.py` providing: + - a metadata-only `LocalBackendProbeRecord` (closed `source_type`, + `error_category`, and `evidence_tier` vocabularies) whose `to_dict()` + enforces the persistent privacy invariant on every emitted record; + - `redact_base_url()` (validation rule: reject userinfo/query/fragment, strip + path, store `scheme://host[:port]` only); + - `probe_local_backend()` which hits a metadata endpoint only + (`ollama -> /api/tags`, `lm_studio` / `llama_cpp -> /v1/models`), fails + closed with a closed `error_category`, and accepts an injectable transport + for offline testing; + - `render_probe_record()` for a privacy-safe human-readable summary. +- Add a `tc probe` command: + `tc probe --source-type {ollama|lm_studio|llama_cpp} --base-url ` + with `--timeout`, `--include-model-names`, `--disabled`, and `--output`. +- Add offline tests in `tests/test_local_backend_probe.py`. +- Update `docs/operations/local-backend-telemetry.md` Status to record that + CR-114 implements the probe slice only. + +### Exit codes + +- `0` — the probe produced a valid metadata record, including `reachable=false` + records and `probe_disabled` records. +- `1` — argument / input / validation error, including a secret-bearing + `base_url` (userinfo or query present). + +There is no exit `2` for this slice; there is no config/policy gate that makes +the command unable to run. `--disabled` is a valid record outcome (exit `0`), +not a crash. + +## Non-Goals + +- No model generation, completions, or embeddings; metadata endpoints only. +- No cloud/frontier probing; local endpoints only, nothing leaves the machine. +- No routing changes and no `ResilienceRouteInput` wiring (future M1 slice). +- No circuit breakers or degraded-mode states (future M1 slice). +- No background polling and no automatic discovery; probing is opt-in and + explicit against an operator-supplied URL. +- No ledger writes and no default artifact write location; only `--output` + writes an operator-named file. +- No new dependencies beyond the existing `requests`. +- No claim that the full telemetry design is implemented; only the probe slice + is built. + +## Description + +The runtime-strategy lane is otherwise deterministic. The CR-113 brief bounded a +future read-only probe as the point where that determinism boundary is crossed, +fixing the record shape, closed failure vocabulary, redaction rules, +evidence-tier provenance, opt-in posture, and reviewer path before any probe +code existed. CR-114 implements exactly that slice and nothing beyond it. + +The probe observes; it does not act. It never feeds routing, never mutates +state, and never invokes a model. `base_url` redaction is enforced as a +validation rule (secret-bearing URLs are rejected, not stored), path-like model +identifiers are dropped, `observed_models` is off by default, and every emitted +record passes the persistent privacy invariant. Fixture-tier records carry no +timestamp so deterministic exports stay byte-identical; only probe/recorded +tiers may carry `observed_at`. + +## Acceptance Criteria + +- [x] `probe_local_backend()` returns a metadata-only record and never invokes a + model endpoint. +- [x] Closed vocabularies are enforced for `source_type` (via the CLI choices + and an `unsupported_backend` record), `error_category`, and `evidence_tier`. +- [x] `base_url` is stored redacted as `scheme://host[:port]`; userinfo/query + URLs are rejected with an input error (exit `1`); paths are stripped. +- [x] `observed_models` is off by default; path-like identifiers are dropped + when names are requested. +- [x] Every emitted record passes `assert_persistent_privacy_safe`. +- [x] `tc probe` exits `0` for valid records (including `reachable=false` and + `probe_disabled`) and `1` for argument/validation errors. +- [x] `--output` is the only write path; there is no default artifact and no + ledger write. +- [x] Tests run offline (injected transport plus a closed local port), covering + each failure category, redaction/rejection, determinism, and the privacy + invariant. + +## Validation + +- `python -m pytest -q tests/test_local_backend_probe.py` +- `tc audit --privacy-invariants` (CR-021 invariant remains clean) +- `git diff --check` +- Manual (optional): `tc probe --source-type ollama --base-url http://localhost:11434` + against a live local backend. + +## Dependencies / Sequencing + +- Implements the CR-113 telemetry design brief's probe slice. +- Precedes the M1 slices that wire probe output into `ResilienceRouteInput`, + bind `local_fast`/`local_heavy` to distinct models, and add circuit breakers / + degraded modes. None of those are started here. diff --git a/docs/operations/local-backend-telemetry.md b/docs/operations/local-backend-telemetry.md index 961def3..8dc66ad 100644 --- a/docs/operations/local-backend-telemetry.md +++ b/docs/operations/local-backend-telemetry.md @@ -16,9 +16,16 @@ and privacy rules agreed before any probe code is written. ## Status -Design brief only. No telemetry code, CLI surface, schema module, or fixture -exists for this lane yet. Nothing in this document describes current -behavior; every statement below is future design intent. +Partially implemented. **CR-114 implements the read-only metadata probe slice** +only: the `triage_core/local_backend_probe.py` module and the `tc probe` command +produce metadata-only records against the endpoints below, with the closed +failure vocabulary, tiered evidence, and `base_url` redaction described here. + +Routing wiring, route-input population (`ResilienceRouteInput`), circuit +breakers, degraded modes, and any daily-driver enforcement **remain future +work** and are out of scope for CR-114. This document still describes the full +telemetry design; only the read-only probe slice is built. Nothing here should +be read as implying the whole telemetry design is implemented. ## Supported Future Sources diff --git a/tests/test_local_backend_probe.py b/tests/test_local_backend_probe.py new file mode 100644 index 0000000..1c2a1d1 --- /dev/null +++ b/tests/test_local_backend_probe.py @@ -0,0 +1,248 @@ +"""Offline tests for the read-only local backend metadata probe (CR-114). + +All tests run without a live backend. Network outcomes are exercised through an +injected transport or a closed local port; no test contacts a real model +endpoint, and no test writes to the real ledger. +""" +import json +import socket + +import pytest +import requests + +from triage_core.local_backend_probe import ( + ERROR_CATEGORIES, + LocalBackendProbeRecord, + ProbeInputError, + probe_local_backend, + redact_base_url, + render_probe_record, +) +from triage_core.privacy_invariants import find_forbidden_persistent_fields + + +# ---- transports (injected) ------------------------------------------------- + +def _ollama_ok(url, timeout): + return 200, {"models": [{"name": "qwen2.5-coder:7b"}, {"name": "llama3.1:8b"}]} + + +def _openai_ok(url, timeout): + return 200, {"data": [{"id": "local-model"}, {"id": "another-model"}]} + + +def _path_like_models(url, timeout): + return 200, { + "models": [ + {"name": "qwen2.5-coder:7b"}, + {"name": "/home/corey/models/private.gguf"}, + ] + } + + +def _malformed(url, timeout): + return 200, {"unexpected": "shape"} + + +def _timeout(url, timeout): + raise requests.exceptions.Timeout("timed out") + + +def _refused(url, timeout): + raise requests.exceptions.ConnectionError("connection refused") + + +def _blocked(url, timeout): + raise PermissionError("blocked by policy") + + +# ---- reachable / model counting ------------------------------------------- + +def test_reachable_ollama_counts_models_no_names_by_default(): + rec = probe_local_backend( + source_type="ollama", + base_url="http://localhost:11434", + transport=_ollama_ok, + ) + assert rec.reachable is True + assert rec.model_count == 2 + assert rec.observed_models is None # off by default + assert rec.error_category is None + assert rec.evidence_tier == "local_metadata_probe" + + +def test_openai_shape_backends_use_v1_models(): + rec = probe_local_backend( + source_type="lm_studio", + base_url="http://localhost:1234", + transport=_openai_ok, + ) + assert rec.reachable is True + assert rec.model_count == 2 + + +def test_include_model_names_drops_path_like_identifiers(): + rec = probe_local_backend( + source_type="ollama", + base_url="http://localhost:11434", + include_model_names=True, + transport=_path_like_models, + ) + assert rec.model_count == 2 + # The path-like identifier is dropped from the recorded names. + assert rec.observed_models == ["qwen2.5-coder:7b"] + + +# ---- fail-closed categories (all valid records, exit 0 territory) ---------- + +def test_unsupported_backend_is_a_record_not_an_error(): + rec = probe_local_backend( + source_type="vllm", # outside the closed vocabulary + base_url="http://localhost:8000", + transport=_openai_ok, + ) + assert rec.reachable is False + assert rec.error_category == "unsupported_backend" + + +def test_disabled_probe_emits_probe_disabled_record(): + rec = probe_local_backend( + source_type="ollama", + base_url="http://localhost:11434", + enabled=False, + transport=_ollama_ok, # must not be consulted + ) + assert rec.reachable is False + assert rec.error_category == "probe_disabled" + + +def test_timeout_category(): + rec = probe_local_backend( + source_type="ollama", base_url="http://localhost:11434", transport=_timeout + ) + assert rec.reachable is False + assert rec.error_category == "timeout" + + +def test_endpoint_unreachable_category(): + rec = probe_local_backend( + source_type="ollama", base_url="http://localhost:11434", transport=_refused + ) + assert rec.reachable is False + assert rec.error_category == "endpoint_unreachable" + + +def test_permission_or_policy_blocked_category(): + rec = probe_local_backend( + source_type="ollama", base_url="http://localhost:11434", transport=_blocked + ) + assert rec.reachable is False + assert rec.error_category == "permission_or_policy_blocked" + + +def test_malformed_response_category(): + rec = probe_local_backend( + source_type="ollama", base_url="http://localhost:11434", transport=_malformed + ) + assert rec.reachable is False + assert rec.error_category == "malformed_response" + + +def test_unreachable_against_closed_local_port_no_injection(): + # A genuinely closed local port: fail closed with no network fixture. + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + closed_port = sock.getsockname()[1] + sock.close() + rec = probe_local_backend( + source_type="ollama", + base_url=f"http://127.0.0.1:{closed_port}", + timeout=0.25, + ) + assert rec.reachable is False + assert rec.error_category in {"endpoint_unreachable", "timeout"} + + +# ---- base_url redaction / secret rejection -------------------------------- + +def test_base_url_redacts_path_to_scheme_host_port(): + assert redact_base_url("http://localhost:11434/api/tags") == "http://localhost:11434" + assert redact_base_url("http://localhost:11434/some/path") == "http://localhost:11434" + assert redact_base_url("http://localhost") == "http://localhost" + + +def test_base_url_with_userinfo_is_rejected(): + with pytest.raises(ProbeInputError): + redact_base_url("http://user:secret@localhost:11434") + + +def test_base_url_with_query_is_rejected(): + with pytest.raises(ProbeInputError): + redact_base_url("http://localhost:11434/v1/models?token=abc") + + +def test_probe_raises_input_error_for_secret_bearing_url(): + with pytest.raises(ProbeInputError): + probe_local_backend( + source_type="ollama", + base_url="http://user:pass@localhost:11434", + transport=_ollama_ok, + ) + + +# ---- privacy invariant / determinism -------------------------------------- + +def test_every_record_passes_persistent_privacy_invariant(): + rec = probe_local_backend( + source_type="ollama", + base_url="http://localhost:11434", + include_model_names=True, + transport=_ollama_ok, + ) + assert find_forbidden_persistent_fields(rec.to_dict()) == [] + + +def test_synthetic_fixture_tier_forbids_timestamp(): + # Fixture-tier records stay byte-identical: no observed_at allowed. + ok = LocalBackendProbeRecord( + source_type="ollama", + base_url="http://localhost:11434", + reachable=True, + evidence_tier="synthetic_fixture", + model_count=1, + ) + assert ok.observed_at is None + with pytest.raises(ValueError): + LocalBackendProbeRecord( + source_type="ollama", + base_url="http://localhost:11434", + reachable=True, + evidence_tier="synthetic_fixture", + model_count=1, + observed_at="2026-07-09T00:00:00+00:00", + ) + + +def test_error_category_vocabulary_is_closed(): + with pytest.raises(ValueError): + LocalBackendProbeRecord( + source_type="ollama", + base_url="http://localhost:11434", + reachable=False, + evidence_tier="local_metadata_probe", + error_category="something_new", + ) + assert "endpoint_unreachable" in ERROR_CATEGORIES + + +def test_render_is_readable_and_privacy_safe(): + rec = probe_local_backend( + source_type="ollama", + base_url="http://localhost:11434", + transport=_ollama_ok, + ) + text = render_probe_record(rec) + assert "source_type:" in text + assert "reachable:" in text + # Rendering must not leak forbidden content (it is built from to_dict()). + assert "secret" not in text.lower() diff --git a/triage_core/local_backend_probe.py b/triage_core/local_backend_probe.py new file mode 100644 index 0000000..79ccc37 --- /dev/null +++ b/triage_core/local_backend_probe.py @@ -0,0 +1,327 @@ +"""Read-only local backend metadata probe (CR-114). + +Metadata-only observations about local backend availability and model/runtime +identity. This module never invokes a model: no completions, chat, or +embeddings. It probes harmless metadata endpoints only: + + ollama -> /api/tags + lm_studio -> /v1/models + llama_cpp -> /v1/models + +Records are metadata-only and must pass the persistent privacy invariant. See +``docs/operations/local-backend-telemetry.md`` (design brief, CR-113) for the +bounding contract this slice implements. Routing wiring, route-input +population, circuit breakers, degraded modes, and daily-driver enforcement +remain future work. +""" +from __future__ import annotations + +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Callable, Dict, List, Optional, Tuple +from urllib.parse import urlsplit + +from .privacy_invariants import assert_persistent_privacy_safe + +SCHEMA_VERSION = "local_backend_probe_record.v1" + +SOURCE_TYPES = frozenset({"ollama", "lm_studio", "llama_cpp"}) +EVIDENCE_TIERS = frozenset( + {"synthetic_fixture", "local_metadata_probe", "operator_recorded"} +) +ERROR_CATEGORIES = frozenset( + { + "endpoint_unreachable", + "timeout", + "malformed_response", + "unsupported_backend", + "permission_or_policy_blocked", + "probe_disabled", + } +) + +# Metadata-only endpoints per source type. Never a generation endpoint. +METADATA_PATHS = { + "ollama": "/api/tags", + "lm_studio": "/v1/models", + "llama_cpp": "/v1/models", +} + +DEFAULT_TIMEOUT_SECONDS = 3.0 + +# Model identifiers that look like filesystem paths (may embed private paths). +_PATH_LIKE = re.compile(r"[\\/]") + +# Transport contract: callable(url, timeout) -> (status_code, parsed_json). +Transport = Callable[[str, float], Tuple[int, Any]] + + +class ProbeInputError(ValueError): + """Argument/validation error (secret-bearing or malformed base_url). + + Distinct from a fail-closed probe outcome: input errors are the operator's + to fix and map to CLI exit 1, whereas an unreachable/disabled backend is a + valid record (exit 0). + """ + + +@dataclass(frozen=True) +class LocalBackendProbeRecord: + source_type: str + base_url: Optional[str] # redacted: scheme://host[:port] + reachable: bool + evidence_tier: str + model_count: Optional[int] = None + observed_models: Optional[List[str]] = None + response_latency_ms: Optional[int] = None + error_category: Optional[str] = None + observed_at: Optional[str] = None + schema_version: str = SCHEMA_VERSION + + def __post_init__(self) -> None: + if self.evidence_tier not in EVIDENCE_TIERS: + raise ValueError(f"invalid evidence_tier: {self.evidence_tier}") + if ( + self.error_category is not None + and self.error_category not in ERROR_CATEGORIES + ): + raise ValueError(f"invalid error_category: {self.error_category}") + # Determinism: fixture-tier records must carry no timestamp. + if self.evidence_tier == "synthetic_fixture" and self.observed_at is not None: + raise ValueError("synthetic_fixture records must not carry observed_at") + if self.model_count is not None and self.model_count < 0: + raise ValueError("model_count must be non-negative") + + def to_dict(self) -> Dict[str, Any]: + record = { + "schema_version": self.schema_version, + "source_type": self.source_type, + "base_url": self.base_url, + "reachable": self.reachable, + "evidence_tier": self.evidence_tier, + "model_count": self.model_count, + "observed_models": ( + list(self.observed_models) + if self.observed_models is not None + else None + ), + "response_latency_ms": self.response_latency_ms, + "error_category": self.error_category, + "observed_at": self.observed_at, + } + # Every emitted record must pass the persistent privacy invariant. + assert_persistent_privacy_safe( + record, artifact_name="local backend probe record" + ) + return record + + +def redact_base_url(raw_url: str) -> str: + """Return ``scheme://host[:port]``; reject secret-bearing URLs; strip path. + + Redaction is a validation rule, not a display convention. Userinfo + (``user:pass@``) and query strings can smuggle credentials or tokens into + persisted evidence, so they are rejected outright. Path segments are + stripped rather than stored. + """ + parts = urlsplit(raw_url.strip()) + if not parts.scheme or not parts.hostname: + raise ProbeInputError("base_url must include a scheme and host") + if parts.username or parts.password: + raise ProbeInputError("base_url must not contain userinfo (credentials)") + if parts.query: + raise ProbeInputError("base_url must not contain a query string") + if parts.fragment: + raise ProbeInputError("base_url must not contain a fragment") + if parts.port is not None: + return f"{parts.scheme}://{parts.hostname}:{parts.port}" + return f"{parts.scheme}://{parts.hostname}" + + +def _sanitize_model_identifiers(names: List[str]) -> List[str]: + """Drop path-like identifiers, which can embed private filesystem paths.""" + return [name for name in names if not _PATH_LIKE.search(str(name))] + + +def _extract_models(source_type: str, payload: Any) -> Optional[List[str]]: + """Return reported model identifiers, or ``None`` if the shape is wrong.""" + if not isinstance(payload, dict): + return None + if source_type == "ollama": + models = payload.get("models") + if not isinstance(models, list): + return None + names = [m.get("name") for m in models if isinstance(m, dict)] + else: # lm_studio / llama_cpp -> OpenAI-compatible /v1/models + data = payload.get("data") + if not isinstance(data, list): + return None + names = [m.get("id") for m in data if isinstance(m, dict)] + return [str(n) for n in names if n is not None] + + +def _default_transport(url: str, timeout: float) -> Tuple[int, Any]: + import requests + + response = requests.get(url, timeout=timeout) + return response.status_code, response.json() + + +def _record( + *, + source_type: str, + base_url: str, + reachable: bool, + error_category: Optional[str] = None, + model_count: Optional[int] = None, + observed_models: Optional[List[str]] = None, + response_latency_ms: Optional[int] = None, + observed_at: Optional[str] = None, +) -> LocalBackendProbeRecord: + return LocalBackendProbeRecord( + source_type=source_type, + base_url=base_url, + reachable=reachable, + evidence_tier="local_metadata_probe", + model_count=model_count, + observed_models=observed_models, + response_latency_ms=response_latency_ms, + error_category=error_category, + observed_at=observed_at, + ) + + +def probe_local_backend( + *, + source_type: str, + base_url: str, + timeout: float = DEFAULT_TIMEOUT_SECONDS, + include_model_names: bool = False, + enabled: bool = True, + transport: Optional[Transport] = None, +) -> LocalBackendProbeRecord: + """Probe a local backend's metadata endpoint. Never invokes a model. + + Returns a metadata-only :class:`LocalBackendProbeRecord`. Network failures + fail closed with a closed ``error_category`` (never raw error text); this + function raises :class:`ProbeInputError` only for argument/validation + errors (secret-bearing or malformed ``base_url``). ``transport`` is + injectable for offline tests. + """ + import requests + + # Validate/redact the URL first so a bad URL never reaches the network. + redacted = redact_base_url(base_url) + + if not enabled: + return _record( + source_type=source_type, + base_url=redacted, + reachable=False, + error_category="probe_disabled", + ) + + if source_type not in SOURCE_TYPES: + return _record( + source_type=source_type, + base_url=redacted, + reachable=False, + error_category="unsupported_backend", + ) + + url = redacted.rstrip("/") + METADATA_PATHS[source_type] + observed_at = datetime.now(timezone.utc).isoformat() + send: Transport = transport if transport is not None else _default_transport + + start = time.time() + try: + status, payload = send(url, timeout) + except requests.exceptions.Timeout: + return _record( + source_type=source_type, + base_url=redacted, + reachable=False, + error_category="timeout", + observed_at=observed_at, + ) + except (requests.exceptions.ConnectionError, ConnectionError, OSError) as exc: + category = ( + "permission_or_policy_blocked" + if isinstance(exc, PermissionError) + else "endpoint_unreachable" + ) + return _record( + source_type=source_type, + base_url=redacted, + reachable=False, + error_category=category, + observed_at=observed_at, + ) + except (ValueError, requests.exceptions.RequestException): + # JSON decode failure or other response-shape problem. + return _record( + source_type=source_type, + base_url=redacted, + reachable=False, + error_category="malformed_response", + observed_at=observed_at, + ) + latency_ms = int((time.time() - start) * 1000) + + if status == 403: + return _record( + source_type=source_type, + base_url=redacted, + reachable=False, + error_category="permission_or_policy_blocked", + observed_at=observed_at, + response_latency_ms=latency_ms, + ) + + models = _extract_models(source_type, payload) + if models is None: + return _record( + source_type=source_type, + base_url=redacted, + reachable=False, + error_category="malformed_response", + observed_at=observed_at, + response_latency_ms=latency_ms, + ) + + observed_models = ( + _sanitize_model_identifiers(models) if include_model_names else None + ) + return _record( + source_type=source_type, + base_url=redacted, + reachable=True, + model_count=len(models), + observed_models=observed_models, + response_latency_ms=latency_ms, + observed_at=observed_at, + ) + + +def render_probe_record(record: LocalBackendProbeRecord) -> str: + """Render a probe record as a human-readable, privacy-safe summary.""" + fields = record.to_dict() + lines = [ + f"source_type: {fields['source_type']}", + f"base_url: {fields['base_url']}", + f"reachable: {fields['reachable']}", + f"evidence_tier: {fields['evidence_tier']}", + ] + if fields["model_count"] is not None: + lines.append(f"model_count: {fields['model_count']}") + if fields["observed_models"] is not None: + lines.append(f"observed_models: {', '.join(fields['observed_models'])}") + if fields["response_latency_ms"] is not None: + lines.append(f"response_latency_ms: {fields['response_latency_ms']}") + if fields["error_category"] is not None: + lines.append(f"error_category: {fields['error_category']}") + if fields["observed_at"] is not None: + lines.append(f"observed_at: {fields['observed_at']}") + return "\n".join(lines) diff --git a/triage_core/tc_cli.py b/triage_core/tc_cli.py index 8834ae5..e174ae2 100644 --- a/triage_core/tc_cli.py +++ b/triage_core/tc_cli.py @@ -1137,6 +1137,53 @@ def tc_run(args, client=None) -> None: sys.exit(3) +def tc_probe(args) -> None: + """Read-only local backend metadata probe (CR-114). + + Probes a metadata-only endpoint of a local backend and renders a + privacy-safe record. Never invokes a model; writes no ledger evidence and + creates no artifact unless ``--output`` names one. + + Exit codes: + 0 the probe produced a valid metadata record (including reachable=false + and probe_disabled records) + 1 argument / input / validation error (e.g. a secret-bearing base_url) + """ + from triage_core.local_backend_probe import ( + ProbeInputError, + probe_local_backend, + render_probe_record, + ) + + timeout = args.timeout if args.timeout is not None else 3.0 + try: + record = probe_local_backend( + source_type=args.source_type, + base_url=args.base_url, + timeout=timeout, + include_model_names=args.include_model_names, + enabled=not args.disabled, + ) + rendered = render_probe_record(record) + except ProbeInputError as exc: + print(f"Error: {exc}") + sys.exit(1) + + print(rendered) + + if args.output: + out_dir = os.path.dirname(args.output) + try: + if out_dir: + os.makedirs(out_dir, exist_ok=True) + with open(args.output, "w", encoding="utf-8") as handle: + json.dump(record.to_dict(), handle, indent=2) + except OSError as exc: + print(f"Error writing output to {args.output}: {exc}") + sys.exit(1) + print(f"Record written to {args.output}.") + + def tc_status(): print("TriageCore Status\n") @@ -2003,6 +2050,36 @@ def main(): help="Do not write any evidence record to the ledger (prints a warning)", ) + # probe + probe_parser = subparsers.add_parser( + "probe", + help="Read-only local backend metadata probe (no model calls, no ledger writes)", + ) + probe_parser.add_argument( + "--source-type", required=True, choices=["ollama", "lm_studio", "llama_cpp"], + help="Local backend type to probe", + ) + probe_parser.add_argument( + "--base-url", required=True, type=str, + help="Operator-supplied local endpoint (stored redacted as scheme://host[:port])", + ) + probe_parser.add_argument( + "--timeout", type=float, default=None, + help="Bounded metadata-request timeout in seconds", + ) + probe_parser.add_argument( + "--include-model-names", action="store_true", + help="Include reported model identifiers (off by default; path-like ids are dropped)", + ) + probe_parser.add_argument( + "--disabled", action="store_true", + help="Do not contact the endpoint; emit a probe_disabled record", + ) + probe_parser.add_argument( + "--output", type=str, default=None, + help="Write the rendered record to this operator-named file (no default write location)", + ) + # audit audit_parser = subparsers.add_parser("audit", help="Inspect ledger audit events safely") audit_parser.add_argument("--kind", type=str, default="route_audit", help="The event_type to filter by (default: route_audit)") @@ -2648,6 +2725,8 @@ def main(): tc_status() elif args.command == "run": tc_run(args) + elif args.command == "probe": + tc_probe(args) elif args.command == "doctor": tc_doctor() elif args.command == "identity":