From 5f36b1218b128401cc58d1748f979d3b6d385260 Mon Sep 17 00:00:00 2001 From: wujunchen Date: Tue, 16 Jun 2026 09:37:59 +0800 Subject: [PATCH 1/2] feat: provider/lifecycle robustness, onboarding nudge, CI coverage gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assessment-driven batch of 10 verified, low-risk improvements. All gates green (typecheck, biome, obsidian lint + strict review, 28 test files, branch coverage 100%, e2e gate). Correctness: - provider-request: decide structured-output fallback on HTTP status via a new ProviderApiError (status+body) instead of pattern-matching the i18n-translated message — previously only en/zh matched, so fr/de/es/ja/ko users hit silent permanent failures when a provider rejected json_schema. - streaming: surface in-stream provider error payloads ({type:'error'} / {error:{}}) by throwing, instead of swallowing them and later misreporting a transient overload/quota error as "non-JSON LLM output". Note: detection runs outside the JSON.parse try/catch so the throw is not swallowed. - main: throwIfCancelled before cacheManager.put so a cancelled job cannot poison the cache. - generation-job-manager: add cancelAll(); onunload now cancels in-flight jobs (aborting streaming HTTP + SIGKILL-ing CLI children), not just queued waiters. Onboarding / UX: - settings: DEFAULT_SETTINGS.promptLanguage 'zh' -> 'auto' so new non-Chinese users get source-language summaries by default (existing users unaffected). - view/main/types: first-run "Set up AI provider" CTA in the empty state when no credential is configured (PluginHost.openSettings + isCredentialConfigured). - error-ui/generation-job-manager/types: new 'network' ErrorKind with an actionable notice + Retry for offline/connection failures. - i18n-strings: 4 new keys across all 7 locales (parity test enforced). CI / docs: - .c8rc.json + package.json: branch-coverage gate (check-coverage, branches=100, degenerate metrics disabled) and preserve c8's exit code in the coverage script. - ci.yml: run coverage gate and strict obsidian review in CI. - README: fix Obsidian version badge 1.4.0 -> 1.8.7 (matches manifest). - e2e product-shell DOM shim: add createSpan (was missing; real Obsidian has it). Tests: cover the status-based fallback (incl. non-English locale), in-stream error throwing, cancelAll, and network classification. Change-Id: Ic619098aa7cdf3dc1c444be4bb8a445550eadf55 --- .c8rc.json | 7 +++- .e2e/cases/product-shell/run.mjs | 4 ++ .github/workflows/ci.yml | 6 +++ README.md | 2 +- README.zh-CN.md | 2 +- main.ts | 40 +++++++++++++++--- package.json | 2 +- src/error-ui.ts | 13 ++++++ src/generation-job-manager.ts | 20 +++++++++ src/i18n-strings.ts | 29 +++++++++++++ src/provider-request.ts | 39 ++++++++++++++++-- src/settings.ts | 5 ++- src/streaming.ts | 38 +++++++++++++++-- src/test-exports.ts | 10 ++++- src/types.ts | 6 ++- src/view.ts | 13 ++++++ tests/direct-prompt.test.js | 6 ++- tests/direct-providers.test.js | 61 ++++++++++++++++++++++++++++ tests/direct-streaming.test.js | 41 +++++++++++++++++++ tests/generation-job-manager.test.js | 39 ++++++++++++++++++ 20 files changed, 360 insertions(+), 23 deletions(-) diff --git a/.c8rc.json b/.c8rc.json index d968976..a5bd6e1 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -12,5 +12,10 @@ "excludeAfterRemap": true, "reporter": ["text-summary", "text", "html", "json-summary"], "reportsDirectory": "coverage", - "tempDirectory": "coverage/.tmp" + "tempDirectory": "coverage/.tmp", + "check-coverage": true, + "branches": 100, + "statements": 0, + "lines": 0, + "functions": 0 } diff --git a/.e2e/cases/product-shell/run.mjs b/.e2e/cases/product-shell/run.mjs index f26138b..23604d6 100644 --- a/.e2e/cases/product-shell/run.mjs +++ b/.e2e/cases/product-shell/run.mjs @@ -98,6 +98,10 @@ class FakeElement { return this.createEl('div', options); } + createSpan(options = {}) { + return this.createEl('span', options); + } + addEventListener(type, handler) { if (typeof handler !== 'function') return; if (!this._listeners.has(type)) this._listeners.set(type, []); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f8b2bc..0980267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,12 @@ jobs: - name: Lint (Obsidian review rules) run: npm run lint:obsidian + - name: Lint (Obsidian strict review) + run: npm run lint:obsidian:review:all + + - name: Coverage (branch threshold) + run: npm run coverage + - name: E2E gate run: timeout 600 npm run e2e diff --git a/README.md b/README.md index 46d1dbb..54bd9b9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Total downloads CI status License - Obsidian 1.4+ + Obsidian 1.8.7+

diff --git a/README.zh-CN.md b/README.zh-CN.md index 1074cec..8e4973e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -9,7 +9,7 @@ 累计下载 CI 状态 开源协议 - Obsidian 1.4+ + Obsidian 1.8.7+

