Skip to content

Add TTS endpoint (Phase 1) - #21

Closed
wpak-ai wants to merge 12 commits into
cppalliance:masterfrom
wpak-ai:add-tts-phase-1
Closed

Add TTS endpoint (Phase 1)#21
wpak-ai wants to merge 12 commits into
cppalliance:masterfrom
wpak-ai:add-tts-phase-1

Conversation

@wpak-ai

@wpak-ai wpak-ai commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes #20

@wpak-ai
wpak-ai marked this pull request as draft September 8, 2026 15:23
@vinniefalco

Copy link
Copy Markdown
Member

Gateway TTS Phase 1 Branch Review

  • Report type: Evaluation / review
  • Decision: Do not merge as currently written
  • Reviewed range: 5edcb3c9..dd2c1716
  • Local branch: wp
  • Pull request: Add TTS endpoint, Phase 1
  • Review date: 2026-09-09
  • Audience: PromptForge maintainers deciding whether to amend and merge the pull request

Executive summary

Do not merge the current branch. The core routed-speech implementation is substantial and its covered paths are healthy, but the branch has four production defects, six material documentation, verification, governance, or tooling defects, and five smaller record or history defects. The most consequential problems are unsupported local speech models downloading artifacts before refusal, unbounded permit ownership under slow streaming, profile cancellation ending as clean success, and a 30-second read timeout that also limits time to first response headers.

The branch adds 3,283 lines and removes 81 across 43 files in ten commits over current master. Forty-one focused TTS tests pass. Formatting, the featureless gateway build, and warnings-denied Clippy for the four owning crates also pass. These results establish that the implemented batch speech path works under its covered conditions. They do not exercise the failure modes identified in this review.

The original gripe list was directionally useful but not uniformly correct. Together batch synthesis works, and phase 1 originally excluded Together SSE. The defect is that later documentation advertises an impossible SSE path. The current Vibe Coder schema and pending YAML todo statuses are not valid merge objections because the run closed under an older contract and completion is tracked through step headings. By contrast, stale survey claims became actionable when the branch was rebased onto a base that had removed the named tools and renumbered the architecture.

The recommended repair is a history rewrite that amends the owning commits rather than stacking cleanup at the tip. Preserve the phase 1 decision not to implement Together SSE, bound the audio relay, move unsupported-kind checks before provisioning, replace the unmanaged Python probe with a dependency-free Node live-gateway probe, remove direct provider calls, add the missing behavior tests, and reconcile every plan and design claim. Estimated engineering work is 24 to 32 hours, plus live-provider access and complete CI. Confidence is high because the main findings follow directly from control flow, commit history, current provider documentation, and reproducible repository checks.

Contents

  1. Decision and required conditions
  2. Scope, method, and limitations
  3. What the branch gets right
  4. Severity-ranked findings
  5. Original gripes that do not stand
  6. Amendment plan by commit
  7. Verification required before merge
  8. Conclusion
  9. References

Decision and required conditions

Merge only after the author completes the following conditions:

  1. Reject unsupported local speech kinds before any server, model, companion, or cache side effect.
  2. Bound total speech-stream permit lifetime, response bytes, upstream idle, and blocked downstream delivery.
  3. Surface profile-switch cancellation as a body error rather than clean EOF.
  4. Separate the first-response deadline from the body-idle deadline.
  5. Correct every claim that Together SSE currently passes through, unless the author deliberately expands scope and implements it.
  6. Remove the direct-provider credential path or obtain an explicit architecture exception.
  7. Replace or properly manage the standalone Python tool.
  8. Add the missing route, cancellation, framing, and passthrough tests.
  9. Refresh stale survey, CI, provenance, and public API documentation.
  10. Dispose every architecture queue record explicitly before closure.
  11. Push the rewritten series and obtain a complete exact-commit CI result, including the externally provisioned native lane.

This is a conditional no-go rather than a rejection of the routed-speech design. The feature can become merge-ready without changing its core architecture.

Scope, method, and limitations

The review compares local branch wp at dd2c1716 with current master at 5edcb3c9. That local range is the intended rewritten pull request: ten TTS commits, 43 changed files, 3,283 insertions, and 81 deletions. The remote pull request still carries its pre-rewrite lineage, reports merge conflicts, and has stale check results. Remote status is therefore evidence about what must rerun, not evidence that the local TTS series itself fails.

The review applied the root AGENTS.md, nested crate instructions, vibe/archdoc.md, current CI, the plan and ledger, the source report, the as-built design, current Together and OpenAI speech documentation, commit diffs, and current production and test code. Each original gripe received an independent focused investigation. A separate branch-wide pass searched for defects outside that list.

The review judged the branch against six criteria: user-visible protocol correctness, architecture and ownership conformance, bounded resource and cancellation behavior, tests that would detect each claimed failure, truthful documentation and provenance, and repairability within the existing commit structure.

Local verification ran 14 shared-protocol speech tests, four gateway-config voice tests, one gateway-local refusal test, and 22 gateway speech integration tests. All 41 passed. Formatting, the featureless gateway check, and warnings-denied Clippy for gateway, shared-protocol, gateway-config, and gateway-local also passed.

The review did not rerun the credentialed live-provider probe, provoke paid-provider rate limits, or execute the complete cross-platform CI matrix. Repair estimates include implementation, focused tests, and documentation, but exclude queue time on external runners. Provider behavior can drift after the 2026-09-09 documentation snapshot.

What the branch gets right

The routed model-kind decision fits the repository. Speech models use the existing catalog, routing, dominion queue, upstream abstraction, and model discovery instead of adding a parallel service lifecycle. This preserves the gateway as the runtime credential owner and follows the established chat, embedding, and classifier shape.

The batch speech path is well covered. The tests prove model rewriting, bearer separation, structural MP3 defaulting, byte-preserving response relay, MIME fallback for six audio formats, voice validation before queue admission, queue-full behavior, permit retention, client-disconnect cleanup, error-body secrecy, 429 and 503 mappings, and voice-union ordering.

The branch also handles the new model kind safely in the launch mapping. launch_options no longer lets an unknown or speech kind fall through to chat. Config validation rejects speech-only voices on other kinds and rejects empty or duplicate configured voices before they leave gateway-config.

These strengths narrow the repair. The recommendation does not replace the route, model kind, upstream method, or voices union.

Severity-ranked findings

The findings rank as follows. Estimates overlap where one relay change closes several defects.

  1. F1, Important: local speech provisions before refusal. Confirmed production side effect. Estimated repair: 2 to 4 hours.
  2. F2, Important: stream ownership is not fully bounded. Confirmed conditional availability defect. Estimated repair: 8 to 12 hours.
  3. F3, Important: profile cancellation ends as clean EOF. Confirmed client-visible ambiguity. Estimated repair: 1 to 2 hours, overlapping F2.
  4. F4, Important: the 30-second idle timeout also limits response headers. Confirmed mechanism with uncertain provider incidence. Estimated repair: 2 to 4 hours, overlapping F2.
  5. F5, Important: Together SSE is documented but impossible. Confirmed documentation and compatibility defect. Estimated repair: 2 to 4 hours.
  6. F6, Important: the live tool violates credential ownership. Confirmed governance violation with low incremental secret risk. Estimated repair: 1 to 2 hours alone.
  7. F7, Important: the branch creates an unmanaged Python tool island. Confirmed operational debt. Estimated repair: 4 to 8 hours to replace.
  8. F8, Important: required behavior lacks route-level tests. Confirmed aggregate coverage gap, driven by profile cancellation. Estimated repair: 1.5 to 2 hours, overlapping F2 through F5.
  9. F9, Important: the rebase invalidated verification provenance. Confirmed auditability defect. Estimated repair: 1 to 2 hours plus reruns.
  10. F14, Important: closure silently deletes unresolved architecture records. Confirmed governance defect. Estimated repair: under 1 hour plus operator disposition.
  11. F10, Minor: live parity claims exceed its assertions. Confirmed plan and verification drift. Estimated repair: 1 to 2 hours.
  12. F11, Minor: Project Survey and CI wording are stale. Confirmed after rebase and partly historical. Estimated repair: 1 to 2 hours.
  13. F12, Minor: public model-kind documentation omits speech. Confirmed in five locations. Estimated repair: under 1 hour.
  14. F13, Minor: frontmatter todos still report pending work. Execution is unaffected, but metadata is misleading. Estimated repair: 5 to 10 minutes.
  15. F15, Minor: the first retained commit temporarily launches local speech as chat. Final HEAD is safe, but branch history is not. Estimated repair: no extra implementation time.

