feat: Fix loop guard and avoid other harnesses from using oauth - #104
Conversation
…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>
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (33)
📝 WalkthroughWalkthroughThe 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. ChangesLynkr routing and integration updates
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
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning Some tools did not complete. Review the errors below. 🔧 Biome (2.5.8)bin/cli.jsFile contains syntax errors that prevent linting: Line 31: Illegal return statement outside of a function 🔧 ESLint
package.jsonParsing error: Unexpected token : Comment |
Summary by CodeRabbit
New Features
lynkr runfor launching supported tools with refreshed configuration.Improvements
Documentation