Intent-Window Routing

June 30, 2026 · View on GitHub

Lynkr scores tier selection from user intent, not from the full request payload. Intent is read as a recency-weighted window over the last N user messages: each message is scored independently, the score is decayed by its age, and the message with the highest weighted score determines the tier.

The bandit explorer that sits on top of tier selection is constrained to the models you've configured in TIER_*. The *[Lynkr] …* routing badge rendered into the response is sanitised on the inbound side so it never re-enters the model's context across turns.


Tier picker

pickTierByIntent runs at the /v1/messages entry for every auth mode (subscription, OAuth, PAYG). Subscription requests where the picked tier resolves to azure-anthropic are forwarded byte-for-byte to api.anthropic.com (anti-abuse stealth path). All other dispatches pin the picked (provider, model) onto the request so the orchestrator honours the intent-based decision.

Scoring algorithm

For each of the last N user messages (age 0 is the latest):

weighted_score(msg) = raw_complexity_score(msg) × decay^age

The message with the highest weighted_score wins. Its provider, model, tier, and raw score are returned as the routing decision.

Worked example

N = 5, decay = 0.7. The latest user message is "yes continue"; an "audit credentials" turn sits four messages back.

AgeUser saidRawDecayWeighted
4"audit auth for credential leaks"800.2419.2
3"go ahead"50.341.7
2"what about session tokens?"250.4912.3
1"thanks"30.702.1
0"yes continue" (current)51.005.0

Winner: the credential-audit message at age 4. The conversation stays on the credentialing-appropriate tier even though the latest message is a short acknowledgement. After roughly ten more "ok continue" turns the audit signal decays to a negligible fraction of its raw score and the conversation naturally returns to SIMPLE.

Comparison with alternatives

ApproachRecency?Stickiness controlCost behaviour
Latest message onlyextremenonemisses ongoing context
Sum / weighted-avg of all messagesnonepermanent stickevery short follow-up inherits full history
Window + decay, max-poolsmoothnatural decaycatches earlier signals without inflation

Configuration

# Window size: how many recent user messages contribute to scoring.
# Set 1 to score only the latest user message.
LYNKR_INTENT_WINDOW_N=5

# Per-turn exponential decay applied during window scoring.
# 0.5 = old turns fade fast; 0.9 = old turns linger.
LYNKR_INTENT_DECAY=0.7

Both are optional; defaults apply when unset.

Implementation

SymbolLocation
pickTierByIntent(body)src/api/router.js:41
Window scoring loopsrc/api/router.js:99-128
_intentTier request fieldset at src/api/router.js:896, read by downstream badge/header logic

Tier-strict bandit

The LinUCB bandit at src/routing/index.js:533-574 selects between the tier's primary model and a kNN-suggested alternative drawn from the historical request index. The kNN candidate is admitted into the bandit's candidate set only if its (provider, model) pair appears in a configured TIER_* entry.

In practice this means:

  • A model credentialed in .env but never listed in any TIER_* line cannot surface as a bandit exploration arm.
  • The bandit can still cross tier boundaries — e.g. for a SIMPLE request, it can pick a model you've listed under TIER_COMPLEX if the UCB score is higher.
  • Tier configuration is the source of truth for what's eligible to be picked, regardless of which other provider credentials happen to be set.

Tier introspection API

const selector = require('./routing/model-tiers').getModelTierSelector();

selector.getModelsForTier('SIMPLE');
// → [{provider: 'ollama', model: 'minimax-m2.5:cloud'}]

selector.getAllConfiguredModels();
// → deduped union across SIMPLE, MEDIUM, COMPLEX, REASONING

getModelsForTier returns an array (one entry today) so the call sites are forward-compatible with a multi-model tier syntax extension.


Visible badge sanitisation

When LYNKR_VISIBLE_ROUTING=true, Lynkr prepends a routing badge to the assistant response:

*[Lynkr] SIMPLE → minimax-m2.5:cloud (ollama) · score 21*

The badge is render-only — your TUI sees it, but it never re-enters the model's context on subsequent turns. The sanitiser runs at two points:

  1. /v1/messages entry — strips any *[Lynkr] …* content from the inbound messages array before history compression or the orchestrator touch it. This is the load-bearing strip.
  2. Top of invokeModel — defense-in-depth in case a future code path bypasses the router entry.

Both string-shape and array-shape assistant.content are handled. The matching regex is anchored at the start of a text block:

/^\*\[Lynkr\][^*\n]*\*\s*/

Implementation: src/clients/databricks.js:2491 (stripLynkrBadges).


Output-budget defaults

The Azure OpenAI Responses-API path caps max_output_tokens at 32768. Long-form responses (multi-file explanations, large refactors) complete without silent mid-stream truncation. Client-supplied body.max_tokens is honoured up to the cap.

To raise the cap further, edit azureOpenAIMaxOutput in src/clients/databricks.js at the top of invokeAzureOpenAI's body construction.


Verifying behaviour

lynkr wrap claude

Inside the wrap session:

/clear
Read /path/to/your/project/CLAUDE.md and summarize in 2-3 bullets.

Expected: the badge renders on each assistant turn, the model fires the file-read tool once, and a coherent summary comes back. Multi-turn follow-ups stay on the same tier the initial scoring picked, modulo the decay window surfacing earlier high-signal turns when relevant.