lynkr 9.5.0 → 9.7.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 +39 -1
- package/bin/cli.js +2 -0
- package/bin/wrap.js +686 -0
- package/package.json +4 -3
- package/scripts/build-knn-index.js +1 -1
- package/scripts/convert-routellm.py +105 -0
- package/src/agents/decomposition/dispatcher.js +185 -0
- package/src/agents/decomposition/gate.js +136 -0
- package/src/agents/decomposition/index.js +183 -0
- package/src/agents/decomposition/model-call.js +75 -0
- package/src/agents/decomposition/planner.js +223 -0
- package/src/agents/decomposition/synthesizer.js +89 -0
- package/src/agents/decomposition/telemetry.js +55 -0
- package/src/api/router.js +694 -21
- package/src/auth-mode.js +116 -0
- package/src/clients/databricks.js +551 -68
- package/src/clients/openrouter-utils.js +6 -29
- package/src/clients/prompt-cache-injection.js +64 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +23 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/orchestrator/index.js +120 -60
- package/src/routing/index.js +31 -4
- package/src/routing/knn-router.js +9 -2
- package/src/routing/model-tiers.js +34 -0
- package/src/routing/tier-fallback.js +91 -0
- package/src/server.js +2 -0
- package/src/tools/decompose.js +91 -0
- package/src/tools/lazy-loader.js +8 -0
package/src/api/router.js
CHANGED
|
@@ -11,12 +11,550 @@ const { getRoutingHeaders, getRoutingStats, analyzeComplexity, getModelTierSelec
|
|
|
11
11
|
const { buildInteractionBlock } = require("../routing/interaction");
|
|
12
12
|
const { validateCwd } = require("../workspace");
|
|
13
13
|
const { renderText } = require("../utils/markdown-ansi");
|
|
14
|
+
const { classifyAuthMode } = require("../auth-mode");
|
|
14
15
|
|
|
15
16
|
const router = express.Router();
|
|
16
17
|
|
|
17
18
|
// Create rate limiter middleware
|
|
18
19
|
const rateLimiter = createRateLimiter();
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Decide which tier/provider/model handles an OAuth-subscription request.
|
|
23
|
+
*
|
|
24
|
+
* Runs Lynkr's full `determineProviderSmart` pipeline — same one PAYG / API-key
|
|
25
|
+
* traffic uses — but on a user-intent payload (last user message only) so
|
|
26
|
+
* Claude Code's 12-tool / fat-system bloat doesn't inflate the decision.
|
|
27
|
+
*
|
|
28
|
+
* The pipeline includes:
|
|
29
|
+
* - force_local / force_cloud regex shortcuts
|
|
30
|
+
* - risk classifier (high-risk → forced COMPLEX)
|
|
31
|
+
* - complexity scoring (weighted heuristic)
|
|
32
|
+
* - agentic-workflow detector (may bump min-tier)
|
|
33
|
+
* - kNN router (embedding-based nearest-neighbors of historical queries)
|
|
34
|
+
* - LinUCB contextual bandit (intra-tier model selection, learns from reward)
|
|
35
|
+
* - cost-optimizer (cheaper qualifying model when safe)
|
|
36
|
+
* - session affinity (sticks to previous turn's provider for tool chains)
|
|
37
|
+
* - tenant policy
|
|
38
|
+
*
|
|
39
|
+
* Plus telemetry — every decision is recorded so kNN/bandit improve over time.
|
|
40
|
+
*/
|
|
41
|
+
async function pickTierByIntent(body) {
|
|
42
|
+
// Build a user-intent payload. We INCLUDE the tools array (signals agentic
|
|
43
|
+
// intent — a request with 12 tools attached is meaningfully different from
|
|
44
|
+
// a chat-only one, even if both messages look short) but EXCLUDE the system
|
|
45
|
+
// prompt (Claude Code's interactive system is several KB and would always
|
|
46
|
+
// push every request into COMPLEX regardless of what the user typed).
|
|
47
|
+
//
|
|
48
|
+
// Window-scored intent (Phase 5.x):
|
|
49
|
+
// Score the last N user messages independently, apply exponential
|
|
50
|
+
// recency decay (decay^age, age 0 = latest), take the message with the
|
|
51
|
+
// max weighted score as the winner. This catches "this conversation had
|
|
52
|
+
// a complex/risky turn earlier" without inflating short follow-ups like
|
|
53
|
+
// "yes" or "continue" with the whole 30-turn history.
|
|
54
|
+
//
|
|
55
|
+
// Research backing: WSeq attention (Tian et al.) shows last-utterance
|
|
56
|
+
// weighting is empirically the strongest signal in multi-turn dialogues;
|
|
57
|
+
// sliding-window 3-5 turns matches the de-facto multi-turn intent-
|
|
58
|
+
// classification convention. See doc comment on LYNKR_INTENT_WINDOW_N.
|
|
59
|
+
const messages = Array.isArray(body?.messages) ? body.messages : [];
|
|
60
|
+
const allUserMsgs = messages.filter((m) => m?.role === 'user');
|
|
61
|
+
const N = Math.max(1, Number(process.env.LYNKR_INTENT_WINDOW_N) || 5);
|
|
62
|
+
const decay = Number(process.env.LYNKR_INTENT_DECAY);
|
|
63
|
+
const decayFactor = Number.isFinite(decay) && decay > 0 && decay <= 1 ? decay : 0.7;
|
|
64
|
+
const windowUserMsgs = allUserMsgs.slice(-N); // chronological, oldest-first
|
|
65
|
+
|
|
66
|
+
// Cap tools at 3 so we stay below the agentic detector's tool-count
|
|
67
|
+
// signal thresholds: high_tool_count fires at >5, moderate_tool_count at
|
|
68
|
+
// >3, no tool-count signal at <=3. Claude Code interactive mode attaches
|
|
69
|
+
// 11+ tools every request, but our pickTier needs to reflect USER intent,
|
|
70
|
+
// not session context.
|
|
71
|
+
const intentTools = Array.isArray(body?.tools) ? body.tools.slice(0, 3) : undefined;
|
|
72
|
+
|
|
73
|
+
// CLEAN each user message: Claude Code wraps user input in
|
|
74
|
+
// <system-reminder>...</system-reminder> blocks (CLAUDE.md context,
|
|
75
|
+
// tool-search hints, current-date inserts, etc.). Those blocks make
|
|
76
|
+
// "Hi" look like a 500-token complex query to the scorer, and
|
|
77
|
+
// force_local stops matching. Strip them for the intent score.
|
|
78
|
+
const stripReminders = (s) =>
|
|
79
|
+
typeof s === 'string'
|
|
80
|
+
? s.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '').trim()
|
|
81
|
+
: s;
|
|
82
|
+
const cleanMsg = (msg) => {
|
|
83
|
+
if (!msg) return msg;
|
|
84
|
+
if (typeof msg.content === 'string') {
|
|
85
|
+
return { ...msg, content: stripReminders(msg.content) };
|
|
86
|
+
} else if (Array.isArray(msg.content)) {
|
|
87
|
+
const cleanedContent = msg.content
|
|
88
|
+
.map((b) =>
|
|
89
|
+
b?.type === 'text' && typeof b.text === 'string'
|
|
90
|
+
? { ...b, text: stripReminders(b.text) }
|
|
91
|
+
: b
|
|
92
|
+
)
|
|
93
|
+
.filter((b) => !(b?.type === 'text' && (!b.text || b.text.trim() === '')));
|
|
94
|
+
return { ...msg, content: cleanedContent };
|
|
95
|
+
}
|
|
96
|
+
return msg;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
if (windowUserMsgs.length === 0) {
|
|
100
|
+
// No user messages in payload (shouldn't happen) — fall through to the
|
|
101
|
+
// error fallback below to preserve prior behavior.
|
|
102
|
+
return {
|
|
103
|
+
tier: 'COMPLEX',
|
|
104
|
+
provider: 'azure-anthropic',
|
|
105
|
+
model: null,
|
|
106
|
+
score: null,
|
|
107
|
+
method: 'fallback',
|
|
108
|
+
reason: 'no_user_messages',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Per-message scoring intentionally omits _sessionId so session affinity
|
|
113
|
+
// isn't polluted by multiple intent-only routing calls per request. The
|
|
114
|
+
// FINAL provider pick (downstream of this function) uses the full body
|
|
115
|
+
// including _sessionId, so affinity still works end-to-end.
|
|
116
|
+
const { determineProviderSmart } = require("../clients/routing");
|
|
117
|
+
let winner = null;
|
|
118
|
+
let bestWeighted = -Infinity;
|
|
119
|
+
const perMsgScores = [];
|
|
120
|
+
|
|
121
|
+
for (let i = 0; i < windowUserMsgs.length; i++) {
|
|
122
|
+
const age = windowUserMsgs.length - 1 - i; // 0 = latest, length-1 = oldest in window
|
|
123
|
+
const cleaned = cleanMsg(windowUserMsgs[i]);
|
|
124
|
+
const intentPayload = {
|
|
125
|
+
messages: cleaned ? [cleaned] : [],
|
|
126
|
+
tools: intentTools,
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
const decision = await determineProviderSmart(intentPayload, {
|
|
130
|
+
workspace: body?._workspace || null,
|
|
131
|
+
tenantPolicy: body?._tenantPolicy || null,
|
|
132
|
+
});
|
|
133
|
+
const rawScore = decision.score ?? 0;
|
|
134
|
+
const weighted = rawScore * Math.pow(decayFactor, age);
|
|
135
|
+
perMsgScores.push({ age, rawScore, weighted, tier: decision.tier });
|
|
136
|
+
if (weighted > bestWeighted) {
|
|
137
|
+
bestWeighted = weighted;
|
|
138
|
+
winner = { decision, age, rawScore, weighted };
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
logger.debug({ err: err.message, age }, "[OAuthIntent] per-message scoring failed");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!winner) {
|
|
146
|
+
logger.warn("OAuth smart routing failed across whole window, falling back to azure-anthropic");
|
|
147
|
+
return {
|
|
148
|
+
tier: 'COMPLEX',
|
|
149
|
+
provider: 'azure-anthropic',
|
|
150
|
+
model: null,
|
|
151
|
+
score: null,
|
|
152
|
+
method: 'fallback',
|
|
153
|
+
reason: 'window_all_failed',
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const d = winner.decision;
|
|
158
|
+
logger.debug({
|
|
159
|
+
windowSize: windowUserMsgs.length,
|
|
160
|
+
decayFactor,
|
|
161
|
+
winnerAge: winner.age,
|
|
162
|
+
winnerRawScore: winner.rawScore,
|
|
163
|
+
winnerWeighted: Number(winner.weighted.toFixed(2)),
|
|
164
|
+
perMsg: perMsgScores,
|
|
165
|
+
}, "[OAuthIntent] window scoring decision");
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
tier: d.tier || null,
|
|
169
|
+
provider: d.provider,
|
|
170
|
+
model: d.model || null,
|
|
171
|
+
score: winner.rawScore,
|
|
172
|
+
method: (d.method || 'tier_config') + '+window',
|
|
173
|
+
reason: d.reason || null,
|
|
174
|
+
agenticResult: d.agenticResult || null,
|
|
175
|
+
risk: d.risk || null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Transparent passthrough for Claude Code OAuth subscription requests.
|
|
181
|
+
* Forwards the inbound body and headers verbatim to api.anthropic.com so the
|
|
182
|
+
* outgoing request is byte-for-byte what Claude Code would have sent directly,
|
|
183
|
+
* with no orchestrator mutations.
|
|
184
|
+
*
|
|
185
|
+
* Observability is bolted on around the call (start telemetry, response
|
|
186
|
+
* telemetry, memory extraction, audit) so we keep visibility even though we're
|
|
187
|
+
* skipping the orchestrator.
|
|
188
|
+
*/
|
|
189
|
+
async function handleOauthPassthrough(req, res, opts = {}) {
|
|
190
|
+
const upstream = process.env.LYNKR_OAUTH_PASSTHROUGH_URL
|
|
191
|
+
|| "https://api.anthropic.com/v1/messages";
|
|
192
|
+
|
|
193
|
+
// === Optional: memory injection at last-user-message tail ===
|
|
194
|
+
// Headroom's P0-1 pattern: append memory context to the latest user
|
|
195
|
+
// message's first text block. NEVER touches system prompt or frozen-prefix
|
|
196
|
+
// messages, so the cache-hot zone Anthropic fingerprints stays intact.
|
|
197
|
+
// Opt-in via LYNKR_OAUTH_MEMORY_INJECTION=true since any body mutation on
|
|
198
|
+
// a subscription request has nonzero anti-abuse risk.
|
|
199
|
+
let bodyToSend = req.body;
|
|
200
|
+
if (process.env.LYNKR_OAUTH_MEMORY_INJECTION === 'true' && config.memory?.enabled !== false) {
|
|
201
|
+
try {
|
|
202
|
+
bodyToSend = maybeInjectMemoryIntoUserTail(req.body);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
logger.debug({ err: err.message }, "Memory injection skipped (non-fatal)");
|
|
205
|
+
bodyToSend = req.body;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// === Observability: start ===
|
|
210
|
+
const startedAt = Date.now();
|
|
211
|
+
const inputTokenEstimate = estimateTokenCount(bodyToSend?.messages, bodyToSend?.system, bodyToSend?.model);
|
|
212
|
+
metrics.recordRequest();
|
|
213
|
+
|
|
214
|
+
// Hop-by-hop and proxy-managed headers we must not forward.
|
|
215
|
+
const HOP_BY_HOP = new Set([
|
|
216
|
+
"host", "connection", "keep-alive", "transfer-encoding", "upgrade",
|
|
217
|
+
"proxy-authorization", "proxy-authenticate", "te", "trailer",
|
|
218
|
+
"content-length", "accept-encoding",
|
|
219
|
+
"x-lynkr-tenant-id", "x-lynkr-workspace", "x-workspace-cwd",
|
|
220
|
+
"x-session-id", "x-request-id", "x-forwarded-for", "x-forwarded-proto",
|
|
221
|
+
"x-forwarded-host", "x-real-ip",
|
|
222
|
+
]);
|
|
223
|
+
const outHeaders = {};
|
|
224
|
+
for (const [name, value] of Object.entries(req.headers || {})) {
|
|
225
|
+
if (value == null) continue;
|
|
226
|
+
if (HOP_BY_HOP.has(name.toLowerCase())) continue;
|
|
227
|
+
outHeaders[name] = Array.isArray(value) ? value.join(", ") : value;
|
|
228
|
+
}
|
|
229
|
+
// Re-stringify the body — express already parsed it. Identical re-encoding
|
|
230
|
+
// is fine; Anthropic doesn't fingerprint key ordering.
|
|
231
|
+
const bodyText = JSON.stringify(bodyToSend);
|
|
232
|
+
|
|
233
|
+
let upstreamResp;
|
|
234
|
+
try {
|
|
235
|
+
upstreamResp = await fetch(upstream, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
headers: outHeaders,
|
|
238
|
+
body: bodyText,
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
logger.error({ err: err.message, upstream }, "OAuth passthrough fetch failed");
|
|
242
|
+
res.status(502).json({ type: "error", error: { type: "api_error", message: "upstream fetch failed" } });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Mirror status + content-type + body. For streaming SSE responses, pipe
|
|
247
|
+
// the stream straight through.
|
|
248
|
+
res.status(upstreamResp.status);
|
|
249
|
+
const contentType = upstreamResp.headers.get("content-type") || "application/json";
|
|
250
|
+
res.set("Content-Type", contentType);
|
|
251
|
+
// Forward selected useful headers.
|
|
252
|
+
for (const h of ["request-id", "anthropic-ratelimit-requests-limit",
|
|
253
|
+
"anthropic-ratelimit-requests-remaining", "anthropic-ratelimit-requests-reset",
|
|
254
|
+
"anthropic-ratelimit-tokens-limit", "anthropic-ratelimit-tokens-remaining",
|
|
255
|
+
"anthropic-ratelimit-tokens-reset", "retry-after"]) {
|
|
256
|
+
const v = upstreamResp.headers.get(h);
|
|
257
|
+
if (v) res.set(h, v);
|
|
258
|
+
}
|
|
259
|
+
// Lynkr's own decision headers so callers can see which model answered.
|
|
260
|
+
res.set("X-Lynkr-Provider", "azure-anthropic-passthrough");
|
|
261
|
+
if (opts.tier?.tier) res.set("X-Lynkr-Tier", opts.tier.tier);
|
|
262
|
+
if (req.body?.model) res.set("X-Lynkr-Model", req.body.model);
|
|
263
|
+
res.set("X-Lynkr-Routing-Method", "oauth-subscription-stealth");
|
|
264
|
+
|
|
265
|
+
// Capture the response (buffered or streamed) so we can do observability hooks
|
|
266
|
+
// on the way back without changing what the client sees.
|
|
267
|
+
let responseTextForObservability = "";
|
|
268
|
+
|
|
269
|
+
// LYNKR_VISIBLE_ROUTING=true: inject a routing badge into the response on
|
|
270
|
+
// its way back to the client. Mutating the RESPONSE is safe — Anthropic's
|
|
271
|
+
// anti-abuse fingerprints the inbound request, not what the proxy does
|
|
272
|
+
// with the response stream before handing it to the client.
|
|
273
|
+
const wantsBadge = config.routing?.visibleInteraction && upstreamResp.ok;
|
|
274
|
+
const badgeText = wantsBadge
|
|
275
|
+
? `*[Lynkr] subscription-passthrough → ${req.body?.model || '—'} (azure-anthropic)*\n\n`
|
|
276
|
+
: null;
|
|
277
|
+
|
|
278
|
+
if (contentType.includes("text/event-stream") && upstreamResp.body) {
|
|
279
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
280
|
+
|
|
281
|
+
// For SSE: emit the badge as a synthetic content_block_start +
|
|
282
|
+
// content_block_delta + content_block_stop at index 0, BEFORE the
|
|
283
|
+
// upstream stream begins. Anthropic re-indexes subsequent blocks from 1+,
|
|
284
|
+
// which is fine because Claude Code treats index as opaque and just
|
|
285
|
+
// appends to the rendered content array.
|
|
286
|
+
if (badgeText) {
|
|
287
|
+
const synthetic = [
|
|
288
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } })}\n\n`,
|
|
289
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: badgeText } })}\n\n`,
|
|
290
|
+
`event: content_block_stop\ndata: ${JSON.stringify({ type: 'content_block_stop', index: 0 })}\n\n`,
|
|
291
|
+
].join('');
|
|
292
|
+
res.write(synthetic);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const reader = upstreamResp.body.getReader();
|
|
296
|
+
const decoder = new TextDecoder();
|
|
297
|
+
try {
|
|
298
|
+
while (true) {
|
|
299
|
+
const { value, done } = await reader.read();
|
|
300
|
+
if (done) break;
|
|
301
|
+
const buf = Buffer.from(value);
|
|
302
|
+
res.write(buf);
|
|
303
|
+
if (typeof res.flush === "function") res.flush();
|
|
304
|
+
// Capture for observability (only first 64KB to avoid memory issues).
|
|
305
|
+
if (responseTextForObservability.length < 65536) {
|
|
306
|
+
responseTextForObservability += decoder.decode(value, { stream: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} finally {
|
|
310
|
+
try { reader.releaseLock(); } catch {}
|
|
311
|
+
}
|
|
312
|
+
res.end();
|
|
313
|
+
} else {
|
|
314
|
+
const text = await upstreamResp.text();
|
|
315
|
+
if (!upstreamResp.ok) {
|
|
316
|
+
logger.warn({
|
|
317
|
+
status: upstreamResp.status,
|
|
318
|
+
bodyPreview: text.slice(0, 500),
|
|
319
|
+
upstream,
|
|
320
|
+
}, "OAuth passthrough upstream returned non-2xx");
|
|
321
|
+
}
|
|
322
|
+
responseTextForObservability = text;
|
|
323
|
+
|
|
324
|
+
// For buffered JSON: prepend a text content block.
|
|
325
|
+
if (badgeText && contentType.includes('application/json')) {
|
|
326
|
+
try {
|
|
327
|
+
const parsed = JSON.parse(text);
|
|
328
|
+
if (parsed?.type === 'message' && Array.isArray(parsed.content)) {
|
|
329
|
+
parsed.content.unshift({ type: 'text', text: badgeText });
|
|
330
|
+
res.send(JSON.stringify(parsed));
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
} catch (_) { /* fall through to raw send */ }
|
|
334
|
+
}
|
|
335
|
+
res.send(text);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// === Observability: end ===
|
|
339
|
+
// Fire-and-forget: never block returning to the client. Record telemetry,
|
|
340
|
+
// metrics, audit, memory — all read-only on the response.
|
|
341
|
+
setImmediate(() => {
|
|
342
|
+
try {
|
|
343
|
+
const latencyMs = Date.now() - startedAt;
|
|
344
|
+
const tier = opts.tier || {};
|
|
345
|
+
let parsedResponse = null;
|
|
346
|
+
if (contentType.includes("application/json")) {
|
|
347
|
+
try { parsedResponse = JSON.parse(responseTextForObservability); } catch {}
|
|
348
|
+
} else if (contentType.includes("text/event-stream")) {
|
|
349
|
+
// Extract a usable response object from the SSE stream by finding the
|
|
350
|
+
// final message_delta / message_stop events.
|
|
351
|
+
parsedResponse = extractAnthropicMessageFromSSE(responseTextForObservability);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const outputTokens = parsedResponse?.usage?.output_tokens
|
|
355
|
+
?? parsedResponse?.usage?.completion_tokens
|
|
356
|
+
?? null;
|
|
357
|
+
const inputTokensActual = parsedResponse?.usage?.input_tokens
|
|
358
|
+
?? parsedResponse?.usage?.prompt_tokens
|
|
359
|
+
?? inputTokenEstimate;
|
|
360
|
+
|
|
361
|
+
// Lynkr-wide metrics
|
|
362
|
+
try {
|
|
363
|
+
const { getMetricsCollector } = require("../observability/metrics");
|
|
364
|
+
const mc = getMetricsCollector();
|
|
365
|
+
mc.recordProviderSuccess?.("azure-anthropic-passthrough", latencyMs);
|
|
366
|
+
if (outputTokens || inputTokensActual) mc.recordTokens?.(inputTokensActual, outputTokens || 0);
|
|
367
|
+
} catch (_) {}
|
|
368
|
+
|
|
369
|
+
// Tier router telemetry (so it shows up in dashboards / routing stats)
|
|
370
|
+
try {
|
|
371
|
+
const tlm = require("../routing/telemetry");
|
|
372
|
+
tlm.record?.({
|
|
373
|
+
request_id: req.headers["request-id"] || req.headers["x-request-id"] || null,
|
|
374
|
+
session_id: req.body?._sessionId || req.sessionId || null,
|
|
375
|
+
timestamp: startedAt,
|
|
376
|
+
tier: tier.tier || "COMPLEX",
|
|
377
|
+
provider: "azure-anthropic-passthrough",
|
|
378
|
+
model: req.body?.model || tier.model || null,
|
|
379
|
+
routing_method: "oauth-passthrough",
|
|
380
|
+
status_code: upstreamResp.status,
|
|
381
|
+
latency_ms: latencyMs,
|
|
382
|
+
input_tokens: inputTokensActual || null,
|
|
383
|
+
output_tokens: outputTokens || null,
|
|
384
|
+
message_count: req.body?.messages?.length || null,
|
|
385
|
+
tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
|
|
386
|
+
was_fallback: false,
|
|
387
|
+
});
|
|
388
|
+
} catch (_) {}
|
|
389
|
+
|
|
390
|
+
// Audit log
|
|
391
|
+
try {
|
|
392
|
+
const { createAuditLogger } = require("../logger/audit-logger");
|
|
393
|
+
const audit = createAuditLogger(config.audit);
|
|
394
|
+
audit?.log?.({
|
|
395
|
+
provider: "azure-anthropic-passthrough",
|
|
396
|
+
destination: upstream,
|
|
397
|
+
status: upstreamResp.status,
|
|
398
|
+
latencyMs,
|
|
399
|
+
inputTokens: inputTokensActual,
|
|
400
|
+
outputTokens,
|
|
401
|
+
model: req.body?.model,
|
|
402
|
+
});
|
|
403
|
+
} catch (_) {}
|
|
404
|
+
|
|
405
|
+
// Memory extraction (read-only on response, no LLM call — pure regex)
|
|
406
|
+
if (parsedResponse && config.memory?.extraction?.enabled) {
|
|
407
|
+
try {
|
|
408
|
+
const memoryExtractor = require("../memory/extractor");
|
|
409
|
+
memoryExtractor.extractMemories?.(
|
|
410
|
+
parsedResponse,
|
|
411
|
+
req.body?.messages || [],
|
|
412
|
+
{ sessionId: req.body?._sessionId || req.sessionId || null }
|
|
413
|
+
).catch(() => {});
|
|
414
|
+
} catch (_) {}
|
|
415
|
+
}
|
|
416
|
+
} catch (err) {
|
|
417
|
+
logger.debug({ err: err.message }, "OAuth passthrough observability hook failed (non-fatal)");
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Extract the final assembled Anthropic message from a captured SSE stream.
|
|
424
|
+
* Looks at message_start (for id/model), content_block_delta (for text),
|
|
425
|
+
* message_delta (for stop_reason and usage), and message_stop events.
|
|
426
|
+
* Best-effort; returns null on failure.
|
|
427
|
+
*/
|
|
428
|
+
function extractAnthropicMessageFromSSE(sseText) {
|
|
429
|
+
if (!sseText) return null;
|
|
430
|
+
const result = { id: null, type: "message", role: "assistant", content: [], model: null, stop_reason: null, usage: {} };
|
|
431
|
+
const lines = sseText.split("\n");
|
|
432
|
+
let textAcc = "";
|
|
433
|
+
for (const line of lines) {
|
|
434
|
+
if (!line.startsWith("data:")) continue;
|
|
435
|
+
const payload = line.slice(5).trim();
|
|
436
|
+
if (!payload || payload === "[DONE]") continue;
|
|
437
|
+
let evt;
|
|
438
|
+
try { evt = JSON.parse(payload); } catch { continue; }
|
|
439
|
+
if (evt.type === "message_start" && evt.message) {
|
|
440
|
+
result.id = evt.message.id;
|
|
441
|
+
result.model = evt.message.model;
|
|
442
|
+
if (evt.message.usage) Object.assign(result.usage, evt.message.usage);
|
|
443
|
+
} else if (evt.type === "content_block_delta" && evt.delta?.text) {
|
|
444
|
+
textAcc += evt.delta.text;
|
|
445
|
+
} else if (evt.type === "message_delta") {
|
|
446
|
+
if (evt.delta?.stop_reason) result.stop_reason = evt.delta.stop_reason;
|
|
447
|
+
if (evt.usage) Object.assign(result.usage, evt.usage);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (textAcc) result.content.push({ type: "text", text: textAcc });
|
|
451
|
+
return result;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Append relevant memories to the FIRST TEXT BLOCK of the LATEST USER MESSAGE.
|
|
456
|
+
*
|
|
457
|
+
* Headroom's P0-1 pattern (`_append_context_to_latest_non_frozen_user_turn`).
|
|
458
|
+
* The cache hot zone (system + frozen prefix) is NEVER touched. Mutating only
|
|
459
|
+
* the latest user message — which is the request's "live zone" — keeps the
|
|
460
|
+
* prompt-cache identity stable and avoids Anthropic anti-abuse fingerprint
|
|
461
|
+
* divergence for subscription tokens.
|
|
462
|
+
*
|
|
463
|
+
* Returns the body unchanged if:
|
|
464
|
+
* - Memory is disabled
|
|
465
|
+
* - No memories retrieved
|
|
466
|
+
* - Latest message is not a user turn (could be tool_result, assistant)
|
|
467
|
+
* - Latest user message sits inside a cache_control-marked prefix
|
|
468
|
+
*
|
|
469
|
+
* Returns a new body with appended context otherwise. Original body never
|
|
470
|
+
* mutated (returns a shallow-cloned messages array).
|
|
471
|
+
*/
|
|
472
|
+
function maybeInjectMemoryIntoUserTail(body) {
|
|
473
|
+
if (!body || !Array.isArray(body.messages) || body.messages.length === 0) return body;
|
|
474
|
+
|
|
475
|
+
const lastIdx = body.messages.length - 1;
|
|
476
|
+
const lastMsg = body.messages[lastIdx];
|
|
477
|
+
if (!lastMsg || lastMsg.role !== "user") return body;
|
|
478
|
+
|
|
479
|
+
// Frozen-prefix check: if the previous message has cache_control set, the
|
|
480
|
+
// model client (Claude Code) considers messages up to that point cached.
|
|
481
|
+
// We refuse to mutate inside the cached prefix to preserve cache hits.
|
|
482
|
+
// (For Anthropic, cache_control is on a content block, not the message
|
|
483
|
+
// itself, so scan content blocks.)
|
|
484
|
+
const hasCacheControlAtOrBefore = (idx) => {
|
|
485
|
+
for (let i = 0; i <= idx; i++) {
|
|
486
|
+
const m = body.messages[i];
|
|
487
|
+
if (!m || !Array.isArray(m.content)) continue;
|
|
488
|
+
for (const blk of m.content) {
|
|
489
|
+
if (blk && typeof blk === "object" && blk.cache_control) return true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return false;
|
|
493
|
+
};
|
|
494
|
+
// Only mutate if the previous message (lastIdx-1) is NOT cache-marked.
|
|
495
|
+
// That keeps Claude Code's prompt-cache breakpoint stable.
|
|
496
|
+
if (lastIdx >= 1 && hasCacheControlAtOrBefore(lastIdx - 1)) {
|
|
497
|
+
// Common case: it's fine — the user message itself isn't in the prefix.
|
|
498
|
+
// Continue.
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Retrieve relevant memories for this user query.
|
|
502
|
+
const { retrieveRelevantMemories, formatMemoriesForContext, extractQueryFromMessage } =
|
|
503
|
+
require("../memory/retriever");
|
|
504
|
+
const query = extractQueryFromMessage(lastMsg);
|
|
505
|
+
if (!query || query.length < 10) return body; // too short to be a useful query
|
|
506
|
+
|
|
507
|
+
const memories = retrieveRelevantMemories(query, {
|
|
508
|
+
limit: Math.min(parseInt(process.env.MEMORY_RETRIEVAL_LIMIT, 10) || 5, 10),
|
|
509
|
+
sessionId: body._sessionId || null,
|
|
510
|
+
includeGlobal: process.env.MEMORY_INCLUDE_GLOBAL !== "false",
|
|
511
|
+
});
|
|
512
|
+
if (!memories || memories.length === 0) return body;
|
|
513
|
+
|
|
514
|
+
const formatted = formatMemoriesForContext(memories);
|
|
515
|
+
if (!formatted) return body;
|
|
516
|
+
|
|
517
|
+
const contextText = `\n\n## Relevant context from earlier sessions:\n${formatted}`;
|
|
518
|
+
|
|
519
|
+
// Bound the injection size (Headroom uses a MemoryInjectionBudget; we use
|
|
520
|
+
// a simpler char cap — ~1024 tokens * 4 chars/token = 4096 chars).
|
|
521
|
+
const MAX_INJECTION_CHARS = 4096;
|
|
522
|
+
const boundedContext = contextText.length > MAX_INJECTION_CHARS
|
|
523
|
+
? contextText.slice(0, MAX_INJECTION_CHARS) + "\n…"
|
|
524
|
+
: contextText;
|
|
525
|
+
|
|
526
|
+
// Clone messages array (shallow) so we don't mutate the caller's body.
|
|
527
|
+
const newMessages = body.messages.slice();
|
|
528
|
+
|
|
529
|
+
if (typeof lastMsg.content === "string") {
|
|
530
|
+
newMessages[lastIdx] = { ...lastMsg, content: lastMsg.content + boundedContext };
|
|
531
|
+
} else if (Array.isArray(lastMsg.content) && lastMsg.content.length > 0) {
|
|
532
|
+
// Append to the FIRST text block, preserving every other block (images,
|
|
533
|
+
// tool_use, etc.) untouched.
|
|
534
|
+
const newContent = [];
|
|
535
|
+
let appended = false;
|
|
536
|
+
for (const block of lastMsg.content) {
|
|
537
|
+
if (!appended && block && typeof block === "object" && block.type === "text") {
|
|
538
|
+
newContent.push({ ...block, text: (block.text || "") + boundedContext });
|
|
539
|
+
appended = true;
|
|
540
|
+
} else {
|
|
541
|
+
newContent.push(block);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (!appended) return body; // no text block to append to
|
|
545
|
+
newMessages[lastIdx] = { ...lastMsg, content: newContent };
|
|
546
|
+
} else {
|
|
547
|
+
return body;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
logger.debug({
|
|
551
|
+
memoryCount: memories.length,
|
|
552
|
+
appendedChars: boundedContext.length,
|
|
553
|
+
}, "Memory injected into last-user-message tail");
|
|
554
|
+
|
|
555
|
+
return { ...body, messages: newMessages };
|
|
556
|
+
}
|
|
557
|
+
|
|
20
558
|
/**
|
|
21
559
|
* Estimate token count for messages.
|
|
22
560
|
*
|
|
@@ -209,11 +747,112 @@ router.post("/api/event_logging/batch", (req, res) => {
|
|
|
209
747
|
res.status(200).json({ success: true });
|
|
210
748
|
});
|
|
211
749
|
|
|
750
|
+
// In-process counter so users can see when an agent loop is burning requests.
|
|
751
|
+
// Logged on every inbound /v1/messages so a runaway loop is visible at LOG_LEVEL=info.
|
|
752
|
+
let messagesRequestCount = 0;
|
|
753
|
+
const messagesSessionStart = Date.now();
|
|
754
|
+
|
|
212
755
|
router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
213
756
|
try {
|
|
214
757
|
const { createTimer } = require("../utils/perf-timer");
|
|
215
758
|
const timer = createTimer("POST /v1/messages");
|
|
216
759
|
metrics.recordRequest();
|
|
760
|
+
// Also bump the rich observability collector — that's what `lynkr wrap`'s
|
|
761
|
+
// session-stats summary and the /metrics/observability dashboard read.
|
|
762
|
+
// Without this call the wrap UI ends every session with "No requests
|
|
763
|
+
// tracked" regardless of actual traffic.
|
|
764
|
+
try {
|
|
765
|
+
const { getMetricsCollector } = require("../observability/metrics");
|
|
766
|
+
getMetricsCollector().recordRequest("POST", "/v1/messages", null, null);
|
|
767
|
+
} catch (_) {}
|
|
768
|
+
|
|
769
|
+
messagesRequestCount += 1;
|
|
770
|
+
|
|
771
|
+
// Strip prior-turn Lynkr routing badges from inbound history BEFORE any
|
|
772
|
+
// downstream stage (auth classification, tier router, history compression,
|
|
773
|
+
// orchestrator agent loop, invokeModel) sees them. History compression
|
|
774
|
+
// bakes prior message text into a single summary user message, so once
|
|
775
|
+
// compressed the badge is no longer a recognizable prefixed block — it
|
|
776
|
+
// becomes an embedded substring inside a user-role summary, which our
|
|
777
|
+
// assistant-only/anchored strip can't catch. Doing it here is the only
|
|
778
|
+
// chokepoint upstream of all of those.
|
|
779
|
+
if (Array.isArray(req.body?.messages)) {
|
|
780
|
+
const { stripLynkrBadges } = require("../clients/databricks");
|
|
781
|
+
req.body.messages = stripLynkrBadges(req.body.messages);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const lastMsg = Array.isArray(req.body?.messages) ? req.body.messages[req.body.messages.length - 1] : null;
|
|
785
|
+
const lastRole = lastMsg?.role;
|
|
786
|
+
const hasToolResult = Array.isArray(lastMsg?.content)
|
|
787
|
+
&& lastMsg.content.some(b => b?.type === 'tool_result');
|
|
788
|
+
logger.debug({
|
|
789
|
+
reqNumber: messagesRequestCount,
|
|
790
|
+
sessionElapsedMs: Date.now() - messagesSessionStart,
|
|
791
|
+
lastMessageRole: lastRole,
|
|
792
|
+
isToolResultContinuation: hasToolResult,
|
|
793
|
+
messageCount: req.body?.messages?.length,
|
|
794
|
+
hasTools: Array.isArray(req.body?.tools) && req.body.tools.length > 0,
|
|
795
|
+
toolCount: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
|
|
796
|
+
model: req.body?.model,
|
|
797
|
+
}, "Inbound /v1/messages");
|
|
798
|
+
|
|
799
|
+
// Auth-mode classification (Headroom-style, UA-first):
|
|
800
|
+
//
|
|
801
|
+
// - 'subscription': UX-bound CLI/IDE (Claude Code, Cursor, Copilot, …).
|
|
802
|
+
// Anthropic anti-abuse fingerprints these clients. Stealth required:
|
|
803
|
+
// tier-route on user intent, then either passthrough to api.anthropic.com
|
|
804
|
+
// byte-for-byte, or route to a non-Anthropic provider (where mutation
|
|
805
|
+
// is safe).
|
|
806
|
+
//
|
|
807
|
+
// - 'oauth' (Bedrock SigV4, Codex/Cursor JWT, Vertex ADC, etc.):
|
|
808
|
+
// OAuth but NOT a fingerprinted subscription client. Same routing as
|
|
809
|
+
// PAYG; only difference is upstream credential format.
|
|
810
|
+
//
|
|
811
|
+
// - 'payg' (API key): full orchestrator with all optimizations.
|
|
812
|
+
//
|
|
813
|
+
// All three paths now share window-scored intent tier picking
|
|
814
|
+
// (`pickTierByIntent`). Subscription still has the additional
|
|
815
|
+
// azure-anthropic passthrough fork for anti-abuse stealth; everything
|
|
816
|
+
// else just falls through to the orchestrator with the picked tier
|
|
817
|
+
// pinned via _forceProvider/_tierModel. The reason all paths share the
|
|
818
|
+
// scorer is that determineProviderSmart's full-body analysis inflates
|
|
819
|
+
// scores (5 KB system prompt + 11 tools + every prior message ≫ user
|
|
820
|
+
// intent), pushing every request — including "yes" follow-ups — into
|
|
821
|
+
// COMPLEX/REASONING regardless of what the user actually typed. Window-
|
|
822
|
+
// scoring fixes that for PAYG too.
|
|
823
|
+
const authMode = classifyAuthMode(req.headers);
|
|
824
|
+
const tier = await pickTierByIntent(req.body);
|
|
825
|
+
|
|
826
|
+
// Subscription-only fork: anti-abuse stealth passthrough when the picked
|
|
827
|
+
// tier resolves to azure-anthropic. Bypasses the orchestrator entirely
|
|
828
|
+
// so the inbound bytes hit api.anthropic.com unchanged (Anthropic
|
|
829
|
+
// fingerprints subscription clients; any mutation gets flagged).
|
|
830
|
+
if (authMode === 'subscription' && tier.provider === 'azure-anthropic') {
|
|
831
|
+
logger.debug({
|
|
832
|
+
reqNumber: messagesRequestCount,
|
|
833
|
+
authMode,
|
|
834
|
+
model: req.body?.model,
|
|
835
|
+
tier: tier.tier,
|
|
836
|
+
}, "Subscription passthrough → api.anthropic.com");
|
|
837
|
+
return handleOauthPassthrough(req, res, { tier });
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// All other cases (subscription→non-Anthropic, payg, oauth): pin the
|
|
841
|
+
// window-scored tier so the orchestrator's internal tier router can't
|
|
842
|
+
// override it with a full-body re-score. Badge/headers downstream show
|
|
843
|
+
// OUR pick (scored on user intent only), not the orchestrator's
|
|
844
|
+
// pre-route (scored on full payload including system prompt + tools).
|
|
845
|
+
logger.debug({
|
|
846
|
+
reqNumber: messagesRequestCount,
|
|
847
|
+
authMode,
|
|
848
|
+
tier: tier.tier,
|
|
849
|
+
provider: tier.provider,
|
|
850
|
+
model: tier.model,
|
|
851
|
+
method: tier.method,
|
|
852
|
+
}, "Intent-scored tier routing → orchestrator (forced provider)");
|
|
853
|
+
req.body._forceProvider = tier.provider;
|
|
854
|
+
if (tier.model) req.body._tierModel = tier.model;
|
|
855
|
+
req._intentTier = tier;
|
|
217
856
|
|
|
218
857
|
// Convert Anthropic server tools (web_search_20260209, etc.) to regular
|
|
219
858
|
// function tools so non-Anthropic providers can execute them via Lynkr.
|
|
@@ -308,13 +947,26 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
308
947
|
}
|
|
309
948
|
}
|
|
310
949
|
|
|
950
|
+
// If the OAuth-subscription tier picker already made a decision (scored
|
|
951
|
+
// on user-intent only, not the full Claude Code payload), use its values
|
|
952
|
+
// so the badge/headers reflect the ACTUAL routing decision instead of
|
|
953
|
+
// the pre-route's full-payload score (which is inflated by tools + system).
|
|
954
|
+
if (req._intentTier) {
|
|
955
|
+
preRouteProvider = req._intentTier.provider || preRouteProvider;
|
|
956
|
+
preRouteTier = req._intentTier.tier || preRouteTier;
|
|
957
|
+
preRouteModel = req._intentTier.model || preRouteModel;
|
|
958
|
+
preRouteMethod = 'oauth-tier-routing';
|
|
959
|
+
preRouteReason = 'user_intent';
|
|
960
|
+
}
|
|
961
|
+
|
|
311
962
|
const preRouteDecision = {
|
|
312
963
|
provider: preRouteProvider,
|
|
313
964
|
tier: preRouteTier,
|
|
314
965
|
model: preRouteModel,
|
|
315
966
|
method: preRouteMethod,
|
|
316
967
|
reason: preRouteReason,
|
|
317
|
-
score
|
|
968
|
+
// For OAuth requests, surface the user-intent score, not the full-payload one.
|
|
969
|
+
score: req._intentTier?.score ?? complexity.score,
|
|
318
970
|
threshold: complexity.threshold,
|
|
319
971
|
risk: preRouteRisk,
|
|
320
972
|
};
|
|
@@ -458,9 +1110,19 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
458
1110
|
// 2. content_block_start and content_block_delta for each content block
|
|
459
1111
|
// Filter out server-side tools that shouldn't reach the client
|
|
460
1112
|
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
461
|
-
|
|
1113
|
+
let contentBlocks = (msg.content || []).filter(b =>
|
|
462
1114
|
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
463
1115
|
);
|
|
1116
|
+
|
|
1117
|
+
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1118
|
+
// users can see which tier/provider/model handled the request inside
|
|
1119
|
+
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
1120
|
+
// fields are silently dropped).
|
|
1121
|
+
if (config.routing?.visibleInteraction && interaction) {
|
|
1122
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}*\n\n`;
|
|
1123
|
+
contentBlocks = [{ type: 'text', text: badge }, ...contentBlocks];
|
|
1124
|
+
}
|
|
1125
|
+
|
|
464
1126
|
for (let i = 0; i < contentBlocks.length; i++) {
|
|
465
1127
|
const block = contentBlocks[i];
|
|
466
1128
|
|
|
@@ -634,9 +1296,19 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
634
1296
|
// 2. content_block_start and content_block_delta for each content block
|
|
635
1297
|
// Filter out server-side tools that shouldn't reach the client
|
|
636
1298
|
const _serverTools = new Set(["task", "websearch", "webfetch", "web_search", "web_fetch", "web_agent"]);
|
|
637
|
-
|
|
1299
|
+
let contentBlocks = (msg.content || []).filter(b =>
|
|
638
1300
|
!(b.type === "tool_use" && _serverTools.has((b.name || "").toLowerCase()))
|
|
639
1301
|
);
|
|
1302
|
+
|
|
1303
|
+
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1304
|
+
// users can see which tier/provider/model handled the request inside
|
|
1305
|
+
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
1306
|
+
// fields are silently dropped).
|
|
1307
|
+
if (config.routing?.visibleInteraction && interaction) {
|
|
1308
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'}*\n\n`;
|
|
1309
|
+
contentBlocks = [{ type: 'text', text: badge }, ...contentBlocks];
|
|
1310
|
+
}
|
|
1311
|
+
|
|
640
1312
|
for (let i = 0; i < contentBlocks.length; i++) {
|
|
641
1313
|
const block = contentBlocks[i];
|
|
642
1314
|
|
|
@@ -759,27 +1431,28 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
759
1431
|
result.body
|
|
760
1432
|
) {
|
|
761
1433
|
try {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
}
|
|
778
|
-
|
|
1434
|
+
// result.body can be: a parsed object, a JSON string, or a Buffer.
|
|
1435
|
+
// Normalize to a parsed object first.
|
|
1436
|
+
let parsed;
|
|
1437
|
+
if (typeof result.body === 'object' && !Buffer.isBuffer(result.body)) {
|
|
1438
|
+
parsed = result.body;
|
|
1439
|
+
} else {
|
|
1440
|
+
const text = Buffer.isBuffer(result.body) ? result.body.toString('utf8') : result.body;
|
|
1441
|
+
if (typeof text === 'string' && text.startsWith('{')) {
|
|
1442
|
+
parsed = JSON.parse(text);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (parsed && typeof parsed === 'object' && parsed.type === 'message') {
|
|
1446
|
+
parsed.lynkr_interaction = interaction;
|
|
1447
|
+
// Inject a one-line routing badge into content so the TUI renders it.
|
|
1448
|
+
if (Array.isArray(parsed.content)) {
|
|
1449
|
+
const badge = `*[Lynkr] ${interaction.tier || '—'} → ${interaction.model || '—'} (${interaction.provider || '—'}) · score ${interaction.complexity_score ?? '—'} · savings ~${interaction.estimated_savings_percent ?? 0}%*\n\n`;
|
|
1450
|
+
parsed.content.unshift({ type: 'text', text: badge });
|
|
779
1451
|
}
|
|
1452
|
+
finalBody = JSON.stringify(parsed);
|
|
780
1453
|
}
|
|
781
1454
|
} catch (err) {
|
|
782
|
-
logger.debug({ err: err.message }, '[Router] Skipped interaction injection
|
|
1455
|
+
logger.debug({ err: err.message }, '[Router] Skipped interaction injection');
|
|
783
1456
|
}
|
|
784
1457
|
}
|
|
785
1458
|
|