@shipispec/tsfix 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,47 @@ All notable changes to `@shipispec/tsfix` are documented here. Format follows [K
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.1] - 2026-05-19
8
+
9
+ **Integration release.** Combines v0.6.0's library-aware error recovery with the multi-provider + telemetry work that landed on `main` between v0.5.0 and v0.6.0. The npm-published v0.6.0 was built from a stale local checkout and shipped without the Tier 2 (multi-provider) and Tier 3 (onLayerEvent + runFullStack) features that were already on `main`. v0.6.1 is the canonical "everything-since-0.5.0" release; users upgrading from v0.5.0 should jump straight to v0.6.1.
10
+
11
+ ### Added (Tier 3 — telemetry + unified entrypoint)
12
+ - **`onLayerEvent?: (event: LayerEvent) => void`** callback option on `ValidationLoopOptions`, `RunMendLoopOptions`, and (new) `RunFullStackOptions`. Wires the `LayerEvent` type that's been published since v0.3.0 but never had a callback. Optional — undefined callback costs nothing.
13
+ - **Layer 1** emits one event per fixable-error attempt: `{layer: 1, errorCode, fixed, latencyMs, ts}`. `fixed: true` when a safe LSP fix landed; `fixed: false` when the fixer abstained (no candidate, ambiguous candidates, or zero-fix response).
14
+ - **Layer 2** emits one event per `runMendLoop` iteration: `{layer: 2, errorCode: <dominant code in iteration input>, fixed: <iteration cleared all errors>, latencyMs, ts}`. `costUsd` intentionally omitted from the per-event payload — callers can compute it from `result.layer2.totalInputTokens` + `totalOutputTokens` plus their own pricing.
15
+ - **Layer 4** emits one event per stub applied: `{layer: 4, errorCode: <parsed from "TSNNNN">, fixed: true, latencyMs: 0, ts}`. Multi-error coalesced stubs emit one event per `(stub × errorCode)` pair.
16
+ - **`runFullStack(opts)`** — new top-level entrypoint that composes Layer 0/1 → Layer 2 (opt-in via `llm`) → Layer 4 (opt-in via `stubOnFailure`) and returns a unified `RunFullStackResult`. Callers who want "run the whole stack" no longer need to compose `runValidationLoop` + `runMendLoop` + (post-`runInProcessTsc` re-check) by hand. Library equivalent of the CLI's existing all-layers flow.
17
+ - **`RunFullStackResult`** flat shape: `passed`, `errorsBefore`, `errorsAfterLayer1`, `errorsAfterAllLayers`, `layer1` (LSPFixer sub-result), `layer2` (RunMendLoopResult | null), `layer4` (`{stubsApplied: AppliedStub[]} | null`), `totalCostUsd`, `totalLatencyMs`, `remainingByCode`, `remainingByFile`. Matches the v0.3.0 roadmap sketch for the "unified result" type with cost + telemetry rolled in.
18
+ - **10 new unit tests** in `src/runFullStack.test.ts` covering: clean workspace, Layer-1-only fix, unfixable-no-LLM, mocked-Layer-2 + cost math, unknown-model fallback, Layer-4 stubOnFailure path, per-error Layer-1 events, per-iteration Layer-2 events, per-stub Layer-4 events, undefined-callback smoke.
19
+
20
+ ### Added (multi-provider — Tier 2)
21
+ - **OpenAI and Google providers** for Layer 2. `runMendLoop` and `mendSingleFile` now accept `llm.provider: "anthropic" | "openai" | "google"` (was: `"anthropic"` only). Each provider uses its corresponding `@ai-sdk/X` package via a small `buildLanguageModel` factory in `mendAgent.ts`. The factory's `switch` is exhaustive — TypeScript flags missing cases if a new provider is added to the `LLMProvider` union.
22
+ - **`LLMProvider` type** exported from `src/index.ts`. Re-exportable for callers building their own CLI / pipeline integrations.
23
+ - **CLI `--llm-provider <name>`** flag — `anthropic` (default, back-compat), `openai`, or `google`. Invalid values exit 2 with a clear message.
24
+ - **Per-provider default models** when `--llm-model` is omitted: `claude-haiku-4-5` for anthropic, `gpt-5-mini` for openai, `gemini-2.5-flash` for google.
25
+ - **Per-provider env var routing** in the CLI: `--llm-provider anthropic` → `ANTHROPIC_API_KEY`, `openai` → `OPENAI_API_KEY`, `google` → `GOOGLE_GENERATIVE_AI_API_KEY`. The error message names the exact missing var.
26
+ - **Pricing table refreshed against current provider pricing pages (snapshot 2026-05-16):**
27
+ - **OpenAI:** `gpt-5-nano`, `gpt-5-mini`, `gpt-5`, `gpt-5.1`, `gpt-5.2`, `o3-mini`, `o4-mini`, `o3`. Default `--llm-model` for openai is `gpt-5-mini`.
28
+ - **Google:** `gemini-2.5-flash-lite`, `gemini-2.5-flash`, `gemini-2.5-pro`. Default for google is `gemini-2.5-flash`.
29
+ - **Anthropic (corrects v0.5.0 bugs):** `claude-haiku-4-5` was listed at `$0.80 / $4.00` — actual is **`$1.00 / $5.00`** (v0.5.0 carried the older Haiku 3.5 numbers). `claude-opus-4-7` was listed at `$15.00 / $75.00` — actual is **`$5.00 / $25.00`** (the 4.5 release dropped Opus pricing 3×; v0.5.0 carried the Opus 4.1 numbers). Now also lists `-sonnet-4-6`, `-opus-4-5`, `-opus-4-6`, `-opus-4-1` so callers pinning any 4.x model get accurate cost estimates.
30
+ - **Cost impact for v0.5.0 users:** `--llm-budget-usd` enforcement on `claude-haiku-4-5` was ~20% under-estimating actual spend; on `claude-opus-4-7` was ~3× over-estimating (your budget triggered earlier than it should have). Both fixed.
31
+ - Newer / unlisted models still fall back to cost=0 with a logger warning — `--llm-budget-usd` won't trigger for unpriced models.
32
+ - **CLI JSON report** `layer2.provider` field added.
33
+ - **CLI human report** Layer-2 line now shows `<provider>/<model>` instead of just `<model>`.
34
+ - **2 new cache tests** in `benchmark/cache.test.ts` covering provider-discrimination in the cache key + back-compat default to `anthropic` when provider is omitted (preserves v0.5.0 cache entries on upgrade).
35
+ - **5 new CLI tests** in `cli/run-stack.test.ts` covering `--help` listing all three providers + env-var names, invalid `--llm-provider` rejection, per-provider env-var routing, and the default-provider-is-anthropic back-compat case.
36
+
37
+ ### Changed
38
+ - **`tsLanguageServiceFixer.ts`** internal loop now emits a `LayerEvent` per fixable error attempt when `onLayerEvent` is provided to `LSPFixerOptions`. Loop control unchanged; if the callback is undefined the only cost is one optional-chaining check per fix.
39
+ - **`runMendLoop.ts`** emits per-iteration Layer-2 events and per-stub Layer-4 events when `onLayerEvent` is provided. New internal helpers `parseTsCode("TS2304") → 2304` and `dominantErrorCode(diags) → 2304` for event payload assembly.
40
+ - **`LLMCall` type** input gains optional `provider?: LLMProvider`. Optional so v0.5.0 callers' `LLMCall` injections still type-check (their callbacks just ignore the new field).
41
+ - **Cache key** at `benchmark/cache.ts` now includes provider: `sha256(systemBlock + " " + userBlock + " " + provider + " " + model)`. Provider defaults to `"anthropic"` when not passed → v0.5.0 cache entries remain valid for unchanged anthropic prompts.
42
+ - **`scripts/build.mjs`** externalizes `@ai-sdk/openai` and `@ai-sdk/google` (in addition to `@ai-sdk/anthropic` and `ai`). Consumers who never invoke Layer 2 still don't load any AI SDK; consumers who do get whichever provider package they hit.
43
+ - **Runtime dependencies added:** `@ai-sdk/openai@^3.0.64`, `@ai-sdk/google@^3.0.75`. Both are loaded lazily — the AI SDK package only loads when its corresponding provider is actually called.
44
+
45
+ ### Note on v0.6.0 npm tarball
46
+ The `0.6.0` tarball on the npm registry was published from a stale local checkout that was based on `8921356 (chore: release v0.5.0)` and never fetched the `feat/multi-provider` and `feat/tier-3-onlayerevent` PRs that had already merged to `main`. As a result, npm `0.6.0` contains library-aware error recovery (see [0.6.0] below) but **not** multi-provider or onLayerEvent. v0.6.1 is the first release that combines all three feature sets. We did not unpublish `0.6.0` to avoid leaving an unpublish tombstone in the registry; please upgrade directly to `0.6.1` or later.
47
+
7
48
  ## [0.6.0] - 2026-05-19
