Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<img src="https://img.shields.io/badge/TypeScript-tsgo_native_preview-3178C6?logo=typescript&logoColor=white" alt="TypeScript native preview (tsgo)">
<img src="https://img.shields.io/badge/Desktop-Tauri_2-FFC131?logo=tauri&logoColor=black" alt="Tauri 2">
<img src="https://img.shields.io/badge/i18n-19_locales-2942_keys-0EA5E9" alt="19 locales — 2942 keys">
<img src="https://img.shields.io/badge/Tests-7927%2B_%2F_609_files-22C55E" alt="7927+ tests / 609 files">
<img src="https://img.shields.io/badge/Tests-7931%2B_%2F_609_files-22C55E" alt="7931+ tests / 609 files">
<img src="https://img.shields.io/codecov/c/github/qnbs/WorldScript-Studio?logo=codecov&label=Coverage" alt="Codecov Coverage">
<img src="https://img.shields.io/github/actions/workflow/status/qnbs/WorldScript-Studio/.github/workflows/ci.yml?branch=main&logo=github&label=CI" alt="CI status">
<img src="https://img.shields.io/badge/License-MIT-22C55E" alt="MIT License">
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
262 changes: 172 additions & 90 deletions services/aiProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -437,6 +447,154 @@ 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<AttemptOutcome> {
// 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<AttemptOutcome> {
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 };
}
}

// 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<string>(
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<void> {
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;
}
}
// 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;
}

export async function streamText(
prompt: string,
creativity: AiCreativity,
Expand All @@ -455,90 +613,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<string>(
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;
// 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(
prompt,
creativity,
{ ...mergedOpts, provider: nextProvider },
callbacksForAttempt,
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 (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;
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,
);
});
}

Expand Down
Loading
Loading