diff --git a/recommend.js b/recommend.js index d582465..a87b1f5 100644 --- a/recommend.js +++ b/recommend.js @@ -5,12 +5,18 @@ * Reads from the cached token-monitor run (if fresh) or probes directly. * Returns the best Teams provider considering 7d budget utilization. * - * Selection rules: - * 1. status must be "allowed" or "allowed_warning" (both can serve requests) + * Selection rules (extra-usage credit era, post April 4 2026): + * 0. Consult provider-check.json (pi session usability) — primary availability signal + * With extra-usage credit, 7d limits don't block sessions; broken OAuth tokens do. + * 1. Among pi-usable providers: status must be "allowed" or "allowed_warning" * 2. Scan chain in order; take first with utilization_7d < SWITCH_THRESHOLD * 3. If none under threshold, take lowest-utilization allowed/allowed_warning provider * 4. If no usable Teams providers, return emergency=true (shelley-proxy) * + * Note on provider-check.json: + * Written by ~/projects/provider-check/provider-check.ts (run by health-pulse hourly) + * at /tmp/provider-check.json. Stale if > 2 hours old — fallback to budget-only logic. + * * Usage: * node recommend.js # JSON output * node recommend.js --threshold 0.80 # switch above 80% 7d @@ -23,6 +29,7 @@ import { getProviders } from './providers/index.js'; import { probeTeamsProvider } from './providers/anthropic-teams.js'; import { getCachedRun } from './logger.js'; +import { readFileSync, existsSync, statSync } from 'fs'; const args = process.argv.slice(2); @@ -32,13 +39,36 @@ const SWITCH_THRESHOLD = threshIdx !== -1 ? parseFloat(args[threshIdx + 1]) : 0. // Parse --chain const chainIdx = args.indexOf('--chain'); -const DEFAULT_CHAIN = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja']; +const DEFAULT_CHAIN = ['team-vigilio', 'team-ludo', 'team-buio', 'team-molto', 'team-nadja']; const PROVIDER_CHAIN = chainIdx !== -1 ? args[chainIdx + 1].split(',').map(s => s.trim()) : DEFAULT_CHAIN; const DEFAULT_MODEL = 'claude-sonnet-4-6'; const EMERGENCY_FALLBACK = 'shelley-proxy'; +const PROVIDER_CHECK_PATH = '/tmp/provider-check.json'; +const PROVIDER_CHECK_MAX_AGE_MS = 2 * 60 * 60 * 1000; // 2 hours + +// Load provider-check.json — returns Set of provider names that can start pi sessions +// Returns null if file missing, stale, or unreadable (fall through to budget-only logic) +function getPiUsableProviders() { + try { + if (!existsSync(PROVIDER_CHECK_PATH)) return null; + const stat = statSync(PROVIDER_CHECK_PATH); + const ageMs = Date.now() - stat.mtimeMs; + if (ageMs > PROVIDER_CHECK_MAX_AGE_MS) return null; // stale — don't trust it + const data = JSON.parse(readFileSync(PROVIDER_CHECK_PATH, 'utf8')); + if (!data.results) return null; + const usable = new Set( + data.results + .filter(r => r.status === 'ok') + .map(r => r.provider) + ); + return usable.size > 0 ? usable : null; + } catch { + return null; + } +} // Format utilization as percentage string function pct(v) { @@ -86,10 +116,25 @@ async function main() { const { source, providers } = await getProviderData(); // Filter to chain members that are Teams providers with data - const candidates = PROVIDER_CHAIN + let candidates = PROVIDER_CHAIN .filter(name => providers[name] && providers[name].type === 'teams-direct') .map(name => ({ name, ...providers[name] })); + // Apply pi-usability filter from provider-check.json + // With extra-usage credit, 7d limits don't block sessions — OAuth token validity does. + // provider-check.json tests actual pi session startup: the ground truth for usability. + const piUsable = getPiUsableProviders(); + let piFilterApplied = false; + if (piUsable) { + const filtered = candidates.filter(p => piUsable.has(p.name)); + if (filtered.length > 0) { + candidates = filtered; + piFilterApplied = true; + } + // If no candidates pass pi check — don't filter (stale data or misconfigured check) + // Fall through to budget-only selection so we don't always emergency-fallback + } + // Phase 1: first provider under threshold with status=allowed or allowed_warning // Both statuses can serve requests; allowed_warning just means approaching limit let best = null; @@ -99,7 +144,8 @@ async function main() { if (util7d < SWITCH_THRESHOLD) { best = { name: p.name, - reason: `7d at ${pct(util7d)}, 5h at ${pct(p.utilization_5h)} — under ${pct(SWITCH_THRESHOLD)} threshold (data: ${source})`, + reason: `7d at ${pct(util7d)}, 5h at ${pct(p.utilization_5h)} — under ${pct(SWITCH_THRESHOLD)} threshold (data: ${source}${piFilterApplied ? ', pi-check' : ''})`, + piFilterApplied, }; break; } @@ -124,7 +170,8 @@ async function main() { const warningTag = lowestCandidate.status === 'allowed_warning' ? ', warning' : ''; best = { name: lowestCandidate.name, - reason: `all over threshold — best available at ${pct(lowestUtil)} 7d${warningTag} (data: ${source})`, + reason: `all over threshold — best available at ${pct(lowestUtil)} 7d${warningTag} (data: ${source}${piFilterApplied ? ', pi-check' : ''})`, + piFilterApplied, }; } }