Compare commits

..
23 Commits
Author SHA1 Message Date
james.maes 2a34d74bea Merge pull request 'test: make the Ollama connection-refused test deterministic' (#79) from fix/ollama-test-retry-flake into develop
node / node (push) Successful in 51s
build / node (push) Successful in 51s
2026-11-07 23:52:52 +00:00
James MaesandClaude Opus 5.5 324830218c test: zero the retry jitter instead of disabling retries
node / node (push) Successful in 52s
build / node (push) Successful in 52s
maxRetries: 0 dropped the transport-retry branches below the 79% branch
coverage threshold. Keep the default retries (and assert all 4 attempts) but
stub Math.random to 0 so the backoff is immediate and deterministic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:49:24 -06:00
James MaesandClaude Opus 5.5 5499a86936 test: make the Ollama connection-refused test single-attempt
node / node (push) Failing after 51s
build / node (push) Failing after 51s
The test used the default 3 retries, whose jittered backoff (up to 1+2+4 s)
can exceed the 5 s Vitest timeout, so it failed intermittently in CI. Use
maxRetries: 0 like the other single-attempt error tests; retry behavior is
covered by the retry tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:42:39 -06:00
james.maes 823adf3c2a Merge pull request 'ci: build on the shared ci-workflows workflow' (#78) from ci/ci-workflows into develop
node / node (push) Failing after 54s
build / node (push) Failing after 54s
2026-11-07 23:30:03 +00:00
James MaesandClaude Opus 5.5 2593993622 ci: build on the shared ci-workflows Node workflow
node / node (push) Successful in 56s
build / node (push) Successful in 56s
Add .gitea/workflows/build.yaml calling KofTwentyTwo/ci-workflows node.yaml@v1
with the repository's own gates, and remove the old CI configuration that
called the retired CircleCI orb / platform-ci system.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:27:59 -06:00
james.maes 2eb7aad21f Merge pull request 'ci: remove workflows that call the retired CI system' (#77) from retire/old-ci into develop 2026-11-07 13:30:34 +00:00
james.maesandClaude Opus 5.5 a00cc64edc ci: remove workflows that call the retired CI system
Owner decision 2026-11-07: only KofTwentyTwo/ci-workflows remains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 13:30:32 +00:00
forge-recovery ca65cba959 ci: use the central original KOF container runner default
KofTwentyTwo/ci-policy: Shared Node CI / verify (push) Successful in 1m30s
KofTwentyTwo/ci-policy: ci / node-8 (push) Successful in 1m40s
KofTwentyTwo/ci-policy: ci / fork-guard (push) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / go-nix-1 (push) Skipped
KofTwentyTwo/ci-policy: ci / java-docker-4 (push) Skipped
KofTwentyTwo/ci-policy: ci / gradle-2 (push) Skipped
KofTwentyTwo/ci-policy: ci / ci-result (push) Successful in 4s
KofTwentyTwo/ci-policy: ci / gradle-3 (push) Skipped
KofTwentyTwo/ci-policy: ci / java-maven-5 (push) Skipped
Original Munitor Obsidian plugin / prepare (push) Successful in 1m53s
Original Munitor Obsidian plugin / allocate-version (push) Skipped
Original Munitor Obsidian plugin / code-quality (push) Skipped
Original Munitor Obsidian plugin / coverage (push) Skipped
KofTwentyTwo/ci-policy: ci / manifests-6 (push) Skipped
KofTwentyTwo/ci-policy: ci / nix-7 (push) Skipped
Original Munitor Obsidian plugin / secrets-scan (push) Skipped
Original Munitor Obsidian plugin / release-assets (push) Skipped
Original Munitor Obsidian plugin / sast-scan (push) Skipped
Original Munitor Obsidian plugin / sbom (push) Skipped
Original Obsidian plugin build and release / plugin (push) Failing after 2m1s
KofTwentyTwo/ci-policy: ci / rust-10 (push) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (push) Skipped
KofTwentyTwo/ci-policy: ci / source-config-14 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-2 (push) Skipped
KofTwentyTwo/ci-policy: ci / python-9 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-4 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-1 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-6 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-5 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-7 (push) Skipped
KofTwentyTwo/ci-policy: ci / content-validate-0 (push) Skipped
KofTwentyTwo/ci-policy: native / fork-deny (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-0 (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-3 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-4 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-5 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-8 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-12 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-13 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-15 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-16 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-19 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-17 (push) Skipped
KofTwentyTwo/ci-policy: Shared Node CI / gate (push) Successful in 8s
Original Munitor Obsidian plugin / build-and-test (push) Skipped
Original Munitor Obsidian plugin / security-scan (push) Skipped
Original Munitor Obsidian plugin / gate (push) Failing after 4s
KofTwentyTwo/ci-policy: ci / source-config-15 (push) Skipped
KofTwentyTwo/ci-policy: ci / not-enrolled (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-1 (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-2 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-6 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-9 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-10 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-11 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-14 (push) Skipped
KofTwentyTwo/ci-policy: Shared Node CI / secrets (push) Successful in 11s
KofTwentyTwo/ci-policy: native / native-result (push) Successful in 6s
2026-11-04 09:33:07 +00:00
forge-recoveryandCodex dffe635c55 ci: use the central original container runner default
KofTwentyTwo/ci-policy: native / native-result (pull_request) Successful in 3s
KofTwentyTwo/ci-policy: Shared Node CI / secrets (pull_request) Successful in 9s
KofTwentyTwo/ci-policy: Shared Node CI / verify (pull_request) Successful in 1m29s
KofTwentyTwo/ci-policy: ci / node-8 (pull_request) Successful in 1m41s
KofTwentyTwo/ci-policy: ci / ci-result (pull_request) Successful in 5s
Original Obsidian plugin build and release / plugin (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-maven-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-nix-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / gradle-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / gradle-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-docker-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / nix-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / manifests-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / python-9 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-15 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / not-enrolled (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / content-validate-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-8 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-9 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-11 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-12 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-15 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-14 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-16 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-17 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-19 (pull_request) Skipped
KofTwentyTwo/ci-policy: Shared Node CI / gate (pull_request) Successful in 9s
KofTwentyTwo/ci-policy: ci / fork-guard (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-14 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / fork-deny (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 09:28:47 +00:00
forge-recovery c7bf2373c2 ci: use original shared Obsidian plugin pipeline
KofTwentyTwo/ci-policy: ci / go-nix-1 (push) Skipped
KofTwentyTwo/ci-policy: ci / fork-guard (push) Skipped
KofTwentyTwo/ci-policy: ci / gradle-3 (push) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / gradle-2 (push) Skipped
KofTwentyTwo/ci-policy: ci / java-docker-4 (push) Skipped
KofTwentyTwo/ci-policy: ci / java-maven-5 (push) Skipped
KofTwentyTwo/ci-policy: ci / manifests-6 (push) Skipped
KofTwentyTwo/ci-policy: ci / nix-7 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-10 (push) Skipped
KofTwentyTwo/ci-policy: ci / python-9 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (push) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (push) Skipped
Original Munitor Obsidian plugin / release-assets (push) Canceled after 0s
Original Munitor Obsidian plugin / secrets-scan (push) Canceled after 0s
KofTwentyTwo/ci-policy: ci / source-config-14 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-1 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (push) Skipped
Original Munitor Obsidian plugin / sbom (push) Canceled after 0s
Original Munitor Obsidian plugin / sast-scan (push) Canceled after 0s
Original Munitor Obsidian plugin / coverage (push) Canceled after 0s
KofTwentyTwo/ci-policy: ci / image-build-2 (push) Skipped
Original Munitor Obsidian plugin / code-quality (push) Canceled after 0s
Original Munitor Obsidian plugin / prepare (push) Canceled after 0s
Original Munitor Obsidian plugin / allocate-version (push) Canceled after 0s
KofTwentyTwo/ci-policy: ci / image-build-4 (push) Skipped
Original Obsidian plugin build and release / plugin (push) Canceled after 0s
KofTwentyTwo/ci-policy: ci / image-build-5 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-7 (push) Skipped
KofTwentyTwo/ci-policy: ci / content-validate-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-6 (push) Skipped
KofTwentyTwo/ci-policy: native / fork-deny (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-3 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-4 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-8 (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-0 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-5 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-15 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-16 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-17 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-19 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-12 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-13 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (push) Skipped
KofTwentyTwo/ci-policy: Shared Node CI / verify (push) Successful in 1m38s
KofTwentyTwo/ci-policy: ci / node-8 (push) Successful in 1m48s
KofTwentyTwo/ci-policy: ci / ci-result (push) Successful in 5s
Original Munitor Obsidian plugin / gate (push) Canceled after 0s
KofTwentyTwo/ci-policy: ci / source-config-15 (push) Skipped
Original Munitor Obsidian plugin / security-scan (push) Canceled after 0s
Original Munitor Obsidian plugin / build-and-test (push) Canceled after 0s
KofTwentyTwo/ci-policy: ci / not-enrolled (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-1 (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-2 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-6 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-10 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-11 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-9 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-14 (push) Skipped
KofTwentyTwo/ci-policy: native / native-result (push) Successful in 3s
KofTwentyTwo/ci-policy: Shared Node CI / secrets (push) Successful in 11s
KofTwentyTwo/ci-policy: Shared Node CI / gate (push) Successful in 8s
2026-11-04 08:22:26 +00:00
forge-recoveryandCodex 73c1a9e5fe ci: use original shared Obsidian plugin pipeline
Original Obsidian plugin build and release / plugin (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / gradle-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-nix-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / gradle-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-docker-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / manifests-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-maven-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / nix-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / python-9 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-15 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / not-enrolled (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / content-validate-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-2 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-3 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-8 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-9 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / dotnet-11 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-12 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-14 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-15 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-17 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-19 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-16 (pull_request) Skipped
KofTwentyTwo/ci-policy: Shared Node CI / secrets (pull_request) Successful in 13s
KofTwentyTwo/ci-policy: native / native-result (pull_request) Successful in 5s
KofTwentyTwo/ci-policy: Shared Node CI / verify (pull_request) Successful in 1m38s
KofTwentyTwo/ci-policy: ci / node-8 (pull_request) Successful in 1m47s
KofTwentyTwo/ci-policy: ci / ci-result (pull_request) Successful in 3s
KofTwentyTwo/ci-policy: ci / fork-guard (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-14 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / fork-deny (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: Shared Node CI / gate (pull_request) Successful in 8s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 08:10:54 +00:00
james.maes 6d0e0cf33f fix(frontmatter): broaden detectCharacters regex (#33) (#70)
Captures two-word names, hyphenated names, apostrophe names like O'Brien,
and all-caps shouted attribution. Previous regex only matched single
PascalCase tokens, which silently misfiltered voice-test selection
upstream and drifted prose voice for chapters with multi-word casts.
2026-04-29 08:28:19 -05:00
james.maes 012d73d795 fix(voice-check): correct dialogue regex; support all quote styles (#5) (#69)
Replaces the broken regex with a small set of patterns covering straight
double quotes, paired typographic double quotes, British single-quoted
dialogue (anchored to avoid contraction false positives), and paired
typographic single quotes. Same patterns drive narration extraction so the
long-sentence flag is computed against actual narration.
2026-04-29 08:24:39 -05:00
james.maes 8be93c9e3e feat(providers): preserve whitespace in LLM responses (#16) (#68)
Removes .trim() from the non-streaming parseResponse path in all four
providers. Authors rely on intentional whitespace (paragraph breaks,
vertical rhythm) and stripping it silently changes the splice point in
assembler.ts. Streaming paths already accumulate raw chunks and are
unaffected.
2026-04-28 07:23:43 -05:00
james.maes 28e701a450 feat: snapshot .state.json.bak before overwrite (#31) (#67)
* feat(commands): snapshot .state.json.bak before overwrite (#31)

* ci: retrigger build (no code changes)
2026-04-28 07:11:27 -05:00
james.maes ea3cbd2587 fix: retry on 429 / transient 5xx with backoff (#15) (#66)
* docs: add design spec for retry/backoff (#15)

* docs: add implementation plan for retry/backoff (#15)

* feat(providers): add HttpError class for typed retry classification (#15)

* feat(providers): classify retriable errors via isRetriable (#15)

* feat(providers): add parseRetryAfter and backoffDelay helpers (#15)

* feat(providers): add withRetry helper with abort-aware backoff (#15)

* feat(providers): expose response headers on StreamResult (#15)

* feat(providers): add maxRetries and onRetry to CompletionRequest (#15)

* feat(pipeline): add retry variant to ProgressEvent (#15)

* feat(settings): add maxRetries to PennySettings (default 3) (#15)

* feat(providers): apply withRetry to Anthropic complete() (#15)

* feat(providers): apply withRetry to OpenAI complete() (#15)

* feat(providers): apply withRetry to Google complete() (#15)

* feat(providers): apply withRetry to Ollama complete() (#15)

* feat(pipeline): forward maxRetries and surface retry events (#15)

* feat(settings): expose maxRetries in Behavior tab (#15)

* feat(progress-modal): render retry status events (#15)

* chore(ci): ratchet coverage thresholds for retry/backoff (#15)

* fix(tests): keep streaming-errors mock real for HttpError/withRetry exports (#15)

The streaming-errors mock returned only streamRequest/withTimeout, but the
post-#15 providers also import HttpError and withRetry from node-stream.
Spread the actual module via importActual; only override streamRequest and
withTimeout. Pass maxRetries: 0 in each test call so pre-token errors do
not trigger retry sleeps and exceed the test timeout.
2026-04-28 06:57:00 -05:00
james.maesandClaude Opus 4.7 faf6cc8d9f fix: surface mid-stream SSE error events across all providers (#17) (#65)
Closes #17. Each streaming provider's SSE parser had a try/catch that
swallowed any well-formed event whose shape it didn't recognize -- which
included upstream `error` events (overloaded_error, rate_limit_exceeded,
context-length-exceeded, content-filter-triggered, etc). Result: a
streaming call that errored mid-flight returned empty text and status 200,
making it indistinguishable from a successful empty completion.

Fix per provider:
- Anthropic: detect `event.type === "error"` -> capture msg + type
- OpenAI:    detect `event.error` object       -> capture msg + type
- Google:    detect `event.error` object       -> capture msg + status
- Ollama:    detect `event.error` (string OR object, both shapes seen)

The error is captured in a closure variable (not thrown directly) because
streamRequest's onChunk handler wraps consumer throws to keep the stream
alive. After streamRequest resolves, providers throw the captured error,
which means partial pre-error tokens never surface as a "successful"
CompletionResponse.

Also extended each provider's complete()'s streaming-fallback catch to
re-throw `<Provider> streaming error` and `TimeoutError` instead of
falling through to the non-streaming httpFn path. Without this the
streaming error was silently retried via the non-streaming API.

Coverage ratchet: streaming-errors tests exercised previously-uncovered
streaming SSE paths in all four providers, lifting global coverage from
82.19% -> 90.93% lines (the audit issue #41 jump arrived early). Updated
vitest thresholds to lines=90 / statements=89 / functions=96 / branches=77,
and .munitor.yml min_instruction 82 -> 90 to keep the orb-side gate aligned.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 06:39:47 -05:00
james.maesandClaude Opus 4.7 022941bdfa fix: add per-call timeout to LLM provider requests (#18) (#64)
* fix: add per-call timeout to LLM provider requests (#18)

Close #18. Without a timeout, a stalled connection makes the progress modal
spin indefinitely with no automatic recovery. Adds:

- streamRequest({ timeoutMs }) wires Node's req.setTimeout(ms, cb), destroys
  the request on inactivity, and rejects with name === "TimeoutError"
  (distinct from AbortError so cancellation stays distinguishable from
  server-side hangs in logs and review notes)
- withTimeout(promise, ms) helper for the non-streaming httpFn path, which
  goes through Obsidian's requestUrl with no native AbortSignal support.
  Promise.race shape: the user-facing promise rejects on time; the
  underlying request keeps running in background until Node's transport
  timeout — bounded leak, acceptable trade for stopping the UI hang
- PennySettings.requestTimeoutMs (default 300000ms = 5min, 0 disables)
  exposed in Settings → Behavior as seconds
- All four providers (Anthropic, Ollama, Google, OpenAI) honor the timeout
  in both streaming and non-streaming paths
- The pipeline already routes any thrown provider error into a flag entry
  on the review note; TimeoutError surfaces as
  "API error for [TAG]: Request timed out after 300000ms"

Tests:
- streamRequest timeout fires when handler never responds (~150ms)
- streamRequest timeout error is named TimeoutError, not AbortError
- streamRequest timeout does not fire when server responds in budget
- withTimeout resolves with original value within budget
- withTimeout rejects with TimeoutError when over budget
- withTimeout returns promise unchanged when timeoutMs is undefined or 0
- withTimeout propagates upstream rejections without timeout shadowing

Coverage ratchet: lines 81→82, node-stream.ts 93→95, .munitor min_instruction
81→82 to keep the orb-side gate aligned with the new vitest floor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: clear sast-scan and secrets-scan false positives

Both pre-existing pre-issue-#18 findings, surfaced for the first time by
the pr-checks workflow. Fixed at source rather than suppressed so future
scanner additions cannot reintroduce them.

semgrep (4 INFO findings, src/logger.ts:61-64):
  Rule: javascript.lang.security.audit.unsafe-formatstring
  pennyLog passed the prefix as a separate first argument to console.log,
  which the rule treats as a potential format-string injection sink. The
  prefix is built from a closed LogLevel enum (debug|info|warn|error|off)
  and never sees user input, so the finding is a false positive. Refactored
  to concatenate prefix + message into a single line argument up front --
  identical console output, no separate-argument format-string surface.

gitleaks (2 generic-api-key findings, test/providers/google.test.ts):
  Test placeholder values "AIzaSy-test-key-123" / "AIzaSy-valid" / etc.
  matched the pattern used by real Google API keys. Renamed all six
  occurrences to mock-google-* placeholders that no real-key heuristic
  matches. Tests still verify the same path (key forwarded into the
  request URL); only the literal value changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): pin SAST sink to string literals; allowlist test mock keys

The two security-scan failures on PR #64 turned out to need stronger
fixes than the source-rename in 0cd7968:

semgrep (4 INFO findings still in src/logger.ts):
  Taint-flow analysis follows any non-literal value reaching the first
  argument of console.log. The previous fix moved the prefix into a
  template-literal-built `line` variable, which left the same flow path
  intact -- now the variable name was just `line` instead of `prefix`.
  Refactored to four hardcoded literal prefixes per case so the AST has
  no source-to-sink path. Functionally identical output.

gitleaks (2 generic-api-key findings still in test/providers/google.test.ts):
  gitleaks scans git history, so the AIzaSy-test-key-123 string still
  matched in commits prior to 0cd7968 even though the working tree was
  clean. Added a .gitleaks.toml allowlist covering the historical
  placeholder strings and the new mock-google-* shape, plus a path-based
  allow for the entire test/ tree (mock fixtures by definition).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:58:22 -05:00
james.maesandClaude Opus 4.7 d5d5e9e063 chore: add CLAUDE.md project guidance, gitignore .claude/
CLAUDE.md captures the architectural rules a Claude Code agent (or
human contributor) needs to be productive quickly: the pure-logic vs
Obsidian-shell import boundary, the runPipeline orchestration shape,
the provider HttpFn injection pattern, the versioning model, the
save-hook debounce/re-entrancy guards, and the gap between
CONTRIBUTING.md's aspirational layout and the real flat src/ tree.

.claude/ is local session/transcript cache and should not be tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:36:24 -05:00
james.maesandClaude Opus 4.7 8a35989c7d chore(ci): add orb-side coverage gate (min_instruction 81)
The munitor orb's node_coverage job runs independently of the test
commands and parses coverage/coverage-summary.json against
test.coverage.min_instruction. With this set to 81, we have two layers
of coverage enforcement:

  1. Vitest's own threshold gate (fires inside npm run check; fails the
     test job locally and in CI on threshold violation)
  2. Orb's node_coverage job (parses the summary file and fails the
     pipeline independently)

Ratchet min_instruction up alongside vitest.config.ts thresholds so
both gates stay aligned.

Also documents why orb_version remains dev:snapshot: the obsidian-plugin
pipeline template ships only in the snapshot orb today and is not in
any v0.2.x tagged release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:34:06 -05:00
james.maesandClaude Opus 4.7 33356b568b chore(ci): gate CI on full check pipeline (typecheck + tests + coverage)
Replace bare `npm test` in the munitor test commands with `npm run check`,
which runs typecheck plus vitest with coverage threshold enforcement.
Vitest exits non-zero on any threshold violation, so a regression below
the locked floor or a per-file ratchet now fails CI.

Coverage reporting (the munitor orb's coverage.command block) continues
to run via `npm run test:coverage` so the orb still gets a report file
for its dashboards.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:28:25 -05:00
james.maesandClaude Opus 4.7 cf64834eaa chore(test): add npm run check script for typecheck + coverage gate
Single command that runs the full local quality bar: TypeScript
typecheck plus the vitest coverage gate. Intended for CI use and as the
"is this ready to commit" pre-flight from the developer's terminal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:27:38 -05:00
james.maesandClaude Opus 4.7 46bf4cdd45 chore(test): configure vitest coverage thresholds at current actuals
Lock current coverage levels as a regression floor, with per-file ratchets
on well-tested modules so they cannot silently drop. Shell modules
(commands.ts, settings.ts, modals, statusbar.ts, setup-wizard.ts, main.ts)
and pure-type files (types.ts, logger.ts) are excluded from threshold
gating because they require Obsidian mocking infrastructure to test.

Global floor (lines 81 / statements 80 / functions 91 / branches 69) and
per-file pins all match current actuals; raise them as tests are added.
The path to 90% global is dominated by audit issue #41 (provider
streaming SSE tests), which would push the four providers from ~60% to
~90% line coverage in one batch.

Adds quotes.test.ts and providers/index.test.ts to cover two pure-logic
files that were previously hidden by vitest's default include heuristic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:27:21 -05:00
42 changed files with 3911 additions and 214 deletions

No files matched your search

-8
View File
@@ -1,8 +0,0 @@
version: 2.1
setup: true
orbs:
munitor: kof22/munitor@dev:snapshot
workflows:
setup:
jobs:
- munitor/generate_pipeline
+13
View File
@@ -0,0 +1,13 @@
name: build
on:
push:
jobs:
node:
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
secrets: inherit
with:
node-version: '22'
# check = typecheck + vitest with the coverage thresholds enforced
commands: |
npm run build
npm run check
-22
View File
@@ -1,22 +0,0 @@
name: Original Obsidian plugin build and release
'on':
push:
branches:
- main
- develop
- feature/**
- hotfix/**
- release/**
pull_request: {}
workflow_dispatch: {}
permissions:
contents: write
actions: read
jobs:
plugin:
if: ${{ (github.head_ref || github.ref_name) == 'main' || (github.head_ref || github.ref_name) == 'develop' || startsWith((github.head_ref
|| github.ref_name), 'feature/') || startsWith((github.head_ref || github.ref_name), 'hotfix/') || startsWith((github.head_ref
|| github.ref_name), 'release/') }}
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/munitor-obsidian.yaml@kof-release-v1
with:
runner: linux-small
+3
View File
@@ -22,3 +22,6 @@ Thumbs.db
# Test
coverage/
# AI tooling caches (session/transcript data, not portable)
.claude/
+27
View File
@@ -0,0 +1,27 @@
# gitleaks configuration
#
# Allowlist for test-fixture API key placeholders that gitleaks's history
# scan would otherwise flag as real secrets. These strings have only ever
# been used as placeholders in unit tests; no real credential has ever lived
# in this repository.
[extend]
# Inherit gitleaks's default rule set.
useDefault = true
[allowlist]
description = "Test-fixture mock API keys"
regexes = [
# Old placeholder shape that previously matched gitleaks's
# generic-api-key rule (still in git history). See
# test/providers/google.test.ts for current values.
'''AIzaSy-test-key-123''',
'''AIzaSy-(valid|bad|key|test)\b''',
# Current placeholder shape -- wide net so any future
# mock-google-* test fixture is covered.
'''mock-google-[a-z0-9-]+''',
]
paths = [
# Anything under test/ is fixture territory; never real secrets.
'''^test/.+''',
]
-12
View File
@@ -1,12 +0,0 @@
pipeline: obsidian-plugin
orb_version: dev:snapshot
node_version: "20"
sast: true
sbom: true
test:
commands: ["npm run build", "npm test"]
coverage:
tool: vitest
command: npx vitest run --coverage
contexts:
github: github
+113
View File
@@ -0,0 +1,113 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
PENNY is an Obsidian community plugin that processes editorial annotations (`%% REWRITE: ... %%`, `%% TONE: ... %%`, etc.) inside novel chapter markdown, calls an LLM to revise the surrounding prose, and writes a new versioned chapter file alongside a review note. It supports Anthropic Claude, Ollama (local), Google Gemini, and OpenAI as providers, with per-tag complexity-tier model routing. Desktop-only (uses Node.js `child_process` for git).
## Common commands
```bash
npm install # install deps
npm run dev # esbuild watch mode -> writes main.js at repo root
npm run build # tsc --noEmit + production esbuild bundle (also auto-deploys, see below)
npm run typecheck # tsc --noEmit
npm run lint # alias for typecheck (no separate eslint script in package.json; eslint config exists)
npm test # vitest run
npm run test:watch # vitest watch
npm run test:coverage # vitest run --coverage (text + json-summary reporters)
```
Run a single test file: `npx vitest run test/parser.test.ts`
Run a single test by name: `npx vitest run -t "parses REWRITE annotation"`
### Build auto-deploy quirk
`npm run build` (production) inspects `../Books/.obsidian/plugins/penny/` and, if the directory exists, copies `main.js`, `styles.css`, and `manifest.json` into it (see `esbuild.config.mjs:48-54`). This is a developer convenience for the maintainer's local vault — do not assume the path exists in CI or for contributors. To test in your own vault, copy or symlink those three files into `<your-vault>/.obsidian/plugins/penny/` manually.
## Architecture
### The hard rule: pure-logic vs Obsidian-shell
The code splits into two zones by **what they import**:
- **Pure-logic modules** (no `obsidian` import, take pre-read strings/objects as parameters):
`parser.ts`, `pipeline.ts`, `assembler.ts`, `drafter.ts`, `versioner.ts`, `frontmatter.ts`, `context.ts`, `reviewer.ts`, `voice-check.ts`, `logger.ts`, `migrate.ts`, `project-config.ts`, `quotes.ts`, `utils.ts`, `types.ts`, and everything under `src/providers/`.
- **Obsidian-shell modules** (free to use Obsidian APIs):
`main.ts`, `commands.ts`, `settings.ts`, `setup-wizard.ts`, `statusbar.ts`, `progress-modal.ts`, `error-modal.ts`, `file-conflict-modal.ts`.
`commands.ts` is the bridge: it reads files via the vault API, calls `runPipeline()` from `pipeline.ts` with everything pre-loaded, then writes results back. **Never add `import { ... } from "obsidian"` to a pure-logic module** — that breaks the test architecture (vitest tests for pure modules do not mock Obsidian).
CONTRIBUTING.md describes a `src/core/` + `src/shell/` directory split, but the codebase is currently a flat `src/` with only `src/providers/` as a subfolder. The boundary is enforced by import discipline, not directory layout.
### Pipeline flow (the heart of the plugin)
1. User runs **PENNY: Process this chapter** (or save hook fires).
2. `commands.ts` reads the chapter, `.version`, `.state.json`, and all context files (style guide, voice tests, characters, outline, wiki) from the vault.
3. It calls `runPipeline()` (`src/pipeline.ts`) with that input plus a `getProvider` lookup callback.
4. The pipeline parses annotations (`parser.ts`), filters out already-processed ones via a content hash stored in `.state.json` (`versioner.ts`), assembles per-annotation context (`context.ts`), builds prompts and calls the LLM (`drafter.ts`), then assembles the final new chapter (`assembler.ts`), updates frontmatter (`frontmatter.ts`), runs voice compliance (`voice-check.ts`), and generates a review note (`reviewer.ts`).
5. `commands.ts` writes the new version file (`ch-05.v2.md`), updates `.version` / `.state.json`, writes the review file, and appends to the activity log.
The pipeline emits real-time `ProgressEvent`s (consumed by `progress-modal.ts`) and supports cancellation via an `isCancelled()` callback.
### Provider system
Four providers live in `src/providers/`: `anthropic.ts`, `ollama.ts`, `google.ts`, `openai.ts`. They all implement the `LLMService` interface from `service.ts` and **do not import `obsidian`**. Instead, they receive an `HttpFn` at construction time. `main.ts` adapts Obsidian's `requestUrl` into that shape:
```ts
this.providerRegistry = createRegistry(async (params) => {
const resp = await requestUrl({...});
return { status, headers, text, json };
});
```
This is why provider tests can mock the HTTP layer directly without any Obsidian shim.
`router.ts` maps annotation tags to complexity tiers (light/standard/heavy) and resolves each tier to a configured provider+model. The `"auto-latest"` model literal is resolved at call-time to the currently-recommended Anthropic model for that tier.
### Versioning model
Each chapter folder contains:
- `ch-NN.v1.md`, `ch-NN.v2.md`, ... — successive versions, never overwritten
- `.version` — plain integer of current version
- `.state.json` — `VersionState` tracking content hashes of already-processed annotations (idempotency)
A processing run that finds zero new annotations writes nothing. Annotation hashes mean re-running on the same file is a no-op even after a crash.
### Settings + per-project overrides
Settings live in Obsidian's `data.json` (loaded in `main.ts:loadSettings`, with legacy field migration via `migrateSettings` in `types.ts`). A project may override a subset by adding a `PENNY.md` at the book folder root — see `project-config.ts`. The settings tab is `settings.ts`; first-run UX is `setup-wizard.ts`.
### Save-hook debouncing
When `autoProcessOnSave` is on, `main.ts` registers a `vault.on("modify")` handler with a per-file 2-second debounce and a `processingFiles: Set<string>` re-entrancy guard. Without both, processing-induced file writes would cascade into infinite re-triggers. If you touch this code, preserve those guards.
## Code conventions
From `CONTRIBUTING.md` and `tsconfig.json` (strict mode, `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`):
- **No `any`.** ESLint warns on `@typescript-eslint/no-explicit-any`. Prefer `unknown` and narrow.
- **Named exports only**, no default exports (the one exception is `class PennyPlugin extends Plugin` in `main.ts`, which Obsidian's plugin loader requires as default).
- **Explicit return types** on exported functions.
- **Pure functions in pure-logic modules**: take inputs, return outputs, no side effects. Side effects (vault I/O, network, child_process) belong in shell modules.
- TypeScript target is ES2018; bundled as CJS for Obsidian.
## Testing
- Tests live in `test/` and mirror `src/` (e.g. `src/pipeline.ts` ↔ `test/pipeline-cancel.test.ts`).
- Pure-logic tests **must not** mock Obsidian — that is the whole point of the import boundary. If a test wants to drive the pipeline, it constructs `PipelineInput` directly with raw strings.
- Provider tests mock the injected `HttpFn`, not network or Obsidian.
- Test fixtures (style guide, voice tests, sample chapter) are in `test/fixtures/`.
## Branch model
- **`develop`** — default branch, integration target for all PRs.
- **`main`** — release branch, only receives merges from `develop`.
- Feature branches: `feature/<short-description>` or `fix/<short-description>`, branched from `develop`.
Commits follow Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`).
## CI
CircleCI with the `kof22/munitor` orb (see `.circleci/config.yml` and `.munitor.yml`). The pipeline runs `npm run build` and `npm test`, plus SAST and SBOM. There is no separate ESLint job — `npm run lint` is aliased to `tsc --noEmit`, so type errors block CI but ESLint warnings do not.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,110 @@
# Design: Retry on 429 / transient 5xx with backoff (#15)
**Status:** Draft, awaiting approval
**Issue:** [#15](https://github.com/KofTwentyTwo/obsidian-penny/issues/15)
**Date:** 2026-04-27
**Branch (planned):** `feature/issue-15-retry-backoff` from `develop`
## Goal
Make PENNY's LLM calls survive transient failures. Today, a single 429 or 5xx aborts the entire annotation pass and pollutes the new chapter version with `AGENT-ERROR` markers (`src/pipeline.ts:342-373`) for revisions that would have succeeded on retry. This is a daily-use blocker the moment #9 (parallel annotations) lands, because concurrent Anthropic calls will routinely hit the tier-1 50 RPM / 40K TPM ceiling.
## Architecture
A new `withRetry<T>(fn, opts)` helper lives in `src/providers/node-stream.ts` (same module as `TimeoutError`, `AbortError`, `withTimeout`). Each provider's `complete()` extracts its current request body into a private `doOneAttempt()` method and wraps the call:
```ts
const maxRetries = request.maxRetries ?? 3;
return withRetry(() => this.doOneAttempt(request), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
```
A new `HttpError` class (also in `node-stream.ts`) carries `status`, `headers`, and `body` so the retry helper can read `Retry-After`. `streamRequest`'s return shape is extended with `headers: Record<string, string>` so the streaming path can populate `HttpError.headers` the same way the non-streaming path does. Both paths route HTTP errors through the same `buildHttpError(status, text, headers)` helper.
Retry placement is **inside each provider's `complete()`**, around the entire request path (streaming and non-streaming). This keeps the retry decision close to the code that knows what kind of error was thrown, mirrors the surface change made for `withTimeout` in #18, and avoids a new abstraction layer.
## Components
1. **`HttpError extends Error`** — `{ status: number; headers: Record<string,string>; body: string }`. Replaces the plain `Error` returned by every provider's `buildHttpError`.
2. **`withRetry<T>(fn, opts)`** — runs `fn`, classifies thrown errors, computes delay, sleeps cancellation-aware, retries up to `maxAttempts`. Options: `{ maxAttempts: number; canStillRetry?: () => boolean; signal?: AbortSignal; onRetry?: (info: RetryInfo) => void }`.
3. **`isRetriable(err): boolean`** — true when `err instanceof HttpError && (err.status === 429 || err.status === 408 || err.status >= 500)`, OR when `err` is a transport error (none of `HttpError | TimeoutError | AbortError`). False for `TimeoutError`, `AbortError`, other 4xx.
4. **`backoffDelay(err, attempt): number`** — `attempt` is 0-indexed across retries (0 = wait before retry #1, 1 = wait before retry #2, ...). If `HttpError` and `Retry-After` parses cleanly (seconds or HTTP-date), return that delay. Else `random(0, 1000 * 2^attempt)` (full jitter): max 1s before retry #1, max 2s before retry #2, max 4s before retry #3. Both branches capped at 60000ms.
5. **`CompletionRequest` additions** — `maxRetries?: number` and `onRetry?: (info: { attempt: number; waitMs: number; reason: string }) => void`.
6. **`PennySettings.maxRetries`** — default 3, range 0–5 (0 disables). Exposed in Settings → Behavior next to `requestTimeoutMs`. Threaded into `complete()` calls from `pipeline.ts`.
7. **Pipeline integration** — `pipeline.ts` passes `maxRetries` from settings, supplies an `onRetry` callback that emits `ProgressEvent("retry", { attempt, waitMs, reason })`. Providers stay free of Obsidian dependencies.
## Data flow (one annotation, 429 → 200)
1. `pipeline.ts` calls `service.complete(request)` with `maxRetries` from settings and an `onRetry` that emits a `ProgressEvent("retry", ...)`.
2. Provider's `complete()` initializes `started = false`, wraps `onToken` so the first call flips `started = true`.
3. Calls `withRetry(() => doOneAttempt(...), { maxAttempts: 4 /* maxRetries:3 + 1 initial */, canStillRetry: () => !started, signal, onRetry })`.
4. **Attempt 1 (429 with `Retry-After: 30`):** non-2xx response → `buildHttpError` constructs `HttpError(429, { "retry-after": "30" }, body)` and throws. `withRetry` catches; `isRetriable(err)` is true; `canStillRetry()` is true; `backoffDelay(err, 0)` returns 30000ms. Emits `onRetry({ attempt: 2, waitMs: 30000, reason: "429" })`, `LOG.info("retrying after 429", { provider, attempt, waitMs })`, sleeps 30s with abort-signal listener.
5. **Attempt 2 (200):** returns `CompletionResponse` normally.
Cancellation during the sleep wakes immediately and throws `AbortError`. If any token reached the user before the error (mid-stream failure), `started === true`, `canStillRetry()` returns false, and the error propagates without retry.
## Error handling
| Error kind | Retriable? |
|---|---|
| `HttpError` with status 429 | yes |
| `HttpError` with status 408 | yes |
| `HttpError` with status 5xx | yes |
| `HttpError` with other 4xx (401, 403, 400, 404, ...) | no |
| Transport error (no recognized class) | yes |
| `TimeoutError` (from #18) | no — user chose the timeout; retrying doubles the wait |
| `AbortError` (user cancellation) | no |
| Any error after first `onToken` callback fired | no — retrying would re-emit tokens |
After all attempts exhausted, throw the last error unchanged.
**Naming convention.** The setting is `maxRetries` — the number of retries on top of the initial call. Default `maxRetries: 3` means up to 4 total calls (1 initial + 3 retries). `maxRetries: 0` disables retry entirely (single attempt, surface the first error). The internal helper signature uses `maxAttempts = maxRetries + 1` so its loop bound is unambiguous.
## Testing
New `test/providers/retry.test.ts`:
- 429 → 429 → 200 succeeds with mock `httpFn`.
- `Retry-After: 5` honored; malformed `Retry-After: ABCD` falls back to exponential.
- Honors HTTP-date `Retry-After` (e.g. `Wed, 21 Oct 2026 07:28:00 GMT`) within the 60s cap.
- 401 propagates immediately, no retry.
- All 5xx retriable (501, 502, 503, 504 covered by representative tests).
- `TimeoutError` propagates, no retry.
- `AbortSignal` aborted mid-sleep → prompt `AbortError`.
- `canStillRetry` returns false (tokens emitted) → no retry on 503.
- `maxRetries: 0` makes a single attempt and throws the first error.
- Per-provider smoke tests proving wiring (one minimal test each for anthropic/openai/google/ollama).
Existing tests remain green; retry is transparent on the success path. Coverage expected to lift from 91% to ~93%; the same PR ratchets `vitest.config.ts` line/statement/function/branch thresholds and `.munitor.yml` `min_instruction` per the standing sprint pattern.
## Files affected
| Path | Change |
|---|---|
| `src/providers/node-stream.ts` | Add `HttpError`, `withRetry`, `isRetriable`, `backoffDelay`. Extend `streamRequest` return shape with `headers`. |
| `src/providers/anthropic.ts` | Refactor `complete()` body into `doOneAttempt`, wrap with `withRetry`. Update `buildHttpError(status, text, headers)` to populate `HttpError`. |
| `src/providers/openai.ts` | Same refactor as anthropic. |
| `src/providers/google.ts` | Same refactor. |
| `src/providers/ollama.ts` | Same refactor. |
| `src/providers/service.ts` | Add `maxRetries?: number` and `onRetry?: (info) => void` to `CompletionRequest`. |
| `src/types.ts` | Add `maxRetries: number` to `PennySettings`, default 3 in `migrateSettings`. |
| `src/settings.ts` | Settings → Behavior UI for max retries (slider 0–5, default 3). |
| `src/pipeline.ts` | Pass `maxRetries` from settings, wire `onRetry` to a `ProgressEvent("retry", ...)`. |
| `src/progress-modal.ts` | Render "Retrying after rate limit (attempt N/M, waiting Xs)…" when retry events arrive. |
| `test/providers/retry.test.ts` | New file covering the cases above. |
| `test/providers/{anthropic,openai,google,ollama}.test.ts` | Targeted updates for the new error shape and `doOneAttempt` refactor. |
| `vitest.config.ts`, `.munitor.yml` | Coverage threshold ratchet at PR-finalization time. |
## Open questions
None at design time. The retriable-set decision (5xx range, 408, transport errors; 4xx-other / `TimeoutError` / `AbortError` / post-onToken excluded) follows industry default behavior and the existing repo idioms.
## Out of scope
- Concurrency-aware retry coordination (single global token bucket across parallel calls). #9 will likely want this, but it can layer on top of `withRetry` later.
- Per-provider retry policy customization (e.g., "retry Ollama 5 times because it's local"). Single global setting is fine for now.
- Persistence of retry stats. Activity log captures success/failure; not adding a per-attempt audit.
-30
View File
@@ -921,9 +921,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -941,9 +938,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -961,9 +955,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -981,9 +972,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1001,9 +989,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1021,9 +1006,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2397,9 +2379,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2421,9 +2400,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2445,9 +2421,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2469,9 +2442,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
+1
View File
@@ -11,6 +11,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"check": "npm run typecheck && npm run test:coverage",
"version": "node version-bump.mjs && git add manifest.json versions.json"
},
"keywords": [
+26 -4
View File
@@ -603,9 +603,11 @@ export async function processChapter(plugin: PennyPlugin, file: TFile, options?:
return null;
}
// Update .version and .state.json (upsert -- these always exist after first run)
// Update .version and .state.json (upsert -- these always exist after first run).
// .state.json is the source of truth for which annotations have been processed;
// safeStateWrite snapshots a `.bak` before overwrite (issue #31).
await upsertFile(plugin, versionFilePath, String(result.newVersion));
await upsertFile(plugin, stateFilePath, result.stateJson);
await safeStateWrite(plugin, stateFilePath, result.stateJson);
// Write review note (reviewPath pre-computed above)
const reviewFolder = reviewPath.split("/").slice(0, -1).join("/");
@@ -1284,13 +1286,33 @@ async function reconcileVersionState(
}, null, 2);
}
// Write corrected files to disk
// Write corrected files to disk. State gets a .bak snapshot so a corrupt
// reconciliation can be recovered manually (issue #31).
await plugin.app.vault.adapter.write(`${folderPath}/.version`, newVersionContent);
await plugin.app.vault.adapter.write(`${folderPath}/.state.json`, newStateContent);
await safeStateWrite(plugin, `${folderPath}/.state.json`, newStateContent);
return { versionContent: newVersionContent, stateContent: newStateContent };
}
/**
* Write critical state content (`.state.json`) with a one-shot `.bak` of the
* previous contents. Keeps the most recent backup only — the next overwrite
* replaces it. Cheap insurance against a buggy reconciliation or partial
* write corrupting the source of truth for which annotations were processed.
*/
async function safeStateWrite(
plugin: PennyPlugin,
path: string,
content: string,
): Promise<void> {
const exists = await plugin.app.vault.adapter.exists(path);
if (exists) {
const previous = await plugin.app.vault.adapter.read(path);
await plugin.app.vault.adapter.write(`${path}.bak`, previous);
}
await plugin.app.vault.adapter.write(path, content);
}
/**
* Ensure a folder exists in the vault, creating parent folders as needed.
*/
+21 -8
View File
@@ -285,17 +285,30 @@ export function detectCharacters(content: string, focusField: string): string[]
}
}
// From dialogue attribution patterns.
const attributionRe = /(?:^|\s)([A-Z][a-z]+)\s+(?:said|asked|whispered|muttered|yelled|replied|shouted|murmured|called|snapped)/gm;
let m: RegExpExecArray | null;
while ((m = attributionRe.exec(content)) !== null) {
chars.add(m[1].toLowerCase());
// Verb list shared across attribution directions. Includes shouted variants.
const VERBS = "said|asked|whispered|muttered|yelled|replied|shouted|murmured|called|snapped|growled|sighed|laughed|hissed|breathed|cried";
// Capture name shapes (issue #33):
// - Single PascalCase: "Mary"
// - Hyphenated: "Mary-Anne"
// - Apostrophe names: "O'Brien", "M'Lady"
// - All-caps shouted: "MARY"
// - Two-word names: "Mary Jane", "Mary-Anne O'Connor"
// The hyphen/apostrophe alternative comes first so single-letter prefixes
// (the "O" in "O'Brien") are allowed only when followed by a separator and
// a capitalized continuation -- avoids matching the pronoun "I said".
const SINGLE = "(?:[A-Z][a-zA-Z]*['-][A-Z][a-zA-Z]+|[A-Z][a-zA-Z]+|[A-Z]{2,})";
const NAME = `${SINGLE}(?:\\s+${SINGLE})?`;
// Forward: `Name said` / `Name shouted` etc.
const forwardRe = new RegExp(`(?:^|\\s)(${NAME})\\s+(?:${VERBS})\\b`, "gm");
for (const match of content.matchAll(forwardRe)) {
chars.add(match[1].toLowerCase());
}
// Reverse: `said Name`
const reverseRe = /(?:said|asked|whispered|muttered|yelled|replied|shouted|murmured|called|snapped)\s+([A-Z][a-z]+)/gm;
while ((m = reverseRe.exec(content)) !== null) {
chars.add(m[1].toLowerCase());
const reverseRe = new RegExp(`(?:${VERBS})\\s+(${NAME})\\b`, "gm");
for (const match of content.matchAll(reverseRe)) {
chars.add(match[1].toLowerCase());
}
return Array.from(chars);
+10 -5
View File
@@ -56,12 +56,17 @@ const LOG_LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error
*/
export function pennyLog(level: LogLevel, settingLevel: LogLevel, message: string, ...data: unknown[]): void {
if (LOG_LEVELS[level] >= LOG_LEVELS[settingLevel]) {
const prefix = `[PENNY ${level.toUpperCase()}]`;
// The first argument to console.* is a string LITERAL per case so that
// taint-flow analyzers (semgrep's unsafe-formatstring rule) cannot
// construct a path from runtime values to a format-string sink. The
// prefix has always been derived from the closed LogLevel enum so this
// was never a real risk -- but expressing it as four literals keeps the
// SAST gate quiet without per-line rule suppressions.
switch (level) {
case "debug": console.log(prefix, message, ...data); break;
case "info": console.info(prefix, message, ...data); break;
case "warn": console.warn(prefix, message, ...data); break;
case "error": console.error(prefix, message, ...data); break;
case "debug": console.log("[PENNY DEBUG]", message, ...data); break;
case "info": console.info("[PENNY INFO]", message, ...data); break;
case "warn": console.warn("[PENNY WARN]", message, ...data); break;
case "error": console.error("[PENNY ERROR]", message, ...data); break;
}
}
}
+30 -2
View File
@@ -32,7 +32,7 @@ import { assembleNewVersion } from "./assembler";
import { readVersion, nextVersion, readState, updateState, shouldProcess, serializeState } from "./versioner";
import { parseFrontmatter, serializeFrontmatter, updateAgentFields, countProseWords, detectCharacters } from "./frontmatter";
import { generateReview } from "./reviewer";
import { createLogEntry, formatLogEntry } from "./logger";
import { createLogEntry, formatLogEntry, pennyLog } from "./logger";
import { checkVoiceCompliance } from "./voice-check";
import { getRoute } from "./providers/router";
@@ -58,7 +58,8 @@ export interface ProgressEvent {
| "assembling"
| "complete"
| "cancelled"
| "token";
| "token"
| "retry";
total?: number;
current?: number;
tag?: string;
@@ -70,6 +71,12 @@ export interface ProgressEvent {
message?: string;
/** Streaming token text (for type: "token"). */
text?: string;
/** Upcoming attempt number (1-based) for type: "retry". */
attempt?: number;
/** Milliseconds the pipeline will sleep before the upcoming attempt. */
waitMs?: number;
/** Short cause label (e.g. "HTTP 429", "TimeoutError"). */
reason?: string;
}
/**
@@ -299,6 +306,27 @@ export async function runPipeline(input: PipelineInput): Promise<PipelineResult
endpoint: route.provider === "ollama" ? settings.ollamaEndpoint : undefined,
useThinking,
signal: input.signal,
timeoutMs: settings.requestTimeoutMs,
maxRetries: settings.maxRetries ?? 3,
onRetry: (info) => {
pennyLog("info", settings.logLevel, "LLM retry", {
provider: route.provider,
model: route.model,
attempt: info.attempt,
waitMs: info.waitMs,
reason: info.reason,
});
onProgress?.({
type: "retry",
attempt: info.attempt,
waitMs: info.waitMs,
reason: info.reason,
provider: route.provider,
model: route.model,
current: i + 1,
total: toProcess.length,
});
},
onToken: onProgress ? (text: string) => {
onProgress({ type: "token", text, current: i + 1 });
} : undefined,
+17
View File
@@ -227,6 +227,23 @@ export class PennyProgressModal extends Modal {
}
break;
}
case "retry": {
const line = this.logEl.querySelector(
`[data-index="${event.current}"]`,
) as HTMLElement | null;
if (line) {
const attempt = event.attempt ?? 0;
const waitSec = Math.round((event.waitMs ?? 0) / 1000);
const reason = event.reason ?? "transient error";
// Replace the line content. Spinner is dropped for now; it returns
// on the next annotation-start cycle. Acceptable trade-off for a
// transient state.
line.setText(
` Retrying after ${reason} (attempt ${attempt}, waiting ${waitSec}s)...`,
);
}
break;
}
case "annotation-done": {
if (this.streamEl) {
this.streamEl.remove();
+78 -22
View File
@@ -24,7 +24,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest } from "./node-stream";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION = "2023-06-01";
@@ -86,6 +86,34 @@ export class AnthropicProvider implements LLMService {
throw new Error("Anthropic API key is required");
}
const maxRetries = request.maxRetries ?? 3;
// Track whether streaming has begun emitting tokens. Once any token has
// reached the user, a retry would re-emit them, so the retry helper
// skips further attempts via canStillRetry.
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest, apiKey), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
// Try streaming when onToken is provided. Uses Node's https module under
// the hood (streamRequest) which bypasses Obsidian's Electron CSP entirely.
// The globalThis.fetch guard is kept as a "is this a web-capable env"
@@ -100,7 +128,9 @@ export class AnthropicProvider implements LLMService {
return await this.completeStreaming(request, apiKey);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.message.startsWith("Anthropic API error")) throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Anthropic streaming error")) throw e;
// Transport-level failure (rare with Node https) -- fall through.
}
}
@@ -129,15 +159,18 @@ export class AnthropicProvider implements LLMService {
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
}
const response = await this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers,
body: JSON.stringify(body),
});
const response = await withTimeout(
this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers,
body: JSON.stringify(body),
}),
request.timeoutMs,
);
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text);
throw this.buildHttpError(response.status, response.text, response.headers);
}
return this.parseResponse(response.text, request.model);
@@ -180,6 +213,10 @@ export class AnthropicProvider implements LLMService {
const textParts: string[] = [];
let inputTokens = 0;
let outputTokens = 0;
// Captured by the SSE parser when an upstream `error` event arrives.
// Thrown after streamRequest resolves so the partial textParts cannot
// be returned as a "successful" CompletionResponse.
let streamError: Error | null = null;
const result = await streamRequest({
url: ANTHROPIC_API_URL,
@@ -187,6 +224,7 @@ export class AnthropicProvider implements LLMService {
headers,
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
// Parse SSE lines from buffer; keep incomplete last line for next chunk
@@ -201,6 +239,15 @@ export class AnthropicProvider implements LLMService {
try {
const event = JSON.parse(data);
// Upstream error event -- captured for throw after the stream ends.
// Anthropic shape: { type: "error", error: { type, message } }
if (event.type === "error") {
const errType = event.error?.type ?? "error";
const errMsg = event.error?.message ?? "unknown error";
streamError = new Error(`Anthropic streaming error [${errType}]: ${errMsg}`);
continue;
}
// Text delta -- the main output
if (event.type === "content_block_delta" && event.delta?.type === "text_delta") {
const text = event.delta.text;
@@ -224,9 +271,13 @@ export class AnthropicProvider implements LLMService {
},
});
// Upstream emitted an `error` SSE event mid-stream; surface it instead
// of returning the partial pre-error tokens as a successful result.
if (streamError) throw streamError;
// streamRequest returns non-2xx as data (not an error); inspect status here.
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText);
throw this.buildHttpError(result.status, result.fullText, result.headers);
}
return {
@@ -268,7 +319,7 @@ export class AnthropicProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text).message;
return this.buildHttpError(response.status, response.text, response.headers).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `Anthropic connection failed: ${message}`;
@@ -276,10 +327,15 @@ export class AnthropicProvider implements LLMService {
}
/**
* Build a descriptive Error from an Anthropic HTTP error response.
* Attempts to parse the Anthropic error JSON format; falls back to raw text.
* Build a typed HttpError from an Anthropic HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After. Attempts to
* parse the Anthropic error JSON format; falls back to raw text.
*/
private buildHttpError(status: number, responseText: string): Error {
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -292,13 +348,13 @@ export class AnthropicProvider implements LLMService {
detail = responseText;
}
if (status === 401) {
return new Error(`Anthropic API error (401): Invalid API key. ${detail}`);
}
if (status === 429) {
return new Error(`Anthropic API error (429): Rate limited. ${detail}`);
}
return new Error(`Anthropic API error (${status}): ${detail}`);
const baseMsg =
status === 401
? `Anthropic API error (401): Invalid API key. ${detail}`
: status === 429
? `Anthropic API error (429): Rate limited. ${detail}`
: `Anthropic API error (${status}): ${detail}`;
return new HttpError(status, headers, responseText, baseMsg);
}
/**
@@ -333,7 +389,7 @@ export class AnthropicProvider implements LLMService {
(block: Record<string, unknown>) => block.type === "text",
);
if (textBlock && typeof textBlock.text === "string") {
text = textBlock.text.trim();
text = textBlock.text;
}
}
+70 -24
View File
@@ -21,7 +21,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest } from "./node-stream";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const GOOGLE_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
@@ -71,6 +71,31 @@ export class GoogleProvider implements LLMService {
throw new Error("Google API key is required");
}
const maxRetries = request.maxRetries ?? 3;
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest, apiKey), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
// Stream via Node https (streamRequest) when onToken is set. AbortError
// and API errors must propagate; only true transport failures fall back.
if (request.onToken && typeof globalThis.fetch === "function") {
@@ -78,7 +103,9 @@ export class GoogleProvider implements LLMService {
return await this.completeStreaming(request, apiKey);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.message.startsWith("Google API error")) throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Google streaming error")) throw e;
// Transport-level failure -- fall through to httpFn.
}
}
@@ -103,17 +130,20 @@ export class GoogleProvider implements LLMService {
const url = `${GOOGLE_API_BASE}/${request.model}:generateContent?key=${apiKey}`;
const response = await this.httpFn({
url,
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
});
const response = await withTimeout(
this.httpFn({
url,
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
}),
request.timeoutMs,
);
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text);
throw this.buildHttpError(response.status, response.text, response.headers);
}
return this.parseResponse(response.text, request.model);
@@ -136,6 +166,8 @@ export class GoogleProvider implements LLMService {
let buffer = "";
const textParts: string[] = [];
// Captured by the SSE parser when an upstream `error` event arrives.
let streamError: Error | null = null;
const result = await streamRequest({
url,
@@ -143,6 +175,7 @@ export class GoogleProvider implements LLMService {
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
@@ -153,6 +186,13 @@ export class GoogleProvider implements LLMService {
const data = line.slice(6).trim();
try {
const event = JSON.parse(data);
// Upstream error event. Google shape: { error: { code, message, status } }
if (event.error) {
const errStatus = event.error?.status ?? event.error?.code ?? "error";
const errMsg = event.error?.message ?? String(event.error);
streamError = new Error(`Google streaming error [${errStatus}]: ${errMsg}`);
continue;
}
const parts = event.candidates?.[0]?.content?.parts;
if (Array.isArray(parts)) {
for (const part of parts) {
@@ -167,8 +207,10 @@ export class GoogleProvider implements LLMService {
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText);
throw this.buildHttpError(result.status, result.fullText, result.headers);
}
return {
@@ -216,7 +258,7 @@ export class GoogleProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text).message;
return this.buildHttpError(response.status, response.text, response.headers).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `Google connection failed: ${message}`;
@@ -224,10 +266,14 @@ export class GoogleProvider implements LLMService {
}
/**
* Build a descriptive Error from a Google HTTP error response.
* Attempts to parse the Google error JSON format; falls back to raw text.
* Build a typed HttpError from a Google HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
*/
private buildHttpError(status: number, responseText: string): Error {
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -240,13 +286,13 @@ export class GoogleProvider implements LLMService {
detail = responseText;
}
if (status === 401) {
return new Error(`Google API error (401): Invalid API key. ${detail}`);
}
if (status === 429) {
return new Error(`Google API error (429): Rate limited. ${detail}`);
}
return new Error(`Google API error (${status}): ${detail}`);
const baseMsg =
status === 401
? `Google API error (401): Invalid API key. ${detail}`
: status === 429
? `Google API error (429): Rate limited. ${detail}`
: `Google API error (${status}): ${detail}`;
return new HttpError(status, headers, responseText, baseMsg);
}
/**
@@ -282,7 +328,7 @@ export class GoogleProvider implements LLMService {
(part: Record<string, unknown>) => typeof part.text === "string",
);
if (textPart) {
text = textPart.text.trim();
text = textPart.text;
}
}
}
+253
View File
@@ -25,6 +25,13 @@ export interface StreamOptions {
headers: Record<string, string>;
body?: string;
signal?: AbortSignal;
/**
* Optional inactivity timeout in milliseconds. When the request goes this
* long without sending or receiving bytes, the request is destroyed and the
* promise rejects with `name === "TimeoutError"` (distinct from AbortError
* so callers can distinguish user-cancellation from server-hang).
*/
timeoutMs?: number;
/** Called for each raw chunk of response text as it arrives. */
onChunk: (text: string) => void;
}
@@ -32,12 +39,16 @@ export interface StreamOptions {
export interface StreamResult {
status: number;
fullText: string;
/** Lowercased response headers; multi-value headers joined with ", ". */
headers: Record<string, string>;
}
/**
* Issue an HTTP(S) request and stream the response body chunk-by-chunk.
*
* Throws an Error with `name === "AbortError"` if the signal aborts.
* Throws an Error with `name === "TimeoutError"` if `timeoutMs` is set and
* elapses without server activity.
* Rejects on network/DNS errors. A non-2xx status is NOT an error here --
* the caller inspects `result.status` and `result.fullText` to decide.
*/
@@ -72,6 +83,10 @@ export function streamRequest(opts: StreamOptions): Promise<StreamResult> {
const chunks: string[] = [];
let settled = false;
// Marker set by the timeout callback before destroying the request.
// Read in the error handler to disambiguate timeout from abort/transport errors,
// since req.destroy() emits the same ECONNRESET-shaped error in all three cases.
let timedOut = false;
const req = transport.request(requestOptions, (res: IncomingMessage) => {
res.setEncoding("utf8");
@@ -91,6 +106,7 @@ export function streamRequest(opts: StreamOptions): Promise<StreamResult> {
resolve({
status: res.statusCode ?? 0,
fullText: chunks.join(""),
headers: normalizeIncomingHeaders(res.headers),
});
});
@@ -104,6 +120,14 @@ export function streamRequest(opts: StreamOptions): Promise<StreamResult> {
req.on("error", (err: Error) => {
if (settled) return;
settled = true;
// Order matters: timeout must be checked before abort, since a timeout
// that fires while a signal is also pending could be misclassified.
if (timedOut) {
const timeoutErr = new Error(`Request timed out after ${opts.timeoutMs}ms`);
timeoutErr.name = "TimeoutError";
reject(timeoutErr);
return;
}
// When we call req.destroy() due to abort, Node emits ECONNRESET / socket
// hang up here. Translate to a clean AbortError so callers can detect it.
if (opts.signal?.aborted) {
@@ -123,6 +147,17 @@ export function streamRequest(opts: StreamOptions): Promise<StreamResult> {
};
opts.signal?.addEventListener("abort", onAbort);
// Wire the inactivity timeout. Node's req.setTimeout(ms, cb) fires cb after
// ms of no socket activity. We mark `timedOut` so the error handler can
// produce a TimeoutError instead of a generic transport error or AbortError.
if (opts.timeoutMs && opts.timeoutMs > 0) {
req.setTimeout(opts.timeoutMs, () => {
if (settled) return;
timedOut = true;
req.destroy(new Error("timeout"));
});
}
// Clean up the listener once we've resolved or rejected
const cleanup = () => {
opts.signal?.removeEventListener("abort", onAbort);
@@ -135,3 +170,221 @@ export function streamRequest(opts: StreamOptions): Promise<StreamResult> {
req.end();
});
}
/**
* Wrap an arbitrary Promise with a TimeoutError fallback.
*
* Used for the non-streaming provider path, which goes through Obsidian's
* `requestUrl` (no native AbortSignal support). The underlying request keeps
* running in the background after the timeout fires -- there is no way to
* cancel it -- but the promise the caller awaits rejects on time, freeing
* the UI. Node/Electron eventually GCs the orphaned request.
*
* For the streaming path, prefer `streamRequest({ timeoutMs })` which can
* actually destroy the in-flight request via `req.destroy()`.
*
* @param promise The promise to race against the timeout.
* @param timeoutMs Inactivity ceiling in milliseconds. If undefined or <=0,
* the original promise is returned unchanged (no timeout).
* @returns A promise that resolves with the original value, or rejects
* with `name === "TimeoutError"` after `timeoutMs` elapses.
*/
export function withTimeout<T>(promise: Promise<T>, timeoutMs?: number): Promise<T> {
if (!timeoutMs || timeoutMs <= 0) return promise;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
const err = new Error(`Request timed out after ${timeoutMs}ms`);
err.name = "TimeoutError";
reject(err);
}, timeoutMs);
promise.then(
(value) => { clearTimeout(timer); resolve(value); },
(err) => { clearTimeout(timer); reject(err); },
);
});
}
/**
* Typed HTTP error carrying the response status, headers, and body.
*
* Thrown by each provider's `buildHttpError` so the retry helper can
* read `Retry-After` and decide whether the failure is retriable.
*/
export class HttpError extends Error {
constructor(
public readonly status: number,
public readonly headers: Record<string, string>,
public readonly body: string,
message?: string,
) {
super(message ?? `HTTP ${status}`);
this.name = "HttpError";
}
}
/**
* Classify a thrown error as retriable or not for `withRetry`.
*
* Retriable: HttpError with status 429/408/5xx, OR transport errors
* (anything that is not HttpError, AbortError, or TimeoutError).
* Non-retriable: AbortError (user cancellation), TimeoutError (the
* user-set inactivity ceiling fired -- retrying would double the wait),
* and HttpError with non-retriable status (other 4xx).
*/
export function isRetriable(err: unknown): boolean {
if (err instanceof Error) {
if (err.name === "AbortError" || err.name === "TimeoutError") return false;
if (err instanceof HttpError) {
return err.status === 429 || err.status === 408 || err.status >= 500;
}
}
return true;
}
const MAX_BACKOFF_MS = 60_000;
/**
* Parse an RFC 7231 `Retry-After` header value into milliseconds.
* Accepts delta-seconds ("30") or HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT").
* Returns null when the value is missing or unparseable.
* HTTP-dates in the past are clamped to 0 ms (retry immediately).
*/
export function parseRetryAfter(value: string | undefined): number | null {
if (value === undefined || value === "") return null;
const trimmed = value.trim();
if (/^\d+$/.test(trimmed)) {
const seconds = Number(trimmed);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
return null;
}
// Require at least one alphabetic character before trying Date.parse so that
// bare-number/sign inputs like "-5" don't sneak through as year-style dates.
if (!/[A-Za-z]/.test(trimmed)) return null;
const ts = Date.parse(trimmed);
if (Number.isFinite(ts)) {
return Math.max(0, ts - Date.now());
}
return null;
}
/**
* Compute the wait between retry attempts.
*
* If `err` is an `HttpError` with a parseable `Retry-After` header, use
* that (capped at 60s). Otherwise, full-jitter exponential backoff:
* delay = random(0, min(60_000, 1000 * 2^attempt))
*
* `attempt` is 0-indexed across retries:
* attempt 0 -> base 1s (wait before retry #1)
* attempt 1 -> base 2s (wait before retry #2)
* attempt 2 -> base 4s (wait before retry #3)
* attempt N -> base min(60s, 2^N s)
*/
export function backoffDelay(err: unknown, attempt: number): number {
if (err instanceof HttpError) {
const parsed = parseRetryAfter(err.headers["retry-after"]);
if (parsed !== null) return Math.min(parsed, MAX_BACKOFF_MS);
}
const base = Math.min(MAX_BACKOFF_MS, 1000 * Math.pow(2, attempt));
return Math.floor(Math.random() * base);
}
/** Information passed to `onRetry` before the helper sleeps and re-attempts. */
export interface RetryInfo {
/** 1-based count of the upcoming attempt. First retry is `attempt: 2`. */
attempt: number;
/** Milliseconds the helper will sleep before the upcoming attempt. */
waitMs: number;
/** Short label describing the cause (e.g. "HTTP 429", "TimeoutError", "transport"). */
reason: string;
}
export interface RetryOpts {
/** Total attempts, including the initial call. `maxAttempts: 3` allows up to 2 retries. */
maxAttempts: number;
/** Optional gate. If returns false, retries are skipped (used to stop after tokens stream). */
canStillRetry?: () => boolean;
/** Optional cancellation signal. Aborts both in-flight attempts and inter-attempt sleeps. */
signal?: AbortSignal;
/** Optional callback invoked before each retry sleep (for logging / progress UI). */
onRetry?: (info: RetryInfo) => void;
}
/**
* Retry an async function up to `maxAttempts` times when the thrown error
* is classified retriable by `isRetriable`. Sleeps between attempts using
* `backoffDelay` (Retry-After honored when present; otherwise full-jitter
* exponential). Cancellation-aware: an aborted signal interrupts both
* in-flight attempts and inter-attempt sleeps.
*/
export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOpts): Promise<T> {
if (opts.signal?.aborted) throw makeAbortError();
let lastErr: unknown;
for (let attempt = 0; attempt < opts.maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
const isFinalAttempt = attempt === opts.maxAttempts - 1;
if (isFinalAttempt) throw err;
if (!isRetriable(err)) throw err;
if (opts.canStillRetry && !opts.canStillRetry()) throw err;
const waitMs = backoffDelay(err, attempt);
const reason = describeRetryReason(err);
opts.onRetry?.({ attempt: attempt + 2, waitMs, reason });
await sleepCancellable(waitMs, opts.signal);
}
}
throw lastErr;
}
function makeAbortError(): Error {
const err = new Error("Request aborted");
err.name = "AbortError";
return err;
}
function describeRetryReason(err: unknown): string {
if (err instanceof HttpError) return `HTTP ${err.status}`;
if (err instanceof Error) return err.name === "Error" ? "transport" : err.name;
return "transport";
}
async function sleepCancellable(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) return;
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(makeAbortError());
return;
}
const onAbort = (): void => {
clearTimeout(timer);
reject(makeAbortError());
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}
/**
* Flatten Node's IncomingHttpHeaders shape (string | string[] | undefined)
* into a lowercased Record<string,string> for downstream consumers (the
* retry helper, providers reading Retry-After, etc.).
*/
function normalizeIncomingHeaders(
raw: NodeJS.Dict<string | string[]>,
): Record<string, string> {
const out: Record<string, string> = {};
for (const [name, value] of Object.entries(raw)) {
if (value === undefined) continue;
out[name.toLowerCase()] = Array.isArray(value) ? value.join(", ") : value;
}
return out;
}
+69 -15
View File
@@ -28,7 +28,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest } from "./node-stream";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434";
@@ -98,6 +98,28 @@ export class OllamaProvider implements LLMService {
}
async complete(request: CompletionRequest): Promise<CompletionResponse> {
const maxRetries = request.maxRetries ?? 3;
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(request: CompletionRequest): Promise<CompletionResponse> {
const endpoint = request.endpoint ?? DEFAULT_OLLAMA_ENDPOINT;
// Stream via Node http/https (streamRequest) when onToken is set. AbortError
@@ -108,7 +130,9 @@ export class OllamaProvider implements LLMService {
return await this.completeStreaming(request, endpoint);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.message.startsWith("Ollama error")) throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Ollama streaming error")) throw e;
// Transport-level failure (ECONNREFUSED, etc.) -- fall through to httpFn,
// which will produce the usual "Is Ollama running?" diagnostic.
}
@@ -133,12 +157,15 @@ export class OllamaProvider implements LLMService {
let response;
try {
response = await this.httpFn({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
});
response = await withTimeout(
this.httpFn({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
}),
request.timeoutMs,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
@@ -148,7 +175,7 @@ export class OllamaProvider implements LLMService {
}
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, endpoint);
throw this.buildHttpError(response.status, response.text, endpoint, response.headers);
}
return this.parseResponse(response.text, request.model);
@@ -175,6 +202,8 @@ export class OllamaProvider implements LLMService {
let buffer = "";
const textParts: string[] = [];
// Captured by the SSE parser when an upstream `error` event arrives.
let streamError: Error | null = null;
const result = await streamRequest({
url: `${endpoint}/v1/chat/completions`,
@@ -182,6 +211,7 @@ export class OllamaProvider implements LLMService {
headers,
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
@@ -193,6 +223,22 @@ export class OllamaProvider implements LLMService {
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data);
// Ollama emits errors in two shapes:
// { "error": "context deadline exceeded" } -- bare string
// { "error": { "message": "...", "type": "..." } } -- OpenAI-compat object
if (event.error) {
let errMsg: string;
let errType: string;
if (typeof event.error === "string") {
errMsg = event.error;
errType = "error";
} else {
errMsg = event.error?.message ?? String(event.error);
errType = event.error?.type ?? "error";
}
streamError = new Error(`Ollama streaming error [${errType}]: ${errMsg}`);
continue;
}
const delta = event.choices?.[0]?.delta?.content;
if (typeof delta === "string") {
textParts.push(delta);
@@ -203,8 +249,10 @@ export class OllamaProvider implements LLMService {
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, endpoint);
throw this.buildHttpError(result.status, result.fullText, endpoint, result.headers);
}
return {
@@ -232,7 +280,7 @@ export class OllamaProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, endpoint).message;
return this.buildHttpError(response.status, response.text, endpoint, response.headers).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
@@ -243,9 +291,15 @@ export class OllamaProvider implements LLMService {
}
/**
* Build a descriptive Error from an Ollama HTTP error response.
* Build a typed HttpError from an Ollama HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
*/
private buildHttpError(status: number, responseText: string, _endpoint: string): Error {
private buildHttpError(
status: number,
responseText: string,
_endpoint: string,
headers: Record<string, string> = {},
): HttpError {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -253,7 +307,7 @@ export class OllamaProvider implements LLMService {
} catch {
detail = responseText;
}
return new Error(`Ollama error (${status}): ${detail}`);
return new HttpError(status, headers, responseText, `Ollama error (${status}): ${detail}`);
}
/**
@@ -285,7 +339,7 @@ export class OllamaProvider implements LLMService {
if (data && Array.isArray(data.choices) && data.choices.length > 0) {
const choice = data.choices[0];
if (choice.message && typeof choice.message.content === "string") {
text = choice.message.content.trim();
text = choice.message.content;
}
}
+73 -25
View File
@@ -21,7 +21,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest } from "./node-stream";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
const OPENAI_API_URL = "https://api.openai.com/v1/chat/completions";
const OPENAI_MODELS_URL = "https://api.openai.com/v1/models";
@@ -82,6 +82,31 @@ export class OpenAIProvider implements LLMService {
throw new Error("OpenAI API key is required");
}
const maxRetries = request.maxRetries ?? 3;
let started = false;
const wrappedRequest: CompletionRequest = request.onToken
? {
...request,
onToken: (text: string) => {
started = true;
request.onToken!(text);
},
}
: request;
return withRetry(() => this.doOneAttempt(wrappedRequest, apiKey), {
maxAttempts: maxRetries + 1,
canStillRetry: () => !started,
signal: request.signal,
onRetry: request.onRetry,
});
}
private async doOneAttempt(
request: CompletionRequest,
apiKey: string,
): Promise<CompletionResponse> {
// Stream via Node https (streamRequest) when onToken is set. AbortError
// and API errors must propagate; only true transport failures fall back.
if (request.onToken && typeof globalThis.fetch === "function") {
@@ -89,7 +114,9 @@ export class OpenAIProvider implements LLMService {
return await this.completeStreaming(request, apiKey);
} catch (e) {
if (e instanceof Error && e.name === "AbortError") throw e;
if (e instanceof Error && e.message.startsWith("OpenAI API error")) throw e;
if (e instanceof Error && e.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("OpenAI streaming error")) throw e;
// Transport-level failure -- fall through to httpFn.
}
}
@@ -103,18 +130,21 @@ export class OpenAIProvider implements LLMService {
max_tokens: request.maxTokens,
};
const response = await this.httpFn({
url: OPENAI_API_URL,
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
const response = await withTimeout(
this.httpFn({
url: OPENAI_API_URL,
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
}),
request.timeoutMs,
);
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text);
throw this.buildHttpError(response.status, response.text, response.headers);
}
return this.parseResponse(response.text, request.model);
@@ -136,6 +166,10 @@ export class OpenAIProvider implements LLMService {
let buffer = "";
const textParts: string[] = [];
// Captured by the SSE parser when an upstream `error` event arrives.
// Thrown after streamRequest resolves so partial pre-error tokens can't
// surface as a successful CompletionResponse.
let streamError: Error | null = null;
const result = await streamRequest({
url: OPENAI_API_URL,
@@ -143,6 +177,7 @@ export class OpenAIProvider implements LLMService {
headers: { "Authorization": `Bearer ${apiKey}`, "content-type": "application/json" },
body: JSON.stringify(body),
signal: request.signal,
timeoutMs: request.timeoutMs,
onChunk: (chunk: string) => {
buffer += chunk;
const lines = buffer.split("\n");
@@ -154,6 +189,13 @@ export class OpenAIProvider implements LLMService {
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data);
// Upstream error event. OpenAI shape: { error: { message, type, ... } }
if (event.error) {
const errType = event.error?.type ?? "error";
const errMsg = event.error?.message ?? String(event.error);
streamError = new Error(`OpenAI streaming error [${errType}]: ${errMsg}`);
continue;
}
const delta = event.choices?.[0]?.delta?.content;
if (typeof delta === "string") {
textParts.push(delta);
@@ -164,8 +206,10 @@ export class OpenAIProvider implements LLMService {
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText);
throw this.buildHttpError(result.status, result.fullText, result.headers);
}
return {
@@ -198,7 +242,7 @@ export class OpenAIProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text).message;
return this.buildHttpError(response.status, response.text, response.headers).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `OpenAI connection failed: ${message}`;
@@ -206,10 +250,14 @@ export class OpenAIProvider implements LLMService {
}
/**
* Build a descriptive Error from an OpenAI HTTP error response.
* Attempts to parse the OpenAI error JSON format; falls back to raw text.
* Build a typed HttpError from an OpenAI HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
*/
private buildHttpError(status: number, responseText: string): Error {
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -222,13 +270,13 @@ export class OpenAIProvider implements LLMService {
detail = responseText;
}
if (status === 401) {
return new Error(`OpenAI API error (401): Invalid API key. ${detail}`);
}
if (status === 429) {
return new Error(`OpenAI API error (429): Rate limited. ${detail}`);
}
return new Error(`OpenAI API error (${status}): ${detail}`);
const baseMsg =
status === 401
? `OpenAI API error (401): Invalid API key. ${detail}`
: status === 429
? `OpenAI API error (429): Rate limited. ${detail}`
: `OpenAI API error (${status}): ${detail}`;
return new HttpError(status, headers, responseText, baseMsg);
}
/**
@@ -261,7 +309,7 @@ export class OpenAIProvider implements LLMService {
if (data && Array.isArray(data.choices) && data.choices.length > 0) {
const choice = data.choices[0];
if (choice.message && typeof choice.message.content === "string") {
text = choice.message.content.trim();
text = choice.message.content;
}
}
+19
View File
@@ -85,6 +85,25 @@ export interface CompletionRequest {
* `AbortError` (err.name === "AbortError").
*/
signal?: AbortSignal;
/**
* Optional inactivity timeout in milliseconds. If the provider's HTTP call
* goes this long without progress, the call is aborted and the request
* rejects with `name === "TimeoutError"` (distinct from AbortError so the
* pipeline can surface "Request timed out" in review notes / logs).
*/
timeoutMs?: number;
/**
* Optional cap on retries beyond the initial call. `maxRetries: 3` means up
* to 4 total HTTP attempts (1 initial + 3 retries). Defaults to 3 when
* undefined. Set to 0 to disable retry entirely.
*/
maxRetries?: number;
/**
* Optional callback invoked before each retry sleep. The pipeline forwards
* this to a `ProgressEvent("retry", ...)` so the modal can render status.
* Providers themselves never construct ProgressEvents (Obsidian-free).
*/
onRetry?: (info: { attempt: number; waitMs: number; reason: string }) => void;
}
/** Response from an LLM provider. The `text` field contains the revised prose. */
+42
View File
@@ -864,6 +864,48 @@ export class PennySettingTab extends PluginSettingTab {
})
);
new Setting(details)
.setName("Request timeout (seconds)")
.setDesc(
"Maximum inactivity time per LLM call before PENNY gives up and reports a timeout error. Prevents the modal from hanging forever on a stalled connection. Default: 300 (5 minutes). Set to 0 to disable."
)
.addText((text) =>
text
.setPlaceholder("300")
.setValue(String(Math.round(this.plugin.settings.requestTimeoutMs / 1000)))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 0) {
new Notice("PENNY: Request timeout must be a non-negative number of seconds.");
text.setValue(String(Math.round(this.plugin.settings.requestTimeoutMs / 1000)));
return;
}
this.plugin.settings.requestTimeoutMs = parsed * 1000;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Max retries on transient errors")
.setDesc(
"Number of retries on top of the initial LLM call when the API returns 429, 408, 5xx, or a transport error. Default: 3. Set to 0 to disable retry entirely. Honors Retry-After when present, otherwise full-jitter exponential backoff capped at 60 seconds per attempt."
)
.addText((text) =>
text
.setPlaceholder("3")
.setValue(String(this.plugin.settings.maxRetries))
.onChange(async (value) => {
const parsed = parseInt(value, 10);
if (isNaN(parsed) || parsed < 0 || parsed > 10) {
new Notice("PENNY: Max retries must be an integer between 0 and 10.");
text.setValue(String(this.plugin.settings.maxRetries));
return;
}
this.plugin.settings.maxRetries = parsed;
await this.plugin.saveSettings();
})
);
new Setting(details)
.setName("Show progress modal")
.setDesc(
+14
View File
@@ -72,6 +72,18 @@ export interface PennySettings {
contextBudget: number;
/** Max tokens the LLM may generate per revision response. */
maxTokens: number;
/**
* Per-call inactivity timeout in milliseconds. Prevents the modal from
* hanging forever on a stalled connection. Default 300_000 (5 minutes) to
* accommodate slow Opus calls with long context. Set to 0 to disable.
*/
requestTimeoutMs: number;
/**
* Maximum retries on top of the initial LLM call. `maxRetries: 3` means up
* to 4 total attempts. Set to 0 to disable retry. Applies to 429, 408, 5xx,
* and transport errors. Default 3.
*/
maxRetries: number;
// Project structure (paths relative to vault root)
/** Folder containing chapter draft files, organized by book subfolder. */
@@ -252,6 +264,8 @@ export const DEFAULT_SETTINGS: PennySettings = {
contextBudget: 800000,
maxTokens: 16000,
requestTimeoutMs: 300000,
maxRetries: 3,
draftsFolder: "04-drafts",
styleGuide: "",
+38 -8
View File
@@ -58,20 +58,50 @@ function splitSentences(text: string): string[] {
}
/**
* Extract dialogue word count from prose.
* Dialogue = text enclosed in quotation marks (straight or curly).
* Patterns that match dialogue spans across the supported quote styles
* (issue #5):
*
* - Straight double quotes (`"..."`)
* - Paired typographic double quotes (U+201C ... U+201D)
* - Single-quoted dialogue (British style `'...'`). Opener anchored at line
* start or after whitespace; closer followed by sentence punctuation,
* whitespace, or end-of-line. Apostrophes inside contractions don't qualify
* because they are mid-word, not at quote boundaries.
* - Paired typographic single quotes (U+2018 ... U+2019)
*
* Each pattern captures the dialogue text in group 1.
*/
const DIALOGUE_PATTERNS: readonly RegExp[] = [
/"([^"\n]+?)"/g,
/\u201C([^\u201D\n]+?)\u201D/g,
/(?:^|\s)'([^'\n]+?)'(?=[\s.!?,;:]|$)/gm,
/\u2018([^\u2019\n]+?)\u2019/g,
];
/**
* Extract dialogue word count from prose. Counts spoken text across all
* supported quote styles.
*/
function extractDialogueWords(text: string): number {
const dialogueRe = /[""\u201C](.*?)[""\u201D]/g;
let count = 0;
let m: RegExpExecArray | null;
while ((m = dialogueRe.exec(text)) !== null) {
const words = m[1].trim().split(/\s+/);
count += words.filter((w) => w.length > 0).length;
for (const re of DIALOGUE_PATTERNS) {
for (const match of text.matchAll(re)) {
const words = match[1].trim().split(/\s+/);
count += words.filter((w) => w.length > 0).length;
}
}
return count;
}
/** Strip dialogue spans across all supported quote styles, leaving narration. */
function stripDialogue(text: string): string {
let out = text;
for (const re of DIALOGUE_PATTERNS) {
out = out.replace(re, " ");
}
return out;
}
function totalWords(text: string): number {
const trimmed = text.trim();
if (trimmed.length === 0) return 0;
@@ -92,7 +122,7 @@ export function checkVoiceCompliance(prose: string): VoiceComplianceResult {
// --- Long narration sentences ---
// Remove dialogue from the text to isolate narration.
const narration = prose.replace(/[""\u201C].*?[""\u201D]/g, "");
const narration = stripDialogue(prose);
const sentences = splitSentences(narration);
const longNarrationSentences: string[] = [];
for (const sentence of sentences) {
+24
View File
@@ -383,6 +383,30 @@ describe("detectCharacters", () => {
expect(chars).toHaveLength(0);
});
it("captures two-word names (#33)", () => {
const content = '"Run!" Mary Jane shouted.';
const chars = detectCharacters(content, "");
expect(chars).toContain("mary jane");
});
it("captures apostrophe names (#33)", () => {
const content = '"Get out," O\'Brien snapped.';
const chars = detectCharacters(content, "");
expect(chars).toContain("o'brien");
});
it("captures hyphenated names (#33)", () => {
const content = '"Hi," Mary-Anne whispered.';
const chars = detectCharacters(content, "");
expect(chars).toContain("mary-anne");
});
it("captures all-caps shouted attribution (#33)", () => {
const content = '"WAIT!" MARY shouted.';
const chars = detectCharacters(content, "");
expect(chars).toContain("mary");
});
it("handles comma-separated focus field", () => {
const chars = detectCharacters("", "protagonist, tim, rabbit");
expect(chars).toContain("protagonist");
+122
View File
@@ -0,0 +1,122 @@
/**
* PENNY - Pipeline Retry Event Tests
*
* Verifies that the pipeline forwards a provider-emitted onRetry callback
* to a ProgressEvent("retry", ...) for the modal, and threads
* settings.maxRetries through to the provider.
*/
import { describe, it, expect } from "vitest";
import { runPipeline } from "../src/pipeline";
import type { PipelineInput, ProgressEvent } from "../src/pipeline";
import type { PennySettings } from "../src/types";
import { DEFAULT_SETTINGS, DEFAULT_SYSTEM_PROMPT } from "../src/types";
import type { ContextFiles } from "../src/context";
import type { CompletionRequest, CompletionResponse } from "../src/providers/service";
const TEST_CHAPTER = `---
type: chapter
book: 1
chapter: 5
title: Retry Test
pov: protagonist
status: draft
wordcount: 50
focus: protagonist
---
# Book 1, Chapter 5
<!-- Prose begins below -->
The lab was quiet at 3am.
%% REWRITE: Add sensory detail %%
`;
const TEST_SETTINGS: PennySettings = {
...DEFAULT_SETTINGS,
systemPromptTemplate: DEFAULT_SYSTEM_PROMPT,
proseMarker: "<!-- Prose begins below -->",
};
function makeContextFiles(chapter: string): ContextFiles {
return {
chapter,
voiceTests: "",
styleGuide: "",
outline: "",
characters: [],
wiki: [],
seriesBible: "",
themes: "",
};
}
describe("runPipeline retry event forwarding", () => {
it("threads settings.maxRetries into the provider request", async () => {
let observedMaxRetries: number | undefined;
const provider = {
name: "stub",
requiresApiKey: false,
tokenMultiplier: 1.0,
async complete(req: CompletionRequest): Promise<CompletionResponse> {
observedMaxRetries = req.maxRetries;
return { text: "revised", usage: {}, model: req.model, provider: "stub" };
},
};
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: { ...TEST_SETTINGS, maxRetries: 5 },
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => provider,
};
await runPipeline(input);
expect(observedMaxRetries).toBe(5);
});
it("emits a ProgressEvent('retry', ...) when the provider invokes onRetry", async () => {
// The provider simulates the inner withRetry: invokes onRetry then returns
// success on the same call (the mock represents the wrapped result).
const provider = {
name: "retrying",
requiresApiKey: false,
tokenMultiplier: 1.0,
async complete(req: CompletionRequest): Promise<CompletionResponse> {
req.onRetry?.({ attempt: 2, waitMs: 100, reason: "HTTP 429" });
return { text: "revised", usage: {}, model: req.model, provider: "retrying" };
},
};
const events: ProgressEvent[] = [];
const input: PipelineInput = {
content: TEST_CHAPTER,
versionContent: "1",
stateContent: "",
contextFiles: makeContextFiles(TEST_CHAPTER),
settings: TEST_SETTINGS,
chapterId: "ch-05",
bookId: "book-1",
getProvider: () => provider,
onProgress: (e) => events.push(e),
};
await runPipeline(input);
const retryEvents = events.filter((e) => e.type === "retry");
expect(retryEvents).toHaveLength(1);
expect(retryEvents[0]).toMatchObject({
type: "retry",
attempt: 2,
waitMs: 100,
reason: "HTTP 429",
current: 1,
total: 1,
});
});
});
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect, vi } from "vitest";
import { AnthropicProvider } from "../../src/providers/anthropic";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
content: [{ type: "text", text: "revised" }],
usage: { input_tokens: 5, output_tokens: 3 },
};
describe("AnthropicProvider retry", () => {
it("retries 429 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(429, { "retry-after": "0" }, { error: { message: "rate" } });
return jsonResponse(200, {}, successBody);
});
const provider = new AnthropicProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 401", async () => {
const httpFn = vi.fn(async () => jsonResponse(401, {}, { error: { message: "auth" } }));
const provider = new AnthropicProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 3,
}),
).rejects.toThrow(/401/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("calls onRetry between attempts", async () => {
let n = 0;
const httpFn = async () => {
n += 1;
if (n < 2) return jsonResponse(503, { "retry-after": "0" }, { error: { message: "svc" } });
return jsonResponse(200, {}, successBody);
};
const onRetry = vi.fn();
const provider = new AnthropicProvider(httpFn);
await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 3, onRetry,
});
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 503" });
});
it("maxRetries: 0 disables retry (single attempt only)", async () => {
const httpFn = vi.fn(async () => jsonResponse(503, {}, { error: { message: "svc" } }));
const provider = new AnthropicProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "claude-haiku-4-5",
maxTokens: 100, apiKey: "k", maxRetries: 0,
}),
).rejects.toThrow(/503/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
});
+18 -5
View File
@@ -296,6 +296,18 @@ describe("AnthropicProvider", () => {
expect(body.model).toBe("claude-opus-4-6");
});
it("preserves leading and trailing whitespace in the response (#16)", async () => {
// Authors rely on intentional whitespace as part of vertical rhythm.
// Provider parseResponse must not silently .trim() the model output.
const responseText = anthropicResponse("\n the door slammed. \n");
const { fn } = mockHttp({ text: responseText });
const provider = new AnthropicProvider(fn);
const result = await provider.complete(makeRequest());
expect(result.text).toBe("\n the door slammed. \n");
});
it("throws on 400 response with error message", async () => {
const errorResponse = JSON.stringify({
error: { type: "invalid_request_error", message: "max_tokens must be positive" },
@@ -326,8 +338,9 @@ describe("AnthropicProvider", () => {
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new AnthropicProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
@@ -337,15 +350,15 @@ describe("AnthropicProvider", () => {
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new AnthropicProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Anthropic API error (500)");
await expect(provider.complete(makeRequest())).rejects.toThrow("Internal server error");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Anthropic API error (500)");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Internal server error");
});
it("throws with raw text when error response is not valid JSON", async () => {
const { fn } = mockHttp({ status: 502, text: "Bad Gateway" });
const provider = new AnthropicProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Anthropic API error (502): Bad Gateway");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Anthropic API error (502): Bad Gateway");
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { GoogleProvider } from "../../src/providers/google";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
candidates: [{ content: { parts: [{ text: "revised" }] } }],
usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 3 },
};
describe("GoogleProvider retry", () => {
it("retries 429 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(429, { "retry-after": "0" }, { error: { message: "rate" } });
return jsonResponse(200, {}, successBody);
});
const provider = new GoogleProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gemini-2.0-flash",
maxTokens: 100, apiKey: "k", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 401", async () => {
const httpFn = vi.fn(async () => jsonResponse(401, {}, { error: { message: "auth" } }));
const provider = new GoogleProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gemini-2.0-flash",
maxTokens: 100, apiKey: "k", maxRetries: 3,
}),
).rejects.toThrow(/401/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("calls onRetry between attempts", async () => {
let n = 0;
const httpFn = async () => {
n += 1;
if (n < 2) return jsonResponse(503, { "retry-after": "0" }, { error: { message: "svc" } });
return jsonResponse(200, {}, successBody);
};
const onRetry = vi.fn();
const provider = new GoogleProvider(httpFn);
await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gemini-2.0-flash",
maxTokens: 100, apiKey: "k", maxRetries: 3, onRetry,
});
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 503" });
});
});
+13 -12
View File
@@ -52,7 +52,7 @@ function makeRequest(overrides: Partial<CompletionRequest> = {}): CompletionRequ
userPrompt: "Rewrite this passage with more tension.",
model: "gemini-2.5-flash",
maxTokens: 4096,
apiKey: "AIzaSy-test-key-123",
apiKey: "mock-google-test-key-123",
...overrides,
};
}
@@ -124,7 +124,7 @@ describe("GoogleProvider", () => {
await provider.complete(makeRequest());
expect(calls[0].url).toBe(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=AIzaSy-test-key-123"
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=mock-google-test-key-123"
);
});
@@ -228,8 +228,9 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
@@ -239,15 +240,15 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Google API error (500)");
await expect(provider.complete(makeRequest())).rejects.toThrow("Internal error");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Google API error (500)");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Internal error");
});
it("throws with raw text when error response is not valid JSON", async () => {
const { fn } = mockHttp({ status: 502, text: "Bad Gateway" });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Google API error (502): Bad Gateway");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Google API error (502): Bad Gateway");
});
});
@@ -344,7 +345,7 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 200, text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "AIzaSy-valid" });
const result = await provider.testConnection({ apiKey: "mock-google-valid" });
expect(result).toBeNull();
});
@@ -360,7 +361,7 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 401, text: '{"error":{"message":"invalid key"}}' });
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "AIzaSy-bad" });
const result = await provider.testConnection({ apiKey: "mock-google-bad" });
expect(result).toContain("401");
});
@@ -370,7 +371,7 @@ describe("GoogleProvider", () => {
};
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "AIzaSy-key" });
const result = await provider.testConnection({ apiKey: "mock-google-key" });
expect(result).toContain("Network error");
});
@@ -379,10 +380,10 @@ describe("GoogleProvider", () => {
const { fn, calls } = mockHttp({ status: 200, text: responseText });
const provider = new GoogleProvider(fn);
await provider.testConnection({ apiKey: "AIzaSy-test" });
await provider.testConnection({ apiKey: "mock-google-test" });
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain("key=AIzaSy-test");
expect(calls[0].url).toContain("key=mock-google-test");
expect(calls[0].url).toContain("gemini-2.0-flash:generateContent");
const body = JSON.parse(calls[0].body!);
expect(body.generationConfig.maxOutputTokens).toBe(16);
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { createRegistry } from "../../src/providers";
import type { HttpFn } from "../../src/providers/service";
describe("createRegistry", () => {
// Stub HttpFn — never called by registry construction; tests only assert wiring.
const stubHttp: HttpFn = async () => ({
status: 200,
headers: {},
text: "{}",
json: {},
});
it("returns a registry pre-populated with all built-in providers", () => {
const reg = createRegistry(stubHttp);
const names = reg.getNames().sort();
expect(names).toEqual(["anthropic", "google", "ollama", "openai"]);
});
it("each registered provider implements the LLMService interface", () => {
const reg = createRegistry(stubHttp);
for (const provider of reg.getAll()) {
expect(typeof provider.name).toBe("string");
expect(typeof provider.requiresApiKey).toBe("boolean");
expect(typeof provider.complete).toBe("function");
expect(typeof provider.estimateTokens).toBe("function");
expect(typeof provider.testConnection).toBe("function");
expect(typeof provider.getModels).toBe("function");
}
});
it("Anthropic, Google, OpenAI require API keys; Ollama does not", () => {
const reg = createRegistry(stubHttp);
expect(reg.get("anthropic")?.requiresApiKey).toBe(true);
expect(reg.get("google")?.requiresApiKey).toBe(true);
expect(reg.get("openai")?.requiresApiKey).toBe(true);
expect(reg.get("ollama")?.requiresApiKey).toBe(false);
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { OllamaProvider } from "../../src/providers/ollama";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
choices: [{ message: { content: "revised" } }],
usage: { prompt_tokens: 5, completion_tokens: 3 },
};
describe("OllamaProvider retry", () => {
it("retries 503 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(503, { "retry-after": "0" }, { error: "loading model" });
return jsonResponse(200, {}, successBody);
});
const provider = new OllamaProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "llama3.2",
maxTokens: 100, endpoint: "http://localhost:11434", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 404 (model not found)", async () => {
const httpFn = vi.fn(async () => jsonResponse(404, {}, { error: "model not found" }));
const provider = new OllamaProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "missing-model",
maxTokens: 100, endpoint: "http://localhost:11434", maxRetries: 3,
}),
).rejects.toThrow(/404/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("maxRetries: 0 disables retry on 503", async () => {
const httpFn = vi.fn(async () => jsonResponse(503, {}, { error: "busy" }));
const provider = new OllamaProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "llama3.2",
maxTokens: 100, endpoint: "http://localhost:11434", maxRetries: 0,
}),
).rejects.toThrow(/503/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
});
+16 -5
View File
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { OllamaProvider } from "../../src/providers/ollama";
import type { HttpFn, HttpResponse, CompletionRequest } from "../../src/providers/service";
@@ -278,18 +278,29 @@ describe("OllamaProvider", () => {
const { fn } = mockHttp({ status: 500, text: "Internal Server Error" });
const provider = new OllamaProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Ollama error (500): Internal Server Error");
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Ollama error (500): Internal Server Error");
});
it("throws connection refused message when Ollama is not running", async () => {
let attempts = 0;
const fn: HttpFn = async () => {
attempts++;
throw new Error("ECONNREFUSED");
};
const provider = new OllamaProvider(fn);
// Zero the retry jitter: the default 3 retries back off up to 1+2+4 s at random,
// which intermittently exceeded the 5 s test timeout.
const random = vi.spyOn(Math, "random").mockReturnValue(0);
await expect(
provider.complete(makeRequest()),
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
try {
await expect(
provider.complete(makeRequest()),
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
expect(attempts).toBe(4);
} finally {
random.mockRestore();
}
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { OpenAIProvider } from "../../src/providers/openai";
function jsonResponse(status: number, headers: Record<string, string>, body: unknown) {
return {
status,
headers,
text: typeof body === "string" ? body : JSON.stringify(body),
json: typeof body === "string" ? undefined : body,
};
}
const successBody = {
choices: [{ message: { content: "revised" } }],
usage: { prompt_tokens: 5, completion_tokens: 3 },
};
describe("OpenAIProvider retry", () => {
it("retries 429 then succeeds", async () => {
let n = 0;
const httpFn = vi.fn(async () => {
n += 1;
if (n < 3) return jsonResponse(429, { "retry-after": "0" }, { error: { message: "rate" } });
return jsonResponse(200, {}, successBody);
});
const provider = new OpenAIProvider(httpFn);
const result = await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gpt-4o-mini",
maxTokens: 100, apiKey: "k", maxRetries: 3,
});
expect(result.text).toBe("revised");
expect(httpFn).toHaveBeenCalledTimes(3);
});
it("does not retry on 401", async () => {
const httpFn = vi.fn(async () => jsonResponse(401, {}, { error: { message: "auth" } }));
const provider = new OpenAIProvider(httpFn);
await expect(
provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gpt-4o-mini",
maxTokens: 100, apiKey: "k", maxRetries: 3,
}),
).rejects.toThrow(/401/);
expect(httpFn).toHaveBeenCalledTimes(1);
});
it("calls onRetry between attempts", async () => {
let n = 0;
const httpFn = async () => {
n += 1;
if (n < 2) return jsonResponse(503, { "retry-after": "0" }, { error: { message: "svc" } });
return jsonResponse(200, {}, successBody);
};
const onRetry = vi.fn();
const provider = new OpenAIProvider(httpFn);
await provider.complete({
systemPrompt: "s", userPrompt: "u", model: "gpt-4o-mini",
maxTokens: 100, apiKey: "k", maxRetries: 3, onRetry,
});
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 503" });
});
});
+6 -5
View File
@@ -239,8 +239,9 @@ describe("OpenAIProvider", () => {
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new OpenAIProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
// maxRetries: 0 keeps this single-attempt; retry behavior covered separately.
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
@@ -250,15 +251,15 @@ describe("OpenAIProvider", () => {
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new OpenAIProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("OpenAI API error (500)");
await expect(provider.complete(makeRequest())).rejects.toThrow("Internal server error");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("OpenAI API error (500)");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Internal server error");
});
it("throws with raw text when error response is not valid JSON", async () => {
const { fn } = mockHttp({ status: 502, text: "Bad Gateway" });
const provider = new OpenAIProvider(fn);
await expect(provider.complete(makeRequest())).rejects.toThrow("OpenAI API error (502): Bad Gateway");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("OpenAI API error (502): Bad Gateway");
});
});
+254
View File
@@ -0,0 +1,254 @@
import { describe, it, expect, vi } from "vitest";
import { HttpError, isRetriable, parseRetryAfter, backoffDelay, withRetry } from "../../src/providers/node-stream";
describe("HttpError", () => {
it("captures status, headers, and body", () => {
const err = new HttpError(429, { "retry-after": "30" }, '{"error":"rate"}');
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("HttpError");
expect(err.status).toBe(429);
expect(err.headers["retry-after"]).toBe("30");
expect(err.body).toBe('{"error":"rate"}');
expect(err.message).toContain("429");
});
it("accepts a custom message", () => {
const err = new HttpError(500, {}, "boom", "Anthropic API error (500): boom");
expect(err.message).toBe("Anthropic API error (500): boom");
});
});
describe("isRetriable", () => {
it("retries 429", () => {
expect(isRetriable(new HttpError(429, {}, ""))).toBe(true);
});
it("retries 408", () => {
expect(isRetriable(new HttpError(408, {}, ""))).toBe(true);
});
it("retries all 5xx (500, 501, 502, 503, 504)", () => {
for (const s of [500, 501, 502, 503, 504]) {
expect(isRetriable(new HttpError(s, {}, ""))).toBe(true);
}
});
it("does not retry 4xx other than 429/408", () => {
for (const s of [400, 401, 403, 404, 422]) {
expect(isRetriable(new HttpError(s, {}, ""))).toBe(false);
}
});
it("does not retry AbortError", () => {
const e = new Error("aborted");
e.name = "AbortError";
expect(isRetriable(e)).toBe(false);
});
it("does not retry TimeoutError", () => {
const e = new Error("timed out");
e.name = "TimeoutError";
expect(isRetriable(e)).toBe(false);
});
it("retries plain Error (transport-level failure)", () => {
expect(isRetriable(new Error("ECONNRESET"))).toBe(true);
});
it("retries non-Error throws (treats unknown as retriable transport noise)", () => {
expect(isRetriable("network down")).toBe(true);
});
});
describe("parseRetryAfter", () => {
it("parses delta-seconds (number)", () => {
expect(parseRetryAfter("30")).toBe(30_000);
expect(parseRetryAfter("0")).toBe(0);
});
it("parses HTTP-date and returns ms-from-now (clamped to >= 0)", () => {
const future = new Date(Date.now() + 10_000).toUTCString();
const parsed = parseRetryAfter(future);
expect(parsed).not.toBeNull();
expect(parsed!).toBeGreaterThan(5_000);
expect(parsed!).toBeLessThanOrEqual(10_000);
const past = new Date(Date.now() - 60_000).toUTCString();
expect(parseRetryAfter(past)).toBe(0);
});
it("returns null for malformed values", () => {
expect(parseRetryAfter("abc")).toBeNull();
expect(parseRetryAfter("")).toBeNull();
expect(parseRetryAfter(undefined)).toBeNull();
expect(parseRetryAfter("-5")).toBeNull();
});
});
describe("backoffDelay", () => {
it("honors Retry-After header (seconds), capped at 60s", () => {
const err = new HttpError(429, { "retry-after": "30" }, "");
expect(backoffDelay(err, 0)).toBe(30_000);
});
it("caps Retry-After at 60s for a misformatted long delay", () => {
const err = new HttpError(429, { "retry-after": "86400" }, "");
expect(backoffDelay(err, 0)).toBe(60_000);
});
it("falls back to exponential with full jitter on malformed Retry-After", () => {
const err = new HttpError(429, { "retry-after": "ABCD" }, "");
const samples = Array.from({ length: 50 }, () => backoffDelay(err, 0));
for (const s of samples) {
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThan(1_000);
}
});
it("exponential base scales 1s/2s/4s with full jitter, capped at 60s", () => {
const err = new HttpError(500, {}, "");
for (let attempt = 0; attempt < 4; attempt++) {
const cap = Math.min(60_000, 1_000 * Math.pow(2, attempt));
const samples = Array.from({ length: 30 }, () => backoffDelay(err, attempt));
for (const s of samples) {
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThan(cap || 1);
}
}
});
it("non-HttpError uses exponential", () => {
const samples = Array.from({ length: 30 }, () => backoffDelay(new Error("oops"), 1));
for (const s of samples) {
expect(s).toBeGreaterThanOrEqual(0);
expect(s).toBeLessThan(2_000);
}
});
});
describe("withRetry", () => {
it("returns the result on first success without retry", async () => {
const fn = vi.fn(async () => "ok");
const result = await withRetry(fn, { maxAttempts: 3 });
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries on retriable error and succeeds (429 -> 429 -> 200)", async () => {
let attempts = 0;
const fn = vi.fn(async () => {
attempts += 1;
if (attempts < 3) throw new HttpError(429, { "retry-after": "0" }, "");
return "ok";
});
const result = await withRetry(fn, { maxAttempts: 3 });
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(3);
});
it("propagates non-retriable error immediately (no further attempts)", async () => {
const fn = vi.fn(async () => { throw new HttpError(401, {}, ""); });
await expect(withRetry(fn, { maxAttempts: 3 })).rejects.toMatchObject({ status: 401 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("propagates the last error after maxAttempts", async () => {
// Retry-After: 0 keeps the inter-attempt sleep at 0ms so the test is deterministic.
const fn = vi.fn(async () => { throw new HttpError(503, { "retry-after": "0" }, ""); });
await expect(withRetry(fn, { maxAttempts: 3 })).rejects.toMatchObject({ status: 503 });
expect(fn).toHaveBeenCalledTimes(3);
});
it("invokes onRetry with attempt number, waitMs, and reason before each retry", async () => {
let attempts = 0;
const fn = async (): Promise<string> => {
attempts += 1;
if (attempts < 3) throw new HttpError(429, { "retry-after": "0" }, "");
return "ok";
};
const onRetry = vi.fn();
await withRetry(fn, { maxAttempts: 3, onRetry });
expect(onRetry).toHaveBeenCalledTimes(2);
expect(onRetry.mock.calls[0][0]).toMatchObject({ attempt: 2, reason: "HTTP 429" });
expect(onRetry.mock.calls[1][0]).toMatchObject({ attempt: 3, reason: "HTTP 429" });
});
it("respects canStillRetry returning false (skips retry)", async () => {
let started = false;
const fn = vi.fn(async () => {
started = true;
throw new HttpError(503, {}, "");
});
await expect(
withRetry(fn, { maxAttempts: 3, canStillRetry: () => !started }),
).rejects.toMatchObject({ status: 503 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("respects an already-aborted signal and throws AbortError immediately", async () => {
const ctrl = new AbortController();
ctrl.abort();
const fn = vi.fn(async () => "ok");
await expect(
withRetry(fn, { maxAttempts: 3, signal: ctrl.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(fn).not.toHaveBeenCalled();
});
it("aborts mid-sleep promptly and throws AbortError", async () => {
const ctrl = new AbortController();
const fn = vi.fn(async () => { throw new HttpError(503, { "retry-after": "60" }, ""); });
setTimeout(() => ctrl.abort(), 10);
const start = Date.now();
await expect(
withRetry(fn, { maxAttempts: 3, signal: ctrl.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(Date.now() - start).toBeLessThan(500);
});
it("maxAttempts: 1 makes a single attempt and surfaces the error (no retry)", async () => {
const fn = vi.fn(async () => { throw new HttpError(503, {}, ""); });
await expect(withRetry(fn, { maxAttempts: 1 })).rejects.toMatchObject({ status: 503 });
expect(fn).toHaveBeenCalledTimes(1);
});
it("rejects immediately if signal aborts before sleepCancellable arms its timer", async () => {
// The fn aborts the signal as part of its rejection, so by the time the
// helper enters sleepCancellable the signal is already aborted. This
// exercises the pre-check inside the sleep promise.
const ctrl = new AbortController();
const fn = vi.fn(async () => {
ctrl.abort();
throw new HttpError(503, { "retry-after": "10" }, "");
});
await expect(
withRetry(fn, { maxAttempts: 3, signal: ctrl.signal }),
).rejects.toMatchObject({ name: "AbortError" });
expect(fn).toHaveBeenCalledTimes(1);
});
it("describes a non-Error throw as 'transport' in the onRetry reason", async () => {
let attempts = 0;
const fn = async (): Promise<string> => {
attempts += 1;
if (attempts === 1) throw "network down"; // non-Error throw
return "ok";
};
const onRetry = vi.fn();
const result = await withRetry(fn, { maxAttempts: 2, onRetry });
expect(result).toBe("ok");
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry.mock.calls[0][0].reason).toBe("transport");
});
});
describe("StreamResult shape", () => {
it("exposes headers as Record<string,string>", () => {
const r: import("../../src/providers/node-stream").StreamResult = {
status: 200,
fullText: "",
headers: { "content-type": "application/json" },
};
expect(r.headers["content-type"]).toBe("application/json");
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* PENNY - Per-Provider Streaming SSE Error Event Tests (#17)
*
* Verifies that each provider's streaming path detects mid-stream `error`
* events and rejects the completion with a descriptive error, instead of
* silently swallowing them and returning empty text.
*
* Mocks `streamRequest` so we can feed pre-canned SSE chunks directly to the
* provider's onChunk callback, avoiding the need to stand up a real test
* server with provider-specific URL routing.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../../src/providers/node-stream", async (importActual) => {
// Keep the real HttpError / withRetry / isRetriable / backoffDelay so the
// providers' refactored complete() wrappers function normally. Only swap
// the network primitives (streamRequest) and bypass the timeout helper.
const actual = await importActual<typeof import("../../src/providers/node-stream")>();
return {
...actual,
streamRequest: vi.fn(),
withTimeout: <T>(p: Promise<T>) => p,
};
});
import { streamRequest } from "../../src/providers/node-stream";
import { AnthropicProvider } from "../../src/providers/anthropic";
import { OpenAIProvider } from "../../src/providers/openai";
import { GoogleProvider } from "../../src/providers/google";
import { OllamaProvider } from "../../src/providers/ollama";
import type { HttpFn } from "../../src/providers/service";
const stubHttp: HttpFn = async () => ({ status: 200, headers: {}, text: "{}", json: {} });
const mockStream = vi.mocked(streamRequest);
beforeEach(() => {
mockStream.mockReset();
});
/** Convenience: program the mock to feed a sequence of SSE-formatted chunks. */
function programChunks(chunks: string[]): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mockStream.mockImplementation(async (opts: any) => {
for (const chunk of chunks) {
opts.onChunk(chunk);
}
return { status: 200, fullText: chunks.join("") };
});
}
describe("Anthropic streaming — SSE error events", () => {
it("rejects with upstream message when an error event is received mid-stream", async () => {
programChunks([
'data: {"type":"message_start","message":{"usage":{"input_tokens":10}}}\n\n',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello "}}\n\n',
'data: {"type":"error","error":{"type":"overloaded_error","message":"Server is currently overloaded"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
const tokens: string[] = [];
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: (t) => tokens.push(t),
}),
).rejects.toThrow(/Server is currently overloaded/);
});
it("does NOT silently include pre-error tokens in a successful result", async () => {
programChunks([
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hello "}}\n\n',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"there"}}\n\n',
'data: {"type":"error","error":{"type":"api_error","message":"Internal failure"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
const result = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: () => { /* ignore */ },
}).catch((e) => e);
// Should be a thrown Error, not a CompletionResponse
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toMatch(/Internal failure/);
// The function rejected — caller has no way to access the partial text
expect("text" in (result as object)).toBe(false);
});
it("includes the error type in the thrown message", async () => {
programChunks([
'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: () => { /* required to take streaming path */ },
}),
).rejects.toThrow(/overloaded_error/);
});
});
describe("OpenAI streaming — SSE error events", () => {
it("rejects when an error chunk is received mid-stream", async () => {
programChunks([
'data: {"choices":[{"delta":{"content":"Hello "}}]}\n\n',
'data: {"error":{"message":"Rate limit exceeded","type":"rate_limit_exceeded"}}\n\n',
]);
const provider = new OpenAIProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gpt-4.1",
maxTokens: 100,
apiKey: "mock-openai-test",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/Rate limit exceeded/);
});
it("does NOT silently include pre-error tokens", async () => {
programChunks([
'data: {"choices":[{"delta":{"content":"partial"}}]}\n\n',
'data: {"error":{"message":"Service unavailable"}}\n\n',
]);
const provider = new OpenAIProvider(stubHttp);
const result = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gpt-4.1",
maxTokens: 100,
apiKey: "mock-openai-test",
onToken: () => { /* ignore */ },
}).catch((e) => e);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toMatch(/Service unavailable/);
});
});
describe("Google streaming — SSE error events", () => {
it("rejects when an error chunk is received mid-stream", async () => {
programChunks([
'data: {"candidates":[{"content":{"parts":[{"text":"Some "}]}}]}\n\n',
'data: {"error":{"code":429,"message":"Resource exhausted","status":"RESOURCE_EXHAUSTED"}}\n\n',
]);
const provider = new GoogleProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gemini-2.5-flash",
maxTokens: 100,
apiKey: "mock-google-test",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/Resource exhausted/);
});
it("does NOT silently include pre-error tokens", async () => {
programChunks([
'data: {"candidates":[{"content":{"parts":[{"text":"begin"}]}}]}\n\n',
'data: {"error":{"message":"Filtered for safety"}}\n\n',
]);
const provider = new GoogleProvider(stubHttp);
const result = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "gemini-2.5-flash",
maxTokens: 100,
apiKey: "mock-google-test",
onToken: () => { /* ignore */ },
}).catch((e) => e);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toMatch(/Filtered for safety/);
});
});
describe("Ollama streaming — SSE error events", () => {
// Ollama's /v1/chat/completions endpoint is OpenAI-compatible; same shape.
it("rejects when an error chunk is received mid-stream", async () => {
programChunks([
'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n',
'data: {"error":{"message":"Model not found"}}\n\n',
]);
const provider = new OllamaProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "llama3.2",
maxTokens: 100,
endpoint: "http://localhost:11434",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/Model not found/);
});
it("rejects on bare-string Ollama error shape", async () => {
// Ollama also sometimes emits `{"error": "..."}` (string, not object)
programChunks([
'data: {"error":"context deadline exceeded"}\n\n',
]);
const provider = new OllamaProvider(stubHttp);
await expect(
provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "llama3.2",
maxTokens: 100,
endpoint: "http://localhost:11434",
onToken: () => { /* ignore */ },
}),
).rejects.toThrow(/context deadline exceeded/);
});
});
describe("Streaming error events — common contract", () => {
it("error message includes the provider name for triage", async () => {
programChunks([
'data: {"type":"error","error":{"type":"x","message":"y"}}\n\n',
]);
const provider = new AnthropicProvider(stubHttp);
const err = await provider.complete({ maxRetries: 0,
systemPrompt: "s",
userPrompt: "u",
model: "claude-sonnet-4-6",
maxTokens: 100,
apiKey: "mock-anthropic-test",
onToken: () => { /* required to take streaming path */ },
}).catch((e) => e);
expect((err as Error).message.toLowerCase()).toContain("anthropic");
});
});
+106 -1
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createServer, Server } from "http";
import { AddressInfo } from "net";
import { streamRequest } from "../../src/providers/node-stream";
import { streamRequest, withTimeout } from "../../src/providers/node-stream";
/**
* These tests spin up a tiny real HTTP server on an ephemeral port and exercise
@@ -170,6 +170,69 @@ describe("streamRequest", () => {
expect(result.fullText).toBe("server overloaded");
});
describe("timeout", () => {
it("rejects with TimeoutError when the server never responds", async () => {
// Handler accepts the connection but never writes a response. Without a
// timeout, this hangs forever -- the bug we're fixing in #18.
currentHandler = (_req, _res) => {
// Intentionally do nothing -- never send headers, never end the response.
};
const start = Date.now();
await expect(
streamRequest({
url: `${baseUrl}/never-responds`,
method: "GET",
headers: {},
timeoutMs: 150,
onChunk: () => { /* never called */ },
}),
).rejects.toMatchObject({ name: "TimeoutError" });
// Should fire near our 150ms threshold, not run to completion or vitest's 5s default.
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(140);
expect(elapsed).toBeLessThan(2000);
});
it("does not fire when the server responds within the budget", async () => {
currentHandler = (_req, res) => {
res.writeHead(200);
res.end("ok");
};
const result = await streamRequest({
url: `${baseUrl}/fast`,
method: "GET",
headers: {},
timeoutMs: 1000,
onChunk: () => { /* ignore */ },
});
expect(result.status).toBe(200);
expect(result.fullText).toBe("ok");
});
it("timeout error is distinguishable from AbortError", async () => {
currentHandler = (_req, _res) => {
// Never respond
};
const promise = streamRequest({
url: `${baseUrl}/timeout-not-abort`,
method: "GET",
headers: {},
timeoutMs: 100,
onChunk: () => { /* ignore */ },
});
const err = await promise.catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(err.name).toBe("TimeoutError");
expect(err.name).not.toBe("AbortError");
expect(err.message).toMatch(/timed? ?out/i);
});
});
it("does not let a throwing onChunk consumer kill the stream", async () => {
currentHandler = (_req, res) => {
res.writeHead(200);
@@ -194,3 +257,45 @@ describe("streamRequest", () => {
expect(received).toContain("b");
});
});
describe("withTimeout", () => {
it("resolves with the original value when the promise completes within budget", async () => {
const result = await withTimeout(Promise.resolve("ok"), 1000);
expect(result).toBe("ok");
});
it("rejects with TimeoutError when the promise takes longer than the budget", async () => {
const slow = new Promise((resolve) => setTimeout(() => resolve("late"), 200));
const start = Date.now();
await expect(withTimeout(slow, 50)).rejects.toMatchObject({
name: "TimeoutError",
});
const elapsed = Date.now() - start;
expect(elapsed).toBeGreaterThanOrEqual(40);
expect(elapsed).toBeLessThan(150);
});
it("propagates the timeoutMs in the error message", async () => {
const slow = new Promise(() => { /* never resolves */ });
const err = await withTimeout(slow, 75).catch((e) => e);
expect(err.message).toMatch(/75/);
expect(err.message).toMatch(/timed? ?out/i);
});
it("returns the promise unchanged when timeoutMs is undefined", async () => {
const slow = new Promise((resolve) => setTimeout(() => resolve("done"), 50));
const result = await withTimeout(slow, undefined);
expect(result).toBe("done");
});
it("returns the promise unchanged when timeoutMs is zero", async () => {
const slow = new Promise((resolve) => setTimeout(() => resolve("done"), 50));
const result = await withTimeout(slow, 0);
expect(result).toBe("done");
});
it("propagates rejections from the wrapped promise", async () => {
const failing = Promise.reject(new Error("upstream failure"));
await expect(withTimeout(failing, 1000)).rejects.toThrow("upstream failure");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { QUOTES, pickRandomQuote, quoteAt } from "../src/quotes";
describe("quotes", () => {
it("exposes a non-empty pool", () => {
expect(QUOTES.length).toBeGreaterThan(0);
for (const q of QUOTES) {
expect(typeof q.text).toBe("string");
expect(q.text.length).toBeGreaterThan(0);
expect(typeof q.author).toBe("string");
expect(q.author.length).toBeGreaterThan(0);
}
});
describe("pickRandomQuote", () => {
it("returns a quote from the pool", () => {
for (let i = 0; i < 20; i++) {
const q = pickRandomQuote();
expect(QUOTES).toContainEqual(q);
}
});
});
describe("quoteAt", () => {
it("returns the quote at the given index, modulo pool length", () => {
expect(quoteAt(0)).toEqual(QUOTES[0]);
expect(quoteAt(QUOTES.length)).toEqual(QUOTES[0]);
expect(quoteAt(QUOTES.length + 3)).toEqual(QUOTES[3]);
});
it("handles negative indices correctly (wraps positively)", () => {
expect(quoteAt(-1)).toEqual(QUOTES[QUOTES.length - 1]);
expect(quoteAt(-QUOTES.length)).toEqual(QUOTES[0]);
expect(quoteAt(-QUOTES.length - 1)).toEqual(QUOTES[QUOTES.length - 1]);
});
});
});
+36
View File
@@ -34,6 +34,42 @@ describe("checkVoiceCompliance", () => {
expect(result.dialogueRatio).toBeGreaterThan(0);
});
it("counts British single-quoted dialogue (#5)", () => {
// 'Hello there' = 2 words of dialogue out of 5 total \u2192 ratio 0.4
const prose = "'Hello there,' she said.";
const result = checkVoiceCompliance(prose);
expect(result.dialogueRatio).toBeGreaterThan(0);
expect(result.dialogueRatio).toBeLessThan(1);
});
it("does not double-count apostrophes inside contractions (#5)", () => {
// The contraction "don't" must not register as opening a quote span.
const prose = "She said \"I don't know what you're doing.\" The day went on.";
const result = checkVoiceCompliance(prose);
// Dialogue: "I don't know what you're doing" = 6 words
// Total words: ~14
expect(result.dialogueRatio).toBeGreaterThan(0.3);
expect(result.dialogueRatio).toBeLessThan(0.6);
});
it("counts mixed quote styles in the same chapter (#5)", () => {
const prose = '"Hi," he said. \u2018Bye,\u2019 she replied. \u201CGood,\u201D he agreed.';
const result = checkVoiceCompliance(prose);
// Three single-word dialogue spans out of ~9 total \u2192 > 0.3
expect(result.dialogueRatio).toBeGreaterThan(0.3);
});
it("strips British single-quoted dialogue from narration analysis (#5)", () => {
// Without stripping, the long sentence below would NOT exceed 20 words
// because the dialogue would inflate the count. With proper stripping,
// the narration is short and below threshold.
const prose =
"'I think I shall walk to the very far end of the very long lane today.' She said it firmly.";
const result = checkVoiceCompliance(prose);
// Narration is short; should not flag a long sentence.
expect(result.longNarrationSentences).toHaveLength(0);
});
it("returns 0 for empty text", () => {
const result = checkVoiceCompliance("");
expect(result.dialogueRatio).toBe(0);
+52 -1
View File
@@ -5,7 +5,58 @@ export default defineConfig({
include: ["test/**/*.test.ts"],
globals: true,
coverage: {
reporter: ["text", "json-summary"],
provider: "v8",
reporter: ["text", "json-summary", "html"],
include: ["src/**/*.ts"],
// Shell modules and pure-type files are excluded from threshold gating.
// The pure-logic / Obsidian-shell import boundary in this codebase makes
// these inherently hard to test without Obsidian mocking infrastructure.
// See audit issues #7 (split commands.ts) and #39 (extract testable
// logic from commands.ts) for the path to bringing these into coverage.
exclude: [
"src/main.ts",
"src/commands.ts",
"src/settings.ts",
"src/setup-wizard.ts",
"src/*-modal.ts",
"src/statusbar.ts",
"src/types.ts",
"src/logger.ts",
],
thresholds: {
// Global floor — ratchet only up.
//
// After issue #17 (SSE error events): lines 90.93%, statements 89.77%,
// functions 96.38%, branches 77.54%.
// After issue #15 (retry/backoff) on top of #17: lines 91.60%,
// statements 90.51%, functions 97.43%, branches 79.08%.
lines: 91,
statements: 90,
functions: 97,
branches: 79,
// Per-file ratchets — each module locked at or just below its current
// coverage so it cannot regress. When tests rise, raise the lock here
// in the same PR. Modules at 100% are pinned at 100%.
"src/versioner.ts": { lines: 100, functions: 100 },
"src/migrate.ts": { lines: 100, functions: 100 },
"src/drafter.ts": { lines: 100, functions: 100 },
"src/progress-modal-state.ts": { lines: 100, functions: 100 },
"src/utils.ts": { lines: 100, functions: 100 },
"src/quotes.ts": { lines: 100, functions: 100 },
"src/providers/registry.ts": { lines: 100, functions: 100 },
"src/providers/router.ts": { lines: 100, functions: 100 },
"src/providers/index.ts": { lines: 100, functions: 100 },
"src/providers/service.ts": { lines: 100, functions: 100 },
"src/frontmatter.ts": { lines: 97, functions: 100 },
"src/pipeline.ts": { lines: 95, functions: 87 },
"src/providers/node-stream.ts": { lines: 96, functions: 100 },
"src/parser.ts": { lines: 88, functions: 100 },
"src/reviewer.ts": { lines: 85 },
"src/context.ts": { lines: 83, functions: 100 },
"src/assembler.ts": { lines: 81 },
"src/project-config.ts": { lines: 71 },
},
},
},
});