Skip to content

Add TTS endpoint (Phase 1) - #21

Draft
wpak-ai wants to merge 11 commits into
cppalliance:masterfrom
wpak-ai:add-tts-phase-1
Draft

Add TTS endpoint (Phase 1)#21
wpak-ai wants to merge 11 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

Closes #20

@wpak-ai
wpak-ai marked this pull request as draft September 8, 2026 15:23
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 the list is rejected outright 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 appears 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: launch_options becomes fallible with a refuse-unknown wildcard
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
A local model whose kind has no launch mode previously fell through a wildcard arm and started as a chat server. Building launch options is now fallible, and speech along with every kind the mapping does not name fails with a dedicated error instead. Because the kind enum is open-ended, a kind added later can never silently inherit the chat default.

- `LocalError::UnsupportedKind` carries the offending `ModelKind` on the public, non-exhaustive error enum and renders "local {kind} models are not yet supported", so the refusal names the kind it rejected.
- `launch_options` now returns `Result<LaunchOptions, LocalError>`; the serve-mode mapping lists chat, embedding, and classifier explicitly, and both the speech arm and the retained wildcard arm fail instead of defaulting to `ServeMode::Chat`. The wildcard stays because `ModelKind` is `#[non_exhaustive]`: a kind added after this mapping fails loudly instead of launching as a chat server.
- `launch_options_for` propagates the new failure with `?` ahead of chat-template resolution.
- `speech_kind_refuses_to_launch_as_chat` pins the refusal: the error is `LocalError::UnsupportedKind` with `ModelKind::Speech` and the exact message text, while the existing launch-options tests unwrap the result.
- No `ServeMode` arm for speech is added; no local speech runtime exists yet, so the error is the entire launch behavior for that kind.

Design: new surface-growth @ crates/gateway-local/src/error.rs::LocalError boundary: pub
Violates: A2 - not determinable from diff
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
Add the wire contract for incoming speech synthesis requests to the shared protocol layer. The request carries the named fields an OpenAI-shaped speech call needs and preserves every unnamed field verbatim, so provider-specific dialect fields pass through untouched. Validation at the trust boundary rejects an empty model, empty or over-cap text, out-of-range speed, and reserved keys smuggled into the passthrough map. The audio format set is closed with its mp3 default pinned in the type, so an omitted field resolves to mp3 at deserialization and no route can forget the pin.

- `SpeechRequest` names seven fields plus a flattened `rest` map that keeps every unnamed field verbatim; the RESERVED list pins the seven keys the passthrough map must never carry.
- `SpeechVoice` accepts both the plain name and the object form; membership in a model's catalog is checked at the route, never in the type, because voice sets are per-checkpoint.
- `SpeechResponseFormat` is a closed set whose serde default resolves an omitted field to mp3; provider-only spellings fail deserialization, so no route can forward one.
- `validate` returns a static reason string for an empty model, an empty or over-cap input (4096 characters), a speed outside 0.25 to 4.0, or a reserved key in `rest`.
- `speech_request_validation_table` and its companion tests pin the cap at 4096 versus 4097 characters, both speed bounds, both voice forms round-tripping, the mp3 default serializing back onto the wire, and verbatim passthrough of unnamed fields.
- `SpeechRequest` is constructed only by its tests in this change; no route or upstream consumes it yet.

Design: new surface-growth @ crates/shared-protocol/src/wire.rs::SpeechRequest boundary: wire
Design: new value-object @ crates/shared-protocol/src/wire.rs::SpeechResponseFormat instead-of: stringly-typed: provider-only spellings stay unrepresentable until the enum is deliberately widened
Design: new value-object @ crates/shared-protocol/src/wire.rs::SpeechStreamFormat
Design: new pure-function @ crates/shared-protocol/src/wire.rs::SpeechRequest::validate
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The upstream abstraction now forwards speech synthesis requests and returns provider audio as a stream of opaque bytes. A speech call substitutes the routed model name, posts to the provider's audio endpoint, forwards the response content type verbatim, and passes the audio through untransformed, because the gateway never inspects or rewrites audio frames. Upstreams with no speech implementation decline the workload with a model-unavailable error rather than fabricate a response. A dedicated client for long-lived audio drops the whole-request timeout that would kill any lengthy stream, bounds stall detection with a per-read idle timeout, and keeps middleboxes from dropping idle connections.

