GLaDOS Vol 5 · Alive on the Bench
ch 44 / 99
Chapter 44

Provider contracts

The wiring file trusts every object you hand it

This volume keeps taking her apart on the bench. A watchdog notices when the loop stops breathing, hardware tests write down what a servo was supposed to do before it does it, and the seam tests from last chapter feed one module's output straight into the next. All three assume the same thing: that you can pull a part out, drop a different one in, and run again. That assumption is where this chapter starts, because the file that builds her parts checks nothing whatsoever about them.

The swaps are real ones. Piper speaks a sentence in a fraction of a second and sounds like a polite stranger, so it is what you want while you run the same test forty times; F5-TTS clones her actual voice and takes long enough that you do not want it inside a loop you are still editing. faster-whisper today, some other recognizer the week you go hunting for latency. Every engine arrives as a class with its own constructor, its own method names and its own habits, and the core from chapter 32 accepts any of them without comment, since what it holds is three plain callables and the only question it ever asks is whether the call comes back.

That was the right question while a collaborator was one function. A provider on the bench is more. The speech engine has to synthesize, and it also has to tell the playback code what rate its audio comes out at, and by the time she moves to the Jetson it will need a way to give the GPU back when she goes idle. Three obligations, and a Callable alias can describe exactly one of them. Leave the second one out and nothing objects until the first line that needs it runs, which will be during a conversation, on the machine in the hallway, at the hour you were not watching.

So the rule for every swappable part from here on: a provider's obligations are declared in one abstract base class, and Python refuses to build a provider that has not met them. Not written in a comment, not caught in review. Refused, with a TypeError, on the line that tries to construct the thing.

◆ Note — what this costs, measured against chapter 32's aliases

Chapter 32 chose a type alias over an abstract base class on purpose, and that argument still holds where it was made. Callable[[str], str] describes the seam between the core and whatever fills it, and it lets a lambda, a closure over a loaded model and a bound method all pass without inheriting anything. Two limits are what changed. An alias cannot mention a second obligation, and it constrains nobody while the program runs, because annotations are notes to readers and type checkers that Python itself skips. The base class is the heavier instrument, and the weight is real: a provider now has to be a class, and it has to inherit from yours, so a vendor engine you cannot subclass needs a wrapper. Both tools survive in the finished system. The base class governs the provider, the alias governs the seam, and a bound method is the joint between them.

Declare the obligations, then satisfy them

▣ Build · stage 1 — one contract, and the refusal it buys
# glados/providers.py
from abc import ABC, abstractmethod


class STTProvider(ABC):
    @abstractmethod
    def transcribe(self, audio_path: str, language: str = "en") -> str:
        """Return the words spoken in the audio file at audio_path."""


if __name__ == "__main__":
    class FakeSTT(STTProvider):
        def transcribe(self, audio_path: str, language: str = "en") -> str:
            return "Are you still pretending to be helpful?"

    print("STTProvider needs:", STTProvider.__abstractmethods__)
    print("FakeSTT needs:    ", FakeSTT.__abstractmethods__)
    print(FakeSTT().transcribe("glados/data/heard.wav"))
    try:
        STTProvider()
    except TypeError as exc:
        print(f"TypeError: {exc}")
$ uv run python -m glados.providers
STTProvider needs: frozenset({'transcribe'})
FakeSTT needs:     frozenset()
Are you still pretending to be helpful?
TypeError: Can't instantiate abstract class STTProvider with abstract method transcribe

The two frozenset lines are the whole mechanism, printed. @abstractmethod records a name on the class; a subclass that overrides the name drops it from the set; a class whose set is still occupied cannot be turned into an object. Note the body of transcribe: a docstring is a legal function body, so no pass and no ellipsis is needed, and that sentence carries what the signature cannot say, which is what the argument means and what the return value is supposed to contain. FakeSTT answers with a fixed line and loads nothing, so the contract's first proof is that something cheap can meet it.

▣ Build · stage 2 — the other two, and an obligation that is not a method
# glados/providers.py — the three contracts
class LLMProvider(ABC):
    @abstractmethod
    def chat(self, text: str, history: list[dict]) -> str:
        """Return her reply to text, given the conversation so far."""


class TTSProvider(ABC):
    @property
    @abstractmethod
    def sample_rate(self) -> int:
        """The rate, in hertz, of the audio this engine writes."""

    @abstractmethod
    def synthesize(self, text: str, output_path: str) -> None:
        """Write spoken audio for text to output_path."""


# glados/fakes.py
from glados.providers import LLMProvider, STTProvider, TTSProvider


class FakeSTT(STTProvider):
    def transcribe(self, audio_path: str, language: str = "en") -> str:
        return "Are you still pretending to be helpful?"


class FakeLLM(LLMProvider):
    def chat(self, text: str, history: list[dict]) -> str:
        return f"I never pretended. ({len(history)} messages remembered)"


class FakeTTS(TTSProvider):
    sample_rate = 22050

    def synthesize(self, text: str, output_path: str) -> None:
        print(f"[TTS {self.sample_rate} Hz] {text[:30]}... -> {output_path}")
$ uv run python -m labs.fake_stack
TTSProvider needs: ['sample_rate', 'synthesize']
FakeTTS needs:     []
rate: 22050 Hz
[TTS 22050 Hz] I never pretended. (0 messages... -> glados/data/glados_says.wav

TTSProvider is the case an alias could not carry: two obligations, and one of them is a value, not a call. Stacking @property under @abstractmethod puts sample_rate in the same required set as synthesize, and FakeTTS satisfies it with a plain class attribute, because the check asks whether an attribute of that name exists and is no longer marked abstract, never whether it is a property. Callers write tts.sample_rate either way, so a fake can answer with a constant while a real engine computes the number from the model it loaded. The demo prints the required names through sorted(), since a set of strings has no order you can rely on between runs. And 22,050 is the number chapter 3 read off a Piper chunk. She listens at 16 kHz and speaks at 22,050 Hz, and the rate is now a question you can ask the object instead of a fact the playback code has to remember.

Bound methods fit the core exactly as it is

▣ Build · stage 3 — three providers into a core that never heard of them
# labs/wire_providers.py
from glados.core import GladOSCore
from glados.fakes import FakeLLM, FakeSTT, FakeTTS
from glados.providers import LLMProvider, STTProvider, TTSProvider

AUDIO_OUT = "glados/data/glados_says.wav"


def compose(stt: STTProvider, llm: LLMProvider, tts: TTSProvider) -> GladOSCore:
    def speak(text: str) -> None:
        tts.synthesize(text, AUDIO_OUT)

    return GladOSCore(stt=stt.transcribe, llm=llm.chat, tts=speak)


if __name__ == "__main__":
    core = compose(FakeSTT(), FakeLLM(), FakeTTS())
    core.run_turn("glados/data/heard.wav")
    core.run_turn("glados/data/heard.wav")
    print(f"history holds {len(core.history)} messages")
$ uv run python -m labs.wire_providers
You: Are you still pretending to be helpful?
GLaDOS: I never pretended. (0 messages remembered)
[TTS 22050 Hz] I never pretended. (0 messages... -> glados/data/glados_says.wav
You: Are you still pretending to be helpful?
GLaDOS: I never pretended. (2 messages remembered)
[TTS 22050 Hz] I never pretended. (2 messages... -> glados/data/glados_says.wav
history holds 4 messages

Two full turns ran, and glados/core.py was not edited to allow it. stt.transcribe is a bound method: the instance is already attached, so what compose hands over is a callable taking one string and returning one string, which is what the core's alias asked for. llm.chat lines up the same way. The voice is the one that needs an adapter, because the core tells its speaker some text and nothing else, while synthesize also wants a destination; the closure supplies the path and the provider keeps the ability to write anywhere, which the tests that read a file back are going to want. The startup probes from chapter 33 go on working untouched for the same reason, since what they probe is still three callables on the core.

▣ Build · stage 4 — real engines, chosen by a string in the registry
# labs/wire_providers.py — full file
import json
import wave
from pathlib import Path

import ollama
from f5_tts.api import F5TTS
from faster_whisper import WhisperModel
from piper.voice import PiperVoice

from glados.core import GladOSCore
from glados.providers import LLMProvider, STTProvider, TTSProvider
from labs.system_config import SystemConfig, build_default_config

AUDIO_OUT = "glados/data/glados_says.wav"
from labs.glados_voice import REF_FILE, REF_TEXT


class WhisperSTT(STTProvider):
    def __init__(self, model: str, device: str, compute_type: str) -> None:
        self.engine = WhisperModel(model, device=device, compute_type=compute_type)

    def transcribe(self, audio_path: str, language: str = "en") -> str:
        segments, _ = self.engine.transcribe(audio_path, language=language)
        return " ".join(s.text.strip() for s in segments)


class OllamaLLM(LLMProvider):
    def __init__(self, model: str, system_prompt: str) -> None:
        self.model = model
        self.system_prompt = system_prompt

    def chat(self, text: str, history: list[dict]) -> str:
        messages = [{"role": "system", "content": self.system_prompt}]
        messages += history + [{"role": "user", "content": text}]
        return ollama.chat(model=self.model, messages=messages)["message"]["content"]


class PiperTTS(TTSProvider):
    def __init__(self, voice_model: str) -> None:
        self.voice = PiperVoice.load(Path(voice_model))

    @property
    def sample_rate(self) -> int:
        return self.voice.config.sample_rate

    def synthesize(self, text: str, output_path: str) -> None:
        chunks = list(self.voice.synthesize(text))
        with wave.open(output_path, "wb") as out:
            out.setnchannels(chunks[0].sample_channels)
            out.setsampwidth(chunks[0].sample_width)
            out.setframerate(chunks[0].sample_rate)
            out.writeframes(b"".join(c.audio_int16_bytes for c in chunks))


class F5TTSProvider(TTSProvider):
    sample_rate = 24000

    def __init__(self, ref_file: str, ref_text: str, device: str | None = None) -> None:
        self.engine = F5TTS(device=device)
        self.ref_file = ref_file
        self.ref_text = ref_text

    def synthesize(self, text: str, output_path: str) -> None:
        self.engine.infer(ref_file=self.ref_file, ref_text=self.ref_text,
                          gen_text=text, file_wave=output_path, remove_silence=True)


def build_tts(settings: dict) -> TTSProvider:
    if settings.get("engine", "piper") == "f5":
        return F5TTSProvider(REF_FILE, REF_TEXT, device=settings.get("device"))
    return PiperTTS(f"configs/{settings['voice']}.onnx")


def build_core(config: SystemConfig) -> GladOSCore:
    stt_cfg = config.get_component("stt").settings
    llm_cfg = config.get_component("llm").settings
    tts_cfg = config.get_component("tts").settings
    persona = json.loads(Path("configs/personality.json").read_text())

    stt = WhisperSTT(stt_cfg["model"], stt_cfg["device"], stt_cfg["compute_type"])
    llm = OllamaLLM(llm_cfg["model"], persona["system_prompt"])
    tts = build_tts(tts_cfg)
    print(f"voice: {type(tts).__name__} writing {tts.sample_rate} Hz audio")

    def speak(text: str) -> None:
        tts.synthesize(text, AUDIO_OUT)

    return GladOSCore(stt=stt.transcribe, llm=llm.chat, tts=speak)


if __name__ == "__main__":
    build_core(build_default_config()).run_turn("glados/data/heard.wav")
$ uv run python -m labs.wire_providers
voice: PiperTTS writing 22050 Hz audio
You: Are you still pretending to be helpful?
GLaDOS: I have never pretended. You simply assumed.
$ uv run python -m labs.wire_providers   # after "engine": "f5" in configs/system_config.json
voice: F5TTSProvider writing 24000 Hz audio
You: Are you still pretending to be helpful?
GLaDOS: Pretending would require effort. This is just my voice.

Those two runs were captured on the bench, and yours will read differently: the model writes a new reply every time, and your transcript depends on what you said into the microphone. What does not vary is the first line of each run. One JSON edit moved her from a fast local voice to her cloned one, and the only Python that noticed was build_tts. Look closely at what is not interchangeable there: the constructors. PiperTTS wants a path to an .onnx voice while F5TTSProvider wants a reference clip and its transcript, so the two classes are substitutable at every call site and at none of the construction sites. That is exactly why a branch on the engine name lives in one small function, above the line where both results become a TTSProvider and the difference stops mattering. The "engine" key is a new setting on the tts component in the registry from chapter 31, which is the payoff that registry was built for.

Why this works: a set of names, checked at construction

Inheriting from ABC gives a class the ABCMeta metaclass, and ABCMeta runs once per class, at the moment the class body finishes executing. It walks the names the class declares abstract plus the ones it inherited, drops any the class overrode, and stores what is left in __abstractmethods__. Constructing an object then costs one extra test: if that set is not empty, TypeError, before __init__ ever runs. Import-time work to build the set, construction-time work to consult it, and nothing at all per call.

The timing is the reason to bother. Construction happens in the one function that assembles her, at startup, which is where the health report of chapter 33 already lives. Those two gates ask different questions in the same second. The probe asks whether a wired component actually works. The contract asks whether the component was ever allowed to exist. Push obligations to whichever gate can answer earliest and be honest about it, and the class of bug that used to end a conversation turns into a machine that will not start.

⚠ Worked failure — the provider that would not build

You write the F5-TTS provider, your fingers spell the method the British way, and the class looks entirely finished:

class F5TTSProvider(TTSProvider):
    sample_rate = 24000

    def __init__(self, ref_file: str, ref_text: str) -> None:
        self.engine = F5TTS()
        self.ref_file = ref_file
        self.ref_text = ref_text

    def synthesise(self, text: str, output_path: str) -> None:   # BUG: not the contract's name
        self.engine.infer(ref_file=self.ref_file, ref_text=self.ref_text,
                          gen_text=text, file_wave=output_path, remove_silence=True)
$ uv run python -m labs.wire_providers
Traceback (most recent call last):
  File "/home/you/GladOS/labs/wire_providers.py", line 98, in <module>
    build_core(build_default_config()).run_turn("glados/data/heard.wav")
  File "/home/you/GladOS/labs/wire_providers.py", line 88, in build_core
    tts = build_tts(tts_cfg)
          ^^^^^^^^^^^^^^^^^^
  File "/home/you/GladOS/labs/wire_providers.py", line 74, in build_tts
    return F5TTSProvider(REF_FILE, REF_TEXT)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: Can't instantiate abstract class F5TTSProvider with abstract method synthesize

The method is present and the code inside it is correct. It simply is not named synthesize, so nothing ever overrode that entry and it stayed in the required set. Read the message and it tells you the class, the missing name and the construction site; fix the spelling and the class becomes buildable. Now the honest limit of the tool. The contract checks names and nothing else. Declare def synthesize(self, text), with the destination argument forgotten, and the object builds without complaint and fails later at the call with TypeError: synthesize() takes 2 positional arguments but 3 were given. Signatures are the type checker's department, and running mypy over labs/ catches that second mistake at the desk. The annotations on every abstract method are what make that possible, so write them even though Python will not read them.

Checkpoint, and a way to time what you just made swappable

✓ Checkpoint — what you can now do
  • I can print __abstractmethods__ on a base and on a subclass and say what the difference between the two sets predicts.
  • I can explain why FakeTTS satisfies an abstract property with the plain assignment sample_rate = 22050.
  • I can hand a provider to a core that expects callables, and say why stt.transcribe passes while tts.synthesize needs a wrapper.
  • I know what the contract refuses to check, and which tool catches a method whose name is right and whose arguments are wrong.
  • Given two engines with different constructors, I can say where the branch between them belongs and why substitutability says nothing about that line.
  • I can swap her voice engine by editing one string in configs/system_config.json.
⚡ Exercises — try first, then reveal
Exercise 1 — make the refusal happen to you. Write a BrokenLLM(LLMProvider) that implements complete(prompt) and no chat, then construct it inside a try/except TypeError and print what comes back.

The printed line is TypeError: Can't instantiate abstract class BrokenLLM with abstract method chat, and the useful part of the exercise is where it fired: on the construction line, with the class body long since executed and no call to complete anywhere. Then delete the inheritance, so BrokenLLM is a plain class, and hand it to compose. It builds, it wires, and it dies at the first turn with AttributeError: 'BrokenLLM' object has no attribute 'chat'. Same mistake, two very different moments to learn about it.

Exercise 2 — accept an engine you cannot subclass. A vendor ships a class with a correct synthesize that inherits nothing of yours. Use typing.Protocol with @runtime_checkable to accept it, and print the isinstance result.

Declare the same method on a Protocol subclass, decorate it @runtime_checkable, and isinstance(VendorTTS(), Speaks) prints True with no inheritance anywhere: a protocol matches on the members an object has, which is the tool for code you do not own. Two things to carry away. The runtime check looks only at member names, so it is weaker than the base class it resembles, and it can never refuse to build anything, since the vendor's class is not yours to gate. The alternative is a five-line VendorAdapter(TTSProvider) that holds the vendor object and forwards to it, which costs a file and gets you the refusal back.

Exercise 3 — teach the startup gate to read the contract. Extend the voice probe from chapter 33 so it fails when the configured rate and the engine's actual rate disagree, then run it against a core built with "engine": "f5" while the registry still says 22050.

Hand the probe the provider as well as the core, and add one comparison: if tts.sample_rate != settings["sample_rate"], return a failing CheckResult reading something like configured 22050 Hz, engine writes 24000 Hz. Left unchecked this mismatch is not a crash at all, it is her voice played back too slow, and you would spend the evening suspecting the speaker. The general move is the one worth keeping: once a component can answer questions about itself, the startup gate gets to cross-examine it against the configuration instead of trusting both.

Every provider on the bench can now be swapped without the core noticing, and refused before it runs if it was built wrong. What you cannot yet see is what a swap costs. When the recognizer changes, does a turn get faster or slower, and by how much? Next chapter builds a small harness that runs any function, records whether its assertions held, times it to the microsecond and writes the run to JSON, so the answer to that question is a file you can compare against last week's.