Session 20: Provider intelligence — quota tracker, gateway with fallback cascade, admin quota dashboard (1476 tests)

This commit is contained in:
Kev
2026-06-12 00:54:39 -04:00
parent 56392ec8f4
commit 9b10bb4138
17 changed files with 1422 additions and 15 deletions
+149
View File
@@ -0,0 +1,149 @@
'use strict';
/**
* Provider registry (Session 20).
*
* Every external data provider VYNDR talks to is enumerated here.
* Each entry declares:
* - envKey — the environment variable holding the API key
* (presence = the provider is configured)
* - quotaType — 'monthly' | 'daily' | 'per_minute'
* - quotaLimit — calls allowed per quotaType period
* - resetDay — for monthly quotas, day-of-month the counter
* resets (1 = first of the month). null otherwise.
* - sports — which sport keys this provider covers
* - capabilities — what kinds of data it can return
* - priority — 1 = primary for its capability set; higher
* numbers are fallbacks
*
* The quotaTracker keys off provider IDs from this map. Wiring a
* new provider = adding it here + having its adapter route through
* providerGateway.fetch(providerId, callback, opts).
*
* IMPORTANT: keep quotaLimit conservative. Over-counting throttles
* the platform under-load; under-counting blows the budget. If a
* provider's actual limit changes (e.g. plan upgrade), update this
* number — the tracker re-reads it each call.
*/
const PROVIDERS = {
// === ODDS / LINES ===
'odds-api': {
name: 'The Odds API',
envKey: 'ODDS_API_KEY',
quotaType: 'monthly',
quotaLimit: 500,
resetDay: 1,
sports: ['nba', 'wnba', 'mlb', 'soccer_wc', 'nfl', 'nhl'],
capabilities: ['odds', 'props', 'lines', 'spreads'],
priority: 1,
},
'oddspapi': {
name: 'ODDSPAPI',
envKey: 'ODDSPAPI_KEY',
quotaType: 'monthly',
quotaLimit: 1000,
resetDay: 1,
sports: ['nba', 'wnba', 'mlb', 'nfl'],
capabilities: ['odds', 'props'],
priority: 2,
},
'parlayapi': {
name: 'ParlayAPI',
envKey: 'PARLAYAPI_KEY',
quotaType: 'monthly',
quotaLimit: 1000,
resetDay: 1,
sports: ['nba', 'wnba', 'mlb', 'nfl'],
capabilities: ['odds', 'parlays', 'correlations'],
priority: 3,
},
// === STATS / BOX SCORES ===
'tank01': {
name: 'Tank01 (RapidAPI)',
envKey: 'RAPID_API_KEY',
quotaType: 'monthly',
quotaLimit: 1000,
resetDay: 1,
sports: ['nba', 'mlb'],
capabilities: ['box_scores', 'schedules', 'player_stats', 'bvp'],
priority: 1,
},
// === SOCCER ===
'api-football': {
name: 'API-Football',
envKey: 'API_FOOTBALL_KEY',
quotaType: 'daily',
quotaLimit: 100,
resetDay: null,
sports: ['soccer_wc', 'soccer'],
capabilities: ['lineups', 'player_stats', 'match_events', 'live_scores'],
priority: 1,
},
'football-data': {
name: 'Football-Data.org',
envKey: 'FOOTBALL_DATA_API_KEY',
quotaType: 'per_minute',
quotaLimit: 10,
resetDay: null,
sports: ['soccer_wc', 'soccer'],
capabilities: ['standings', 'fixtures', 'scorers'],
priority: 2,
},
};
/**
* Threshold constants — shared by quotaTracker and providerGateway
* so the WARN/BLOCK lines stay in lockstep.
*/
const THRESHOLDS = Object.freeze({
WARN_PCT: 0.80,
BLOCK_PCT: 0.95,
});
function getProvider(providerId) {
return PROVIDERS[providerId] || null;
}
function listProviderIds() {
return Object.keys(PROVIDERS);
}
/**
* Subset of providers whose envKey is set. Logged at startup; used
* by the admin dashboard to render only providers the operator has
* actually wired up.
*/
function getConfiguredProviders() {
return Object.entries(PROVIDERS)
.filter(([, cfg]) => !!process.env[cfg.envKey])
.map(([id, cfg]) => ({ id, ...cfg }));
}
/**
* Fallback chain for a capability + sport, in priority order,
* excluding `excludeId`. Used by the gateway to walk down to the
* next provider when the primary is exhausted.
*/
function getFallbackChain(capability, sport, excludeId) {
return Object.entries(PROVIDERS)
.filter(([id, cfg]) =>
id !== excludeId &&
cfg.capabilities.includes(capability) &&
(!sport || cfg.sports.includes(sport)) &&
!!process.env[cfg.envKey],
)
.sort((a, b) => a[1].priority - b[1].priority)
.map(([id]) => id);
}
module.exports = {
PROVIDERS,
THRESHOLDS,
getProvider,
listProviderIds,
getConfiguredProviders,
getFallbackChain,
};