Compare commits

3 Commits
Author SHA1 Message Date
forge-recovery 38733160a9 ci: use the central original KOF container runner default
KofTwentyTwo/ci-policy: ci / image-build-0 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-1 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-2 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-5 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-4 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-6 (push) Skipped
KofTwentyTwo/ci-policy: ci / image-build-7 (push) Skipped
KofTwentyTwo/ci-policy: ci / not-enrolled (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-1 (push) Skipped
KofTwentyTwo/ci-policy: native / build-macos-2 (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 / dotnet-10 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-12 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-6 (push) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-8 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-9 (push) Skipped
KofTwentyTwo/ci-policy: native / dotnet-11 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-14 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-13 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-15 (push) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (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: Shared Node CI / secrets (push) Successful in 10s
KofTwentyTwo/ci-policy: native / native-result (push) Successful in 4s
KofTwentyTwo/ci-policy: Shared Node CI / verify (push) Successful in 1m23s
KofTwentyTwo/ci-policy: Shared Node CI / gate (push) Successful in 8s
KofTwentyTwo/ci-policy: ci / node-8 (push) Successful in 1m32s
KofTwentyTwo/ci-policy: ci / ci-result (push) Successful in 3s
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 / gradle-2 (push) Skipped
KofTwentyTwo/ci-policy: ci / gradle-3 (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 / python-9 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-10 (push) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (push) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (push) Skipped
KofTwentyTwo/ci-policy: ci / source-config-14 (push) Skipped
KofTwentyTwo/ci-policy: ci / source-config-15 (push) Skipped
Original Munitor Obsidian plugin / prepare (push) Successful in 37s
Original Munitor Obsidian plugin / secrets-scan (push) Skipped
Original Munitor Obsidian plugin / sast-scan (push) Skipped
Original Munitor Obsidian plugin / allocate-version (push) Successful in 8s
Original Munitor Obsidian plugin / build-and-test (push) Successful in 1m31s
Original Munitor Obsidian plugin / code-quality (push) Skipped
Original Munitor Obsidian plugin / coverage (push) Skipped
Original Munitor Obsidian plugin / security-scan (push) Skipped
Original Munitor Obsidian plugin / sbom (push) Skipped
Original Munitor Obsidian plugin / release-assets (push) Successful in 10s
Original Munitor Obsidian plugin / gate (push) Successful in 4s
Original Obsidian plugin build and release / plugin (push) Successful in 2m37s
2026-11-04 09:47:17 +00:00
forge-recoveryandCodex 73ff925124 ci: use the central original container runner default
Original Obsidian plugin build and release / plugin (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / fork-guard (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-nix-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (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 / java-maven-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / manifests-6 (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 / rust-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-12 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-15 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-14 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / image-build-3 (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 / 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 / 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 / fork-deny (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-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (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 / swift-13 (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-16 (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-19 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / native-result (pull_request) Successful in 4s
KofTwentyTwo/ci-policy: Shared Node CI / secrets (pull_request) Successful in 11s
KofTwentyTwo/ci-policy: Shared Node CI / verify (pull_request) Successful in 1m22s
KofTwentyTwo/ci-policy: Shared Node CI / gate (pull_request) Successful in 8s
KofTwentyTwo/ci-policy: ci / node-8 (pull_request) Successful in 1m31s
KofTwentyTwo/ci-policy: ci / ci-result (pull_request) Successful in 4s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 09:34:47 +00:00
forge-recoveryandCodex e3ca2c4e3a ci: use original shared Obsidian plugin pipeline
KofTwentyTwo/ci-policy: native / build-windows-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-7 (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 / swift-14 (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-13 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-16 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-15 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-18 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / swift-17 (pull_request) Skipped
KofTwentyTwo/ci-policy: Shared Node CI / secrets (pull_request) Failing after 14s
KofTwentyTwo/ci-policy: native / swift-19 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / native-result (pull_request) Waiting for needed jobs
KofTwentyTwo/ci-policy: Shared Node CI / verify (pull_request) Successful in 1m37s
KofTwentyTwo/ci-policy: Shared Node CI / gate (pull_request) Failing after 10s
KofTwentyTwo/ci-policy: ci / node-8 (pull_request) Failing after 1m48s
KofTwentyTwo/ci-policy: ci / ci-result (pull_request) Failing after 3s
Original Obsidian plugin build and release / plugin (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / fork-guard (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / go-0 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-docker-4 (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 / manifests-6 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / nix-7 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-10 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / java-maven-5 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / rust-11 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / python-9 (pull_request) Skipped
KofTwentyTwo/ci-policy: ci / source-config-13 (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: ci / source-config-15 (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 / 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 / fork-deny (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-1 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-macos-0 (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-4 (pull_request) Skipped
KofTwentyTwo/ci-policy: native / build-windows-5 (pull_request) Skipped
Co-Authored-By: Codex <noreply@openai.com>
2026-11-04 08:07:48 +00:00
42 changed files with 214 additions and 3911 deletions

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
version: 2.1
setup: true
orbs:
munitor: kof22/munitor@dev:snapshot
workflows:
setup:
jobs:
- munitor/generate_pipeline
-13
View File
@@ -1,13 +0,0 @@
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
@@ -0,0 +1,22 @@
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,6 +22,3 @@ Thumbs.db
# Test
coverage/
# AI tooling caches (session/transcript data, not portable)
.claude/
-27
View File
@@ -1,27 +0,0 @@
# 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
@@ -0,0 +1,12 @@
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
@@ -1,113 +0,0 @@
# 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
@@ -1,110 +0,0 @@
# 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,6 +921,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -938,6 +941,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -955,6 +961,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -972,6 +981,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -989,6 +1001,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1006,6 +1021,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -2379,6 +2397,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2400,6 +2421,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2421,6 +2445,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2442,6 +2469,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
-1
View File
@@ -11,7 +11,6 @@
"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": [
+4 -26
View File
@@ -603,11 +603,9 @@ export async function processChapter(plugin: PennyPlugin, file: TFile, options?:
return null;
}
// 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).
// Update .version and .state.json (upsert -- these always exist after first run)
await upsertFile(plugin, versionFilePath, String(result.newVersion));
await safeStateWrite(plugin, stateFilePath, result.stateJson);
await upsertFile(plugin, stateFilePath, result.stateJson);
// Write review note (reviewPath pre-computed above)
const reviewFolder = reviewPath.split("/").slice(0, -1).join("/");
@@ -1286,33 +1284,13 @@ async function reconcileVersionState(
}, null, 2);
}
// Write corrected files to disk. State gets a .bak snapshot so a corrupt
// reconciliation can be recovered manually (issue #31).
// Write corrected files to disk
await plugin.app.vault.adapter.write(`${folderPath}/.version`, newVersionContent);
await safeStateWrite(plugin, `${folderPath}/.state.json`, newStateContent);
await plugin.app.vault.adapter.write(`${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.
*/
+8 -21
View File
@@ -285,30 +285,17 @@ export function detectCharacters(content: string, focusField: string): string[]
}
}
// 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());
// 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());
}
// Reverse: `said Name`
const reverseRe = new RegExp(`(?:${VERBS})\\s+(${NAME})\\b`, "gm");
for (const match of content.matchAll(reverseRe)) {
chars.add(match[1].toLowerCase());
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());
}
return Array.from(chars);
+5 -10
View File
@@ -56,17 +56,12 @@ 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]) {
// 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.
const prefix = `[PENNY ${level.toUpperCase()}]`;
switch (level) {
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;
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;
}
}
}
+2 -30
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, pennyLog } from "./logger";
import { createLogEntry, formatLogEntry } from "./logger";
import { checkVoiceCompliance } from "./voice-check";
import { getRoute } from "./providers/router";
@@ -58,8 +58,7 @@ export interface ProgressEvent {
| "assembling"
| "complete"
| "cancelled"
| "token"
| "retry";
| "token";
total?: number;
current?: number;
tag?: string;
@@ -71,12 +70,6 @@ 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;
}
/**
@@ -306,27 +299,6 @@ 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,23 +227,6 @@ 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();
+22 -78
View File
@@ -24,7 +24,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
import { streamRequest } from "./node-stream";
const ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages";
const ANTHROPIC_VERSION = "2023-06-01";
@@ -86,34 +86,6 @@ 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"
@@ -128,9 +100,7 @@ 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.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Anthropic streaming error")) throw e;
if (e instanceof Error && e.message.startsWith("Anthropic API error")) throw e;
// Transport-level failure (rare with Node https) -- fall through.
}
}
@@ -159,18 +129,15 @@ export class AnthropicProvider implements LLMService {
headers["anthropic-beta"] = "interleaved-thinking-2025-05-14";
}
const response = await withTimeout(
this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers,
body: JSON.stringify(body),
}),
request.timeoutMs,
);
const response = await this.httpFn({
url: ANTHROPIC_API_URL,
method: "POST",
headers,
body: JSON.stringify(body),
});
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, response.headers);
throw this.buildHttpError(response.status, response.text);
}
return this.parseResponse(response.text, request.model);
@@ -213,10 +180,6 @@ 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,
@@ -224,7 +187,6 @@ 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
@@ -239,15 +201,6 @@ 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;
@@ -271,13 +224,9 @@ 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, result.headers);
throw this.buildHttpError(result.status, result.fullText);
}
return {
@@ -319,7 +268,7 @@ export class AnthropicProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, response.headers).message;
return this.buildHttpError(response.status, response.text).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `Anthropic connection failed: ${message}`;
@@ -327,15 +276,10 @@ export class AnthropicProvider implements LLMService {
}
/**
* 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.
* Build a descriptive Error from an Anthropic HTTP error response.
* Attempts to parse the Anthropic error JSON format; falls back to raw text.
*/
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
private buildHttpError(status: number, responseText: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -348,13 +292,13 @@ export class AnthropicProvider implements LLMService {
detail = responseText;
}
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);
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}`);
}
/**
@@ -389,7 +333,7 @@ export class AnthropicProvider implements LLMService {
(block: Record<string, unknown>) => block.type === "text",
);
if (textBlock && typeof textBlock.text === "string") {
text = textBlock.text;
text = textBlock.text.trim();
}
}
+24 -70
View File
@@ -21,7 +21,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
import { streamRequest } from "./node-stream";
const GOOGLE_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models";
@@ -71,31 +71,6 @@ 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") {
@@ -103,9 +78,7 @@ 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.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Google streaming error")) throw e;
if (e instanceof Error && e.message.startsWith("Google API error")) throw e;
// Transport-level failure -- fall through to httpFn.
}
}
@@ -130,20 +103,17 @@ export class GoogleProvider implements LLMService {
const url = `${GOOGLE_API_BASE}/${request.model}:generateContent?key=${apiKey}`;
const response = await withTimeout(
this.httpFn({
url,
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
}),
request.timeoutMs,
);
const response = await this.httpFn({
url,
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, response.headers);
throw this.buildHttpError(response.status, response.text);
}
return this.parseResponse(response.text, request.model);
@@ -166,8 +136,6 @@ 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,
@@ -175,7 +143,6 @@ 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");
@@ -186,13 +153,6 @@ 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) {
@@ -207,10 +167,8 @@ export class GoogleProvider implements LLMService {
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, result.headers);
throw this.buildHttpError(result.status, result.fullText);
}
return {
@@ -258,7 +216,7 @@ export class GoogleProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, response.headers).message;
return this.buildHttpError(response.status, response.text).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `Google connection failed: ${message}`;
@@ -266,14 +224,10 @@ export class GoogleProvider implements LLMService {
}
/**
* Build a typed HttpError from a Google HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
* Build a descriptive Error from a Google HTTP error response.
* Attempts to parse the Google error JSON format; falls back to raw text.
*/
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
private buildHttpError(status: number, responseText: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -286,13 +240,13 @@ export class GoogleProvider implements LLMService {
detail = responseText;
}
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);
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}`);
}
/**
@@ -328,7 +282,7 @@ export class GoogleProvider implements LLMService {
(part: Record<string, unknown>) => typeof part.text === "string",
);
if (textPart) {
text = textPart.text;
text = textPart.text.trim();
}
}
}
-253
View File
@@ -25,13 +25,6 @@ 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;
}
@@ -39,16 +32,12 @@ 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.
*/
@@ -83,10 +72,6 @@ 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");
@@ -106,7 +91,6 @@ export function streamRequest(opts: StreamOptions): Promise<StreamResult> {
resolve({
status: res.statusCode ?? 0,
fullText: chunks.join(""),
headers: normalizeIncomingHeaders(res.headers),
});
});
@@ -120,14 +104,6 @@ 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) {
@@ -147,17 +123,6 @@ 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);
@@ -170,221 +135,3 @@ 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;
}
+15 -69
View File
@@ -28,7 +28,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
import { streamRequest } from "./node-stream";
const DEFAULT_OLLAMA_ENDPOINT = "http://localhost:11434";
@@ -98,28 +98,6 @@ 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
@@ -130,9 +108,7 @@ 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.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("Ollama streaming error")) throw e;
if (e instanceof Error && e.message.startsWith("Ollama error")) throw e;
// Transport-level failure (ECONNREFUSED, etc.) -- fall through to httpFn,
// which will produce the usual "Is Ollama running?" diagnostic.
}
@@ -157,15 +133,12 @@ export class OllamaProvider implements LLMService {
let response;
try {
response = await withTimeout(
this.httpFn({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
}),
request.timeoutMs,
);
response = await this.httpFn({
url: `${endpoint}/v1/chat/completions`,
method: "POST",
headers,
body: JSON.stringify(body),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes("ECONNREFUSED") || message.includes("fetch failed") || message.includes("Connection refused")) {
@@ -175,7 +148,7 @@ export class OllamaProvider implements LLMService {
}
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, endpoint, response.headers);
throw this.buildHttpError(response.status, response.text, endpoint);
}
return this.parseResponse(response.text, request.model);
@@ -202,8 +175,6 @@ 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`,
@@ -211,7 +182,6 @@ 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");
@@ -223,22 +193,6 @@ 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);
@@ -249,10 +203,8 @@ export class OllamaProvider implements LLMService {
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, endpoint, result.headers);
throw this.buildHttpError(result.status, result.fullText, endpoint);
}
return {
@@ -280,7 +232,7 @@ export class OllamaProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, endpoint, response.headers).message;
return this.buildHttpError(response.status, response.text, endpoint).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")) {
@@ -291,15 +243,9 @@ export class OllamaProvider implements LLMService {
}
/**
* Build a typed HttpError from an Ollama HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
* Build a descriptive Error from an Ollama HTTP error response.
*/
private buildHttpError(
status: number,
responseText: string,
_endpoint: string,
headers: Record<string, string> = {},
): HttpError {
private buildHttpError(status: number, responseText: string, _endpoint: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -307,7 +253,7 @@ export class OllamaProvider implements LLMService {
} catch {
detail = responseText;
}
return new HttpError(status, headers, responseText, `Ollama error (${status}): ${detail}`);
return new Error(`Ollama error (${status}): ${detail}`);
}
/**
@@ -339,7 +285,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;
text = choice.message.content.trim();
}
}
+25 -73
View File
@@ -21,7 +21,7 @@ import type {
ProviderSettings,
HttpFn,
} from "./service";
import { streamRequest, withTimeout, withRetry, HttpError } from "./node-stream";
import { streamRequest } 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,31 +82,6 @@ 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") {
@@ -114,9 +89,7 @@ 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.name === "TimeoutError") throw e;
if (e instanceof HttpError) throw e;
if (e instanceof Error && e.message.startsWith("OpenAI streaming error")) throw e;
if (e instanceof Error && e.message.startsWith("OpenAI API error")) throw e;
// Transport-level failure -- fall through to httpFn.
}
}
@@ -130,21 +103,18 @@ export class OpenAIProvider implements LLMService {
max_tokens: request.maxTokens,
};
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,
);
const response = await this.httpFn({
url: OPENAI_API_URL,
method: "POST",
headers: {
"Authorization": `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status < 200 || response.status >= 300) {
throw this.buildHttpError(response.status, response.text, response.headers);
throw this.buildHttpError(response.status, response.text);
}
return this.parseResponse(response.text, request.model);
@@ -166,10 +136,6 @@ 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,
@@ -177,7 +143,6 @@ 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");
@@ -189,13 +154,6 @@ 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);
@@ -206,10 +164,8 @@ export class OpenAIProvider implements LLMService {
},
});
if (streamError) throw streamError;
if (result.status < 200 || result.status >= 300) {
throw this.buildHttpError(result.status, result.fullText, result.headers);
throw this.buildHttpError(result.status, result.fullText);
}
return {
@@ -242,7 +198,7 @@ export class OpenAIProvider implements LLMService {
if (response.status >= 200 && response.status < 300) {
return null; // success
}
return this.buildHttpError(response.status, response.text, response.headers).message;
return this.buildHttpError(response.status, response.text).message;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return `OpenAI connection failed: ${message}`;
@@ -250,14 +206,10 @@ export class OpenAIProvider implements LLMService {
}
/**
* Build a typed HttpError from an OpenAI HTTP error response. Attaches
* status + headers so the retry helper can read Retry-After.
* Build a descriptive Error from an OpenAI HTTP error response.
* Attempts to parse the OpenAI error JSON format; falls back to raw text.
*/
private buildHttpError(
status: number,
responseText: string,
headers: Record<string, string> = {},
): HttpError {
private buildHttpError(status: number, responseText: string): Error {
let detail: string;
try {
const data = JSON.parse(responseText);
@@ -270,13 +222,13 @@ export class OpenAIProvider implements LLMService {
detail = responseText;
}
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);
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}`);
}
/**
@@ -309,7 +261,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;
text = choice.message.content.trim();
}
}
-19
View File
@@ -85,25 +85,6 @@ 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,48 +864,6 @@ 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,18 +72,6 @@ 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. */
@@ -264,8 +252,6 @@ export const DEFAULT_SETTINGS: PennySettings = {
contextBudget: 800000,
maxTokens: 16000,
requestTimeoutMs: 300000,
maxRetries: 3,
draftsFolder: "04-drafts",
styleGuide: "",
+8 -38
View File
@@ -58,50 +58,20 @@ function splitSentences(text: string): string[] {
}
/**
* 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.
* Extract dialogue word count from prose.
* Dialogue = text enclosed in quotation marks (straight or curly).
*/
function extractDialogueWords(text: string): number {
const dialogueRe = /[""\u201C](.*?)[""\u201D]/g;
let count = 0;
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;
}
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;
}
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;
@@ -122,7 +92,7 @@ export function checkVoiceCompliance(prose: string): VoiceComplianceResult {
// --- Long narration sentences ---
// Remove dialogue from the text to isolate narration.
const narration = stripDialogue(prose);
const narration = prose.replace(/[""\u201C].*?[""\u201D]/g, "");
const sentences = splitSentences(narration);
const longNarrationSentences: string[] = [];
for (const sentence of sentences) {
-24
View File
@@ -383,30 +383,6 @@ 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
@@ -1,122 +0,0 @@
/**
* 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
@@ -1,75 +0,0 @@
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);
});
});
+5 -18
View File
@@ -296,18 +296,6 @@ 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" },
@@ -338,9 +326,8 @@ describe("AnthropicProvider", () => {
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new AnthropicProvider(fn);
// 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");
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
@@ -350,15 +337,15 @@ describe("AnthropicProvider", () => {
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new AnthropicProvider(fn);
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");
await expect(provider.complete(makeRequest())).rejects.toThrow("Anthropic API error (500)");
await expect(provider.complete(makeRequest())).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({ maxRetries: 0 }))).rejects.toThrow("Anthropic API error (502): Bad Gateway");
await expect(provider.complete(makeRequest())).rejects.toThrow("Anthropic API error (502): Bad Gateway");
});
});
-63
View File
@@ -1,63 +0,0 @@
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" });
});
});
+12 -13
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: "mock-google-test-key-123",
apiKey: "AIzaSy-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=mock-google-test-key-123"
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=AIzaSy-test-key-123"
);
});
@@ -228,9 +228,8 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new GoogleProvider(fn);
// 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");
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
@@ -240,15 +239,15 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new GoogleProvider(fn);
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Google API error (500)");
await expect(provider.complete(makeRequest({ maxRetries: 0 }))).rejects.toThrow("Internal error");
await expect(provider.complete(makeRequest())).rejects.toThrow("Google API error (500)");
await expect(provider.complete(makeRequest())).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({ maxRetries: 0 }))).rejects.toThrow("Google API error (502): Bad Gateway");
await expect(provider.complete(makeRequest())).rejects.toThrow("Google API error (502): Bad Gateway");
});
});
@@ -345,7 +344,7 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 200, text: responseText });
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "mock-google-valid" });
const result = await provider.testConnection({ apiKey: "AIzaSy-valid" });
expect(result).toBeNull();
});
@@ -361,7 +360,7 @@ describe("GoogleProvider", () => {
const { fn } = mockHttp({ status: 401, text: '{"error":{"message":"invalid key"}}' });
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "mock-google-bad" });
const result = await provider.testConnection({ apiKey: "AIzaSy-bad" });
expect(result).toContain("401");
});
@@ -371,7 +370,7 @@ describe("GoogleProvider", () => {
};
const provider = new GoogleProvider(fn);
const result = await provider.testConnection({ apiKey: "mock-google-key" });
const result = await provider.testConnection({ apiKey: "AIzaSy-key" });
expect(result).toContain("Network error");
});
@@ -380,10 +379,10 @@ describe("GoogleProvider", () => {
const { fn, calls } = mockHttp({ status: 200, text: responseText });
const provider = new GoogleProvider(fn);
await provider.testConnection({ apiKey: "mock-google-test" });
await provider.testConnection({ apiKey: "AIzaSy-test" });
expect(calls).toHaveLength(1);
expect(calls[0].url).toContain("key=mock-google-test");
expect(calls[0].url).toContain("key=AIzaSy-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
@@ -1,39 +0,0 @@
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
@@ -1,58 +0,0 @@
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);
});
});
+5 -16
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect } from "vitest";
import { OllamaProvider } from "../../src/providers/ollama";
import type { HttpFn, HttpResponse, CompletionRequest } from "../../src/providers/service";
@@ -278,29 +278,18 @@ describe("OllamaProvider", () => {
const { fn } = mockHttp({ status: 500, text: "Internal Server Error" });
const provider = new OllamaProvider(fn);
// 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");
await expect(provider.complete(makeRequest())).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);
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();
}
await expect(
provider.complete(makeRequest()),
).rejects.toThrow("Cannot connect to Ollama at http://localhost:11434. Is Ollama running?");
});
});
-63
View File
@@ -1,63 +0,0 @@
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" });
});
});
+5 -6
View File
@@ -239,9 +239,8 @@ describe("OpenAIProvider", () => {
const { fn } = mockHttp({ status: 429, text: errorResponse });
const provider = new OpenAIProvider(fn);
// 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");
await expect(provider.complete(makeRequest())).rejects.toThrow("Rate limited");
await expect(provider.complete(makeRequest())).rejects.toThrow("429");
});
it("throws on 500 response with server error text", async () => {
@@ -251,15 +250,15 @@ describe("OpenAIProvider", () => {
const { fn } = mockHttp({ status: 500, text: errorResponse });
const provider = new OpenAIProvider(fn);
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");
await expect(provider.complete(makeRequest())).rejects.toThrow("OpenAI API error (500)");
await expect(provider.complete(makeRequest())).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({ maxRetries: 0 }))).rejects.toThrow("OpenAI API error (502): Bad Gateway");
await expect(provider.complete(makeRequest())).rejects.toThrow("OpenAI API error (502): Bad Gateway");
});
});
-254
View File
@@ -1,254 +0,0 @@
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
@@ -1,257 +0,0 @@
/**
* 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");
});
});
+1 -106
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, withTimeout } from "../../src/providers/node-stream";
import { streamRequest } from "../../src/providers/node-stream";
/**
* These tests spin up a tiny real HTTP server on an ephemeral port and exercise
@@ -170,69 +170,6 @@ 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);
@@ -257,45 +194,3 @@ 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
@@ -1,37 +0,0 @@
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,42 +34,6 @@ 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);
+1 -52
View File
@@ -5,58 +5,7 @@ export default defineConfig({
include: ["test/**/*.test.ts"],
globals: true,
coverage: {
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 },
},
reporter: ["text", "json-summary"],
},
},
});