8
49
 
9
50
  **Library-aware error recovery.** Layer 2 now auto-detects breaking-change hints for known libraries from your `package.json` and steers the LLM away from tsc's misleading quick-fixes when a library migration is the real cause. Plus a hardened type-context walk (no more crashes on rename cascades or branded types) and a meaningful set of security anti-patterns in the system prompt.
@@ -265,7 +306,8 @@ Initial public release. **Layers 0–1 only** (deterministic detection + auto-fi
265
306
  - Node `>=20.9.0` (matches VS Code Extension Host runtime)
266
307
  - TypeScript `>=5.0.0` (peer dep, must be installed in the consuming workspace)
267
308
 
268
- [Unreleased]: https://github.com/owgreen-dev/tsfix/compare/v0.6.0...HEAD
309
+ [Unreleased]: https://github.com/owgreen-dev/tsfix/compare/v0.6.1...HEAD
310
+ [0.6.1]: https://github.com/owgreen-dev/tsfix/compare/v0.6.0...v0.6.1
269
311
  [0.6.0]: https://github.com/owgreen-dev/tsfix/compare/v0.5.0...v0.6.0
270
312
  [0.5.0]: https://github.com/owgreen-dev/tsfix/compare/v0.4.0...v0.5.0
271
313
  [0.4.0]: https://github.com/owgreen-dev/tsfix/compare/v0.3.0...v0.4.0
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  `@shipispec/tsfix` is what you reach for when you've just generated a few hundred files of TypeScript with an LLM and `tsc --noEmit` is screaming at you. It runs in layers:
6
6
 
7
7
  - **Layer 0/1** — Deterministic. Borrows the same TypeScript Language Service that powers VS Code's "Quick Fix" lightbulb and runs it as a CLI. Fixes typos, missing imports, and did-you-mean errors with no LLM, no network, no config.
8
- - **Layer 2** — Opt-in. A single-file LLM mend agent (Vercel AI SDK + Anthropic) that picks up what Layer 0 abstains on: TS2339 (property doesn't exist), TS7006 (implicit `any`), TS2741 (missing required prop), and other cases where the LSP can't statically derive the fix. Driven by **type-context injection** — when tsc says "Property 'foo' doesn't exist on type 'Bar'", tsfix resolves the `Bar` declaration via the TypeChecker and feeds its source to the model. As of v0.6.0, also **library-aware**: tsfix reads your `package.json` and injects breaking-change hints for known libraries (`vite-plugin-svgr`, `next`, `ai`, `drizzle-orm`) so the model picks the runtime-correct fix instead of tsc's misleading quick-fix.
8
+ - **Layer 2** — Opt-in. A single-file LLM mend agent (Vercel AI SDK) that picks up what Layer 0 abstains on: TS2339 (property doesn't exist), TS7006 (implicit `any`), TS2741 (missing required prop), and other cases where the LSP can't statically derive the fix. Driven by **type-context injection** — when tsc says "Property 'foo' doesn't exist on type 'Bar'", tsfix resolves the `Bar` declaration via the TypeChecker and feeds its source to the model. **Multi-provider** (Anthropic / OpenAI / Google) via `--llm-provider`. As of v0.6.0, also **library-aware**: tsfix reads your `package.json` and injects breaking-change hints for known libraries (`vite-plugin-svgr`, `next`, `ai`, `drizzle-orm`) so the model picks the runtime-correct fix instead of tsc's misleading quick-fix.
9
9
  - **Layer 4** — Escape hatch. When Layer 2 can't resolve the last few errors, opt in to `// @ts-expect-error - tsfix: ...` directives that self-destruct once the underlying issue is fixed elsewhere. tsfix never leaves the workspace worse than it found it.
10
10
 
11
11
  Layer 2 only runs if you explicitly call its API or set `ANTHROPIC_API_KEY` and pass `--llm` to the CLI. The default `tsfix --workspace ...` CLI is still **Layer 0/1 only**.
package/dist/cli.js CHANGED
@@ -192,7 +192,7 @@ var SAFE_FIX_NAMES = /* @__PURE__ */ new Set([
192
192
  // alternate spelling-fix name some TS versions emit
193
193
  ]);
194
194
  function runLSPFixerPass(opts) {
195
- const { workspaceRoot, targetFiles, logger } = opts;
195
+ const { workspaceRoot, targetFiles, logger, onLayerEvent } = opts;
196
196
  const maxIterations = opts.maxIterations ?? 5;
197
197
  const dryRun = opts.dryRun ?? false;
198
198
  const tsconfigPath = path2.join(workspaceRoot, "tsconfig.json");
@@ -279,15 +279,37 @@ function runLSPFixerPass(opts) {
279
279
  lastErrorSignatures = signatures;
280
280
  let appliedThisIter = 0;
281
281
  for (const err of fixableErrors) {
282
+ const errStartMs = Date.now();
282
283
  const fixes = safeGetCodeFixes(service, err);
283
284
  if (!fixes || fixes.length === 0) {
285
+ onLayerEvent?.({
286
+ layer: 1,
287
+ errorCode: err.code,
288
+ fixed: false,
289
+ latencyMs: Date.now() - errStartMs,
290
+ ts: Date.now()
291
+ });
284
292
  continue;
285
293
  }
286
294
  const safeFixes = fixes.filter((f) => SAFE_FIX_NAMES.has(f.fixName));
287
295
  if (safeFixes.length === 0) {
296
+ onLayerEvent?.({
297
+ layer: 1,
298
+ errorCode: err.code,
299
+ fixed: false,
300
+ latencyMs: Date.now() - errStartMs,
301
+ ts: Date.now()
302
+ });
288
303
  continue;
289
304
  }
290
305
  if (safeFixes.length > 1 && !fixesAreEquivalent(safeFixes)) {
306
+ onLayerEvent?.({
307
+ layer: 1,
308
+ errorCode: err.code,
309
+ fixed: false,
310
+ latencyMs: Date.now() - errStartMs,
311
+ ts: Date.now()
312
+ });
291
313
  continue;
292
314
  }
293
315
  const fix = safeFixes[0];
@@ -298,6 +320,21 @@ function runLSPFixerPass(opts) {
298
320
  for (const change of fix.changes) {
299
321
  filesEdited.add(change.fileName);
300
322
  }
323
+ onLayerEvent?.({
324
+ layer: 1,
325
+ errorCode: err.code,
326
+ fixed: true,
327
+ latencyMs: Date.now() - errStartMs,
328
+ ts: Date.now()
329
+ });
330
+ } else {
331
+ onLayerEvent?.({
332
+ layer: 1,
333
+ errorCode: err.code,
334
+ fixed: false,
335
+ latencyMs: Date.now() - errStartMs,
336
+ ts: Date.now()
337
+ });
301
338
  }
302
339
  }
303
340
  logger.info(
@@ -799,6 +836,8 @@ import * as fs6 from "node:fs";
799
836
  import * as path6 from "node:path";
800
837
  import { generateText } from "ai";
801
838
  import { createAnthropic } from "@ai-sdk/anthropic";
839
+ import { createOpenAI } from "@ai-sdk/openai";
840
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
802
841
 
803
842
  // src/libraryMigrations.ts
804
843
  import * as fs5 from "node:fs";
@@ -982,10 +1021,24 @@ ${lines.join("\n")}
982
1021
 
983
1022
  Emit SEARCH/REPLACE blocks to resolve.`;
984
1023
  }
985
- var defaultLLMCall = async ({ systemBlock, userBlock, model, apiKey }) => {
986
- const anthropic = createAnthropic({ apiKey });
1024
+ function buildLanguageModel(provider, model, apiKey) {
1025
+ switch (provider) {
1026
+ case "anthropic":
1027
+ return createAnthropic({ apiKey })(model);
1028
+ case "openai":
1029
+ return createOpenAI({ apiKey })(model);
1030
+ case "google":
1031
+ return createGoogleGenerativeAI({ apiKey })(model);
1032
+ default: {
1033
+ const _exhaustive = provider;
1034
+ throw new Error(`unknown provider: ${_exhaustive}`);
1035
+ }
1036
+ }
1037
+ }
1038
+ var defaultLLMCall = async ({ systemBlock, userBlock, provider = "anthropic", model, apiKey }) => {
1039
+ const llmModel = buildLanguageModel(provider, model, apiKey);
987
1040
  const result = await generateText({
988
- model: anthropic(model),
1041
+ model: llmModel,
989
1042
  system: systemBlock,
990
1043
  messages: [{ role: "user", content: userBlock }]
991
1044
  });
@@ -1007,6 +1060,7 @@ async function mendSingleFile(opts) {
1007
1060
  const llmResult = await _callLLM({
1008
1061
  systemBlock,
1009
1062
  userBlock,
1063
+ provider: llm.provider,
1010
1064
  model: llm.model,
1011
1065
  apiKey: llm.apiKey
1012
1066
  });
@@ -1211,8 +1265,35 @@ function refreshDiagnostics(workspaceRoot, files) {
1211
1265
  });
1212
1266
  return result.diagnostics.filter((d) => d.category === "error");
1213
1267
  }
1268
+ function parseTsCode(code) {
1269
+ const m = /^TS(\d+)$/.exec(code);
1270
+ return m ? parseInt(m[1], 10) : 0;
1271
+ }
1272
+ function dominantErrorCode(diags) {
1273
+ const counts = /* @__PURE__ */ new Map();
1274
+ for (const d of diags) {
1275
+ counts.set(d.code, (counts.get(d.code) ?? 0) + 1);
1276
+ }
1277
+ let bestCode = "";
1278
+ let bestCount = 0;
1279
+ for (const [code, count] of counts) {
1280
+ if (count > bestCount) {
1281
+ bestCount = count;
1282
+ bestCode = code;
1283
+ }
1284
+ }
1285
+ return parseTsCode(bestCode);
1286
+ }
1214
1287
  async function runMendLoop(opts) {
1215
- const { context: rawContext, llm, maxIterations = 3, dryRun = false, stubOnFailure = false, _callLLM } = opts;
1288
+ const {
1289
+ context: rawContext,
1290
+ llm,
1291
+ maxIterations = 3,
1292
+ dryRun = false,
1293
+ stubOnFailure = false,
1294
+ onLayerEvent,
1295
+ _callLLM
1296
+ } = opts;
1216
1297
  const startMs = Date.now();
1217
1298
  const context = rawContext.libraryMigrations === void 0 ? { ...rawContext, libraryMigrations: detectLibraryMigrations(rawContext.workspaceRoot) } : rawContext;
1218
1299
  const diagnosticsBefore = context.diagnostics.filter((d) => d.category === "error");
@@ -1263,6 +1344,13 @@ async function runMendLoop(opts) {
1263
1344
  latencyMs: mend.latencyMs,
1264
1345
  rawResponse: mend.rawResponse
1265
1346
  });
1347
+ onLayerEvent?.({
1348
+ layer: 2,
1349
+ errorCode: dominantErrorCode(currentDiags),
1350
+ fixed: newDiags.length === 0,
1351
+ latencyMs: mend.latencyMs,
1352
+ ts: Date.now()
1353
+ });
1266
1354
  if (dryRun) {
1267
1355
  currentDiags = newDiags;
1268
1356
  stopReason = "maxIterations";
@@ -1293,6 +1381,20 @@ async function runMendLoop(opts) {
1293
1381
  diagnostics: currentDiags
1294
1382
  });
1295
1383
  stubs = stubResult.stubsApplied;
1384
+ if (onLayerEvent) {
1385
+ const stubTs = Date.now();
1386
+ for (const stub of stubResult.stubsApplied) {
1387
+ for (const code of stub.codes) {
1388
+ onLayerEvent({
1389
+ layer: 4,
1390
+ errorCode: parseTsCode(code),
1391
+ fixed: true,
1392
+ latencyMs: 0,
1393
+ ts: stubTs
1394
+ });
1395
+ }
1396
+ }
1397
+ }
1296
1398
  const postStubDiags = refreshDiagnostics(context.workspaceRoot, filesInScope);
1297
1399
  if (postStubDiags.length === 0) {
1298
1400
  stopReason = "stubbed";
@@ -1369,7 +1471,13 @@ function runValidationLoop(opts) {
1369
1471
  iterations: 0
1370
1472
  };
1371
1473
  if (errorsBefore > 0 && !skipLSPFixer) {
1372
- const lsp = runLSPFixerPass({ workspaceRoot, targetFiles, logger, dryRun });
1474
+ const lsp = runLSPFixerPass({
1475
+ workspaceRoot,
1476
+ targetFiles,
1477
+ logger,
1478
+ dryRun,
1479
+ onLayerEvent: opts.onLayerEvent
1480
+ });
1373
1481
  lspFixer = {
1374
1482
  ran: true,
1375
1483
  fixesApplied: lsp.fixesApplied,
@@ -1402,16 +1510,59 @@ function runValidationLoop(opts) {
1402
1510
  }
1403
1511
 
1404
1512
  // cli/run-stack.ts
1405
- var ANTHROPIC_PRICING = {
1406
- "claude-haiku-4-5": { input: 0.8, output: 4 },
1407
- "claude-sonnet-4-5": { input: 3, output: 15 },
1408
- "claude-opus-4-7": { input: 15, output: 75 }
1513
+ var PRICING = {
1514
+ anthropic: {
1515
+ // All 4.5+ models share the same tier (the 4.5 release brought a
1516
+ // significant price drop on Opus). 4.1 retains the older Opus tier.
1517
+ "claude-haiku-4-5": { input: 1, output: 5 },
1518
+ "claude-sonnet-4-5": { input: 3, output: 15 },
1519
+ "claude-sonnet-4-6": { input: 3, output: 15 },
1520
+ "claude-opus-4-5": { input: 5, output: 25 },
1521
+ "claude-opus-4-6": { input: 5, output: 25 },
1522
+ "claude-opus-4-7": { input: 5, output: 25 },
1523
+ "claude-opus-4-1": { input: 15, output: 75 }
1524
+ },
1525
+ openai: {
1526
+ // Mini / nano tiers — well-matched to TypeScript repair (small
1527
+ // context, structured output). Default model uses one of these.
1528
+ "gpt-5-nano": { input: 0.05, output: 0.4 },
1529
+ "gpt-5-mini": { input: 0.25, output: 2 },
1530
+ // gpt-5 flagship + recent point releases (all $1.25 / $10).
1531
+ "gpt-5": { input: 1.25, output: 10 },
1532
+ "gpt-5.1": { input: 1.25, output: 10 },
1533
+ "gpt-5.2": { input: 1.75, output: 14 },
1534
+ // Reasoning models — sometimes better at semantic repair, more expensive.
1535
+ "o3-mini": { input: 1.1, output: 4.4 },
1536
+ "o4-mini": { input: 1.1, output: 4.4 },
1537
+ "o3": { input: 2, output: 8 }
1538
+ },
1539
+ google: {
1540
+ // Lite < flash < pro, matching the haiku/sonnet/opus mental model.
1541
+ "gemini-2.5-flash-lite": { input: 0.1, output: 0.4 },
1542
+ "gemini-2.5-flash": { input: 0.3, output: 2.5 },
1543
+ // Standard tier (≤200k tokens). 2.5-pro doubles to $2.50/$15.00 above
1544
+ // 200k — not modeled here since our prompts are well below that.
1545
+ "gemini-2.5-pro": { input: 1.25, output: 10 }
1546
+ }
1409
1547
  };
1410
- function estimateCostUsd(model, inputTokens, outputTokens) {
1411
- const p = ANTHROPIC_PRICING[model];
1548
+ function estimateCostUsd(provider, model, inputTokens, outputTokens) {
1549
+ const p = PRICING[provider]?.[model];
1412
1550
  if (!p) return 0;
1413
1551
  return (inputTokens * p.input + outputTokens * p.output) / 1e6;
1414
1552
  }
1553
+ var ENV_KEY_BY_PROVIDER = {
1554
+ anthropic: "ANTHROPIC_API_KEY",
1555
+ openai: "OPENAI_API_KEY",
1556
+ google: "GOOGLE_GENERATIVE_AI_API_KEY"
1557
+ };
1558
+ var DEFAULT_MODEL_BY_PROVIDER = {
1559
+ anthropic: "claude-haiku-4-5",
1560
+ openai: "gpt-5-mini",
1561
+ google: "gemini-2.5-flash"
1562
+ };
1563
+ function isLLMProvider(s) {
1564
+ return s === "anthropic" || s === "openai" || s === "google";
1565
+ }
1415
1566
  function parseArgs(argv) {
1416
1567
  const args = {
1417
1568
  workspace: "",
@@ -1421,7 +1572,11 @@ function parseArgs(argv) {
1421
1572
  files: void 0,
1422
1573
  verbose: false,
1423
1574
  llm: false,
1424
- llmModel: "claude-haiku-4-5",
1575
+ llmProvider: "anthropic",
1576
+ // llmModel default depends on provider — we set it AFTER parsing so
1577
+ // `--llm-provider openai` without `--llm-model` picks gpt-4o-mini, etc.
1578
+ // An empty string here means "use the provider's default".
1579
+ llmModel: "",
1425
1580
  llmMaxIterations: 3,
1426
1581
  llmBudgetUsd: void 0,
1427
1582
  noLibraryHints: false
@@ -1442,6 +1597,13 @@ function parseArgs(argv) {
1442
1597
  args.verbose = true;
1443
1598
  } else if (a === "--llm") {
1444
1599
  args.llm = true;
1600
+ } else if (a === "--llm-provider") {
1601
+ const p = argv[++i] ?? "";
1602
+ if (!isLLMProvider(p)) {
1603
+ console.error(`error: --llm-provider expects one of: anthropic, openai, google. Got '${p}'`);
1604
+ process.exit(2);
1605
+ }
1606
+ args.llmProvider = p;
1445
1607
  } else if (a === "--llm-model") {
1446
1608
  args.llmModel = argv[++i] ?? args.llmModel;
1447
1609
  } else if (a === "--llm-max-iterations") {
@@ -1470,6 +1632,9 @@ function parseArgs(argv) {
1470
1632
  printHelp();
1471
1633
  process.exit(2);
1472
1634
  }
1635
+ if (args.llmModel === "") {
1636
+ args.llmModel = DEFAULT_MODEL_BY_PROVIDER[args.llmProvider];
1637
+ }
1473
1638
  return args;
1474
1639
  }
1475
1640
  function printHelp() {
@@ -1485,12 +1650,23 @@ Layer 0/1 (default \u2014 deterministic, no network):
1485
1650
  --verbose, -v Stream layer logs to stderr
1486
1651
  --help, -h Show this help
1487
1652
 
1488
- Layer 2 (opt-in \u2014 single-file LLM mend via Anthropic):
1653
+ Layer 2 (opt-in \u2014 single-file LLM mend):
1489
1654
  --llm Enable Layer 2 on errors that survive Layer 0/1
1490
- --llm-model <name> Anthropic model (default: claude-haiku-4-5)
1491
- Known-priced models: claude-haiku-4-5,
1492
- claude-sonnet-4-5, claude-opus-4-7.
1493
- Cost estimate is 0 for unknown models.
1655
+ --llm-provider <name> anthropic | openai | google (default: anthropic)
1656
+ --llm-model <name> Model name. Defaults per provider:
1657
+ anthropic \u2192 claude-haiku-4-5
1658
+ openai \u2192 gpt-5-mini
1659
+ google \u2192 gemini-2.5-flash
1660
+ Known-priced models per provider:
1661
+ anthropic: claude-haiku-4-5, -sonnet-4-5,
1662
+ -sonnet-4-6, -opus-4-5, -opus-4-6,
1663
+ -opus-4-7, -opus-4-1
1664
+ openai: gpt-5-nano, gpt-5-mini, gpt-5,
1665
+ gpt-5.1, gpt-5.2, o3-mini, o4-mini, o3
1666
+ google: gemini-2.5-flash-lite, gemini-2.5-flash,
1667
+ gemini-2.5-pro
1668
+ Cost estimate is 0 for unlisted models (the
1669
+ warning suggests pinning a listed one).
1494
1670
  --llm-max-iterations <N> Cap on LLM retries (default: 3)
1495
1671
  --llm-budget-usd <amount> Soft cost cap. Exits with code 3 if exceeded.
1496
1672
  --no-library-hints Disable auto-detection of library breaking-change
@@ -1500,7 +1676,10 @@ Layer 2 (opt-in \u2014 single-file LLM mend via Anthropic):
1500
1676
  tsfix injects hints into Layer 2's prompt + skips
1501
1677
  Layer 0/1 (whose quick-fix would conflict).
1502
1678
 
1503
- Layer 2 requires ANTHROPIC_API_KEY in the environment.
1679
+ Layer 2 requires the provider's API key in env:
1680
+ anthropic \u2192 ANTHROPIC_API_KEY
1681
+ openai \u2192 OPENAI_API_KEY
1682
+ google \u2192 GOOGLE_GENERATIVE_AI_API_KEY
1504
1683
 
1505
1684
  Exit codes:
1506
1685
  0 no errors after stack
@@ -1551,7 +1730,7 @@ TSC Defense Stack \u2014 ${r.workspace}${r.dryRun ? " (dry-run)" : ""}
1551
1730
  ` Layer 2 (LLM): ${l2.errorsBefore} \u2192 ${l2.errorsAfter} errors ${l2.iterations}\xD7 iter ${l2.totalInputTokens}\u2192${l2.totalOutputTokens} tokens $${l2.totalCostUsd.toFixed(4)} ${l2.budgetExceeded ? "\u26A0\uFE0F budget exceeded" : ""}
1552
1731
  `
1553
1732
  );
1554
- w.write(` model=${l2.model} \xB7 stopReason=${l2.stopReason}
1733
+ w.write(` ${l2.provider}/${l2.model} \xB7 stopReason=${l2.stopReason}
1555
1734
  `);
1556
1735
  }
1557
1736
  w.write(` errors after: ${r.errorsAfter}
@@ -1622,14 +1801,15 @@ async function main() {
1622
1801
  console.error("error: --llm and --dry-run are mutually exclusive (Layer 2 writes patches to disk)");
1623
1802
  return 2;
1624
1803
  }
1625
- const apiKey = process.env.ANTHROPIC_API_KEY;
1804
+ const envKeyName = ENV_KEY_BY_PROVIDER[args.llmProvider];
1805
+ const apiKey = process.env[envKeyName];
1626
1806
  if (!apiKey) {
1627
- console.error("error: --llm requires ANTHROPIC_API_KEY in the environment");
1807
+ console.error(`error: --llm with provider '${args.llmProvider}' requires ${envKeyName} in the environment`);
1628
1808
  return 2;
1629
1809
  }
1630
- if (!ANTHROPIC_PRICING[args.llmModel]) {
1810
+ if (!PRICING[args.llmProvider]?.[args.llmModel]) {
1631
1811
  logger.warn(
1632
- `unknown model '${args.llmModel}' \u2014 cost estimates will be 0; budget cap will not trigger`
1812
+ `unknown model '${args.llmProvider}/${args.llmModel}' \u2014 cost estimates will be 0; budget cap will not trigger`
1633
1813
  );
1634
1814
  }
1635
1815
  const errorDiags = loop.diagnostics.filter((d) => d.category === "error");
@@ -1645,11 +1825,12 @@ async function main() {
1645
1825
  const layer2Start = Date.now();
1646
1826
  const mend = await runMendLoop({
1647
1827
  context,
1648
- llm: { provider: "anthropic", model: args.llmModel, apiKey },
1828
+ llm: { provider: args.llmProvider, model: args.llmModel, apiKey },
1649
1829
  maxIterations: args.llmMaxIterations
1650
1830
  });
1651
1831
  void layer2Start;
1652
1832
  const totalCostUsd = estimateCostUsd(
1833
+ args.llmProvider,
1653
1834
  args.llmModel,
1654
1835
  mend.totalInputTokens,
1655
1836
  mend.totalOutputTokens
@@ -1665,6 +1846,7 @@ async function main() {
1665
1846
  totalOutputTokens: mend.totalOutputTokens,
1666
1847
  totalCostUsd,
1667
1848
  budgetExceeded,
1849
+ provider: args.llmProvider,
1668
1850
  model: args.llmModel
1669
1851
  };
1670
1852
  const post = runInProcessTsc({
package/dist/index.d.ts CHANGED
@@ -82,6 +82,12 @@ export interface ValidationLoopOptions {
82
82
  dryRun?: boolean;
83
83
  /** Default: a no-op logger. Pass your own to capture layer events. */
84
84
  logger?: Logger;
85
+ /**
86
+ * Per-error telemetry callback for Layer 1 (LSP fixer). Fires once per
87
+ * fixable error with `{layer: 1, errorCode, fixed, latencyMs, ts}`. Optional;
88
+ * undefined callback costs nothing. See `LayerEvent`.
89
+ */
90
+ onLayerEvent?: (event: LayerEvent) => void;
85
91
  }
86
92
  export interface ValidationLoopResult {
87
93
  passed: boolean;
@@ -203,10 +209,88 @@ export type { TypeContextOptions, TypeContext } from "./typeContext.js";
203
209
  export { parseEditBlocks, applySingleBlock, applyEditBlocks } from "./applyEditBlock.js";
204
210
  export type { EditBlock, ApplyEditBlocksOptions, ApplyResult, SingleBlockResult, } from "./applyEditBlock.js";
205
211
  export { mendSingleFile } from "./mendAgent.js";
206
- export type { MendSingleFileOptions, MendSingleFileResult, LLMCall } from "./mendAgent.js";
212
+ export type { MendSingleFileOptions, MendSingleFileResult, LLMCall, LLMProvider, } from "./mendAgent.js";
207
213
  export { runMendLoop } from "./runMendLoop.js";
208
214
  export type { RunMendLoopOptions, RunMendLoopResult, MendLoopIteration, StopReason, } from "./runMendLoop.js";
209
215
  export { stubAndContinue } from "./stubAndContinue.js";
210
216
  export type { StubAndContinueOptions, StubAndContinueResult, AppliedStub, SkippedStub, } from "./stubAndContinue.js";
211
217
  export { BUILT_IN_LIBRARY_MIGRATIONS, detectLibraryMigrations, formatLibraryMigrationsBlock, formatLibraryMigrationsTaskDescription, } from "./libraryMigrations.js";
212
218
  export type { LibraryMigrationHint } from "./libraryMigrations.js";
219
+ import { type RunMendLoopResult } from "./runMendLoop.js";
220
+ import type { AppliedStub } from "./stubAndContinue.js";
221
+ import type { LLMProvider } from "./mendAgent.js";
222
+ export interface RunFullStackOptions {
223
+ /** Absolute path to the workspace (must contain `tsconfig.json`). */
224
+ workspaceRoot: string;
225
+ /** Files to scope to. If omitted, all `.ts`/`.tsx` files under workspaceRoot. */
226
+ targetFiles?: string[];
227
+ /** Skip Layer 0/1 LSP auto-fixer. Default false. */
228
+ skipLSPFixer?: boolean;
229
+ /**
230
+ * Layer 2 config. If omitted, the loop stops after Layer 0/1 (matches
231
+ * `runValidationLoop` behavior — no LLM calls).
232
+ */
233
+ llm?: {
234
+ provider: LLMProvider;
235
+ model: string;
236
+ apiKey: string;
237
+ maxIterations?: number;
238
+ };
239
+ /**
240
+ * After Layer 2 (if any), insert `// @ts-expect-error - tsfix: …` above
241
+ * each unresolved error site so tsc exits 0. Opt-in. Default false.
242
+ */
243
+ stubOnFailure?: boolean;
244
+ /**
245
+ * Run all layers in memory; report counts but don't write. Default false.
246
+ * Layer 2 is auto-skipped under dryRun (it writes patches; would be a
247
+ * no-op).
248
+ */
249
+ dryRun?: boolean;
250
+ /** Logger. Default no-op. */
251
+ logger?: Logger;
252
+ /** Per-layer telemetry stream. Forwarded to Layer 1, 2, 4. */
253
+ onLayerEvent?: (event: LayerEvent) => void;
254
+ /** @internal — LLM call override. Tests inject a fake; real callers leave it. */
255
+ _callLLM?: import("./mendAgent.js").LLMCall;
256
+ }
257
+ export interface RunFullStackResult {
258
+ /** True if `errorsAfterAllLayers === 0`. */
259
+ passed: boolean;
260
+ /** Errors detected before any fix attempt. */
261
+ errorsBefore: number;
262
+ /** Errors remaining after Layer 0/1 (before Layer 2 + 4). */
263
+ errorsAfterLayer1: number;
264
+ /** Errors remaining after every layer that ran. */
265
+ errorsAfterAllLayers: number;
266
+ /** Per-layer sub-results. `layer2` is null when `llm` was omitted; `layer4` is null when `stubOnFailure` was false (or had no candidates). */
267
+ layer1: ValidationLoopResult["lspFixer"];
268
+ layer2: RunMendLoopResult | null;
269
+ layer4: {
270
+ stubsApplied: AppliedStub[];
271
+ } | null;
272
+ /** USD spent on Layer 2. 0 when Layer 2 didn't run. */
273
+ totalCostUsd: number;
274
+ /** Wall-clock total across all layers. */
275
+ totalLatencyMs: number;
276
+ /** Remaining diagnostics, grouped by TS code (e.g. `{TS2339: 3}`). */
277
+ remainingByCode: Record<string, number>;
278
+ /** Remaining diagnostics, grouped by file path (relative to workspaceRoot). */
279
+ remainingByFile: Record<string, number>;
280
+ }
281
+ /**
282
+ * Run the full tsfix stack (Layer 0/1 → Layer 2 → Layer 4) end-to-end.
283
+ *
284
+ * @example
285
+ * ```ts
286
+ * const result = await runFullStack({
287
+ * workspaceRoot: "/path/to/project",
288
+ * llm: { provider: "anthropic", model: "claude-haiku-4-5", apiKey: KEY },
289
+ * stubOnFailure: true,
290
+ * onLayerEvent: (e) => console.log(e),
291
+ * });
292
+ * if (!result.passed) { ... }
293
+ * console.log(`Spent $${result.totalCostUsd.toFixed(4)}`);
294
+ * ```
295
+ */
296
+ export declare function runFullStack(opts: RunFullStackOptions): Promise<RunFullStackResult>;
package/dist/index.js CHANGED
@@ -189,7 +189,7 @@ var SAFE_FIX_NAMES = /* @__PURE__ */ new Set([
189
189
  // alternate spelling-fix name some TS versions emit
190
190
  ]);
191
191
  function runLSPFixerPass(opts) {
192
- const { workspaceRoot, targetFiles, logger } = opts;
192
+ const { workspaceRoot, targetFiles, logger, onLayerEvent } = opts;
193
193
  const maxIterations = opts.maxIterations ?? 5;
194
194
  const dryRun = opts.dryRun ?? false;
195
195
  const tsconfigPath = path2.join(workspaceRoot, "tsconfig.json");
@@ -276,15 +276,37 @@ function runLSPFixerPass(opts) {
276
276
  lastErrorSignatures = signatures;
277
277
  let appliedThisIter = 0;
278
278
  for (const err of fixableErrors) {
279
+ const errStartMs = Date.now();
279
280
  const fixes = safeGetCodeFixes(service, err);
280
281
  if (!fixes || fixes.length === 0) {
282
+ onLayerEvent?.({
283
+ layer: 1,
284
+ errorCode: err.code,
285
+ fixed: false,
286
+ latencyMs: Date.now() - errStartMs,
287
+ ts: Date.now()
288
+ });
281
289
  continue;
282
290
  }
283
291
  const safeFixes = fixes.filter((f) => SAFE_FIX_NAMES.has(f.fixName));
284
292
  if (safeFixes.length === 0) {
293
+ onLayerEvent?.({
294
+ layer: 1,
295
+ errorCode: err.code,
296
+ fixed: false,
297
+ latencyMs: Date.now() - errStartMs,
298
+ ts: Date.now()
299
+ });
285
300
  continue;
286
301
  }
287
302
  if (safeFixes.length > 1 && !fixesAreEquivalent(safeFixes)) {
303
+ onLayerEvent?.({
304
+ layer: 1,
305
+ errorCode: err.code,
306
+ fixed: false,
307
+ latencyMs: Date.now() - errStartMs,
308
+ ts: Date.now()
309
+ });
288
310
  continue;
289
311
  }
290
312
  const fix = safeFixes[0];
@@ -295,6 +317,21 @@ function runLSPFixerPass(opts) {
295
317
  for (const change of fix.changes) {
296
318
  filesEdited.add(change.fileName);
297
319
  }
320
+ onLayerEvent?.({
321
+ layer: 1,
322
+ errorCode: err.code,
323
+ fixed: true,
324
+ latencyMs: Date.now() - errStartMs,
325
+ ts: Date.now()
326
+ });
327
+ } else {
328
+ onLayerEvent?.({
329
+ layer: 1,
330
+ errorCode: err.code,
331
+ fixed: false,
332
+ latencyMs: Date.now() - errStartMs,
333
+ ts: Date.now()
334
+ });
298
335
  }
299
336
  }
300
337
  logger.info(
@@ -804,6 +841,8 @@ import * as fs6 from "node:fs";
804
841
  import * as path6 from "node:path";
805
842
  import { generateText } from "ai";
806
843
  import { createAnthropic } from "@ai-sdk/anthropic";
844
+ import { createOpenAI } from "@ai-sdk/openai";
845
+ import { createGoogleGenerativeAI } from "@ai-sdk/google";
807
846
 
808
847
  // src/libraryMigrations.ts
809
848
  import * as fs5 from "node:fs";
@@ -987,10 +1026,24 @@ ${lines.join("\n")}
987
1026
 
988
1027
  Emit SEARCH/REPLACE blocks to resolve.`;
989
1028
  }
990
- var defaultLLMCall = async ({ systemBlock, userBlock, model, apiKey }) => {
991
- const anthropic = createAnthropic({ apiKey });
1029
+ function buildLanguageModel(provider, model, apiKey) {
1030
+ switch (provider) {
1031
+ case "anthropic":
1032
+ return createAnthropic({ apiKey })(model);
1033
+ case "openai":
1034
+ return createOpenAI({ apiKey })(model);
1035
+ case "google":
1036
+ return createGoogleGenerativeAI({ apiKey })(model);
1037
+ default: {
1038
+ const _exhaustive = provider;
1039
+ throw new Error(`unknown provider: ${_exhaustive}`);
1040
+ }
1041
+ }
1042
+ }
1043
+ var defaultLLMCall = async ({ systemBlock, userBlock, provider = "anthropic", model, apiKey }) => {
1044
+ const llmModel = buildLanguageModel(provider, model, apiKey);
992
1045
  const result = await generateText({
993
- model: anthropic(model),
1046
+ model: llmModel,
994
1047
  system: systemBlock,
995
1048
  messages: [{ role: "user", content: userBlock }]
996
1049
  });
@@ -1012,6 +1065,7 @@ async function mendSingleFile(opts) {
1012
1065
  const llmResult = await _callLLM({
1013
1066
  systemBlock,
1014
1067
  userBlock,
1068
+ provider: llm.provider,
1015
1069
  model: llm.model,
1016
1070
  apiKey: llm.apiKey
1017
1071
  });
@@ -1216,8 +1270,35 @@ function refreshDiagnostics(workspaceRoot, files) {
1216
1270
  });
1217
1271
  return result.diagnostics.filter((d) => d.category === "error");
1218
1272
  }
1273
+ function parseTsCode(code) {
1274
+ const m = /^TS(\d+)$/.exec(code);
1275
+ return m ? parseInt(m[1], 10) : 0;
1276
+ }
1277
+ function dominantErrorCode(diags) {
1278
+ const counts = /* @__PURE__ */ new Map();
1279
+ for (const d of diags) {
1280
+ counts.set(d.code, (counts.get(d.code) ?? 0) + 1);
1281
+ }
1282
+ let bestCode = "";
1283
+ let bestCount = 0;
1284
+ for (const [code, count] of counts) {
1285
+ if (count > bestCount) {
1286
+ bestCount = count;
1287
+ bestCode = code;
1288
+ }
1289
+ }
1290
+ return parseTsCode(bestCode);
1291
+ }
1219
1292
  async function runMendLoop(opts) {
1220
- const { context: rawContext, llm, maxIterations = 3, dryRun = false, stubOnFailure = false, _callLLM } = opts;
1293
+ const {
1294
+ context: rawContext,
1295
+ llm,
1296
+ maxIterations = 3,
1297
+ dryRun = false,
1298
+ stubOnFailure = false,
1299
+ onLayerEvent,
1300
+ _callLLM
1301
+ } = opts;
1221
1302
  const startMs = Date.now();
1222
1303
  const context = rawContext.libraryMigrations === void 0 ? { ...rawContext, libraryMigrations: detectLibraryMigrations(rawContext.workspaceRoot) } : rawContext;
1223
1304
  const diagnosticsBefore = context.diagnostics.filter((d) => d.category === "error");
@@ -1268,6 +1349,13 @@ async function runMendLoop(opts) {
1268
1349
  latencyMs: mend.latencyMs,
1269
1350
  rawResponse: mend.rawResponse
1270
1351
  });
1352
+ onLayerEvent?.({
1353
+ layer: 2,
1354
+ errorCode: dominantErrorCode(currentDiags),
1355
+ fixed: newDiags.length === 0,
1356
+ latencyMs: mend.latencyMs,
1357
+ ts: Date.now()
1358
+ });
1271
1359
  if (dryRun) {
1272
1360
  currentDiags = newDiags;
1273
1361
  stopReason = "maxIterations";
@@ -1298,6 +1386,20 @@ async function runMendLoop(opts) {
1298
1386
  diagnostics: currentDiags
1299
1387
  });
1300
1388
  stubs = stubResult.stubsApplied;
1389
+ if (onLayerEvent) {
1390
+ const stubTs = Date.now();
1391
+ for (const stub of stubResult.stubsApplied) {
1392
+ for (const code of stub.codes) {
1393
+ onLayerEvent({
1394
+ layer: 4,
1395
+ errorCode: parseTsCode(code),
1396
+ fixed: true,
1397
+ latencyMs: 0,
1398
+ ts: stubTs
1399
+ });
1400
+ }
1401
+ }
1402
+ }
1301
1403
  const postStubDiags = refreshDiagnostics(context.workspaceRoot, filesInScope);
1302
1404
  if (postStubDiags.length === 0) {
1303
1405
  stopReason = "stubbed";
@@ -1374,7 +1476,13 @@ function runValidationLoop(opts) {
1374
1476
  iterations: 0
1375
1477
  };
1376
1478
  if (errorsBefore > 0 && !skipLSPFixer) {
1377
- const lsp = runLSPFixerPass({ workspaceRoot, targetFiles, logger, dryRun });
1479
+ const lsp = runLSPFixerPass({
1480
+ workspaceRoot,
1481
+ targetFiles,
1482
+ logger,
1483
+ dryRun,
1484
+ onLayerEvent: opts.onLayerEvent
1485
+ });
1378
1486
  lspFixer = {
1379
1487
  ran: true,
1380
1488
  fixesApplied: lsp.fixesApplied,
@@ -1405,6 +1513,100 @@ function runValidationLoop(opts) {
1405
1513
  elapsedMs: Date.now() - startMs
1406
1514
  };
1407
1515
  }
1516
+ var PRICING = {
1517
+ anthropic: {
1518
+ "claude-haiku-4-5": { input: 1, output: 5 },
1519
+ "claude-sonnet-4-5": { input: 3, output: 15 },
1520
+ "claude-sonnet-4-6": { input: 3, output: 15 },
1521
+ "claude-opus-4-5": { input: 5, output: 25 },
1522
+ "claude-opus-4-6": { input: 5, output: 25 },
1523
+ "claude-opus-4-7": { input: 5, output: 25 },
1524
+ "claude-opus-4-1": { input: 15, output: 75 }
1525
+ },
1526
+ openai: {
1527
+ "gpt-5-nano": { input: 0.05, output: 0.4 },
1528
+ "gpt-5-mini": { input: 0.25, output: 2 },
1529
+ "gpt-5": { input: 1.25, output: 10 },
1530
+ "gpt-5.1": { input: 1.25, output: 10 },
1531
+ "gpt-5.2": { input: 1.75, output: 14 },
1532
+ "o3-mini": { input: 1.1, output: 4.4 },
1533
+ "o4-mini": { input: 1.1, output: 4.4 },
1534
+ "o3": { input: 2, output: 8 }
1535
+ },
1536
+ google: {
1537
+ "gemini-2.5-flash-lite": { input: 0.1, output: 0.4 },
1538
+ "gemini-2.5-flash": { input: 0.3, output: 2.5 },
1539
+ "gemini-2.5-pro": { input: 1.25, output: 10 }
1540
+ }
1541
+ };
1542
+ function costUsd(provider, model, inputTokens, outputTokens) {
1543
+ const p = PRICING[provider]?.[model];
1544
+ if (!p) return 0;
1545
+ return (inputTokens * p.input + outputTokens * p.output) / 1e6;
1546
+ }
1547
+ async function runFullStack(opts) {
1548
+ const startMs = Date.now();
1549
+ const { workspaceRoot, llm, stubOnFailure = false, dryRun = false, onLayerEvent } = opts;
1550
+ const layer1 = runValidationLoop({
1551
+ workspaceRoot,
1552
+ targetFiles: opts.targetFiles,
1553
+ skipLSPFixer: opts.skipLSPFixer,
1554
+ dryRun,
1555
+ logger: opts.logger,
1556
+ onLayerEvent
1557
+ });
1558
+ let layer2 = null;
1559
+ let layer4 = null;
1560
+ let totalCostUsd = 0;
1561
+ let finalDiagnostics = layer1.diagnostics;
1562
+ const shouldRunLayer2 = llm && !dryRun && layer1.errorsAfter > 0;
1563
+ if (shouldRunLayer2) {
1564
+ const errorDiags = layer1.diagnostics.filter((d) => d.category === "error");
1565
+ layer2 = await runMendLoop({
1566
+ context: {
1567
+ workspaceRoot,
1568
+ diagnostics: errorDiags,
1569
+ erroredFiles: Array.from(new Set(errorDiags.map((d) => d.file)))
1570
+ },
1571
+ llm: { provider: llm.provider, model: llm.model, apiKey: llm.apiKey },
1572
+ maxIterations: llm.maxIterations,
1573
+ stubOnFailure,
1574
+ onLayerEvent,
1575
+ _callLLM: opts._callLLM
1576
+ });
1577
+ totalCostUsd = costUsd(llm.provider, llm.model, layer2.totalInputTokens, layer2.totalOutputTokens);
1578
+ if (layer2.stubs && layer2.stubs.length > 0) {
1579
+ layer4 = { stubsApplied: layer2.stubs };
1580
+ }
1581
+ resetInProcessTscCache();
1582
+ const post = runInProcessTsc({
1583
+ workspaceRoot,
1584
+ generatedFiles: opts.targetFiles ?? discoverTsFiles(workspaceRoot),
1585
+ logger: opts.logger ?? noopLogger3
1586
+ });
1587
+ finalDiagnostics = post.diagnostics;
1588
+ }
1589
+ const finalErrorDiags = finalDiagnostics.filter((d) => d.category === "error");
1590
+ const remainingByCode = {};
1591
+ const remainingByFile = {};
1592
+ for (const d of finalErrorDiags) {
1593
+ remainingByCode[d.code] = (remainingByCode[d.code] ?? 0) + 1;
1594
+ remainingByFile[d.file] = (remainingByFile[d.file] ?? 0) + 1;
1595
+ }
1596
+ return {
1597
+ passed: finalErrorDiags.length === 0,
1598
+ errorsBefore: layer1.errorsBefore,
1599
+ errorsAfterLayer1: layer1.errorsAfter,
1600
+ errorsAfterAllLayers: finalErrorDiags.length,
1601
+ layer1: layer1.lspFixer,
1602
+ layer2,
1603
+ layer4,
1604
+ totalCostUsd,
1605
+ totalLatencyMs: Date.now() - startMs,
1606
+ remainingByCode,
1607
+ remainingByFile
1608
+ };
1609
+ }
1408
1610
  export {
1409
1611
  BUILT_IN_LIBRARY_MIGRATIONS,
1410
1612
  applyEditBlocks,
@@ -1421,6 +1623,7 @@ export {
1421
1623
  resetInProcessTscCache,
1422
1624
  resetLSPFixerCache,
1423
1625
  resetTypeContextCache,
1626
+ runFullStack,
1424
1627
  runInProcessTsc,
1425
1628
  runLSPFixerPass,
1426
1629
  runMendLoop,
@@ -82,6 +82,12 @@ export interface ValidationLoopOptions {
82
82
  dryRun?: boolean;
83
83
  /** Default: a no-op logger. Pass your own to capture layer events. */
84
84
  logger?: Logger;
85
+ /**
86
+ * Per-error telemetry callback for Layer 1 (LSP fixer). Fires once per
87
+ * fixable error with `{layer: 1, errorCode, fixed, latencyMs, ts}`. Optional;
88
+ * undefined callback costs nothing. See `LayerEvent`.
89
+ */
90
+ onLayerEvent?: (event: LayerEvent) => void;
85
91
  }
86
92
  export interface ValidationLoopResult {
87
93
  passed: boolean;
@@ -203,10 +209,88 @@ export type { TypeContextOptions, TypeContext } from "./typeContext.js";
203
209
  export { parseEditBlocks, applySingleBlock, applyEditBlocks } from "./applyEditBlock.js";
204
210
  export type { EditBlock, ApplyEditBlocksOptions, ApplyResult, SingleBlockResult, } from "./applyEditBlock.js";
205
211
  export { mendSingleFile } from "./mendAgent.js";
206
- export type { MendSingleFileOptions, MendSingleFileResult, LLMCall } from "./mendAgent.js";
212
+ export type { MendSingleFileOptions, MendSingleFileResult, LLMCall, LLMProvider, } from "./mendAgent.js";
207
213
  export { runMendLoop } from "./runMendLoop.js";
208
214
  export type { RunMendLoopOptions, RunMendLoopResult, MendLoopIteration, StopReason, } from "./runMendLoop.js";
209
215
  export { stubAndContinue } from "./stubAndContinue.js";
210
216
  export type { StubAndContinueOptions, StubAndContinueResult, AppliedStub, SkippedStub, } from "./stubAndContinue.js";
211
217
  export { BUILT_IN_LIBRARY_MIGRATIONS, detectLibraryMigrations, formatLibraryMigrationsBlock, formatLibraryMigrationsTaskDescription, } from "./libraryMigrations.js";
212
218
  export type { LibraryMigrationHint } from "./libraryMigrations.js";
219
+ import { type RunMendLoopResult } from "./runMendLoop.js";
220
+ import type { AppliedStub } from "./stubAndContinue.js";
221
+ import type { LLMProvider } from "./mendAgent.js";
222
+ export interface RunFullStackOptions {
223
+ /** Absolute path to the workspace (must contain `tsconfig.json`). */
224
+ workspaceRoot: string;
225
+ /** Files to scope to. If omitted, all `.ts`/`.tsx` files under workspaceRoot. */
226
+ targetFiles?: string[];
227
+ /** Skip Layer 0/1 LSP auto-fixer. Default false. */
228
+ skipLSPFixer?: boolean;
229
+ /**
230
+ * Layer 2 config. If omitted, the loop stops after Layer 0/1 (matches
231
+ * `runValidationLoop` behavior — no LLM calls).
232
+ */
233
+ llm?: {
234
+ provider: LLMProvider;
235
+ model: string;
236
+ apiKey: string;
237
+ maxIterations?: number;
238
+ };
239
+ /**
240
+ * After Layer 2 (if any), insert `// @ts-expect-error - tsfix: …` above
241
+ * each unresolved error site so tsc exits 0. Opt-in. Default false.
242
+ */
243
+ stubOnFailure?: boolean;
244
+ /**
245
+ * Run all layers in memory; report counts but don't write. Default false.
246
+ * Layer 2 is auto-skipped under dryRun (it writes patches; would be a
247
+ * no-op).
248
+ */
249
+ dryRun?: boolean;
250
+ /** Logger. Default no-op. */
251
+ logger?: Logger;
252
+ /** Per-layer telemetry stream. Forwarded to Layer 1, 2, 4. */
253
+ onLayerEvent?: (event: LayerEvent) => void;
254
+ /** @internal — LLM call override. Tests inject a fake; real callers leave it. */
255
+ _callLLM?: import("./mendAgent.js").LLMCall;
256
+ }
257
+ export interface RunFullStackResult {
258
+ /** True if `errorsAfterAllLayers === 0`. */
259
+ passed: boolean;
260
+ /** Errors detected before any fix attempt. */
261
+ errorsBefore: number;
262
+ /** Errors remaining after Layer 0/1 (before Layer 2 + 4). */
263
+ errorsAfterLayer1: number;
264
+ /** Errors remaining after every layer that ran. */
265
+ errorsAfterAllLayers: number;
266
+ /** Per-layer sub-results. `layer2` is null when `llm` was omitted; `layer4` is null when `stubOnFailure` was false (or had no candidates). */
267
+ layer1: ValidationLoopResult["lspFixer"];
268
+ layer2: RunMendLoopResult | null;
269
+ layer4: {
270
+ stubsApplied: AppliedStub[];
271
+ } | null;
272
+ /** USD spent on Layer 2. 0 when Layer 2 didn't run. */
273
+ totalCostUsd: number;
274
+ /** Wall-clock total across all layers. */
275
+ totalLatencyMs: number;
276
+ /** Remaining diagnostics, grouped by TS code (e.g. `{TS2339: 3}`). */
277
+ remainingByCode: Record<string, number>;
278
+ /** Remaining diagnostics, grouped by file path (relative to workspaceRoot). */
279
+ remainingByFile: Record<string, number>;
280
+ }
281
+ /**
282
+ * Run the full tsfix stack (Layer 0/1 → Layer 2 → Layer 4) end-to-end.
283
+ *
284
+ * @example
285
+ * ```ts
286
+ * const result = await runFullStack({
287
+ * workspaceRoot: "/path/to/project",
288
+ * llm: { provider: "anthropic", model: "claude-haiku-4-5", apiKey: KEY },
289
+ * stubOnFailure: true,
290
+ * onLayerEvent: (e) => console.log(e),
291
+ * });
292
+ * if (!result.passed) { ... }
293
+ * console.log(`Spent $${result.totalCostUsd.toFixed(4)}`);
294
+ * ```
295
+ */
296
+ export declare function runFullStack(opts: RunFullStackOptions): Promise<RunFullStackResult>;
@@ -16,10 +16,17 @@
16
16
  */
17
17
  import type { MendContext } from "./index.js";
18
18
  import { type ApplyResult, type EditBlock } from "./applyEditBlock.js";
19
+ /**
20
+ * Provider identifier. Each corresponds to one `@ai-sdk/<provider>` adapter
21
+ * in the Vercel AI SDK. Adding a provider requires (1) the corresponding
22
+ * `@ai-sdk/X` dep in package.json, (2) a case in `buildLanguageModel`, and
23
+ * (3) the appropriate API-key env-var name documented in the CLI.
24
+ */
25
+ export type LLMProvider = "anthropic" | "openai" | "google";
19
26
  export interface MendSingleFileOptions {
20
27
  context: MendContext;
21
28
  llm: {
22
- provider: "anthropic";
29
+ provider: LLMProvider;
23
30
  model: string;
24
31
  apiKey: string;
25
32
  };
@@ -39,6 +46,12 @@ export interface MendSingleFileResult {
39
46
  export type LLMCall = (params: {
40
47
  systemBlock: string;
41
48
  userBlock: string;
49
+ /**
50
+ * Provider name. Optional for backward compatibility — when omitted,
51
+ * the default impl treats it as "anthropic" (matches v0.5.0 behavior).
52
+ * Test injection points can ignore this field.
53
+ */
54
+ provider?: LLMProvider;
42
55
  model: string;
43
56
  apiKey: string;
44
57
  }) => Promise<{
@@ -22,13 +22,13 @@
22
22
  * returns. We can't iterate without writing to disk because re-validation
23
23
  * needs the actual file changes.
24
24
  */
25
- import type { Diagnostic, MendContext } from "./index.js";
26
- import { type LLMCall } from "./mendAgent.js";
25
+ import type { Diagnostic, LayerEvent, MendContext } from "./index.js";
26
+ import { type LLMCall, type LLMProvider } from "./mendAgent.js";
27
27
  import { type AppliedStub } from "./stubAndContinue.js";
28
28
  export interface RunMendLoopOptions {
29
29
  context: MendContext;
30
30
  llm: {
31
- provider: "anthropic";
31
+ provider: LLMProvider;
32
32
  model: string;
33
33
  apiKey: string;
34
34
  };
@@ -43,6 +43,13 @@ export interface RunMendLoopOptions {
43
43
  * Default false. Ignored when `dryRun: true`.
44
44
  */
45
45
  stubOnFailure?: boolean;
46
+ /**
47
+ * Per-iteration / per-stub telemetry callback. Layer 2 emits one event per
48
+ * iteration with the dominant error code (`fixed: true` if the iteration
49
+ * cleared all errors); Layer 4 emits one event per stubbed `(line, code)`
50
+ * pair. Both forwarded to the same callback. Optional.
51
+ */
52
+ onLayerEvent?: (event: LayerEvent) => void;
46
53
  /** @internal — LLM call override for tests. */
47
54
  _callLLM?: LLMCall;
48
55
  }
@@ -51,6 +51,13 @@ export interface LSPFixerOptions {
51
51
  * before letting it modify a workspace.
52
52
  */
53
53
  dryRun?: boolean;
54
+ /**
55
+ * Per-error telemetry callback. One event per `(errorCode, fix-attempt)`
56
+ * with `fixed: true` when the fix landed and `fixed: false` when the LSP
57
+ * abstained (no safe candidate). Events fire even on dry runs.
58
+ * Optional — undefined callback costs nothing.
59
+ */
60
+ onLayerEvent?: (event: import("./index.js").LayerEvent) => void;
54
61
  }
55
62
  export interface LSPFixerResult {
56
63
  /** Number of fixes successfully applied across all iterations. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipispec/tsfix",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "TypeScript error-recovery for LLM-generated code. Layer 0/1 deterministic auto-fix via the TS Language Service + Layer 2 LLM mend (Vercel AI SDK + Anthropic) in one package.",
5
5
  "keywords": [
6
6
  "typescript",
@@ -65,6 +65,8 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@ai-sdk/anthropic": "^3.0.44",
68
+ "@ai-sdk/google": "^3.0.75",
69
+ "@ai-sdk/openai": "^3.0.64",
68
70
  "ai": "^6.0.86"
69
71
  },
70
72
  "devDependencies": {