F1. Reject local speech before provisioning

  • Severity: Important
  • Verdict: Confirmed in practice
  • Confidence: High
  • Owning commit: d2d9e4a5

The branch promises that a local speech model fails because no local speech runtime exists. The failure happens too late. start_impl provisions the shared server at crates/gateway-local/src/runtime.rs:459, downloads the model at lines 473 to 479, and writes sidecar metadata at line 486 before launch_options_for reaches the unsupported-kind check at lines 489 and 745 to 753. The separate artifact-preparation path is worse: provision_artifacts_impl provisions the server at line 355 and every model at lines 357 to 370 without checking whether any model can launch.

An operator who accidentally declares a local speech model can download a multi-gigabyte model, provision llama-server, write cache metadata, and receive successful preparation before startup refuses the model. This contradicts architecture invariant A6, which requires capability and semantic validation before side effects. It can also waste time, bandwidth, and disk space during a profile switch.

The strongest counterargument is that prefetching may help phase 2. That behavior is speculative and conflicts with the phase 1 statement that local speech is unsupported. A preparation command must not report success for a model the runtime cannot start.

Fix the defect by extracting a side-effect-free supported-kind preflight. Run it before provisioning the shared server and before each model or companion download. Mixed profiles should preserve supported-model progress while returning a per-model failure for unsupported kinds. Add tests proving that an all-speech profile touches neither the server provisioner nor the model store, and that a mixed profile provisions only supported models.

Estimated work is 2 to 4 hours across runtime.rs and its tests. Amend d2d9e4a5, then reconcile the plan and as-built design if their launch-only wording changes.

F2. Bound audio relay ownership independently of polling

  • Severity: Important
  • Verdict: Confirmed conditional availability defect
  • Confidence: High
  • Owning commits: 90061d68 and 061064c7

Architecture invariant A5 requires every stream and wait to be bounded. Invariant A27 separately requires stream permits to survive until body termination and stalled reads to expire. The implementation satisfies A27 under ordinary polling but does not fully satisfy A5.

audio_streaming_client applies a 30-second read-idle timeout at crates/shared-protocol/src/http_util.rs:46-67. relay_audio stores the dominion permit and in-flight guard inside a response stream at crates/gateway/src/lib.rs:1039-1060. Any upstream byte resets the read timer. A provider can therefore send one byte every 29 seconds indefinitely. A slow downstream creates a second gap: when Hyper stops polling the body because the client is not reading, neither the upstream read nor the cancellation select advances, while the response still owns the permit.

The request-side 4,096-character cap, queue-depth cap, error-body cap, client-disconnect drop chain, and typical provider behavior all reduce the likelihood of accidental occurrence. They do not create a finite successful-response lifetime. An accepted caller or faulty upstream can occupy every speech dominion slot indefinitely. An endpoint with no dominion avoids permit starvation but permits unbounded concurrent upstream tasks and sockets instead.

A short whole-request timeout is not an acceptable fix. A 4,096-character request at slow playback can legitimately produce many minutes of audio, and raw PCM is large. The safer policy is a bounded background relay that owns the upstream stream, permit, and cancellation guard while feeding a small bounded channel to the HTTP body. The task should enforce a generous total lifetime, a byte ceiling, upstream idle, blocked downstream delivery, cancellation, and receiver drop independently.

A reasonable static starting envelope is 60 minutes, 1 GiB, 30 seconds of upstream read idle after headers, and 60 seconds blocked on downstream delivery. Together documents Orpheus output as 24 kHz signed 16-bit PCM. One hour of mono audio at that rate is 172,800,000 bytes, about 165 MiB, before SSE framing. The proposed byte limit leaves more than six times that payload. These values still need production validation, named constants, and boundary tests. Configurability should wait until operators demonstrate a need because it would add schema, validation, UI, and documentation surface.

Estimated work is 8 to 12 hours for a static bounded pump and deterministic tests. Amend the owning protocol and route commits, then update the plan, guide, and as-built design. Confidence in the need for a finite policy is high. Confidence in the suggested numeric values is medium because production duration distributions are not in the repository.

F3. Make profile cancellation fail the body read

  • Severity: Important
  • Verdict: Confirmed
  • Confidence: High
  • Owning commit: 061064c7

relay_audio selects in_flight.cancelled() and returns None at crates/gateway/src/lib.rs:1053-1057. Hyper therefore sends a clean end-of-body for a response that already has status 200. A caller can accept partial MP3 or raw PCM as a successful complete response. This differs from the upstream-error path, which emits an error item and makes the client body read fail.

The branch correctly notes that no JSON envelope can follow audio bytes. That does not require clean EOF. Emit one body error when profile cancellation wins, then terminate. The bounded relay proposed in F2 should own this behavior so cancellation remains observable even when the downstream has stopped polling.

Add a profile-switch integration test that reads one audio chunk, switches profile, and proves the next body read fails, the upstream drops, and the permit becomes available. This requires 1 to 2 hours if implemented alone and less when folded into F2.

F4. Separate response-header and body-idle deadlines

  • Severity: Important
  • Verdict: Mechanism confirmed, provider incidence uncertain
  • Confidence: Medium
  • Owning commit: 90061d68

The dedicated client applies read_timeout before send_speech awaits the response at crates/shared-protocol/src/upstream.rs:450-456. Reqwest documents the timeout as applying to each read and resetting after successful reads. The design record for that option states that the first interval covers response headers and later intervals cover body chunks. The intended 30-second stalled-body defense therefore also rejects a provider that takes more than 30 seconds to begin its response.

This matters because Together batch synthesis is not the low-latency SSE mode. The accepted request can contain 4,096 characters, while live verification used about 90. A long request or cold provider can accept and potentially bill work before the gateway reports upstream_transport. The current tests prove that a silent server times out, but they do not separate slow headers from an idle opened body.

The strongest counterargument is that production providers usually return headers promptly or emit chunks during generation. No branch evidence establishes the latency distribution, so actual incidence remains uncertain. The code mechanism and resulting error classification are certain.

Use a longer explicit first-response deadline, then enforce the shorter body-idle deadline in the relay itself. This design overlaps the bounded pump in F2 and avoids forcing one timeout value to serve two different phases. Add one test where headers arrive after the body-idle threshold but before the first-response threshold, and another where an opened body stalls.

Estimated incremental work is 2 to 4 hours, mostly overlapping F2. Amend 90061d68 and 061064c7.

F5. Stop advertising Together SSE as a phase 1 capability

  • Severity: Important
  • Verdict: Confirmed documentation and compatibility defect
  • Confidence: High
  • Owning commits: bd7452fa, 577bfe99, and 1820218b

Together batch speech works. The live note proves MP3 and WAV output through the gateway. Together SSE does not work through this branch.

Together's current documentation requires stream: true and response_format: "raw" for HTTP SSE. The branch can preserve the unknown top-level stream field through SpeechRequest.rest, but SpeechResponseFormat deliberately rejects raw at crates/shared-protocol/src/wire.rs:363-385. Deserialization fails before routing at crates/gateway/src/lib.rs:966-971.

The source report states the correct scope at design/report-gateway-tts-endpoint.md:124: phase 1 supports non-streaming Together passthrough, and SSE translation is a later feature. The plan simultaneously excludes Together SSE decoding at line 32, declares raw unrepresentable at line 91, and says phase 1 passes Together SSE through at line 202. The guide repeats that implication at guide/src/gateway/06-speech-synthesis.md:55-57, and crates/gateway/README.md:170 more directly claims that stream_format = "sse" selects provider framing.

The practical consequence is false operator guidance. stream_format: "sse" sent to Together is not translated to stream: true and will ordinarily produce batch audio. A native Together request with response_format: "raw" receives a gateway 400. Users cannot obtain Together's low-time-to-first-byte path despite documentation that implies otherwise.

Preserve phase 1 scope and correct the plan, README, guide, parity terminology, verification note, and as-built design. State that OpenAI stream_format is forwarded, Together batch MP3 and WAV are supported, and Together SSE cannot currently be requested. Rename checks that only prove absent Content-Length as chunked transport checks.

This correction requires 2 to 4 hours across six or seven documentation and verification files. Actual Together opaque SSE passthrough would require adding raw, typing stream, defining fallback media behavior, and adding live incremental tests. That is a separate 6 to 12 hour scope choice. A translation adapter that normalizes OpenAI and Together SSE belongs at the protocol boundary and is likely a 3 to 5 day feature.

One small production defect remains even for an OpenAI SSE-capable upstream. relay_audio chooses fallback media type from response_format alone at crates/gateway/src/lib.rs:1064-1067. If an SSE upstream omits or sends an invalid Content-Type, the gateway labels event data as audio. Pass the framing selector into the fallback function and use text/event-stream for SSE. The code and test should take under 1 hour.

F6. Remove the direct-provider credential path

  • Severity: Important
  • Verdict: Confirmed governance violation with low incremental secret risk
  • Confidence: High
  • Owning commits: bd7452fa and 4ee06cff

Architecture invariant A19 says vendor credentials remain inside the gateway. The plan repeats that nothing above the gateway holds a vendor key, then requires a direct provider comparator. tools/gateway-tts-parity.py:275-277 reads TOGETHER_API_KEY from the process environment or repository dotenv. Lines 213 to 215 build an OpenAI client with that key, and line 313 sends direct Together requests.

This is not an inferred objection. The introducing commit message states: “Violates: A2 - tools/gateway-tts-parity.py holds TOGETHER_API_KEY outside the gateway and calls the provider directly.” The live verification note confirms that the direct calls actually ran. The close commit leaves the admitted violation unresolved.

Incremental secret-exposure risk is low because the script is opt-in, does not run in CI, and does not print or newly persist the key. The governance consequence is larger. The script bypasses gateway-owned redaction, routing, accounting, and error handling while the plan simultaneously treats credential isolation as a founding invariant.

The strongest counterargument is that architecture invariants govern deployed components, not an operator's local oracle. The written rule contains no such exception, and the branch explicitly labels the implementation a violation. Review cannot silently invent an exemption.

Preserve the invariant. Remove the direct Together half and dotenv parsing. The live tool should call only a separately configured gateway using its local bearer. Provider defaults can remain sourced to current documentation, while mocked tests prove exact outbound bodies. If maintainers want direct-provider probes, they should approve a narrow developer-tool exception with host pinning, no persistence, bounded calls, and no CI.

Removing the direct half alone takes 1 to 2 hours across the script, plan, note, ledger, commit message, and as-built design. This work should be combined with F7.

F7. Replace the unmanaged standalone Python tool

  • Severity: Important
  • Verdict: Confirmed operational and reproducibility debt
  • Confidence: High
  • Owning commit: 4ee06cff

The repository previously used short standard-library Python fragments inside workflows. This branch adds its first tracked standalone .py file and imports third-party openai and httpx packages. It adds no pyproject.toml, requirements file, lock file, installation command, version constraint, or offline test. The verification note records OpenAI SDK 3.8.0, but a future operator receives whichever packages happen to be installed.

The documented no-key skip path is already false on a clean machine. tools/gateway-tts-parity.py:31-33 imports httpx and openai before main checks for the key at lines 275 to 282. Missing packages therefore raise an import error instead of printing the promised skip. The tool can also change behavior after an SDK release or run against dependency combinations different from the recorded verification.

The binary-selection logic creates a second reproducibility defect. ensure_gateway_binary returns the first existing debug or release executable at lines 78 to 83 and builds only when neither exists. A live run can therefore test stale code from another commit while recording the current source hash. Always build the reviewed target or require an explicit executable whose identity is recorded.

Python is not technically required. The repository already requires Node 22 and uses .mjs for developer tooling. A dependency-free Node script can spawn or target the gateway, use built-in fetch, stream the response, and make the same gateway-visible assertions. The claim that no first-party Rust SDK exists explains why Rust cannot exercise that SDK, but it does not establish why a permanent official-SDK dependency is necessary for a live gateway smoke test.

Replace the Python probe with a Node script that contacts only the gateway. Keep exact protocol behavior in Rust integration tests. Record a separate manual official-SDK smoke run against the gateway if compatibility with one client library is an acceptance concern.

The replacement is estimated at 4 to 8 hours, including deterministic helper tests, a credentialed live rerun, and updates to the plan, note, ledger, guide references, and as-built design. Retaining Python is the weaker alternative: add pinned dependencies, an installation workflow, offline unit tests, and an explicit tooling exception. That option takes roughly 2 to 4 hours but leaves an additional language environment to maintain.

F8. Add route-level tests for claimed behavior

  • Severity: Important
  • Verdict: Confirmed
  • Confidence: High
  • Owning commits: d2d9e4a5, 061064c7, and 2f72c02e

The existing suite is broad, but five claimed boundaries lack direct coverage.

First, the product requirements promise a specified unknown-model envelope on the speech route. Only the chat integration suite directly tests model_not_found. A routing unit test proves the shared resolver, but not that audio_speech calls it and preserves the envelope.

Second, every voices integration helper sends a bearer at crates/gateway/tests/it/speech.rs:851-857. No test proves that GET /v1/audio/voices rejects an unauthenticated caller, even though the handler calls check_auth.

Third, the plan and as-built design claim profile-switch cancellation truncates speech, but no speech integration test switches profile during an open body. This omission concealed F3 and the unpolled-cancellation portion of F2.

Fourth, angle-bracket preservation is tested only through the optional live script. The generic passthrough test uses ordinary text. Offline CI could regress the specific hard requirement without failing.

Fifth, a wire test accepts and round-trips stream_format: "sse", but no route test combines that selector with outbound forwarding, text/event-stream, opaque event bytes, or missing SSE content type. No focused test pins the intentionally unsupported Together-native combination with raw. The suite therefore cannot distinguish intentional phase 1 exclusion from accidental or falsely documented behavior.

The unknown-model, voices-authentication, emotion-tag, and SSE-composition gaps are individually low severity because shared mechanisms already have coverage. Profile-switch cancellation is medium severity because it is bespoke composition and concealed F3. Add all five direct pins in 1.5 to 2 hours. The no-provision and bounded-relay tests belong to F1 and F2 estimates. Tests should amend the commits that introduce each behavior rather than land in a catch-all tip commit.

F9. Restore verifiable provenance after the rebase

  • Severity: Important
  • Verdict: Confirmed auditability defect
  • Confidence: High
  • Owning commits: 4ee06cff and 1820218b

The branch was rebased after live verification and documentation. design/note-gateway-tts-phase-1-verification.md:5 cites f92f33ec and f7e00c81. design/design-gateway-tts-phase-1.md:7 cites the range 5b58bc8b through b9924a18. The ledger claims the object store retains the pre-amend hash. Git reports all four cited objects as invalid in the current repository.

Maintainers cannot inspect the exact trees that supposedly passed live verification. The stale-binary behavior in F7 also means the recorded source hash would not prove which executable was exercised even if the commit existed. The rebased content may be equivalent, but the report rulebook and the repository's own evidence discipline require verifiable names and numbers.

Rerun the live check against a freshly built executable from a retained rewritten commit. Record the executable hash, commit, and tree hash, because subsequent message-only amendments can change the commit while preserving the tree. Update the note, as-built design, ledger, and commit messages. Estimated repository work is 1 to 2 hours, excluding provider and CI queue time.

F14. Dispose architecture records before closure

  • Severity: Important
  • Verdict: Confirmed governance defect
  • Confidence: High
  • Owning commit: dd2c1716

