85 lines
3.1 KiB
JavaScript
85 lines
3.1 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* llm-judge-config — the Layer 4 enabling-gate for router-gate v4.
|
||
*
|
||
* The LLM-judge engine (llm-judge.mjs) is fully built but MUST stay OFF until
|
||
* the owner deliberately turns it on, because enabling it incurs real LLM cost
|
||
* (~$300–1500/month per the v4.1 amendment). This module is the single switch.
|
||
*
|
||
* SAFE-BY-DEFAULT CONTRACT:
|
||
* enabled === true ⇔ the explicit flag ROUTER_LLM_JUDGE_ENABLED is truthy
|
||
* AND a key is resolvable (keychain first, then env).
|
||
* Anything else → enabled:false. Building this file does NOT enable the judge:
|
||
* with no flag and no key the gate is closed. keychainGet errors degrade to
|
||
* "no key, disabled" (never throw).
|
||
*
|
||
* Activation (a separate, owner-driven step — NOT done here):
|
||
* 1. store the API key in the OS keychain (or set ROUTER_LLM_KEY),
|
||
* 2. set ROUTER_LLM_JUDGE_ENABLED=1,
|
||
* 3. register the enforce-llm-judge-* hooks in .claude/settings.json.
|
||
* Cost starts only after all three.
|
||
*/
|
||
import { JUDGE_MODELS } from './llm-judge.mjs';
|
||
|
||
const ENABLE_FLAG = 'ROUTER_LLM_JUDGE_ENABLED';
|
||
const KEY_ENV = 'ROUTER_LLM_KEY';
|
||
const BASE_URL_ENV = 'ROUTER_LLM_BASE_URL';
|
||
const KEYCHAIN_SERVICE = 'router-gate-llm-judge';
|
||
const KEYCHAIN_ACCOUNT = 'default';
|
||
|
||
function isTruthyFlag(v) {
|
||
if (typeof v !== 'string') return false;
|
||
return v.trim().toLowerCase() === '1' || v.trim().toLowerCase() === 'true';
|
||
}
|
||
|
||
/**
|
||
* Resolve the Layer 4 judge configuration.
|
||
*
|
||
* @param {object} [args]
|
||
* @param {object} [args.env] - environment map (defaults to process.env)
|
||
* @param {Function} [args.keychainGet] - () => string|null, OS-keychain reader (injectable for tests)
|
||
* @returns {{enabled:boolean, apiKey:string|null, baseUrl:string|null, models:string[]}}
|
||
*/
|
||
export function resolveJudgeConfig({ env = process.env, keychainGet = defaultKeychainGet } = {}) {
|
||
let keychainKey = null;
|
||
try {
|
||
const v = keychainGet();
|
||
keychainKey = v ? String(v) : null;
|
||
} catch {
|
||
keychainKey = null;
|
||
}
|
||
const envKey = env[KEY_ENV] ? String(env[KEY_ENV]) : null;
|
||
const apiKey = keychainKey || envKey || null;
|
||
|
||
const flagOn = isTruthyFlag(env[ENABLE_FLAG]);
|
||
const enabled = flagOn && apiKey !== null;
|
||
|
||
return {
|
||
enabled,
|
||
apiKey,
|
||
baseUrl: env[BASE_URL_ENV] ? String(env[BASE_URL_ENV]) : null,
|
||
models: JUDGE_MODELS.multi,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Default OS-keychain reader. Lazily loads `keytar`; returns null if keytar is
|
||
* absent or the entry is missing. Never throws (caller also guards).
|
||
*/
|
||
export function defaultKeychainGet() {
|
||
try {
|
||
// Lazy require keeps the native dep optional — tests inject keychainGet and
|
||
// never hit this path; the no-op posture means missing keytar => no key.
|
||
const require = createRequire(import.meta.url);
|
||
const keytar = require('keytar');
|
||
const v = keytar.getPassword ? keytar.getPasswordSync?.(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT) : null;
|
||
return v || null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
import { createRequire } from 'node:module';
|
||
|
||
export const _internals = { ENABLE_FLAG, KEY_ENV, BASE_URL_ENV, KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, isTruthyFlag };
|