Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions bin/call-quorum-slot.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -276,10 +276,16 @@ if (!slot && require.main === module) {
}

// ─── Find providers.json ───────────────────────────────────────────────────────
// Path precedence: the slash path (~/.claude/nf/bin/) is where /nf:link-daintree writes
// preset-cloned slots and what mcpServers UNIFIED_PROVIDERS_CONFIG references — it's the
// canonical runtime source. The dash path (~/.claude/nf-bin/) lags behind for Daintree
// fan-out additions. Without preferring slash, the quorum dispatcher silently omits
// Daintree slots (e.g. claude-z-ai, claude-minimax).
function findProviders() {
const searchPaths = [
path.join(__dirname, 'providers.json'), // same dir (nf-bin)
path.join(os.homedir(), '.claude', 'nf-bin', 'providers.json'), // installed fallback
path.join(os.homedir(), '.claude', 'nf', 'bin', 'providers.json'), // canonical (slash)
path.join(__dirname, 'providers.json'), // same dir (nf-bin)
path.join(os.homedir(), '.claude', 'nf-bin', 'providers.json'), // installed fallback
];

// Also derive path from unified-1 MCP server config in ~/.claude.json
Expand All @@ -295,7 +301,9 @@ function findProviders() {
for (const p of searchPaths) {
try {
if (fs.existsSync(p)) {
return JSON.parse(fs.readFileSync(p, 'utf8')).providers;
const providers = JSON.parse(fs.readFileSync(p, 'utf8')).providers;
// Skip empty files (the shipped repo source is empty by design); fall through to next path
if (Array.isArray(providers) && providers.length > 0) return providers;
}
} catch (_) { /* try next */ }
}
Expand Down
49 changes: 19 additions & 30 deletions bin/quorum-preflight.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,15 @@ function readConfig() {
}

// ─── Find providers.json (mirrors call-quorum-slot.cjs / probe-quorum-slots.cjs) ──
// Path precedence: the slash path (~/.claude/nf/bin/) is where /nf:link-daintree writes
// preset-cloned slots and what mcpServers UNIFIED_PROVIDERS_CONFIG references — it's the
// canonical runtime source. The dash path (~/.claude/nf-bin/) is where install.js's
// mergeProvidersJson writes; it lags behind for Daintree fan-out additions. Reading the
// dash path first hides Daintree slots from quorum dispatch (5/7 slots visible instead
// of 7/7). Prefer slash when present.
function findProviders() {
const searchPaths = [
path.join(os.homedir(), '.claude', 'nf', 'bin', 'providers.json'),
path.join(__dirname, 'providers.json'),
path.join(os.homedir(), '.claude', 'nf-bin', 'providers.json'),
];
Expand All @@ -65,7 +72,11 @@ function findProviders() {
} catch (_) {}
for (const p of searchPaths) {
try {
if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')).providers;
if (fs.existsSync(p)) {
const providers = JSON.parse(fs.readFileSync(p, 'utf8')).providers;
// Skip empty files (the shipped repo source is empty by design); fall through to next path
if (Array.isArray(providers) && providers.length > 0) return providers;
}
} catch (_) {}
}
return [];
Expand Down Expand Up @@ -272,50 +283,30 @@ function probeInferenceHistory(ttlMinutes = 30) {

// ─── Two-layer parallel health probe ────────────────────────────────────────
async function probeHealth(providers) {
// Load ~/.claude.json for MCP server env (ccr slots need ANTHROPIC_BASE_URL)
let mcpServers = {};
try {
const raw = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
mcpServers = raw.mcpServers ?? {};
} catch (e) {
process.stderr.write(`[probe] Warning: could not read ~/.claude.json: ${e.message}\n`);
// All ccr slots will get layer2 skipped
}

const cache = loadCache();
const health = {};

// Group ccr slots by normalized base URL for dedup
const urlToSlots = new Map(); // normalizedUrl -> { baseUrl, apiKey, slots[] }

await Promise.all(providers.map(async (p) => {
const isCcr = p.display_type === 'claude-code-router';
const isHttp = p.type === 'http';

// Layer 1: binary probe (CLI slots only — HTTP slots have no binary)
const layer1Promise = isHttp
? Promise.resolve({ ok: true, skipped: true, reason: 'HTTP slot — no CLI binary' })
: probeBinary(p.cli, p.health_check_args || []);

// Layer 2: upstream API probe (ccr slots AND http slots)
// Layer 2: upstream API probe (HTTP slots only)
let layer2Promise;
let baseUrl, apiKey;
if (isHttp) {
// HTTP slots have baseUrl and apiKeyEnv directly in providers.json
baseUrl = p.baseUrl;
apiKey = p.apiKeyEnv ? process.env[p.apiKeyEnv] : undefined;
} else if (isCcr) {
// CCR slots: extract from mcpServers env in ~/.claude.json
const mcpEntry = mcpServers[p.name];
const env = mcpEntry?.env ?? {};
baseUrl = env.ANTHROPIC_BASE_URL;
apiKey = env.ANTHROPIC_API_KEY;
}

if (!isCcr && !isHttp) {
if (!isHttp) {
layer2Promise = Promise.resolve({ ok: true, skipped: true, reason: 'no upstream API' });
} else if (!baseUrl) {
layer2Promise = Promise.resolve({ ok: true, skipped: true, reason: isHttp ? 'baseUrl not configured' : 'ANTHROPIC_BASE_URL not configured' });
layer2Promise = Promise.resolve({ ok: true, skipped: true, reason: 'baseUrl not configured' });
} else {
const normalizedUrl = normalizeBaseUrl(baseUrl);
// Check cache first
Expand Down Expand Up @@ -514,7 +505,7 @@ async function main() {
const typeMap = new Map(activeProviders.map(p => [p.name, p.type]));
const originalOrder = new Map(output.available_slots.map((s, i) => [s, i]));

// Sort available_slots: CLI/CCR primary (type !== 'http') before HTTP backup (type === 'http')
// Sort available_slots: CLI primary (type !== 'http') before HTTP backup (type === 'http')
output.available_slots.sort((a, b) => {
const aIsBackup = typeMap.get(a) === 'http' ? 1 : 0;
const bIsBackup = typeMap.get(b) === 'http' ? 1 : 0;
Expand All @@ -523,10 +514,8 @@ async function main() {
});

// ─── Model dedup guard (DEDUP-01) ────────────────────────────────────
// When the same model appears in both CCR and API tiers (e.g., a user-added
// ccr-* slot and an api-* slot both → DeepSeek-V3.1), keep the first occurrence
// (higher-tier slot due to sort order: CLI/CCR before HTTP) and move the
// duplicate to backup.
// When the same model appears in both CLI and HTTP tiers, keep the first
// occurrence (CLI tier by sort order) and move duplicates to backup.
const modelMap = new Map(activeProviders.map(p => [p.name, p.model]));
const seenModels = new Set();
const deduped = [];
Expand All @@ -552,7 +541,7 @@ async function main() {

// Emit stderr log when backup slots exist
if (output.backup_slots.length > 0) {
process.stderr.write(`[preflight] Tiered ordering: ${output.primary_slots.length} primary (CLI/CCR) + ${output.backup_slots.length} backup (HTTP API)\n`);
process.stderr.write(`[preflight] Tiered ordering: ${output.primary_slots.length} primary (CLI) + ${output.backup_slots.length} backup (HTTP API)\n`);
}
}

Expand Down
Loading