Immediately before closure, vibe/archdoc-next.md contained N69 and N70. N69 recorded an unresolved A2 concern from the local launch change. N70 recorded the new InvalidVoice public error surface. dd2c1716 deletes both records in the same commit that deletes vibe/ACTIVE.

The Vibe Coder contract in force when the TTS run closed required the operator alone to promote, reject, or leave each queue record open. It also required queue dispositions to live in an operator-authored drain commit, never a step or Close commit. The closure carries no disposition rationale and the accepted architecture contains neither record.

This has no runtime consequence, but it removes the audit trail for two findings while claiming the plan is closed. Restore the records at the end of the amended implementation series, have the operator dispose each one explicitly in a separate drain commit, and only then replay closure. N69 appears malformed because it calls an undetermined fact a violation; that is a reason to reject it explicitly, not silently delete it.

Repository work is under 1 hour. Operator review time is separate. Do not fold the disposition into dd2c1716.

F10. Make parity claims match actual assertions

  • Severity: Minor
  • Verdict: Confirmed plan and verification drift
  • Confidence: High
  • Owning commits: bd7452fa, 4ee06cff, and 1820218b

The plan says the gateway and provider run the same assertions. The script runs the same call set but not the same checks. WAV, emotion tags, and three passthrough fields are asserted for both targets at tools/gateway-tts-parity.py:234-272. Default MP3, absent content length, and voices shape are gateway-only checks at lines 220 to 259. Together's default and missing voices route are observations that cannot fail the run.

Literal symmetry would be wrong because the gateway deliberately changes default format and adds a voices route. The defect is the promise, not the asymmetry. The script still provides useful live evidence for the shared subset.

If F6 removes direct provider calls, rename the tool as a live gateway smoke probe and remove parity claims. If maintainers approve direct comparisons instead, represent target expectations explicitly and make known provider baselines fail on drift. Avoid a boolean mode flag whose meaning is spread through the function. The script and document correction is 1 to 2 hours, largely overlapping F6 and F7.

F11. Correct the post-rebase survey and CI account

  • Severity: Minor
  • Verdict: Confirmed, with historical qualification
  • Confidence: High
  • Owning commits: bd7452fa and dd2c1716

Most stale survey claims were true at the original TTS base f8e07fb6. That tree contained integration ceilings, STT architecture tools, UI layer checks, module-ceiling manifests, and the old architecture numbering. Current base 5edcb3c9 intentionally removed those structural ratchets and replaced the architecture with A1 through A30. Rebasing preserved the old survey as if its commands and citations still governed the branch.

The current plan names removed Node tools, claims a removed JSON ceiling file exists, says a removed UI pretest still runs, and cites A102 and A112 through A117. It also says only workshop-server had a module ceiling, although four STT crates had one at the original base.

Do not restore any removed parser, walker, count, or ceiling. Amend the survey to record its original base and a post-rebase validation section against 5edcb3c9. Replace old invariant citations with current equivalents, remove executable references to deleted tools, and remove module ceilings as a future service-split criterion.

The CI wording needs a separate narrow correction. Plan line 80 calls a feature-focused subset “the exact CI gates,” while line 143 says every step ended with the full suite. vibe-ledger.md:51 explicitly records a different cadence, and Steps 1 and 5 received focused verification. No listed feature gate was omitted from the final recorded run, so this is evidence-accounting debt rather than proof of an untested feature.

Describe focused, component, and final verification accurately. Then obtain one exact-commit integrated CI result after the rewrite. Updating the plan and ledger takes 1 to 2 hours.

F12. Complete public model-kind documentation

  • Severity: Minor
  • Verdict: Confirmed
  • Confidence: High
  • Owning commits: bd7452fa and 061064c7

Five live documentation locations still enumerate the old three-kind set:

  • crates/shared-protocol/src/wire.rs:592
  • crates/gateway-routing/src/model.rs:46
  • crates/gateway-config/src/config/accessors.rs:882-883
  • crates/gateway-config/src/config/accessors.rs:1223-1224
  • crates/gateway/src/model_info.rs:48

The code works because the type already carries ModelKind::Speech. The consequence is stale generated API documentation and misleading source guidance. Update all five comments in their owning commits and run warnings-denied documentation. Estimated work is under 1 hour.

F13. Synchronize terminal frontmatter metadata

  • Severity: Minor
  • Verdict: Confirmed metadata defect, not an execution-state defect
  • Confidence: High for execution behavior, medium for viewer impact
  • Owning commit: 1820218b

Six frontmatter todos remain pending at vibe/2026-09-07-2-gateway-tts-phase-1.md:7,10,13,16,19,22, while all nine execution headings are completed and vibe/ACTIVE is absent. Current resume logic selects the first step heading without a completed marker and does not consult todo status. Commit-message matching reads todo text but ignores status.

The mismatch cannot reopen work, break closure, or change generated commit messages. It can still mislead a frontmatter-aware viewer or a maintainer reading the plan without knowing which state is authoritative. Repository precedent also exists for synchronizing every todo in the final execution commit.

Amend 1820218b to change the six terminal statuses to completed, then replay closure. Do not amend the seed commit, where pending was truthful. The change requires one file, six lines, and 5 to 10 minutes. No code test is needed; parse the YAML, confirm nine completed step headings, and confirm vibe/ACTIVE remains absent.

F15. Keep the first retained speech commit safe

  • Severity: Minor
  • Verdict: Confirmed history defect, final HEAD unaffected
  • Confidence: High
  • Owning commits: bd7452fa and d2d9e4a5

bd7452fa introduces ModelKind::Speech while the then-current launch_options wildcard still maps every unknown non-embedding and non-classifier kind to chat. Until d2d9e4a5, a local speech declaration can therefore launch llama-server in chat mode. Final HEAD fixes that behavior, but the first retained feature commit is unsafe to test, bisect, or reuse independently.

Combine d2d9e4a5 into bd7452fa, or otherwise make the explicit speech refusal part of the same commit that introduces the enum. This adds no implementation effort beyond F1, but it changes the rewrite shape. The config kind and exhaustive local behavior should enter history atomically.

Original gripes that do not stand

Current Vibe Coder tags are not retroactive

The plan does not satisfy the current strict tag schema. That schema was introduced after the original TTS run closed. The run had no contract tags, step tags, exact capitalized Project Survey heading, survey status line, or component-test field because the governing tool did not yet require them.

The local rebase happened later, but it replayed a closed run and did not reactivate vibe/ACTIVE. No repository CI validates historical plans against the current schema. Amending the old execution commits to imply that the new dispatch contract governed them would reduce provenance accuracy.

Do not make current Vibe Coder syntax a merge condition. If maintainers want every newly merged plan file to parse under the current tool regardless of execution date, add a separate post-close migration commit and ledger note. Estimated optional work is 20 to 30 minutes. Confidence is high.

Together SSE absence is not a batch-route failure

The source report deliberately scoped Together phase 1 to batch passthrough. MP3 and WAV batch calls succeeded live, and focused tests cover their gateway behavior. The inability to request Together's SSE mode is therefore not evidence that the delivered batch feature fails. F5 remains because user-facing documentation advertises more than the branch implements.

Remote PR failures do not describe the rebased TTS code

The remote pull request currently reports merge conflicts and two failed checks on its pre-rewrite lineage. The core check stopped on an unrelated STT Clippy diagnostic that current local master already fixes. The native lane stopped because the self-hosted runner lacked the required Rust 1.89 directory.

The local branch passes the reviewed TTS tests, formatting, featureless check, and owning-crate Clippy. Nevertheless, the rewritten combined tree has no complete exact-commit CI result. The remote failures should be replaced by fresh results after push, not counted as TTS defects.

Amendment plan by commit

