"""Model binding value type shared by the compiler and the engine.
:class:`InferenceTarget` lives in its own module rather than in
``loom.ai.config`` because it is a value type of the pillar: the compiler
embeds it in the plan and the engine consumes it, so it is not a
config-parsing detail.
Secret containment (data-model invariant 4, FR-018): ``credentials_ref`` and
``options`` never leave the process in clear text. The mechanism is
fail-closed — on construction both values are rewrapped into types msgspec
refuses to encode, so ``msgspec.json.encode`` (and ``msgspec.to_builtins``,
``msgspec.msgpack.encode``) raise ``TypeError`` instead of leaking them, and
``repr``/``str`` redact them. Rejecting the encode was chosen over silent
redaction because a plan that reaches a wire encoder with a secret reference
aboard is a bug worth surfacing, not smoothing over. Decoding through the
config loader is unaffected: ``msgspec.convert`` builds the struct from plain
values and ``__post_init__`` wraps them afterwards.
"""
from __future__ import annotations
from collections.abc import Iterator, Mapping
from typing import Any, Final, Literal, get_args
from msgspec import field, structs
from loom.core.model import LoomFrozenStruct
OutputMode = Literal["tool", "native"]
"""How the engine asks the model for the structured answer.
``prompted`` is deliberately absent: the engine strips markdown fences before
validating a prompted answer while loom decodes the raw text part, so a fenced
answer would pass the engine and fail loom.
This is the single source of truth for the set: adding a member here makes
every exhaustive dispatch over it (``_spec.build_output_type``) fail type
checking until it handles the new mode, instead of degrading to a default at
run time.
"""
OUTPUT_MODES: Final[tuple[OutputMode, ...]] = get_args(OutputMode)
"""Values ``InferenceTarget.output_mode`` accepts, derived from :data:`OutputMode`."""
_REDACTED: Final = "<redacted>"
"""Placeholder shown in place of a secret-bearing value (FR-018)."""
class _RedactedRef(str):
"""Secret reference that redacts itself and refuses msgspec encoding.
A ``str`` subclass behaves as the reference everywhere in Python, while
msgspec rejects encoding non-exact ``str`` types, which is exactly the
fail-closed behaviour invariant 4 requires.
"""
__slots__ = ()
def __repr__(self) -> str:
return _REDACTED
class _RedactedOptions(Mapping[str, Any]):
"""Options mapping that redacts itself and refuses msgspec encoding.
msgspec encodes ``dict`` but not arbitrary mappings, so wrapping the
decoded ``dict`` makes any direct encode of the struct raise instead of
leaking vendor settings.
"""
__slots__ = ("_raw",)
def __init__(self, raw: Mapping[str, Any]) -> None:
self._raw: dict[str, Any] = dict(raw)
def __getitem__(self, key: str) -> Any:
return self._raw[key]
def __iter__(self) -> Iterator[str]:
return iter(self._raw)
def __len__(self) -> int:
return len(self._raw)
def __eq__(self, other: object) -> bool:
if isinstance(other, _RedactedOptions):
return self._raw == other._raw
if isinstance(other, Mapping):
return self._raw == dict(other)
return NotImplemented
def __repr__(self) -> str:
return _REDACTED
[docs]
class InferenceTarget(LoomFrozenStruct, frozen=True, kw_only=True):
"""One resolved model binding for a model role (``ai.models.<role>``).
``repr``/``str`` show ``provider``, ``model``, ``region``, ``endpoint``
and ``output_mode``
but never the values of ``credentials_ref`` or ``options`` — the plan
carries this struct, so an unredacted repr in a start-up traceback is the
concrete leak path. Encoding the struct with msgspec raises when either
secret-bearing field is set (see the module docstring for the rationale).
Attributes:
provider: Provider identifier (``bedrock``, ``openai``, ...).
model: Vendor model id.
region: Region for regional providers such as Bedrock.
endpoint: Gateway or compatible endpoint URL.
output_mode: How the engine asks the model for the structured
answer (``tool`` or ``native``, see :data:`OutputMode`).
``None`` leaves the choice to the engine. Typed ``str`` rather
than :data:`OutputMode` because msgspec validates a ``Literal``
during the decode, before ``__post_init__``: an unknown value
would surface as a raw ``ValidationError`` instead of the
``OUTPUT_MODE_UNKNOWN`` issue naming the role. The set is
enforced by ``loom.ai.config._validate_model_binding``.
credentials_ref: Reference resolved by the existing secrets resolver.
Never a literal secret (FR-018).
options: Vendor-specific settings. Confined here; never reaches the
artifact.
"""
provider: str
model: str
region: str | None = None
endpoint: str | None = None
output_mode: str | None = None
credentials_ref: str | None = None
options: Mapping[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.credentials_ref is not None:
structs.force_setattr(self, "credentials_ref", _RedactedRef(self.credentials_ref))
if self.options:
structs.force_setattr(self, "options", _RedactedOptions(self.options))
def __repr__(self) -> str:
credentials = _REDACTED if self.credentials_ref is not None else None
options = _REDACTED if self.options else "{}"
return (
f"InferenceTarget(provider={self.provider!r},"
f" model={self.model!r},"
f" region={self.region!r},"
f" endpoint={self.endpoint!r},"
f" output_mode={self.output_mode!r},"
f" credentials_ref={credentials},"
f" options={options})"
)
__str__ = __repr__