- `StreamedAudio` pairs the upstream content type with the raw byte stream; an empty content type is the route's signal to apply its format-to-MIME fallback, and dropping the stream drops the upstream response and aborts the connection.
- `Upstream::send_speech` defaults to `ProtocolError::ModelUnavailable` naming the caller's model, so an upstream without a speech implementation declines the workload; the signature stays object-safe through `Arc<dyn Upstream>`.
- `audio_streaming_client` carries no whole-request timeout, a 30-second per-read idle timeout, and 60-second TCP keepalive, so a stalled or silently dead audio stream is detected without capping the stream's total length.
- `http_audio` is a third client on `OpenAiUpstream` dedicated to the speech path; the chat SSE client is untouched.
- `OpenAiUpstream::send_speech` rewrites the request model to the upstream alias, posts to the audio speech endpoint over the audio client, and maps mid-stream read failures to `ProtocolError::upstream_transport` as explicit stream errors rather than silent truncation. Non-success statuses arrive through the existing post helper as `ProtocolError::UpstreamStatus` with a capped body, so no error-path work exists here.
- `OpenAiUpstream::with_client` now serves all three client roles from the one injected client, which is how the tests inject a short read deadline against a stalled server.
- `bytes = "1"` is pinned once in the workspace manifest and taken up as `bytes.workspace = true`, the house convention for shared external dependencies.
- `speech_rewrites_caller_model_and_posts_to_audio_speech` and its companions pin model substitution with no caller-name leak, bearer credential forwarding, the 429 and 503 status shape with capped body, untransformed bytes including invalid UTF-8, the empty content type, stall timeout as a transport error, and the object-safe default refusal.
- `crates/shared-protocol/README.md` now names streaming speech among the passthrough workloads the crate describes.
- `ProtocolError::classify` and its table test stay frozen: the protocol error type is untouched, the speech-only envelope seam belonging to the gateway crate's own error type. Within this change only the tests exercise `send_speech`; no route consumes it yet.

Design: new surface-growth @ crates/shared-protocol/src/upstream.rs::StreamedAudio boundary: pub
Design: new surface-growth @ crates/shared-protocol/src/upstream.rs::Upstream::send_speech boundary: pub
Design: new surface-growth @ crates/shared-protocol/src/http_util.rs::audio_streaming_client boundary: pub
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The gateway gains a speech synthesis route that authenticates the caller before parsing the request body, guards the routed model's kind, and validates the requested voice against the model's catalog before queue admission, so a client error never consumes a queue slot. Because audio frames are opaque bytes that cannot be revalidated chunk by chunk, the reply is a byte passthrough: the provider's stream is forwarded unread under its own content type, with the requested format's MIME type as the fallback and no content length, and the queue permit is held for the stream's entire life. Upstream rate limiting and unavailability map to speech-only retryable error envelopes while every other route keeps its existing mapping, and a mid-stream failure or cancellation surfaces as truncation rather than an error tail.

- `audio_speech` runs `check_auth` on the ungated `Caller` parts extractor before extracting `Json<SpeechRequest>` by hand, so an unauthorized caller never makes the gateway parse a body, and it validates the requested voice against the model's catalog before queue admission, so a rejected voice never burns a queue slot.
- `relay_audio` re-emits the upstream byte stream unread, holding the queue permit and the in-flight guard inside the stream state for the body's whole lifetime; a mid-stream upstream failure propagates as a body error and a profile switch ends the stream early, so no error envelope ever follows audio bytes.
- `GatewayError::UpstreamRateLimited` and `GatewayError::UpstreamUnavailable` are speech-only variants that map an upstream 429 and 503 to retryable 429 and 503 envelopes; every other route keeps the shared `ProtocolError` mapping, so existing envelopes stay bit-identical.
- `GatewayError::InvalidVoice` carries the requested voice and the model's catalog list, so the 400 `invalid_voice` envelope names the valid voices.
- `build_router` registers `POST /v1/audio/speech` unconditionally, and `admin_status` gains a Speech synthesis row through a table-driven rewrite of the endpoint list.
- `speech_mime` maps each response format to its OpenAI MIME spelling and is the content-type fallback when the upstream omits the header or sends an unparsable one; content length is never set, so the response streams chunked.
- `crates/gateway/tests/it/speech.rs` adds sixteen integration tests over three fake backends: byte-identical passthrough with no caller-bearer leak, the per-format fallback table, kind and voice rejection, voice-before-admission under a full pool, queue-full 503, the permit held until stream end, disconnect releasing the permit, mid-stream failure with no spliced envelope, the 429 and 503 mappings, and provider internals never reaching the client.
- `speech_auth_tests` pins the auth ordering through the real router: a 401 before a malformed body is parsed, and an authenticated malformed body in the OpenAI envelope as `malformed_request`.
- `chat.rs`, `embeddings.rs`, and `rerank.rs` each add a configured speech model to the loop that asserts `kind_mismatch` on the wrong route.
- `GET /v1/audio/voices` is named in the crate doc header but has no handler or registration in this change.