The branch is already organized into behavior-sized commits. Preserve that structure and amend defects into the commits that introduced them. The recommended chronological rewrite is:

  1. Combine d2d9e4a5 into bd7452fa. In that atomic kind-introduction commit, correct the plan scope, credential rule, parity wording, survey, invariant citations, CI cadence, supported-kind preflight, and no-side-effect tests. Estimated effort: 4 to 7 hours.
  2. Leave 4114b7de unchanged under the recommended non-SSE Together scope. Change it only if raw becomes supported.
  3. Amend 90061d68 to separate first-response and body-idle deadlines and support the bounded relay policy. Estimated effort: 3 to 5 hours.
  4. Amend 061064c7 to add the bounded background relay, cancellation error, response limits, SSE MIME fallback, and route tests. Estimated effort: 8 to 12 hours.
  5. Amend 2f72c02e to add unauthenticated voices-route coverage. Estimated effort: under 1 hour.
  6. Amend 4ee06cff to replace the Python probe with Node, remove direct-provider calls, force a fresh reviewed build, rerun live gateway checks, and restore provenance. Estimated effort: 4 to 8 hours.
  7. Amend 577bfe99 to correct Together SSE claims and regenerate the guide. Estimated effort: 1 to 2 hours.
  8. Amend 1820218b to reconcile as-built choices, limits, tooling, retained commit references, and terminal frontmatter statuses. Estimated effort: 1 to 2 hours.
  9. Restore N69 and N70, then create a separate operator-authored drain commit that records each disposition. Estimated effort: under 1 hour plus operator review.
  10. Replay dd2c1716 only after the queue is drained and all ledger statements are accurate. Estimated effort: under 1 hour.

The estimates overlap. The bounded relay, timeout separation, cancellation test, and several missing tests should be implemented as one coherent stream-lifecycle change. The Node rewrite, credential correction, parity correction, and live rerun should likewise be one verification-tool change.

Expected total engineering effort is 24 to 32 hours for the recommended scope. A developer already familiar with the gateway test harness is likely to land near the lower bound. External live-provider access and CI queue time are additional. Confidence is medium because deterministic backpressure tests are the largest uncertainty.

The rewrite should begin at bd7452fa and proceed chronologically. Preserve each commit's behavior boundary, regenerate commit messages from the amended diff, and update every later factual reference after hashes stabilize. Push the rewritten head with lease protection only after the local range is clean and internally consistent.

Verification required before merge

Focused behavior verification

The amended local-provisioning commit must prove that unsupported kinds perform no artifact or metadata side effects. Test all-speech and mixed supported and unsupported profiles with injected provisioners and stores.

The amended stream commit must prove all terminal paths. Required cases are exact byte limit, one byte over limit, total deadline, sub-idle upstream drip, slow downstream channel saturation, upstream idle after headers, delayed but accepted headers, client disconnect, profile cancellation, and upstream body error. Every case must prove permit release and admission of a subsequent request.

The route suite must add unknown-model, unauthenticated voices, angle-bracket input, SSE content-type fallback, and the intentionally unsupported Together-native streaming shape. Existing batch MP3, WAV, queue, error, voice, and disconnect tests must remain green.

The replacement live tool needs offline tests for gateway startup failure, readiness timeout, request failure, assertion failure, process cleanup, skip behavior, and secret-free output. The credentialed run must contact Together only through the gateway and record the retained commit and tree.

Repository gates

Run formatting, warnings-denied Clippy, non-Workshop workspace tests, doctests, warnings-denied documentation, the featureless gateway build, dependency policy, supply-chain checks, both UI packages, and clean-tree verification. Rebuild the generated guide and prove that regeneration leaves no unexplained diff.

Run the Workshop Windows and Linux lanes because the rebased base overlaps gateway configuration, routing, and integration harness files. Run MSRV because the branch adds public Rust types and dependency usage.

The native Whisper lane remains externally blocked until the self-hosted runner supplies the configured Rust 1.89 binary directory. Do not infer that lane passing from local TTS results.

Evidence closure

Record one exact rewritten commit for every local and remote result. Use tree hashes for live observations that survive message-only amendments. Remove or correct every invalid historical object reference. The final pull request must be conflict-free and every required check must report against the pushed head.

Conclusion

The routed-speech architecture should proceed, but the current branch is not merge-ready. Its ordinary remote batch path is functional and well tested. The reasons to stop are narrower and concrete: unsupported local models perform side effects, stream ownership can outlive every useful bound, cancellation can look successful, the first-response timeout is too tightly coupled to body idle, documentation advertises an unavailable Together mode, and the verification tool knowingly violates credential ownership while introducing an unmanaged runtime.

Amending the existing commit series is preferable to adding a repair stack. The defects align cleanly with the commits that introduced config, local launch behavior, protocol streaming, routing, verification, and documentation. A 24 to 32 hour repair budget is proportionate to the branch's 3,283 added lines and the need for deterministic backpressure tests.

Final recommendation: request changes, complete the chronological amendment plan, rerun live verification through the gateway, and require a complete exact-commit CI matrix before merge. Confidence is high in the merge call and medium in the repair duration.

References

Repository evidence

  • AGENTS.md
  • vibe/archdoc.md
  • vibe/2026-09-07-2-gateway-tts-phase-1.md
  • vibe-ledger.md
  • design/report-gateway-tts-endpoint.md
  • design/design-gateway-tts-phase-1.md
  • design/note-gateway-tts-phase-1-verification.md
  • crates/gateway-local/src/runtime.rs
  • crates/shared-protocol/src/wire.rs
  • crates/shared-protocol/src/upstream.rs
  • crates/shared-protocol/src/http_util.rs
  • crates/gateway/src/lib.rs
  • crates/gateway/tests/it/speech.rs
  • tools/gateway-tts-parity.py
  • .github/workflows/ci.yml
  • Commits bd7452fa, d2d9e4a5, 4114b7de, 90061d68, 061064c7, 2f72c02e, 4ee06cff, 577bfe99, 1820218b, and dd2c1716

External provider evidence

2026-09-09 04:02 - gpt-5.6-sol

@vinniefalco

Copy link
Copy Markdown
Member

I rebased the branch and fixed merge conflicts. Let's go over this report and try to figure out what it means.

@vinniefalco vinniefalco closed this Sep 9, 2026
@vinniefalco vinniefalco reopened this Sep 9, 2026
@vinniefalco

Copy link
Copy Markdown
Member

This introduces the first Python source file in the project, are you sure we can't just do this in JS?

@wpak-ai
wpak-ai force-pushed the add-tts-phase-1 branch 2 times, most recently from c13d454 to c4ec890 Compare September 10, 2026 22:01
@wpak-ai
wpak-ai marked this pull request as ready for review September 11, 2026 13:48
@wpak-ai
wpak-ai requested a review from vinniefalco September 11, 2026 14:24
vinniefalco and others added 12 commits September 11, 2026 14:03
Local builds were pinned to an older Rust while CI linted and tested on stable, and that skew just produced three CI failures on a pull request that had passed every local check. This change puts local builds and CI on the same stable toolchain, removes the minimum-version declarations and the CI job that enforced them, and deletes the runner validation script together with its dedicated test. The three failures the skew exposed are fixed in place: an unnecessary-parentheses lint, a pair of lint expectations consolidated under a new name, and a paused-clock test structurally incompatible with its real-time rendezvous.

- `rust-toolchain.toml` now selects `channel = "stable"`, so local builds track the same toolchain CI uses; the header comment describes that rule instead of the removed pin.
- `rust-version` leaves the workspace manifest and the crate manifests, ending the resolver's preference for dependency versions compatible with the old minimum.
- `crates/gateway-stt/src/audio.rs` drops the parentheses around the trait object type in an error-chain assertion, clearing the lint stable fires on them.
- `crates/workshop/src/bridge.rs` expects `clippy::ref_as_ptr` on the test module in place of `clippy::ptr_as_ptr` and `clippy::borrow_as_ptr`, which stable consolidated into the single lint.
- `decode_rendezvous_timeout_releases_a_late_arrival_and_permits_a_follow_up` runs on the real clock again, because a paused clock cannot drive the test's real-time condvar rendezvous.
- `.github/workflows/ci.yml` loses the `msrv` job and every `RUSTUP_TOOLCHAIN` override, and `ci-green` no longer lists `msrv` among its needs.
- `tools/validate-rust-1.89.0.ps1` is deleted along with `tools/check-stt-native-workflow.test.mjs`, the test file that existed only to exercise it.
- `README.md` loses the minimum-version section, the version badge, and the version number in the build instructions; `AGENTS.md` loses the bullet warning that CI lints on a newer toolchain than local.

