lynkr 9.9.1 → 9.11.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 +11 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +309 -160
- package/src/clients/databricks.js +215 -71
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +82 -43
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +5 -1
- package/src/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +202 -1237
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +85 -4
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +7 -1
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +161 -17
- package/src/routing/index.js +196 -56
- package/src/routing/intent-score.js +20 -2
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +68 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/routing/telemetry.js +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -49
- package/src/tools/code-graph.js +6 -4
- 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
|
+
};
|
package/src/prompts/system.js
CHANGED
|
@@ -70,13 +70,27 @@ function compressToolDescriptions(tools, mode = null) {
|
|
|
70
70
|
return tools; // Return unmodified if not in minimal mode
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// Normalize to Anthropic shape: OpenAI-format callers (OpenWorker, aisuite,
|
|
74
|
+
// any OpenAI SDK) send {type:"function", function:{name, description, parameters}}.
|
|
75
|
+
// Unwrap once here so the compression logic below can assume input_schema exists.
|
|
76
|
+
tools = tools.map(tool =>
|
|
77
|
+
tool?.type === "function" && tool.function
|
|
78
|
+
? {
|
|
79
|
+
name: tool.function.name,
|
|
80
|
+
description: tool.function.description,
|
|
81
|
+
input_schema: tool.function.parameters || { type: "object", properties: {} },
|
|
82
|
+
}
|
|
83
|
+
: tool
|
|
84
|
+
);
|
|
85
|
+
|
|
73
86
|
return tools.map(tool => {
|
|
87
|
+
const input_schema = tool.input_schema || { type: "object", properties: {} };
|
|
74
88
|
const compressed = {
|
|
75
89
|
name: tool.name,
|
|
76
90
|
input_schema: {
|
|
77
|
-
type:
|
|
91
|
+
type: input_schema.type || "object",
|
|
78
92
|
properties: {},
|
|
79
|
-
required:
|
|
93
|
+
required: input_schema.required || [],
|
|
80
94
|
}
|
|
81
95
|
};
|
|
82
96
|
|
|
@@ -86,8 +100,8 @@ function compressToolDescriptions(tools, mode = null) {
|
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
// Compress property descriptions
|
|
89
|
-
if (
|
|
90
|
-
for (const [key, value] of Object.entries(
|
|
103
|
+
if (input_schema.properties) {
|
|
104
|
+
for (const [key, value] of Object.entries(input_schema.properties)) {
|
|
91
105
|
compressed.input_schema.properties[key] = {
|
|
92
106
|
type: value.type,
|
|
93
107
|
};
|
|
@@ -108,8 +122,8 @@ function compressToolDescriptions(tools, mode = null) {
|
|
|
108
122
|
}
|
|
109
123
|
|
|
110
124
|
// Preserve additionalProperties if set
|
|
111
|
-
if (
|
|
112
|
-
compressed.input_schema.additionalProperties =
|
|
125
|
+
if (input_schema.additionalProperties !== undefined) {
|
|
126
|
+
compressed.input_schema.additionalProperties = input_schema.additionalProperties;
|
|
113
127
|
}
|
|
114
128
|
|
|
115
129
|
return compressed;
|
|
@@ -49,6 +49,19 @@ 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
|
+
}
|
|
59
|
+
// Additive migration for cache-aware routing (Phase 1): JSON blob
|
|
60
|
+
// holding {warmPrefixTokens, provider, model, lastRequestAt, ttlMs},
|
|
61
|
+
// updated after every upstream response that reports cache usage.
|
|
62
|
+
if (!cols.has("cache_state")) {
|
|
63
|
+
db.exec("ALTER TABLE session_pins ADD COLUMN cache_state TEXT");
|
|
64
|
+
}
|
|
52
65
|
schemaEnsured = true;
|
|
53
66
|
} catch (err) {
|
|
54
67
|
degradation.record("feedback", err);
|
|
@@ -81,7 +94,7 @@ function load(sessionId, ttlMs) {
|
|
|
81
94
|
const row = _stmt(
|
|
82
95
|
db,
|
|
83
96
|
"load",
|
|
84
|
-
"SELECT provider, model, tier, score, message_count, prompt_tokens_est, ts FROM session_pins WHERE session_id = ?"
|
|
97
|
+
"SELECT provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts FROM session_pins WHERE session_id = ?"
|
|
85
98
|
).get(sessionId);
|
|
86
99
|
if (!row) return null;
|
|
87
100
|
if (ttlMs && Date.now() - row.ts > ttlMs) {
|
|
@@ -98,6 +111,7 @@ function load(sessionId, ttlMs) {
|
|
|
98
111
|
score: row.score,
|
|
99
112
|
messageCount: row.message_count,
|
|
100
113
|
promptTokensEst: row.prompt_tokens_est,
|
|
114
|
+
hasToolHistory: !!row.has_tool_history,
|
|
101
115
|
ts: row.ts,
|
|
102
116
|
};
|
|
103
117
|
} catch (err) {
|
|
@@ -121,8 +135,12 @@ function save(sessionId, pin) {
|
|
|
121
135
|
_stmt(
|
|
122
136
|
db,
|
|
123
137
|
"upsert",
|
|
124
|
-
|
|
125
|
-
|
|
138
|
+
// has_tool_history is sticky-true: once the session has ever carried
|
|
139
|
+
// tool blocks it stays flagged for the pin's lifetime. Use MAX so an
|
|
140
|
+
// update from a tool-less request (e.g. compaction refresh) can never
|
|
141
|
+
// clear the flag once set.
|
|
142
|
+
`INSERT INTO session_pins (session_id, provider, model, tier, score, message_count, prompt_tokens_est, has_tool_history, ts)
|
|
143
|
+
VALUES (@session_id, @provider, @model, @tier, @score, @message_count, @prompt_tokens_est, @has_tool_history, @ts)
|
|
126
144
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
127
145
|
provider = excluded.provider,
|
|
128
146
|
model = excluded.model,
|
|
@@ -130,6 +148,7 @@ function save(sessionId, pin) {
|
|
|
130
148
|
score = excluded.score,
|
|
131
149
|
message_count = excluded.message_count,
|
|
132
150
|
prompt_tokens_est = excluded.prompt_tokens_est,
|
|
151
|
+
has_tool_history = MAX(has_tool_history, excluded.has_tool_history),
|
|
133
152
|
ts = excluded.ts`
|
|
134
153
|
).run({
|
|
135
154
|
session_id: sessionId,
|
|
@@ -139,6 +158,7 @@ function save(sessionId, pin) {
|
|
|
139
158
|
score: typeof pin.score === 'number' ? pin.score : null,
|
|
140
159
|
message_count: pin.messageCount ?? null,
|
|
141
160
|
prompt_tokens_est: pin.promptTokensEst ?? null,
|
|
161
|
+
has_tool_history: pin.hasToolHistory ? 1 : 0,
|
|
142
162
|
ts: pin.ts ?? Date.now(),
|
|
143
163
|
});
|
|
144
164
|
} catch (err) {
|
|
@@ -146,6 +166,67 @@ function save(sessionId, pin) {
|
|
|
146
166
|
}
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
/**
|
|
170
|
+
* Persist per-session prompt-cache state (Phase 1, cache-aware routing).
|
|
171
|
+
* Piggybacks on the session_pins row; creates a minimal row when the session
|
|
172
|
+
* has no pin yet (possible when sticky sessions are disabled but tracking
|
|
173
|
+
* is on). Best-effort like everything else in this module.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} sessionId
|
|
176
|
+
* @param {{warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number}} state
|
|
177
|
+
*/
|
|
178
|
+
function saveCacheState(sessionId, state) {
|
|
179
|
+
if (!sessionId || !state?.provider) return;
|
|
180
|
+
const db = _db();
|
|
181
|
+
if (!db) return;
|
|
182
|
+
try {
|
|
183
|
+
const json = JSON.stringify(state);
|
|
184
|
+
const res = _stmt(
|
|
185
|
+
db,
|
|
186
|
+
"cache_state_update",
|
|
187
|
+
"UPDATE session_pins SET cache_state = ? WHERE session_id = ?"
|
|
188
|
+
).run(json, sessionId);
|
|
189
|
+
if (res.changes === 0) {
|
|
190
|
+
_stmt(
|
|
191
|
+
db,
|
|
192
|
+
"cache_state_insert",
|
|
193
|
+
`INSERT INTO session_pins (session_id, provider, model, ts, cache_state)
|
|
194
|
+
VALUES (?, ?, ?, ?, ?)
|
|
195
|
+
ON CONFLICT(session_id) DO UPDATE SET cache_state = excluded.cache_state`
|
|
196
|
+
).run(sessionId, state.provider, state.model ?? null, Date.now(), json);
|
|
197
|
+
}
|
|
198
|
+
} catch (err) {
|
|
199
|
+
degradation.record("feedback", err);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Load per-session cache state. Returns null when absent, unparsable, or the
|
|
205
|
+
* DB is unavailable — callers treat null as "no cache signal for this
|
|
206
|
+
* session" (provider doesn't report cache usage, or no response seen yet).
|
|
207
|
+
*
|
|
208
|
+
* @param {string} sessionId
|
|
209
|
+
* @returns {{warmPrefixTokens:number, provider:string, model:string|null, lastRequestAt:number, ttlMs:number}|null}
|
|
210
|
+
*/
|
|
211
|
+
function loadCacheState(sessionId) {
|
|
212
|
+
if (!sessionId) return null;
|
|
213
|
+
const db = _db();
|
|
214
|
+
if (!db) return null;
|
|
215
|
+
try {
|
|
216
|
+
const row = _stmt(
|
|
217
|
+
db,
|
|
218
|
+
"cache_state_load",
|
|
219
|
+
"SELECT cache_state FROM session_pins WHERE session_id = ?"
|
|
220
|
+
).get(sessionId);
|
|
221
|
+
if (!row?.cache_state) return null;
|
|
222
|
+
const parsed = JSON.parse(row.cache_state);
|
|
223
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
degradation.record("feedback", err);
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
149
230
|
/**
|
|
150
231
|
* Remove a pin.
|
|
151
232
|
* @param {string} sessionId
|
|
@@ -191,4 +272,4 @@ function _clear() {
|
|
|
191
272
|
} catch { /* best-effort */ }
|
|
192
273
|
}
|
|
193
274
|
|
|
194
|
-
module.exports = { load, save, remove, cleanup, _clear };
|
|
275
|
+
module.exports = { load, save, remove, cleanup, saveCacheState, loadCacheState, _clear };
|
|
@@ -224,13 +224,21 @@ class AgenticDetector {
|
|
|
224
224
|
const agentType = this._classifyAgentType(score, signals);
|
|
225
225
|
const isAgentic = score >= 25;
|
|
226
226
|
|
|
227
|
+
// AGENTIC_DISABLE_FLOOR=1 bypasses the per-agent-type minTier — the raw
|
|
228
|
+
// complexity score decides the tier without the tools-based bump. Useful
|
|
229
|
+
// for demos/tests where tool-heavy clients (OpenWorker, aisuite) send 25+
|
|
230
|
+
// tools on every request and otherwise floor everything to MEDIUM+.
|
|
231
|
+
const flooredMinTier = process.env.AGENTIC_DISABLE_FLOOR === '1'
|
|
232
|
+
? 'SIMPLE'
|
|
233
|
+
: AGENT_TYPES[agentType].minTier;
|
|
234
|
+
|
|
227
235
|
const result = {
|
|
228
236
|
isAgentic,
|
|
229
237
|
agentType,
|
|
230
238
|
confidence: Math.min(score / 100, 1),
|
|
231
239
|
score,
|
|
232
240
|
signals,
|
|
233
|
-
minTier:
|
|
241
|
+
minTier: flooredMinTier,
|
|
234
242
|
scoreBoost: AGENT_TYPES[agentType].scoreBoost,
|
|
235
243
|
description: AGENT_TYPES[agentType].description,
|
|
236
244
|
clientProfile: profile?.name ?? null,
|