diff --git a/extensions/google/speech-provider.test.ts b/extensions/google/speech-provider.test.ts index c611a3fb031..b8834a58f0a 100644 --- a/extensions/google/speech-provider.test.ts +++ b/extensions/google/speech-provider.test.ts @@ -277,6 +277,54 @@ describe("Google speech provider", () => { expect(result.audioBuffer.subarray(44)).toEqual(pcm); }); + it("retries once when Gemini TTS fetch aborts", async () => { + const pcm = Buffer.from([7, 0, 8, 0]); + const abortError = new Error("This operation was aborted"); + abortError.name = "AbortError"; + const requestSequence = vi + .fn() + .mockRejectedValueOnce(abortError) + .mockResolvedValueOnce({ + response: googleTtsResponse(pcm), + release: vi.fn(async () => {}), + }); + postJsonRequestMock.mockImplementation(requestSequence); + const provider = buildGoogleSpeechProvider(); + + const result = await provider.synthesize({ + text: "Retry aborted fetch.", + cfg: {}, + providerConfig: { + apiKey: "google-test-key", + }, + target: "audio-file", + timeoutMs: 5_000, + }); + + expect(requestSequence).toHaveBeenCalledTimes(2); + expect(result.audioBuffer.subarray(44)).toEqual(pcm); + }); + + it("does not retry non-transient Gemini TTS request failures", async () => { + const requestSequence = vi.fn().mockRejectedValueOnce(new Error("invalid request")); + postJsonRequestMock.mockImplementation(requestSequence); + const provider = buildGoogleSpeechProvider(); + + await expect( + provider.synthesize({ + text: "Do not retry this.", + cfg: {}, + providerConfig: { + apiKey: "google-test-key", + }, + target: "audio-file", + timeoutMs: 5_000, + }), + ).rejects.toThrow("invalid request"); + + expect(requestSequence).toHaveBeenCalledTimes(1); + }); + it("falls back to GEMINI_API_KEY and configured Google API base URL", async () => { vi.stubEnv("GEMINI_API_KEY", "env-google-key"); const requestMock = installGoogleTtsRequestMock(); diff --git a/extensions/google/speech-provider.ts b/extensions/google/speech-provider.ts index b0a3932e0a6..951a4001cfa 100644 --- a/extensions/google/speech-provider.ts +++ b/extensions/google/speech-provider.ts @@ -107,6 +107,25 @@ class GoogleTtsRetryableError extends Error { } } +function isGoogleTtsRetryableError(err: unknown): boolean { + if (err instanceof GoogleTtsRetryableError) { + return true; + } + if (!(err instanceof Error)) { + return false; + } + if (err.name === "AbortError") { + return true; + } + const message = err.message.toLowerCase(); + return ( + message.includes("aborted") || + message.includes("timeout") || + message.includes("fetch failed") || + message.includes("network") + ); +} + function normalizeGoogleTtsModel(model: unknown): string { const trimmed = normalizeOptionalString(model); if (!trimmed) { @@ -509,7 +528,7 @@ async function synthesizeGoogleTtsPcm(params: { return await synthesizeGoogleTtsPcmOnce(params); } catch (err) { lastError = err; - if (!(err instanceof GoogleTtsRetryableError) || attempt > 0) { + if (!isGoogleTtsRetryableError(err) || attempt > 0) { throw err; } } diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index cecc380a6d7..6d10a3238ef 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -685,6 +685,7 @@ function shouldSkipToolNonceProbeMissForLiveModel(modelKey?: string): boolean { provider === "minimax" || provider === "opencode" || provider === "opencode-go" || + provider === "openrouter" || provider === "xai" || provider === "zai" ) { @@ -703,6 +704,7 @@ describe("shouldSkipToolNonceProbeMissForLiveModel", () => { { modelKey: "minimax/minimax-m1", expected: true }, { modelKey: "opencode/big-pickle", expected: true }, { modelKey: "opencode-go/glm-5", expected: true }, + { modelKey: "openrouter/ai21/jamba-large-1.7", expected: true }, { modelKey: "xai/grok-4.1-fast", expected: true }, { modelKey: "zai/glm-5.1", expected: true }, { modelKey: "google/gemini-3-flash-preview", expected: true },