Design: removes feature-flag @ .github/workflows/ci.yml
Plan: vibe/2026-09-11-2-unify-toolchain.md
State the timeout durations in the units the values use. The change rewrites four timeout constants in `artifacts.rs`, `live.rs`, `backoff.rs`, and `cuda.rs` from `Duration::from_secs` products to `Duration::from_mins` and `Duration::from_hours`.

- The rewritten constants keep their previous values; no test or call site changes.
Speech synthesis becomes a fourth routed model kind beside chat, embedding, and classifier, so a catalog entry can declare a text-to-speech model and clients can discover it before shaping requests. A model may now declare the voices it offers, and the declaration is validated at load: entries must be non-empty and unique, and a non-empty list is rejected on any non-speech kind, symmetric with the existing chat-only field discipline. An empty or absent voice list stays valid, so providers with no fixed voice catalog keep working. The configuration interface gains the new kind as a dropdown choice and a chips editor for the voices that renders only for speech models.

- `ModelKind::Speech` joins the kind enum with the serde and Display spelling "speech", a fourth routed catalog kind whose spelling round-trips through TOML and JSON.
- `voices` is a private, serde-defaulted string list on the capabilities record, documented as speech-only and surfaced verbatim on the models listing; empty means the model exposes no fixed voice list.
- `validate_capabilities` rejects empty voice entries and duplicate voices at load, the error naming the voices field or the duplicated entry.
- `validate_kind_scope` rejects a non-empty voices list on chat, embedding, and classifier models, the symmetric half of the chat-only field discipline, so a misplaced list fails loudly naming the field.
- `settings-registry.ts` declares a chips editor for voices that renders only when the model kind is speech, and `models-view.ts` gains speech as a fourth kind dropdown option.
- `model-detail.test.mjs` pins the four-kind dropdown and a pick-speech, add-chip, save round-trip into the PUT body, while the new config validation tests pin each acceptance and rejection.

Design: new surface-growth @ crates/gateway-config/src/config.rs::ModelKind::Speech boundary: wire
Design: new surface-growth @ crates/gateway-config/src/config.rs::Capabilities::voices boundary: wire
Design: new encapsulated-invariant @ crates/gateway-config/src/config.rs::Capabilities
Design: new surface-growth @ crates/gateway-config/src/config/accessors.rs::Capabilities::voices boundary: pub
Deferred: the speech kind exists one commit before launch_options becomes fallible and refuses it for local models
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
Launching a local model now fails loudly when the model's kind has no local serve mode. The kind-to-mode mapping previously defaulted every unmatched kind to chat, so a speech model declared for local launch would have started as a chat server; it now returns a dedicated error naming the kind, and any kind added to the enum later fails the same way instead of launching misconfigured. Both local launch paths check every configured model's kind before provisioning anything, so a profile containing an unsupported kind refuses it before any download, server provisioning, or metadata write, and a profile with no launchable model does nothing at all.

- `serve_mode_for` extracts the kind-to-serve-mode mapping as a side-effect-free fallible function, so both launch paths can preflight a kind before any provisioning; its wildcard arm refuses kinds added after the mapping because `ModelKind` is `#[non_exhaustive]`.
- `LocalError::UnsupportedKind` carries the offending `ModelKind` and renders "local {kind} models are not yet supported", a public variant downstream callers can match.
- `start_impl` and `provision_artifacts_impl` check every configured model's kind before the shared server or any model provisions; an all-speech profile produces per-model refusals with zero side effects, a mixed profile provisions only supported models, and FailFast returns the first refusal.
- `launch_options` returns `Result<LaunchOptions, LocalError>` and `launch_options_for` propagates, with existing callers and tests unwrapping.
- `tool_dialect` keeps its chat-default wildcard; that default is deliberate and untouched.

Design: swallowed-exception -> pure-function @ crates/gateway-local/src/runtime.rs::serve_mode_for deps: ModelKind
Design: new surface-growth @ crates/gateway-local/src/error.rs::LocalError::UnsupportedKind boundary: pub
Repairs: A14 @ crates/gateway-local/src/runtime.rs::launch_options - a local speech model launched as a chat server through the catch-all kind arm
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
Speech synthesis requests now have a wire type carrying boundary validation. It rejects an empty model, empty or over-length input, an out-of-range playback speed, unknown audio encodings and stream framings, and reserved keys smuggled into the passthrough map, while every field the gateway does not name is preserved verbatim for the provider. The default audio encoding is pinned in the type itself, because providers disagree on the default and an unpinned one would silently change the wire.

- `SpeechVoice` is an untagged enum accepting a plain name or the OpenAI object form; catalog membership is deliberately not checked in the type, because voice sets are per-checkpoint.
- `SpeechResponseFormat` is a closed set with a structural default: an omitted field resolves to mp3 at deserialization, and provider-only spellings such as raw stay unrepresentable until the enum is deliberately widened.
- `SpeechRequest` keeps every field the gateway does not name in a flattened `rest` map for verbatim passthrough, and `RESERVED` names the seven known keys so a duplicate smuggled into `rest` fails validation.
- `validate` rejects an empty model, empty input, input past 4096 characters, and speed outside 0.25 to 4.0, returning a static reason string for each failure.
- `speech_request_validation_table` pins the cap in characters rather than bytes: 4096 two-byte characters pass and 4097 fail.
- `SpeechRequest` has no production caller in this change; only the new tests construct it.

Design: new bag-of-state @ crates/shared-protocol/src/wire.rs::SpeechRequest
Design: new surface-growth @ crates/shared-protocol/src/wire.rs::SpeechRequest boundary: wire
Design: new value-object @ crates/shared-protocol/src/wire.rs::SpeechVoice boundary: wire
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The upstream abstraction can now forward a speech synthesis request to a backend and return the audio as a stream of opaque bytes, and an upstream with no speech implementation declines the workload by default rather than fabricating a response. The forwarding substitutes the backend's model name for the caller's, sends the endpoint credential exactly as the other workloads do, and passes the audio through untransformed. Time to the first response bytes and per-read idle on the opened body are bounded as two separate budgets, because the HTTP client library arms its read timeout during the header wait and would otherwise cap slow but legitimate generations at the body-idle budget. A mid-stream failure or stall surfaces as one error item and ends the stream, so a truncated response is never mistaken for a complete one.

- `Upstream::send_speech` defaults to `ProtocolError::ModelUnavailable` naming the caller's model, so an upstream without speech refuses the workload; a test drives the default through `Arc<dyn Upstream>` to pin object safety.
- `audio_streaming_client()` carries only a connect timeout and a 60 s TCP keepalive: reqwest arms `read_timeout` during the wait for response headers, so both speech deadlines live in `send_speech` rather than on the client.
- `FIRST_RESPONSE_TIMEOUT` (120 s) bounds the header wait and `AUDIO_READ_TIMEOUT` (30 s) bounds each read of the opened body; both are `cfg(test)`-scaled so the deadline-separation tests run in milliseconds.
- `send_speech` rewrites the request model to `upstream_model`, posts to `audio/speech`, and forwards the body otherwise verbatim; tests pin the model substitution, the bearer forwarding, and byte passthrough including invalid UTF-8.
- `StreamedAudio` carries the upstream `Content-Type` verbatim, empty when the upstream omits it, beside the raw byte stream; a transport failure or read stall yields one `Err` item and then ends the stream.
- `with_client` now backs all three clients from the one injected client, so a test client built with a `read_timeout` also arms the audio path.
- `streaming_client()` and the chat SSE path are unchanged; the audio path is a third client field, not a modification of the existing two.
- `ProtocolError::UpstreamStatus` keeps its existing shape for upstream 429 and 503, status plus capped body; this change adds no error variants and no envelope mapping.

