lynkr 9.9.1 → 9.10.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 +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +4 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +2 -2
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +275 -160
- package/src/clients/databricks.js +95 -6
- package/src/config/index.js +3 -43
- package/src/orchestrator/index.js +117 -1235
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/routing/affinity-store.js +17 -3
- package/src/routing/difficulty-classifier.js +52 -10
- package/src/routing/index.js +35 -3
- package/src/routing/intent-score.js +20 -2
- package/src/routing/session-affinity.js +8 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/server.js +0 -46
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2b — cross-format SSE streaming transform.
|
|
3
|
+
*
|
|
4
|
+
* When the client speaks Anthropic but the tier-resolved upstream speaks
|
|
5
|
+
* OpenAI Chat Completions, the buffered path costs a full generation of
|
|
6
|
+
* latency before the client sees a single token. This module reshapes the
|
|
7
|
+
* upstream's SSE deltas into Anthropic Messages events in flight.
|
|
8
|
+
*
|
|
9
|
+
* The hard part is tool calls: OpenAI streams tool_calls[i].function.arguments
|
|
10
|
+
* as unparseable JSON slivers spread across chunks. The transformer
|
|
11
|
+
* accumulates fragments per tool index and emits one clean
|
|
12
|
+
* content_block_start + content_block_delta(input_json_delta) +
|
|
13
|
+
* content_block_stop per COMPLETE tool_use, at stream end — matching the
|
|
14
|
+
* shape Lynkr's buffered synthesis has always sent, so clients see no
|
|
15
|
+
* difference in event structure.
|
|
16
|
+
*
|
|
17
|
+
* Default on; kill switch LYNKR_STREAM_TRANSFORM=false. The buffered path
|
|
18
|
+
* stays for body.stream === false and for hallucination-recovery re-prompts
|
|
19
|
+
* (which require a parsed, buffered response).
|
|
20
|
+
*/
|
|
21
|
+
const logger = require("../logger");
|
|
22
|
+
|
|
23
|
+
// Upstreams whose streaming wire format is OpenAI Chat Completions SSE.
|
|
24
|
+
// ollama is excluded (own format + buffering quirks), moonshot/zai are
|
|
25
|
+
// excluded (their invoke fns convert buffered responses to Anthropic).
|
|
26
|
+
const DEFAULT_OPENAI_SSE_PROVIDERS = [
|
|
27
|
+
"openai",
|
|
28
|
+
"azure-openai",
|
|
29
|
+
"openrouter",
|
|
30
|
+
"databricks",
|
|
31
|
+
"lmstudio",
|
|
32
|
+
"llamacpp",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
function _transformProviders() {
|
|
36
|
+
const env = process.env.LYNKR_STREAM_TRANSFORM_PROVIDERS;
|
|
37
|
+
return new Set(
|
|
38
|
+
env ? env.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_OPENAI_SSE_PROVIDERS,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function shouldTransform(clientWantsStream, provider) {
|
|
43
|
+
// Default ON since 2026-07-21 (E2E-verified against live upstreams);
|
|
44
|
+
// LYNKR_STREAM_TRANSFORM=false is the kill switch, mirroring
|
|
45
|
+
// LYNKR_NATIVE_PASSTHROUGH. Kill switches stay on streaming paths because
|
|
46
|
+
// past the first byte a stream cannot be retried — reverting behavior via
|
|
47
|
+
// env + restart beats reverting code during an incident.
|
|
48
|
+
if (process.env.LYNKR_STREAM_TRANSFORM === "false") return false;
|
|
49
|
+
if (clientWantsStream !== true) return false;
|
|
50
|
+
return _transformProviders().has(provider);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _sse(event, data) {
|
|
54
|
+
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function _mapFinishReason(reason) {
|
|
58
|
+
switch (reason) {
|
|
59
|
+
case "tool_calls":
|
|
60
|
+
case "function_call":
|
|
61
|
+
return "tool_use";
|
|
62
|
+
case "length":
|
|
63
|
+
return "max_tokens";
|
|
64
|
+
case "stop":
|
|
65
|
+
default:
|
|
66
|
+
return "end_turn";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Normalize a web ReadableStream or Node Readable into an async iterator of Buffers. */
|
|
71
|
+
async function* _iterateStream(stream) {
|
|
72
|
+
if (typeof stream.getReader === "function") {
|
|
73
|
+
const reader = stream.getReader();
|
|
74
|
+
try {
|
|
75
|
+
while (true) {
|
|
76
|
+
const { value, done } = await reader.read();
|
|
77
|
+
if (done) return;
|
|
78
|
+
yield value;
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
try { reader.releaseLock(); } catch { /* released */ }
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
yield* stream;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Split an SSE byte stream into `data:` payload strings. */
|
|
89
|
+
async function* _sseDataLines(stream) {
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
let buffer = "";
|
|
92
|
+
for await (const chunk of _iterateStream(stream)) {
|
|
93
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
94
|
+
const lines = buffer.split("\n");
|
|
95
|
+
buffer = lines.pop() ?? "";
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const trimmed = line.trim();
|
|
98
|
+
if (trimmed.startsWith("data:")) yield trimmed.slice(5).trim();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const trimmed = buffer.trim();
|
|
102
|
+
if (trimmed.startsWith("data:")) yield trimmed.slice(5).trim();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Core transform: OpenAI Chat Completions SSE → Anthropic Messages SSE.
|
|
107
|
+
* Yields Anthropic SSE event strings. `stats` accumulates for the onClose
|
|
108
|
+
* finalizer: tool names/arg sizes and usage, gathered DURING the stream so
|
|
109
|
+
* telemetry needs no second pass.
|
|
110
|
+
*/
|
|
111
|
+
async function* _openaiToAnthropicEvents(upstream, opts = {}) {
|
|
112
|
+
const fallbackModel = opts.model || "unknown";
|
|
113
|
+
const stats = {
|
|
114
|
+
toolCalls: [], // { name, argBytes }
|
|
115
|
+
usage: { input_tokens: null, output_tokens: null },
|
|
116
|
+
stopReason: null,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
let started = false;
|
|
120
|
+
let messageId = null;
|
|
121
|
+
let model = fallbackModel;
|
|
122
|
+
let nextIndex = 0;
|
|
123
|
+
let textIndex = null; // open text block index, null when closed
|
|
124
|
+
let finishReason = null;
|
|
125
|
+
const toolAcc = new Map(); // openai tool index -> { id, name, args }
|
|
126
|
+
|
|
127
|
+
const startMessage = function* () {
|
|
128
|
+
if (started) return;
|
|
129
|
+
started = true;
|
|
130
|
+
yield _sse("message_start", {
|
|
131
|
+
type: "message_start",
|
|
132
|
+
message: {
|
|
133
|
+
id: messageId || `msg_${Date.now()}`,
|
|
134
|
+
type: "message",
|
|
135
|
+
role: "assistant",
|
|
136
|
+
content: [],
|
|
137
|
+
model,
|
|
138
|
+
stop_reason: null,
|
|
139
|
+
stop_sequence: null,
|
|
140
|
+
usage: { input_tokens: 0, output_tokens: 1 },
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
// LYNKR_VISIBLE_ROUTING badge — a clean first text block, before any
|
|
144
|
+
// upstream content. Unlike the byte-pipe passthrough, the transformer
|
|
145
|
+
// owns the indices, so the badge slots in properly.
|
|
146
|
+
if (opts.badgeText) {
|
|
147
|
+
const index = nextIndex++;
|
|
148
|
+
yield _sse("content_block_start", { type: "content_block_start", index, content_block: { type: "text", text: "" } });
|
|
149
|
+
yield _sse("content_block_delta", { type: "content_block_delta", index, delta: { type: "text_delta", text: opts.badgeText } });
|
|
150
|
+
yield _sse("content_block_stop", { type: "content_block_stop", index });
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const closeTextBlock = function* () {
|
|
155
|
+
if (textIndex === null) return;
|
|
156
|
+
yield _sse("content_block_stop", { type: "content_block_stop", index: textIndex });
|
|
157
|
+
textIndex = null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const flushToolBlocks = function* () {
|
|
161
|
+
for (const [, tool] of [...toolAcc.entries()].sort((a, b) => a[0] - b[0])) {
|
|
162
|
+
const index = nextIndex++;
|
|
163
|
+
const args = tool.args || "{}";
|
|
164
|
+
yield _sse("content_block_start", {
|
|
165
|
+
type: "content_block_start",
|
|
166
|
+
index,
|
|
167
|
+
content_block: {
|
|
168
|
+
type: "tool_use",
|
|
169
|
+
id: tool.id || `toolu_${Date.now()}_${index.toString(36)}`,
|
|
170
|
+
name: tool.name || "unknown",
|
|
171
|
+
input: {},
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
yield _sse("content_block_delta", {
|
|
175
|
+
type: "content_block_delta",
|
|
176
|
+
index,
|
|
177
|
+
delta: { type: "input_json_delta", partial_json: args },
|
|
178
|
+
});
|
|
179
|
+
yield _sse("content_block_stop", { type: "content_block_stop", index });
|
|
180
|
+
stats.toolCalls.push({ name: tool.name || "unknown", argBytes: args.length });
|
|
181
|
+
}
|
|
182
|
+
toolAcc.clear();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
for await (const payload of _sseDataLines(upstream)) {
|
|
187
|
+
if (payload === "[DONE]") break;
|
|
188
|
+
|
|
189
|
+
let chunk;
|
|
190
|
+
try {
|
|
191
|
+
chunk = JSON.parse(payload);
|
|
192
|
+
} catch {
|
|
193
|
+
continue; // partial/garbled chunk — skip, never crash the stream
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (chunk.id && !messageId) messageId = chunk.id;
|
|
197
|
+
if (chunk.model) model = chunk.model;
|
|
198
|
+
if (chunk.usage) {
|
|
199
|
+
if (chunk.usage.prompt_tokens != null) stats.usage.input_tokens = chunk.usage.prompt_tokens;
|
|
200
|
+
if (chunk.usage.completion_tokens != null) stats.usage.output_tokens = chunk.usage.completion_tokens;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const choice = chunk.choices?.[0];
|
|
204
|
+
if (!choice) continue;
|
|
205
|
+
if (choice.finish_reason) finishReason = choice.finish_reason;
|
|
206
|
+
const delta = choice.delta || {};
|
|
207
|
+
|
|
208
|
+
yield* startMessage();
|
|
209
|
+
|
|
210
|
+
// Text deltas are straightforward: open a block on first text, then
|
|
211
|
+
// emit a text_delta per chunk.
|
|
212
|
+
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
213
|
+
if (textIndex === null) {
|
|
214
|
+
textIndex = nextIndex++;
|
|
215
|
+
yield _sse("content_block_start", {
|
|
216
|
+
type: "content_block_start",
|
|
217
|
+
index: textIndex,
|
|
218
|
+
content_block: { type: "text", text: "" },
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
yield _sse("content_block_delta", {
|
|
222
|
+
type: "content_block_delta",
|
|
223
|
+
index: textIndex,
|
|
224
|
+
delta: { type: "text_delta", text: delta.content },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Tool-call fragments: accumulate per OpenAI tool index. Fragments of
|
|
229
|
+
// function.arguments are NOT parseable individually — only the full
|
|
230
|
+
// concatenation is valid JSON, so blocks are emitted at stream end.
|
|
231
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
232
|
+
yield* closeTextBlock();
|
|
233
|
+
for (const tc of delta.tool_calls) {
|
|
234
|
+
const idx = tc.index ?? 0;
|
|
235
|
+
if (!toolAcc.has(idx)) toolAcc.set(idx, { id: null, name: null, args: "" });
|
|
236
|
+
const acc = toolAcc.get(idx);
|
|
237
|
+
if (tc.id) acc.id = tc.id;
|
|
238
|
+
if (tc.function?.name) acc.name = (acc.name || "") + tc.function.name;
|
|
239
|
+
if (typeof tc.function?.arguments === "string") acc.args += tc.function.arguments;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch (err) {
|
|
244
|
+
logger.warn({ err: err.message }, "[SSETransform] Upstream stream failed mid-flight");
|
|
245
|
+
yield* startMessage();
|
|
246
|
+
yield* closeTextBlock();
|
|
247
|
+
yield _sse("error", {
|
|
248
|
+
type: "error",
|
|
249
|
+
error: { type: "overloaded_error", message: `Lynkr: upstream stream failed — retry (${err.message})` },
|
|
250
|
+
});
|
|
251
|
+
stats.stopReason = "stream_error";
|
|
252
|
+
if (opts.onClose) { try { opts.onClose(stats); } catch { /* non-fatal */ } }
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Normal end of stream ([DONE] or upstream EOF).
|
|
257
|
+
yield* startMessage();
|
|
258
|
+
yield* closeTextBlock();
|
|
259
|
+
yield* flushToolBlocks();
|
|
260
|
+
|
|
261
|
+
const stopReason = _mapFinishReason(finishReason);
|
|
262
|
+
stats.stopReason = stopReason;
|
|
263
|
+
yield _sse("message_delta", {
|
|
264
|
+
type: "message_delta",
|
|
265
|
+
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
266
|
+
usage: { output_tokens: stats.usage.output_tokens ?? 0 },
|
|
267
|
+
});
|
|
268
|
+
yield _sse("message_stop", { type: "message_stop" });
|
|
269
|
+
|
|
270
|
+
if (opts.onClose) { try { opts.onClose(stats); } catch { /* non-fatal */ } }
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* OpenAI SSE → Anthropic SSE. Returns a web ReadableStream (exposes
|
|
275
|
+
* getReader(), matching what the router's stream-forwarding path expects).
|
|
276
|
+
*/
|
|
277
|
+
function openaiToAnthropicSSE(upstream, opts = {}) {
|
|
278
|
+
const generator = _openaiToAnthropicEvents(upstream, opts);
|
|
279
|
+
const encoder = new TextEncoder();
|
|
280
|
+
return new ReadableStream({
|
|
281
|
+
async pull(controller) {
|
|
282
|
+
try {
|
|
283
|
+
const { value, done } = await generator.next();
|
|
284
|
+
if (done) {
|
|
285
|
+
controller.close();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
controller.enqueue(encoder.encode(value));
|
|
289
|
+
} catch (err) {
|
|
290
|
+
controller.error(err);
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
async cancel() {
|
|
294
|
+
try { await generator.return(); } catch { /* already finished */ }
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Anthropic SSE → OpenAI SSE. The symmetric direction — unused by Lynkr's
|
|
301
|
+
* current routes (OpenAI-speaking clients get buffered conversion) but kept
|
|
302
|
+
* so both directions live and are tested together.
|
|
303
|
+
*/
|
|
304
|
+
async function* _anthropicToOpenaiEvents(upstream, opts = {}) {
|
|
305
|
+
const model = opts.model || "unknown";
|
|
306
|
+
let id = `chatcmpl_${Date.now()}`;
|
|
307
|
+
let usage = null;
|
|
308
|
+
let stopReason = null;
|
|
309
|
+
const blockTypes = new Map(); // anthropic block index -> type
|
|
310
|
+
let toolOrdinal = -1; // openai tool_calls array index
|
|
311
|
+
|
|
312
|
+
const chunk = (delta, finish = null) => ({
|
|
313
|
+
id,
|
|
314
|
+
object: "chat.completion.chunk",
|
|
315
|
+
model,
|
|
316
|
+
choices: [{ index: 0, delta, finish_reason: finish }],
|
|
317
|
+
...(usage && finish ? { usage } : {}),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
for await (const payload of _sseDataLines(upstream)) {
|
|
321
|
+
let evt;
|
|
322
|
+
try {
|
|
323
|
+
evt = JSON.parse(payload);
|
|
324
|
+
} catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
switch (evt.type) {
|
|
329
|
+
case "message_start":
|
|
330
|
+
if (evt.message?.id) id = evt.message.id;
|
|
331
|
+
if (evt.message?.usage?.input_tokens != null) {
|
|
332
|
+
usage = { prompt_tokens: evt.message.usage.input_tokens, completion_tokens: 0, total_tokens: evt.message.usage.input_tokens };
|
|
333
|
+
}
|
|
334
|
+
yield `data: ${JSON.stringify(chunk({ role: "assistant", content: "" }))}\n\n`;
|
|
335
|
+
break;
|
|
336
|
+
case "content_block_start":
|
|
337
|
+
blockTypes.set(evt.index, evt.content_block?.type);
|
|
338
|
+
if (evt.content_block?.type === "tool_use") {
|
|
339
|
+
toolOrdinal += 1;
|
|
340
|
+
yield `data: ${JSON.stringify(chunk({
|
|
341
|
+
tool_calls: [{
|
|
342
|
+
index: toolOrdinal,
|
|
343
|
+
id: evt.content_block.id,
|
|
344
|
+
type: "function",
|
|
345
|
+
function: { name: evt.content_block.name, arguments: "" },
|
|
346
|
+
}],
|
|
347
|
+
}))}\n\n`;
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
case "content_block_delta":
|
|
351
|
+
if (evt.delta?.type === "text_delta") {
|
|
352
|
+
yield `data: ${JSON.stringify(chunk({ content: evt.delta.text }))}\n\n`;
|
|
353
|
+
} else if (evt.delta?.type === "input_json_delta") {
|
|
354
|
+
yield `data: ${JSON.stringify(chunk({
|
|
355
|
+
tool_calls: [{ index: toolOrdinal, function: { arguments: evt.delta.partial_json } }],
|
|
356
|
+
}))}\n\n`;
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
case "message_delta":
|
|
360
|
+
if (evt.delta?.stop_reason) stopReason = evt.delta.stop_reason;
|
|
361
|
+
if (evt.usage?.output_tokens != null) {
|
|
362
|
+
usage = usage || { prompt_tokens: 0 };
|
|
363
|
+
usage.completion_tokens = evt.usage.output_tokens;
|
|
364
|
+
usage.total_tokens = (usage.prompt_tokens || 0) + evt.usage.output_tokens;
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
case "message_stop": {
|
|
368
|
+
const finish = stopReason === "tool_use" ? "tool_calls" : stopReason === "max_tokens" ? "length" : "stop";
|
|
369
|
+
yield `data: ${JSON.stringify(chunk({}, finish))}\n\n`;
|
|
370
|
+
yield "data: [DONE]\n\n";
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
break; // ping, content_block_stop — no OpenAI equivalent needed
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function anthropicToOpenaiSSE(upstream, opts = {}) {
|
|
380
|
+
const generator = _anthropicToOpenaiEvents(upstream, opts);
|
|
381
|
+
const encoder = new TextEncoder();
|
|
382
|
+
return new ReadableStream({
|
|
383
|
+
async pull(controller) {
|
|
384
|
+
try {
|
|
385
|
+
const { value, done } = await generator.next();
|
|
386
|
+
if (done) {
|
|
387
|
+
controller.close();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
controller.enqueue(encoder.encode(value));
|
|
391
|
+
} catch (err) {
|
|
392
|
+
controller.error(err);
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
async cancel() {
|
|
396
|
+
try { await generator.return(); } catch { /* already finished */ }
|
|
397
|
+
},
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
module.exports = {
|
|
402
|
+
shouldTransform,
|
|
403
|
+
openaiToAnthropicSSE,
|
|
404
|
+
anthropicToOpenaiSSE,
|
|
405
|
+
// Exported for unit tests.
|
|
406
|
+
_openaiToAnthropicEvents,
|
|
407
|
+
_anthropicToOpenaiEvents,
|
|
408
|
+
};
|
|
@@ -49,6 +49,13 @@ function _db() {
|
|
|
49
49
|
if (!cols.has("score")) {
|
|
50
50
|
db.exec("ALTER TABLE session_pins ADD COLUMN score REAL");
|
|
51
51
|
}
|
|
52
|
+
// Additive migration for the side-channel detector's Signal 2 —
|
|
53
|
+
// once a session has ever carried tool_use/tool_result blocks, this
|
|
54
|
+
// flag stays 1 for the pin's lifetime. Payloads that arrive later
|
|
55
|
+
// without tool blocks in a flagged session are side-channel replays.
|
|
56
|
+
if (!cols.has("has_tool_history")) {
|
|
57
|
+
db.exec("ALTER TABLE session_pins ADD COLUMN has_tool_history INTEGER DEFAULT 0");
|
|
58
|
+
}
|
|
52
59
|
schemaEnsured = true;
|
|
53
60
|
} catch (err) {
|
|
54
61
|
degradation.record("feedback", err);
|
|
@@ -81,7 +88,7 @@ function load(sessionId, ttlMs) {
|
|
|
81
88
|
const row = _stmt(
|
|
82
89
|
db,
|
|
83
90
|
"load",
|
|
84
|
-
"SELECT provider, model, tier, score, message_count, prompt_tokens_est, ts FROM session_pins WHERE session_id = ?"
|
|
91
|
+
"SELECT provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts FROM session_pins WHERE session_id = ?"
|
|
85
92
|
).get(sessionId);
|
|
86
93
|
if (!row) return null;
|
|
87
94
|
if (ttlMs && Date.now() - row.ts > ttlMs) {
|
|
@@ -98,6 +105,7 @@ function load(sessionId, ttlMs) {
|
|
|
98
105
|
score: row.score,
|
|
99
106
|
messageCount: row.message_count,
|
|
100
107
|
promptTokensEst: row.prompt_tokens_est,
|
|
108
|
+
hasToolHistory: !!row.has_tool_history,
|
|
101
109
|
ts: row.ts,
|
|
102
110
|
};
|
|
103
111
|
} catch (err) {
|
|
@@ -121,8 +129,12 @@ function save(sessionId, pin) {
|
|
|
121
129
|
_stmt(
|
|
122
130
|
db,
|
|
123
131
|
"upsert",
|
|
124
|
-
|
|
125
|
-
|
|
132
|
+
// has_tool_history is sticky-true: once the session has ever carried
|
|
133
|
+
// tool blocks it stays flagged for the pin's lifetime. Use MAX so an
|
|
134
|
+
// update from a tool-less request (e.g. compaction refresh) can never
|
|
135
|
+
// clear the flag once set.
|
|
136
|
+
`INSERT INTO session_pins (session_id, provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts)
|
|
137
|
+
VALUES (@session_id, @provider, @model, @tier, @score, @message_count, @prompt_tokens_est, @has_tool_history, @ts)
|
|
126
138
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
127
139
|
provider = excluded.provider,
|
|
128
140
|
model = excluded.model,
|
|
@@ -130,6 +142,7 @@ function save(sessionId, pin) {
|
|
|
130
142
|
score = excluded.score,
|
|
131
143
|
message_count = excluded.message_count,
|
|
132
144
|
prompt_tokens_est = excluded.prompt_tokens_est,
|
|
145
|
+
has_tool_history = MAX(has_tool_history, excluded.has_tool_history),
|
|
133
146
|
ts = excluded.ts`
|
|
134
147
|
).run({
|
|
135
148
|
session_id: sessionId,
|
|
@@ -139,6 +152,7 @@ function save(sessionId, pin) {
|
|
|
139
152
|
score: typeof pin.score === 'number' ? pin.score : null,
|
|
140
153
|
message_count: pin.messageCount ?? null,
|
|
141
154
|
prompt_tokens_est: pin.promptTokensEst ?? null,
|
|
155
|
+
has_tool_history: pin.hasToolHistory ? 1 : 0,
|
|
142
156
|
ts: pin.ts ?? Date.now(),
|
|
143
157
|
});
|
|
144
158
|
} catch (err) {
|
|
@@ -44,23 +44,54 @@ const CLASSIFIER_MODEL = 'qwen2.5:3b';
|
|
|
44
44
|
|
|
45
45
|
const VALID_TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING'];
|
|
46
46
|
|
|
47
|
-
// One-shot classification prompt. Kept in a const so drift is diffable.
|
|
47
|
+
// One-shot classification prompt (v2). Kept in a const so drift is diffable.
|
|
48
48
|
// Difficulty framing (not intent) — matches config B routing goals.
|
|
49
|
-
|
|
49
|
+
//
|
|
50
|
+
// v2 (2026-07-21): added the follow-up rule, negative examples under
|
|
51
|
+
// REASONING, and casual-question SIMPLE examples after a live incident:
|
|
52
|
+
// qwen2.5:3b read "Who kills him ?" (a Doctor Doom plot question) as
|
|
53
|
+
// REASONING conf 1.0 — v1's REASONING examples were all formal-methods
|
|
54
|
+
// flavored and nothing said surface vocabulary isn't the signal. Baseline
|
|
55
|
+
// on data/difficulty-eval-followups.jsonl: 60% overall, 33% on SIMPLE,
|
|
56
|
+
// 3 SIMPLE→REASONING criticals.
|
|
57
|
+
const CLASSIFY_PROMPT = `You are a classifier for an LLM routing proxy. Classify the difficulty of the CURRENT user prompt into exactly one of four tiers. Reply with ONLY valid JSON on a single line, no other text.
|
|
50
58
|
|
|
51
59
|
Tiers:
|
|
52
|
-
- SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups. Any tiny model handles.
|
|
53
|
-
examples: "hi", "ok thanks", "yes continue", "what time is it"
|
|
60
|
+
- SIMPLE: casual acknowledgments, greetings, one-word answers, trivial factual lookups, and short conversational follow-up questions about people, stories, events, or everyday facts. Any tiny model handles.
|
|
61
|
+
examples: "hi", "ok thanks", "yes continue", "what time is it", "who is doctor doom?", "who kills him?", "why did he do that?", "and then what happened?", "does bleach kill mold?"
|
|
54
62
|
- MEDIUM: one specific mechanical task or a focused explanation. Mid-size local model suffices.
|
|
55
|
-
examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block"
|
|
63
|
+
examples: "list the exports from this file", "run the unit tests", "fix the linter warnings", "explain this regex", "add error handling to this block", "verify the file exists before reading it"
|
|
56
64
|
- COMPLEX: multi-file design, systemic refactor, architecture review, debugging that requires broad code understanding. Needs a strong general model.
|
|
57
65
|
examples: "architecture review of the orchestrator", "refactor the entire ingestion pipeline", "debug this complex race condition across three services"
|
|
58
66
|
- REASONING: formal proof, correctness verification, security audit, novel algorithm design, formal reasoning from first principles. Needs a frontier reasoning model.
|
|
59
67
|
examples: "prove the correctness of this lock-free queue", "security audit the auth middleware", "formally verify this state machine never deadlocks", "derive the optimal eviction policy and prove its competitive ratio"
|
|
68
|
+
NOT reasoning: casual questions that merely contain words like "prove", "kill", "verify", "audit" — "who kills him?" is SIMPLE, "prove me wrong lol" is SIMPLE, "can you verify the score?" is SIMPLE, "verify the file exists" is MEDIUM.
|
|
69
|
+
|
|
70
|
+
Rules:
|
|
71
|
+
- Judge the TASK the model must perform, not the vocabulary. Words like kill/prove/verify/audit/security do not make a prompt REASONING unless it demands formal or expert-level analysis.
|
|
72
|
+
- A short follow-up question (pronouns like he/she/it/that referring to the earlier conversation) about a casual topic is SIMPLE. A follow-up that extends a technical task inherits the difficulty of that task.
|
|
60
73
|
|
|
61
74
|
Reply format (strict): {"tier":"SIMPLE|MEDIUM|COMPLEX|REASONING","confidence":0.0-1.0}
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
// Short prompts are where context matters: "Who kills him ?" is
|
|
78
|
+
// unclassifiable in isolation but trivially SIMPLE next to its conversation.
|
|
79
|
+
// Long prompts self-describe, and contextualizing them would only shrink the
|
|
80
|
+
// LRU hit rate and grow latency.
|
|
81
|
+
const CONTEXT_MAX_TEXT_LENGTH = 40;
|
|
82
|
+
const CONTEXT_MAX_CHARS = 300;
|
|
83
|
+
|
|
84
|
+
function _buildPrompt(text, context) {
|
|
85
|
+
if (context) {
|
|
86
|
+
return `${CLASSIFY_PROMPT}
|
|
87
|
+
Conversation so far (context only — classify the CURRENT prompt, inheriting topic difficulty per the rules):
|
|
88
|
+
${context}
|
|
62
89
|
|
|
63
|
-
|
|
90
|
+
CURRENT user prompt: """${text}"""`;
|
|
91
|
+
}
|
|
92
|
+
return `${CLASSIFY_PROMPT}
|
|
93
|
+
User prompt: """${text}"""`;
|
|
94
|
+
}
|
|
64
95
|
|
|
65
96
|
// --- LRU cache --------------------------------------------------------------
|
|
66
97
|
|
|
@@ -95,7 +126,7 @@ function _cacheKey(text) {
|
|
|
95
126
|
|
|
96
127
|
// --- Ollama dispatch (only supported provider for the SIMPLE tier today) ----
|
|
97
128
|
|
|
98
|
-
async function _callOllama(text, opts) {
|
|
129
|
+
async function _callOllama(text, context, opts) {
|
|
99
130
|
const config = require('../config');
|
|
100
131
|
|
|
101
132
|
// First cut supports ollama-family classifier only. Other providers can
|
|
@@ -109,7 +140,7 @@ async function _callOllama(text, opts) {
|
|
|
109
140
|
const url = `${endpoint.replace(/\/$/, '')}/api/chat`;
|
|
110
141
|
const body = {
|
|
111
142
|
model: CLASSIFIER_MODEL,
|
|
112
|
-
messages: [{ role: 'user', content:
|
|
143
|
+
messages: [{ role: 'user', content: _buildPrompt(text, context) }],
|
|
113
144
|
stream: false,
|
|
114
145
|
format: 'json',
|
|
115
146
|
// num_predict generous because thinking models (minimax-m2.5) burn
|
|
@@ -171,6 +202,9 @@ function _parseResult(raw) {
|
|
|
171
202
|
* @param {number} [opts.timeoutMs] — override default 2500ms
|
|
172
203
|
* @param {boolean} [opts.forceMatched] — caller already matched a FORCE_* pattern; skip classifier
|
|
173
204
|
* @param {string} [opts.riskLevel] — 'high' | 'medium' | 'low'; skip when 'high'
|
|
205
|
+
* @param {string} [opts.context] — condensed prior conversation ("user asked:
|
|
206
|
+
* ... → assistant replied about: ..."); used only for short prompts, where
|
|
207
|
+
* a bare follow-up is unclassifiable in isolation
|
|
174
208
|
* @returns {Promise<{tier:string,confidence:number,source:'cache'|'model'}|null>}
|
|
175
209
|
* null when: disabled, skipped, model failure, parse failure, timeout.
|
|
176
210
|
*/
|
|
@@ -182,11 +216,18 @@ async function classifyDifficulty(text, opts = {}) {
|
|
|
182
216
|
if (opts.forceMatched) return null;
|
|
183
217
|
if (opts.riskLevel === 'high') return null;
|
|
184
218
|
|
|
185
|
-
const
|
|
219
|
+
const context =
|
|
220
|
+
typeof opts.context === 'string' && opts.context.trim() && trimmed.length <= CONTEXT_MAX_TEXT_LENGTH
|
|
221
|
+
? opts.context.trim().slice(0, CONTEXT_MAX_CHARS)
|
|
222
|
+
: null;
|
|
223
|
+
|
|
224
|
+
// Context participates in the cache key: the same follow-up text means
|
|
225
|
+
// different things in different conversations.
|
|
226
|
+
const key = _cacheKey(context ? `${trimmed}${context}` : trimmed);
|
|
186
227
|
const cached = _cache.get(key);
|
|
187
228
|
if (cached) return { ...cached, source: 'cache' };
|
|
188
229
|
|
|
189
|
-
const raw = await _callOllama(trimmed, opts);
|
|
230
|
+
const raw = await _callOllama(trimmed, context, opts);
|
|
190
231
|
const parsed = _parseResult(raw);
|
|
191
232
|
if (!parsed) return null;
|
|
192
233
|
_cache.set(key, parsed);
|
|
@@ -214,6 +255,7 @@ module.exports = {
|
|
|
214
255
|
// internals for tests only
|
|
215
256
|
_parseResult,
|
|
216
257
|
_cacheKey,
|
|
258
|
+
_buildPrompt,
|
|
217
259
|
_clearCacheForTests,
|
|
218
260
|
_getCacheStats,
|
|
219
261
|
};
|
package/src/routing/index.js
CHANGED
|
@@ -584,6 +584,8 @@ function checkSessionPin(payload, options = {}) {
|
|
|
584
584
|
// the new_conversation guard for everyone after it.
|
|
585
585
|
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
586
586
|
promptTokensEst: pin.promptTokensEst,
|
|
587
|
+
// We're inside payloadHasToolHistory(payload) — session has committed.
|
|
588
|
+
hasToolHistory: true,
|
|
587
589
|
});
|
|
588
590
|
}
|
|
589
591
|
return { serve: true, pin, reason: 'tool_history', sessionId };
|
|
@@ -599,11 +601,30 @@ function checkSessionPin(payload, options = {}) {
|
|
|
599
601
|
sessionAffinity.setPin(sessionId, pin, {
|
|
600
602
|
messageCount: Math.max(messageCount, pin.messageCount ?? 0),
|
|
601
603
|
promptTokensEst: guards.promptTokensEst ?? pin.promptTokensEst,
|
|
604
|
+
// setPin OR-merges with the existing pin's flag, so a plain-text turn
|
|
605
|
+
// between tool exchanges keeps the sticky-true property.
|
|
606
|
+
hasToolHistory: _payloadHasAnyToolBlocks(payload),
|
|
602
607
|
});
|
|
603
608
|
}
|
|
604
609
|
return { serve: true, pin, reason: 'guards_passed', sessionId };
|
|
605
610
|
}
|
|
606
611
|
|
|
612
|
+
/** Whole-payload scan for tool_use/tool_result blocks. Unlike
|
|
613
|
+
* sessionAffinity.payloadHasToolHistory (last message only), this checks
|
|
614
|
+
* every message so we correctly flag sessions where the last frame is a
|
|
615
|
+
* plain text turn but earlier turns carried tools. */
|
|
616
|
+
function _payloadHasAnyToolBlocks(payload) {
|
|
617
|
+
const msgs = payload?.messages;
|
|
618
|
+
if (!Array.isArray(msgs)) return false;
|
|
619
|
+
for (const m of msgs) {
|
|
620
|
+
if (!Array.isArray(m?.content)) continue;
|
|
621
|
+
for (const b of m.content) {
|
|
622
|
+
if (b?.type === 'tool_use' || b?.type === 'tool_result') return true;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
|
|
607
628
|
/**
|
|
608
629
|
* Write-through helper: persist a fresh routing decision as the session's
|
|
609
630
|
* new pin. No-op when sessionId is missing or the decision has no provider.
|
|
@@ -642,7 +663,11 @@ function writeSessionPin(sessionId, decision, payload) {
|
|
|
642
663
|
return;
|
|
643
664
|
}
|
|
644
665
|
const promptTokensEst = _tryCountTokens(payload, decision.model);
|
|
645
|
-
sessionAffinity.setPin(sessionId, decision, {
|
|
666
|
+
sessionAffinity.setPin(sessionId, decision, {
|
|
667
|
+
messageCount,
|
|
668
|
+
promptTokensEst,
|
|
669
|
+
hasToolHistory: _payloadHasAnyToolBlocks(payload),
|
|
670
|
+
});
|
|
646
671
|
}
|
|
647
672
|
|
|
648
673
|
async function _determineProviderSmartInner(payload, options = {}) {
|
|
@@ -1088,10 +1113,17 @@ async function _determineProviderSmartInner(payload, options = {}) {
|
|
|
1088
1113
|
|
|
1089
1114
|
// Phase 1.2 — cost-optimizer override.
|
|
1090
1115
|
// Only kick in when:
|
|
1091
|
-
// - feature flag enabled
|
|
1116
|
+
// - feature flag EXPLICITLY enabled with LYNKR_COST_OPTIMIZE=true (default OFF)
|
|
1092
1117
|
// - risk level is not high (high-risk keeps the explicitly-configured model)
|
|
1093
1118
|
// - the optimizer finds a meaningfully cheaper qualifying model
|
|
1094
|
-
|
|
1119
|
+
//
|
|
1120
|
+
// Default changed to OFF (2026-07-20): the optimizer silently overrides
|
|
1121
|
+
// explicit TIER_* config with the cheapest model in the tier pool, which
|
|
1122
|
+
// surprised operators who expected their tier settings to be authoritative
|
|
1123
|
+
// (e.g. TIER_REASONING=azure-anthropic:claude-opus-4.7 was being swapped
|
|
1124
|
+
// for openrouter:deepseek/deepseek-reasoner — which was also a typo that
|
|
1125
|
+
// 400'd upstream). Opt-in only.
|
|
1126
|
+
const costOptimizeEnabled = process.env.LYNKR_COST_OPTIMIZE === 'true'
|
|
1095
1127
|
&& config.routing?.costOptimize !== false;
|
|
1096
1128
|
if (costOptimizeEnabled && risk?.level !== 'high') {
|
|
1097
1129
|
try {
|