recommend.js: use provider-check.json as primary availability signal
With extra-usage credit (post April 4 2026), 7d per-seat limits no longer block sessions — broken OAuth tokens do. provider-check.json (written hourly by health-pulse) tests actual pi session startup. Changes: - Load /tmp/provider-check.json (if fresh, < 2h old) before selection - Filter candidates to pi-usable providers only - If filter would empty the pool, fall through to budget-only logic - Reason string includes 'pi-check' when filter was applied - Handles stale file, missing file, parse errors gracefully This fixes the monitoring gap where budget API probes and pi session usability diverge (e.g. team-buio: budget OK, pi ETIMEDOUT at 12:01) Refs: trentuna/token-monitor#4
This commit is contained in:
parent
cdda65f42e
commit
a71474e38d
1 changed files with 52 additions and 5 deletions
57
recommend.js
57
recommend.js
|
|
@ -5,12 +5,18 @@
|
||||||
* Reads from the cached token-monitor run (if fresh) or probes directly.
|
* Reads from the cached token-monitor run (if fresh) or probes directly.
|
||||||
* Returns the best Teams provider considering 7d budget utilization.
|
* Returns the best Teams provider considering 7d budget utilization.
|
||||||
*
|
*
|
||||||
* Selection rules:
|
* Selection rules (extra-usage credit era, post April 4 2026):
|
||||||
* 1. status must be "allowed" or "allowed_warning" (both can serve requests)
|
* 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
|
* 2. Scan chain in order; take first with utilization_7d < SWITCH_THRESHOLD
|
||||||
* 3. If none under threshold, take lowest-utilization allowed/allowed_warning provider
|
* 3. If none under threshold, take lowest-utilization allowed/allowed_warning provider
|
||||||
* 4. If no usable Teams providers, return emergency=true (shelley-proxy)
|
* 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:
|
* Usage:
|
||||||
* node recommend.js # JSON output
|
* node recommend.js # JSON output
|
||||||
* node recommend.js --threshold 0.80 # switch above 80% 7d
|
* node recommend.js --threshold 0.80 # switch above 80% 7d
|
||||||
|
|
@ -23,6 +29,7 @@
|
||||||
import { getProviders } from './providers/index.js';
|
import { getProviders } from './providers/index.js';
|
||||||
import { probeTeamsProvider } from './providers/anthropic-teams.js';
|
import { probeTeamsProvider } from './providers/anthropic-teams.js';
|
||||||
import { getCachedRun } from './logger.js';
|
import { getCachedRun } from './logger.js';
|
||||||
|
import { readFileSync, existsSync, statSync } from 'fs';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
|
@ -39,6 +46,29 @@ const PROVIDER_CHAIN = chainIdx !== -1
|
||||||
|
|
||||||
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
||||||
const EMERGENCY_FALLBACK = 'shelley-proxy';
|
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
|
// Format utilization as percentage string
|
||||||
function pct(v) {
|
function pct(v) {
|
||||||
|
|
@ -86,10 +116,25 @@ async function main() {
|
||||||
const { source, providers } = await getProviderData();
|
const { source, providers } = await getProviderData();
|
||||||
|
|
||||||
// Filter to chain members that are Teams providers with data
|
// 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')
|
.filter(name => providers[name] && providers[name].type === 'teams-direct')
|
||||||
.map(name => ({ name, ...providers[name] }));
|
.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
|
// Phase 1: first provider under threshold with status=allowed or allowed_warning
|
||||||
// Both statuses can serve requests; allowed_warning just means approaching limit
|
// Both statuses can serve requests; allowed_warning just means approaching limit
|
||||||
let best = null;
|
let best = null;
|
||||||
|
|
@ -99,7 +144,8 @@ async function main() {
|
||||||
if (util7d < SWITCH_THRESHOLD) {
|
if (util7d < SWITCH_THRESHOLD) {
|
||||||
best = {
|
best = {
|
||||||
name: p.name,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +170,8 @@ async function main() {
|
||||||
const warningTag = lowestCandidate.status === 'allowed_warning' ? ', warning' : '';
|
const warningTag = lowestCandidate.status === 'allowed_warning' ? ', warning' : '';
|
||||||
best = {
|
best = {
|
||||||
name: lowestCandidate.name,
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue