Skip to content

feat: Fix loop guard and avoid other harnesses from using oauth - #104

Merged
veerareddyvishal144 merged 10 commits into
mainfrom
fix/loop-breaker-and-compression
Sep 5, 2026
Merged

veerareddyvishal144 merged 10 commits into
mainfrom
fix/loop-breaker-and-compression

Conversation

@veerareddyvishal144

@veerareddyvishal144 veerareddyvishal144 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Added OpenCode integration with automatic Lynkr provider setup, tier-specific models, context windows, dry-run support, and configuration preservation.
    • Added lynkr run for launching supported tools with refreshed configuration.
    • Added model and context-window response headers for supported requests.
    • Added optional session and tool-turn limits with protocol-level 429 responses.
  • Improvements

    • Token budgets now adapt to model context windows, with optional maximum caps and adaptive history compression.
    • Tool-loop routing can move to a context-capable model when needed.
    • Read operations are compared by overlapping file ranges.
  • Documentation

    • Expanded OpenCode and context-window integration guidance.

vishal veerareddy and others added 10 commits September 4, 2026 17:36
…flow

Root-caused from a live opencode session (884k-token uv-repo fix) where
Lynkr's own interventions repeatedly killed legitimate work:

1. Loop-guard similarity is now read-window-aware. Paging through
   DISJOINT regions of one large file is how agents work on it — the
   old same-path rule counted offsets 975/3485/3800 of a 4,000-line
   settings.rs as one "repeated call", hit the warn threshold, and the
   injected STOP order ended the work turn four times in one session.
   Overlapping/identical windows still count (the prior incident that
   rule fixed stays fixed); whole-file re-reads still count.

2. Thresholds env-tunable (LYNKR_DEDUP_WARN_THRESHOLD /
   LYNKR_DEDUP_TERMINATE_THRESHOLD), defaults 5/8 → 8/12 now that
   similarity is precise. The injected warning is rewritten to be
   honest (similar-not-identical, states the terminate threshold) and
   no longer instructs the model to hide the intervention. Warnings and
   terminations are counted in metrics (loop_guard_*_total).

3. History compression targets the model's token budget instead of a
   fixed 10 recent messages — the incident session kept ~56k tokens
   while 108.8k were available. Budget math extracted to a shared
   computeModelTokenBudget() so the compressor and the enforcer agree.
   Split is tool-boundary-aligned (never orphans a tool_result).
   Severe compression (>50%) now warns once per session and is exposed
   via metrics (history_compression_*).

4. Pinned tool-loop sessions can now escape context overflow: every
   frame of a tool loop is an unconditional pin serve, so the incident
   session could never re-route off its 128k model. On overflow the pin
   serves the current frame (tool-call id linkage) and drops, letting
   the next boundary re-route to a context-capable model.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Design decision: the proxy meters and reports — it never judges
behavior into the conversation. The dedup guard's two enforcement
mechanisms are removed:

- the injected "[Lynkr loop guard]" message at the warn threshold
- the forced synthetic response at the terminate threshold

Rationale: the proxy has the weakest evidence of any layer (arg
previews + similarity heuristics), giving it an irreducible
false-positive floor, and a false positive here invisibly sabotages
legitimate work (live incident: four killed work turns in one opencode
session). Loop BREAKING belongs to the harness and the model. Lynkr's
runaway-spend protection is the honest protocol layer: TPM limits,
budgets, and the loop-guard middleware's turn caps — visible 429/402
refusals, never ghostwritten messages.

Kept: the similarity counter itself (read-window-aware, from the prior
commit), its once-per-question log line, and a metrics signal
(recordLoopGuard('observed')) — observability survives, enforcement
does not. A source-level tripwire test makes reintroducing enforcement
a conscious, reviewed decision. .env.example now documents the
protocol-level caps (LYNKR_MAX_SESSION_TURNS / LYNKR_MAX_TOOL_TURNS)
as the supported loop protection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clients configure ONE static context number for their Lynkr model entry,
but tier routing serves models with different real windows per request
(32k local vs 128k azure vs 200k+). Whatever number the client picked is
wrong most of the time: too large and its compaction never fires in time
(the 884k-token opencode session that Lynkr then mechanically squeezed
93.7%), too small and big-window models are wasted.

