Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38733160a9
|
||
|
|
73ff925124
|
||
|
|
e3ca2c4e3a
|
No files matched your search
@@ -0,0 +1,8 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
munitor: kof22/munitor@dev:snapshot
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- munitor/generate_pipeline
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -22,6 +22,3 @@ Thumbs.db
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
|
||||
# AI tooling caches (session/transcript data, not portable)
|
||||
.claude/
|
||||
@@ -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/.+''',
|
||||
]
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Generated
+30
@@ -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": [
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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?");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user