Design: new surface-growth @ crates/gateway/src/lib.rs::audio_speech deps: Caller,Request,State<AppState> boundary: wire
Design: new pure-function @ crates/gateway/src/lib.rs::speech_mime deps: SpeechResponseFormat
Design: new surface-growth @ crates/gateway/src/error.rs::GatewayError::InvalidVoice boundary: wire
Design: new surface-growth @ crates/gateway/src/error.rs::GatewayError::UpstreamRateLimited boundary: wire
Design: new surface-growth @ crates/gateway/src/error.rs::GatewayError::UpstreamUnavailable boundary: wire
Deferred: the GET /v1/audio/voices handler and its registration
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The gateway gains a voice-listing route that answers the union of every speech model's configured voices in the active profile, deduplicated and sorted, so a client can discover which voices it may request before synthesizing. OpenAI has no voice-list route, but the OpenAI-compatible ecosystem converged on one whose entries lead with the voice identifier, so each entry pairs that identifier with a display name that mirrors it. The route is bearer-authenticated and reads only live catalog state; it never calls an upstream.

- `audio_voices` returns id-first `{"id", "name"}` entries under `{"voices": [...]}` with `name` mirroring `id`, because ecosystem clients read the `id` key and the catalog configures voices as bare strings with no separate display name; the entry shape is a compatibility surface pinned on the raw response body.
- `build_router` registers `GET /v1/audio/voices` unconditionally beside `/v1/audio/speech`.
- `audio_voices` authenticates through `check_auth`, holds the publication lock while reading live routing, and collects the speech models' voices into a `BTreeSet`, so the union is deduplicated and sorted.
- `crates/gateway/tests/it/speech.rs` gains the `catalog_gateway` and `voices_body` helpers and four integration tests pinning the id-first union shape, deduplication and ordering across two speech models, the empty union when no speech model is configured, and non-speech models contributing nothing.

Design: new surface-growth @ crates/gateway/src/lib.rs::audio_voices deps: Caller,State<AppState> boundary: wire
Pending: N70 - compounds
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
A dev-only parity probe now drives the official OpenAI Python SDK against a real gateway instance and against the live Together AI provider with the same call set, so provider dialect drift surfaces as a failing assertion rather than a stale document. The probe is gated on the vendor key: when the key is absent the script prints a skip message and exits successfully without touching the network, and when present the key travels only through the process environment while the throwaway gateway config carries an interpolation reference. The accompanying verification note records a green run against the live provider, the observed provider dialect, and the behaviors deliberately left to the integration suite.

- `tools/gateway-tts-parity.py` is dev-only and gated: with no TOGETHER_API_KEY in the environment or the repo dotenv it prints a skip and exits zero without touching the network, and the key is never printed or written, since the throwaway config carries only the interpolation reference and the gateway subprocess receives the value through its environment.
- `run_speech_surface` drives the identical call set against the gateway and the live provider from one body, a boolean selecting the extra gateway-only contract checks; both sides assert the provider-stable subset the first live run observed, while genuinely volatile provider behavior stays on the observe path.
- `render_config` emits a throwaway profile on an ephemeral loopback port: one Together endpoint, one speech model upstreaming to Orpheus with the eight voices, and a local-only bearer key that never leaves the loopback listener.
- `speech_create` and `voices_call` return observation dicts with HTTP errors as data, so a provider error shape fails or informs an assertion instead of raising.
- `main` builds the debug gateway when no binary exists, polls readiness on a 90-second deadline, terminates the subprocess in a finally block, and exits nonzero only when a hard assertion failed.
- `design/note-gateway-tts-phase-1-verification.md` records the green live run: the gateway default answers mp3 against a provider whose own default is wav, both sides stream chunked with no content length, emotion tags and unknown fields pass with 200, and the provider has no voices route at all.
- `tools/gateway-tts-parity.py` provokes no 429 or 503 envelope on the paid provider and never exercises voice rejection, kind mismatch, or mid-stream disconnect, all of which stay with the Rust integration suite. The script carries no tests of its own; the live run is its verification.

