-
Notifications
You must be signed in to change notification settings - Fork 108
fix: close TTS rework fallback gaps #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tianheil3
wants to merge
22
commits into
msgbyte:main
Choose a base branch
from
tianheil3:tianheilene/tia-51-tts-能力扩展-rework-5
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
The head ref may contain hidden characters: "tianheilene/tia-51-tts-\u80FD\u529B\u6269\u5C55-rework-5"
Open
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
61315a7
feat(tts): add external provider fallback
tianheil3 17979ae
fix(tts): address rework review findings
tianheil3 e7c5347
fix(tts): harden provider response validation
tianheil3 7b0376f
fix(tts): preserve aborts and normalize config parsing
tianheil3 d7c896a
refactor(tts): clean up provider helper types
tianheil3 d71ca34
fix(tts): use structured provider error codes
tianheil3 44fdfa5
fix(tts): classify timeout failures as upstream errors
tianheil3 eaa3798
fix(tts): narrow fallback and refresh design context
tianheil3 60ccc10
fix(tts): close rework gaps in provider fallback
tianheil3 b5dc030
fix(tts): handle legacy redirects and namespaced config
tianheil3 5e233ff
test(tts): cover fallback 500 path and clean fixtures
tianheil3 97faf99
fix(tts): harden upstream audio reads and probes
tianheil3 7279ef1
test(tts): cover media and timeline insertion flow
tianheil3 1358874
test(tts): reduce flake and tighten final invariants
tianheil3 dcc5a17
docs(tts): document external provider env vars
tianheil3 1b99bcc
docs(tts): add live probe commands
tianheil3 db75780
feat(tts): add responses websocket fallback
tianheil3 9be976b
test(tts): fix websocket test typing
tianheil3 2068474
fix(tts): fall back on account exhaustion
tianheil3 5750a17
fix(tts): address remaining rework review comments
tianheil3 5fb0c77
docs(tts): clarify external model requirements
tianheil3 2e71fe3
docs(tts): add provider capability troubleshooting
tianheil3 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; | ||
| import { TtsError } from "@/lib/tts/errors"; | ||
| import { NextRequest } from "next/server"; | ||
|
|
||
| let synthesizeImpl: typeof import("@/lib/tts/provider").synthesizeSpeechWithFallback; | ||
| const originalConsoleError = console.error; | ||
|
|
||
| mock.module("@cutia/env/web", () => ({ | ||
| webEnv: { | ||
| API_BASE_URL: "https://example.com/v1", | ||
| API_MODEL: "tts-1", | ||
| API_KEY: "secret", | ||
| }, | ||
| })); | ||
|
|
||
| mock.module("@/lib/tts/provider", () => ({ | ||
| synthesizeSpeechWithFallback: (args: Parameters<typeof synthesizeImpl>[0]) => | ||
| synthesizeImpl(args), | ||
| })); | ||
|
|
||
| const { POST } = await import("./route"); | ||
|
|
||
| function createRequest(body: unknown): NextRequest { | ||
| return new NextRequest("http://localhost/api/tts/generate", { | ||
| body: JSON.stringify(body), | ||
| headers: { | ||
| "content-type": "application/json", | ||
| }, | ||
| method: "POST", | ||
| }); | ||
| } | ||
|
|
||
| describe("POST /api/tts/generate", () => { | ||
| beforeEach(() => { | ||
| console.error = mock(() => {}); | ||
| synthesizeImpl = async () => Uint8Array.from([1, 2, 3]).buffer; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| console.error = originalConsoleError; | ||
| }); | ||
|
|
||
| test("returns base64 audio for successful synthesis", async () => { | ||
| const response = await POST(createRequest({ text: "hello" })); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(await response.json()).toEqual({ | ||
| audio: "AQID", | ||
| }); | ||
| }); | ||
|
|
||
| test("returns 502 for structured legacy upstream errors without relying on message prefixes", async () => { | ||
| synthesizeImpl = async () => { | ||
| throw new TtsError({ | ||
| code: "LEGACY_TTS_UPSTREAM", | ||
| message: "legacy fallback audio download failed", | ||
| }); | ||
| }; | ||
|
|
||
| const response = await POST(createRequest({ text: "hello" })); | ||
|
|
||
| expect(response.status).toBe(502); | ||
| expect(await response.json()).toEqual({ | ||
| error: "legacy fallback audio download failed", | ||
| }); | ||
| }); | ||
|
|
||
| test("returns 502 for structured external upstream errors without relying on message prefixes", async () => { | ||
| synthesizeImpl = async () => { | ||
| throw new TtsError({ | ||
| code: "EXTERNAL_TTS_UPSTREAM", | ||
| message: "upstream gateway timeout", | ||
| }); | ||
| }; | ||
|
|
||
| const response = await POST(createRequest({ text: "hello" })); | ||
|
|
||
| expect(response.status).toBe(502); | ||
| expect(await response.json()).toEqual({ | ||
| error: "upstream gateway timeout", | ||
| }); | ||
| }); | ||
|
|
||
| test("returns the original config error message for structured config failures", async () => { | ||
| synthesizeImpl = async () => { | ||
| throw new TtsError({ | ||
| code: "EXTERNAL_TTS_CONFIG", | ||
| message: "external config missing", | ||
| }); | ||
| }; | ||
|
|
||
| const response = await POST(createRequest({ text: "hello" })); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(await response.json()).toEqual({ | ||
| error: "external config missing", | ||
| }); | ||
| }); | ||
|
|
||
| test("returns 500 for unexpected non-TtsError exceptions", async () => { | ||
| synthesizeImpl = async () => { | ||
| throw new Error("unexpected failure"); | ||
| }; | ||
|
|
||
| const response = await POST(createRequest({ text: "hello" })); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(await response.json()).toEqual({ | ||
| error: "Internal server error", | ||
| detail: "unexpected failure", | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| export const TTS_ERROR_CODES = [ | ||
| "EXTERNAL_TTS_CONFIG", | ||
| "EXTERNAL_TTS_UPSTREAM", | ||
| "LEGACY_TTS_UPSTREAM", | ||
| ] as const; | ||
|
|
||
| export type TtsErrorCode = (typeof TTS_ERROR_CODES)[number]; | ||
|
|
||
| export class TtsError extends Error { | ||
| code: TtsErrorCode; | ||
| retryable?: boolean; | ||
| status?: number; | ||
|
|
||
| constructor({ | ||
| code, | ||
| message, | ||
| retryable, | ||
| status, | ||
| }: { | ||
| code: TtsErrorCode; | ||
| message: string; | ||
| retryable?: boolean; | ||
| status?: number; | ||
| }) { | ||
| super(message); | ||
| this.name = "TtsError"; | ||
| this.code = code; | ||
| this.retryable = retryable; | ||
| this.status = status; | ||
| } | ||
| } | ||
|
|
||
| export function isTtsError(error: unknown): error is TtsError { | ||
| if (!(error instanceof Error)) { | ||
| return false; | ||
| } | ||
|
|
||
| return TTS_ERROR_CODES.includes((error as TtsError).code); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { fetchWithTimeout } from "./fetch-with-timeout"; | ||
|
|
||
| describe("fetchWithTimeout", () => { | ||
| test("resolves successfully when fetch completes before the timeout", async () => { | ||
| let fetchCalled = false; | ||
|
|
||
| const response = await fetchWithTimeout({ | ||
| fetchImpl: async () => { | ||
| fetchCalled = true; | ||
| return new Response("ok", { status: 200 }); | ||
| }, | ||
| input: "https://example.com", | ||
| timeoutMessage: "timed out", | ||
| timeoutMs: 50, | ||
| }); | ||
|
|
||
| expect(fetchCalled).toBe(true); | ||
| expect(response.status).toBe(200); | ||
| expect(await response.text()).toBe("ok"); | ||
| }); | ||
|
|
||
| test("rejects immediately when the caller signal is already aborted", async () => { | ||
| const controller = new AbortController(); | ||
| const callerError = new Error("caller aborted"); | ||
| let fetchCalled = false; | ||
|
|
||
| controller.abort(callerError); | ||
|
|
||
| await expect( | ||
| fetchWithTimeout({ | ||
| fetchImpl: async () => { | ||
| fetchCalled = true; | ||
| return new Response("ok"); | ||
| }, | ||
| init: { signal: controller.signal }, | ||
| input: "https://example.com", | ||
| timeoutMessage: "timed out", | ||
| timeoutMs: 50, | ||
| }), | ||
| ).rejects.toThrow("caller aborted"); | ||
|
|
||
| expect(fetchCalled).toBe(false); | ||
| }); | ||
|
|
||
| test("surfaces caller cancellation for in-flight requests", async () => { | ||
| const controller = new AbortController(); | ||
| const callerError = new Error("caller aborted"); | ||
|
|
||
| await expect( | ||
| fetchWithTimeout({ | ||
| fetchImpl: async (_input, init) => | ||
| new Promise((_resolve, reject) => { | ||
| setTimeout(() => controller.abort(callerError), 0); | ||
|
|
||
| init?.signal?.addEventListener( | ||
| "abort", | ||
| () => reject(init.signal?.reason ?? new Error("aborted")), | ||
| { once: true }, | ||
| ); | ||
| }), | ||
| init: { signal: controller.signal }, | ||
| input: "https://example.com", | ||
| timeoutMessage: "timed out", | ||
| timeoutMs: 50, | ||
| }), | ||
| ).rejects.toThrow("caller aborted"); | ||
| }); | ||
|
|
||
| test("rejects with the timeout message when fetch exceeds timeoutMs", async () => { | ||
| await expect( | ||
| fetchWithTimeout({ | ||
| fetchImpl: async (_input, init) => | ||
| new Promise((_resolve, reject) => { | ||
| init?.signal?.addEventListener( | ||
| "abort", | ||
| () => reject(new Error("aborted")), | ||
| { once: true }, | ||
| ); | ||
| }), | ||
| input: "https://example.com", | ||
| timeoutMessage: "timed out", | ||
| timeoutMs: 10, | ||
| }), | ||
| ).rejects.toThrow("timed out"); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.