diff --git a/main.ts b/main.ts index bb3092f..7bb0a45 100644 --- a/main.ts +++ b/main.ts @@ -30,7 +30,14 @@ import { translate } from './src/i18n'; import { cardsToMarkdown } from './src/markdown'; import { confirmRegenerateEditedCards } from './src/modal'; import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll'; -import { cacheEntryMatches, DEFAULT_SETTINGS, normalizeSettings } from './src/settings'; +import { + cacheEntryMatches, + DEFAULT_SETTINGS, + getApiAuthType, + getApiKey, + isApiBackend, + normalizeSettings, +} from './src/settings'; import { ParallelReaderSettingTab } from './src/settings-tab'; import type { StreamProgress } from './src/streaming'; import type { @@ -177,7 +184,7 @@ class ParallelReaderPlugin extends Plugin { } onunload() { - if (this.jobs) this.jobs.cancelAllWaiters(); + if (this.jobs) this.jobs.cancelAll(); this.flushSettingsSave().catch((e: unknown) => console.error('[parallel-reader] flush settings on unload', e)); this.flushCacheSave().catch((e: unknown) => console.error('[parallel-reader] flush cache on unload', e)); } @@ -555,6 +562,9 @@ class ParallelReaderPlugin extends Plugin { } const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets })); job.setPhase('saving'); + // Check BEFORE persisting: a job cancelled during the generate→disk window must not + // poison the cache (a later force=false run would otherwise serve the cancelled output). + job.throwIfCancelled(); await this.cacheManager.put(file.path, content, rawCards, this.settings); job.throwIfCancelled(); if (view && shouldRender) view.loadFor(file, sections, false); @@ -568,7 +578,7 @@ class ParallelReaderPlugin extends Plugin { outcome = 'generated'; }) .catch((e: unknown) => { - this.handleGenerationError(e, file, view); + this.handleGenerationError(e, file, view, force); if (e instanceof GenerationJobAlreadyRunningError) outcome = 'already-running'; else if (e instanceof GenerationJobCancelledError) outcome = 'cancelled'; else outcome = 'error'; @@ -590,7 +600,7 @@ class ParallelReaderPlugin extends Plugin { }; } - private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null) { + private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null, force = false) { if (e instanceof GenerationJobAlreadyRunningError) { new Notice(this.t('generationAlreadyRunning')); return; @@ -608,7 +618,8 @@ class ParallelReaderPlugin extends Plugin { { app: this.app, settings: this.settings, - openSettings: () => this.openPluginSettings(), + openSettings: () => this.openSettings(), + onRetry: () => void this.runForFile(file, force), }, kind, e, @@ -616,7 +627,7 @@ class ParallelReaderPlugin extends Plugin { ); } - private openPluginSettings(): void { + openSettings(): void { // Obsidian doesn't expose a typed API for opening a specific plugin tab; use the documented // (but technically internal) setting/openTabById path with safe fallbacks. const setting = (this.app as unknown as { setting?: { open: () => void; openTabById: (id: string) => void } }) @@ -630,6 +641,23 @@ class ParallelReaderPlugin extends Plugin { } } + /** + * True when the active backend has a usable credential: an API key (direct or via + * env var) for API backends, or any keyless/local provider (auth type 'none'). + * CLI backends authenticate out-of-band (claude/codex login), so they are treated + * as configured. Used to show a first-run setup nudge instead of a dead-end. + */ + isCredentialConfigured(): boolean { + if (!isApiBackend(this.settings.backend)) return true; + try { + if (getApiAuthType(this.settings) === 'none') return true; + return !!getApiKey(this.settings); + } catch { + // Settings resolution can throw for half-configured custom providers — treat as not configured. + return false; + } + } + async runBatchForFolder() { if (this.activeBatch) { new Notice(this.t('batchAlreadyRunning')); diff --git a/package.json b/package.json index 032107f..d046c2d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "node esbuild.config.mjs production", "dev": "node esbuild.config.mjs watch", "typecheck": "tsc --noEmit", - "coverage": "c8 node scripts/run-tests.mjs --all; rm -rf .test-bundles", + "coverage": "c8 node scripts/run-tests.mjs --all; status=$?; rm -rf .test-bundles; exit $status", "lint": "biome check main.ts src/ tests/ scripts/ .e2e/scripts/", "lint:fix": "biome check --write main.ts src/ tests/ scripts/ .e2e/scripts/", "lint:obsidian": "eslint main.ts src/", diff --git a/src/error-ui.ts b/src/error-ui.ts index c61074c..48f439a 100644 --- a/src/error-ui.ts +++ b/src/error-ui.ts @@ -11,6 +11,8 @@ interface GenerationErrorContext { settings: PluginSettings; /** Open the plugin settings tab (best-effort; falls back to no-op if unavailable). */ openSettings: () => void; + /** Re-run the generation that failed (used by retryable error kinds such as network). */ + onRetry?: () => void; } interface ActionableNoticeAction { @@ -205,6 +207,17 @@ export function showGenerationError( return; } + if (kind === 'network') { + const actions: ActionableNoticeAction[] = []; + if (ctx.onRetry) actions.push({ label: tr('errorActionRetry'), primary: true, onClick: ctx.onRetry }); + actions.push({ + label: tr('errorActionCopyDetails'), + onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')), + }); + showActionableNotice(tr('errorNoticeNetwork'), actions); + return; + } + if (kind === 'schema') { showActionableNotice(tr('errorNoticeSchema'), [ { diff --git a/src/generation-job-manager.ts b/src/generation-job-manager.ts index 0b272e0..54bc169 100644 --- a/src/generation-job-manager.ts +++ b/src/generation-job-manager.ts @@ -189,6 +189,20 @@ export class GenerationJobManager { if (!job) return false; return job.cancel(); } + + /** + * Cancel every in-flight job — firing each job's cancel handlers, which abort the + * streaming HTTP request and SIGKILL any CLI child process — and reject all queued + * waiters. Used on plugin unload so nothing keeps running after teardown. Returns + * the total number of jobs and waiters cancelled. + */ + cancelAll(): number { + let cancelled = 0; + for (const job of this.jobs.values()) { + if (job.cancel()) cancelled++; + } + return cancelled + this.cancelAllWaiters(); + } } export function classifyGenerationError(error: unknown): ErrorKind { @@ -216,6 +230,12 @@ export function classifyGenerationError(error: unknown): ErrorKind { if (/api key|unauthorized|401|403|认证|权限/i.test(message)) return 'auth'; if (/timeout|超时|timed out/i.test(message)) return 'timeout'; if (/429|rate limit|too many requests/i.test(message)) return 'rate-limit'; + if ( + /ECONNREFUSED|ENOTFOUND|ENETUNREACH|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|Failed to fetch|NetworkError|net::ERR_|fetch failed/i.test( + message, + ) + ) + return 'network'; if ( /非 JSON|非预期输出|没有返回结果|non-JSON|unexpected output|no result|json_schema|schema|structured/i.test(message) ) diff --git a/src/i18n-strings.ts b/src/i18n-strings.ts index d415c43..fd7ca09 100644 --- a/src/i18n-strings.ts +++ b/src/i18n-strings.ts @@ -6,6 +6,7 @@ export const STRINGS: Record> = { settingsTitle: 'Parallel Reader 设置', emptyOpenNote: '打开一篇笔记,然后运行命令:', emptyNoCache: '该笔记尚无对照笔记缓存。运行命令:', + emptyNeedsSetup: '尚未配置 AI 服务商。填写 API Key 后即可开始生成。', commandGenerate: 'Parallel Reader: 为当前笔记生成对照笔记', displayName: '对照阅读笔记', ribbonOpen: '打开对照笔记面板', @@ -60,6 +61,7 @@ export const STRINGS: Record> = { cancelRequested: '已请求取消生成', cancelRequestedApiInFlight: '已请求取消生成;当前 API 请求无法立即中断,返回后会丢弃结果。', actionGenerate: '生成对照笔记', + actionSetupProvider: '配置 AI 服务商', fileMenuGenerate: '生成对照笔记', fileMenuRegen: '强制重新生成对照笔记', fileMenuClear: '清除对照笔记缓存', @@ -89,7 +91,9 @@ export const STRINGS: Record> = { errorNoticeRateLimit: '触发限流:{error}\n请稍候片刻再重试。', errorNoticeSchema: '模型返回的内容不符合 JSON 格式。可复制原始输出排查或重试。', errorNoticeConfig: '配置错误:{error}', + errorNoticeNetwork: '网络错误——请检查网络连接后重试。', errorActionOpenSettings: '打开设置', + errorActionRetry: '重试', errorActionCopyDetails: '复制详情', errorActionCopyRaw: '复制原始输出', errorModalTimeoutTitle: 'CLI 超时诊断', @@ -219,6 +223,7 @@ export const STRINGS: Record> = { settingsTitle: 'Parallel Reader Settings', emptyOpenNote: 'Open a note, then run:', emptyNoCache: 'This note has no cached parallel notes. Run:', + emptyNeedsSetup: 'No AI provider is configured yet. Add an API key to start generating.', commandGenerate: 'Parallel Reader: Generate notes for current note', displayName: 'Parallel Reader', ribbonOpen: 'Open Parallel Reader pane', @@ -274,6 +279,7 @@ export const STRINGS: Record> = { cancelRequestedApiInFlight: 'Cancel requested. The in-flight API request cannot be aborted immediately; its result will be ignored.', actionGenerate: 'Generate parallel notes', + actionSetupProvider: 'Set up AI provider', fileMenuGenerate: 'Generate parallel notes', fileMenuRegen: 'Regenerate parallel notes', fileMenuClear: 'Clear parallel-note cache', @@ -304,7 +310,9 @@ export const STRINGS: Record> = { errorNoticeRateLimit: 'Rate-limited: {error}\nWait a moment and retry.', errorNoticeSchema: 'Model returned non-JSON output. Copy the raw output to inspect or retry.', errorNoticeConfig: 'Config error: {error}', + errorNoticeNetwork: 'Network error — check your connection and try again.', errorActionOpenSettings: 'Open Settings', + errorActionRetry: 'Retry', errorActionCopyDetails: 'Copy details', errorActionCopyRaw: 'Copy raw output', errorModalTimeoutTitle: 'CLI timeout diagnostics', @@ -455,6 +463,7 @@ defineLocale('ja', { settingsTitle: 'Parallel Reader 設定', emptyOpenNote: 'ノートを開いてから実行:', emptyNoCache: 'このノートにはキャッシュ済みの並列ノートがありません。実行:', + emptyNeedsSetup: 'AI プロバイダーがまだ設定されていません。API キーを追加して生成を開始してください。', commandGenerate: 'Parallel Reader: 現在のノートの並列ノートを生成', displayName: 'Parallel Reader', ribbonOpen: 'Parallel Reader ペインを開く', @@ -504,6 +513,7 @@ defineLocale('ja', { noCancelableJob: 'キャンセル可能な生成ジョブはありません', cancelRequested: 'キャンセルを要求しました', actionGenerate: '並列ノートを生成', + actionSetupProvider: 'AI プロバイダーを設定', fileMenuGenerate: '並列ノートを生成', fileMenuRegen: '並列ノートを強制再生成', fileMenuClear: '並列ノートのキャッシュを削除', @@ -535,7 +545,9 @@ defineLocale('ja', { errorNoticeRateLimit: 'レート制限: {error}\n少し待ってから再試行してください。', errorNoticeSchema: 'モデルが JSON 以外の出力を返しました。生出力をコピーして確認するか再試行してください。', errorNoticeConfig: '設定エラー: {error}', + errorNoticeNetwork: 'ネットワークエラー — 接続を確認して再試行してください。', errorActionOpenSettings: '設定を開く', + errorActionRetry: '再試行', errorActionCopyDetails: '詳細をコピー', errorActionCopyRaw: '生出力をコピー', noCardsReturned: 'LLM はカードを返しませんでした', @@ -682,6 +694,7 @@ defineLocale('ko', { settingsTitle: 'Parallel Reader 설정', emptyOpenNote: '노트를 연 다음 실행:', emptyNoCache: '이 노트에는 캐시된 병렬 노트가 없습니다. 실행:', + emptyNeedsSetup: 'AI 제공업체가 아직 설정되지 않았습니다. API 키를 추가하여 생성을 시작하세요.', commandGenerate: 'Parallel Reader: 현재 노트의 병렬 노트 생성', displayName: 'Parallel Reader', ribbonOpen: 'Parallel Reader 패널 열기', @@ -731,6 +744,7 @@ defineLocale('ko', { noCancelableJob: '취소할 수 있는 생성 작업이 없습니다', cancelRequested: '취소를 요청했습니다', actionGenerate: '병렬 노트 생성', + actionSetupProvider: 'AI 제공업체 설정', fileMenuGenerate: '병렬 노트 생성', fileMenuRegen: '병렬 노트 강제 재생성', fileMenuClear: '병렬 노트 캐시 지우기', @@ -761,7 +775,9 @@ defineLocale('ko', { errorNoticeRateLimit: '요청 제한: {error}\n잠시 후 다시 시도하세요.', errorNoticeSchema: '모델이 JSON이 아닌 출력을 반환했습니다. 원본 출력을 복사해 확인하거나 다시 시도하세요.', errorNoticeConfig: '설정 오류: {error}', + errorNoticeNetwork: '네트워크 오류 — 연결을 확인하고 다시 시도하세요.', errorActionOpenSettings: '설정 열기', + errorActionRetry: '다시 시도', errorActionCopyDetails: '세부 정보 복사', errorActionCopyRaw: '원본 출력 복사', noCardsReturned: 'LLM이 카드를 반환하지 않았습니다', @@ -907,6 +923,7 @@ defineLocale('fr', { settingsTitle: 'Paramètres de Parallel Reader', emptyOpenNote: 'Ouvrez une note, puis lancez :', emptyNoCache: 'Cette note n’a pas de notes parallèles en cache. Lancez :', + emptyNeedsSetup: 'Aucun fournisseur IA n’est encore configuré. Ajoutez une clé API pour commencer à générer.', commandGenerate: 'Parallel Reader : générer les notes de la note actuelle', displayName: 'Parallel Reader', ribbonOpen: 'Ouvrir le panneau Parallel Reader', @@ -956,6 +973,7 @@ defineLocale('fr', { noCancelableJob: 'Aucune génération annulable', cancelRequested: 'Annulation demandée', actionGenerate: 'Générer les notes parallèles', + actionSetupProvider: 'Configurer le fournisseur IA', fileMenuGenerate: 'Générer les notes parallèles', fileMenuRegen: 'Forcer la regénération des notes parallèles', fileMenuClear: 'Effacer le cache des notes parallèles', @@ -986,7 +1004,9 @@ defineLocale('fr', { errorNoticeRateLimit: 'Limite de débit atteinte : {error}\nPatientez puis réessayez.', errorNoticeSchema: 'Le modèle a renvoyé une sortie non JSON. Copiez la sortie brute pour l’inspecter ou réessayez.', errorNoticeConfig: 'Erreur de configuration : {error}', + errorNoticeNetwork: 'Erreur réseau — vérifiez votre connexion et réessayez.', errorActionOpenSettings: 'Ouvrir les paramètres', + errorActionRetry: 'Réessayer', errorActionCopyDetails: 'Copier les détails', errorActionCopyRaw: 'Copier la sortie brute', noCardsReturned: 'Le LLM n’a renvoyé aucune carte', @@ -1141,6 +1161,8 @@ defineLocale('de', { settingsTitle: 'Parallel Reader Einstellungen', emptyOpenNote: 'Öffnen Sie eine Notiz und führen Sie dann aus:', emptyNoCache: 'Diese Notiz hat keine zwischengespeicherten Parallelnotizen. Ausführen:', + emptyNeedsSetup: + 'Es ist noch kein KI-Anbieter konfiguriert. Fügen Sie einen API-Schlüssel hinzu, um mit der Erzeugung zu beginnen.', commandGenerate: 'Parallel Reader: Notizen für aktuelle Notiz erzeugen', displayName: 'Parallel Reader', ribbonOpen: 'Parallel Reader Bereich öffnen', @@ -1190,6 +1212,7 @@ defineLocale('de', { noCancelableJob: 'Keine abbrechbare Erzeugung', cancelRequested: 'Abbruch angefordert', actionGenerate: 'Parallelnotizen erzeugen', + actionSetupProvider: 'KI-Anbieter einrichten', fileMenuGenerate: 'Parallelnotizen erzeugen', fileMenuRegen: 'Parallelnotizen erzwungen neu erzeugen', fileMenuClear: 'Parallelnotizen-Cache löschen', @@ -1222,7 +1245,9 @@ defineLocale('de', { errorNoticeSchema: 'Das Modell hat keine JSON-Ausgabe zurückgegeben. Rohdaten kopieren und prüfen oder erneut versuchen.', errorNoticeConfig: 'Konfigurationsfehler: {error}', + errorNoticeNetwork: 'Netzwerkfehler — Verbindung prüfen und erneut versuchen.', errorActionOpenSettings: 'Einstellungen öffnen', + errorActionRetry: 'Erneut versuchen', errorActionCopyDetails: 'Details kopieren', errorActionCopyRaw: 'Rohdaten kopieren', noCardsReturned: 'LLM hat keine Karten zurückgegeben', @@ -1377,6 +1402,7 @@ defineLocale('es', { settingsTitle: 'Configuración de Parallel Reader', emptyOpenNote: 'Abre una nota y luego ejecuta:', emptyNoCache: 'Esta nota no tiene notas paralelas en caché. Ejecuta:', + emptyNeedsSetup: 'Todavía no hay ningún proveedor de IA configurado. Añade una clave API para empezar a generar.', commandGenerate: 'Parallel Reader: generar notas para la nota actual', displayName: 'Parallel Reader', ribbonOpen: 'Abrir panel de Parallel Reader', @@ -1426,6 +1452,7 @@ defineLocale('es', { noCancelableJob: 'No hay generación cancelable', cancelRequested: 'Cancelación solicitada', actionGenerate: 'Generar notas paralelas', + actionSetupProvider: 'Configurar proveedor de IA', fileMenuGenerate: 'Generar notas paralelas', fileMenuRegen: 'Forzar regeneración de notas paralelas', fileMenuClear: 'Borrar caché de notas paralelas', @@ -1457,7 +1484,9 @@ defineLocale('es', { errorNoticeSchema: 'El modelo devolvió una salida que no es JSON. Copia la salida bruta para inspeccionarla o vuelve a intentar.', errorNoticeConfig: 'Error de configuración: {error}', + errorNoticeNetwork: 'Error de red — comprueba tu conexión y vuelve a intentarlo.', errorActionOpenSettings: 'Abrir configuración', + errorActionRetry: 'Reintentar', errorActionCopyDetails: 'Copiar detalles', errorActionCopyRaw: 'Copiar salida bruta', noCardsReturned: 'El LLM no devolvió tarjetas', diff --git a/src/provider-request.ts b/src/provider-request.ts index 89b37f8..d606e14 100644 --- a/src/provider-request.ts +++ b/src/provider-request.ts @@ -11,6 +11,22 @@ export type RequestUrlFunction = (params: { throw?: boolean; }) => Promise<{ status: number; json: unknown; text: string }>; +/** + * Thrown when a provider responds with HTTP >= 400. Carries the raw status code + * and response body so callers can make locale-independent decisions (e.g. the + * structured-output fallback) instead of pattern-matching a translated message. + */ +export class ProviderApiError extends Error { + readonly status: number; + readonly body: string; + constructor(message: string, status: number, body: string) { + super(message); + this.name = 'ProviderApiError'; + this.status = status; + this.body = body; + } +} + export function endpointUrl(baseUrl: string, suffixes: string[]) { const base = baseUrl.replace(/\/+$/, ''); for (const suffix of suffixes) { @@ -65,24 +81,39 @@ export async function requestJsonBody( } if (resp.status >= 400) { - throw new Error( + throw new ProviderApiError( translate(settings || null, 'errorProviderApiStatus', { label, status: resp.status, excerpt: (resp.text || '').slice(0, 500), }), + resp.status, + resp.text || '', ); } return responseJson(resp, label, settings); } +const STRUCTURED_OUTPUT_REJECTION_KEYWORDS = + /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i; +const STRUCTURED_OUTPUT_FALLBACK_STATUSES = new Set([400, 404, 422]); + export function shouldRetryWithoutStructuredOutput(error: unknown): boolean { + // Preferred path: decide on the locale-independent HTTP status + raw provider body. + // The error message is i18n-translated, so matching it would only work for the two + // languages whose templates happen to contain the English/Chinese status phrasing. + if (error instanceof ProviderApiError) { + if (!STRUCTURED_OUTPUT_FALLBACK_STATUSES.has(error.status)) return false; + return ( + STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.body) || STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.message) + ); + } + // Fallback for errors without a status (e.g. wrapped transport failures): keep the + // legacy English/Chinese template match so existing behavior is preserved. const message = error instanceof Error ? error.message : String(error); if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message)) return false; - return /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test( - message, - ); + return STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(message); } export async function requestJsonBodyWithStructuredFallback( diff --git a/src/settings.ts b/src/settings.ts index 0b17a48..2829b79 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -49,7 +49,10 @@ export const DEFAULT_SETTINGS: PluginSettings = { apiMaxTokens: 4096, maxDocChars: MAX_DOC_CHARS, maxCacheEntries: DEFAULT_MAX_CACHE_ENTRIES, - promptLanguage: 'zh', + // 'auto' = match the source document's main language, so a new user reading an + // English (or any non-Chinese) note gets summaries in that language by default + // instead of forced Chinese. Existing users keep whatever they have persisted. + promptLanguage: 'auto', minCards: 5, maxCards: 15, customSystemPrompt: '', diff --git a/src/streaming.ts b/src/streaming.ts index b07db1e..883a47a 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -26,6 +26,31 @@ function anthropicDelta(json: Record): string { return ''; } +/** + * Detect a provider error delivered as an SSE payload inside an HTTP 200 stream. + * These bypass the `response.status >= 400` guard, so without this they would be + * extracted as empty deltas and later misreported as "non-JSON LLM output". + * Anthropic: { type: 'error', error: { type, message } } + * OpenAI-compatible: { error: { message, type, code } } + * Returns a human-readable error message for an error payload, or null otherwise. + */ +export function streamErrorMessage(json: Record): string | null { + const messageFromError = (value: unknown): string | null => { + if (!value || typeof value !== 'object') return null; + const err = value as { message?: unknown; type?: unknown }; + if (typeof err.message === 'string' && err.message) return err.message; + if (typeof err.type === 'string' && err.type) return err.type; + return null; + }; + if (json.type === 'error') { + return messageFromError(json.error) ?? 'Provider returned a streaming error'; + } + if (json.error) { + return messageFromError(json.error) ?? 'Provider returned a streaming error'; + } + return null; +} + export type DeltaExtractor = (json: Record) => string; export function deltaExtractorForFormat(format: string): DeltaExtractor | null { @@ -61,13 +86,18 @@ export function parseSseBuffer(buffer: string, extractDelta: DeltaExtractor): { const data = dataLines.join('\n'); if (data.trim() === '[DONE]') continue; + let json: Record; try { - const json = JSON.parse(data) as Record; - const delta = extractDelta(json); - if (delta) deltas.push(delta); + json = JSON.parse(data) as Record; } catch { - // skip non-JSON SSE lines + continue; // skip non-JSON SSE lines (keep-alives, partial frames) } + // Provider errors arrive as a 200-status SSE payload — surface them instead of + // swallowing them, so a transient overload/quota error is not misreported downstream. + const errorMessage = streamErrorMessage(json); + if (errorMessage) throw new Error(errorMessage); + const delta = extractDelta(json); + if (delta) deltas.push(delta); } return { deltas, rest }; } diff --git a/src/test-exports.ts b/src/test-exports.ts index 32a23a6..541276d 100644 --- a/src/test-exports.ts +++ b/src/test-exports.ts @@ -33,6 +33,14 @@ export { translate } from './i18n'; export { cardsToMarkdown } from './markdown'; export { activeSectionLine, nextCardIndex } from './navigation'; export { buildPrompts } from './prompt'; +export { + endpointUrl, + ProviderApiError, + requestJsonBody, + requestJsonBodyWithStructuredFallback, + responseJson, + shouldRetryWithoutStructuredOutput, +} from './provider-request'; export { buildAnthropicMessagesBody, buildGeminiBody, @@ -58,7 +66,7 @@ export { normalizeStreamingTimeoutMs, pruneCacheEntries, } from './settings'; -export { deltaExtractorForFormat, parseSseBuffer } from './streaming'; +export { deltaExtractorForFormat, parseSseBuffer, streamErrorMessage } from './streaming'; export { addIconButton, addTextButton, copyToClipboard } from './ui-helpers'; export { folderPathsForTarget } from './vault'; export { ParallelReaderView } from './view'; diff --git a/src/types.ts b/src/types.ts index 929a8a3..f151e30 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,7 +96,7 @@ export type GenerationPhase = | 'done' | 'cancelled'; -export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'schema' | 'config' | 'cancelled' | 'unknown'; +export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'network' | 'schema' | 'config' | 'cancelled' | 'unknown'; export interface RunForFileOptions { rethrowErrors?: boolean; @@ -159,6 +159,10 @@ export interface PluginHost { cache: Record; manifest: PluginManifest; t(key: string, vars?: Record): string; + /** Open the plugin's settings tab (best-effort; no-op if the API is unavailable). */ + openSettings(): void; + /** True if a usable credential is configured for the current backend (API key, env var, or keyless local provider). */ + isCredentialConfigured(): boolean; isGeneratingFile(file: TFile | null): boolean; cancelGenerationForFile(file: TFile | null): boolean; runForFile( diff --git a/src/view.ts b/src/view.ts index 4f9449d..cfa98f6 100644 --- a/src/view.ts +++ b/src/view.ts @@ -76,6 +76,18 @@ export class ParallelReaderView extends ItemView { hint.createEl('h3', { text: this.plugin.t('appTitle') }); hint.createEl('p', { text: this.plugin.t('emptyOpenNote') }); hint.createEl('code', { text: this.plugin.t('commandGenerate') }); + this.appendSetupNudge(hint); + } + + /** + * When no credential is configured, append a "set up your AI provider" call-to-action + * so a first-run user does not hit a dead-end (Generate → immediate API-key error). + */ + private appendSetupNudge(parent: HTMLElement): boolean { + if (this.plugin.isCredentialConfigured()) return false; + parent.createEl('p', { cls: 'parallel-reader-setup-hint', text: this.plugin.t('emptyNeedsSetup') }); + addTextButton(parent, 'settings', this.plugin.t('actionSetupProvider'), () => this.plugin.openSettings()); + return true; } focusSummaryPane() { @@ -157,6 +169,7 @@ export class ParallelReaderView extends ItemView { hint.createEl('h3', { text: file.basename }); hint.createEl('p', { text: this.plugin.t('emptyNoCache') }); hint.createEl('code', { text: this.plugin.t('commandGenerate') }); + this.appendSetupNudge(hint); addTextButton(hint, null, this.plugin.t('actionGenerate'), () => { if (this.plugin.isGeneratingFile(file)) return; void this.plugin.runForFile(file, false); diff --git a/tests/direct-prompt.test.js b/tests/direct-prompt.test.js index 72df5e2..a053742 100644 --- a/tests/direct-prompt.test.js +++ b/tests/direct-prompt.test.js @@ -104,9 +104,11 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') const wsCustom = prompt.buildPrompts('doc', { ...base, customSystemPrompt: ' ' }); assert.ok(wsCustom.system.includes('long-form reading'), 'default prompt used when custom is whitespace-only'); - // ── buildPrompts: invalid promptLanguage falls back ── + // ── buildPrompts: invalid promptLanguage falls back to the default ('auto') ── const badLang = prompt.buildPrompts('doc', { ...base, promptLanguage: 'xyz' }); - assert.ok(badLang.system.includes('中文'), 'invalid promptLanguage falls back to zh default'); + const autoFallback = prompt.buildPrompts('doc', { ...base, promptLanguage: 'auto' }); + assert.strictEqual(badLang.system, autoFallback.system, 'invalid promptLanguage falls back to the default (auto)'); + assert.ok(badLang.system.includes('main language'), 'fallback uses the auto language instruction'); // ── buildPrompts: card count normalization ── // 0 is falsy so || picks DEFAULT (5), then Math.max(1, 5) = 5 diff --git a/tests/direct-providers.test.js b/tests/direct-providers.test.js index 1d9adb8..e36aa00 100644 --- a/tests/direct-providers.test.js +++ b/tests/direct-providers.test.js @@ -4,6 +4,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') try { const generation = await requireBundledModule('src/generation.ts'); const providerParsers = await requireBundledModule('src/provider-parsers.ts'); + const providerRequest = await requireBundledModule('src/provider-request.ts'); // ── generation.ts ── assert.strictEqual( @@ -88,6 +89,66 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') ); assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({}), null); + // ── provider-request: structured-output fallback keyed on HTTP status, not localized text ── + const { ProviderApiError, shouldRetryWithoutStructuredOutput, requestJsonBodyWithStructuredFallback } = + providerRequest; + + // 400 + body keyword ⇒ retry, regardless of the (i18n-translated) message language. + assert.strictEqual( + shouldRetryWithoutStructuredOutput( + new ProviderApiError('英語以外のエラーメッセージ', 400, 'response_format is not supported'), + ), + true, + 'status-400 + body keyword ⇒ retry even when message is non-English/Chinese', + ); + assert.strictEqual( + shouldRetryWithoutStructuredOutput( + new ProviderApiError("L'API a renvoyé une erreur", 422, 'json_schema unsupported'), + ), + true, + 'status-422 + body keyword ⇒ retry for a French-localized message', + ); + // 5xx is a server error, never a structured-output problem ⇒ do not retry. + assert.strictEqual( + shouldRetryWithoutStructuredOutput(new ProviderApiError('server error', 500, 'response_format')), + false, + ); + // 400 without any schema-related keyword ⇒ do not retry. + assert.strictEqual( + shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'missing required field: model')), + false, + ); + // Legacy path: a plain Error carrying the English template still works (back-compat). + assert.strictEqual( + shouldRetryWithoutStructuredOutput(new Error('OpenAI API returned HTTP 400: response_format not supported')), + true, + ); + assert.strictEqual(shouldRetryWithoutStructuredOutput(new Error('some unrelated error')), false); + + // End-to-end: a 400 returned alongside a NON-English locale still triggers the fallback body. + { + const calls = []; + const fakeRequestUrl = async ({ body }) => { + calls.push(JSON.parse(body)); + if (calls.length === 1) { + return { status: 400, json: null, text: 'response_format is not supported by this model' }; + } + return { status: 200, json: { ok: true }, text: '' }; + }; + const result = await requestJsonBodyWithStructuredFallback( + fakeRequestUrl, + 'OpenAI-compatible Chat', + 'https://example.test', + {}, + { model: 'x', response_format: { type: 'json_schema' } }, + { model: 'x' }, + { uiLanguage: 'fr' }, // localized error message must NOT block the fallback + ); + assert.deepStrictEqual(result, { ok: true }); + assert.strictEqual(calls.length, 2, 'should retry exactly once without structured output'); + assert.ok(!('response_format' in calls[1]), 'fallback body omits response_format'); + } + console.log('direct providers tests passed'); } finally { cleanup(); diff --git a/tests/direct-streaming.test.js b/tests/direct-streaming.test.js index fb3c2c5..51f8632 100644 --- a/tests/direct-streaming.test.js +++ b/tests/direct-streaming.test.js @@ -74,6 +74,47 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') const empty = streaming.parseSseBuffer('', openAiExtractor); assert.deepStrictEqual(empty.deltas, []); + // ── streamErrorMessage: provider errors delivered as a 200-status SSE payload ── + assert.strictEqual( + streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }), + 'Overloaded', + ); + assert.strictEqual( + streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error' } }), + 'overloaded_error', + ); + assert.strictEqual(streaming.streamErrorMessage({ type: 'error' }), 'Provider returned a streaming error'); + assert.strictEqual(streaming.streamErrorMessage({ error: { message: 'Quota exceeded' } }), 'Quota exceeded'); + assert.strictEqual( + streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), + 'Provider returned a streaming error', + ); + assert.strictEqual(streaming.streamErrorMessage({ choices: [{ delta: { content: 'hi' } }] }), null); + assert.strictEqual(streaming.streamErrorMessage({ type: 'content_block_delta', delta: { text: 'x' } }), null); + assert.strictEqual(streaming.streamErrorMessage({ error: null }), null); + + // ── parseSseBuffer: surface in-stream provider errors instead of swallowing them ── + assert.throws( + () => + streaming.parseSseBuffer( + 'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n', + anthropicExtractor, + ), + /Overloaded/, + 'anthropic in-stream error must throw, not be swallowed as a non-JSON line', + ); + assert.throws( + () => streaming.parseSseBuffer('data: {"error":{"message":"Rate limit reached"}}\n\n', openAiExtractor), + /Rate limit reached/, + 'openai-compatible in-stream error must throw', + ); + // Normal deltas through the same parse path still work. + const okAfterError = streaming.parseSseBuffer( + 'data: {"choices":[{"delta":{"content":"fine"}}]}\n\n', + openAiExtractor, + ); + assert.deepStrictEqual(okAfterError.deltas, ['fine']); + // ── streamingRequestUrl ── function trackedSignal() { const controller = new AbortController(); diff --git a/tests/generation-job-manager.test.js b/tests/generation-job-manager.test.js index e84ae60..9267685 100644 --- a/tests/generation-job-manager.test.js +++ b/tests/generation-job-manager.test.js @@ -166,6 +166,38 @@ async function testCancelAllWaiters() { assert.strictEqual(manager.waitingCount(), 0); } +async function testCancelAll() { + const manager = new GenerationJobManager(1); + let release; + const blocker = new Promise((r) => { + release = r; + }); + let runningCancelHook = false; + + const running = manager.start('run.md', async (job) => { + job.onCancel(() => { + runningCancelHook = true; + }); + await blocker; + job.throwIfCancelled(); + return 'run'; + }); + const queued = manager.start('q.md', async () => 'q'); + + await new Promise((r) => setTimeout(r, 5)); + assert.strictEqual(manager.isRunning('run.md'), true, 'one running'); + assert.strictEqual(manager.waitingCount(), 1, 'one queued'); + + const cancelled = manager.cancelAll(); + assert.strictEqual(cancelled, 2, 'cancelAll cancels the running job and drains the queued waiter'); + assert.strictEqual(runningCancelHook, true, 'running job cancel handler fired (aborts in-flight HTTP/CLI)'); + + release(); + await assert.rejects(running, GenerationJobCancelledError, 'cancelled running job rejects'); + await assert.rejects(queued, GenerationJobCancelledError, 'queued job rejects'); + assert.strictEqual(manager.waitingCount(), 0); +} + async function testCancelSignalsRunnerAndCleansUp() { const manager = new GenerationJobManager(); let cancelHookCalled = false; @@ -190,6 +222,12 @@ function testErrorClassification() { assert.strictEqual(classifyGenerationError(new Error('API key 未设置')), 'auth'); assert.strictEqual(classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout'); assert.strictEqual(classifyGenerationError(new Error('OpenAI API 429: rate limit')), 'rate-limit'); + assert.strictEqual( + classifyGenerationError(new Error('request to https://api failed, reason: ECONNREFUSED')), + 'network', + ); + assert.strictEqual(classifyGenerationError(new Error('Failed to fetch')), 'network'); + assert.strictEqual(classifyGenerationError(new Error('getaddrinfo ENOTFOUND api.openai.com')), 'network'); assert.strictEqual(classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema'); assert.strictEqual(classifyGenerationError(new Error('Model 未设置')), 'config'); assert.strictEqual(classifyGenerationError(new Error('something else')), 'unknown'); @@ -200,6 +238,7 @@ function testErrorClassification() { await testGlobalConcurrencyLimit(); await testNoOverbookingDuringRelease(); await testCancelAllWaiters(); + await testCancelAll(); await testCancelSignalsRunnerAndCleansUp(); testErrorClassification(); console.log('generation job manager tests passed'); From 7986f1c30844cb2276e46d8efd2988d79bc6ac95 Mon Sep 17 00:00:00 2001 From: wujunchen Date: Tue, 16 Jun 2026 10:05:04 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20codex=20review=20=E2=80=94?= =?UTF-8?q?=20drop=20false-positive=20fallback=20keywords,=20tighten=20str?= =?UTF-8?q?eam-error=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two valid findings from an opposite-model (Codex) review of this branch: - provider-request: drop bare `unknown`/`unrecognized` from the structured-output rejection keywords. They false-positive on model-name errors ("unknown model", "unrecognized model ID" from Ollama/Bedrock), triggering a wasted fallback retry. The specific feature tokens + bare `schema` still cover real structured- output rejections, so true-positive detection is unaffected. - streaming: streamErrorMessage's OpenAI-style `{error:{...}}` branch now only treats the payload as an error when it carries a message/type, so a stray empty `error: {}` (or code-only object) in a normal chunk no longer aborts the stream. The Anthropic `{type:'error'}` branch still always signals an error. Declined: Codex's suggestion to convert streaming's HTTP>=400 to ProviderApiError + add fallback — the streaming path always sends `structured:false`, so there is no structured request to fall back from. Tests updated/added: model-name 400s do not retry; empty/code-only error objects do not throw. Change-Id: I413d633c8ce7036365f97e2d8e1287c111e0902f --- src/provider-request.ts | 6 +++++- src/streaming.ts | 9 +++++---- tests/direct-providers.test.js | 10 ++++++++++ tests/direct-streaming.test.js | 8 ++++---- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/provider-request.ts b/src/provider-request.ts index d606e14..50fc1bd 100644 --- a/src/provider-request.ts +++ b/src/provider-request.ts @@ -94,8 +94,12 @@ export async function requestJsonBody( return responseJson(resp, label, settings); } +// Bare `unknown`/`unrecognized` were intentionally dropped: they false-positive on +// model-name errors ("unknown model", "unrecognized model ID") and trigger a wasted +// fallback retry. The specific feature tokens + bare `schema` cover real structured- +// output rejections (e.g. "Unknown field: responseSchema" still matches `schema`). const STRUCTURED_OUTPUT_REJECTION_KEYWORDS = - /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i; + /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|schema/i; const STRUCTURED_OUTPUT_FALLBACK_STATUSES = new Set([400, 404, 422]); export function shouldRetryWithoutStructuredOutput(error: unknown): boolean { diff --git a/src/streaming.ts b/src/streaming.ts index 883a47a..5b24325 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -42,13 +42,14 @@ export function streamErrorMessage(json: Record): string | null if (typeof err.type === 'string' && err.type) return err.type; return null; }; + // Anthropic: an explicit error event is an error even if its details are sparse. if (json.type === 'error') { return messageFromError(json.error) ?? 'Provider returned a streaming error'; } - if (json.error) { - return messageFromError(json.error) ?? 'Provider returned a streaming error'; - } - return null; + // OpenAI-compatible: { error: { message, type, code } }. Only treat it as an error + // when it actually carries a message/type, so a stray empty `error: {}` (or a + // code-only object) in an otherwise-normal chunk does not abort the stream. + return messageFromError(json.error); } export type DeltaExtractor = (json: Record) => string; diff --git a/tests/direct-providers.test.js b/tests/direct-providers.test.js index e36aa00..316e1c1 100644 --- a/tests/direct-providers.test.js +++ b/tests/direct-providers.test.js @@ -118,6 +118,16 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'missing required field: model')), false, ); + // Model-name errors ("unknown model" / "unrecognized model ID") must NOT trigger a + // wasted fallback retry — bare unknown/unrecognized keywords were dropped for this. + assert.strictEqual( + shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unknown model: gpt-5')), + false, + ); + assert.strictEqual( + shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unrecognized model ID: x')), + false, + ); // Legacy path: a plain Error carrying the English template still works (back-compat). assert.strictEqual( shouldRetryWithoutStructuredOutput(new Error('OpenAI API returned HTTP 400: response_format not supported')), diff --git a/tests/direct-streaming.test.js b/tests/direct-streaming.test.js index 51f8632..c799422 100644 --- a/tests/direct-streaming.test.js +++ b/tests/direct-streaming.test.js @@ -85,10 +85,10 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') ); assert.strictEqual(streaming.streamErrorMessage({ type: 'error' }), 'Provider returned a streaming error'); assert.strictEqual(streaming.streamErrorMessage({ error: { message: 'Quota exceeded' } }), 'Quota exceeded'); - assert.strictEqual( - streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), - 'Provider returned a streaming error', - ); + // OpenAI-style error object without a message/type is NOT treated as an error + // (avoids aborting a normal chunk that carries a stray empty/code-only `error`). + assert.strictEqual(streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), null); + assert.strictEqual(streaming.streamErrorMessage({ error: {} }), null); assert.strictEqual(streaming.streamErrorMessage({ choices: [{ delta: { content: 'hi' } }] }), null); assert.strictEqual(streaming.streamErrorMessage({ type: 'content_block_delta', delta: { text: 'x' } }), null); assert.strictEqual(streaming.streamErrorMessage({ error: null }), null);