lynkr 9.7.3 → 9.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -19
- package/bin/cli.js +11 -0
- package/bin/lynkr-init.js +14 -1
- package/bin/lynkr-usage.js +78 -0
- package/bin/wrap.js +60 -35
- package/config/difficulty-anchors.json +22 -0
- package/package.json +24 -3
- package/scripts/audit-log-reader.js +399 -0
- package/scripts/calibrate-thresholds.js +38 -157
- package/scripts/compact-dictionary.js +204 -0
- package/scripts/test-deduplication.js +448 -0
- package/scripts/ws7-anchor-replay.js +108 -0
- package/skills/lynkr/SKILL.md +195 -0
- package/src/api/middleware/loop-guard.js +87 -0
- package/src/api/middleware/request-logging.js +5 -64
- package/src/api/middleware/session.js +0 -0
- package/src/api/openai-router.js +120 -101
- package/src/api/providers-handler.js +27 -2
- package/src/api/router.js +450 -125
- package/src/budget/index.js +2 -19
- package/src/cache/semantic.js +9 -0
- package/src/clients/databricks.js +455 -142
- package/src/clients/gpt-utils.js +11 -105
- package/src/clients/openai-format.js +10 -3
- package/src/clients/openrouter-utils.js +49 -24
- package/src/clients/prompt-cache-injection.js +1 -0
- package/src/clients/provider-capabilities.js +1 -1
- package/src/clients/responses-format.js +34 -3
- package/src/clients/routing.js +15 -0
- package/src/config/index.js +36 -2
- package/src/context/gcf.js +275 -0
- package/src/context/tool-result-compressor.js +51 -9
- package/src/dashboard/api.js +1 -0
- package/src/logger/index.js +14 -1
- package/src/memory/search.js +12 -40
- package/src/memory/tools.js +3 -24
- package/src/orchestrator/bypass.js +4 -2
- package/src/orchestrator/index.js +120 -85
- package/src/routing/affinity-store.js +194 -0
- package/src/routing/agentic-detector.js +36 -6
- package/src/routing/bandit.js +25 -6
- package/src/routing/calibration.js +212 -0
- package/src/routing/client-profiles.js +292 -0
- package/src/routing/complexity-analyzer.js +48 -11
- package/src/routing/deescalator.js +148 -0
- package/src/routing/degradation.js +109 -0
- package/src/routing/feedback.js +157 -0
- package/src/routing/index.js +897 -87
- package/src/routing/intent-score.js +339 -0
- package/src/routing/interaction.js +3 -0
- package/src/routing/knn-router.js +70 -21
- package/src/routing/model-registry.js +28 -7
- package/src/routing/model-tiers.js +25 -2
- package/src/routing/reward-pipeline.js +68 -2
- package/src/routing/risk-analyzer.js +30 -1
- package/src/routing/risk-classifier.js +6 -2
- package/src/routing/session-affinity.js +162 -34
- package/src/routing/telemetry.js +286 -13
- package/src/routing/verifier.js +267 -0
- package/src/server.js +66 -21
- package/src/sessions/cleanup.js +17 -0
- package/src/tools/index.js +1 -15
- package/src/tools/smart-selection.js +10 -0
- package/src/tools/web-client.js +3 -3
- package/.eslintrc.cjs +0 -12
- package/benchmark-configs/litellm_config.yaml +0 -86
- package/benchmark-configs/lynkr.env +0 -48
- package/benchmark-configs/portkey-config.json +0 -60
- package/benchmark-configs/portkey-docker.sh +0 -23
- package/benchmark-tier-routing.js +0 -449
- package/funding.json +0 -110
- package/src/api/middleware/validation.js +0 -261
- package/src/routing/drift-monitor.js +0 -113
- package/src/workers/helpers.js +0 -185
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* WS7.2 — replay real telemetry request texts through the anchor classifier
|
|
4
|
+
* and measure whether {trivial, substantive, heavyweight} separate cleanly
|
|
5
|
+
* in cosine space. This is the evidence gate for keeping the three-way
|
|
6
|
+
* blend (vs collapsing to a single cheap-vs-capable threshold).
|
|
7
|
+
*
|
|
8
|
+
* Usage: node scripts/ws7-anchor-replay.js [--db .lynkr/telemetry.db] [--limit N]
|
|
9
|
+
* Requires: local Ollama with the configured embeddings model.
|
|
10
|
+
*
|
|
11
|
+
* Reports:
|
|
12
|
+
* - per-class counts + score distribution
|
|
13
|
+
* - top-2 sim margin per class (median/p25) — the separation metric.
|
|
14
|
+
* Margin >= ~0.05 median = classes separate; < 0.02 = collapse to binary.
|
|
15
|
+
* - tier movement vs the recorded (lexical-era) tier.
|
|
16
|
+
*/
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const Database = require('better-sqlite3');
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const dbPath = args.includes('--db')
|
|
22
|
+
? args[args.indexOf('--db') + 1]
|
|
23
|
+
: path.join(__dirname, '../.lynkr/telemetry.db');
|
|
24
|
+
const limit = args.includes('--limit') ? Number(args[args.indexOf('--limit') + 1]) : 100000;
|
|
25
|
+
|
|
26
|
+
const MINING_SQL = `
|
|
27
|
+
SELECT DISTINCT request_text, tier, quality_score FROM routing_telemetry
|
|
28
|
+
WHERE request_text IS NOT NULL AND length(request_text)>3
|
|
29
|
+
AND request_text NOT LIKE '%system-reminder%'
|
|
30
|
+
AND request_text NOT LIKE '%SUGGESTION MODE%'
|
|
31
|
+
AND request_text NOT LIKE '%[Lynkr]%'
|
|
32
|
+
AND request_text NOT LIKE 'quota%'
|
|
33
|
+
AND request_text NOT LIKE '<session>%' AND request_text NOT LIKE '<conversation>%'
|
|
34
|
+
LIMIT ?`;
|
|
35
|
+
|
|
36
|
+
function pct(arr, p) {
|
|
37
|
+
if (!arr.length) return null;
|
|
38
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
39
|
+
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function main() {
|
|
43
|
+
const { scoreIntent, getDefaultCentroids } = require('../src/routing/intent-score');
|
|
44
|
+
const { getModelTierSelector } = require('../src/routing/model-tiers');
|
|
45
|
+
|
|
46
|
+
const centroids = await getDefaultCentroids();
|
|
47
|
+
if (!centroids) {
|
|
48
|
+
console.error('FATAL: could not build anchor centroids — is Ollama running with the embeddings model?');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const db = new Database(dbPath, { readonly: true });
|
|
53
|
+
const rows = db.prepare(MINING_SQL).all(limit);
|
|
54
|
+
console.log(`Mined ${rows.length} distinct request texts from ${dbPath}\n`);
|
|
55
|
+
|
|
56
|
+
const selector = getModelTierSelector();
|
|
57
|
+
const byClass = {};
|
|
58
|
+
const margins = { trivial: [], substantive: [], heavyweight: [] };
|
|
59
|
+
const moves = {};
|
|
60
|
+
let anchorMode = 0;
|
|
61
|
+
let lexicalFallback = 0;
|
|
62
|
+
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const r = await scoreIntent({ messages: [{ role: 'user', content: row.request_text }] });
|
|
65
|
+
if (!r) continue;
|
|
66
|
+
if (r.mode !== 'anchor') { lexicalFallback++; continue; }
|
|
67
|
+
anchorMode++;
|
|
68
|
+
const cls = r.class;
|
|
69
|
+
byClass[cls] = byClass[cls] || { n: 0, scores: [], examples: [] };
|
|
70
|
+
byClass[cls].n++;
|
|
71
|
+
byClass[cls].scores.push(r.score);
|
|
72
|
+
if (byClass[cls].examples.length < 4) {
|
|
73
|
+
byClass[cls].examples.push(`${r.score.toString().padStart(2)} ${row.request_text.slice(0, 90).replace(/\n/g, ' ')}`);
|
|
74
|
+
}
|
|
75
|
+
const sims = Object.entries(r.sims).sort((a, b) => b[1] - a[1]);
|
|
76
|
+
margins[cls].push(sims[0][1] - sims[1][1]);
|
|
77
|
+
|
|
78
|
+
const newTier = selector.getTier(r.score);
|
|
79
|
+
const oldTier = row.tier || '??';
|
|
80
|
+
const key = `${oldTier} -> ${newTier}`;
|
|
81
|
+
moves[key] = (moves[key] || 0) + 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log(`Scored: ${anchorMode} anchor-mode, ${lexicalFallback} lexical-fallback\n`);
|
|
85
|
+
console.log('=== Class distribution + score stats ===');
|
|
86
|
+
for (const [cls, d] of Object.entries(byClass)) {
|
|
87
|
+
console.log(`\n${cls}: n=${d.n} score median=${pct(d.scores, 50)} p10=${pct(d.scores, 10)} p90=${pct(d.scores, 90)}`);
|
|
88
|
+
console.log(` top-2 sim margin: median=${pct(margins[cls], 50)?.toFixed(4)} p25=${pct(margins[cls], 25)?.toFixed(4)} min=${Math.min(...margins[cls]).toFixed(4)}`);
|
|
89
|
+
for (const e of d.examples) console.log(` ${e}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log('\n=== Tier movement (recorded lexical-era tier -> anchor tier) ===');
|
|
93
|
+
for (const [k, v] of Object.entries(moves).sort((a, b) => b[1] - a[1])) {
|
|
94
|
+
console.log(` ${k}: ${v}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const allMargins = Object.values(margins).flat();
|
|
98
|
+
const med = pct(allMargins, 50);
|
|
99
|
+
console.log(`\n=== VERDICT ===`);
|
|
100
|
+
console.log(`Overall top-2 sim margin: median=${med?.toFixed(4)} p25=${pct(allMargins, 25)?.toFixed(4)}`);
|
|
101
|
+
console.log(med >= 0.05
|
|
102
|
+
? 'Classes separate cleanly (median margin >= 0.05) — three-way blend is supported by the data.'
|
|
103
|
+
: med >= 0.02
|
|
104
|
+
? 'Borderline separation — keep three-way but watch misroutes; consider more anchors.'
|
|
105
|
+
: 'Classes do NOT separate — collapse to binary (trivial vs rest).');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lynkr
|
|
3
|
+
display_name: Lynkr AI Routing Proxy
|
|
4
|
+
version: 8.0.1
|
|
5
|
+
description: Universal LLM gateway with intelligent routing, Graphify code intelligence, Distill compression, routing telemetry, Code Mode, and 12+ provider support. 60-80% cost reduction for Claude Code, Cursor, and Codex.
|
|
6
|
+
author: lynkr-ai
|
|
7
|
+
license: MIT
|
|
8
|
+
tags:
|
|
9
|
+
- routing
|
|
10
|
+
- proxy
|
|
11
|
+
- llm
|
|
12
|
+
- multi-provider
|
|
13
|
+
- ollama
|
|
14
|
+
- openai
|
|
15
|
+
- anthropic
|
|
16
|
+
- bedrock
|
|
17
|
+
- cost-optimization
|
|
18
|
+
- graphify
|
|
19
|
+
- distill
|
|
20
|
+
- telemetry
|
|
21
|
+
- code-mode
|
|
22
|
+
- compression
|
|
23
|
+
- memory
|
|
24
|
+
category: infrastructure
|
|
25
|
+
homepage: https://github.com/Fast-Editor/Lynkr
|
|
26
|
+
npm: lynkr
|
|
27
|
+
requires:
|
|
28
|
+
node: ">=20"
|
|
29
|
+
providers:
|
|
30
|
+
- ollama
|
|
31
|
+
- databricks
|
|
32
|
+
- azure-anthropic
|
|
33
|
+
- azure-openai
|
|
34
|
+
- openrouter
|
|
35
|
+
- openai
|
|
36
|
+
- bedrock
|
|
37
|
+
- vertex
|
|
38
|
+
- moonshot
|
|
39
|
+
- zai
|
|
40
|
+
- llamacpp
|
|
41
|
+
- lmstudio
|
|
42
|
+
- codex
|
|
43
|
+
- deepseek
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
# Lynkr - Universal LLM Gateway
|
|
47
|
+
|
|
48
|
+
Lynkr routes AI coding requests to the optimal model based on task complexity, cost, and provider health. Supports 12+ providers with 60-80% cost reduction through intelligent token optimization.
|
|
49
|
+
|
|
50
|
+
## Quick Start
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install -g lynkr
|
|
54
|
+
lynkr-setup # Auto-installs Ollama + pulls a model
|
|
55
|
+
lynkr # Start the proxy
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then point your AI coding tool at `http://localhost:8081/v1`.
|
|
59
|
+
|
|
60
|
+
## How It Works
|
|
61
|
+
|
|
62
|
+
1. **5-Phase Complexity Analysis** - Scores each request 0-100 using token count, tool usage, code patterns, domain keywords, and Graphify structural analysis (god nodes, community cohesion, blast radius)
|
|
63
|
+
2. **4-Tier Routing** - Maps score to SIMPLE/MEDIUM/COMPLEX/REASONING, each with a configured provider:model
|
|
64
|
+
3. **Agentic Detection** - Detects multi-step workflows (tool loops, autonomous agents) and upgrades to higher tiers
|
|
65
|
+
4. **Cost Optimization** - Picks the cheapest provider that can handle the tier
|
|
66
|
+
5. **Circuit Breaker + Failover** - Automatic failover with half-open probe recovery
|
|
67
|
+
|
|
68
|
+
## Key Features (v8.0)
|
|
69
|
+
|
|
70
|
+
### Intelligent Routing
|
|
71
|
+
- 5-phase complexity scoring with 15-dimension weighted mode
|
|
72
|
+
- Agentic workflow detection (SINGLE_SHOT / TOOL_CHAIN / ITERATIVE / AUTONOMOUS)
|
|
73
|
+
- Graphify knowledge graph integration — god node detection, community cohesion, blast radius
|
|
74
|
+
- Routing telemetry with SQLite store, quality scoring (0-100), latency tracking (P50/P95/P99)
|
|
75
|
+
|
|
76
|
+
### Token Optimization (60-80% savings)
|
|
77
|
+
- **Smart tool selection** — filters tools by request type
|
|
78
|
+
- **Distill compression** — structural similarity (Jaccard), delta rendering, block dedup
|
|
79
|
+
- **Code Mode** — replaces 100+ MCP tools with 4 meta-tools (~96% token reduction)
|
|
80
|
+
- **History compression** — sliding window with Distill-powered dedup
|
|
81
|
+
- **Prompt caching** — SHA-256 keyed LRU cache
|
|
82
|
+
- **Headroom sidecar** — optional 47-92% compression via Smart Crusher, CCR, LLMLingua
|
|
83
|
+
|
|
84
|
+
### Production Hardening
|
|
85
|
+
- Circuit breakers with half-open probe recovery
|
|
86
|
+
- Admin hot-reload endpoint (POST /v1/admin/reload) — no restart needed
|
|
87
|
+
- Per-request performance timing (PERF_TIMER=true)
|
|
88
|
+
- Prometheus metrics, structured logging, health checks
|
|
89
|
+
- Rate limiting, load shedding, input validation
|
|
90
|
+
|
|
91
|
+
### Long-Term Memory (Titans-Inspired)
|
|
92
|
+
- Surprise-based memory storage with decay
|
|
93
|
+
- Semantic search via FTS5
|
|
94
|
+
- Automatic extraction and injection
|
|
95
|
+
|
|
96
|
+
## Configuration for OpenClaw
|
|
97
|
+
|
|
98
|
+
Set tier routing in your environment:
|
|
99
|
+
|
|
100
|
+
```env
|
|
101
|
+
MODEL_PROVIDER=ollama
|
|
102
|
+
TIER_SIMPLE=ollama:llama3.2
|
|
103
|
+
TIER_MEDIUM=openrouter:anthropic/claude-sonnet-4
|
|
104
|
+
TIER_COMPLEX=bedrock:anthropic.claude-sonnet-4-20250514-v1:0
|
|
105
|
+
TIER_REASONING=bedrock:anthropic.claude-opus-4-20250514-v1:0
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### OpenClaw Mode
|
|
109
|
+
|
|
110
|
+
When running under OpenClaw, enable model name rewriting:
|
|
111
|
+
|
|
112
|
+
```env
|
|
113
|
+
OPENCLAW_MODE=true
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
This replaces the generic `model: "auto"` in responses with the actual `provider/model` that handled the request.
|
|
117
|
+
|
|
118
|
+
## Provider Registration
|
|
119
|
+
|
|
120
|
+
Add to your `openclaw.json`:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"models": {
|
|
125
|
+
"providers": [
|
|
126
|
+
{
|
|
127
|
+
"name": "lynkr",
|
|
128
|
+
"type": "openai-compatible",
|
|
129
|
+
"base_url": "http://localhost:8081/v1",
|
|
130
|
+
"api_key": "any-value",
|
|
131
|
+
"models": ["auto"]
|
|
132
|
+
}
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"agents": {
|
|
136
|
+
"defaults": {
|
|
137
|
+
"models": {
|
|
138
|
+
"primary": "lynkr/auto",
|
|
139
|
+
"fallback": "lynkr/auto"
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Providers
|
|
147
|
+
|
|
148
|
+
| Provider | Type | Models |
|
|
149
|
+
|----------|------|--------|
|
|
150
|
+
| Ollama | Local (free) | llama3.2, qwen2.5-coder, deepseek-coder, mistral |
|
|
151
|
+
| llama.cpp | Local (free) | Any GGUF model |
|
|
152
|
+
| LM Studio | Local (free) | Any downloaded model |
|
|
153
|
+
| OpenAI | Cloud | gpt-4o, o3, o4-mini |
|
|
154
|
+
| Anthropic | Cloud | claude-opus-4, claude-sonnet-4, claude-haiku-4.5 |
|
|
155
|
+
| Databricks | Cloud | Claude, GPT, Llama via Foundation Model APIs |
|
|
156
|
+
| AWS Bedrock | Cloud | Claude, Titan, Llama, Mistral |
|
|
157
|
+
| Azure OpenAI | Cloud | GPT-4o, o1, o3 |
|
|
158
|
+
| OpenRouter | Cloud | 100+ models |
|
|
159
|
+
| Google Vertex | Cloud | Gemini 2.5 Pro/Flash |
|
|
160
|
+
| Moonshot AI | Cloud | Kimi K2 Thinking/Turbo |
|
|
161
|
+
| Z.AI | Cloud | GLM-4.7 |
|
|
162
|
+
| DeepSeek | Cloud | DeepSeek Reasoner, R1 |
|
|
163
|
+
|
|
164
|
+
## New in v8.0
|
|
165
|
+
|
|
166
|
+
- **Graphify Integration** — AST-based knowledge graph with 19-language support for blast radius analysis
|
|
167
|
+
- **Distill Compression** — Structural similarity, delta rendering, and smart dedup
|
|
168
|
+
- **Routing Telemetry** — SQLite-backed decision recording with quality scoring
|
|
169
|
+
- **Code Mode** — 4 MCP meta-tools replace 100+ individual definitions
|
|
170
|
+
- **Admin Reload** — Hot-reload config + reset circuit breakers without restart
|
|
171
|
+
- **Performance Timer** — Per-request timing breakdown (PERF_TIMER=true)
|
|
172
|
+
- **Large Payload Passthrough** — Smart cloning skips base64 media that will be discarded
|
|
173
|
+
|
|
174
|
+
## Response Headers
|
|
175
|
+
|
|
176
|
+
| Header | Description |
|
|
177
|
+
|--------|-------------|
|
|
178
|
+
| `X-Lynkr-Provider` | Provider that handled the request |
|
|
179
|
+
| `X-Lynkr-Model` | Model used |
|
|
180
|
+
| `X-Lynkr-Tier` | Complexity tier (SIMPLE/MEDIUM/COMPLEX/REASONING) |
|
|
181
|
+
| `X-Lynkr-Complexity-Score` | Numeric score 0-100 |
|
|
182
|
+
| `X-Lynkr-Routing-Method` | How the route was decided |
|
|
183
|
+
| `X-Lynkr-Agentic` | Agentic workflow type (if detected) |
|
|
184
|
+
| `X-Lynkr-Cost-Optimized` | Whether cost optimization changed the provider |
|
|
185
|
+
|
|
186
|
+
## Telemetry Endpoints
|
|
187
|
+
|
|
188
|
+
| Endpoint | Description |
|
|
189
|
+
|----------|-------------|
|
|
190
|
+
| `GET /v1/routing/stats` | Aggregated routing stats with latency percentiles |
|
|
191
|
+
| `GET /v1/routing/stats/:provider` | Per-provider statistics |
|
|
192
|
+
| `GET /v1/routing/telemetry` | Raw telemetry records |
|
|
193
|
+
| `GET /v1/routing/accuracy` | Over/under-provisioned routing detection |
|
|
194
|
+
| `POST /v1/admin/reload` | Hot-reload config + reset circuit breakers |
|
|
195
|
+
| `POST /v1/admin/circuit-breakers/reset` | Reset circuit breakers |
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop guard — runaway-agent circuit breaker (WS: multi-agent cost controls).
|
|
3
|
+
*
|
|
4
|
+
* Agent frameworks (LangGraph, CrewAI, AutoGen) resend the full growing
|
|
5
|
+
* conversation every round-trip, so an uncapped tool loop compounds: each
|
|
6
|
+
* turn pays for every previous turn again. Real-world failure mode this
|
|
7
|
+
* guards: an AutoGen deployment ran 40% over budget in its first month from
|
|
8
|
+
* exactly this (uncapped reviewer<->coder loops).
|
|
9
|
+
*
|
|
10
|
+
* The guard is STATELESS — it reads loop depth from the incoming payload
|
|
11
|
+
* itself rather than tracking sessions:
|
|
12
|
+
* - message count ≈ total turns so far
|
|
13
|
+
* - tool_result count ≈ tool-loop iterations so far
|
|
14
|
+
* Both survive proxy restarts and need no session affinity.
|
|
15
|
+
*
|
|
16
|
+
* Disabled by default (caps unset/0). Enable per deployment:
|
|
17
|
+
* LYNKR_MAX_SESSION_TURNS=80 reject when messages[] exceeds 80 entries
|
|
18
|
+
* LYNKR_MAX_TOOL_TURNS=25 reject when tool_result blocks exceed 25
|
|
19
|
+
*
|
|
20
|
+
* Rejections use 429 + a machine-readable error type so agent frameworks
|
|
21
|
+
* surface them as a hard stop instead of retrying blindly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const logger = require("../../logger");
|
|
25
|
+
|
|
26
|
+
function _countToolResults(messages) {
|
|
27
|
+
let n = 0;
|
|
28
|
+
for (const m of messages) {
|
|
29
|
+
if (!Array.isArray(m?.content)) continue;
|
|
30
|
+
for (const block of m.content) {
|
|
31
|
+
if (block?.type === "tool_result") n++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return n;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function _cap(name) {
|
|
38
|
+
const v = Number(process.env[name]);
|
|
39
|
+
return Number.isFinite(v) && v > 0 ? Math.floor(v) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loopGuard(req, res, next) {
|
|
43
|
+
const maxTurns = _cap("LYNKR_MAX_SESSION_TURNS");
|
|
44
|
+
const maxToolTurns = _cap("LYNKR_MAX_TOOL_TURNS");
|
|
45
|
+
if (!maxTurns && !maxToolTurns) return next();
|
|
46
|
+
|
|
47
|
+
const messages = Array.isArray(req.body?.messages) ? req.body.messages : [];
|
|
48
|
+
|
|
49
|
+
if (maxTurns && messages.length > maxTurns) {
|
|
50
|
+
logger.warn(
|
|
51
|
+
{ messageCount: messages.length, cap: maxTurns, path: req.path },
|
|
52
|
+
"[LoopGuard] conversation exceeded turn cap"
|
|
53
|
+
);
|
|
54
|
+
return res.status(429).json({
|
|
55
|
+
error: {
|
|
56
|
+
type: "loop_cap_exceeded",
|
|
57
|
+
message:
|
|
58
|
+
`Conversation has ${messages.length} messages, over the configured ` +
|
|
59
|
+
`cap of ${maxTurns} (LYNKR_MAX_SESSION_TURNS). This usually means an ` +
|
|
60
|
+
`agent loop is not converging — inspect the conversation before raising the cap.`,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (maxToolTurns) {
|
|
66
|
+
const toolResults = _countToolResults(messages);
|
|
67
|
+
if (toolResults > maxToolTurns) {
|
|
68
|
+
logger.warn(
|
|
69
|
+
{ toolResults, cap: maxToolTurns, path: req.path },
|
|
70
|
+
"[LoopGuard] conversation exceeded tool-turn cap"
|
|
71
|
+
);
|
|
72
|
+
return res.status(429).json({
|
|
73
|
+
error: {
|
|
74
|
+
type: "loop_cap_exceeded",
|
|
75
|
+
message:
|
|
76
|
+
`Conversation carries ${toolResults} tool results, over the configured ` +
|
|
77
|
+
`cap of ${maxToolTurns} (LYNKR_MAX_TOOL_TURNS). This usually means a ` +
|
|
78
|
+
`tool loop is stuck — inspect the last few tool calls before raising the cap.`,
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return next();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = { loopGuard, _countToolResults };
|
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
const crypto = require("crypto");
|
|
2
|
-
const logger = require("../../logger");
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
|
-
*
|
|
4
|
+
* Request ID middleware.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* - Minimal overhead
|
|
6
|
+
* Assigns req.requestId (honouring an inbound X-Request-ID header) and
|
|
7
|
+
* echoes it on the response. Request/response logging itself is handled
|
|
8
|
+
* by pino-http in middleware/logging.js — don't log here or every
|
|
9
|
+
* request gets logged twice.
|
|
12
10
|
*/
|
|
13
11
|
|
|
14
12
|
function generateRequestId() {
|
|
@@ -16,66 +14,9 @@ function generateRequestId() {
|
|
|
16
14
|
}
|
|
17
15
|
|
|
18
16
|
function requestLoggingMiddleware(req, res, next) {
|
|
19
|
-
const startTime = Date.now();
|
|
20
|
-
|
|
21
|
-
// Generate or use existing request ID
|
|
22
17
|
const requestId = req.headers["x-request-id"] || generateRequestId();
|
|
23
18
|
req.requestId = requestId;
|
|
24
|
-
|
|
25
|
-
// Add to response headers
|
|
26
19
|
res.setHeader("X-Request-ID", requestId);
|
|
27
|
-
|
|
28
|
-
// Log request start
|
|
29
|
-
logger.info(
|
|
30
|
-
{
|
|
31
|
-
requestId,
|
|
32
|
-
method: req.method,
|
|
33
|
-
path: req.path || req.url,
|
|
34
|
-
query: req.query,
|
|
35
|
-
ip: req.ip || req.socket.remoteAddress,
|
|
36
|
-
userAgent: req.headers["user-agent"],
|
|
37
|
-
},
|
|
38
|
-
"Request started"
|
|
39
|
-
);
|
|
40
|
-
|
|
41
|
-
// Capture response finish
|
|
42
|
-
const originalSend = res.send;
|
|
43
|
-
res.send = function (body) {
|
|
44
|
-
const duration = Date.now() - startTime;
|
|
45
|
-
|
|
46
|
-
// Log request completion
|
|
47
|
-
logger.info(
|
|
48
|
-
{
|
|
49
|
-
requestId,
|
|
50
|
-
method: req.method,
|
|
51
|
-
path: req.path || req.url,
|
|
52
|
-
status: res.statusCode,
|
|
53
|
-
duration,
|
|
54
|
-
contentLength: res.getHeader("content-length"),
|
|
55
|
-
},
|
|
56
|
-
"Request completed"
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
return originalSend.call(this, body);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
// Handle errors
|
|
63
|
-
res.on("finish", () => {
|
|
64
|
-
if (res.statusCode >= 400) {
|
|
65
|
-
const duration = Date.now() - startTime;
|
|
66
|
-
logger.warn(
|
|
67
|
-
{
|
|
68
|
-
requestId,
|
|
69
|
-
method: req.method,
|
|
70
|
-
path: req.path || req.url,
|
|
71
|
-
status: res.statusCode,
|
|
72
|
-
duration,
|
|
73
|
-
},
|
|
74
|
-
"Request failed"
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
|
|
79
20
|
next();
|
|
80
21
|
}
|
|
81
22
|
|
|
Binary file
|