Design: new flag-parameter @ tools/gateway-tts-parity.py::run_speech_surface deps: api_key,base_url,gateway,label,model
Design: new global-state @ tools/gateway-tts-parity.py::FAILURES
Design: new swallowed-exception @ tools/gateway-tts-parity.py::read_dotenv_key
Violates: A2 - tools/gateway-tts-parity.py holds TOGETHER_API_KEY outside the gateway and calls the provider directly
Deferred: live-provider observation of the 429 and 503 envelopes, which the note leaves to the Rust integration suite
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
Document the speech synthesis surface: how a speech model is declared in the catalog, how the synthesis route is called, and how voices are enumerated. A new guide chapter carries the material ahead of the profiles chapter, the following chapters shift one number up, and the table of contents, the chapter index, and the compiled guide are regenerated to agree. The gateway crate documentation gains a speech section beside the transcription section, the example configuration gains a commented speech model block, and the kind lists in the remote and local model chapters now include speech. The design report's citation of the serving chapter follows the renumber.

- `guide/src/gateway/06-speech-synthesis.md` - New chapter inserted at number 06; the five chapters after it move one number up as pure renames, the table of contents and chapter index are updated to match, and the design report's citation of the serving chapter follows the move.
- `guide/promptforge-gateway-guide.md` - Regenerated compiled output committed in the same change, so the single-file guide mirrors the source chapter edits verbatim.
- `kind = "speech"` - One canonical catalog example, an endpoint plus a speech model with its voice list, now lives in three places: the crate documentation, the new chapter, and the commented block in the example configuration.
- `crates/gateway/README.md` - Documents the route dialect: input capped at 4096 characters, an omitted response format pinned to mp3 in the wire type, an unknown voice rejected as a 400 before queue admission, upstream 429 and 503 answers mapped to distinct client-facing envelopes, and provider audio streamed through with no content length set.
- `guide/src/gateway/04-local-models.md` - Documents that a local model with speech kind is refused at launch because no local speech runtime exists yet.

Design: new shotgun-surgery @ guide/src/gateway
Design: new clone-block @ guide/src/gateway/06-speech-synthesis.md
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
The speech synthesis feature now has its as-built design record. The new document states what the gateway's first speech phase became as built: a standalone executive summary, sixteen numbered design choices reconciled against the finished work, and the list of items deferred to the next phase. It fixes the design facts and their rationale at the moment of completion, so a later pass over the log does not have to reconstruct them from the feature's eight commit messages.

- `design/design-gateway-tts-phase-1.md`: new as-built design document, a title naming the routed speech kind with remote passthrough, a standalone executive summary, sixteen numbered design choices reconciled against the finished work and its decision record, and a deferred-to-phase-2 section.
- `design/design-gateway-tts-phase-1.md`: no code, test, or configuration change rides with the document; the commit is prose-only.

Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
Plan: vibe/2026-09-07-2-gateway-tts-phase-1.md
@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
The live speech parity probe ran every call twice, once through the gateway and once directly against the vendor, which placed the vendor credential outside the gateway and contradicted the invariant that the gateway alone holds it. The probe now drives only the gateway, and the vendor key reaches the provider exclusively through the gateway subprocess environment, so provider drift still surfaces through the same assertions. A credential file that exists but cannot be read now fails the run loudly instead of passing silently as a skip. The boolean that selected the gateway-only checks went away with the second call set, and those checks now run unconditionally. The design report and the verification note record the removal and keep the observed provider dialect as a one-run historical record.

- `tools/gateway-tts-parity.py` now drives only the gateway: the provider is reached exclusively by the gateway subprocess, and the module docstring states the credential invariant outright. Dev tooling admits no exception to gateway-held vendor credentials.
- `run_speech_surface` loses its `gateway` flag parameter with the provider-direct call set gone. The mp3-default, streamed-response, and voices-union checks it selected now run unconditionally against the gateway.
- `read_dotenv_key` returns None only for a missing file; any other OSError propagates, and `main` answers with a FAIL line and exit 1. An unreadable credential file no longer reads as an absent key.
- `TOGETHER_BASE_URL` is gone as a module constant; the vendor URL survives only as text inside the throwaway gateway config. The direct provider call in `main` that consumed it is removed with its `gateway=False` call site.
- `voices_call` drops the `snippet` field from both return shapes; the provider-side observe branch was its only consumer.
- `design/note-gateway-tts-phase-1-verification.md` gains an addendum recording the removal: the dialect it preserves stands as a one-run historical record, and re-verification runs through the gateway alone.

Design: removes hidden-dependency @ tools/gateway-tts-parity.py::TOGETHER_BASE_URL
Design: removes swallowed-exception @ tools/gateway-tts-parity.py::read_dotenv_key deps: path
Design: removes flag-parameter @ tools/gateway-tts-parity.py::run_speech_surface deps: api_key,base_url,label,model
Plan: none
@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?

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