Every response whose served model the registry knows now carries
X-Lynkr-Context-Window with that model's real window — the authoritative
per-turn compaction budget (workweave's router↔client pattern). Omitted,
never guessed, for unknown models: compacting against a fabricated
number is worse than falling back to client config.

Emitted from the shared getRoutingHeaders() helper plus both
passthrough paths, and — new — the OpenAI-compat ingress, which carried
NO decision headers at all (the same single-ingress gap as issue #100),
guarded with res.headersSent for streaming. contextWindowFor() added to
the model registry. documentation/context-window-header.md documents
the contract and the interim opencode guidance (min-window limit.context
until harnesses read the header).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TOKEN_BUDGET_MAX (default 180k) silently converted every big-window
model into a 180k one — a 1M-context model's compressor budget clamped
to 180k regardless. Its safety role is fully covered by the per-model
×0.85 math, and its cost-control role belongs to the honest protocol
layers (budgets, TPM) — so the global clamp is now OPT-IN: applied only
when the env var is explicitly set. Unset, a 128k model gets a 108k
budget and a 1M model gets 850k.

Budget resolution is now pin-aware, most-specific-first:
  1. session pin's model — ground truth for what is actually serving.
     Requested names lie in both virtual cases: "lynkr-auto" literally
     exists in the registry as a 128k entry (the incident's mystery
     number), and tier-slot names (Claude Desktop's "claude-sonnet-5" =
     COMPLEX tier) resolve to the slot model's window, not the tier's
     configured model.
  2. requested model for real names (lynkr-* virtuals excluded — the
     registry's longest-prefix matching extends the lynkr-auto entry to
     any lynkr-* string)
  3. conservative floor: minimum window across configured TIER_* models
  4. 180k absolute fallback

.env.example: TOKEN_BUDGET_MAX now commented out with the cost warning
(input tokens re-bill every turn; huge contexts multiply per-turn cost),
and the stale "refuse requests" enforcement description corrected to
what the code does (adaptive compression).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
opencode compacts off ONE static limit.context per model entry; it
cannot track a router serving different models per request. Instead of
a dynamic plugin, dissolve the problem statically: `lynkr opencode`
writes a provider block with one entry per configured tier, each
carrying its tier model's REAL registry window (read live from TIER_*
config), plus lynkr-auto floored at the minimum across tiers. Picking a
model in opencode's picker doubles as a tier pin, so the window each
entry advertises is the window that actually serves it — compaction is
correct per model with zero client-side machinery.

- model-slots: VIRTUAL_TIER_IDS (lynkr-simple/medium/complex/reasoning
  → tier pins, lynkr-auto → no pin), resolved case-insensitively by
  resolveTierForModelId, which router.js already consults on the
  Anthropic ingress — the ids work from any client. Deliberately kept
  OUT of MODEL_SLOTS: Claude Desktop validates its picker against real
  Claude names and would break (regression-tested).
- bin/opencode-setup.js: non-destructive merge into opencode.json
  (--path/--base-url/--dry-run), conservative floors for unresolvable
  windows with a MODEL_PRICE_OVERRIDES hint, registered as the
  `opencode` subcommand.
- documentation/opencode.md: setup + the why.

Verified against the live tier config: SIMPLE 131k, MEDIUM 1,048k,
COMPLEX/REASONING 922k — each entry honest, auto floored at 131k.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renames the opencode setup to a `run` namespace and makes it earn the
name: `lynkr run opencode` refreshes the provider block (per-tier
context windows recomputed from live TIER_* config on every launch) and
then execs opencode, passing through tool args after `--`. --dry-run
previews without writing or launching; a missing opencode binary exits
127 with the config already written, since plain `opencode` works once
configured — the command is automation for correct numbers, not a
requirement.

opencode-setup's main() becomes exported runSetup(args) so run.js can
configure-then-launch; the standalone subcommand entry is replaced by
`run` in the CLI dispatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The transformer captured prompt_tokens from OpenAI-shaped upstream
usage (Azure Responses delivers it only at end-of-stream) and then
dropped it — the final message_delta emitted output_tokens alone, and
message_start had necessarily gone out with input_tokens: 0 before the
count was known. Net effect at the client: Anthropic-SDK consumers
(opencode's context meter, its cost math) saw only the last response's
output tokens — a 60k-token prompt rendered as "Context: 64 tokens,
0% used", and every turn was mispriced.

The final message_delta now carries cumulative usage — input_tokens and
cache_read_input_tokens (from prompt_tokens_details.cached_tokens) when
the upstream reported them, never fabricated when it didn't. Matches
Anthropic's own newer streams, which carry these fields in
message_delta; SDK clients merge them over message_start. The
no-finish-reason diagnostic log now includes inputTokens too (its
absence is what made this bug invisible in log forensics).

Note the number clients now see is the COMPRESSED prompt (what the
model really received) — smaller than the raw transcript, and the more
honest figure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The log claimed "using minimal system prompt to avoid Azure content
filter" — behavior that was removed back in PR #91, which keeps the
system prompt IDENTICAL on every turn and strips only system-reminder
blocks. The stale message caused a real misdiagnosis this week (an
agent's early wrap-ups were attributed to a prompt swap that doesn't
happen). Logs must describe what the code does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
BREAKING CHANGE: the api.anthropic.com passthrough now serves only
first-party Anthropic clients (claude-cli/, claude-code/,
claude-vscode/, anthropic-cli/). The third-party harness prefixes
(codex-cli/, cursor/, github-copilot/, antigravity/) are removed from
subscription classification — that list was an explicit welcome mat
handing the anti-fingerprint passthrough to exactly the harness
category Anthropic's ToS enforcement targets. Those clients still work
through Lynkr: they classify by token shape (oauth/payg) and route via
the orchestrator with the operator's configured tiers.

- Enforced at the dispatch fork too (isFirstPartyAnthropicClient), not
  just classification, so the two can't silently drift.
- Passthrough is now byte-for-byte BY CONTRACT: the opt-in
  LYNKR_OAUTH_MEMORY_INJECTION body mutation is removed from this path
  (memory injection remains on orchestrator paths); only Lynkr-internal
  underscore fields are stripped (protocol necessity).
- Naming cleanup: X-Lynkr-Routing-Method "oauth-subscription-stealth"
  → "oauth-subscription-passthrough"; "anti-abuse stealth" comments and
  docs scrubbed.
- Pitch rewrite: README/wrap-guide no longer advertise "3-5x more usage
  from the same subscription limits" (limit-arbitrage framing) —
  reframed as subscription offload, with the first-party-only scope
  stated explicitly.
- Tests: UA classification matrix, first-party gate, and source
  tripwires pinning the removals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…list

The lockdown shipped with Desktop unverified and would have broken its
subscription passthrough: Desktop's Electron UA is
"Mozilla/5.0 (...) Claude/1.46388.1 Chrome/... Electron/..." — matching
none of the four prefixes, so its sk-ant-oat token classified as plain
'oauth' and skipped the passthrough entirely.

Added 'claude/' (with slash), from a live-captured gateway request: no
third-party harness UA carries that token, and the existing first-party
ids can't collide with it ('claude-cli/' contains 'claude-' before the
slash). Desktop's real UA is now pinned in the first-party test matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 11170156-d109-4b92-bd31-0fae37c28813

📥 Commits

Reviewing files that changed from the base of the PR and between b2fd256 and 5ac01c2.

📒 Files selected for processing (33)
  • .env.example
  • README.md
  • bin/cli.js
  • bin/opencode-setup.js
  • bin/run.js
  • docs/intent-window-routing.md
  • docs/wrap-guide.md
  • documentation/README.md
  • documentation/context-window-header.md
  • documentation/opencode.md
  • package.json
  • src/api/openai-router.js
  • src/api/router.js
  • src/auth-mode.js
  • src/clients/databricks.js
  • src/clients/gpt-utils.js
  • src/context/compression.js
  • src/observability/metrics.js
  • src/orchestrator/index.js
  • src/orchestrator/passthrough-stream.js
  • src/orchestrator/sse-transformer.js
  • src/routing/index.js
  • src/routing/model-registry.js
  • src/routing/model-slots.js
  • test/auth-mode-first-party.test.js
  • test/compression-budget.test.js
  • test/context-window-header.test.js
  • test/dedup-observe-only.test.js
  • test/gpt-utils.test.js
  • test/opencode-setup.test.js
  • test/sse-transformer.test.js
  • test/sticky-routing.test.js
  • test/token-budget-auto.test.js

📝 Walkthrough

Walkthrough

The PR adds OpenCode integration, model-aware token budgeting, adaptive compression, first-party Anthropic passthrough gating, served-model headers, usage reporting, and observe-only loop deduplication.

Changes

Lynkr routing and integration updates

Layer / File(s) Summary
OpenCode setup and launch
bin/cli.js, bin/opencode-setup.js, bin/run.js, src/routing/model-slots.js, documentation/opencode.md, test/opencode-setup.test.js, package.json
The CLI creates or updates the Lynkr OpenCode provider, exposes virtual tier models, refreshes configuration, and launches OpenCode with passthrough arguments.
First-party passthrough routing
src/auth-mode.js, src/api/router.js, docs/intent-window-routing.md, docs/wrap-guide.md, test/auth-mode-first-party.test.js, .env.example
Subscription passthrough is restricted to first-party Anthropic clients. Passthrough bodies no longer receive memory injection.
Model-aware budgets and compression
src/orchestrator/index.js, src/context/compression.js, src/routing/model-registry.js, src/routing/index.js, src/observability/metrics.js, test/compression-budget.test.js, test/token-budget-auto.test.js, test/sticky-routing.test.js
Budgets use model and tier context windows. Compression adapts to the effective budget. Pinned sessions reroute after context overflow, and metrics record compression and loop-guard events.
Served-model headers and usage
src/api/openai-router.js, src/api/router.js, src/orchestrator/passthrough-stream.js, src/routing/index.js, src/orchestrator/sse-transformer.js, documentation/context-window-header.md, test/context-window-header.test.js, test/sse-transformer.test.js
Responses report the served model and known context window. Anthropic SSE output now includes upstream input and cached input usage.
Read-window and deduplication observation
src/clients/gpt-utils.js, src/orchestrator/index.js, test/gpt-utils.test.js, test/dedup-observe-only.test.js
Read-tool similarity requires overlapping windows. Cross-request deduplication logs and records metrics without changing the conversation or forcing termination.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Router
  participant Orchestrator
  participant ModelRegistry
  Client->>Router: send model request
  Router->>Orchestrator: route request
  Orchestrator->>ModelRegistry: resolve context window
  ModelRegistry-->>Orchestrator: return registered window
  Orchestrator-->>Router: return served model and budget
  Router-->>Client: response with model metadata
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/loop-breaker-and-compression

Warning

Some tools did not complete. Review the errors below.

🔧 Biome (2.5.8)
bin/cli.js

File contains syntax errors that prevent linting: Line 31: Illegal return statement outside of a function

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

package.json

Parsing error: Unexpected token :


Comment @coderabbitai help to get the list of available commands.

@veerareddyvishal144
veerareddyvishal144 merged commit ca59cea into main Sep 5, 2026
3 of 4 checks passed
@veerareddyvishal144
veerareddyvishal144 deleted the fix/loop-breaker-and-compression branch September 5, 2026 22:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant