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.
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
# 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.
# 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
# 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.
# 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.
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
- 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
FakeTTSsatisfies an abstract property with the plain assignmentsample_rate = 22050. - I can hand a provider to a core that expects callables, and say why
stt.transcribepasses whiletts.synthesizeneeds 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.
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.