Skip to content
Open
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
59 changes: 59 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -1095,3 +1095,62 @@ CODE_GRAPH_COMMAND=graphify
# CODE_GRAPH_WORKSPACE=/path/to/repo
# DESCRIPTION: Per-call timeout (ms).
CODE_GRAPH_TIMEOUT=10000

# ---------------------------------------------------------------------------
# Routing hygiene for agent harnesses / structured-output clients
# ---------------------------------------------------------------------------
# Disable keyword-driven force escalation (shouldForceCloud/shouldForceReasoning).
# FORCE_TIER_PATTERNS=false
# Disable security-keyword risk escalation (tasks ABOUT security are not risky).
# RISK_TIER_ESCALATION=false
# Skip the kNN router and its embedding call entirely.
# LYNKR_KNN_ENABLED=false
# Disable the markdown format guard (contradicts clients that demand raw JSON).
# FMT_GUARD_ENABLED=false
# Forward the client's output_format / response_format upstream (default true).
# LYNKR_FORWARD_RESPONSE_FORMAT=true
# Structured-output requests bypass the response cache (default true).
# LYNKR_CACHE_BYPASS_STRUCTURED=true
# Max characters sent to the embedding model (default 5000; nomic-embed-text has a 2048-token context).
# LYNKR_EMBEDDINGS_MAX_CHARS=5000

# ---------------------------------------------------------------------------
# OpenRouter: provider pin, reasoning, schema, timeouts, failover
# ---------------------------------------------------------------------------
# Pin provider(s) globally, or per model (';' between models, ',' between providers).
# OPENROUTER_PROVIDER_ORDER=DeepSeek
# OPENROUTER_PROVIDER_ORDER_MAP="deepseek-v4.1-flash=DeepSeek;glm-5.3-flash=Friendli,Parasail"
# OPENROUTER_ALLOW_FALLBACKS=false
# Reasoning effort (global default, or per model).
# OPENROUTER_REASONING_EFFORT=medium
# OPENROUTER_REASONING_EFFORT_MAP=deepseek-v4.1-flash=medium,glm-5.3-flash=low
# Hosts that honour strict json_schema; others receive json_object.
# OPENROUTER_SCHEMA_PROVIDERS=Friendli,Parasail,Together,Xiaomi
# OPENROUTER_RESPONSE_FORMAT_SCHEMA=false
# Upstream timeout (ms), global and per model; on timeout/5xx/404 Lynkr fails over
# to the next pinned provider, then lets tier-fallback climb.
# OPENROUTER_TIMEOUT_MS=150000
# OPENROUTER_TIMEOUT_MS_MAP=glm-5.3-flash=90000
# Per-model output cap (bounds runaway reasoning on cheap models).
# OPENROUTER_MAX_TOKENS_MAP=glm-5.3-flash=16384

# ---------------------------------------------------------------------------
# Fireworks / OpenAI-compatible: reasoning effort and schema forwarding
# ---------------------------------------------------------------------------
# FIREWORKS_REASONING_EFFORT=medium
# FIREWORKS_REASONING_EFFORT_MAP=glm-5p3-flash=low,deepseek-v4p1-flash=medium
# FIREWORKS_THINKING_MODELS=glm-5p3|glm-5\.3|deepseek-v4p1-flash # regex: never send thinking:disabled
# FIREWORKS_RESPONSE_FORMAT_SCHEMA=false # Fireworks rejects json_schema with $ref; json_object by default
# OPENAI_REASONING_EFFORT=medium
# OPENAI_RESPONSE_FORMAT_SCHEMA=true

# ---------------------------------------------------------------------------
# Declarative routing (signals → decisions) — config/routing.json
# ---------------------------------------------------------------------------
# Path to the routing config (default: config/routing.json; see config/routing.example.json).
# The default file has a single `legacy` decision, so routing is unchanged until you add decisions.
# LYNKR_ROUTING_CONFIG=config/routing.json
# Emit X-Lynkr-Decision / X-Lynkr-Decision-Tier / X-Lynkr-Signals response headers.
# LYNKR_DECISION_HEADERS=false
# Inspect any request: lynkr route --preview request.json [--config routing.json]
# Per-session ledger: lynkr audit <session_id> | lynkr audit --last 10
2 changes: 2 additions & 0 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const SUBCOMMANDS = {
restart: path.join(__dirname, "lynkr-restart.js"),
run: path.join(__dirname, "run.js"),
connect: path.join(__dirname, "lynkr-connect-orcarouter.js"),
route: path.join(__dirname, "lynkr-route.js"),
audit: path.join(__dirname, "lynkr-audit.js"),
};

const sub = process.argv[2];
Expand Down
58 changes: 58 additions & 0 deletions bin/lynkr-audit.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* lynkr audit <session_id|--last N> Per-session routing ledger from telemetry.
*
* Prints, per turn: time, tier, model, decision, effort, latency, status,
* cost, judge verdict, previous-turn outcome. Totals at the bottom.
* Reads <cwd>/.lynkr/telemetry.db (same path the server writes).
*/
'use strict';

const path = require('path');
const fs = require('fs');

function main() {
const argv = process.argv.slice(2);
const json = argv.includes('--json');
const lastIdx = argv.indexOf('--last');
const lastVal = lastIdx >= 0 ? argv[lastIdx + 1] : null;
const sessionId = argv.find((a) => !a.startsWith('--') && a !== lastVal);
const dbPath = path.resolve(process.cwd(), '.lynkr', 'telemetry.db');
if (!fs.existsSync(dbPath)) { console.error(`no telemetry db at ${dbPath}`); process.exit(2); }
Comment on lines +20 to +21

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
The audit script reads from a SQLite database file located in the current working directory (.lynkr/telemetry.db) without verifying file permissions or ownership. If the directory has overly permissive permissions, unauthorized users could read sensitive telemetry data including cost, latency, model decisions, and error information.

Suggestion:

Suggested change
const dbPath = path.resolve(process.cwd(), '.lynkr', 'telemetry.db');
if (!fs.existsSync(dbPath)) { console.error(`no telemetry db at ${dbPath}`); process.exit(2); }
// Verify database file has appropriate restrictive permissions (e.g., owner-only access)
const dbPath = path.resolve(process.cwd(), '.lynkr', 'telemetry.db');
const dbStats = fs.statSync(dbPath);
if (dbStats.mode & 0o444 || dbStats.mode & 0o222) {
console.error('database file has insecure permissions');
process.exit(2);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
Both CLI scripts call process.exit() without returning from the main() function. While this works, it's inconsistent and could cause issues if the functions are called from tests or other modules.

Suggestion:

Suggested change
if (!fs.existsSync(dbPath)) { console.error(`no telemetry db at ${dbPath}`); process.exit(2); }
if (!fs.existsSync(dbPath)) {
console.error(`no telemetry db at ${dbPath}`);
return 2;
}

const Database = require('better-sqlite3');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
The 'better-sqlite3' package is required at runtime but not verified before use. If this package is not installed in the current environment, the command will crash with a module loading error. Consider adding a try/catch around the require or adding it to package.json dependencies explicitly.

const db = new Database(dbPath, { readonly: true });
Comment on lines +21 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · low
The audit command checks file existence with fs.existsSync() before opening the database, which is good. However, if the database file exists but is empty or has no schema, the Database constructor may still fail. Add more robust error handling around the Database instantiation.


if (!sessionId) {
const n = lastIdx >= 0 ? Number(argv[lastIdx + 1]) || 10 : 10;
const rows = db.prepare(`SELECT session_id, COUNT(*) turns, MIN(timestamp) t0, MAX(timestamp) t1, SUM(cost_usd) cost,
SUM(status_code != 200) errors, GROUP_CONCAT(DISTINCT tier) tiers
FROM routing_telemetry WHERE session_id IS NOT NULL GROUP BY session_id ORDER BY t1 DESC LIMIT ?`).all(n);
if (json) { console.log(JSON.stringify(rows, null, 2)); return; }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
JSON.stringify is used for terminal output without any post-processing to sanitize sensitive data that could leak into the output object.

Suggestion:

Suggested change
if (json) { console.log(JSON.stringify(rows, null, 2)); return; }
// Sanitize or exclude sensitive fields from output
const sanitizedRows = rows.map(r => {
const { ...rest } = r;
// Remove or redact sensitive fields if needed
return rest;
});
console.log(JSON.stringify(sanitizedRows, null, 2));

console.log(`\nlast ${rows.length} sessions`);
for (const r of rows) console.log(` ${r.session_id} turns=${String(r.turns).padStart(3)} tiers=${r.tiers} cost=$${(r.cost || 0).toFixed(4)} errors=${r.errors} ${new Date(r.t1).toISOString()}`);
console.log(`\nlynkr audit <session_id> for the per-turn ledger\n`);
return;
}
const rows = db.prepare(`SELECT id, timestamp, tier, model, provider, routing_method, decision_name, engine_tier, engine_mode, effort,
latency_ms, status_code, error_type, cost_usd, input_tokens, output_tokens, cache_read_tokens,
jev_verdict, jev_confidence, prev_turn_outcome, prev_turn_attributable, escalation_source
FROM routing_telemetry WHERE session_id = ? ORDER BY id`).all(sessionId);
if (!rows.length) { console.error(`no rows for session ${sessionId}`); process.exit(1); }
if (json) { console.log(JSON.stringify(rows, null, 2)); return; }
const pad = (s, n) => String(s ?? '-').padEnd(n);
console.log(`\nsession ${sessionId} (${rows.length} turns)\n`);
console.log(` ${pad('#', 3)} ${pad('time', 8)} ${pad('tier', 9)} ${pad('model', 30)} ${pad('decision', 22)} ${pad('eff', 6)} ${pad('ms', 7)} ${pad('st', 4)} ${pad('$', 9)} ${pad('judge', 14)} ${pad('prev-outcome', 16)}`);
let cost = 0, inTok = 0, outTok = 0, cached = 0, errs = 0;
rows.forEach((r, i) => {
cost += r.cost_usd || 0; inTok += r.input_tokens || 0; outTok += r.output_tokens || 0; cached += r.cache_read_tokens || 0; if (r.status_code !== 200) errs++;
const t = new Date(r.timestamp).toISOString().slice(11, 19);
const dec = r.decision_name ? `${r.decision_name}${r.engine_tier && r.engine_tier !== r.tier ? '→' + r.engine_tier : ''}` : '-';
const judge = r.jev_verdict ? `${r.jev_verdict}@${Number(r.jev_confidence || 0).toFixed(2)}` : '-';
const po = r.prev_turn_outcome ? `${r.prev_turn_outcome}${r.prev_turn_attributable ? '' : '(env)'}` : '-';
console.log(` ${pad(i + 1, 3)} ${pad(t, 8)} ${pad(r.tier, 9)} ${pad(String(r.model || '').split('/').pop().slice(0, 30), 30)} ${pad(dec.slice(0, 22), 22)} ${pad(r.effort, 6)} ${pad(r.latency_ms, 7)} ${pad(r.status_code, 4)} ${pad((r.cost_usd || 0).toFixed(5), 9)} ${pad(judge, 14)} ${pad(po, 16)}${r.escalation_source ? ' esc:' + r.escalation_source : ''}`);
});
console.log(`\n cost $${cost.toFixed(4)} in ${inTok} (cached ${cached}, ${inTok ? Math.round(100 * cached / (inTok + cached)) : 0}%) out ${outTok} errors ${errs}\n`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
The audit script computes cached percentage using integer arithmetic that could produce confusing results for edge cases (e.g., when cached > input tokens).

Suggestion:

Suggested change
console.log(`\n cost $${cost.toFixed(4)} in ${inTok} (cached ${cached}, ${inTok ? Math.round(100 * cached / (inTok + cached)) : 0}%) out ${outTok} errors ${errs}\n`);
// Add comment explaining the calculation or use clearer logic
console.log(`\n cost $${cost.toFixed(4)} in ${inTok} (cached ${cached}, cached share: ${inTok + cached ? Math.round(100 * cached / (inTok + cached)) : 0}%) out ${outTok} errors ${errs}\n`);

}

if (require.main === module || process.env._LYNKR_SUBCMD === 'audit') main();
module.exports = { main };
76 changes: 76 additions & 0 deletions bin/lynkr-route.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* lynkr route --preview <request.json|-> Explain how a request would route.
*
* Runs the full routing decision (signals, legacy chain, decision engine)
* without calling any provider or writing telemetry. Prints every signal's
* value, the legacy tier, the matched decision and its tier/effort, and
* whether observe/enforce mode would change the served tier.
*
* Options: --json machine-readable output
* --config <path> use an alternative config/routing.json
*/
'use strict';

const fs = require('fs');
const path = require('path');

function main() {
const argv = process.argv.slice(2);
const json = argv.includes('--json');
const ci = argv.indexOf('--config');
if (ci >= 0) process.env.LYNKR_ROUTING_CONFIG = path.resolve(argv[ci + 1]);
const pi = argv.indexOf('--preview');
const file = pi >= 0 ? argv[pi + 1] : argv.find((a) => !a.startsWith('--'));
if (!file) {
console.error('usage: lynkr route --preview <request.json|-> [--json] [--config routing.json]');
process.exit(2);
}
const raw = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
The route script reads request payloads from files or stdin without validating file size limits. A user could provide an extremely large JSON file (e.g., >100MB) that would exhaust available memory when loaded into memory with fs.readFileSync.

Suggestion:

Suggested change
const raw = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8');
// Set a reasonable file size limit (e.g., 10MB) to prevent memory exhaustion
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const stats = fs.statSync(file);
if (stats.size > MAX_FILE_SIZE) {
console.error('input file exceeds maximum size limit (10MB)');
process.exit(2);
}
const raw = fs.readFileSync(file, 'utf8');

let payload;
try { payload = JSON.parse(raw); } catch (e) { console.error(`not JSON: ${e.message}`); process.exit(2); }
if (typeof payload === 'string' || (payload && !payload.messages)) {
Comment on lines +30 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
The route script catches JSON.parse errors but doesn't validate the parsed payload structure beyond checking for 'messages' property. Invalid or incomplete request objects could cause downstream routing errors with unclear error messages.

Suggestion:

Suggested change
let payload;
try { payload = JSON.parse(raw); } catch (e) { console.error(`not JSON: ${e.message}`); process.exit(2); }
if (typeof payload === 'string' || (payload && !payload.messages)) {
// Add minimal schema validation for expected request structure
if (typeof payload !== 'object' || payload === null) {
console.error('payload must be a valid JSON object');
process.exit(2);
}
if (!payload.messages || !Array.isArray(payload.messages) || payload.messages.length === 0) {
console.error('payload must contain a non-empty messages array');
process.exit(2);
}

// Bare text → a single user message.
const text = typeof payload === 'string' ? payload : raw;
payload = { messages: [{ role: 'user', content: text }], tools: [] };
}

// Load the operator .env like the server does; silence logs.
const envPath = fs.existsSync(path.join(process.cwd(), '.env')) ? path.join(process.cwd(), '.env') : path.join(require('os').homedir(), '.env');
require('dotenv').config({ path: envPath });
process.env.LOG_LEVEL = 'silent'; process.env.LOG_FILE_ENABLED = 'false';
Comment on lines +40 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
Both CLI scripts assume a .env file exists in the current directory or home directory for configuration. If dotenv fails to load or environment variables are missing, configuration failures may occur silently without clear error messages.

Suggestion:

Suggested change
require('dotenv').config({ path: envPath });
process.env.LOG_LEVEL = 'silent'; process.env.LOG_FILE_ENABLED = 'false';
// Add logging or warning if environment variables are missing
require('dotenv').config({ path: envPath });
if (!process.env.LYNKR_API_KEY) {
console.warn('LYNKR_API_KEY not set; some features may not work');
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

other · low
Environment variables LOG_LEVEL='silent' and LOG_FILE_ENABLED='false' are set to suppress all logging. While this reduces noise in the preview output, it may also suppress important error diagnostics from the routing module if unexpected issues occur during evaluation.


const { determineProviderSmart } = require('../src/routing');
const rc = require('../src/routing/routing-config');

(async () => {
const d = await determineProviderSmart(payload, {});
const e = d.engine || null;
const out = {
config: { path: rc.configPath(), mode: rc.mode() },
legacy: { tier: d.tier, provider: d.provider, model: d.model, method: d.method, score: d.score ?? null, anchorScore: d.analysis?.anchorScore ?? null, escalations: d.escalations || [] },
judge: d.analysis?.jev ? { tier: d.analysis.jev.tier, confidence: d.analysis.jev.confidence, probabilities: d.analysis.jev.probabilities } : null,
shortfall: d.shortfall ? { selected: d.shortfall.selected, lift: d.shortfall.lift, req: d.shortfall.req } : null,
engine: e ? { decision: e.decision, tier: e.tier, effort: e.effort, hosts: e.hosts, mode: e.mode, agreesWithLegacy: e.agreesWithLegacy, considered: e.trace?.considered, signals: e.signals } : null,
served: e && e.mode === 'enforce' && e.tier ? e.tier : d.tier,
};
if (json) { console.log(JSON.stringify(out, null, 2)); return; }
const pad = (s, n) => String(s ?? '').padEnd(n);
console.log(`\nconfig ${out.config.path} (mode: ${out.config.mode})`);
console.log(`\nSIGNALS`);
for (const [k, v] of Object.entries(out.engine?.signals || {})) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
The route script uses Object.entries() to iterate over engine signals without checking if the object is empty or undefined. This could cause confusing output when no signals are detected.

Suggestion:

Suggested change
for (const [k, v] of Object.entries(out.engine?.signals || {})) {
// Add header and summary when signals are empty
const signals = out.engine?.signals || {};
if (Object.keys(signals).length === 0) {
console.log(' (no signals detected)');
} else {
for (const [k, v] of Object.entries(signals)) {
// ... existing code
}
}

const val = v.band || v.tier || (v.value && typeof v.value === 'object' ? JSON.stringify(v.value) : v.value);
console.log(` ${pad(k, 12)} ${v.matched ? 'matched ' : '- '} ${pad(val, 22)} ${v.confidence != null ? 'conf ' + Number(v.confidence).toFixed(2) : ''}`);
}
console.log(`\nLEGACY tier=${out.legacy.tier} model=${out.legacy.model} method=${out.legacy.method} anchor=${out.legacy.anchorScore}` + (out.judge ? ` judge=${out.judge.tier}@${out.judge.confidence}` : ''));
if (out.legacy.escalations.length) console.log(` escalations: ${out.legacy.escalations.map((x) => `${x.source}:${x.fromTier}→${x.toTier}`).join(', ')}`);
if (out.shortfall) console.log(`SHORTFALL wants ${out.shortfall.selected?.tier}:${out.shortfall.selected?.model} lift=${(out.shortfall.lift || []).join('+') || '-'}`);
console.log(`\nDECISIONS`);
for (const c of (out.engine?.considered || [])) console.log(` ${c.matched ? '✔' : '·'} ${pad(c.name, 32)} priority ${c.priority}`);
console.log(`\nENGINE decision=${out.engine?.decision} tier=${out.engine?.tier} effort=${out.engine?.effort ?? '-'} hosts=${out.engine?.hosts ? out.engine.hosts.join(',') : '-'}`);
console.log(`SERVED ${out.served}${out.engine && !out.engine.agreesWithLegacy ? (out.engine.mode === 'enforce' ? ' (engine overrode legacy)' : ' (observe: engine would pick ' + out.engine.tier + ')') : ''}\n`);
})().catch((err) => { console.error(err); process.exit(1); });
}

if (require.main === module || process.env._LYNKR_SUBCMD === 'route') main();
module.exports = { main };
Loading
Loading