From 36d34430aa390dd49358a3aa50a7ca1dff94236f Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:24:51 +0200 Subject: [PATCH 1/5] fix(ai): stop appending fallback text after any provider emits a partial stream The partial-then-fail guard in streamText's fallback loop only covered Grok (createGrokAttemptCallbacks/grokEmitted), so a non-Grok primary that streamed visible text and then failed mid-response could have a second, full fallback answer appended after it. Replace the Grok-specific mechanism with a generic per-attempt emitted tracker applied to every provider in the fallback chain and to the OpenRouter-promoted-fallback attempt, so any provider that has already emitted output terminates on failure instead of falling through. Also fixes a vacuous regression test whose fallbackProviders config never actually reached Gemini (that option is only honored for local primaries), so it passed regardless of the guard's correctness. --- README.md | 8 +- services/aiProviderService.ts | 179 ++++++++++++++++++--------- tests/unit/aiProviderService.test.ts | 124 ++++++++++++++++++- 3 files changed, 249 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 379b0cd39..2f52da652 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ TypeScript native preview (tsgo) Tauri 2 19 locales — 2942 keys - 7927+ tests / 609 files + 7931+ tests / 609 files Codecov Coverage CI status MIT License @@ -809,7 +809,7 @@ The normal web PR pipeline is not the complete native release qualification surf | DOCX | `docx` + JSZip | Word-compatible export | | PWA | Service Worker + Web App Manifest | Offline shell/installability | | i18n | Custom React i18n context | 2942 keys × 19 locales | -| Testing | Vitest 4.x (7927+ tests / 609 files) + Playwright | Unit/integration/E2E | +| Testing | Vitest 4.x (7931+ tests / 609 files) + Playwright | Unit/integration/E2E | | Quality | Biome + tsgo + CodeQL/security tooling | Static and CI gates | | Desktop | Tauri 2 | Current native shell | @@ -886,7 +886,7 @@ WorldScript-Studio/ ├── locales/ # Source locale trees ├── public/ # PWA assets, manifest, SW, runtime locale bundles ├── tests/ -│ ├── unit/ # Vitest unit tests (7927+ tests; file count includes package test directories) +│ ├── unit/ # Vitest unit tests (7931+ tests; file count includes package test directories) │ └── e2e/ # Playwright ├── docs/ # Canonical product/engineering documentation + ADRs ├── src-tauri/ # Tauri v2 desktop shell / Rust @@ -1031,7 +1031,7 @@ Raw bundle-budget ceilings (KB per uncompressed asset): entry **2500 KB**, vendo Current source-synchronized README metrics: -- **7927+ unit tests** across **609 test files** +- **7931+ unit tests** across **609 test files** - i18n: **2942 keys × 19 locales** CI remains authoritative for actual pass/fail and live coverage. diff --git a/services/aiProviderService.ts b/services/aiProviderService.ts index 246863529..c1101cb87 100644 --- a/services/aiProviderService.ts +++ b/services/aiProviderService.ts @@ -94,12 +94,22 @@ function recordProviderSuccess(primary: AIProvider, provider: AIProvider, index: index > 0 ? `Primary provider ${primary} failed; fell back to ${provider}.` : ''; } -// QNBS-v3: retain Grok chunk tracking so partial output cannot be followed by fallback text. -function createGrokAttemptCallbacks( - callbacks: AIStreamCallbacks, - onChunk: (text: string) => void, -): AIStreamCallbacks { - return { ...callbacks, onChunk }; +// QNBS-v3: tracks whether an attempt emitted output, so a later failure on it can't be followed by fallback text appended to a truncated answer — applies to every provider, not just Grok. +function createAttemptEmittedTracker(callbacks: AIStreamCallbacks): { + callbacks: AIStreamCallbacks; + hasEmitted: () => boolean; +} { + let emitted = false; + return { + callbacks: { + ...callbacks, + onChunk: (text) => { + emitted = true; + callbacks.onChunk(text); + }, + }, + hasEmitted: () => emitted, + }; } // ─── Gemini Provider ────────────────────────────────────────────────────────── @@ -437,6 +447,65 @@ export async function generateImage( ); } +// QNBS-v3: extracted out of streamText's fallback loop to keep its cognitive complexity under the repo ceiling — the promoted-fallback attempt shares the same try/tracker/cancellation shape. +type AttemptOutcome = + | { kind: 'succeeded' } + | { kind: 'failed-emitted'; error: unknown } + | { kind: 'failed-silent'; error: unknown }; + +async function attemptChainProvider( + prompt: string, + creativity: AiCreativity, + mergedOpts: AIRequestOptions, + nextProvider: AIProvider, + guardedCallbacks: AIStreamCallbacks, + signal: AbortSignal | undefined, +): Promise { + // QNBS-v3: track partial output before fallback decisions — applies to every provider, not just Grok. + const attemptTracker = createAttemptEmittedTracker(guardedCallbacks); + try { + await streamProvider( + prompt, + creativity, + { ...mergedOpts, provider: nextProvider }, + attemptTracker.callbacks, + signal, + ); + throwIfRequestAborted(undefined, mergedOpts.signal, signal); + return { kind: 'succeeded' }; + } catch (error) { + // QNBS-v3: A user-cancelled request is NOT a provider failure — throwing here propagates the cancellation straight out of streamText instead of continuing the fallback loop. + throwIfRequestAborted(error, mergedOpts.signal, signal); + return attemptTracker.hasEmitted() + ? { kind: 'failed-emitted', error } + : { kind: 'failed-silent', error }; + } +} + +async function attemptPromotedOpenRouterFallback( + mergedOpts: AIRequestOptions, + fallback: string, + prompt: string, + creativity: AiCreativity, + guardedCallbacks: AIStreamCallbacks, + signal: AbortSignal | undefined, +): Promise { + const fallbackTracker = createAttemptEmittedTracker(guardedCallbacks); + try { + await attemptOpenRouterFallback(mergedOpts, fallback, (fallbackOpts) => + streamProvider(prompt, creativity, fallbackOpts, fallbackTracker.callbacks, signal), + ); + throwIfRequestAborted(undefined, mergedOpts.signal, signal); + return { kind: 'succeeded' }; + } catch (fallbackError) { + // QNBS-v3: mirrors the outer catch's cancellation guard — a cancel during the promoted fallback must not be treated as a provider failure either. + throwIfRequestAborted(fallbackError, mergedOpts.signal, signal); + return fallbackTracker.hasEmitted() + ? { kind: 'failed-emitted', error: fallbackError } + : { kind: 'failed-silent', error: fallbackError }; + } +} + export async function streamText( prompt: string, creativity: AiCreativity, @@ -474,65 +543,61 @@ export async function streamText( for (let i = 0; i < chain.length; i++) { const nextProvider = chain[i]; if (nextProvider === undefined || nextProvider === attemptedOpenRouterFallback) continue; - // QNBS-v3: track partial Grok output before fallback decisions. - let grokEmitted = false; - const callbacksForAttempt = - nextProvider === 'grok' - ? createGrokAttemptCallbacks(guardedCallbacks, (text) => { - grokEmitted = true; - guardedCallbacks.onChunk(text); - }) - : guardedCallbacks; - try { - await streamProvider( + const outcome = await attemptChainProvider( + prompt, + creativity, + mergedOpts, + nextProvider, + guardedCallbacks, + signal, + ); + if (outcome.kind === 'succeeded') { + // QNBS-v3: mirrors generateText's fallback-reason bookkeeping — without this, a stale reason from an earlier failed/promoted request would keep showing in GpuMetricsPanel after this request's primary provider succeeds outright. + recordProviderSuccess(mergedOpts.provider, nextProvider, i); + return; + } + if (outcome.kind === 'failed-emitted') { + // QNBS-v3: A partial response from any provider must terminate rather than append fallback text to a truncated answer. + const terminal = + outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error)); + callbacks.onError?.(terminal); + throw terminal; + } + const error = outcome.error; + lastError = error; + // QNBS-v3: mirrors generateText's OpenRouter rate-limit/circuit-open promotion — without this, a stream promoted to OpenRouter by resolvePositiveRoutingOpts would fail hard on a transient OpenRouter outage instead of falling back. + if (isOpenRouterTransientFailure(nextProvider, error)) { + const fallback = getOpenRouterFallbackProvider(); + attemptedOpenRouterFallback = fallback; + const promotedOutcome = await attemptPromotedOpenRouterFallback( + mergedOpts, + fallback, prompt, creativity, - { ...mergedOpts, provider: nextProvider }, - callbacksForAttempt, + guardedCallbacks, signal, ); - throwIfRequestAborted(undefined, mergedOpts.signal, signal); - // QNBS-v3: mirrors generateText's fallback-reason bookkeeping — without this, a stale reason from an earlier failed/promoted request would keep showing in GpuMetricsPanel after this request's primary provider succeeds outright. - recordProviderSuccess(mergedOpts.provider, nextProvider, i); - return; - } catch (error) { - // QNBS-v3: A user-cancelled request is NOT a provider failure. Don't fall back to the next - // provider and don't fire a terminal onError — surface the cancellation directly so callers - // run their silent cancel flow instead of an error path. - throwIfRequestAborted(error, mergedOpts.signal, signal); - if (nextProvider === 'grok' && grokEmitted) { - // QNBS-v3: A partial Grok response must terminate rather than append fallback text to a truncated answer. - const terminal = error instanceof Error ? error : new Error(String(error)); - callbacks.onError?.(terminal); - throw terminal; - } - lastError = error; - // QNBS-v3: mirrors generateText's OpenRouter rate-limit/circuit-open promotion — without this, a stream promoted to OpenRouter by resolvePositiveRoutingOpts would fail hard on a transient OpenRouter outage instead of falling back. - if (isOpenRouterTransientFailure(nextProvider, error)) { - const fallback = getOpenRouterFallbackProvider(); - attemptedOpenRouterFallback = fallback; - try { - await attemptOpenRouterFallback(mergedOpts, fallback, (fallbackOpts) => - streamProvider(prompt, creativity, fallbackOpts, guardedCallbacks, signal), - ); - throwIfRequestAborted(undefined, mergedOpts.signal, signal); - _lastFallbackReason = `OpenRouter rate-limited; fell back to ${fallback}.`; - return; - } catch (fallbackError) { - // QNBS-v3: mirrors the outer catch's cancellation guard — a cancel during the promoted fallback must not be treated as a provider failure either. - throwIfRequestAborted(fallbackError, mergedOpts.signal, signal); - lastError = fallbackError; - } + if (promotedOutcome.kind === 'succeeded') { + _lastFallbackReason = `OpenRouter rate-limited; fell back to ${fallback}.`; + return; } - if (i === chain.length - 1) { - // QNBS-v3: onError is owned by this orchestration layer — fire it exactly once, after - // the whole fallback chain is exhausted, so a failing provider never surfaces a terminal - // error callback while a subsequent fallback provider is still about to succeed. - const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); - if (tryHeuristicStream()) return; + if (promotedOutcome.kind === 'failed-emitted') { + // QNBS-v3: same partial-response guard applies to the promoted OpenRouter fallback attempt itself. + const terminal = + promotedOutcome.error instanceof Error + ? promotedOutcome.error + : new Error(String(promotedOutcome.error)); callbacks.onError?.(terminal); throw terminal; } + lastError = promotedOutcome.error; + } + if (i === chain.length - 1) { + // QNBS-v3: onError is owned by this orchestration layer — fire it exactly once, after the whole chain is exhausted, so no fallback provider still in flight gets a premature terminal error. + const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); + if (tryHeuristicStream()) return; + callbacks.onError?.(terminal); + throw terminal; } } const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); diff --git a/tests/unit/aiProviderService.test.ts b/tests/unit/aiProviderService.test.ts index 616551bc6..47c254296 100644 --- a/tests/unit/aiProviderService.test.ts +++ b/tests/unit/aiProviderService.test.ts @@ -48,6 +48,11 @@ vi.mock('@tauri-apps/plugin-http', () => ({ })); import { setActiveAiMode, setOpenRouterConfig } from '../../services/ai/aiModeService'; +import { + _clearHeuristicRegistry, + makeHeuristicResult, + registerHeuristicGenerator, +} from '../../services/ai/heuristicFallback'; import { streamOpenAiCompatibleLocal } from '../../services/ai/providers/localOpenAiCompatibleProvider'; import { consumeOpenAiCompatibleStream } from '../../services/ai/providers/openaiProvider'; import * as openrouterProvider from '../../services/ai/providers/openrouterProvider'; @@ -620,6 +625,117 @@ describe('streamText', () => { expect(geminiService.streamText).toHaveBeenCalledTimes(1); }); + // QNBS-v3 (#714): the partial-then-fail guard was previously Grok-only, so a non-Grok primary could emit visible text and then have a full fallback answer appended after it. + describe('partial-then-fail fallback guard applies to every provider (#714)', () => { + afterEach(() => { + _clearHeuristicRegistry(); + }); + + it('does not append fallback text after a non-Grok primary (OpenRouter) emits a partial response', async () => { + vi.mocked(storageService.getApiKey).mockResolvedValueOnce('or-key'); + vi.mocked(openrouterProvider.streamOpenRouter).mockImplementationOnce( + async (_prompt, _opts, callbacks) => { + callbacks.onChunk('partial openrouter text'); + throw new Error('stream ended before completion'); + }, + ); + const onChunk = vi.fn(); + const onError = vi.fn(); + await expect( + streamText( + 'prompt', + 'Balanced', + { + ...defaultOpts, + provider: 'openrouter', + hybridFallbackEnabled: true, + hybridFallbackChain: ['gemini'], + }, + { onChunk, onError }, + ), + ).rejects.toThrow('stream ended before completion'); + expect(geminiService.streamText).not.toHaveBeenCalled(); + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith('partial openrouter text'); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('terminates without further fallback when the OpenRouter-promoted fallback attempt itself emits a partial response then fails', async () => { + vi.mocked(storageService.getApiKey).mockResolvedValueOnce('or-key'); + vi.mocked(openrouterProvider.streamOpenRouter).mockRejectedValueOnce( + new Error('OPENROUTER_RATE_LIMITED: too many requests'), + ); + vi.mocked(geminiService.streamText).mockImplementationOnce(async (_p, _c, onChunk) => { + onChunk('partial gemini fallback text'); + throw new Error('gemini stream ended before completion'); + }); + const onChunk = vi.fn(); + const onError = vi.fn(); + await expect( + streamText( + 'prompt', + 'Balanced', + { ...defaultOpts, provider: 'openrouter' }, + { onChunk, onError }, + ), + ).rejects.toThrow('gemini stream ended before completion'); + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith('partial gemini fallback text'); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('still delivers a registered heuristic result when the chain is exhausted with zero chunks emitted (unchanged behavior)', async () => { + const generator = vi + .fn() + .mockReturnValue( + makeHeuristicResult('heuristic answer', { confidence: 0.5, tier: 'basic' }), + ); + registerHeuristicGenerator('test.streamText.714', generator); + vi.mocked(geminiService.streamText).mockRejectedValueOnce(new Error('Gemini offline')); + const onChunk = vi.fn(); + const onDone = vi.fn(); + const onError = vi.fn(); + await streamText( + 'prompt', + 'Balanced', + { ...defaultOpts, heuristicTask: 'test.streamText.714' }, + { onChunk, onDone, onError }, + ); + expect(generator).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith('heuristic answer'); + expect(onDone).toHaveBeenCalledTimes(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('does not fall through to the heuristic generator once the failing provider already emitted a partial response', async () => { + const generator = vi + .fn() + .mockReturnValue( + makeHeuristicResult('heuristic answer', { confidence: 0.5, tier: 'basic' }), + ); + registerHeuristicGenerator('test.streamText.714b', generator); + vi.mocked(geminiService.streamText).mockImplementationOnce(async (_p, _c, onChunk) => { + onChunk('partial gemini text'); + throw new Error('Gemini offline mid-stream'); + }); + const onChunk = vi.fn(); + const onError = vi.fn(); + await expect( + streamText( + 'prompt', + 'Balanced', + { ...defaultOpts, heuristicTask: 'test.streamText.714b' }, + { onChunk, onError }, + ), + ).rejects.toThrow('Gemini offline mid-stream'); + expect(generator).not.toHaveBeenCalled(); + expect(onChunk).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith('partial gemini text'); + expect(onError).toHaveBeenCalledTimes(1); + }); + }); + // QNBS-v3: Grok regression locks SSE request, delta, and completion contracts. it('uses xAI streamed chat completions and preserves the system prompt', async () => { const originalFetch = globalThis.fetch; @@ -805,7 +921,13 @@ describe('streamText', () => { streamText( 'user prompt', 'Balanced', - { provider: 'grok', model: 'grok-4.5', fallbackProviders: ['gemini'] }, + { + provider: 'grok', + model: 'grok-4.5', + // QNBS-v3: fallbackProviders only applies to local primaries — grok needs hybridFallbackEnabled + hybridFallbackChain, or the chain stays ['grok'] and the assertions below pass vacuously. + hybridFallbackEnabled: true, + hybridFallbackChain: ['gemini'], + }, { onChunk, onError }, ), ).rejects.toThrow('stream ended before completion'); From ef41ce874599fcd7cddd32a007706f7e18f2d782 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:28:03 +0200 Subject: [PATCH 2/5] docs(changelog): reference PR #770 in Unreleased --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0fcbcbb4..a7d72c658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **AI streaming no longer appends fallback text after a provider emits a partial response:** + the partial-then-fail guard previously only covered Grok, so a non-Grok primary (OpenRouter, + OpenAI, Gemini, Anthropic, Ollama) that streamed visible text and then failed mid-response + could have a second, full fallback answer appended after it — now every provider attempt in + the fallback chain, including the OpenRouter-promoted-fallback attempt, terminates on failure + once it has emitted output. Part of #714. PR #770. - **Writer generations, Global Copilot replies, and Dashboard logline suggestions now reject stale results after a project-incarnation change:** Writer's generation history and Copilot's chat/reply state live in global Redux, so they survived a view unmount/remount across a project From c3747cd39503545d90f3a304fdc011debbafa312 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:47:26 +0200 Subject: [PATCH 3/5] refactor(ai): keep streamText a shallow coordinator over the fallback-chain walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeScene flagged streamText's cyclomatic complexity rising from 29 to 31 after the partial-then-fail guard became provider-generic. Extract the entire fallback-chain walk (the outcome-branching loop, OpenRouter-promotion handling, and terminal heuristic-or-throw path) into runProviderFallbackChain, and the heuristic-delivery closure into a standalone tryHeuristicStream, so streamText itself is left doing only what a coordinator should: build the guarded callbacks, resolve the merged request, and delegate. No behavioral change — attemptChainProvider and attemptPromotedOpenRouterFallback are unchanged, and the emitted-output guard remains load-bearing (verified by temporarily bypassing it and confirming the #714 regression tests fail). --- services/aiProviderService.ts | 183 +++++++++++++++++++--------------- 1 file changed, 103 insertions(+), 80 deletions(-) diff --git a/services/aiProviderService.ts b/services/aiProviderService.ts index c1101cb87..d65ed92f1 100644 --- a/services/aiProviderService.ts +++ b/services/aiProviderService.ts @@ -506,6 +506,101 @@ async function attemptPromotedOpenRouterFallback( } } +// QNBS-v3: after the chain is exhausted, deliver a registered heuristic result through the stream (onChunk + onDone) instead of erroring — so streaming features (Writer tools) stay useful offline. +function tryHeuristicStream( + mergedOpts: AIRequestOptions, + prompt: string, + guardedCallbacks: AIStreamCallbacks, +): boolean { + const heuristic = applyHeuristicFallback( + mergedOpts.heuristicTask, + mergedOpts.heuristicContext ?? { prompt, reasonKey: 'error.fallback.generic' }, + ); + if (!heuristic) return false; + guardedCallbacks.onChunk(heuristic.data); + guardedCallbacks.onDone?.(); + return true; +} + +// QNBS-v3: the whole fallback-chain walk, extracted so streamText itself stays a shallow coordinator (build guardedCallbacks, resolve chain, delegate) instead of owning every branch of the walk. +async function runProviderFallbackChain( + prompt: string, + creativity: AiCreativity, + mergedOpts: AIRequestOptions, + callbacks: AIStreamCallbacks, + guardedCallbacks: AIStreamCallbacks, + signal: AbortSignal | undefined, +): Promise { + const chain = resolveProviderFallbackChain(mergedOpts); + let lastError: unknown; + // QNBS-v3: tracks an OpenRouter-promoted fallback provider already attempted this call, so the outer loop doesn't invoke it a second time (and double-bill/duplicate chunks) if the chain also lists it later. + let attemptedOpenRouterFallback: string | undefined; + for (let i = 0; i < chain.length; i++) { + const nextProvider = chain[i]; + if (nextProvider === undefined || nextProvider === attemptedOpenRouterFallback) continue; + const outcome = await attemptChainProvider( + prompt, + creativity, + mergedOpts, + nextProvider, + guardedCallbacks, + signal, + ); + if (outcome.kind === 'succeeded') { + // QNBS-v3: mirrors generateText's fallback-reason bookkeeping — without this, a stale reason from an earlier failed/promoted request would keep showing in GpuMetricsPanel after this request's primary provider succeeds outright. + recordProviderSuccess(mergedOpts.provider, nextProvider, i); + return; + } + if (outcome.kind === 'failed-emitted') { + // QNBS-v3: A partial response from any provider must terminate rather than append fallback text to a truncated answer. + const terminal = + outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error)); + callbacks.onError?.(terminal); + throw terminal; + } + const error = outcome.error; + lastError = error; + // QNBS-v3: mirrors generateText's OpenRouter rate-limit/circuit-open promotion — without this, a stream promoted to OpenRouter by resolvePositiveRoutingOpts would fail hard on a transient OpenRouter outage instead of falling back. + if (isOpenRouterTransientFailure(nextProvider, error)) { + const fallback = getOpenRouterFallbackProvider(); + attemptedOpenRouterFallback = fallback; + const promotedOutcome = await attemptPromotedOpenRouterFallback( + mergedOpts, + fallback, + prompt, + creativity, + guardedCallbacks, + signal, + ); + if (promotedOutcome.kind === 'succeeded') { + _lastFallbackReason = `OpenRouter rate-limited; fell back to ${fallback}.`; + return; + } + if (promotedOutcome.kind === 'failed-emitted') { + // QNBS-v3: same partial-response guard applies to the promoted OpenRouter fallback attempt itself. + const terminal = + promotedOutcome.error instanceof Error + ? promotedOutcome.error + : new Error(String(promotedOutcome.error)); + callbacks.onError?.(terminal); + throw terminal; + } + lastError = promotedOutcome.error; + } + if (i === chain.length - 1) { + // QNBS-v3: onError is owned by this orchestration layer — fire it exactly once, after the whole chain is exhausted, so no fallback provider still in flight gets a premature terminal error. + const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); + if (tryHeuristicStream(mergedOpts, prompt, guardedCallbacks)) return; + callbacks.onError?.(terminal); + throw terminal; + } + } + const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); + if (tryHeuristicStream(mergedOpts, prompt, guardedCallbacks)) return; + callbacks.onError?.(terminal); + throw terminal; +} + export async function streamText( prompt: string, creativity: AiCreativity, @@ -524,86 +619,14 @@ export async function streamText( if (!mergedOpts.signal?.aborted) callbacks.onDone?.(); }, }; - const chain = resolveProviderFallbackChain(mergedOpts); - let lastError: unknown; - // QNBS-v3: tracks an OpenRouter-promoted fallback provider already attempted this call, so the outer loop doesn't invoke it a second time (and double-bill/duplicate chunks) if the chain also lists it later. - let attemptedOpenRouterFallback: string | undefined; - // QNBS-v3: after the chain is exhausted, deliver a registered heuristic result through the stream - // (onChunk + onDone) instead of erroring — so streaming features (Writer tools) stay useful offline. - const tryHeuristicStream = (): boolean => { - const heuristic = applyHeuristicFallback( - mergedOpts.heuristicTask, - mergedOpts.heuristicContext ?? { prompt, reasonKey: 'error.fallback.generic' }, - ); - if (!heuristic) return false; - guardedCallbacks.onChunk(heuristic.data); - guardedCallbacks.onDone?.(); - return true; - }; - for (let i = 0; i < chain.length; i++) { - const nextProvider = chain[i]; - if (nextProvider === undefined || nextProvider === attemptedOpenRouterFallback) continue; - const outcome = await attemptChainProvider( - prompt, - creativity, - mergedOpts, - nextProvider, - guardedCallbacks, - signal, - ); - if (outcome.kind === 'succeeded') { - // QNBS-v3: mirrors generateText's fallback-reason bookkeeping — without this, a stale reason from an earlier failed/promoted request would keep showing in GpuMetricsPanel after this request's primary provider succeeds outright. - recordProviderSuccess(mergedOpts.provider, nextProvider, i); - return; - } - if (outcome.kind === 'failed-emitted') { - // QNBS-v3: A partial response from any provider must terminate rather than append fallback text to a truncated answer. - const terminal = - outcome.error instanceof Error ? outcome.error : new Error(String(outcome.error)); - callbacks.onError?.(terminal); - throw terminal; - } - const error = outcome.error; - lastError = error; - // QNBS-v3: mirrors generateText's OpenRouter rate-limit/circuit-open promotion — without this, a stream promoted to OpenRouter by resolvePositiveRoutingOpts would fail hard on a transient OpenRouter outage instead of falling back. - if (isOpenRouterTransientFailure(nextProvider, error)) { - const fallback = getOpenRouterFallbackProvider(); - attemptedOpenRouterFallback = fallback; - const promotedOutcome = await attemptPromotedOpenRouterFallback( - mergedOpts, - fallback, - prompt, - creativity, - guardedCallbacks, - signal, - ); - if (promotedOutcome.kind === 'succeeded') { - _lastFallbackReason = `OpenRouter rate-limited; fell back to ${fallback}.`; - return; - } - if (promotedOutcome.kind === 'failed-emitted') { - // QNBS-v3: same partial-response guard applies to the promoted OpenRouter fallback attempt itself. - const terminal = - promotedOutcome.error instanceof Error - ? promotedOutcome.error - : new Error(String(promotedOutcome.error)); - callbacks.onError?.(terminal); - throw terminal; - } - lastError = promotedOutcome.error; - } - if (i === chain.length - 1) { - // QNBS-v3: onError is owned by this orchestration layer — fire it exactly once, after the whole chain is exhausted, so no fallback provider still in flight gets a premature terminal error. - const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); - if (tryHeuristicStream()) return; - callbacks.onError?.(terminal); - throw terminal; - } - } - const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); - if (tryHeuristicStream()) return; - callbacks.onError?.(terminal); - throw terminal; + await runProviderFallbackChain( + prompt, + creativity, + mergedOpts, + callbacks, + guardedCallbacks, + signal, + ); }); } From e6a4e661708537c57b5f5f615c76a9ba8093dc98 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:21:49 +0200 Subject: [PATCH 4/5] test(ai): make the OpenRouter-promoted-fallback regression genuinely observable cubic-dev-ai correctly flagged that the promoted-fallback partial-then-fail test passed even without the production guard: with no provider configured after the promoted gemini attempt, i was already the last chain index, so the same tryHeuristicStream/onError+throw path ran whether or not the guard fired -- the test only documented behavior without proving the guard. Add a third chain entry (ollama, left unconfigured) that a bypassed guard would fall through and invoke, and assert it's never called. Verified by temporarily bypassing the guard again: the call now silently resolves with ollama's fallback text appended after gemini's partial response instead of rejecting -- confirming the guard is what prevents that corruption. --- tests/unit/aiProviderService.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/unit/aiProviderService.test.ts b/tests/unit/aiProviderService.test.ts index 47c254296..cc3db1934 100644 --- a/tests/unit/aiProviderService.test.ts +++ b/tests/unit/aiProviderService.test.ts @@ -661,6 +661,8 @@ describe('streamText', () => { }); it('terminates without further fallback when the OpenRouter-promoted fallback attempt itself emits a partial response then fails', async () => { + // QNBS-v3 (cubic): a chain with nothing after the promoted gemini attempt made the guard unobservable, so add a third entry (ollama, left unconfigured) that the fixed code must never reach. + const { streamOllama } = await import('../../services/ollamaService'); vi.mocked(storageService.getApiKey).mockResolvedValueOnce('or-key'); vi.mocked(openrouterProvider.streamOpenRouter).mockRejectedValueOnce( new Error('OPENROUTER_RATE_LIMITED: too many requests'), @@ -675,10 +677,16 @@ describe('streamText', () => { streamText( 'prompt', 'Balanced', - { ...defaultOpts, provider: 'openrouter' }, + { + ...defaultOpts, + provider: 'openrouter', + hybridFallbackEnabled: true, + hybridFallbackChain: ['gemini', 'ollama'], + }, { onChunk, onError }, ), ).rejects.toThrow('gemini stream ended before completion'); + expect(streamOllama).not.toHaveBeenCalled(); expect(onChunk).toHaveBeenCalledTimes(1); expect(onChunk).toHaveBeenCalledWith('partial gemini fallback text'); expect(onError).toHaveBeenCalledTimes(1); From 6e29235f3e3c7dfd713a92be0b718ef6eeeb9e06 Mon Sep 17 00:00:00 2001 From: qnbs <155236708+qnbs@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:00:25 +0200 Subject: [PATCH 5/5] refactor(ai): drop the redundant in-loop terminal block in the fallback walk CodeRabbit correctly noted that the i === chain.length - 1 branch inside runProviderFallbackChain's loop only runs on the loop's final iteration, where the loop would exit naturally into the identical post-loop heuristic/onError/ throw handling anyway. Removing it preserves behavior exactly while cutting one more branch from the hotspot. --- services/aiProviderService.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/services/aiProviderService.ts b/services/aiProviderService.ts index d65ed92f1..c0cb0bf46 100644 --- a/services/aiProviderService.ts +++ b/services/aiProviderService.ts @@ -587,14 +587,8 @@ async function runProviderFallbackChain( } lastError = promotedOutcome.error; } - if (i === chain.length - 1) { - // QNBS-v3: onError is owned by this orchestration layer — fire it exactly once, after the whole chain is exhausted, so no fallback provider still in flight gets a premature terminal error. - const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); - if (tryHeuristicStream(mergedOpts, prompt, guardedCallbacks)) return; - callbacks.onError?.(terminal); - throw terminal; - } } + // QNBS-v3: onError is owned by this orchestration layer — fire it exactly once, after the whole chain is exhausted, so no fallback provider still in flight gets a premature terminal error. const terminal = lastError instanceof Error ? lastError : new Error(String(lastError)); if (tryHeuristicStream(mergedOpts, prompt, guardedCallbacks)) return; callbacks.onError?.(terminal);