Skip to content

feat: Phase 3 — streaming TTS WebSocket & self-hosted distribution credentials - #166

Open
dg-coreylweathers wants to merge 12 commits into
feat/phase-2-read-stream-modelsfrom
feat/phase-3-tts-ws-selfhosted
Open

dg-coreylweathers wants to merge 12 commits into
feat/phase-2-read-stream-modelsfrom
feat/phase-3-tts-ws-selfhosted

Conversation

@dg-coreylweathers

Copy link
Copy Markdown
Contributor

Phase 3 — Streaming TTS WebSocket + self-hosted credentials

Stacked on #165 (Phase 2). Base is feat/phase-2-read-stream-models; GitHub will retarget to main as the stack merges.

What's included

  • [Enhancement] Add Text-to-Speech WebSocket streaming support #148 / [Enhancement] Add Text-to-Speech WebSocket streaming support #147 / Text to Speech - Websocket API #95 — Streaming TTS over a WebSocket. Speak::speak_stream() → SpeakStreamBuilder (model / encoding / sample rate); handle() opens the connection → SpeakStreamHandle with speak / flush / clear / close, and receives audio + events. SpeakStreamHandle: futures::Stream<Item = Result<SpeakResponse>>. SpeakResponse (Audio / Metadata / Flushed / Cleared / Warning / Unknown) is #[non_exhaustive]; unknown message types are preserved rather than breaking the stream. The speak feature now pulls the WebSocket deps. Modeled on src/listen/websocket.rs. Example text_to_speech_websocket.
  • Self-hosted distribution credentials. Deepgram::self_hosted() → SelfHosted with list / get / create / delete_distribution_credentials + typed response models. Example self_hosted_credentials.

Verification (Rust 1.97 container)

  • build / clippy -D warnings / fmt / cargo test --all --all-features (142 tests, incl. TTS-WS protocol + self-hosted deserialization tests) ✅
  • per-feature cargo check incl. speak-only (now pulls WS deps) ✅
  • cargo semver-checks: no new breaking changes (only the inherited Phase 1 Extra metadata support #130 streaming breaks).
  • Live-verified against production: TTS WebSocket connected, emitted Metadata + Flushed, and streamed 219 KB of audio. Self-hosted list returns a correct 403 INSUFFICIENT_PERMISSIONS on accounts without the self-hosted scope (confirms the request path).

Closes #148, #147, #95

@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

Addressed the review's BLOCKING finding: the TTS-WS worker busy-spun (and hung on current-thread runtimes) after input close because it kept selecting on the drained input channel. run_worker now stops selecting on message_rx once input is closed and only awaits server messages (via a shared handle_incoming helper). Verified: re-ran the example under #[tokio::main(flavor = "current_thread")] — it completes cleanly (219KB audio) where it previously would hang. Also documented the send-then-drain contract and the nil-request_id fallback on SpeakStreamHandle. The self-hosted create/get envelope matches list per the API docs (no secret token field in the create response).

@GregHolmes GregHolmes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: deepgram-rust-sdk #166, feat: Phase 3 - streaming TTS WebSocket and self-hosted distribution credentials

Classification: mixed (code-led)
Verdict: request-changes

Intent

Add WebSocket streaming TTS and self-hosted distribution-credential management while keeping the SDK's existing async patterns. This PR is intentionally stacked on #165 and cannot merge until #164 and #165 are fixed, merged, and the stack is retargeted. Intent is based on the PR, issues #95, #147, and #148, and the author's live-verification comment; no launch or Slack context is linked.

Blocking

  • [B1] src/speak/websocket.rs:161, streaming TTS silently drops documented metadata.

Title: [B1] SpeakResponse::Metadata omits additional_model_uuids

Summary: The streaming TTS API's Metadata event includes the optional additional_model_uuids array, but neither TextEvent::Metadata nor the public SpeakResponse::Metadata carries it. Serde ignores the unknown field, so a successful request silently loses model provenance that a caller may need for auditing or billing.

Expected: All documented fields from a streaming TTS Metadata event are available in the typed response.

Observed: additional_model_uuids is discarded during deserialization and cannot be recovered by SDK users.

Recommended fix: Add additional_model_uuids: Option<Vec<Uuid>> to TextEvent::Metadata and SpeakResponse::Metadata, pass it through the conversion, and add a fixture that asserts the values survive parsing.

  • [B2] src/speak/websocket.rs:98, the streaming TTS builder does not enforce or expose the endpoint's option contract.

Title: [B2] Streaming TTS accepts invalid encodings and omits supported controls

Summary: The streaming endpoint supports only linear16, mulaw, and alaw, but SpeakStreamBuilder::encoding accepts the REST Encoding enum, including mp3, opus, flac, aac, and arbitrary custom values. Conversely, the endpoint documents speed and mip_opt_out, but the builder has no setters or query-parameter escape hatch, so callers cannot use them at all.

Expected: The streaming API rejects locally known-invalid encodings before connecting and exposes every documented streaming control.

Observed: Encoding::Mp3 is serialized into a request the server rejects, while speed and mip_opt_out cannot be sent through this client.

Recommended fix: Use a streaming-specific encoding enum or validate the existing enum in handle() with a clear DeepgramError::InvalidOptions; add speed and mip_opt_out to the builder, with the documented 0.7..=1.5 range for speed, and cover valid and invalid URLs in tests.

Should-fix

  • [S1] src/speak/websocket.rs:534, protocol tests do not cover every supported client and server message.

Title: [S1] Streaming TTS protocol coverage omits Clear, Cleared, and Warning paths

Summary: The unit tests assert Speak, Flush, Close, Metadata, Flushed, and Unknown behavior, but omit Clear transmission and Cleared and Warning parsing. These are public control paths; a future serde rename or refactor can break them without a test failure.

Expected: Each public protocol message has an exact serialization or parsing assertion.

Observed: The available tests do not exercise Clear, Cleared, or Warning.

Recommended fix: Extend the existing table-style tests with those three cases, including optional sequence_id, description, and code values.

Nits

  • None.

Verified (evidence)

  • The streaming TTS WebSocket path, ClientMessage variants, required metadata fields, Flush/Clear/Close messages, and supported encoding list align with the live API reference.
  • The self-hosted list and create paths, request body/query placement, defaults, and response envelope align with the live Self-Hosted API reference.
  • Local Rust 1.94.1: build, tests (79 unit plus 140 doc-tests), clippy, fmt, docs, and cargo check --no-default-features --features speak pass. The rust:1.97 image named in the PR contains neither cargo nor rustup, so it cannot run the declared container gate.
  • The prior busy-spin report is fixed in the current worker: once the input closes, it stops selecting on the drained input channel and drains server messages only.

Needs human

  • Greg: the stack depends on #164 and #165. Both have requested-changes reviews and #164 conflicts with main, so this PR must stay unmerged until their fixes are merged and the stack is retargeted. The inherited SemVer failures are a release-process decision for the planned 0.11.0 aggregation.

Developer-facing messaging

  • The changelog accurately says the speak feature now pulls WebSocket dependencies. It should not imply the typed Metadata event is complete until [B1] preserves additional_model_uuids.
  • Rejecting unsupported output encodings before opening the socket and exposing documented controls gives developers a clear local error instead of an avoidable server-side failure.

@dg-coreylweathers
dg-coreylweathers force-pushed the feat/phase-2-read-stream-models branch from a5fa559 to 4c76c47 Compare September 15, 2026 14:02
dg-coreylweathers added a commit that referenced this pull request Sep 15, 2026
… validation, speed/mip_opt_out, protocol test coverage

- B1: `SpeakResponse::Metadata` and the internal `TextEvent::Metadata` now
  carry `additional_model_uuids: Option<Vec<Uuid>>` (absent -> None), passed
  through the conversion, with a parsing fixture asserting the values survive
  (present, absent, and empty array).
- B2: `SpeakStreamBuilder::handle()` validates options locally before
  connecting. REST-only encodings (`mp3`, `opus`, `flac`, `aac`) and a `speed`
  outside `0.7..=1.5` (or non-finite) are rejected with the new
  `DeepgramError::InvalidOptions(String)`; `linear16` / `mulaw` / `alaw` pass
  and `CustomEncoding` is left as an escape hatch. New `speed(f32)` and
  `mip_opt_out(bool)` setters serialize as `speed=` / `mip_opt_out=` query
  params. Tests cover valid/invalid URLs, each rejected encoding, the speed
  boundaries, and that `handle()` short-circuits without a network attempt.
- S1: protocol tests now cover `Clear` client serialization and table-style
  parsing of `Cleared` (with/without `sequence_id`) and `Warning` (all
  combinations of `description` / `code`), matching the live API reference.
- CHANGELOG: Unreleased entry mentions `speed`, `mip_opt_out`, local encoding
  validation via `DeepgramError::InvalidOptions`, and `additional_model_uuids`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dg-coreylweathers
dg-coreylweathers force-pushed the feat/phase-3-tts-ws-selfhosted branch from 570125f to 8052666 Compare September 15, 2026 14:04
dg-coreylweathers added a commit that referenced this pull request Sep 15, 2026
…rward, and correct the streaming TTS docs

Follow-ups from the #166 review:

- `speak::options::Model` was missing `aura-2-perseo-it`, the 91st Aura-2
  voice in the API specification. Add it in locale order and make the
  round-trip test assert the exact variant count (103) instead of a lower
  bound, so the next specification drift fails loudly.
- Document that a hand-built `Model::CustomId` is not `==` to the named
  variant whose wire string it spells.
- `src/tls.rs`, the `Deepgram::tls_config` rustdoc, and `README.md` each
  enumerated only three `wss://` surfaces; streaming text-to-speech goes
  through the same shared rustls connector, so name it.
- AGENTS.md: mark streaming text-to-speech and self-hosted credentials as
  shipped with their real entry points (the documented self-hosted path was
  wrong too: it is `/self-hosted/distribution/credentials`, not `/onprem/`),
  add `src/speak/websocket.rs` and `self_hosted` to the repository map, and
  correct the default-features list.
- The installable text-to-speech skill said the Aura `/v1/speak` WebSocket
  was not implemented. Correct every such claim and teach the real API.
- `SpeakResponse::Metadata::additional_model_uuids` is now
  `Option<Vec<String>>`, matching `model_uuid`: one non-UUID element no
  longer downgrades a readable `Metadata` event to `Unknown`.
- The worker's terminal write-error forward no longer waits for room on the
  response channel. With events undrained it parked the worker on a full
  channel while the caller parked on a full outbound channel, contradicting
  the rustdoc promise that an audio backlog never blocks `speak`. Same fix in
  the Flux text-to-speech worker, where it is a deadlock released in 0.10.1.
- Tests: a wire assertion for `Encoding::CustomEncoding`, the
  `terminal_read_error_ends_worker_after_single_error` case the other two
  sockets already had, and a write-error-with-undrained-events regression
  test for both text-to-speech workers (each fails without the fix above).
- `tests/connect_tls_config_local.rs` was gated `listen`-only, so its
  `speak`-only cases never compiled in CI's `speak` jobs. Relax the gate,
  per-test `cfg` the `listen` cases, and add a `speak`-only negative case.
dg-coreylweathers added a commit that referenced this pull request Sep 15, 2026
…2 voice

Applies the pass-4 review of #166.

B1: the streaming text-to-speech worker forwarded a terminal write error
with `try_send` on the shared response channel, so the error was silently
discarded whenever the 256-deep channel was full at that instant. With
`SpeakStreamHandle::split()` a consumer drains events from its own task and
never parks on the outbound channel, so a full response channel at failure
time is reachable with the consumer very much alive: it saw the audio stream
end and its next `speak()` fail, with no error value. That violates the
written contract in AGENTS.md ("the worker forwards a terminal transport
error exactly once"). The worker now keeps a dedicated clone of the sender
for exactly that error: a `futures` mpsc channel's capacity is
`buffer + num_senders`, so the clone carries its own guaranteed slot and the
forward neither blocks (which is what deadlocked a non-draining caller
before) nor drops. Same shape applied to the Flux text-to-speech worker's
write-failure and post-loop close paths.

New test `write_error_reaches_a_slow_split_consumer` asserts the error is
*received*, not merely that nothing hangs — the existing
`write_error_with_undrained_events_does_not_stall_the_worker` passed either
way, which is how this slipped through. Against the previous code it fails
deterministically with 0 terminal errors after 297 drained audio events.

B2: remove `Model::Aura2PerseoIt`. The API specification lists it as the
91st Aura-2 voice, but the specification is wrong: production answers
`400 {"err_msg":"No such model/version combination found."}` for
`aura-2-perseo-it`, the other nine Italian Aura-2 voices all return
`200 audio/mpeg`, and `GET /v1/models?include_outdated=true` lists 90
Aura-2 canonical names with `aura-2-perseo-it` the only specification entry
missing. The round-trip test keeps its exact-count assertion — that is the
right mechanism — pinned to 102 (12 Aura-1 + the 90 Aura-2 voices the API
serves) with the reason recorded in the comment, and gains an assertion that
`aura-2-perseo-it` resolves to `CustomId`. Prose that claimed coverage of
every voice "in the API specification" now says what is true: every voice
the API serves.

S1: the changelog bullet for the Flux text-to-speech fix says what a
developer now observes and drops the "no public signature changed" clause; a
signature fact is not evidence about behavior.

S2: `with_base_url` / `with_base_url_and_api_key` no longer claim that all
admin features ignore the base URL. They name what honors it (transcription,
text-to-speech, Text Intelligence, model listing, self-hosted credentials)
and what does not (billing, usage, keys, members, invitations, projects,
scopes, and the token grant).

Nits: the host-header capture now also reports the upgrade target, giving the
streaming text-to-speech escape hatches (`query_params`,
`Encoding::CustomEncoding`) a wire-level guard; `output.raw` and
`flux-tts-batch.mp3` are gitignored; the `Url::join` trailing-slash
requirement is documented on both `with_base_url` constructors; and the
stray space inside the `scopes` URL literal is removed (the URL parser
stripped it, so no behavior change).

Gates, all exit 0 with `RUSTFLAGS=-D warnings RUSTDOCFLAGS=-D warnings`:
`cargo fmt --check --all`; `cargo clippy --all-targets --all-features`;
`cargo test --all --all-features` (204 unit + 150 doc tests, every
`*_local.rs` suite, 0 failed); `cargo doc --workspace --all-features` plus
the five single-feature doc builds; `cargo check --all-targets
--no-default-features` with `speak` and with `speak,rustls-tls-native-roots`;
`cargo test --no-default-features --features speak`.
dg-coreylweathers added a commit that referenced this pull request Sep 15, 2026
…delivery

Applies the three should-fix findings from the final verification review of
#166.

[S1] The README's `speak` feature row named only "Aura REST, Flux TTS REST and
streaming WebSocket" — true on the parent branch, but this branch ships Aura
streaming over `wss /v1/speak`, so the row understated the feature. It now
covers all four surfaces, and the table's column width is back in alignment.
The README named the new entry point nowhere, so a short pointer after the
table gives `dg.text_to_speech().speak_stream()` and the runnable examples for
both streaming sockets.

[S2] The shipped management-API skill omitted the self-hosted credentials
surface and stated a base-URL rule this branch breaks. It now lists the four
`self_hosted()` methods (with the Uuid credentials ID and the return-once
secret), `src/manage/self_hosted.rs`, and
`examples/manage/self_hosted_credentials.rs`; gotcha 3 now matches the
`with_base_url` rustdoc exactly — `models()` and `self_hosted()` honor the
configured base URL, while billing, usage, keys, members, invitations,
projects, scopes, and `/v1/auth/grant` stay on the hosted site.

[S3] Nothing tested that the Flux TTS terminal write error is *delivered*: the
only covering test asserted that `speak()` stops hanging, so it passed with
the error dropped. The new test in `tests/flux_speak_backpressure_local.rs`
fills the response channel, breaks the transport, and then drains
`handle.receive()`, asserting that at least 256 audio events were queued (the
channel was provably full at the failure), that exactly one `Err` arrives,
that it is the last item, and that the stream then ends. Reverting the
`error_tx` forward to `response_tx.try_send(..)` fails it with 0 errors
delivered after 257 queued audio events, while the pre-existing deadlock test
still passes.
@dg-coreylweathers
dg-coreylweathers force-pushed the feat/phase-2-read-stream-models branch from 4c76c47 to 472db5f Compare September 19, 2026 15:07
@dg-coreylweathers
dg-coreylweathers force-pushed the feat/phase-3-tts-ws-selfhosted branch from 8052666 to 8c9b4b4 Compare September 19, 2026 15:07
dg-coreylweathers added a commit that referenced this pull request Sep 19, 2026
… validation, speed/mip_opt_out, protocol test coverage

- B1: `SpeakResponse::Metadata` and the internal `TextEvent::Metadata` now
  carry `additional_model_uuids: Option<Vec<Uuid>>` (absent -> None), passed
  through the conversion, with a parsing fixture asserting the values survive
  (present, absent, and empty array).
- B2: `SpeakStreamBuilder::handle()` validates options locally before
  connecting. REST-only encodings (`mp3`, `opus`, `flac`, `aac`) and a `speed`
  outside `0.7..=1.5` (or non-finite) are rejected with the new
  `DeepgramError::InvalidOptions(String)`; `linear16` / `mulaw` / `alaw` pass
  and `CustomEncoding` is left as an escape hatch. New `speed(f32)` and
  `mip_opt_out(bool)` setters serialize as `speed=` / `mip_opt_out=` query
  params. Tests cover valid/invalid URLs, each rejected encoding, the speed
  boundaries, and that `handle()` short-circuits without a network attempt.
- S1: protocol tests now cover `Clear` client serialization and table-style
  parsing of `Cleared` (with/without `sequence_id`) and `Warning` (all
  combinations of `description` / `code`), matching the live API reference.
- CHANGELOG: Unreleased entry mentions `speed`, `mip_opt_out`, local encoding
  validation via `DeepgramError::InvalidOptions`, and `additional_model_uuids`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
dg-coreylweathers added a commit that referenced this pull request Sep 19, 2026
…rward, and correct the streaming TTS docs

Follow-ups from the #166 review:

- `speak::options::Model` was missing `aura-2-perseo-it`, the 91st Aura-2
  voice in the API specification. Add it in locale order and make the
  round-trip test assert the exact variant count (103) instead of a lower
  bound, so the next specification drift fails loudly.
- Document that a hand-built `Model::CustomId` is not `==` to the named
  variant whose wire string it spells.
- `src/tls.rs`, the `Deepgram::tls_config` rustdoc, and `README.md` each
  enumerated only three `wss://` surfaces; streaming text-to-speech goes
  through the same shared rustls connector, so name it.
- AGENTS.md: mark streaming text-to-speech and self-hosted credentials as
  shipped with their real entry points (the documented self-hosted path was
  wrong too: it is `/self-hosted/distribution/credentials`, not `/onprem/`),
  add `src/speak/websocket.rs` and `self_hosted` to the repository map, and
  correct the default-features list.
- The installable text-to-speech skill said the Aura `/v1/speak` WebSocket
  was not implemented. Correct every such claim and teach the real API.
- `SpeakResponse::Metadata::additional_model_uuids` is now
  `Option<Vec<String>>`, matching `model_uuid`: one non-UUID element no
  longer downgrades a readable `Metadata` event to `Unknown`.
- The worker's terminal write-error forward no longer waits for room on the
  response channel. With events undrained it parked the worker on a full
  channel while the caller parked on a full outbound channel, contradicting
  the rustdoc promise that an audio backlog never blocks `speak`. Same fix in
  the Flux text-to-speech worker, where it is a deadlock released in 0.10.1.
- Tests: a wire assertion for `Encoding::CustomEncoding`, the
  `terminal_read_error_ends_worker_after_single_error` case the other two
  sockets already had, and a write-error-with-undrained-events regression
  test for both text-to-speech workers (each fails without the fix above).
- `tests/connect_tls_config_local.rs` was gated `listen`-only, so its
  `speak`-only cases never compiled in CI's `speak` jobs. Relax the gate,
  per-test `cfg` the `listen` cases, and add a `speak`-only negative case.
dg-coreylweathers added a commit that referenced this pull request Sep 19, 2026
…2 voice

Applies the pass-4 review of #166.

B1: the streaming text-to-speech worker forwarded a terminal write error
with `try_send` on the shared response channel, so the error was silently
discarded whenever the 256-deep channel was full at that instant. With
`SpeakStreamHandle::split()` a consumer drains events from its own task and
never parks on the outbound channel, so a full response channel at failure
time is reachable with the consumer very much alive: it saw the audio stream
end and its next `speak()` fail, with no error value. That violates the
written contract in AGENTS.md ("the worker forwards a terminal transport
error exactly once"). The worker now keeps a dedicated clone of the sender
for exactly that error: a `futures` mpsc channel's capacity is
`buffer + num_senders`, so the clone carries its own guaranteed slot and the
forward neither blocks (which is what deadlocked a non-draining caller
before) nor drops. Same shape applied to the Flux text-to-speech worker's
write-failure and post-loop close paths.

New test `write_error_reaches_a_slow_split_consumer` asserts the error is
*received*, not merely that nothing hangs — the existing
`write_error_with_undrained_events_does_not_stall_the_worker` passed either
way, which is how this slipped through. Against the previous code it fails
deterministically with 0 terminal errors after 297 drained audio events.

B2: remove `Model::Aura2PerseoIt`. The API specification lists it as the
91st Aura-2 voice, but the specification is wrong: production answers
`400 {"err_msg":"No such model/version combination found."}` for
`aura-2-perseo-it`, the other nine Italian Aura-2 voices all return
`200 audio/mpeg`, and `GET /v1/models?include_outdated=true` lists 90
Aura-2 canonical names with `aura-2-perseo-it` the only specification entry
missing. The round-trip test keeps its exact-count assertion — that is the
right mechanism — pinned to 102 (12 Aura-1 + the 90 Aura-2 voices the API
serves) with the reason recorded in the comment, and gains an assertion that
`aura-2-perseo-it` resolves to `CustomId`. Prose that claimed coverage of
every voice "in the API specification" now says what is true: every voice
the API serves.

S1: the changelog bullet for the Flux text-to-speech fix says what a
developer now observes and drops the "no public signature changed" clause; a
signature fact is not evidence about behavior.

S2: `with_base_url` / `with_base_url_and_api_key` no longer claim that all
admin features ignore the base URL. They name what honors it (transcription,
text-to-speech, Text Intelligence, model listing, self-hosted credentials)
and what does not (billing, usage, keys, members, invitations, projects,
scopes, and the token grant).

Nits: the host-header capture now also reports the upgrade target, giving the
streaming text-to-speech escape hatches (`query_params`,
`Encoding::CustomEncoding`) a wire-level guard; `output.raw` and
`flux-tts-batch.mp3` are gitignored; the `Url::join` trailing-slash
requirement is documented on both `with_base_url` constructors; and the
stray space inside the `scopes` URL literal is removed (the URL parser
stripped it, so no behavior change).

Gates, all exit 0 with `RUSTFLAGS=-D warnings RUSTDOCFLAGS=-D warnings`:
`cargo fmt --check --all`; `cargo clippy --all-targets --all-features`;
`cargo test --all --all-features` (204 unit + 150 doc tests, every
`*_local.rs` suite, 0 failed); `cargo doc --workspace --all-features` plus
the five single-feature doc builds; `cargo check --all-targets
--no-default-features` with `speak` and with `speak,rustls-tls-native-roots`;
`cargo test --no-default-features --features speak`.
dg-coreylweathers added a commit that referenced this pull request Sep 19, 2026
…delivery

Applies the three should-fix findings from the final verification review of
#166.

[S1] The README's `speak` feature row named only "Aura REST, Flux TTS REST and
streaming WebSocket" — true on the parent branch, but this branch ships Aura
streaming over `wss /v1/speak`, so the row understated the feature. It now
covers all four surfaces, and the table's column width is back in alignment.
The README named the new entry point nowhere, so a short pointer after the
table gives `dg.text_to_speech().speak_stream()` and the runnable examples for
both streaming sockets.

[S2] The shipped management-API skill omitted the self-hosted credentials
surface and stated a base-URL rule this branch breaks. It now lists the four
`self_hosted()` methods (with the Uuid credentials ID and the return-once
secret), `src/manage/self_hosted.rs`, and
`examples/manage/self_hosted_credentials.rs`; gotcha 3 now matches the
`with_base_url` rustdoc exactly — `models()` and `self_hosted()` honor the
configured base URL, while billing, usage, keys, members, invitations,
projects, scopes, and `/v1/auth/grant` stay on the hosted site.

[S3] Nothing tested that the Flux TTS terminal write error is *delivered*: the
only covering test asserted that `speak()` stops hanging, so it passed with
the error dropped. The new test in `tests/flux_speak_backpressure_local.rs`
fills the response channel, breaks the transport, and then drains
`handle.receive()`, asserting that at least 256 audio events were queued (the
channel was provably full at the failure), that exactly one `Err` arrives,
that it is the last item, and that the stream then ends. Reverting the
`error_tx` forward to `response_tx.try_send(..)` fails it with 0 errors
delivered after 257 queued audio events, while the pre-existing deadlock test
still passes.
@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

Round 2 — pushed, and the self-hosted question is answered

Rebased onto the updated #165. Your three items are fixed; I also probed the self-hosted contract live, because three claims in the code contradicted the published reference.

The self-hosted scope concern turned out to be a docs bug, not an SDK bug

The reference documents scopes and provider as query parameters on create, while this SDK sends both in the JSON body. If the reference had been right, a caller narrowing the grant with .scopes([Scope::Api]) would have silently received the default self-hosted:products — every product image — with no error. I tested both forms against production:

  • The documented query form returns 400 Json deserialize error: missing field 'provider'. The server reads provider from the body.
  • The SDK's body form reaches permission checking and returns 403 INSUFFICIENT_PERMISSIONS whose details quote back the scope supplied in the body: "Check that your account has the 'self-hosted:product:api' scope for this project."

So the server reads both from the body, the narrowed scope is received, and the SDK is correct. The reference page and the OpenAPI spec are wrong — routing that to @GregHolmes separately. No credential was created (403), so nothing was provisioned.

What changed since your review

  • Skill manifest pinned to 0.12 — it had lost its version key, which makes the snippet unparseable by cargo.
  • "covering every voice the API serves" is now scoped to this release, in both the changelog and the skill. The exact-count test still enforces the list, and its comment already records the live probe behind the one exclusion (aura-2-perseo-it).

What I verified

  • B1: additional_model_uuids is Option<Vec<String>> with #[serde(default)], absent and empty both covered. Confirmed live on the REST side too, where the same data arrives as dg-additional-model-uuids — and was being dropped there (fixed in feat: Phase 1 quick wins — TTS request-id, Whisper models, redaction, response fields, captions #164).
  • B2: validate() rejects mp3/opus/flac/aac with InvalidOptions before opening a socket, and the 0.7..=1.5 speed range matches the documented speed_out_of_range error.
  • Lifecycle: capacity reserved before inbound reads, terminal write error delivered exactly once through a dedicated reserved slot, no busy-spin after input close, worker exits without waiting for handles. The backpressure test overflows both bounded channels at once and still delivers everything.
  • Self-hosted list works live (HTTP 200) on both the self-hosted and legacy onprem paths.

Still open: the create response shape. This project's key lacks the self-hosted:product:* scope, so a 200 create is not reachable from here and the flat-vs-wrapper question stays author-asserted. Needs one create on an account carrying the scope.

GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
… validation, speed/mip_opt_out, protocol test coverage

- B1: `SpeakResponse::Metadata` and the internal `TextEvent::Metadata` now
  carry `additional_model_uuids: Option<Vec<Uuid>>` (absent -> None), passed
  through the conversion, with a parsing fixture asserting the values survive
  (present, absent, and empty array).
- B2: `SpeakStreamBuilder::handle()` validates options locally before
  connecting. REST-only encodings (`mp3`, `opus`, `flac`, `aac`) and a `speed`
  outside `0.7..=1.5` (or non-finite) are rejected with the new
  `DeepgramError::InvalidOptions(String)`; `linear16` / `mulaw` / `alaw` pass
  and `CustomEncoding` is left as an escape hatch. New `speed(f32)` and
  `mip_opt_out(bool)` setters serialize as `speed=` / `mip_opt_out=` query
  params. Tests cover valid/invalid URLs, each rejected encoding, the speed
  boundaries, and that `handle()` short-circuits without a network attempt.
- S1: protocol tests now cover `Clear` client serialization and table-style
  parsing of `Cleared` (with/without `sequence_id`) and `Warning` (all
  combinations of `description` / `code`), matching the live API reference.
- CHANGELOG: Unreleased entry mentions `speed`, `mip_opt_out`, local encoding
  validation via `DeepgramError::InvalidOptions`, and `additional_model_uuids`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…rward, and correct the streaming TTS docs

Follow-ups from the #166 review:

- `speak::options::Model` was missing `aura-2-perseo-it`, the 91st Aura-2
  voice in the API specification. Add it in locale order and make the
  round-trip test assert the exact variant count (103) instead of a lower
  bound, so the next specification drift fails loudly.
- Document that a hand-built `Model::CustomId` is not `==` to the named
  variant whose wire string it spells.
- `src/tls.rs`, the `Deepgram::tls_config` rustdoc, and `README.md` each
  enumerated only three `wss://` surfaces; streaming text-to-speech goes
  through the same shared rustls connector, so name it.
- AGENTS.md: mark streaming text-to-speech and self-hosted credentials as
  shipped with their real entry points (the documented self-hosted path was
  wrong too: it is `/self-hosted/distribution/credentials`, not `/onprem/`),
  add `src/speak/websocket.rs` and `self_hosted` to the repository map, and
  correct the default-features list.
- The installable text-to-speech skill said the Aura `/v1/speak` WebSocket
  was not implemented. Correct every such claim and teach the real API.
- `SpeakResponse::Metadata::additional_model_uuids` is now
  `Option<Vec<String>>`, matching `model_uuid`: one non-UUID element no
  longer downgrades a readable `Metadata` event to `Unknown`.
- The worker's terminal write-error forward no longer waits for room on the
  response channel. With events undrained it parked the worker on a full
  channel while the caller parked on a full outbound channel, contradicting
  the rustdoc promise that an audio backlog never blocks `speak`. Same fix in
  the Flux text-to-speech worker, where it is a deadlock released in 0.10.1.
- Tests: a wire assertion for `Encoding::CustomEncoding`, the
  `terminal_read_error_ends_worker_after_single_error` case the other two
  sockets already had, and a write-error-with-undrained-events regression
  test for both text-to-speech workers (each fails without the fix above).
- `tests/connect_tls_config_local.rs` was gated `listen`-only, so its
  `speak`-only cases never compiled in CI's `speak` jobs. Relax the gate,
  per-test `cfg` the `listen` cases, and add a `speak`-only negative case.
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…2 voice

Applies the pass-4 review of #166.

B1: the streaming text-to-speech worker forwarded a terminal write error
with `try_send` on the shared response channel, so the error was silently
discarded whenever the 256-deep channel was full at that instant. With
`SpeakStreamHandle::split()` a consumer drains events from its own task and
never parks on the outbound channel, so a full response channel at failure
time is reachable with the consumer very much alive: it saw the audio stream
end and its next `speak()` fail, with no error value. That violates the
written contract in AGENTS.md ("the worker forwards a terminal transport
error exactly once"). The worker now keeps a dedicated clone of the sender
for exactly that error: a `futures` mpsc channel's capacity is
`buffer + num_senders`, so the clone carries its own guaranteed slot and the
forward neither blocks (which is what deadlocked a non-draining caller
before) nor drops. Same shape applied to the Flux text-to-speech worker's
write-failure and post-loop close paths.

New test `write_error_reaches_a_slow_split_consumer` asserts the error is
*received*, not merely that nothing hangs — the existing
`write_error_with_undrained_events_does_not_stall_the_worker` passed either
way, which is how this slipped through. Against the previous code it fails
deterministically with 0 terminal errors after 297 drained audio events.

B2: remove `Model::Aura2PerseoIt`. The API specification lists it as the
91st Aura-2 voice, but the specification is wrong: production answers
`400 {"err_msg":"No such model/version combination found."}` for
`aura-2-perseo-it`, the other nine Italian Aura-2 voices all return
`200 audio/mpeg`, and `GET /v1/models?include_outdated=true` lists 90
Aura-2 canonical names with `aura-2-perseo-it` the only specification entry
missing. The round-trip test keeps its exact-count assertion — that is the
right mechanism — pinned to 102 (12 Aura-1 + the 90 Aura-2 voices the API
serves) with the reason recorded in the comment, and gains an assertion that
`aura-2-perseo-it` resolves to `CustomId`. Prose that claimed coverage of
every voice "in the API specification" now says what is true: every voice
the API serves.

S1: the changelog bullet for the Flux text-to-speech fix says what a
developer now observes and drops the "no public signature changed" clause; a
signature fact is not evidence about behavior.

S2: `with_base_url` / `with_base_url_and_api_key` no longer claim that all
admin features ignore the base URL. They name what honors it (transcription,
text-to-speech, Text Intelligence, model listing, self-hosted credentials)
and what does not (billing, usage, keys, members, invitations, projects,
scopes, and the token grant).

Nits: the host-header capture now also reports the upgrade target, giving the
streaming text-to-speech escape hatches (`query_params`,
`Encoding::CustomEncoding`) a wire-level guard; `output.raw` and
`flux-tts-batch.mp3` are gitignored; the `Url::join` trailing-slash
requirement is documented on both `with_base_url` constructors; and the
stray space inside the `scopes` URL literal is removed (the URL parser
stripped it, so no behavior change).

Gates, all exit 0 with `RUSTFLAGS=-D warnings RUSTDOCFLAGS=-D warnings`:
`cargo fmt --check --all`; `cargo clippy --all-targets --all-features`;
`cargo test --all --all-features` (204 unit + 150 doc tests, every
`*_local.rs` suite, 0 failed); `cargo doc --workspace --all-features` plus
the five single-feature doc builds; `cargo check --all-targets
--no-default-features` with `speak` and with `speak,rustls-tls-native-roots`;
`cargo test --no-default-features --features speak`.
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…delivery

Applies the three should-fix findings from the final verification review of
#166.

[S1] The README's `speak` feature row named only "Aura REST, Flux TTS REST and
streaming WebSocket" — true on the parent branch, but this branch ships Aura
streaming over `wss /v1/speak`, so the row understated the feature. It now
covers all four surfaces, and the table's column width is back in alignment.
The README named the new entry point nowhere, so a short pointer after the
table gives `dg.text_to_speech().speak_stream()` and the runnable examples for
both streaming sockets.

[S2] The shipped management-API skill omitted the self-hosted credentials
surface and stated a base-URL rule this branch breaks. It now lists the four
`self_hosted()` methods (with the Uuid credentials ID and the return-once
secret), `src/manage/self_hosted.rs`, and
`examples/manage/self_hosted_credentials.rs`; gotcha 3 now matches the
`with_base_url` rustdoc exactly — `models()` and `self_hosted()` honor the
configured base URL, while billing, usage, keys, members, invitations,
projects, scopes, and `/v1/auth/grant` stay on the hosted site.

[S3] Nothing tested that the Flux TTS terminal write error is *delivered*: the
only covering test asserted that `speak()` stops hanging, so it passed with
the error dropped. The new test in `tests/flux_speak_backpressure_local.rs`
fills the response channel, breaks the transport, and then drains
`handle.receive()`, asserting that at least 256 audio events were queued (the
channel was provably full at the failure), that exactly one `Err` arrives,
that it is the last item, and that the stream then ends. Reverting the
`error_tx` forward to `response_tx.try_send(..)` fails it with 0 errors
delivered after 257 queued audio events, while the pre-existing deadlock test
still passes.
@GregHolmes
GregHolmes force-pushed the feat/phase-2-read-stream-models branch from 472db5f to 0bd4173 Compare September 21, 2026 11:26
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
… validation, speed/mip_opt_out, protocol test coverage

- B1: `SpeakResponse::Metadata` and the internal `TextEvent::Metadata` now
  carry `additional_model_uuids: Option<Vec<Uuid>>` (absent -> None), passed
  through the conversion, with a parsing fixture asserting the values survive
  (present, absent, and empty array).
- B2: `SpeakStreamBuilder::handle()` validates options locally before
  connecting. REST-only encodings (`mp3`, `opus`, `flac`, `aac`) and a `speed`
  outside `0.7..=1.5` (or non-finite) are rejected with the new
  `DeepgramError::InvalidOptions(String)`; `linear16` / `mulaw` / `alaw` pass
  and `CustomEncoding` is left as an escape hatch. New `speed(f32)` and
  `mip_opt_out(bool)` setters serialize as `speed=` / `mip_opt_out=` query
  params. Tests cover valid/invalid URLs, each rejected encoding, the speed
  boundaries, and that `handle()` short-circuits without a network attempt.
- S1: protocol tests now cover `Clear` client serialization and table-style
  parsing of `Cleared` (with/without `sequence_id`) and `Warning` (all
  combinations of `description` / `code`), matching the live API reference.
- CHANGELOG: Unreleased entry mentions `speed`, `mip_opt_out`, local encoding
  validation via `DeepgramError::InvalidOptions`, and `additional_model_uuids`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…rward, and correct the streaming TTS docs

Follow-ups from the #166 review:

- `speak::options::Model` was missing `aura-2-perseo-it`, the 91st Aura-2
  voice in the API specification. Add it in locale order and make the
  round-trip test assert the exact variant count (103) instead of a lower
  bound, so the next specification drift fails loudly.
- Document that a hand-built `Model::CustomId` is not `==` to the named
  variant whose wire string it spells.
- `src/tls.rs`, the `Deepgram::tls_config` rustdoc, and `README.md` each
  enumerated only three `wss://` surfaces; streaming text-to-speech goes
  through the same shared rustls connector, so name it.
- AGENTS.md: mark streaming text-to-speech and self-hosted credentials as
  shipped with their real entry points (the documented self-hosted path was
  wrong too: it is `/self-hosted/distribution/credentials`, not `/onprem/`),
  add `src/speak/websocket.rs` and `self_hosted` to the repository map, and
  correct the default-features list.
- The installable text-to-speech skill said the Aura `/v1/speak` WebSocket
  was not implemented. Correct every such claim and teach the real API.
- `SpeakResponse::Metadata::additional_model_uuids` is now
  `Option<Vec<String>>`, matching `model_uuid`: one non-UUID element no
  longer downgrades a readable `Metadata` event to `Unknown`.
- The worker's terminal write-error forward no longer waits for room on the
  response channel. With events undrained it parked the worker on a full
  channel while the caller parked on a full outbound channel, contradicting
  the rustdoc promise that an audio backlog never blocks `speak`. Same fix in
  the Flux text-to-speech worker, where it is a deadlock released in 0.10.1.
- Tests: a wire assertion for `Encoding::CustomEncoding`, the
  `terminal_read_error_ends_worker_after_single_error` case the other two
  sockets already had, and a write-error-with-undrained-events regression
  test for both text-to-speech workers (each fails without the fix above).
- `tests/connect_tls_config_local.rs` was gated `listen`-only, so its
  `speak`-only cases never compiled in CI's `speak` jobs. Relax the gate,
  per-test `cfg` the `listen` cases, and add a `speak`-only negative case.
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…2 voice

Applies the pass-4 review of #166.

B1: the streaming text-to-speech worker forwarded a terminal write error
with `try_send` on the shared response channel, so the error was silently
discarded whenever the 256-deep channel was full at that instant. With
`SpeakStreamHandle::split()` a consumer drains events from its own task and
never parks on the outbound channel, so a full response channel at failure
time is reachable with the consumer very much alive: it saw the audio stream
end and its next `speak()` fail, with no error value. That violates the
written contract in AGENTS.md ("the worker forwards a terminal transport
error exactly once"). The worker now keeps a dedicated clone of the sender
for exactly that error: a `futures` mpsc channel's capacity is
`buffer + num_senders`, so the clone carries its own guaranteed slot and the
forward neither blocks (which is what deadlocked a non-draining caller
before) nor drops. Same shape applied to the Flux text-to-speech worker's
write-failure and post-loop close paths.

New test `write_error_reaches_a_slow_split_consumer` asserts the error is
*received*, not merely that nothing hangs — the existing
`write_error_with_undrained_events_does_not_stall_the_worker` passed either
way, which is how this slipped through. Against the previous code it fails
deterministically with 0 terminal errors after 297 drained audio events.

B2: remove `Model::Aura2PerseoIt`. The API specification lists it as the
91st Aura-2 voice, but the specification is wrong: production answers
`400 {"err_msg":"No such model/version combination found."}` for
`aura-2-perseo-it`, the other nine Italian Aura-2 voices all return
`200 audio/mpeg`, and `GET /v1/models?include_outdated=true` lists 90
Aura-2 canonical names with `aura-2-perseo-it` the only specification entry
missing. The round-trip test keeps its exact-count assertion — that is the
right mechanism — pinned to 102 (12 Aura-1 + the 90 Aura-2 voices the API
serves) with the reason recorded in the comment, and gains an assertion that
`aura-2-perseo-it` resolves to `CustomId`. Prose that claimed coverage of
every voice "in the API specification" now says what is true: every voice
the API serves.

S1: the changelog bullet for the Flux text-to-speech fix says what a
developer now observes and drops the "no public signature changed" clause; a
signature fact is not evidence about behavior.

S2: `with_base_url` / `with_base_url_and_api_key` no longer claim that all
admin features ignore the base URL. They name what honors it (transcription,
text-to-speech, Text Intelligence, model listing, self-hosted credentials)
and what does not (billing, usage, keys, members, invitations, projects,
scopes, and the token grant).

Nits: the host-header capture now also reports the upgrade target, giving the
streaming text-to-speech escape hatches (`query_params`,
`Encoding::CustomEncoding`) a wire-level guard; `output.raw` and
`flux-tts-batch.mp3` are gitignored; the `Url::join` trailing-slash
requirement is documented on both `with_base_url` constructors; and the
stray space inside the `scopes` URL literal is removed (the URL parser
stripped it, so no behavior change).

Gates, all exit 0 with `RUSTFLAGS=-D warnings RUSTDOCFLAGS=-D warnings`:
`cargo fmt --check --all`; `cargo clippy --all-targets --all-features`;
`cargo test --all --all-features` (204 unit + 150 doc tests, every
`*_local.rs` suite, 0 failed); `cargo doc --workspace --all-features` plus
the five single-feature doc builds; `cargo check --all-targets
--no-default-features` with `speak` and with `speak,rustls-tls-native-roots`;
`cargo test --no-default-features --features speak`.
@GregHolmes
GregHolmes force-pushed the feat/phase-3-tts-ws-selfhosted branch from 8c9b4b4 to 06ef75c Compare September 21, 2026 11:26
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…delivery

Applies the three should-fix findings from the final verification review of
#166.

[S1] The README's `speak` feature row named only "Aura REST, Flux TTS REST and
streaming WebSocket" — true on the parent branch, but this branch ships Aura
streaming over `wss /v1/speak`, so the row understated the feature. It now
covers all four surfaces, and the table's column width is back in alignment.
The README named the new entry point nowhere, so a short pointer after the
table gives `dg.text_to_speech().speak_stream()` and the runnable examples for
both streaming sockets.

[S2] The shipped management-API skill omitted the self-hosted credentials
surface and stated a base-URL rule this branch breaks. It now lists the four
`self_hosted()` methods (with the Uuid credentials ID and the return-once
secret), `src/manage/self_hosted.rs`, and
`examples/manage/self_hosted_credentials.rs`; gotcha 3 now matches the
`with_base_url` rustdoc exactly — `models()` and `self_hosted()` honor the
configured base URL, while billing, usage, keys, members, invitations,
projects, scopes, and `/v1/auth/grant` stay on the hosted site.

[S3] Nothing tested that the Flux TTS terminal write error is *delivered*: the
only covering test asserted that `speak()` stops hanging, so it passed with
the error dropped. The new test in `tests/flux_speak_backpressure_local.rs`
fills the response channel, breaks the transport, and then drains
`handle.receive()`, asserting that at least 256 audio events were queued (the
channel was provably full at the failure), that exactly one `Err` arrives,
that it is the last item, and that the stream then ends. Reverting the
`error_tx` forward to `response_tx.try_send(..)` fails it with 0 errors
delivered after 257 queued audio events, while the pre-existing deadlock test
still passes.
@GregHolmes
GregHolmes force-pushed the feat/phase-2-read-stream-models branch from 0bd4173 to 1f61ac3 Compare September 21, 2026 11:28
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
… validation, speed/mip_opt_out, protocol test coverage

- B1: `SpeakResponse::Metadata` and the internal `TextEvent::Metadata` now
  carry `additional_model_uuids: Option<Vec<Uuid>>` (absent -> None), passed
  through the conversion, with a parsing fixture asserting the values survive
  (present, absent, and empty array).
- B2: `SpeakStreamBuilder::handle()` validates options locally before
  connecting. REST-only encodings (`mp3`, `opus`, `flac`, `aac`) and a `speed`
  outside `0.7..=1.5` (or non-finite) are rejected with the new
  `DeepgramError::InvalidOptions(String)`; `linear16` / `mulaw` / `alaw` pass
  and `CustomEncoding` is left as an escape hatch. New `speed(f32)` and
  `mip_opt_out(bool)` setters serialize as `speed=` / `mip_opt_out=` query
  params. Tests cover valid/invalid URLs, each rejected encoding, the speed
  boundaries, and that `handle()` short-circuits without a network attempt.
- S1: protocol tests now cover `Clear` client serialization and table-style
  parsing of `Cleared` (with/without `sequence_id`) and `Warning` (all
  combinations of `description` / `code`), matching the live API reference.
- CHANGELOG: Unreleased entry mentions `speed`, `mip_opt_out`, local encoding
  validation via `DeepgramError::InvalidOptions`, and `additional_model_uuids`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…rward, and correct the streaming TTS docs

Follow-ups from the #166 review:

- `speak::options::Model` was missing `aura-2-perseo-it`, the 91st Aura-2
  voice in the API specification. Add it in locale order and make the
  round-trip test assert the exact variant count (103) instead of a lower
  bound, so the next specification drift fails loudly.
- Document that a hand-built `Model::CustomId` is not `==` to the named
  variant whose wire string it spells.
- `src/tls.rs`, the `Deepgram::tls_config` rustdoc, and `README.md` each
  enumerated only three `wss://` surfaces; streaming text-to-speech goes
  through the same shared rustls connector, so name it.
- AGENTS.md: mark streaming text-to-speech and self-hosted credentials as
  shipped with their real entry points (the documented self-hosted path was
  wrong too: it is `/self-hosted/distribution/credentials`, not `/onprem/`),
  add `src/speak/websocket.rs` and `self_hosted` to the repository map, and
  correct the default-features list.
- The installable text-to-speech skill said the Aura `/v1/speak` WebSocket
  was not implemented. Correct every such claim and teach the real API.
- `SpeakResponse::Metadata::additional_model_uuids` is now
  `Option<Vec<String>>`, matching `model_uuid`: one non-UUID element no
  longer downgrades a readable `Metadata` event to `Unknown`.
- The worker's terminal write-error forward no longer waits for room on the
  response channel. With events undrained it parked the worker on a full
  channel while the caller parked on a full outbound channel, contradicting
  the rustdoc promise that an audio backlog never blocks `speak`. Same fix in
  the Flux text-to-speech worker, where it is a deadlock released in 0.10.1.
- Tests: a wire assertion for `Encoding::CustomEncoding`, the
  `terminal_read_error_ends_worker_after_single_error` case the other two
  sockets already had, and a write-error-with-undrained-events regression
  test for both text-to-speech workers (each fails without the fix above).
- `tests/connect_tls_config_local.rs` was gated `listen`-only, so its
  `speak`-only cases never compiled in CI's `speak` jobs. Relax the gate,
  per-test `cfg` the `listen` cases, and add a `speak`-only negative case.
@GregHolmes
GregHolmes force-pushed the feat/phase-3-tts-ws-selfhosted branch from 06ef75c to 7f415a5 Compare September 21, 2026 11:28
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…2 voice

Applies the pass-4 review of #166.

B1: the streaming text-to-speech worker forwarded a terminal write error
with `try_send` on the shared response channel, so the error was silently
discarded whenever the 256-deep channel was full at that instant. With
`SpeakStreamHandle::split()` a consumer drains events from its own task and
never parks on the outbound channel, so a full response channel at failure
time is reachable with the consumer very much alive: it saw the audio stream
end and its next `speak()` fail, with no error value. That violates the
written contract in AGENTS.md ("the worker forwards a terminal transport
error exactly once"). The worker now keeps a dedicated clone of the sender
for exactly that error: a `futures` mpsc channel's capacity is
`buffer + num_senders`, so the clone carries its own guaranteed slot and the
forward neither blocks (which is what deadlocked a non-draining caller
before) nor drops. Same shape applied to the Flux text-to-speech worker's
write-failure and post-loop close paths.

New test `write_error_reaches_a_slow_split_consumer` asserts the error is
*received*, not merely that nothing hangs — the existing
`write_error_with_undrained_events_does_not_stall_the_worker` passed either
way, which is how this slipped through. Against the previous code it fails
deterministically with 0 terminal errors after 297 drained audio events.

B2: remove `Model::Aura2PerseoIt`. The API specification lists it as the
91st Aura-2 voice, but the specification is wrong: production answers
`400 {"err_msg":"No such model/version combination found."}` for
`aura-2-perseo-it`, the other nine Italian Aura-2 voices all return
`200 audio/mpeg`, and `GET /v1/models?include_outdated=true` lists 90
Aura-2 canonical names with `aura-2-perseo-it` the only specification entry
missing. The round-trip test keeps its exact-count assertion — that is the
right mechanism — pinned to 102 (12 Aura-1 + the 90 Aura-2 voices the API
serves) with the reason recorded in the comment, and gains an assertion that
`aura-2-perseo-it` resolves to `CustomId`. Prose that claimed coverage of
every voice "in the API specification" now says what is true: every voice
the API serves.

S1: the changelog bullet for the Flux text-to-speech fix says what a
developer now observes and drops the "no public signature changed" clause; a
signature fact is not evidence about behavior.

S2: `with_base_url` / `with_base_url_and_api_key` no longer claim that all
admin features ignore the base URL. They name what honors it (transcription,
text-to-speech, Text Intelligence, model listing, self-hosted credentials)
and what does not (billing, usage, keys, members, invitations, projects,
scopes, and the token grant).

Nits: the host-header capture now also reports the upgrade target, giving the
streaming text-to-speech escape hatches (`query_params`,
`Encoding::CustomEncoding`) a wire-level guard; `output.raw` and
`flux-tts-batch.mp3` are gitignored; the `Url::join` trailing-slash
requirement is documented on both `with_base_url` constructors; and the
stray space inside the `scopes` URL literal is removed (the URL parser
stripped it, so no behavior change).

Gates, all exit 0 with `RUSTFLAGS=-D warnings RUSTDOCFLAGS=-D warnings`:
`cargo fmt --check --all`; `cargo clippy --all-targets --all-features`;
`cargo test --all --all-features` (204 unit + 150 doc tests, every
`*_local.rs` suite, 0 failed); `cargo doc --workspace --all-features` plus
the five single-feature doc builds; `cargo check --all-targets
--no-default-features` with `speak` and with `speak,rustls-tls-native-roots`;
`cargo test --no-default-features --features speak`.
GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…delivery

Applies the three should-fix findings from the final verification review of
#166.

[S1] The README's `speak` feature row named only "Aura REST, Flux TTS REST and
streaming WebSocket" — true on the parent branch, but this branch ships Aura
streaming over `wss /v1/speak`, so the row understated the feature. It now
covers all four surfaces, and the table's column width is back in alignment.
The README named the new entry point nowhere, so a short pointer after the
table gives `dg.text_to_speech().speak_stream()` and the runnable examples for
both streaming sockets.

[S2] The shipped management-API skill omitted the self-hosted credentials
surface and stated a base-URL rule this branch breaks. It now lists the four
`self_hosted()` methods (with the Uuid credentials ID and the return-once
secret), `src/manage/self_hosted.rs`, and
`examples/manage/self_hosted_credentials.rs`; gotcha 3 now matches the
`with_base_url` rustdoc exactly — `models()` and `self_hosted()` honor the
configured base URL, while billing, usage, keys, members, invitations,
projects, scopes, and `/v1/auth/grant` stay on the hosted site.

[S3] Nothing tested that the Flux TTS terminal write error is *delivered*: the
only covering test asserted that `speak()` stops hanging, so it passed with
the error dropped. The new test in `tests/flux_speak_backpressure_local.rs`
fills the response channel, breaks the transport, and then drains
`handle.receive()`, asserting that at least 256 audio events were queued (the
channel was provably full at the failure), that exactly one `Err` arrives,
that it is the last item, and that the stream then ends. Reverting the
`error_tx` forward to `response_tx.try_send(..)` fails it with 0 errors
delivered after 257 queued audio events, while the pre-existing deadlock test
still passes.
@GregHolmes
GregHolmes force-pushed the feat/phase-2-read-stream-models branch from 1f61ac3 to 302f5ed Compare September 21, 2026 11:39
dg-coreylweathers and others added 9 commits September 21, 2026 12:39
…credentials

- TTS WebSocket (#148/#147/#95): Speak::speak_stream() -> SpeakStreamBuilder
  (model/encoding/sample_rate); handle() opens the connection and returns a
  SpeakStreamHandle for sending text (speak/flush/clear/close) and receiving
  audio + events. SpeakStreamHandle implements futures::Stream; SpeakResponse
  (Audio/Metadata/Flushed/Cleared/Warning/Unknown) is #[non_exhaustive] and
  unknown message types are preserved rather than breaking the stream. The
  speak feature now enables the WebSocket deps. New example
  text_to_speech_websocket.
- Self-hosted distribution credentials: Deepgram::self_hosted() -> SelfHosted
  with list/get/create/delete_distribution_credentials, plus typed response
  models. New example self_hosted_credentials.

All additive (cargo semver-checks: no new breaking changes beyond the
Phase 1 #130 streaming change). Live-verified the TTS WebSocket against
production; the self-hosted list path returns a correct 403 on accounts
without the self-hosted scope.

Closes #148, #147, #95

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… validation, speed/mip_opt_out, protocol test coverage

- B1: `SpeakResponse::Metadata` and the internal `TextEvent::Metadata` now
  carry `additional_model_uuids: Option<Vec<Uuid>>` (absent -> None), passed
  through the conversion, with a parsing fixture asserting the values survive
  (present, absent, and empty array).
- B2: `SpeakStreamBuilder::handle()` validates options locally before
  connecting. REST-only encodings (`mp3`, `opus`, `flac`, `aac`) and a `speed`
  outside `0.7..=1.5` (or non-finite) are rejected with the new
  `DeepgramError::InvalidOptions(String)`; `linear16` / `mulaw` / `alaw` pass
  and `CustomEncoding` is left as an escape hatch. New `speed(f32)` and
  `mip_opt_out(bool)` setters serialize as `speed=` / `mip_opt_out=` query
  params. Tests cover valid/invalid URLs, each rejected encoding, the speed
  boundaries, and that `handle()` short-circuits without a network attempt.
- S1: protocol tests now cover `Clear` client serialization and table-style
  parsing of `Cleared` (with/without `sequence_id`) and `Warning` (all
  combinations of `description` / `code`), matching the live API reference.
- CHANGELOG: Unreleased entry mentions `speed`, `mip_opt_out`, local encoding
  validation via `DeepgramError::InvalidOptions`, and `additional_model_uuids`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…shared TLS connector

Route /v1/speak streaming through crate::tls like every other wss://
surface since 0.11.0, so rustls-tls-native-roots and Deepgram::tls_config
apply to it and an untrusted certificate surfaces as
UntrustedTlsCertificate. Adds the per-surface tls_config test alongside
the existing live-transcription and Flux cases.
Uses the shared crate::websocket_host_header so a base URL on a non-default
port is routed by host:port like every other WebSocket surface; the Host
capture test covers the streaming TTS client.
…rward, and correct the streaming TTS docs

Follow-ups from the #166 review:

- `speak::options::Model` was missing `aura-2-perseo-it`, the 91st Aura-2
  voice in the API specification. Add it in locale order and make the
  round-trip test assert the exact variant count (103) instead of a lower
  bound, so the next specification drift fails loudly.
- Document that a hand-built `Model::CustomId` is not `==` to the named
  variant whose wire string it spells.
- `src/tls.rs`, the `Deepgram::tls_config` rustdoc, and `README.md` each
  enumerated only three `wss://` surfaces; streaming text-to-speech goes
  through the same shared rustls connector, so name it.
- AGENTS.md: mark streaming text-to-speech and self-hosted credentials as
  shipped with their real entry points (the documented self-hosted path was
  wrong too: it is `/self-hosted/distribution/credentials`, not `/onprem/`),
  add `src/speak/websocket.rs` and `self_hosted` to the repository map, and
  correct the default-features list.
- The installable text-to-speech skill said the Aura `/v1/speak` WebSocket
  was not implemented. Correct every such claim and teach the real API.
- `SpeakResponse::Metadata::additional_model_uuids` is now
  `Option<Vec<String>>`, matching `model_uuid`: one non-UUID element no
  longer downgrades a readable `Metadata` event to `Unknown`.
- The worker's terminal write-error forward no longer waits for room on the
  response channel. With events undrained it parked the worker on a full
  channel while the caller parked on a full outbound channel, contradicting
  the rustdoc promise that an audio backlog never blocks `speak`. Same fix in
  the Flux text-to-speech worker, where it is a deadlock released in 0.10.1.
- Tests: a wire assertion for `Encoding::CustomEncoding`, the
  `terminal_read_error_ends_worker_after_single_error` case the other two
  sockets already had, and a write-error-with-undrained-events regression
  test for both text-to-speech workers (each fails without the fix above).
- `tests/connect_tls_config_local.rs` was gated `listen`-only, so its
  `speak`-only cases never compiled in CI's `speak` jobs. Relax the gate,
  per-test `cfg` the `listen` cases, and add a `speak`-only negative case.
…t the new public items

Four follow-ups on the self-hosted and streaming-TTS work in this branch, all
on API that is new here and not in 0.11.0:

- `CreateDistributionCredentials::scopes` and the `scopes` on both response
  types are now `Vec<Scope>` instead of `Vec<String>`. `Scope` follows the
  SDK's open-enum convention: eight named variants plus
  `Scope::Unknown(String)`, which preserves an unrecognized wire value and
  re-serializes to it exactly, and is also how a caller sends a scope the SDK
  has not caught up with. `From<&str>` / `From<String>` keep the string form
  of the builder working.
- All four `SelfHosted` methods build their URL against the client's
  configured base URL, the way `read::rest` does, so
  `Deepgram::with_base_url` is no longer ignored by the module self-hosted
  users are most likely to point at their own host.
- `get_distribution_credentials` and `delete_distribution_credentials` take
  the `Uuid` the SDK hands back in `distribution_credentials_id` rather than
  `&str`, so the round trip through `.to_string()` in the example is gone.
- The `#[allow(missing_docs)]` on the seven self-hosted response fields is
  replaced with real doc comments, and `speak_models!` now generates a doc
  comment per variant from the wire string and the language it is grouped
  under, so all 103 voices are documented from one place.
…2 voice

Applies the pass-4 review of #166.

B1: the streaming text-to-speech worker forwarded a terminal write error
with `try_send` on the shared response channel, so the error was silently
discarded whenever the 256-deep channel was full at that instant. With
`SpeakStreamHandle::split()` a consumer drains events from its own task and
never parks on the outbound channel, so a full response channel at failure
time is reachable with the consumer very much alive: it saw the audio stream
end and its next `speak()` fail, with no error value. That violates the
written contract in AGENTS.md ("the worker forwards a terminal transport
error exactly once"). The worker now keeps a dedicated clone of the sender
for exactly that error: a `futures` mpsc channel's capacity is
`buffer + num_senders`, so the clone carries its own guaranteed slot and the
forward neither blocks (which is what deadlocked a non-draining caller
before) nor drops. Same shape applied to the Flux text-to-speech worker's
write-failure and post-loop close paths.

New test `write_error_reaches_a_slow_split_consumer` asserts the error is
*received*, not merely that nothing hangs — the existing
`write_error_with_undrained_events_does_not_stall_the_worker` passed either
way, which is how this slipped through. Against the previous code it fails
deterministically with 0 terminal errors after 297 drained audio events.

B2: remove `Model::Aura2PerseoIt`. The API specification lists it as the
91st Aura-2 voice, but the specification is wrong: production answers
`400 {"err_msg":"No such model/version combination found."}` for
`aura-2-perseo-it`, the other nine Italian Aura-2 voices all return
`200 audio/mpeg`, and `GET /v1/models?include_outdated=true` lists 90
Aura-2 canonical names with `aura-2-perseo-it` the only specification entry
missing. The round-trip test keeps its exact-count assertion — that is the
right mechanism — pinned to 102 (12 Aura-1 + the 90 Aura-2 voices the API
serves) with the reason recorded in the comment, and gains an assertion that
`aura-2-perseo-it` resolves to `CustomId`. Prose that claimed coverage of
every voice "in the API specification" now says what is true: every voice
the API serves.

S1: the changelog bullet for the Flux text-to-speech fix says what a
developer now observes and drops the "no public signature changed" clause; a
signature fact is not evidence about behavior.

S2: `with_base_url` / `with_base_url_and_api_key` no longer claim that all
admin features ignore the base URL. They name what honors it (transcription,
text-to-speech, Text Intelligence, model listing, self-hosted credentials)
and what does not (billing, usage, keys, members, invitations, projects,
scopes, and the token grant).

Nits: the host-header capture now also reports the upgrade target, giving the
streaming text-to-speech escape hatches (`query_params`,
`Encoding::CustomEncoding`) a wire-level guard; `output.raw` and
`flux-tts-batch.mp3` are gitignored; the `Url::join` trailing-slash
requirement is documented on both `with_base_url` constructors; and the
stray space inside the `scopes` URL literal is removed (the URL parser
stripped it, so no behavior change).

Gates, all exit 0 with `RUSTFLAGS=-D warnings RUSTDOCFLAGS=-D warnings`:
`cargo fmt --check --all`; `cargo clippy --all-targets --all-features`;
`cargo test --all --all-features` (204 unit + 150 doc tests, every
`*_local.rs` suite, 0 failed); `cargo doc --workspace --all-features` plus
the five single-feature doc builds; `cargo check --all-targets
--no-default-features` with `speak` and with `speak,rustls-tls-native-roots`;
`cargo test --no-default-features --features speak`.
…delivery

Applies the three should-fix findings from the final verification review of
#166.

[S1] The README's `speak` feature row named only "Aura REST, Flux TTS REST and
streaming WebSocket" — true on the parent branch, but this branch ships Aura
streaming over `wss /v1/speak`, so the row understated the feature. It now
covers all four surfaces, and the table's column width is back in alignment.
The README named the new entry point nowhere, so a short pointer after the
table gives `dg.text_to_speech().speak_stream()` and the runnable examples for
both streaming sockets.

[S2] The shipped management-API skill omitted the self-hosted credentials
surface and stated a base-URL rule this branch breaks. It now lists the four
`self_hosted()` methods (with the Uuid credentials ID and the return-once
secret), `src/manage/self_hosted.rs`, and
`examples/manage/self_hosted_credentials.rs`; gotcha 3 now matches the
`with_base_url` rustdoc exactly — `models()` and `self_hosted()` honor the
configured base URL, while billing, usage, keys, members, invitations,
projects, scopes, and `/v1/auth/grant` stay on the hosted site.

[S3] Nothing tested that the Flux TTS terminal write error is *delivered*: the
only covering test asserted that `speak()` stops hanging, so it passed with
the error dropped. The new test in `tests/flux_speak_backpressure_local.rs`
fills the response channel, breaks the transport, and then drains
`handle.receive()`, asserting that at least 256 audio events were queued (the
channel was provably full at the failure), that exactly one `Err` arrives,
that it is the last item, and that the stream then ends. Reverting the
`error_tx` forward to `response_tx.try_send(..)` fails it with 0 errors
delivered after 257 queued audio events, while the pre-existing deadlock test
still passes.
Review follow-ups on Phase 3.

The text-to-speech skill's dependency block lost its version key, which
leaves the snippet unparseable by cargo ("dependency (deepgram)
specified without providing a local path, Git repository, version, or
workspace dependency to use"). Pinned to the version this stack ships.

"covering every voice the API serves" and "names every Aura-1 and
Aura-2 voice the API serves" are unverifiable from the repo and false
the day a voice ships, so both are scoped to this release. The
exact-count test keeps enforcing the list, and its comment already
records the live probe behind the one exclusion (`aura-2-perseo-it`).
@GregHolmes
GregHolmes force-pushed the feat/phase-3-tts-ws-selfhosted branch from 7f415a5 to a8b8eaf Compare September 21, 2026 11:39

@GregHolmes GregHolmes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[B1] src/manage/self_hosted.rs:293 — Successful credential creation can be reported as a client error

Summary: create_distribution_credentials deserializes a bare CreatedDistributionCredentials object with one-time username and secret. The published endpoint reference instead documents a { member, distribution_credentials } response, and the PR records that no scoped account was available to observe a successful server response. If the published response is what the server returns, creation succeeds remotely but the SDK returns a JSON error; a caller that retries may create duplicate credentials while never receiving the original result.

Expected: The SDK create-response type matches a verified successful server response and returns it without a deserialization failure.

Observed: The response model is author-asserted rather than exercised, and it conflicts with the published success schema.

Recommended fix: Run one create request against an account with the required self-hosted scope, capture the sanitized 200 response, and model that shape. If it is the documented wrapper, return DistributionCredentialsEntry; if it is the bare secret-bearing object, retain the current type and open a docs/spec correction with the captured evidence.

GregHolmes pushed a commit that referenced this pull request Sep 21, 2026
…ror (#179)

## Proposed changes

The `/v1/listen` and `/v2/listen` streaming workers forwarded inbound
responses and terminal errors with a blocking send, so a full response
channel parked the worker mid-forward: it stopped writing the caller's
audio, the caller filled the bounded command channel and parked in
`send_data`, and the session hung. On `/v1/listen` this happened on a
**perfectly healthy socket with no transport failure involved at all** —
the inbound forward was never gated. Present on `/v1/listen` since 0.6.0
and on `/v2/listen` since 0.8.0.

Three changes:

- Inbound reads reserve capacity with `poll_ready` before forwarding, so
backpressure reaches the socket instead of the worker — the behavior
`/v2/listen` has had since 0.10.1.
- Terminal errors forward through a `response_tx.clone()` that owns a
guaranteed channel slot (a `futures` mpsc channel's capacity is `buffer
+ num_senders`), so the error neither blocks nor gets dropped. Plain
`try_send` was measured dropping it: 257 queued responses, 0 errors
delivered.
- The first failed write ends the session, so one broken transport
produces exactly one error instead of one per subsequent write plus one
from cleanup.

The Flux text-to-speech WebSocket (`/v2/speak`) has the same defect; its
fix ships on the Phase 3 branch (#166), so this PR does not claim it.

## Types of changes

- [x] Breaking change (fix or feature that would cause existing
functionality to not work as expected)

The worker no longer drains its command channel after the loop, so sends
on an ended `/v1/listen` session return `Err` where 0.11.0 returned
`Ok(())` indefinitely. Per `AGENTS.md`, a behavior change that makes
working code fail until the consumer changes something is breaking in
0.x, so the release carrying this needs a minor bump and the
`**BREAKING**` line is in the changelog. `cargo semver-checks` passes —
no signature moved — which is exactly why it is not the evidence here.

## Checklist

- [x] I have read the CONTRIBUTING.md doc
- [x] I have added tests and/or examples that prove my fix is effective
or that my feature works
- [x] I have added necessary documentation (if appropriate)

## Further comments

Each regression test was verified by reverting the fix and watching it
fail, because a test that only asserts "does not hang" passes with the
error dropped:

| Revert | Result |
| --- | --- |
| Full source revert | both regression tests fail, `Elapsed(())` at
31.03 s total |
| Gating kept, terminal forward back to blocking | write-error test
fails, `Elapsed(())` at 16.02 s |
| Gating kept, `try_send` instead of the sender clone | `left: 0 right:
1` — the error was dropped |
| Command drain restored | send on an ended session succeeds forever
instead of erroring |

The two causes mask each other: with only the error-forward fix, the
original reproduction still hangs. The delivery test is coordinated on
two oneshots and asserts `responses == 257`, so a run that failed to
fill the channel fails loudly rather than passing vacuously.

**Known defect left for its own branch:** with `keep_alive` off (the
default), the elapsed 3-second timer branch parks the worker in
`pending::<()>()` permanently — a response arriving 5 s into an idle
session never reaches the consumer. Pre-existing, different blast
radius.
Replace the author-asserted note on the create-request body test with what
the API server actually does. Probed against api.deepgram.com and
api.staging.deepgram.com on 2026-09-23: `provider`, `comment`, and `scopes`
are all required JSON body fields, omitting any one answers HTTP 400 with
`Json deserialize error: missing field <name>` even when the same value is
passed as a query parameter, and `provider` accepts only `quay`. The
published reference documents `scopes` and `provider` as query parameters
and `comment` as an optional body field; the server contradicts it.

No behavior change: the request the SDK already builds matches the server.
@dg-coreylweathers

Copy link
Copy Markdown
Contributor Author

I can't close B1 from this account, and I don't want to guess the shape — that's exactly what you flagged. Here's what I established and what I need.

Blocked on scope. Our member is ["owner"] on both prod and staging, and owner is not sufficient. Every create attempt, both hosts:

{"category":"INSUFFICIENT_PERMISSIONS","message":"Your account does not have the required scope to perform that action for this project.","details":"Check that your account has the 'self-hosted:product:api' scope for this project."}

Tried all seven self-hosted:product:* values. The list endpoint returns {"distribution_credentials":[]} on both, so it constrains nothing either. I never observed a successful create response, so I changed nothing about CreatedDistributionCredentials.

What would unblock it: a project on staging whose member holds any one of self-hosted:product:api / engine / license-proxy / dgtools / billing / hotpepper / metrics-server. One create plus one delete settles it in a minute; the probe script already does create → GET → list → delete → verify-zero.

What I did verify — and the reference is wrong about it. The create request contract:

  • provider, comment and scopes are all required JSON body fields. Omit any one and you get Json deserialize error: missing field \``, including when you supply it as a query parameter instead.
  • Query parameters are not read at all.
  • provider accepts only quay — anything else gives unknown variant \bogus-provider`, expected `quay``.

The reference documents scopes and provider as query parameters with defaults and comment as an optional body field. All three are wrong. The PR's implementation already matches the server; the only change here turns an author-asserted test comment into the verified contract, with the date.

Two more for the spec, separate from this PR:

  1. The create 200 never mentions username or secret, and reuses the GET-by-id response description verbatim. Whichever model turns out to be right, the reference can't currently be the source for either.
  2. Our own api skill lists the domain as /v1/projects/*/selfhosted/*, which 404s. Live paths are self-hosted and onprem (both 200).

A caution for whoever runs the scoped create. I nearly shipped a wrong fix here. scopes: ["self-hosted:products"] returns a 400 identical to a bogus string, which reads like "the shorthand is invalid" — I had a whole change written removing DEFAULT_SCOPE. It isn't invalid: the roles guide says the shorthand expands to the scopes the caller actually holds, and ours holds none, so it expands to empty. From an unentitled account those two hypotheses are indistinguishable. Re-run that probe from the scoped account; it'll settle whether DEFAULT_SCOPE is usable.

… into feat/phase-3-tts-ws-selfhosted-r3

# Conflicts:
#	CHANGELOG.md

@GregHolmes GregHolmes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[B1] Self-hosted credential creation has no verified success-response contract

Summary: SelfHosted::create_distribution_credentials decodes only a bare CreatedDistributionCredentials object with one-time username and secret, while the published endpoint reference documents a { member, distribution_credentials } wrapper. The available account cannot obtain a successful create response, so the bare shape remains unverified. If production returns the documented wrapper, the credential is created but the SDK returns a deserialization error; a retry can create duplicates without exposing the original secret.

Expected: The return type and example are based on one sanitized HTTP 200 create response from an entitled project.

Observed: src/manage/self_hosted/response.rs and its fixtures encode the bare secret-bearing shape without a captured successful create response, despite the conflicting public schema.

Recommended fix: Run create, get, list, and delete against a project whose member has a self-hosted:product:* scope. Model the captured 200 response and add it as a fixture. If it is the documented wrapper, return DistributionCredentialsEntry; if it is the bare secret-bearing shape, retain the current type and file a deepgram-docs / OpenAPI correction with the evidence.

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.

2 participants