CrispStrobe Claude Opus 4.8 (1M context) commited on
Commit
e806ac6
Β·
1 Parent(s): b27d5c9

ci: add provider health-check workflow to catch broken fetchers

Browse files

scripts/healthcheck.js runs every fetcher and fails if one returns
too few models or throws β€” the failure mode that silently broke the
Nebius and Mistral scrapers. Keyed providers are neutral (SKIP) when
their secret is absent.

healthcheck.yml runs it on PRs and pushes touching scripts/**, on a
daily schedule, and on demand; a second job verifies the app builds.

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

.github/workflows/healthcheck.yml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Provider health check
2
+
3
+ # Catches upstream breakage: if a provider changes its page/API shape and a
4
+ # fetcher starts returning 0 models (or throws), this run goes red and notifies.
5
+ on:
6
+ schedule:
7
+ - cron: '0 12 * * *' # daily at 12:00 UTC (independent of the 06:00 data update)
8
+ pull_request:
9
+ paths:
10
+ - 'scripts/**'
11
+ - '.github/workflows/healthcheck.yml'
12
+ push:
13
+ branches: [main]
14
+ paths:
15
+ - 'scripts/providers/**'
16
+ - 'scripts/fetch-providers.js'
17
+ - 'scripts/healthcheck.js'
18
+ workflow_dispatch:
19
+
20
+ permissions:
21
+ contents: read
22
+
23
+ jobs:
24
+ # Verify every provider fetcher still returns a sane number of models.
25
+ fetchers:
26
+ runs-on: ubuntu-latest
27
+ timeout-minutes: 10
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: actions/setup-node@v4
31
+ with:
32
+ node-version: '22'
33
+ cache: 'npm'
34
+ - run: npm install
35
+ - name: Run fetcher health check
36
+ env:
37
+ REQUESTY_API_KEY: ${{ secrets.REQUESTY_API_KEY }}
38
+ OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
39
+ NSCALE_API_KEY: ${{ secrets.NSCALE_API_KEY }}
40
+ NEBIUS_API_KEY: ${{ secrets.NEBIUS_API_KEY }}
41
+ run: node scripts/healthcheck.js
42
+
43
+ # Verify the app still builds (TypeScript + Vite).
44
+ build:
45
+ runs-on: ubuntu-latest
46
+ timeout-minutes: 10
47
+ steps:
48
+ - uses: actions/checkout@v4
49
+ - uses: actions/setup-node@v4
50
+ with:
51
+ node-version: '22'
52
+ cache: 'npm'
53
+ - run: npm install
54
+ - run: npm run build
package.json CHANGED
@@ -25,7 +25,8 @@
25
  "fetch:benchmarks": "node scripts/fetch-benchmarks.js",
26
  "push:hf": "node scripts/sync-hf.js",
27
  "server": "node server.js",
28
- "dev:api": "node server.js"
 
29
  },
30
  "keywords": [],
31
  "author": "",
 
25
  "fetch:benchmarks": "node scripts/fetch-benchmarks.js",
26
  "push:hf": "node scripts/sync-hf.js",
27
  "server": "node server.js",
28
+ "dev:api": "node server.js",
29
+ "healthcheck": "node scripts/healthcheck.js"
30
  },
31
  "keywords": [],
32
  "author": "",
scripts/healthcheck.js ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 'use strict';
2
+
3
+ /**
4
+ * Provider fetcher health check.
5
+ *
6
+ * Runs every provider fetcher and asserts it still returns a sane number of
7
+ * models. This catches the failure mode where an upstream page/API changes
8
+ * shape and a scraper silently returns 0 (or throws) β€” as happened when Nebius
9
+ * rebranded to Token Factory and Mistral moved to Astro.
10
+ *
11
+ * Exit code:
12
+ * 0 β€” all checked providers healthy
13
+ * 1 β€” one or more providers broke (below their minimum, or threw)
14
+ *
15
+ * Providers that require an API key are only checked when that key is present
16
+ * in the environment; otherwise they are reported as SKIP (neutral), so the
17
+ * check still passes on forks / environments without secrets.
18
+ *
19
+ * Usage: node scripts/healthcheck.js
20
+ */
21
+
22
+ const path = require('path');
23
+
24
+ // key: provider module under scripts/providers/
25
+ // min: lowest model count we still consider healthy (set well below the live
26
+ // count so normal catalog churn never trips it, but a broken scraper does)
27
+ // keyEnv: env var required to reach the source (null = public scrape)
28
+ const CHECKS = [
29
+ { key: 'scaleway', min: 8, keyEnv: null },
30
+ { key: 'ovhcloud', min: 10, keyEnv: null },
31
+ { key: 'stackit', min: 4, keyEnv: null },
32
+ { key: 'mistral', min: 10, keyEnv: null },
33
+ { key: 'langdock', min: 12, keyEnv: null },
34
+ { key: 'groq', min: 5, keyEnv: null },
35
+ { key: 'infomaniak', min: 6, keyEnv: null },
36
+ { key: 'ionos', min: 5, keyEnv: null },
37
+ { key: 'black-forest-labs', min: 6, keyEnv: null },
38
+ { key: 'nebius', min: 10, keyEnv: 'NEBIUS_API_KEY' },
39
+ { key: 'nscale', min: 5, keyEnv: 'NSCALE_API_KEY' },
40
+ { key: 'requesty', min: 40, keyEnv: 'REQUESTY_API_KEY' },
41
+ { key: 'openrouter', min: 80, keyEnv: 'OPENROUTER_API_KEY' },
42
+ ];
43
+
44
+ const TIMEOUT_MS = 120000;
45
+
46
+ function loadFetcher(key) {
47
+ const mod = require(path.join(__dirname, 'providers', key));
48
+ const fn = Object.values(mod).find((v) => typeof v === 'function');
49
+ if (!fn) throw new Error(`module ${key} exports no fetcher function`);
50
+ return { fn, providerName: mod.providerName || key };
51
+ }
52
+
53
+ function withTimeout(promise, ms, label) {
54
+ return Promise.race([
55
+ promise,
56
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)),
57
+ ]).catch((e) => { throw new Error(`${label}: ${e.message}`); });
58
+ }
59
+
60
+ async function main() {
61
+ const results = [];
62
+
63
+ for (const check of CHECKS) {
64
+ const { key, min, keyEnv } = check;
65
+ let providerName = key;
66
+
67
+ if (keyEnv && !process.env[keyEnv]) {
68
+ results.push({ key, providerName, status: 'SKIP', count: null, note: `no ${keyEnv}` });
69
+ continue;
70
+ }
71
+
72
+ try {
73
+ const { fn, providerName: name } = loadFetcher(key);
74
+ providerName = name;
75
+ const models = await withTimeout(Promise.resolve().then(fn), TIMEOUT_MS, key);
76
+ const count = Array.isArray(models) ? models.length : 0;
77
+ if (count >= min) {
78
+ results.push({ key, providerName, status: 'PASS', count, note: `>= ${min}` });
79
+ } else {
80
+ results.push({ key, providerName, status: 'FAIL', count, note: `expected >= ${min}` });
81
+ }
82
+ } catch (err) {
83
+ results.push({ key, providerName, status: 'FAIL', count: null, note: err.message });
84
+ }
85
+ }
86
+
87
+ // Report
88
+ const pad = (s, n) => String(s).padEnd(n);
89
+ console.log('\nProvider fetcher health check\n' + '='.repeat(60));
90
+ for (const r of results) {
91
+ const mark = r.status === 'PASS' ? 'βœ“' : r.status === 'SKIP' ? '–' : 'βœ—';
92
+ const count = r.count == null ? '' : `${r.count} models`;
93
+ console.log(` ${mark} ${pad(r.status, 5)} ${pad(r.providerName, 20)} ${pad(count, 12)} ${r.note}`);
94
+ }
95
+ console.log('='.repeat(60));
96
+
97
+ const failed = results.filter((r) => r.status === 'FAIL');
98
+ const passed = results.filter((r) => r.status === 'PASS').length;
99
+ const skipped = results.filter((r) => r.status === 'SKIP').length;
100
+ console.log(` ${passed} passed, ${failed.length} failed, ${skipped} skipped\n`);
101
+
102
+ if (failed.length) {
103
+ console.error('BROKEN FETCHERS: ' + failed.map((r) => r.providerName).join(', '));
104
+ process.exit(1);
105
+ }
106
+ }
107
+
108
+ main().catch((err) => {
109
+ console.error('Health check crashed:', err);
110
+ process.exit(1);
111
+ });