UNPKG

lynkr

Version:

Self-hosted LLM gateway and tier-routing proxy for Claude Code, Cursor, and Codex. Routes across Ollama, AWS Bedrock, OpenRouter, Databricks, Azure OpenAI, llama.cpp, and LM Studio with prompt caching, MCP tools, and 60-80% cost savings.

184 lines (175 loc) 6.03 kB
/** * Routing Interaction Block * * Builds an "interaction" block that explains, in plain text, what * Lynkr decided to do with a request — which tier, which provider, * why it routed there, and what (if anything) the user should do next. * * Lynkr already surfaces this information via X-Lynkr-* response * headers, but headers are invisible to most users in Claude Code / * Cursor / Codex. The interaction block lives in the response body * so it shows up alongside the model's reply when the visible-routing * env flag is on (LYNKR_VISIBLE_ROUTING=true). * * @module routing/interaction */ /** * Rough estimate of cost savings vs always-COMPLEX baseline. Not * invoice-grade, just a reproducible number for users to glance at. * * @param {string|null} tier * @param {string|null} provider * @returns {number} 0-100 */ function estimateSavingsPercent(tier, provider) { if (!tier) return 0; const t = tier.toUpperCase(); // Local providers carry the same savings band as their tier. const isLocal = provider && ['ollama', 'llamacpp', 'lmstudio'].includes(provider); if (t === 'SIMPLE') return isLocal ? 100 : 70; if (t === 'MEDIUM') return isLocal ? 90 : 45; if (t === 'COMPLEX') return 10; if (t === 'REASONING') return 0; return 0; } /** * Choose a mode label that describes what happened. * * @param {object} decision * @returns {string} */ function modeFor(decision) { if (decision.method === 'risk') return 'risk_forced_tier'; if (decision.method === 'agentic') return 'agentic_workflow'; if (decision.method === 'force' && decision.reason === 'force_local_pattern') return 'force_local'; if (decision.method === 'force' && decision.reason === 'force_cloud_pattern') return 'force_cloud'; if (decision.method === 'static') return 'static'; return 'tier_routed'; } /** * Produce a one-line, terminal-friendly route label, e.g. * "[Lynkr] tier=COMPLEX provider=databricks risk=high score=78" * * @param {object} decision * @returns {string} */ function routeLabel(decision) { const parts = ['[Lynkr]']; if (decision.tier) parts.push(`tier=${decision.tier}`); if (decision.provider) parts.push(`provider=${decision.provider}`); if (decision.model) parts.push(`model=${decision.model}`); if (decision.risk?.level) parts.push(`risk=${decision.risk.level}`); if (typeof decision.score === 'number') parts.push(`score=${decision.score}`); return parts.join(' '); } /** * Headline + next_step are model-facing prose. We keep them terse so * they don't pollute the user's view when the model echoes them back. * * @param {object} decision * @returns {{ headline: string, next_step: string }} */ function copyFor(decision) { const mode = modeFor(decision); if (mode === 'risk_forced_tier') { return { headline: `Lynkr routed to ${decision.tier} tier because the request touches a protected domain.`, next_step: 'Review the response carefully — sensitive logic was involved.', }; } if (mode === 'agentic_workflow') { return { headline: `Lynkr detected an agentic workflow and routed to ${decision.provider || decision.tier}.`, next_step: 'No action needed — autonomous workflows always use cloud providers.', }; } if (mode === 'force_local') { return { headline: 'Lynkr routed to the local tier (greeting or trivial request).', next_step: 'No action needed.', }; } if (mode === 'force_cloud') { return { headline: `Lynkr forced cloud routing (${decision.provider || 'cloud'}) for this request.`, next_step: 'No action needed.', }; } if (mode === 'static') { return { headline: `Lynkr used the static provider ${decision.provider}.`, next_step: 'Tier routing is disabled — set TIER_* env vars to enable.', }; } return { headline: `Lynkr routed to the ${decision.tier || 'default'} tier (${decision.provider || 'unknown'}).`, next_step: 'No action needed.', }; } /** * Build the full interaction block. * * @param {object} decision - The routing decision (from determineProviderSmart * or the pre-route in api/router.js). Must at least have `provider`; ideally * includes `tier`, `model`, `method`, `reason`, `score`, and `risk`. * @returns {object} */ function buildInteractionBlock(decision) { if (!decision || typeof decision !== 'object') return null; const { headline, next_step } = copyFor(decision); return { tool: 'lynkr.route', mode: modeFor(decision), headline, route_label: routeLabel(decision), reason: decision.reason || 'unspecified', tier: decision.tier || null, provider: decision.provider || null, model: decision.model || null, risk: decision.risk?.level || 'low', risk_hits: Array.from(new Set([ ...(decision.risk?.instructionHits || []), ...(decision.risk?.pathHits || []), ])), complexity_score: typeof decision.score === 'number' ? decision.score : null, estimated_savings_percent: estimateSavingsPercent(decision.tier, decision.provider), next_step, }; } /** * Attach an interaction block to an Anthropic-format response body. * Mutates and returns the body. * * Anthropic clients ignore unknown top-level fields, so this is safe. * * @param {object} body * @param {object} interaction * @returns {object} */ function attachToAnthropicResponse(body, interaction) { if (!body || !interaction) return body; body.lynkr_interaction = interaction; return body; } /** * Attach an interaction block to an OpenAI chat-completions response. * Mutates and returns the body. * * @param {object} body * @param {object} interaction * @returns {object} */ function attachToOpenAIResponse(body, interaction) { if (!body || !interaction) return body; body.lynkr_interaction = interaction; return body; } module.exports = { buildInteractionBlock, attachToAnthropicResponse, attachToOpenAIResponse, // Exposed for tests estimateSavingsPercent, modeFor, routeLabel, };