CI Safety Net Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give NeuroLink's provider layer a CI safety net that runs on every PR with zero API keys — structural registry checks plus mocked request/response/error-mapping contracts for the five highest-traffic providers — and wire it into branch protection, pre-push, and a separate nightly live-credential sweep, so a broken provider integration fails CI instead of shipping silently.
Architecture: Two zero-API structural checks are extracted from the live-credential continuous-test-suite-providers.ts into a standalone suite so they can run without secrets; a new required CI job runs build freshness + that suite + the existing mocked-contract suite; the mocked-contract suite gains five new provider sections (OpenAI, Azure, Anthropic via real fetch interception; Vertex, Bedrock via construction + formatProviderError contract, since their SDKs bypass globalThis.fetch); branch protection, the pre-push hook, and a scheduled live-matrix.yml workflow are updated to match; stale docs/comments are corrected in place.
Tech Stack: TypeScript, tsx (no vitest runner), pnpm, GitHub Actions, Husky v9, test/helpers/harness.ts (defineSuite/test/assert), test/utils/mockFetch.ts (installMockFetch/record/expect/expectEq).
Spec: Verified architecture-audit reports for NeuroLink's provider-scaling initiative (CI/testing-coverage gap analysis), treated as spec; this plan additionally re-verifies every referenced line of source/config against the current feat/proider-redesign worktree as of 2026-08-15 (see inline file/line citations in each task).
Global Constraints
- pnpm ONLY. Build:
pnpm run build. Typecheck:pnpm run check. Lint:pnpm run lint. - Tests run via tsx, NOT vitest:
npx tsx test/continuous-test-suite-<name>.ts; new suites need a matchingtest:<name>script inpackage.json. - TEST HARNESS SKIP HAZARD:
defineSuite'stest()classifies a thrown error as SKIP (not FAIL) when the message starts withSKIP:, is aSkipinstance, or matchesisExpectedProviderError()fromtest/helpers/envGuard.ts. Never interpolate raw payloads/error text intoassert()messages — describe the mismatch abstractly (e.g.`${failures.length} registry/filesystem mismatch(es) found`, not the raw diff).test/utils/mockFetch.ts'srecord()/expect()/expectEq()helpers do not do SKIP classification (confirmed by reading the full 232-line file:record()just pushes{name, ok, reason}) — so this hazard applies to Task 1's new suite (usesdefineSuite/assert()) but not to Tasks 5-9 (extend therecord()/expect()-based mocked suite, which has no skip concept at all). - Repo rules (from
CLAUDE.md): dynamic imports only inproviderRegistry.ts; all types insrc/lib/types/; nointerface(alwaystype); unique type names acrosssrc/lib/types/; barrelexport *only; barrel-only type imports outsidesrc/lib/types/; no double type assertions (x as unknown as T) insrc/— except test files, which are explicitly exempt under rule 14, used here in Tasks 8-9 to invokeprotectedformatProviderError. - Conventional commits; one commit per task; NEVER
git push. - Plan-specific constraint: every new/modified mocked-provider test section must assert on values already confirmed against the live
formatProviderError/constructor source in this plan's task bodies — no guessed error-classifier behavior.
Task 1: Extract zero-API provider-structure checks into a standalone suite
Files:
- Create:
test/continuous-test-suite-provider-structure.ts - Modify:
test/continuous-test-suite-providers.ts(remove the two extracted functions + theirtestsarray entries + the now-deadpathimport) - Modify:
package.json(addtest:provider-structurescript)
Interfaces:
- Consumes:
defineSuite,assertfrom./helpers/harness.js;assertDistFreshfrom./helpers/distFreshness.js;../dist/index.js(AIProviderName),../dist/lib/factories/providerRegistry.js(ProviderRegistry),../dist/lib/factories/providerFactory.js(ProviderFactory),../dist/lib/constants/enums.js(AIProviderName). - Produces: new script
test/continuous-test-suite-provider-structure.tsrunnable viapnpm run test:provider-structure; two named checks —"Model Registry Completeness","Provider Registration Completeness".
Why extract (justification for keep-or-remove): testModelRegistryCompleteness (test/continuous-test-suite-providers.ts:2057-2178) and testProviderRegistrationCompleteness (:2195-2366) make zero live API calls — they only inspect dist/ artifacts and the filesystem — but they currently live in a suite (test:providers) that also runs ~30 other tests requiring real provider credentials, so nothing exercises them on a plain PR from a contributor without keys. Extracting (not duplicating) into a standalone suite lets Task 2 gate every PR on them without also requiring secrets. The functions are removed from the original file (not kept in both places) to avoid double maintenance — the original file's "Provider Base Class Inheritance" test (checkProviderBaseClasses, immediately following in the tests array) stays untouched since it is unrelated in scope and this plan doesn't touch it.
Steps:
-
1.1 Verify current state before editing — confirm the two functions and their
testsarray entries are exactly where expected:grep -n "^async function testModelRegistryCompleteness\|^async function testProviderRegistrationCompleteness\|^async function testProviderBaseClassInheritance" test/continuous-test-suite-providers.tsExpected output (line numbers as of this plan; re-anchor on the function names if they've drifted):
2057:async function testModelRegistryCompleteness(): Promise<boolean | null> {
2195:async function testProviderRegistrationCompleteness(): Promise<boolean | null> {
2369:async function testProviderBaseClassInheritance(): Promise<boolean | null> { -
1.2 Create
test/continuous-test-suite-provider-structure.tswith the full converted suite (legacylogTest-based functions rewritten as moderntest()/assert()blocks, per thetest/continuous-test-suite-agent-delegation.tsexemplar pattern —defineSuite(name)→ top-levelawait test(name, fn)calls → bareawait runSuite();as the last line):#!/usr/bin/env tsx
import "dotenv/config";
/**
* Continuous Test Suite — Provider Structure
*
* Zero-API structural checks that verify the provider registry stays
* internally consistent as new providers are added: every value in the
* canonical AIProviderName enum resolves via ProviderFactory, and every
* concrete provider module under src/lib/providers/ has exactly one
* dynamic import in providerRegistry.ts — no orphaned imports left behind
* when a provider file is renamed or removed, no provider added to the
* enum without also being wired into the registry.
*
* Split out of continuous-test-suite-providers.ts (which needs live API
* keys for its other ~30 tests) so these two checks can run on every
* commit with zero credentials, zero network calls, and a fast (<5s)
* runtime — see docs/superpowers/plans/2026-08-15-02-ci-safety-net.md
* Task 1.
*
* Run: npx tsx test/continuous-test-suite-provider-structure.ts
* pnpm run test:provider-structure
*/
import * as fs from "fs";
import * as path from "path";
import { assert, defineSuite } from "./helpers/harness.js";
import { assertDistFresh } from "./helpers/distFreshness.js";
// Fail loudly rather than silently testing a stale build.
assertDistFresh();
const { test, runSuite } = defineSuite("Provider Structure");
/**
* Modules under src/lib/providers/ that are not ProviderRegistry entries
* (barrel, shared helpers, legacy alternate). Keep in sync with any new
* non-provider file added directly under src/lib/providers/.
*/
const PROVIDER_REGISTRATION_EXCLUSIONS = new Set([
"index",
"providerTypeUtils",
"anthropicBaseProvider",
"googleNativeGemini3",
]);
const DYNAMIC_PROVIDER_IMPORT_RE =
/import\s*\(\s*["']\.\.\/providers\/([A-Za-z][\w-]*)\.js["']\s*\)/g;
await test("Model Registry Completeness", async () => {
const distModule = await import("../dist/index.js");
const expectedProviders = [
"openai",
"anthropic",
"vertex",
"google-ai",
"bedrock",
"azure",
"ollama",
"mistral",
"litellm",
"huggingface",
"openrouter",
"openai-compatible",
"sagemaker",
"deepseek",
"nvidia-nim",
"lm-studio",
"llamacpp",
"xai",
"groq",
"cohere",
"together-ai",
"fireworks",
"perplexity",
"cloudflare",
"voyage",
"jina",
"stability",
"ideogram",
"recraft",
"replicate",
];
const aiProviderName = distModule.AIProviderName as
| Record<string, unknown>
| undefined;
assert(!!aiProviderName, "AIProviderName enum not exported from dist");
const providerValues = Object.values(
aiProviderName as Record<string, unknown>,
).filter((v) => typeof v === "string") as string[];
const missingProviders = expectedProviders.filter(
(p) => !providerValues.includes(p),
);
assert(
missingProviders.length === 0,
`enum missing ${missingProviders.length} expected provider id(s)`,
);
const modelEnums = [
"OpenAIModels",
"AnthropicModels",
"VertexModels",
"GoogleAIModels",
"BedrockModels",
"MistralModels",
"OllamaModels",
];
const requiredEnums = ["OpenAIModels", "VertexModels", "BedrockModels"];
const presentEnums = modelEnums.filter(
(enumName) => !!(distModule as Record<string, unknown>)[enumName],
);
const missingRequired = requiredEnums.filter(
(e) => !presentEnums.includes(e),
);
assert(
missingRequired.length === 0,
`dist is missing ${missingRequired.length} required model enum(s)`,
);
});
await test("Provider Registration Completeness", async () => {
const providersDir = path.join(process.cwd(), "src", "lib", "providers");
const registryPath = path.join(
process.cwd(),
"src",
"lib",
"factories",
"providerRegistry.ts",
);
assert(
fs.existsSync(providersDir) && fs.existsSync(registryPath),
"providers/ or providerRegistry.ts not found (run from repo root)",
);
const registrySource = fs.readFileSync(registryPath, "utf8");
const concreteProviders = fs
.readdirSync(providersDir)
.filter((name) => name.endsWith(".ts"))
.map((name) => name.replace(/\.ts$/, ""))
.filter((base) => !PROVIDER_REGISTRATION_EXCLUSIONS.has(base))
.sort();
const importCounts = new Map<string, number>();
for (const match of registrySource.matchAll(DYNAMIC_PROVIDER_IMPORT_RE)) {
const base = match[1];
importCounts.set(base, (importCounts.get(base) ?? 0) + 1);
}
const failures: string[] = [];
for (const base of concreteProviders) {
const count = importCounts.get(base) ?? 0;
if (count === 0) {
failures.push(`missing dynamic import: ${base}`);
} else if (count > 1) {
failures.push(`duplicate dynamic import: ${base} (${count}x)`);
}
const source = fs.readFileSync(
path.join(providersDir, `${base}.ts`),
"utf8",
);
if (!/export\s+class\s+\w+/.test(source)) {
failures.push(`no exported class in ${base}.ts`);
}
}
for (const [base, count] of [...importCounts.entries()].sort()) {
if (!fs.existsSync(path.join(providersDir, `${base}.ts`))) {
failures.push(
`stale dynamic import: ${base}.js (${count}x, file missing)`,
);
}
}
assert(
failures.length === 0,
`${failures.length} registry/filesystem mismatch(es) found`,
);
const { ProviderRegistry } =
await import("../dist/lib/factories/providerRegistry.js");
const { ProviderFactory } =
await import("../dist/lib/factories/providerFactory.js");
const { AIProviderName } = await import("../dist/lib/constants/enums.js");
ProviderRegistry.clearRegistrations();
await ProviderRegistry.registerAllProviders();
const canonicalIds = Object.values(AIProviderName)
.filter(
(v): v is string => typeof v === "string" && v !== AIProviderName.AUTO,
)
.sort();
assert(
new Set(canonicalIds).size === canonicalIds.length,
"duplicate AIProviderName values detected",
);
const unresolved = canonicalIds.filter(
(id) => !ProviderFactory.hasProvider(id),
);
assert(
unresolved.length === 0,
`${unresolved.length} canonical id(s) not resolvable via ProviderFactory`,
);
assert(
!ProviderFactory.hasProvider(AIProviderName.AUTO),
"AUTO must not be registered as a concrete provider",
);
const claimedKeys = new Map<string, string>();
const keyCollisions: string[] = [];
for (const id of canonicalIds) {
const info = ProviderFactory.getProviderInfo(id);
if (!info) {
keyCollisions.push(`${id}: missing registration info`);
continue;
}
const keys = [
id.toLowerCase(),
...(info.aliases ?? []).map((a) => a.toLowerCase()),
];
for (const key of keys) {
const owner = claimedKeys.get(key);
if (owner && owner !== id) {
keyCollisions.push(`key "${key}" claimed by ${owner} and ${id}`);
} else {
claimedKeys.set(key, id);
}
}
for (const alias of info.aliases ?? []) {
if (ProviderFactory.getProviderInfo(alias) !== info) {
keyCollisions.push(
`alias "${alias}" does not resolve to primary "${id}"`,
);
}
}
}
assert(
keyCollisions.length === 0,
`${keyCollisions.length} alias/key collision(s) found`,
);
});
await runSuite(); -
1.3 Add the npm script — edit
package.json, insert immediately after thetest:providers-mockedline (package.json:105):"test:providers-mocked": "npx tsx test/continuous-test-suite-providers-mocked.ts",
+ "test:provider-structure": "npx tsx test/continuous-test-suite-provider-structure.ts",
"test:provider-fallback": "npx tsx test/continuous-test-suite-provider-fallback.ts", -
1.4 Build and run the new suite standalone to confirm it passes against the current registry before touching the source file:
pnpm run build && pnpm run test:provider-structureExpected output ends with:
✓ Model Registry Completeness
✓ Provider Registration Completeness
...and exits 0.
-
1.5 Commit the new suite on its own before removing anything from the original file, so the extraction is reviewable as "add" then "remove":
git add test/continuous-test-suite-provider-structure.ts package.json
git commit -m "test: extract zero-API provider structure checks into standalone suite" -
1.6 Remove the now-duplicated logic from
test/continuous-test-suite-providers.ts. Delete the full block spanning from thetestModelRegistryCompletenessfunction declaration through the end oftestProviderRegistrationCompleteness(inclusive ofPROVIDER_REGISTRATION_EXCLUSIONSandDYNAMIC_PROVIDER_IMPORT_RE, which are used only by the removed function) — lines 2057-2366 as of this plan. Use the exact start/end anchors to delete precisely regardless of minor line drift:START=$(grep -n "^async function testModelRegistryCompleteness" test/continuous-test-suite-providers.ts | cut -d: -f1)
END=$(grep -n "^async function testProviderBaseClassInheritance" test/continuous-test-suite-providers.ts | head -1 | cut -d: -f1)
END=$((END - 2))
sed -i '' "${START},${END}d" test/continuous-test-suite-providers.ts(
END - 2drops the trailing blank line and section-comment line immediately precedingtestProviderBaseClassInheritance, leaving exactly one blank line before it — verify with step 1.7.) -
1.7 Remove the two now-dead entries from the
testsarray (the"Model Registry Completeness"and"Provider Registration Completeness"blocks, originally at lines 3755-3763), leaving the immediately-following"Provider Base Class Inheritance"entry untouched:{
- name: "Model Registry Completeness",
- fn: testModelRegistryCompleteness,
- },
- {
- name: "Provider Registration Completeness",
- fn: testProviderRegistrationCompleteness,
- },
- {
name: "Provider Base Class Inheritance",
fn: testProviderBaseClassInheritance,
}, -
1.8 Remove the now-dead
pathimport (import * as path from "path";, originally line 26) —fsstays, it's still used elsewhere in the file:grep -n '^import \* as path from "path";$' test/continuous-test-suite-providers.tsConfirm exactly one match, then delete that line. Confirm
fsis still referenced afterward:grep -c 'fs\.' test/continuous-test-suite-providers.tsExpected: a positive count (fs is used at multiple other locations in the file, outside the removed functions).
-
1.9 Typecheck and confirm no dangling references to the removed functions/constants remain:
pnpm run check
grep -n "testModelRegistryCompleteness\|testProviderRegistrationCompleteness\|PROVIDER_REGISTRATION_EXCLUSIONS\|DYNAMIC_PROVIDER_IMPORT_RE" test/continuous-test-suite-providers.tsExpected:
pnpm run checkexits 0; thegrepfinds zero matches. -
1.10 Rebuild and run both the original suite (minus its structural tests, still needs live keys for the rest — acceptable to run locally with whatever keys are configured, or skip if none) and the new standalone suite, to confirm nothing regressed structurally:
pnpm run build && pnpm run test:provider-structureExpected: same
✓ Model Registry Completeness/✓ Provider Registration Completenessoutput as step 1.4, confirming the extraction preserved identical behavior. -
1.11 Commit the removal:
git add test/continuous-test-suite-providers.ts
git commit -m "refactor(test): remove structural checks now covered by continuous-test-suite-provider-structure"
Task 2: Add a required provider-safety-net CI job
Files:
- Modify:
.github/workflows/ci.yml(add new job aftertest:, lines 10-84; replace the no-op step inquality-gate, lines 301-305)
Interfaces:
- Consumes:
pnpm run build,pnpm run test:providers-mocked,pnpm run test:provider-structure(script added in Task 1). - Produces: new required GitHub Actions job
provider-safety-net, whose check-run name (noname:/strategy:set) is literallyprovider-safety-net— the exact context string used in Task 3's branch-protection fix.
Steps:
-
2.1 Verify the current job list and the no-op step before editing:
grep -n "^ [a-z].*:$" .github/workflows/ci.yml
sed -n '295,310p' .github/workflows/ci.ymlExpected job list:
test:,build-check:,proxy-performance:,quality-gate:,semantic-release-validation:(plus a commented-out# action-smoke-test:block). Expectedsedoutput includes:- name: 🎯 Test Suite Validation
run: |
echo "🎯 Comprehensive test validation would run here"
echo "Skipping automated test execution in CI for now"
continue-on-error: true -
2.2 Insert the new job immediately after the
test:job's closing (right before the commented# action-smoke-test:block that starts at line 86):provider-safety-net:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v4
with:
version: 10.15.1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: 🔄 SvelteKit Sync
run: |
echo "🔄 Running SvelteKit sync to generate TypeScript configs..."
pnpm exec svelte-kit sync
- name: Build package
run: pnpm run build
- name: Mocked provider contract tests (no API keys required)
run: pnpm run test:providers-mocked
- name: Provider structure tests (registry ↔ filesystem, no API keys required)
run: pnpm run test:provider-structure -
2.3 Replace the no-op
quality-gatestep (lines 301-305 as of this plan) — remove it entirely, since real validation now runs in the dedicatedprovider-safety-netjob instead of being faked here:- - name: 🎯 Test Suite Validation
- run: |
- echo "🎯 Comprehensive test validation would run here"
- echo "Skipping automated test execution in CI for now"
- continue-on-error: true
- -
2.4 Validate the YAML parses (GitHub Actions has no local
act-free linter in this repo, so use a YAML syntax check):npx -y js-yaml .github/workflows/ci.yml > /dev/null && echo "YAML OK"Expected:
YAML OK. -
2.5 Confirm the referenced scripts actually exist and pass locally (this is what CI will run):
pnpm run build && pnpm run test:providers-mocked && pnpm run test:provider-structureExpected: all three commands exit 0.
-
2.6 Commit:
git add .github/workflows/ci.yml
git commit -m "ci: add required provider-safety-net job, remove no-op test-validation step"
Task 3: Fix branch-protection contexts and the test job matrix mismatch
Files:
- Modify:
.github/settings.yml(two duplicatebranches: - name: releaseprotection blocks, lines 173-192 and 193-210) - Modify:
.github/workflows/ci.yml(test:job'sstrategy: matrix:block, lines 10-28)
Interfaces:
- Produces:
.github/settings.ymlrequired-context list["test", "build-check", "provider-safety-net", "Yama PR Review"], all four of which now match real, unmatrixed job/check-run names inci.yml.
Decision — drop the matrix, don't update the context string: ci.yml's test: job currently reports as test (20) because of a single-entry node-version: [20] matrix (.github/workflows/ci.yml:12-14), while .github/settings.yml requires the literal context "test". Two fixes are possible: (a) change settings.yml to require "test (20)", or (b) drop the single-entry matrix so the job reports as plain "test". Choosing (b): a matrix with exactly one entry provides no coverage benefit (it doesn't test multiple Node versions), and the suffixed context name is themselves fragile — any future change to the matrix values (e.g. adding Node 22) silently changes the required-check string and re-breaks branch protection the same way "build" broke. A flat job name has no such failure mode.
Steps:
-
3.1 Verify current mismatch before editing:
sed -n '10,28p' .github/workflows/ci.yml
grep -n 'contexts:\|"test"\|"build"\|"Yama PR Review"' .github/settings.ymlExpected:
ci.ymlshowsstrategy: matrix: node-version: [20]and${{ matrix.node-version }}used at two points;settings.ymlshows the string"build"appearing twice (once per duplicated block) with no job inci.ymlnamedbuild(the real job isbuild-check). -
3.2 Drop the matrix in
ci.yml'stest:job, hardcoding Node 20:test:
runs-on: ubuntu-latest
- strategy:
- matrix:
- node-version: [20]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v4
with:
version: 10.15.1
- - name: Setup Node.js ${{ matrix.node-version }}
+ - name: Setup Node.js 20
uses: actions/setup-node@v4
with:
- node-version: ${{ matrix.node-version }}
+ node-version: "20"
cache: "pnpm" -
3.3 Fix both duplicate
contexts:blocks in.github/settings.ymlin one pass (the two blocks are byte-identical, so a singlereplace_alledit covers both):contexts:
- "test"
- - "build"
+ - "build-check"
+ - "provider-safety-net"
# AI code review — fails only on a Yama BLOCKED verdict (see
# .github/workflows/yama-review.yml + yama.config.yaml).
- "Yama PR Review" -
3.4 Confirm both blocks now match by re-grepping:
grep -n '"test"\|"build-check"\|"provider-safety-net"\|"build"$' .github/settings.ymlExpected:
"test","build-check","provider-safety-net"each appear exactly twice (once per duplicated protection block); zero remaining bare"build"matches. -
3.5 Validate both YAML files parse:
npx -y js-yaml .github/workflows/ci.yml > /dev/null && echo "ci.yml OK"
npx -y js-yaml .github/settings.yml > /dev/null && echo "settings.yml OK"Expected: both print
OK. -
3.6 Confirm the
testjob still runs correctly without the matrix:pnpm run buildExpected: exits 0 (this is a stand-in for the job's actual CI steps, which are unchanged besides the matrix removal).
-
3.7 Commit:
git add .github/workflows/ci.yml .github/settings.yml
git commit -m "ci: fix required-context drift — build-check/provider-safety-net, drop single-entry test matrix"
Task 4: Wire pre-push to a real Husky hook running the cheap no-API tier
Files:
- Modify:
package.json(pre-pushscript, line 226) - Create:
.husky/pre-push
Interfaces:
- Consumes:
pnpm run build,pnpm run test:providers-mocked,pnpm run test:provider-structure(same three commands as the CI job in Task 2, so a local push fails exactly what CI would fail, before it's pushed).
Steps:
-
4.1 Verify current state —
pre-pushscript exists but no.husky/pre-pushhook file wires it up:grep -n '"pre-push"' package.json
ls -la .husky/Expected:
package.jsonshows"pre-push": "pnpm run validate:commit && pnpm run validate:env && pnpm run validate && pnpm run test:ci";.husky/contains onlycommit-msg,pre-commit, and_(nopre-pushfile). -
4.2 Redefine the
pre-pushscript to the cheap no-API tier — editpackage.json:226:- "pre-push": "pnpm run validate:commit && pnpm run validate:env && pnpm run validate && pnpm run test:ci",
+ "pre-push": "pnpm run build && pnpm run test:providers-mocked && pnpm run test:provider-structure", -
4.3 Create
.husky/pre-push, matching the shebang + sourcing style of the existing.husky/commit-msg:#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Cheap no-API tier: build freshness + mocked provider contracts + registry
# structure. Keeps local pushes fast; live-credential suites run in the
# separate nightly live-matrix.yml workflow, not on every push.
pnpm run pre-push -
4.4 Make it executable, matching the other hooks:
chmod +x .husky/pre-push
ls -la .husky/pre-pushExpected: permissions show
-rwxr-xr-x(or equivalent executable bit set). -
4.5 Run the hook's own command manually to confirm it passes before relying on the git hook to catch failures:
pnpm run pre-pushExpected: build, mocked-contract suite, and structure suite all run and the command exits 0.
-
4.6 Commit:
git add package.json .husky/pre-push
git commit -m "chore(husky): wire pre-push to the cheap no-API provider safety-net tier"
Task 5: OpenAI mocked contract section
Files:
- Modify:
test/continuous-test-suite-providers-mocked.ts(newrunOpenAISection()function + wiring intomain())
Interfaces:
- Consumes:
installMockFetch,record,expect,expectEqfrom./utils/mockFetch.js;withMocks,setEnv,openAIChatResponse,results(module-level helpers already in the file);NeuroLinkviaawait import("../dist/index.js"). - Produces: three new
TestRecordentries inresults:"LLM openai: happy-path generate()","LLM openai: 401 → AuthenticationError","LLM openai: 429 → RateLimitError".
Verified request contract (src/lib/providers/openaiChatCompletionsBase.ts:383-399, unoverridden by OpenAI's provider class): default getChatCompletionsURL() returns `${baseURL}/chat/completions` with baseURL = "https://api.openai.com/v1" (OPENAI_DEFAULT_BASE_URL) → full URL https://api.openai.com/v1/chat/completions; default getAuthHeaders() returns { Authorization: `Bearer ${apiKey}` }.
Verified error contract (src/lib/providers/openAI/client.ts:135-199, full body read verbatim): buildAPIError always sets a numeric statusCode, so classification is reliable via the statusCode branches alone — statusCode === 401 → AuthenticationError; statusCode === 429 → RateLimitError with the exact literal message "OpenAI rate limit exceeded. Please try again later." (line 185).
Steps:
-
5.1 Verify the suite's tail structure before inserting (confirms exact insertion points):
sed -n '1165,1206p' test/continuous-test-suite-providers-mocked.tsExpected: shows
runImageGenSection()ending at line ~1172, the// Section: maincomment, thenasync function main()with atry { await runOpenAICompatSection(); await runReplicateLLMSection(); await runEmbeddingsSection(); await runImageGenSection(); } finally { restoreEnv(); }block. -
5.2 Insert the new section function immediately before the
// Section: maincomment (i.e. right afterrunImageGenSection()'s closing brace):// ──────────────────────────── ───────────────────────────────────────────
// Section: OpenAI (native client — the base OpenAIChatCompletionsProvider
// whose defaults every OpenAI-compat provider above inherits, so it gets
// its own bespoke section rather than joining OPENAI_COMPAT_PROVIDERS)
// ───────────────────────────────────────────────────────────────────────
async function runOpenAISection(): Promise<void> {
const section = "LLM openai";
console.log(`\n=== ${section} ===`);
const fakeKey = "sk-mock-openai-1234567890abcdef";
const model = "gpt-4o-mini";
setEnv("OPENAI_API_KEY", fakeKey);
const { NeuroLink } = await import("../dist/index.js");
// ── Happy path ──────────────────────────────────────────────────────
try {
await withMocks(
[
{
method: "POST",
url: "api.openai.com/v1/chat/completions",
respond: { status: 200, json: openAIChatResponse("pong", model) },
},
],
async ({ calls }) => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
const result = await nl.generate({
provider: "openai",
model,
input: { text: "ping" },
disableTools: true,
});
expect(calls.length > 0, "at least one fetch call captured");
const call = calls[0];
expect(
call.url.includes("api.openai.com/v1/chat/completions"),
`URL contains 'api.openai.com/v1/chat/completions' (got ${call.url})`,
);
expectEq(call.method, "POST", "request method");
expect(
(call.headers["authorization"] ?? "").startsWith(
`Bearer ${fakeKey}`,
),
`Authorization header starts with 'Bearer ${fakeKey.slice(0, 12)}...'`,
);
const body = call.bodyJson as { model: string; messages: unknown[] };
expect(typeof body === "object", "body is JSON object");
expectEq(body.model, model, "body.model");
expect(Array.isArray(body.messages), "body.messages is array");
expect(
(result.content ?? "").toLowerCase().includes("pong"),
`response content includes 'pong' (got ${JSON.stringify(result.content?.slice(0, 100))})`,
);
record(results, `${section}: happy-path generate()`, true);
},
);
} catch (err) {
record(
results,
`${section}: happy-path generate()`,
false,
err instanceof Error ? err.message : String(err),
);
}
// ── 401 → AuthenticationError (buildAPIError always sets a numeric
// statusCode, so this classifies via the statusCode branch alone) ──────
try {
await withMocks(
[
{
method: "POST",
url: "api.openai.com/v1/chat/completions",
respond: {
status: 401,
json: {
error: {
message: "Invalid API key",
type: "invalid_request_error",
},
},
},
},
],
async () => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
try {
await nl.generate({
provider: "openai",
model,
input: { text: "ping" },
disableTools: true,
});
record(
results,
`${section}: 401 → AuthenticationError`,
false,
"no error thrown",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
record(
results,
`${section}: 401 → AuthenticationError`,
/invalid openai api key|incorrect api key|invalid api key/i.test(
msg,
),
`msg='${msg.slice(0, 120)}'`,
);
}
},
);
} catch (err) {
record(
results,
`${section}: 401 → AuthenticationError`,
false,
err instanceof Error ? err.message : String(err),
);
}
// ── 429 → RateLimitError with the exact literal message OpenAI's
// formatProviderError hardcodes (client.ts:185) ─────────────────────
try {
await withMocks(
[
{
method: "POST",
url: "api.openai.com/v1/chat/completions",
respond: {
status: 429,
json: {
error: {
message: "Rate limit reached",
type: "rate_limit_error",
},
},
},
},
],
async () => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
try {
await nl.generate({
provider: "openai",
model,
input: { text: "ping" },
disableTools: true,
});
record(
results,
`${section}: 429 → RateLimitError`,
false,
"no error thrown",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
record(
results,
`${section}: 429 → RateLimitError`,
msg === "OpenAI rate limit exceeded. Please try again later.",
`msg='${msg.slice(0, 120)}'`,
);
}
},
);
} catch (err) {
record(
results,
`${section}: 429 → RateLimitError`,
false,
err instanceof Error ? err.message : String(err),
);
}
} -
5.3 Wire the call into
main():await runOpenAICompatSection();
await runReplicateLLMSection();
await runEmbeddingsSection();
await runImageGenSection();
+ await runOpenAISection(); -
5.4 Typecheck and run:
pnpm run check && pnpm run build && pnpm run test:providers-mockedExpected: last lines include
✓ LLM openai: happy-path generate(),✓ LLM openai: 401 → AuthenticationError,✓ LLM openai: 429 → RateLimitError, and the script exits 0. -
5.5 Sanity-check the SKIP/FAIL distinction is real for this section by temporarily breaking one assertion (per the Global Constraints break-one-assertion check), confirming a genuine failure reports
✗and a non-zero exit — then revert:sed -i '' 's/msg === "OpenAI rate limit exceeded. Please try again later."/msg === "this will never match"/' test/continuous-test-suite-providers-mocked.ts
pnpm run test:providers-mocked; echo "exit code: $?"
git checkout -- test/continuous-test-suite-providers-mocked.tsExpected:
✗ LLM openai: 429 → RateLimitErrorprinted,exit code: 1(or a value shown by the suite's ownpassed/failedsummary followed byprocess.exit(failed > 0 ? 1 : 0)), confirming the assertion is load-bearing, then the file is restored. -
5.6 Re-run to confirm the revert restored a clean pass, then commit:
pnpm run test:providers-mocked
git add test/continuous-test-suite-providers-mocked.ts
git commit -m "test(providers-mocked): add OpenAI contract section (request shape + 401/429 mapping)"
Task 6: Azure mocked contract section
Files:
- Modify:
test/continuous-test-suite-providers-mocked.ts(newrunAzureSection()function + wiring intomain())
Interfaces:
- Produces:
TestRecordentries"LLM azure: happy-path generate()","LLM azure: 401 → AuthenticationError","LLM azure: 429 → generic ProviderError (documented gap)".
Verified request contract (src/lib/providers/azureOpenai.ts, full 292-line file read twice verbatim): getChatCompletionsURL() builds `${azureResourceOrigin}${prefix}/deployments/${deployment}/chat/completions?api-version=${azureApiVersion}` where prefix = azureDeploymentPathPrefix.replace(/\/+$/, "") = "/openai" for a classic *.openai.azure.com host; deployment = credentials?.deploymentName || modelName || process.env.AZURE_OPENAI_MODEL || process.env.AZURE_OPENAI_DEPLOYMENT || process.env.AZURE_OPENAI_DEPLOYMENT_ID || "gpt-4o" — passing model: "mock-deployment" directly to nl.generate() sets it with no extra env var; azureApiVersion defaults to APIVersions.AZURE_LATEST = "2025-04-01-preview" (src/lib/constants/enums.ts:978); getAuthHeaders() returns {"api-key": apiKey} — not Authorization: Bearer.
Verified error contract (formatProviderError, src/lib/providers/azureOpenai.ts, read verbatim this session): 401 works via errorObj.message.includes("401") → AuthenticationError("Invalid Azure OpenAI API key or endpoint.", "azure") (an exact, fixed message regardless of upstream body). 429 has no dedicated branch at all — falls through to the generic ProviderError(`Azure OpenAI error: ${message}`, "azure"). This is a real, confirmed classification gap; the test documents it rather than asserting incorrect RateLimitError behavior.
Steps:
-
6.1 Verify the exact endpoint-building and auth-header logic one more time immediately before writing the mock (guards against drift since the constructor was last read):
sed -n '244,262p' src/lib/providers/azureOpenai.tsExpected: shows
getChatCompletionsURL(modelId)building the URL with the/openai/deployments/.../chat/completions?api-version=...pattern, andgetAuthHeaders()returning{"api-key": this.config.apiKey}. -
6.2 Insert the new section function after
runOpenAISection()(added in Task 5):// ───────────────────────────────────────────────────────────────────────
// Section: Azure OpenAI (api-key header, not Bearer; deployment-scoped URL)
// ───────────────────────────────────────────────────────────────────────
async function runAzureSection(): Promise<void> {
const section = "LLM azure";
console.log(`\n=== ${section} ===`);
const fakeKey = "mock-azure-key-abcdef1234567890";
const deployment = "mock-deployment";
const resourceOrigin = "https://mock-resource.openai.azure.com";
setEnv("AZURE_OPENAI_API_KEY", fakeKey);
setEnv("AZURE_OPENAI_ENDPOINT", resourceOrigin);
const { NeuroLink } = await import("../dist/index.js");
const expectedUrl = `${resourceOrigin}/openai/deployments/${deployment}/chat/completions?api-version=2025-04-01-preview`;
// ── Happy path ──────────────────────────────────────────────────────
try {
await withMocks(
[
{
method: "POST",
url: expectedUrl,
respond: {
status: 200,
json: openAIChatResponse("pong", deployment),
},
},
],
async ({ calls }) => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
const result = await nl.generate({
provider: "azure",
model: deployment,
input: { text: "ping" },
disableTools: true,
});
expect(calls.length > 0, "at least one fetch call captured");
const call = calls[0];
expectEq(call.url, expectedUrl, "request URL");
expectEq(call.method, "POST", "request method");
expectEq(call.headers["api-key"], fakeKey, "api-key header");
expect(
!("authorization" in call.headers),
"Authorization header must NOT be set (Azure uses api-key)",
);
const body = call.bodyJson as { messages: unknown[] };
expect(Array.isArray(body.messages), "body.messages is array");
expect(
(result.content ?? "").toLowerCase().includes("pong"),
`response content includes 'pong' (got ${JSON.stringify(result.content?.slice(0, 100))})`,
);
record(results, `${section}: happy-path generate()`, true);
},
);
} catch (err) {
record(
results,
`${section}: happy-path generate()`,
false,
err instanceof Error ? err.message : String(err),
);
}
// ── 401 → AuthenticationError (message.includes("401") substring check) ─
try {
await withMocks(
[
{
method: "POST",
url: expectedUrl,
respond: {
status: 401,
json: { error: { message: "401 Unauthorized" } },
},
},
],
async () => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
try {
await nl.generate({
provider: "azure",
model: deployment,
input: { text: "ping" },
disableTools: true,
});
record(
results,
`${section}: 401 → AuthenticationError`,
false,
"no error thrown",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
record(
results,
`${section}: 401 → AuthenticationError`,
msg === "Invalid Azure OpenAI API key or endpoint.",
`msg='${msg.slice(0, 120)}'`,
);
}
},
);
} catch (err) {
record(
results,
`${section}: 401 → AuthenticationError`,
false,
err instanceof Error ? err.message : String(err),
);
}
// ── 429 → NO dedicated branch in Azure's formatProviderError; falls
// through to a generic ProviderError. This test documents that gap by
// asserting the actual (imperfect) behavior, not RateLimitError. ──────
try {
await withMocks(
[
{
method: "POST",
url: expectedUrl,
respond: {
status: 429,
json: { error: { message: "Rate limit exceeded" } },
},
},
],
async () => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
try {
await nl.generate({
provider: "azure",
model: deployment,
input: { text: "ping" },
disableTools: true,
});
record(
results,
`${section}: 429 → generic ProviderError (documented gap)`,
false,
"no error thrown",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
record(
results,
`${section}: 429 → generic ProviderError (documented gap)`,
msg.startsWith("Azure OpenAI error: "),
`msg='${msg.slice(0, 120)}'`,
);
}
},
);
} catch (err) {
record(
results,
`${section}: 429 → generic ProviderError (documented gap)`,
false,
err instanceof Error ? err.message : String(err),
);
}
} -
6.3 Wire the call into
main():await runOpenAISection();
+ await runAzureSection(); -
6.4 Typecheck and run:
pnpm run check && pnpm run build && pnpm run test:providers-mockedExpected:
✓ LLM azure: happy-path generate(),✓ LLM azure: 401 → AuthenticationError,✓ LLM azure: 429 → generic ProviderError (documented gap), exit 0. -
6.5 Break-one-assertion sanity check (URL construction, since that's the most fragile part of this section), then revert:
sed -i '' 's/api-version=2025-04-01-preview/api-version=WRONG/' test/continuous-test-suite-providers-mocked.ts
pnpm run test:providers-mocked; echo "exit code: $?"
git checkout -- test/continuous-test-suite-providers-mocked.tsExpected:
✗ LLM azure: happy-path generate()(route match fails, throws[mockFetch] No route matched),exit code: 1, then the file is restored. -
6.6 Re-run to confirm clean pass, then commit:
pnpm run test:providers-mocked
git add test/continuous-test-suite-providers-mocked.ts
git commit -m "test(providers-mocked): add Azure contract section, document 429 classifier gap"
Task 7: Anthropic mocked contract section
Files:
- Modify:
test/continuous-test-suite-providers-mocked.ts(newanthropicMessageResponse()helper +runAnthropicSection()function + wiring intomain())
Interfaces:
- Produces:
TestRecordentries"LLM anthropic: happy-path generate()","LLM anthropic: 401 → generic ProviderError (documented gap)","LLM anthropic: 429 → RateLimitError".
Verified request contract (@anthropic-ai/sdk v0.102.0 source in node_modules, read verbatim this session): default header anthropic-version: '2023-06-01' (client.js:799); apiKeyAuth() returns {'X-Api-Key': apiKey} (client.js:353-357) — not Authorization: Bearer; endpoint POST /v1/messages relative to baseURL="https://api.anthropic.com" (resources/messages/messages.js:35) → full URL https://api.anthropic.com/v1/messages. Interceptable via installMockFetch() because fetchWithRetry() calls the bare fetch() identifier, resolved dynamically from globalThis.fetch (confirmed no local shadow).
Verified error contract (formatProviderError, src/lib/providers/anthropic/client.ts:1668-1727, read verbatim this session): auth branch matches only message.includes("API_KEY_INVALID") || message.includes("Invalid API key") — the SDK's actual 401 message format is ${status} ${msg} (node_modules/@anthropic-ai/sdk/core/error.js:11,18-26), e.g. "401 invalid x-api-key", which contains neither literal substring, so a real 401 misclassifies and falls through to the generic ProviderError(`Anthropic error: ${message}`, ...)— a confirmed gap, documented rather than worked around. 429 correctly matchesmessage.includes("429")(the SDK's 429 message is"429 " + rawMessage) → RateLimitErrorwith the exact literal message"Anthropic rate limit exceeded. Please try again later." (line 1698).
Steps:
-
7.1 Verify the SDK's exact 401 message format one more time immediately before writing the mock (guards against a version bump changing the format):
sed -n '9,27p' node_modules/@anthropic-ai/sdk/core/error.jsExpected: shows
APIError.makeMessage(status, error, message)returning`${status} ${msg}`when both are present, andstatic generate()routingstatus === 401toAuthenticationError(the SDK's own class, unrelated to NeuroLink'sAuthenticationError— the SDK throws its own typed error, NeuroLink'sformatProviderErrorre-classifies it from.message). -
7.2 Add a response-builder helper next to
openAIChatResponse()(near the top of the file, after the existing helper):function anthropicMessageResponse(text: string, model: string): unknown {
return {
id: "msg_mock",
type: "message",
role: "assistant",
model,
content: [{ type: "text", text }],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 5, output_tokens: 5 },
};
} -
7.3 Insert the new section function after
runAzureSection()(added in Task 6):// ───────────────────────────────────────────────────────────────────────
// Section: Anthropic (x-api-key + anthropic-version headers, not Bearer)
// ───────────────────────────────────────────────────────────────────────
async function runAnthropicSection(): Promise<void> {
const section = "LLM anthropic";
console.log(`\n=== ${section} ===`);
const fakeKey = "sk-ant-mock-1234567890abcdef";
const model = "claude-sonnet-4-6";
setEnv("ANTHROPIC_API_KEY", fakeKey);
const { NeuroLink } = await import("../dist/index.js");
// ── Happy path ──────────────────────────────────────────────────────
try {
await withMocks(
[
{
method: "POST",
url: "api.anthropic.com/v1/messages",
respond: {
status: 200,
json: anthropicMessageResponse("pong", model),
},
},
],
async ({ calls }) => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
const result = await nl.generate({
provider: "anthropic",
model,
input: { text: "ping" },
disableTools: true,
});
expect(calls.length > 0, "at least one fetch call captured");
const call = calls[0];
expect(
call.url.includes("api.anthropic.com/v1/messages"),
`URL contains 'api.anthropic.com/v1/messages' (got ${call.url})`,
);
expectEq(call.method, "POST", "request method");
expectEq(call.headers["x-api-key"], fakeKey, "x-api-key header");
expectEq(
call.headers["anthropic-version"],
"2023-06-01",
"anthropic-version header",
);
expect(
!("authorization" in call.headers),
"Authorization header must NOT be set (Anthropic uses x-api-key)",
);
const body = call.bodyJson as { model: string; messages: unknown[] };
expectEq(body.model, model, "body.model");
expect(Array.isArray(body.messages), "body.messages is array");
expect(
(result.content ?? "").toLowerCase().includes("pong"),
`response content includes 'pong' (got ${JSON.stringify(result.content?.slice(0, 100))})`,
);
record(results, `${section}: happy-path generate()`, true);
},
);
} catch (err) {
record(
results,
`${section}: happy-path generate()`,
false,
err instanceof Error ? err.message : String(err),
);
}
// ── 401 → misclassifies to generic ProviderError. The auth branch only
// matches "API_KEY_INVALID" / "Invalid API key" substrings; the SDK's
// real 401 message is "401 <body message>", which matches neither.
// This test documents that gap rather than asserting AuthenticationError. ─
try {
await withMocks(
[
{
method: "POST",
url: "api.anthropic.com/v1/messages",
respond: {
status: 401,
json: {
type: "error",
error: {
type: "authentication_error",
message: "invalid x-api-key",
},
},
},
},
],
async () => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
try {
await nl.generate({
provider: "anthropic",
model,
input: { text: "ping" },
disableTools: true,
});
record(
results,
`${section}: 401 → generic ProviderError (documented gap)`,
false,
"no error thrown",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
record(
results,
`${section}: 401 → generic ProviderError (documented gap)`,
msg.startsWith("Anthropic error: 401"),
`msg='${msg.slice(0, 120)}'`,
);
}
},
);
} catch (err) {
record(
results,
`${section}: 401 → generic ProviderError (documented gap)`,
false,
err instanceof Error ? err.message : String(err),
);
}
// ── 429 → correctly classifies via the "429" substring match ──────────
try {
await withMocks(
[
{
method: "POST",
url: "api.anthropic.com/v1/messages",
respond: {
status: 429,
json: {
type: "error",
error: { type: "rate_limit_error", message: "Rate limited" },
},
},
},
],
async () => {
const nl = new NeuroLink({ conversationMemory: { enabled: false } });
try {
await nl.generate({
provider: "anthropic",
model,
input: { text: "ping" },
disableTools: true,
});
record(
results,
`${section}: 429 → RateLimitError`,
false,
"no error thrown",
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
record(
results,
`${section}: 429 → RateLimitError`,
msg === "Anthropic rate limit exceeded. Please try again later.",
`msg='${msg.slice(0, 120)}'`,
);
}
},
);
} catch (err) {
record(
results,
`${section}: 429 → RateLimitError`,
false,
err instanceof Error ? err.message : String(err),
);
}
} -
7.4 Wire the call into
main():await runAzureSection();
+ await runAnthropicSection(); -
7.5 Typecheck and run:
pnpm run check && pnpm run build && pnpm run test:providers-mockedExpected:
✓ LLM anthropic: happy-path generate(),✓ LLM anthropic: 401 → generic ProviderError (documented gap),✓ LLM anthropic: 429 → RateLimitError, exit 0. -
7.6 Break-one-assertion sanity check (the 429 literal message), then revert:
sed -i '' 's/msg === "Anthropic rate limit exceeded. Please try again later."/msg === "this will never match"/' test/continuous-test-suite-providers-mocked.ts
pnpm run test:providers-mocked; echo "exit code: $?"
git checkout -- test/continuous-test-suite-providers-mocked.tsExpected:
✗ LLM anthropic: 429 → RateLimitError,exit code: 1, then the file is restored. -
7.7 Re-run to confirm clean pass, then commit:
pnpm run test:providers-mocked
git add test/continuous-test-suite-providers-mocked.ts
git commit -m "test(providers-mocked): add Anthropic contract section, document 401 classifier gap"
Task 8: Vertex construction + formatProviderError contract section
Files:
- Modify:
test/continuous-test-suite-providers-mocked.ts(newrunVertexSection()function + wiring intomain())
Interfaces:
- Consumes:
GoogleVertexProviderfrom"../dist/lib/providers/googleVertex/client.js"(not re-exported fromdist/index.js— dynamic-import-only per repo rule 1, confirmed by grep);AuthenticationError,RateLimitErrorfrom"../dist/lib/types/index.js". - Produces:
TestRecordentries"Vertex (construction + formatProviderError contract): constructs without throwing","...: 403 → AuthenticationError","...: 429 → RateLimitError".
Why construction-only, not fetch interception: Vertex's @google/genai client routes its ADC (Application Default Credentials) token exchange through gaxios, which imports the node-fetch npm package directly rather than calling globalThis.fetch — confirmed by inspecting gaxios's dependency graph in node_modules. installMockFetch() only replaces globalThis.fetch, so it cannot intercept this path. The substitute contract test constructs the real GoogleVertexProvider class (safe — its constructor makes no network call, it only builds a client object) and invokes its formatProviderError() directly with synthetic error objects, verifying the classifier logic in isolation.
Verified classifier (formatProviderError, src/lib/providers/googleVertex/client.ts:8465-8622, read verbatim this session): 401/403/PERMISSION_DENIED/UNAUTHENTICATED/statusCode 401 or 403 → AuthenticationError; QUOTA_EXCEEDED/RATE_LIMIT_EXCEEDED/"rate limit"/"429"/statusCode 429 or 529//overloaded/i → RateLimitError (with retryDelay scraped via /["']?retryDelay["']?\s*[:=]\s*["']?(\d+(?:\.\d+)?s)/). formatProviderError is declared protected (line 8465) — invoked here via a test-only double assertion (provider as unknown as {formatProviderError(...): Error}), which is banned in src/ under project rule 14 but explicitly exempt for test files.
Verified construction requirements: GoogleVertexProvider constructor signature is (modelName?, _providerName?, sdk?, region?, credentials?) — note the different param order vs. the other 4 providers. Construction guard hasGoogleCredentials() checks only environment variables, not the constructor's credentials param, so the test must setEnv("GOOGLE_SERVICE_ACCOUNT_KEY", <fake JSON>) before constructing or the constructor throws.
Steps:
-
8.1 Verify
GoogleVertexProvideris not exported from the main barrel (confirms the deep-import necessity) and confirm the deep-import precedent already used elsewhere in this test suite family:grep -c "GoogleVertexProvider" dist/index.js
grep -n "export class GoogleVertexProvider" dist/lib/providers/googleVertex/client.js
grep -n "from \"../dist/lib/providers/" test/continuous-test-suite-token-usage.tsExpected: first command returns
0; second confirms the named export exists in the deep path; third shows the existing precedent (test/continuous-test-suite-token-usage.ts:30-31importsmergeUsageandparseUsageFromResponseBodyfrom equivalent deep dist paths). -
8.2 Insert the new section function after
runAnthropicSection()(added in Task 7):// ───────────────────────────────────────────────────────────────────────
// Section: Vertex (construction + formatProviderError contract only —
// gaxios routes ADC token exchange through node-fetch, not globalThis.fetch,
// so installMockFetch() cannot intercept a full request/response round trip)
// ───────────────────────────────────────────────────────────────────────
async function runVertexSection(): Promise<void> {
const section = "Vertex (construction + formatProviderError contract)";
console.log(`\n=== ${section} ===`);
console.log(
" NOTE: Vertex's ADC token exchange goes through gaxios -> the " +
"node-fetch npm package directly, not globalThis.fetch, so " +
"installMockFetch() cannot intercept it. This section verifies " +
"provider construction plus the 403/429 branches of " +
"formatProviderError() directly instead of a full request/response " +
"round trip.",
);
setEnv(
"GOOGLE_SERVICE_ACCOUNT_KEY",
JSON.stringify({
type: "service_account",
project_id: "mock-project",
private_key:
"-----BEGIN PRIVATE KEY-----\nMOCK\n-----END PRIVATE KEY-----\n",
client_email: "[email protected]",
}),
);
try {
const { GoogleVertexProvider } =
await import("../dist/lib/providers/googleVertex/client.js");
const { AuthenticationError, RateLimitError } =
await import("../dist/lib/types/index.js");
const provider = new GoogleVertexProvider(
"gemini-2.5-flash",
"vertex",
undefined,
"us-central1",
{ projectId: "mock-project", location: "us-central1" },
);
record(results, `${section}: constructs without throwing`, true);
const formatError = (
provider as unknown as {
formatProviderError(error: unknown): Error;
}
).formatProviderError.bind(provider);
const authErr = formatError({
message: "403 PERMISSION_DENIED: caller does not have permission",
});
record(
results,
`${section}: 403 → AuthenticationError`,
authErr instanceof AuthenticationError,
`got ${authErr.constructor.name}`,
);
const rateErr = formatError({
message: '429 RESOURCE_EXHAUSTED: {"retryDelay":"12s"}',
});
record(
results,
`${section}: 429 → RateLimitError`,
rateErr instanceof RateLimitError,
`got ${rateErr.constructor.name}`,
);
} catch (err) {
record(
results,
`${section}: setup`,
false,
err instanceof Error ? err.message : String(err),
);
}
} -
8.3 Wire the call into
main():await runAnthropicSection();
+ await runVertexSection(); -
8.4 Typecheck and run:
pnpm run check && pnpm run build && pnpm run test:providers-mockedExpected:
✓ Vertex (construction + formatProviderError contract): constructs without throwing,✓ ...: 403 → AuthenticationError,✓ ...: 429 → RateLimitError, exit 0. -
8.5 Break-one-assertion sanity check (swap the expected class in the 429 case), then revert:
sed -i '' 's/rateErr instanceof RateLimitError,/rateErr instanceof AuthenticationError,/' test/continuous-test-suite-providers-mocked.ts
pnpm run test:providers-mocked; echo "exit code: $?"
git checkout -- test/continuous-test-suite-providers-mocked.tsExpected:
✗ Vertex (construction + formatProviderError contract): 429 → RateLimitError,exit code: 1, then the file is restored. -
8.6 Re-run to confirm clean pass, then commit:
pnpm run test:providers-mocked
git add test/continuous-test-suite-providers-mocked.ts
git commit -m "test(providers-mocked): add Vertex construction + formatProviderError contract section"
Task 9: Bedrock construction + formatProviderError contract section
Files:
- Modify:
test/continuous-test-suite-providers-mocked.ts(newrunBedrockSection()function + wiring intomain())
Interfaces:
- Consumes:
AmazonBedrockProviderfrom"../dist/lib/providers/amazonBedrock/client.js";AuthenticationError,RateLimitErrorfrom"../dist/lib/types/index.js". - Produces:
TestRecordentries"Bedrock (construction + formatProviderError contract): constructs without throwing","...: AccessDeniedException → AuthenticationError","...: ThrottlingException → RateLimitError".
Why construction-only, not fetch interception: the AWS SDK v3's @smithy/node-http-handler uses Node's native http/http2/https modules directly, not globalThis.fetch — installMockFetch() cannot intercept it. Substitute: construct the real AmazonBedrockProvider (confirmed safe — its constructor, src/lib/providers/amazonBedrock/client.ts:81-156, only builds a BedrockRuntimeClient object and never calls performInitialHealthCheck(), a separate method that is never invoked during construction) and invoke formatProviderError() directly with synthetic AWS SDK-shaped errors.
Verified classifier (formatProviderError, src/lib/providers/amazonBedrock/client.ts:2406-2441, read verbatim this session): message.includes("AccessDeniedException") (checked via error instanceof Error ? error.message : String(error) — must pass a real Error instance, a plain object stringifies to "[object Object]" and never matches) → AuthenticationError("AWS Bedrock access denied. Check your credentials and permissions.", providerName); throttling is checked via (error as {name?}).name === "ThrottlingException" || (error as {code?}).code === "ThrottlingException" (property check, not a message substring) → RateLimitError(...).
Steps:
-
9.1 Verify the constructor doesn't perform a health check, and confirm the deep-import path, immediately before writing the test:
grep -n "performInitialHealthCheck" src/lib/providers/amazonBedrock/client.ts
grep -c "AmazonBedrockProvider" dist/index.js
grep -n "export class AmazonBedrockProvider" dist/lib/providers/amazonBedrock/client.jsExpected:
performInitialHealthCheckappears as aprivate asyncmethod declaration and its latercatchblock, but is not called from inside theconstructor(...)body (only from other, unrelated code paths);dist/index.jsgrep returns0; the class export is confirmed in the deep dist path. -
9.2 Insert the new section function after
runVertexSection()(added in Task 8):// ───────────────────────────────────────────────────────────────────────
// Section: Bedrock (construction + formatProviderError contract only —
// AWS SDK v3's @smithy/node-http-handler uses native Node http(s), not
// globalThis.fetch, so installMockFetch() cannot intercept it)
// ───────────────────────────────────────────────────────────────────────
async function runBedrockSection(): Promise<void> {
const section = "Bedrock (construction + formatProviderError contract)";
console.log(`\n=== ${section} ===`);
console.log(
" NOTE: AWS SDK v3's @smithy/node-http-handler uses native Node " +
"http/http2/https, not globalThis.fetch, so installMockFetch() " +
"cannot intercept it. This section verifies provider construction " +
"plus the AccessDeniedException/ThrottlingException branches of " +
"formatProviderError() directly instead of a full request/response " +
"round trip.",
);
try {
const { AmazonBedrockProvider } =
await import("../dist/lib/providers/amazonBedrock/client.js");
const { AuthenticationError, RateLimitError } =
await import("../dist/lib/types/index.js");
const provider = new AmazonBedrockProvider(
"anthropic.claude-3-5-sonnet-20241022-v2:0",
undefined,
"us-east-1",
{ accessKeyId: "MOCKACCESSKEYID", secretAccessKey: "mock-secret" },
);
record(results, `${section}: constructs without throwing`, true);
const formatError = (
provider as unknown as {
formatProviderError(error: unknown): Error;
}
).formatProviderError.bind(provider);
const authErr = formatError(
new Error(
"AccessDeniedException: User is not authorized to perform this action",
),
);
record(
results,
`${section}: AccessDeniedException → AuthenticationError`,
authErr instanceof AuthenticationError,
`got ${authErr.constructor.name}`,
);
const throttleErr = formatError(
Object.assign(new Error("Rate exceeded"), {
name: "ThrottlingException",
}),
);
record(
results,
`${section}: ThrottlingException → RateLimitError`,
throttleErr instanceof RateLimitError,
`got ${throttleErr.constructor.name}`,
);
} catch (err) {
record(
results,
`${section}: setup`,
false,
err instanceof Error ? err.message : String(err),
);
}
} -
9.3 Wire the call into
main()and update the module docstring's coverage matrix to reflect all five new sections:await runVertexSection();
+ await runBedrockSection();* Coverage matrix:
*
* LLM (OpenAI-compat): xAI, Groq, Together AI, Fireworks, Perplexity
* LLM (custom shape): Cohere, Cloudflare Workers AI, Replicate
* Embeddings: Voyage AI, Jina AI
* Image-gen: Stability, Ideogram, Recraft
+ * LLM (native, fetch-interceptable): OpenAI, Azure, Anthropic
+ * LLM (native, construction-only —
+ * SDK bypasses globalThis.fetch): Vertex, Bedrock -
9.4 Typecheck and run the full mocked suite (all five new sections together):
pnpm run check && pnpm run build && pnpm run test:providers-mockedExpected: all prior sections plus
✓ Bedrock (construction + formatProviderError contract): constructs without throwing,✓ ...: AccessDeniedException → AuthenticationError,✓ ...: ThrottlingException → RateLimitError; final summary line shows0 failed; exit 0. -
9.5 Break-one-assertion sanity check (drop the
.nameassignment so the throttle branch can't match), then revert:sed -i '' 's/name: "ThrottlingException",/name: "SomethingElse",/' test/continuous-test-suite-providers-mocked.ts
pnpm run test:providers-mocked; echo "exit code: $?"
git checkout -- test/continuous-test-suite-providers-mocked.tsExpected:
✗ Bedrock (construction + formatProviderError contract): ThrottlingException → RateLimitError,exit code: 1, then the file is restored. -
9.6 Re-run to confirm clean pass, then commit:
pnpm run test:providers-mocked
git add test/continuous-test-suite-providers-mocked.ts
git commit -m "test(providers-mocked): add Bedrock construction + formatProviderError contract section"
Task 10: Scheduled nightly live-matrix.yml (not a PR gate)
Files:
- Create:
.github/workflows/live-matrix.yml
Interfaces:
- Consumes:
pnpm run test:matrix(test/continuous-test-suite-provider-matrix.ts, self-gates viahasProviderEnv()per-provider filtering — confirmed it exits 0 cleanly with zero targets when no keys are present). - Produces: a new, non-required GitHub Actions workflow triggered by
schedule(cron) andworkflow_dispatch, entirely separate fromci.yml's PR-triggered jobs — never added to.github/settings.yml's required contexts.
Steps:
-
10.1 Verify
test:matrix's self-gating behavior by running it locally with no provider keys set, to confirm the "clean skip" claim before building a workflow around it:env -i PATH="$PATH" HOME="$HOME" npx tsx test/continuous-test-suite-provider-matrix.ts; echo "exit code: $?"Expected: the suite logs that zero providers have credentials configured and exits
0(not a hang, not a crash) — confirming it's safe to run unconditionally in a scheduled workflow without pre-filtering secrets in the YAML. -
10.2 Create
.github/workflows/live-matrix.yml:name: Live Provider Matrix (Nightly)
on:
schedule:
# 03:00 UTC daily — off-peak, avoids colliding with PR CI load.
- cron: "0 3 * * *"
workflow_dispatch: {}
permissions:
contents: read
jobs:
live-matrix:
name: 🌐 Live Provider Matrix Sweep
runs-on: ubuntu-latest
# Not a required check — this workflow never appears in
# .github/settings.yml's branch-protection contexts. It exercises real
# provider credentials against real APIs and must not block merges on
# transient upstream outages or missing repo secrets.
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PNPM
uses: pnpm/action-setup@v4
with:
version: 10.15.1
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: 🔄 SvelteKit Sync
run: pnpm exec svelte-kit sync
- name: Build package
run: pnpm run build
- name: Live provider matrix sweep (self-gates cleanly when keys are absent)
run: pnpm run test:matrix
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GOOGLE_AI_API_KEY: ${{ secrets.GOOGLE_AI_API_KEY }}
GOOGLE_SERVICE_ACCOUNT_KEY: ${{ secrets.GOOGLE_SERVICE_ACCOUNT_KEY }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
HUGGINGFACE_API_KEY: ${{ secrets.HUGGINGFACE_API_KEY }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
VOYAGE_API_KEY: ${{ secrets.VOYAGE_API_KEY }}
JINA_API_KEY: ${{ secrets.JINA_API_KEY }}
STABILITY_API_KEY: ${{ secrets.STABILITY_API_KEY }}
IDEOGRAM_API_KEY: ${{ secrets.IDEOGRAM_API_KEY }}
RECRAFT_API_KEY: ${{ secrets.RECRAFT_API_KEY }}
REPLICATE_API_TOKEN: ${{ secrets.REPLICATE_API_TOKEN }} -
10.3 Validate the YAML parses:
npx -y js-yaml .github/workflows/live-matrix.yml > /dev/null && echo "YAML OK"Expected:
YAML OK. -
10.4 Confirm this workflow file is not referenced anywhere in
.github/settings.yml's required contexts (it must never block a PR):grep -n "live-matrix\|Live Provider Matrix" .github/settings.ymlExpected: no matches.
-
10.5 Trigger a manual dry run locally to prove the command it invokes behaves as expected without keys (already done in 10.1) — no further local step needed since
workflow_dispatchtriggers are validated on GitHub, not locally. -
10.6 Commit:
git add .github/workflows/live-matrix.yml
git commit -m "ci: add nightly live-matrix workflow (schedule + workflow_dispatch, not a PR gate)"
Task 11: Documentation and comment truth fixes
Files:
- Modify:
test/helpers/providerMatrix.ts(header docstring, lines 1-4) - Modify:
docs/provider-integration/06-testing.md(Section A, lines 12-42; Section D, line 137) - Modify:
package.json(three"// CI tier"comment keys, lines 160, 167, 169)
Interfaces: none (comment/doc-only changes; no runtime symbols produced or consumed).
Provider count verified at 30: the assignment specified fixing providerMatrix.ts's docstring from "13 providers" to "30 providers" (matching the full AIProviderName enum). Counting the actual keys in the file's PROVIDERS object (test/helpers/providerMatrix.ts:64) confirms 30 entries — an exact match against every non-AUTO value in the AIProviderName enum (src/lib/constants/enums.ts:8-39), with zero gaps. (An earlier pass at this count used a grep pattern anchored on ^ [a-z], which silently skips the five quoted kebab-case keys — "google-ai", "openai-compatible", "nvidia-nim", "lm-studio", "together-ai" — undercounting to 25; the corrected pattern below matches both bare and quoted keys and confirms 30.)
Steps:
-
11.1 Re-verify the provider count immediately before editing (guards against the object having changed since this plan was written; the pattern matches both bare identifier keys like
openai:and quoted kebab-case keys like"google-ai":):awk '/^export const PROVIDERS/,/^};/' test/helpers/providerMatrix.ts | grep -cE '^ "?[a-zA-Z][a-zA-Z0-9_-]*"?: \{'Expected:
30. -
11.2 Fix
test/helpers/providerMatrix.ts's header docstring:/**
- * Provider capability matrix — the single source of truth for what each of
- * NeuroLink's 13 providers supports. Used by the matrix test runner and any
- * suite that needs to skip a test based on provider capability.
+ * Provider capability matrix — the single source of truth for what each of
+ * NeuroLink's 30 providers supports. Used by the matrix test runner and any
+ * suite that needs to skip a test based on provider capability.
*
* Adding a new provider: -
11.3 Fix
docs/provider-integration/06-testing.mdSection A — the current text describes anALL_PROVIDERSarray that no longer exists incontinuous-test-suite-providers.ts(removed, per the "ALL_PROVIDERS list removed" comment near the top of that file):### A. `test/continuous-test-suite-providers.ts`
-
-This is the main provider suite. The relevant section is the `ALL_PROVIDERS` array (around line 73):
-
-```diff
- const ALL_PROVIDERS = [
- "openai",
- "anthropic",
- "vertex",
- "google-ai",
- "openrouter",
- "bedrock",
- "azure",
- "mistral",
- "ollama",
- "litellm",
- "huggingface",
-+ "deepseek",
-+ "nvidia-nim",
-+ "lm-studio",
-+ "llamacpp",
- ] as const;
-```
-
-The all-provider loop at the bottom of this file iterates `ALL_PROVIDERS` and:
-
-1. Calls `validateConfiguration()` on the provider
-2. Skips with `[SKIP] env not configured` if it returns false
-3. Otherwise runs `generate("Hi")` and `stream("Hi")`
-
-For local providers (LM Studio, llama.cpp), the skip behavior already works because `validateConfiguration()` does an HTTP probe of `/v1/models` or `/health`. If the server isn't running, returns false → tests skip.
+
+**Updated 2026-08-15:** the `ALL_PROVIDERS` array described below no longer
+exists — `continuous-test-suite-providers.ts` deleted it once provider
+coverage moved to two more targeted places:
+
+1. **Structural completeness** (zero API keys, runs in CI on every commit):
+ `test/continuous-test-suite-provider-structure.ts`
+ (`pnpm run test:provider-structure`) asserts every value in the
+ canonical `AIProviderName` enum resolves via `ProviderFactory`, and every
+ `src/lib/providers/*.ts` module has exactly one dynamic import in
+ `providerRegistry.ts`.
+2. **Live per-provider generate/stream sweep** (needs API keys, runs
+ nightly via `.github/workflows/live-matrix.yml`, not a PR gate):
+ `test/continuous-test-suite-provider-matrix.ts` (`pnpm run test:matrix`)
+ iterates `test/helpers/providerMatrix.ts`'s `PROVIDERS` map — see that
+ file's header comment for the current provider count and coverage gaps.
+
+If you're adding a new provider, add it to `providerMatrix.ts`'s
+`PROVIDERS` map so `test:matrix` picks it up automatically;
+`test:provider-structure` needs no edits — it derives its expectations from
+the `AIProviderName` enum and the filesystem, not a hand-maintained list. -
11.4 Fix Section D's dead phrase (line 137, references the same deleted loop):
-The canonical entrypoint for the four new providers is **`pnpm run test:new-providers`**, which runs the dedicated suite `test/continuous-test-suite-new-providers.ts` (full feature surface per provider — generate, stream, tools, structured, reasoning, vision-where-supported, abort, timeout, per-call creds, telemetry, error formatting). The existing `test:providers` (`ALL_PROVIDERS` loop) and `test:credentials` are still useful for cross-provider checks but the new suite is the primary coverage for the integration.
+The canonical entrypoint for the four new providers is **`pnpm run test:new-providers`**, which runs the dedicated suite `test/continuous-test-suite-new-providers.ts` (full feature surface per provider — generate, stream, tools, structured, reasoning, vision-where-supported, abort, timeout, per-call creds, telemetry, error formatting). The existing `test:providers` and `test:credentials` are still useful for cross-provider checks but the new suite is the primary coverage for the integration. -
11.5 Update
package.json's three"// CI tier"comment keys to reflect the now-real CI automation added in Task 2 (previously these comments described an aspirational tier structure with zero actual CI wiring):- "// CI tier — fast, no live AI calls, safe for every commit": "",
+ "// CI tier — fast, no live AI calls, safe for every commit (test:unit; also see the separate provider-safety-net CI job, which runs build + test:providers-mocked + test:provider-structure on every PR)": "",- "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit)": "",
+ "// CI tier — live providers, runs only when API keys are present (test:credentials and test:dynamic make real provider calls when keys are set, so they live here, not in test:unit; test:providers itself now runs nightly via .github/workflows/live-matrix.yml, not on every PR)": "",- "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run": "",
+ "// CI tier — product output (image/video/TTS/PPT) — costs $$ per run (not wired into any GitHub Actions workflow as of this comment; run manually or add to live-matrix.yml if nightly coverage is needed)": "", -
11.6 Verify
package.jsonis still valid JSON after the comment-key edits:node -e "JSON.parse(require('fs').readFileSync('package.json', 'utf8')); console.log('valid JSON')"Expected:
valid JSON. -
11.7 Confirm the doc changes render sensibly (no broken markdown fences from the diff removal) by checking the file's fence balance:
grep -c '^```' docs/provider-integration/06-testing.mdExpected: an even number (each opening fence has a matching close).
-
11.8 Commit:
git add test/helpers/providerMatrix.ts docs/provider-integration/06-testing.md package.json
git commit -m "docs: fix stale provider counts and dead ALL_PROVIDERS references"
Task 12: Fix hardcoded /9 denominator in environmentManager.ts
Files:
- Modify:
tools/automation/environmentManager.ts(reportValidation()lines 316-353,calculateScore()lines 355-369)
Interfaces: none new (internal refactor of an existing class method's arithmetic — no exported symbol's signature changes).
Verified current state (full 461-line file read verbatim this session): validateEnvironment() builds validation.providers as a 9-key object (openai, anthropic, "google-ai", vertex, bedrock, azure, huggingface, ollama, mistral — tools/automation/environmentManager.ts:252-266). reportValidation() prints `✅ Configured providers: ${validation.configured.length}/9` and `⚠️ Missing providers: ${validation.missing.length}/9` (lines 319-320) — hardcoded literals, not derived from the object. calculateScore() computes (validation.configured.length / 9) * configuredWeight (line 361) — same hardcoded 9. This plan's scope is limited to deriving the denominator from the object's own key count (stopping the lie that it's always exactly 9); a full descriptor-driven rewrite covering all 30 providers is explicitly out of scope (owned by a separate plan covering environmentManager.ts's full provider-descriptor derivation).
Steps:
-
12.1 Verify the current hardcoded values one more time immediately before editing:
grep -n "/9\|9)" tools/automation/environmentManager.tsExpected: three matches —
reportValidation()'s two template-literal/9usages (lines 319-320) andcalculateScore()'s/ 9division (line 361). -
12.2 Fix
reportValidation()to derive the denominator fromvalidation.providers:reportValidation(validation: any) {
console.log("\n📊 ENVIRONMENT VALIDATION RESULTS");
console.log("=".repeat(50));
- console.log(`✅ Configured providers: ${validation.configured.length}/9`);
- console.log(`⚠️ Missing providers: ${validation.missing.length}/9`);
+ const totalProviders = Object.keys(validation.providers).length;
+ console.log(
+ `✅ Configured providers: ${validation.configured.length}/${totalProviders}`,
+ );
+ console.log(
+ `⚠️ Missing providers: ${validation.missing.length}/${totalProviders}`,
+ ); -
12.3 Fix
calculateScore()to derive the same denominator independently (it's a separate method receiving the samevalidationobject, so it must compute its owntotalProvidersrather than relying on a value set inreportValidation()):calculateScore(validation: any) {
const configuredWeight = 70; // 70% for having providers configured
const diversityWeight = 20; // 20% for provider diversity
const bestPracticeWeight = 10; // 10% for following best practices
+ const totalProviders = Object.keys(validation.providers).length;
const configuredScore =
- (validation.configured.length / 9) * configuredWeight;
+ (validation.configured.length / totalProviders) * configuredWeight;
const diversityScore =
Math.min(validation.configured.length / 3, 1) * diversityWeight; -
12.4 Typecheck (this file is TypeScript run via
tsx, not part of the compiledsrc/build, sopnpm run checkmay or may not cover it — verify directly withtsc --noEmitscoped to this file's syntax via a dry run):npx tsx --test-only tools/automation/environmentManager.ts --validate 2>&1 | head -20 || npx tsx tools/automation/environmentManager.ts --validateExpected: the script runs (prints the
🔍 Validating environment configuration...banner and a final📈 Environment Score:line) without a TypeScript syntax error; the two provider-count lines now show/9only ifvalidation.providersstill happens to have 9 keys (it does, since this task doesn't changevalidateEnvironment()'s key list) — confirming the derived value matches the previous hardcoded one exactly for today's 9-provider set, while no longer being a lie if that set ever changes. -
12.5 Confirm no other hardcoded
9reference to provider count remains in the file:grep -n "/ 9\|/9\b" tools/automation/environmentManager.tsExpected: no matches.
-
12.6 Commit:
git add tools/automation/environmentManager.ts
git commit -m "fix(tools): derive environmentManager provider-count denominator instead of hardcoding /9"
Verification Checklist
-
pnpm run buildsucceeds cleanly from a freshdist/. -
pnpm run check(typecheck) passes with zero errors. -
pnpm run lintpasses with zero errors. -
pnpm run test:provider-structurepasses (Task 1) — bothModel Registry CompletenessandProvider Registration Completenessreport✓. -
pnpm run test:providers-mockedpasses with all ten sections (5 pre-existing + 5 new from Tasks 5-9) reporting✓, final summary0 failed. -
test/continuous-test-suite-providers.tsno longer referencestestModelRegistryCompleteness,testProviderRegistrationCompleteness,PROVIDER_REGISTRATION_EXCLUSIONS, orDYNAMIC_PROVIDER_IMPORT_RE, and no longer importspath. -
.github/workflows/ci.ymlcontains aprovider-safety-netjob with nostrategy:/name:overriding its check-run name; thetest:job'sstrategy: matrix:block is gone;quality-gate's no-op "🎯 Test Suite Validation" step is gone. -
.github/settings.yml's twobranches: - name: releaseblocks both list["test", "build-check", "provider-safety-net", "Yama PR Review"]as required contexts. -
.husky/pre-pushexists, is executable, and itspackage.jsonpre-pushscript runs exactlypnpm run build && pnpm run test:providers-mocked && pnpm run test:provider-structure. -
.github/workflows/live-matrix.ymlexists, is triggered byschedule+workflow_dispatchonly, and is not listed in.github/settings.yml's required contexts. -
test/helpers/providerMatrix.ts's header docstring says 30, not 13. -
docs/provider-integration/06-testing.mdno longer references a deletedALL_PROVIDERSarray. -
tools/automation/environmentManager.tshas zero remaining hardcoded/9(or/ 9) provider-count literals. - Every new/modified
assert()/expect()/expectEq()message in this plan's tasks was sanity-checked via the break-one-assertion method (Tasks 5.5, 6.5, 7.6, 8.5, 9.5) and confirmed to produce a real, non-zero-exit failure — not a silent skip.
Risks & Rollback
- Risk: dropping the
test:job'snode-versionmatrix (Task 3) reduces Node-version coverage to a single version (20) if a future contributor assumed multi-version testing was happening. Mitigation: it was already a single-entry matrix providing zero actual multi-version coverage; this is a naming fix, not a coverage reduction. Rollback: re-addstrategy: matrix: node-version: [20, 22]and update.github/settings.yml's contexts to"test (20)"/"test (22)"if broader version coverage is later desired. - Risk: the new
provider-safety-netCI job becomes a required check (via Task 3'ssettings.ymlupdate) before it has been proven stable onrelease, potentially blocking legitimate PRs on a flaky new test. Mitigation: Tasks 5-9 each include a build → test → break-one-assertion → revert → re-test cycle before committing, so every new assertion is proven to both pass on real code and genuinely fail on broken code before it becomes a required gate. Rollback: remove"provider-safety-net"from.github/settings.yml'scontexts:list (GitHub Settings app re-syncs on the next push to a config-changing PR merged to the default branch) without touching the CI job itself, decoupling "job exists" from "job blocks merges." - Risk: Task 12's
environmentManager.tsfix changes the displayed score/ratio ifvalidation.providers' key count ever diverges from 9 in the future (e.g., if a later plan expandsvalidateEnvironment()'s provider list) — anyone with a saved/cached "score out of 100 assuming 9 providers" expectation would see different numbers. Mitigation: this is the entire point of the fix (stop the lie); the displayed ratio becomes more accurate, not less. Rollback: revert the single commit from Task 12; no other task depends on this change. - Risk: Tasks 8-9's
formatProviderErrorinvocation viaprovider as unknown as {formatProviderError(...): Error}is a double type assertion — normally banned under project rule 14. Mitigation: rule 14 explicitly exempts test files; both usages are confined totest/continuous-test-suite-providers-mocked.ts, neversrc/. Rollback: none needed; this is compliant as written. - General rollback for any single task: every task ends in its own commit with a Conventional Commits message;
git revert <sha>cleanly undoes any one task without affecting the others, since no task's committed state depends on a later task's uncommitted changes (each task's validation step runs against the fully-committed state of all prior tasks).
Out of Scope
- Extending the mocked-contract pattern to the remaining ~20 providers beyond OpenAI/Azure/Anthropic/Vertex/Bedrock (this plan's 5 targets) — covered by a per-provider onboarding requirement in the plan governing new-provider PR checklists, and by the plan covering providers ported/migrated in this redesign.
- Full
environmentManager.tsprovider-descriptor rewrite (deriving the entire validation matrix, not just the denominator, from a shared descriptor source covering all 30 canonical providers) — this plan's Task 12 only stops the hardcoded/9lie; the full derivation is owned by the plan coveringtools/automation/provider-descriptor consolidation. - Any
src/lib/refactor of provider classes themselves (error-classifier gaps documented in Tasks 6-7 — Azure's missing 429 branch, Anthropic's 401 substring-match gap — are intentionally left as-is and merely asserted-as-documented; fixing the classifiers is asrc/behavior change outside a CI-safety-net plan's scope). - Wiring
test:ci/test:unit/test:live/test:productinto any GitHub Actions workflow beyond what Tasks 2 and 10 add — the pre-existing gap where mosttest:*npm scripts are invoked by no CI workflow or git hook at all remains, apart from the specific scripts this plan wires intoprovider-safety-net,pre-push, andlive-matrix.yml.