You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
As a contributor picking up the audio milestone, I want the TTS provider matrix (env keys, SDK names, model/voice defaults, licensing) to already exist as a tested data table with resolution rules, so that whoever wires real synthesis writes audio code instead of re-litigating env-var and model-name policy that llm.py already settled.
What
First slice of #113. One new module + one new test file. No audio is produced, no network is touched, no dependency is added. The module is deliberately unwired — nothing imports it when this lands.
resolve_tts_backend(name: str | None) -> str — mirrors llm.resolve_backend (llm.py:221-244): an explicit name whose key_env is unset is a loud error naming the variable; "auto" picks the first provider (deterministic table order) whose key_env is set; nothing available is a loud error listing the options.
resolve_tts_model / resolve_tts_voice — mirror _provider_model (llm.py:333-355). Order: explicit value (litellm-style <provider>/ prefix stripped) → model_env/voice_env → spec.default_model/spec.default_voice → loud error naming the flag, the env var, and models_url.
check_tts_sdk(backend) / check_tts_model(backend, model) — preflight-shaped, same three-case structure as check_sdk (llm.py:160-195): absent → install hint; importable but raises → quote the real ImportError; importable but missing the sentinel attribute → upgrade hint. check_tts_model is a no-op for backends with no spec, exactly like check_model (llm.py:198-208).
tests/test_tts_providers.py covering all of the above against fixtures/monkeypatched env.
Two policy decisions to encode (both are the point of the issue, not incidental):
No hardcoded default_model for the cloud providers.llm.PROVIDERS sets default_model only for Anthropic; the comment at llm.py:110-113 explains why: "Google and OpenAI retire model names with hard 404s, so guessing is a loud error instead (a --gemini run once died in ingest on a stale hardcoded default)." Same rule here. default_voiceis set per provider — a voice is a taste default the operator should not have to supply.
any change to pyproject.toml (no audio extra, no new dependency — spec.extra is a string used to build the install hint; the extra gets declared by the slice that actually needs the SDK)
any Config field or CLI flag (Config is extra="forbid" at config.py:22, so adding a field is a public-surface change that belongs with the feature that uses it)
wiring into cli.py preflight, prompts, templates, docs, README
The resolution rules being ported are not style preferences — they are encoded incidents. Failure class 15 in CLAUDE.md and the --gemini 404 both produced the "never guess a model name" and "preflight, don't die in-stage" rules that check_sdk/check_model implement. Porting them as data means the audio work inherits the fixes instead of rediscovering them.
Grounding implication: none. This slice touches no grounding-path module (distill.py, tutorial.py, normalize.py, engines/, prompts/, templates/) and adds no code path that can write tutorial.md, step_by_step.md, commands.sh, or demo.tape. It is a leaf module with no importers. Reviewers should confirm that stays true — a TTS module has no business reading or writing a published artifact.
Pointers
All line numbers verified by reading the files at 64a3cbd.
src/readme2demo/llm.py:93-116 — ProviderSpec, the dataclass to mirror. Field comment at :108: model_prefixes: tuple[str, ...] # names recognized as this provider's models.
src/readme2demo/llm.py:118-140 — the PROVIDERS table itself (3 entries). Note llm.py:138: default_model="claude-sonnet-5", # keep in sync with Config.model — Anthropic is the only entry with a default.
src/readme2demo/llm.py:144-148 — _BACKEND_KEY_ENVS, the "which backends need a key at resolve time" map, with the comment "fail in preflight, not mid-run".
src/readme2demo/llm.py:154-157 — _BACKEND_SDKS, {backend: (import name, pip name, sentinel attribute)}. The TTS spec should carry this per-entry rather than as a side table (there are only 2-3 entries and the side table adds a lookup that can drift).
src/readme2demo/llm.py:160-195 — check_sdk, the three-branch structure to copy verbatim in shape.
src/readme2demo/llm.py:198-208 — check_model; the body is just spec = PROVIDERS.get(backend) then _provider_model(spec, model) when non-None.
src/readme2demo/llm.py:333-355 — _provider_model. Two behaviors to port: prefix stripping at :345-346, and the config-default leak guard at :347 — if not name or (spec.name != "anthropic" and name.startswith("claude")):. The TTS analog is broader: a name matching another spec's model_prefixes counts as "not specified", because the same leak will happen the moment a shared config field feeds both providers.
src/readme2demo/cli.py:64-66 — the current consumer of model_prefixes (deciding whether the token after a bare --gemini is a model name or a positional). Worth knowing: in this slice model_prefixes has no CLI consumer yet; its only in-slice consumer is the cross-provider leak guard above. That is fine and intentional — the flag arrives with the feature.
src/readme2demo/cli.py:565-574 — where LLM preflight runs (llm.check_sdk(backend) / llm.check_model(backend, cfg.model)), preceded by the comment explaining that "a --openai run once burned its ingest stage on an ImportError instead." This is where a future slice hooks check_tts_*; do not add the hook here.
tests/test_llm_backend.py:467-501 — the exact test patterns to mirror: monkeypatch.setitem(sys.modules, "openai", ModuleType("openai")) for too-old, monkeypatch.setattr("readme2demo.llm.importlib.import_module", boom) for broken-import, monkeypatch.delenv/setenv for the model-resolution cases.
pyproject.toml:46-52 — the optional-extras block (gemini, openai). Read for context; leave unchanged.
Acceptance criteria
src/readme2demo/tts.py exists with TTSProviderSpec (frozen dataclass), TTS_PROVIDERS, resolve_tts_backend, resolve_tts_model, resolve_tts_voice, check_tts_sdk, check_tts_model, and a module-level TTSError.
TTS_PROVIDERS has entries for openai, elevenlabs, and local; local is marked noncommercial=True with its license named in the spec.
Neither cloud provider carries a default_model; both carry a default_voice.
resolve_tts_backend("auto") returns the first key-bearing cloud provider and never returns a noncommercial=True backend; a test asserts this with every key set.
resolve_tts_backend("elevenlabs") with ELEVENLABS_API_KEY unset raises TTSError naming the variable.
Model resolution honors explicit → env → spec default → error, strips a <provider>/ prefix, and treats a name matching a different provider's model_prefixes as unspecified.
Every "not specified" error names the env var and, when models_url is set, links it — matching llm.py:349-354.
check_tts_sdk distinguishes absent / broken-import / too-old with three different messages; the install hint is built from spec.extra, never hardcoded.
check_tts_model is a no-op for an unknown backend.
No new entry in pyproject.toml; no Config field; no CLI flag; no import of tts.py from any other module.
Public functions have type hints and docstrings; ruff check clean; full suite green.
Notes for contributors
This is a genuinely self-contained first issue. Pure Python, pure data. No Docker, no API key, no network, no LLM call — nothing in this slice can reach a provider even if you wanted it to. You do not need to understand the sandbox, the agent, or the grounding pipeline to land it.
pip install -e ".[dev]"
python -m pytest tests/ -q # whole suite, well under 2s — no Docker, no network, no API key
ruff check src/ tests/
The single most useful preparation is to open src/readme2demo/llm.py and read lines 93-208 and 333-355 end to end before writing anything. This issue is deliberately a mirror of that code; if your module reads like a sibling of llm.py, it is right. Where you find yourself inventing a new pattern, prefer copying the existing one and note the divergence in your PR description.
Two things reviewers will look for: that the error messages are actionable (name the flag AND the env var — see llm.py:351-354 for the tone), and that no default model name for a cloud provider is hardcoded anywhere.
User story
What
First slice of #113. One new module + one new test file. No audio is produced, no network is touched, no dependency is added. The module is deliberately unwired — nothing imports it when this lands.
In scope —
src/readme2demo/tts.py:TTSProviderSpec— frozen dataclass, mirroringProviderSpec(llm.py:93-116):name,title,key_env,model_env,voice_env,model_prefixes,sdk(import module, pip name, sentinel attribute),extra,default_model,default_voice,models_url,noncommercial: bool = False.TTS_PROVIDERS: dict[str, TTSProviderSpec]—openai,elevenlabs,local(the three named in podcast/narration: TTS backend abstraction (BYOK cloud first, optional local engines) #113).resolve_tts_backend(name: str | None) -> str— mirrorsllm.resolve_backend(llm.py:221-244): an explicit name whosekey_envis unset is a loud error naming the variable;"auto"picks the first provider (deterministic table order) whosekey_envis set; nothing available is a loud error listing the options.resolve_tts_model/resolve_tts_voice— mirror_provider_model(llm.py:333-355). Order: explicit value (litellm-style<provider>/prefix stripped) →model_env/voice_env→spec.default_model/spec.default_voice→ loud error naming the flag, the env var, andmodels_url.check_tts_sdk(backend)/check_tts_model(backend, model)— preflight-shaped, same three-case structure ascheck_sdk(llm.py:160-195): absent → install hint; importable but raises → quote the realImportError; importable but missing the sentinel attribute → upgrade hint.check_tts_modelis a no-op for backends with no spec, exactly likecheck_model(llm.py:198-208).tests/test_tts_providers.pycovering all of the above against fixtures/monkeypatched env.Two policy decisions to encode (both are the point of the issue, not incidental):
default_modelfor the cloud providers.llm.PROVIDERSsetsdefault_modelonly for Anthropic; the comment atllm.py:110-113explains why: "Google and OpenAI retire model names with hard 404s, so guessing is a loud error instead (a --gemini run once died in ingest on a stale hardcoded default)." Same rule here.default_voiceis set per provider — a voice is a taste default the operator should not have to supply."auto"must never select anoncommercial=Truebackend, even when its runtime imports fine. podcast/narration: TTS backend abstraction (BYOK cloud first, optional local engines) #113 records that OuteTTS 0.2 is CC-BY-NC; a licensing constraint belongs in the table as data, not in a contributor's memory. Selectinglocalrequires naming it explicitly.Out of scope — do not include in this PR:
synthesize(), audio bytes, files, chunking, stitching, ffmpegpyproject.toml(noaudioextra, no new dependency —spec.extrais a string used to build the install hint; the extra gets declared by the slice that actually needs the SDK)Configfield or CLI flag (Configisextra="forbid"atconfig.py:22, so adding a field is a public-surface change that belongs with the feature that uses it)cli.pypreflight, prompts, templates, docs, READMEWhy
--podcastepic) and --narrate: TTS voiceover muxed over the existing demo.mp4 #115 (--narrate) both name podcast/narration: TTS backend abstraction (BYOK cloud first, optional local engines) #113 as their blocker; podcast: dialogue-script stage as a standalone first slice (podcast_script.json, no TTS dependency) #112 (dialogue script, no TTS) is the parallel half.CLAUDE.mdand the--gemini404 both produced the "never guess a model name" and "preflight, don't die in-stage" rules thatcheck_sdk/check_modelimplement. Porting them as data means the audio work inherits the fixes instead of rediscovering them.Grounding implication: none. This slice touches no grounding-path module (
distill.py,tutorial.py,normalize.py,engines/,prompts/,templates/) and adds no code path that can writetutorial.md,step_by_step.md,commands.sh, ordemo.tape. It is a leaf module with no importers. Reviewers should confirm that stays true — a TTS module has no business reading or writing a published artifact.Pointers
All line numbers verified by reading the files at
64a3cbd.src/readme2demo/llm.py:93-116—ProviderSpec, the dataclass to mirror. Field comment at:108:model_prefixes: tuple[str, ...] # names recognized as this provider's models.src/readme2demo/llm.py:118-140— thePROVIDERStable itself (3 entries). Notellm.py:138:default_model="claude-sonnet-5", # keep in sync with Config.model— Anthropic is the only entry with a default.src/readme2demo/llm.py:144-148—_BACKEND_KEY_ENVS, the "which backends need a key at resolve time" map, with the comment "fail in preflight, not mid-run".src/readme2demo/llm.py:154-157—_BACKEND_SDKS,{backend: (import name, pip name, sentinel attribute)}. The TTS spec should carry this per-entry rather than as a side table (there are only 2-3 entries and the side table adds a lookup that can drift).src/readme2demo/llm.py:160-195—check_sdk, the three-branch structure to copy verbatim in shape.src/readme2demo/llm.py:198-208—check_model; the body is justspec = PROVIDERS.get(backend)then_provider_model(spec, model)when non-None.src/readme2demo/llm.py:333-355—_provider_model. Two behaviors to port: prefix stripping at:345-346, and the config-default leak guard at:347—if not name or (spec.name != "anthropic" and name.startswith("claude")):. The TTS analog is broader: a name matching another spec'smodel_prefixescounts as "not specified", because the same leak will happen the moment a shared config field feeds both providers.src/readme2demo/cli.py:64-66— the current consumer ofmodel_prefixes(deciding whether the token after a bare--geminiis a model name or a positional). Worth knowing: in this slicemodel_prefixeshas no CLI consumer yet; its only in-slice consumer is the cross-provider leak guard above. That is fine and intentional — the flag arrives with the feature.src/readme2demo/cli.py:565-574— where LLM preflight runs (llm.check_sdk(backend)/llm.check_model(backend, cfg.model)), preceded by the comment explaining that "a --openai run once burned its ingest stage on an ImportError instead." This is where a future slice hookscheck_tts_*; do not add the hook here.tests/test_llm_backend.py:467-501— the exact test patterns to mirror:monkeypatch.setitem(sys.modules, "openai", ModuleType("openai"))for too-old,monkeypatch.setattr("readme2demo.llm.importlib.import_module", boom)for broken-import,monkeypatch.delenv/setenvfor the model-resolution cases.pyproject.toml:46-52— the optional-extras block (gemini,openai). Read for context; leave unchanged.Acceptance criteria
src/readme2demo/tts.pyexists withTTSProviderSpec(frozen dataclass),TTS_PROVIDERS,resolve_tts_backend,resolve_tts_model,resolve_tts_voice,check_tts_sdk,check_tts_model, and a module-levelTTSError.TTS_PROVIDERShas entries foropenai,elevenlabs, andlocal;localis markednoncommercial=Truewith its license named in the spec.default_model; both carry adefault_voice.resolve_tts_backend("auto")returns the first key-bearing cloud provider and never returns anoncommercial=Truebackend; a test asserts this with every key set.resolve_tts_backend("elevenlabs")withELEVENLABS_API_KEYunset raisesTTSErrornaming the variable.<provider>/prefix, and treats a name matching a different provider'smodel_prefixesas unspecified.models_urlis set, links it — matchingllm.py:349-354.check_tts_sdkdistinguishes absent / broken-import / too-old with three different messages; the install hint is built fromspec.extra, never hardcoded.check_tts_modelis a no-op for an unknown backend.tests/test_tts_providers.pycovers all of the above; the tests for the cross-provider leak guard and the auto-selection licensing rule carry a"""Regression: ..."""docstring citing the--geministale-default incident and podcast/narration: TTS backend abstraction (BYOK cloud first, optional local engines) #113's CC-BY-NC note respectively.pyproject.toml; noConfigfield; no CLI flag; no import oftts.pyfrom any other module.ruff checkclean; full suite green.Notes for contributors
This is a genuinely self-contained first issue. Pure Python, pure data. No Docker, no API key, no network, no LLM call — nothing in this slice can reach a provider even if you wanted it to. You do not need to understand the sandbox, the agent, or the grounding pipeline to land it.
The single most useful preparation is to open
src/readme2demo/llm.pyand read lines 93-208 and 333-355 end to end before writing anything. This issue is deliberately a mirror of that code; if your module reads like a sibling ofllm.py, it is right. Where you find yourself inventing a new pattern, prefer copying the existing one and note the divergence in your PR description.Two things reviewers will look for: that the error messages are actionable (name the flag AND the env var — see
llm.py:351-354for the tone), and that no default model name for a cloud provider is hardcoded anywhere.