Design: new pure-function @ crates/shared-protocol/src/http_util.rs::audio_streaming_client boundary: pub
Design: new surface-growth @ crates/shared-protocol/src/upstream.rs::Upstream boundary: pub
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The gateway gains a speech synthesis route for speech-kind models: an OpenAI-shaped endpoint that authenticates before parsing the body, validates the request and the voice before queue admission, and then streams the provider's audio bytes back unread. Audio frames are opaque bytes that cannot be re-validated per chunk, so the reply is a byte passthrough rather than the usual typed relay, and a bounded background task owns the upstream body, the queue permit, and the cancellation guard so neither a stalled provider nor a stalled client can hold a queue slot indefinitely. Upstream rate-limit and unavailability statuses map to speech-only 429 and 503 envelopes, while every other route keeps its existing error mapping.

- `audio_speech` runs `check_auth` on the ungated `Caller` parts-extractor before extracting `Json<SpeechRequest>` from the raw `Request`, so an unauthorized caller never makes the gateway parse a body. The kind guard and the voice check also run before dominion queue admission, so a 400 never burns a queue slot.
- `relay_audio` spawns `relay_speech_stream`, a background task that owns the upstream body, the dominion `Permit`, and the `InFlightGuard` cancellation guard and feeds a bounded channel (four data slots plus one reserved for the terminal error) to the HTTP body. `SPEECH_RELAY_TOTAL_LIFETIME` (60 min), `SPEECH_RELAY_BYTE_CEILING` (1 GiB), `SPEECH_RELAY_UPSTREAM_IDLE` (30 s), and `SPEECH_RELAY_DOWNSTREAM_BLOCKED` (60 s) cap every stream, each test-scaled under `cfg(test)`/`test-fixtures`.
- `GatewayError::UpstreamRateLimited` and `GatewayError::UpstreamUnavailable` give the speech route its own 429 `upstream_rate_limited` and 503 `upstream_unavailable` envelopes, matched out of `ProtocolError::UpstreamStatus` at the handler. The shared protocol mapping is untouched, so every other route's envelopes stay bit-identical.
- `GatewayError::InvalidVoice` rejects a voice outside the model's catalog list with 400 `invalid_voice`, and the message names the valid voices. An empty `voices` list skips the check and forwards any voice verbatim.
- `speech_fallback_mime` resolves the response `Content-Type` when the upstream omits or garbles it: the framing selector wins first, so an SSE stream is labeled `text/event-stream` and never an audio type, and only then does the requested format's MIME mapping apply.
- `gateway = { path = ".", features = ["test-fixtures"], default-features = false }` makes the crate dev-depend on itself so every test target builds the library with the relay's test-scaled bounds and no `--features` flag. A recorded side effect: the `not(test-fixtures)` rendezvous test in `boot.rs` now compiles out under every current runner, and its comment says so and names the re-enablement conditions.
- `build_router` registers `POST /v1/audio/speech`, and `admin_status` gains a "Speech synthesis" row through a table-driven refactor of the endpoint list.
- `crates/gateway/tests/it/speech.rs` pins byte-identical passthrough with the `<laugh>` emotion tag intact, the structural mp3 default reaching the provider, and no caller-bearer leak upstream; `kind_mismatch` in both directions (the reverse rows ride along in `chat.rs`, `embeddings.rs`, and `rerank.rs`); voice-before-admission under a full pool; queue-full 503; the permit held until the stream ends; client disconnect aborting the upstream stream; and profile-switch cancellation and mid-stream upstream error each failing the client's body read, with permit release proven by a later request's admission. The relay boundary tests cover the exact byte ceiling, one byte over, the total deadline, a sub-idle drip, an idle upstream, a blocked downstream, and delayed-but-accepted headers.
- `INFERENCE_DRAIN_TIMEOUT` names the profile-switch drain deadline (30 s) and test-scales it to 1 s, so a switch cancellation beats the relay's scaled 2 s total lifetime to the terminal error item.
- `Content-Length` is never set on the speech response, so hyper emits `Transfer-Encoding: chunked`.
- No JSON error envelope can follow 200 plus audio bytes: every terminal path (a bound tripped, an upstream error, a cancellation, the downstream gone) emits exactly one `Err` item, so the client's body read fails instead of seeing a clean EOF.
- `GET /v1/audio/voices` is advertised by the crate-doc header this change writes, but this change registers no such route and adds no handler for it.

Design: new surface-growth @ crates/gateway/src/lib.rs::audio_speech
  boundary: wire
Design: new pure-function @ crates/gateway/src/lib.rs::speech_fallback_mime deps: Option<SpeechStreamFormat>,SpeechResponseFormat
Design: new oversized-unit @ crates/gateway/src/lib.rs::relay_speech_stream
Design: new oversized-unit @ crates/gateway/src/error.rs::gateway_error_classify_is_table_driven
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The gateway gains a read-only route that answers the union of every configured speech model's voices, deduplicated and sorted, as identifier-first entries under a single voices key. OpenAI has no voice-list route, but the OpenAI-compatible ecosystem converged on this shape and clients read the identifier key, so the entry shape is a compatibility surface pinned by the integration suite. Authentication runs before the union is computed, so an unauthenticated request is refused without any voice entry leaving the handler. Each entry's display name mirrors its identifier because the catalog configures voices as bare strings.

- `audio_voices` collects the active profile's speech models' configured voices into a `BTreeSet`, so the union is deduplicated and sorted by construction, then maps each voice to an `{"id", "name"}` object with `name` mirroring `id`. The doc comment records the ecosystem rationale: Kokoro-FastAPI, vLLM-Omni, and Fish Audio converged on this route, and clients such as Open WebUI read the `id` key.
- `build_router` registers `/v1/audio/voices` with `get(audio_voices)` between the speech and models routes, so the route is unconditional like its neighbors.
- `check_auth` runs before the switch lock and the live-state read, so authentication precedes any catalog access in the handler.
- `voices_route_deduplicates_and_sorts_across_speech_models` pins cross-model deduplication and ordering on the raw body; companion tests pin the id-first shape, the empty union without speech models, and non-speech exclusion.
- `unauthenticated_voices_request_is_refused_before_the_union_is_computed` pins the 401 `unauthorized` envelope and asserts no `voices` key is present, so the refusal happens before any union is computed.
- `catalog_gateway` exists only to satisfy config validation: its doc comment states the voices route never calls an upstream, and the fixture's recorder is unused.

Design: new surface-growth @ crates/gateway/src/lib.rs::audio_voices deps: Caller,State<AppState> boundary: wire
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
A dev-only Node script can now build the gateway fresh, boot it with a throwaway speech profile on loopback, and assert the speech and voices surfaces through the gateway's own responses, so the wire contract can be checked against a live provider on demand. The vendor key comes from the process environment only and is ferried to the gateway subprocess, whose configuration interpolation is what ultimately receives it; the script never contacts a provider directly and never prints or persists the key. Every wait is bounded, and the gateway subprocess is terminated on success and on failure. With no key in the environment the probe prints a skip and exits successfully without building or touching the network. A verification note records the probe's contract and assertion set now, defers the live run's provenance and observed dialect to the phase 3 rerun, and nine offline tests drive the probe against a fake gateway double.

- `tools/gateway-tts-live.mjs` always builds the gateway with `cargo build -p gateway` before booting it, so a stale binary can never be probed against a recorded current commit. It boots the fresh binary on an ephemeral loopback port with a throwaway Together-backed profile written into a temp directory that the run deletes afterward.
- `runProbe` takes its collaborators as injected parameters (environment, build, spawn, fetch, and every timeout), so the offline suite drives the full probe against a fake gateway with no build and no network. With no key in the environment it logs a skip and returns 0 before anything builds.
- `renderConfig` writes the vendor credential as the `api_key = "${TOGETHER_API_KEY}"` interpolation reference only, and the probe ferries the key solely into the gateway subprocess environment. The key is never printed, never written to a file, and never sent anywhere except through the gateway.
- `GATEWAY_KEY` is a throwaway shared secret that exists only so the booted gateway serves with authentication enabled; it never leaves the loopback listener and is not a real credential.
- `runSpeechSurface` asserts the speech contract through the gateway's responses: the default call returns `audio/mpeg` with mp3 magic bytes, which proves the structural format pin reached the provider because the provider's own documented default is wav; the response carries no `Content-Length`; a wav request returns `audio/wav` with a RIFF body; and an input carrying `<laugh>` returns 200 with audio.
- `VOICES` names the eight configured Orpheus voices, and the voices assertion requires the union answer to list them sorted as `{"id", "name"}` entries with `name` mirroring `id`.
- `instructions` is probed as the named optional wire field, while `sample_rate` and the bogus `promptforge_probe` ride the verbatim passthrough; all three must come back 2xx.
- `stopGateway` escalates from SIGTERM to SIGKILL inside a bounded grace window, and the suite asserts every spawned gateway child is dead after success, boot failure, and assertion failure alike.
- `tools/gateway-tts-live.test.mjs` runs nine tests against a fake gateway double whose behavior modes arrive through its environment, exactly where the real gateway would find the vendor key. A canary credential and a sink file prove the key reaches the subprocess unmodified while never appearing in the probe's output or in the rendered config.
- `design/note-gateway-tts-phase-1-verification.md` records the probe's run boundary and assertion set now and leaves the commit, tree hash, and observed-dialect lines as placeholders for the phase 3 rerun.
- `UpstreamRateLimited` and `UpstreamUnavailable` envelopes are not provoked on the paid provider unless a cost-free trigger exists; those mappings stay covered by the gateway integration suite.
- `design/note-gateway-tts-phase-1-verification.md` also names what the probe never exercises live: voice rejection, kind mismatch, and mid-stream disconnect, all left to the integration suite.

Design: new pure-function @ tools/gateway-tts-live.mjs::renderConfig deps: port
Design: new oversized-unit @ tools/gateway-tts-live.mjs::runSpeechSurface
Design: new oversized-unit @ tools/gateway-tts-live.mjs::runProbe
Deferred: the live run and its observed-dialect record (commit, tree hash, dialect observations) to the Phase 3 rerun
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The gateway's operator documentation now covers remote speech synthesis: how to declare a speech model, how to call the synthesis route, and how to list voices. An omitted audio format pin resolves to mp3 so a Together-backed model does not silently answer wav, and a missing or invalid content type is labeled as an event stream before any audio type. Together's event-stream mode cannot be requested because the wire type rejects the format it requires, so Together speech stays non-streaming and event-stream forwarding is only forward-looking for providers that already speak that dialect. Later guide chapters shift down by one to make room, the remote and local kind lists include speech, and a commented catalog example shows a remote speech model. Local speech models are documented as refused at launch because no local speech runtime exists yet.

- `crates/gateway/README.md` now lists `POST /v1/audio/speech` and `GET /v1/audio/voices` in the served-route paragraph and adds a Speech synthesis models section with a Together-backed catalog example.
- `guide/src/gateway/06-speech-synthesis.md` is a new chapter inserted after speech-to-text. Chapters 06 through 10 become 07 through 11, `guide/src/SUMMARY.md` and the gateway index list the new chapter, and the assembled gateway guide regenerates with the same shift; the design report's serving-chapter citation follows to `11-serving-and-observing.md`.
- `gateway.local.example.toml` adds a commented Together-backed `[[endpoint]]` and speech `[[model]]` with `voices`, beside the existing commented chat block.
- `guide/src/gateway/03-remote-models.md` adds `speech` to the kind list and to the enumerated spelling vocabulary.
- `POST /v1/audio/speech` is documented as the OpenAI-shaped route: `model`, `input` (non-empty, capped at 4096 characters), and `voice` (a name or `{"id": "..."}`) are required; omitted `response_format` resolves to `mp3` in the wire type; `speed`, `instructions`, and `stream_format` are optional, and unnamed fields pass through verbatim.
- `text/event-stream` is the documented Content-Type fallback when the upstream type is missing or invalid and the framing selector is SSE, so an event stream is never labeled as an audio type; only then does the requested format's MIME type apply, and `Content-Length` is never set.
- `stream_format` is forwarded, but Together's `stream=true` mode requires `response_format = "raw"`, which the wire enum rejects, so Together SSE cannot be requested and Together speech is non-streaming; the forwarding is forward-looking for SSE-capable OpenAI-compatible providers.
- `GET /v1/audio/voices` is documented as the active profile's deduplicated, sorted union of id-first `{"id", "name"}` entries, with `name` mirroring `id`.
- `guide/src/gateway/04-local-models.md` states a `speech` kind has no local serving mode and is refused at launch: no local speech runtime exists yet.
- `stream_format = "sse"` is forwarded with no reframing or decoding: whatever framing the provider answers with passes through untouched.

Design: new clone-block @ crates/gateway/README.md
Design: new clone-block @ guide/src/gateway/06-speech-synthesis.md
Design: new shotgun-surgery @ guide/src/gateway/06-speech-synthesis.md
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The gateway now carries a standalone record of the speech synthesis design that actually shipped, so later work can navigate the as-built choices rather than the original report. Speech is a routed catalog kind with remote passthrough, inline handlers, authentication before body parse, voice checks before queue admission, a bounded background relay for unread audio, split header and body deadlines, and speech-only upstream rate-limit envelopes. Local launch refuses the kind before any provision. A gateway-only Node probe takes the vendor key from the process environment and ferries it only to the gateway subprocess. Catalog examples use the shipped interpolation form, and live-provider verification remains for a later rerun.

- `design/design-gateway-tts-phase-1.md` records sixteen as-built choices, naming preceding commits `05dd8eda`, `5392c357`, `ffc535eb`, `6506f4ca`, `b32c64c6`, `738cfc7c`, `7f3cf607`, and `9153cd7c` as the series this document closes.
- `ModelKind::Speech` is recorded as a fourth routed catalog kind with `audio_speech` and `audio_voices` inline in the gateway crate. An engine-slot catalog and a `gateway-tts` service crate are recorded as rejected; branch `gate-way-tts-phase-1` was discarded, not ported.
- `relay_speech_stream` is recorded as a bounded background relay that owns the upstream body, the dominion permit, and the cancellation guard, with named ceilings on total lifetime, response bytes, upstream idle after headers, and blocked downstream delivery. Every terminal path emits one error item so cancellation never surfaces as a clean end of stream.
- `send_speech` is recorded as owning both the first-response deadline and the per-read body idle deadline; `audio_streaming_client` carries connect timeout and keepalive only.
- `ProtocolError::classify` is recorded as frozen; speech maps upstream 429 and 503 through `GatewayError::UpstreamRateLimited` and `GatewayError::UpstreamUnavailable` only.
- `serve_mode_for` is recorded as a side-effect-free preflight so a local speech model fails before any server, model, companion, or cache side effect.
- `check_auth` is recorded as running before body extraction, and catalog voice membership as checked before queue admission so a client error never occupies a queue slot.
- `tools/gateway-tts-live.mjs` is recorded as a gateway-only Node probe: the vendor key comes from the process environment and is ferried only to the gateway subprocess, whose throwaway profile uses `api_key = "${TOGETHER_API_KEY}"` interpolation and never a direct vendor call.
- `ServeMode::Speech` is absent, as are a local engine, encoder and WAV-header policy, sampling pins, Together SSE reframing, Workshop playback, the config-UI voice picker, text conditioning, and ElevenLabs and Baseten adapters. Live-provider verification remains for the Phase 3 rerun; this document names no live commit or tree hash.

Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
close cppalliance#21

A kind switch away from speech hides the voices control but keeps its value in the draft, and the server rejects a non-speech model that carries voices, so the save failed with no visible field to clear. The new stripKindBoundFields runs in buildSavePayload and deletes voices from the payload when the effective kind is not speech.

- \stripKindBoundFields\ runs on both payload paths: the draft branch that pushes \entry.data\ and the edit branch that writes the edits map into the loaded entry.

- The value stays in the draft after the strip, so toggling the kind back to speech before Save restores the voices control with its chips intact.

- The new model-detail test picks speech, adds a chip, switches back to chat, and pins that the PUT body carries kind chat and no voices key.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gateway: OpenAI-shaped text-to-speech endpoint (phase 1: remote passthrough)

2 participants