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,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2a — native-format streaming passthrough.
|
|
3
|
+
*
|
|
4
|
+
* When the client speaks Anthropic and the tier-resolved upstream is an
|
|
5
|
+
* Anthropic-family provider, the buffered orchestrator adds nothing but
|
|
6
|
+
* latency: the response needs no format conversion and (post server-mode
|
|
7
|
+
* removal) no tool execution. This module connects to the upstream with
|
|
8
|
+
* stream:true and pipes the SSE bytes to the client as they arrive, so
|
|
9
|
+
* Claude Code sees first tokens as soon as the model emits them.
|
|
10
|
+
*
|
|
11
|
+
* Fallback contract: before the first byte reaches the client we can still
|
|
12
|
+
* fall back to the buffered orchestrator path — handleNativeStream returns
|
|
13
|
+
* false and the router continues its normal flow. After the first byte the
|
|
14
|
+
* stream is the response; upstream failures surface as SSE error events,
|
|
15
|
+
* never retries.
|
|
16
|
+
*
|
|
17
|
+
* Kill switch: LYNKR_NATIVE_PASSTHROUGH=false reverts every request to the
|
|
18
|
+
* buffered path.
|
|
19
|
+
*/
|
|
20
|
+
const config = require("../config");
|
|
21
|
+
const logger = require("../logger");
|
|
22
|
+
|
|
23
|
+
// Providers whose wire format is Anthropic Messages SSE end-to-end. vertex is
|
|
24
|
+
// deliberately absent: this deployment's vertex client speaks Gemini.
|
|
25
|
+
// - zai qualifies only when its endpoint is the Anthropic-format one
|
|
26
|
+
// (api.z.ai/api/anthropic/...), not /chat/completions.
|
|
27
|
+
// - ollama qualifies only when LYNKR_OLLAMA_BUFFER_RESPONSES=false: buffering
|
|
28
|
+
// is the default because Ollama Cloud thinking models (MiniMax M2.5) can
|
|
29
|
+
// leak raw <think> tags into text blocks, and the buffered path is where
|
|
30
|
+
// _liftLeakedThinkingBlocks repairs that. Streaming skips the repair.
|
|
31
|
+
// The daemon must also expose the Anthropic Messages API (v0.14+) — that
|
|
32
|
+
// check is async and happens in handleNativeStream, falling back to the
|
|
33
|
+
// buffered path when absent.
|
|
34
|
+
// Tier configs write the z.ai provider as "z-ai"; the config key and
|
|
35
|
+
// invokeModel dispatch use "zai". Accept both.
|
|
36
|
+
function _canonicalProvider(provider) {
|
|
37
|
+
return provider === "z-ai" ? "zai" : provider;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function _anthropicNative(provider) {
|
|
41
|
+
switch (_canonicalProvider(provider)) {
|
|
42
|
+
case "azure-anthropic":
|
|
43
|
+
return true;
|
|
44
|
+
case "zai":
|
|
45
|
+
return !!config.zai?.apiKey && !String(config.zai?.endpoint || "").includes("/chat/completions");
|
|
46
|
+
case "ollama":
|
|
47
|
+
return process.env.LYNKR_OLLAMA_BUFFER_RESPONSES === "false" && !!config.ollama?.endpoint;
|
|
48
|
+
default:
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const IDLE_TIMEOUT_MS = Number.parseInt(process.env.LYNKR_STREAM_IDLE_TIMEOUT_MS ?? "60000", 10);
|
|
54
|
+
|
|
55
|
+
// Parseable Anthropic error event for mid-stream failures — clients fail fast
|
|
56
|
+
// and retry instead of hanging on a truncated stream.
|
|
57
|
+
const SSE_ERROR_EVENT = (message) =>
|
|
58
|
+
`event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "overloaded_error", message } })}\n\n`;
|
|
59
|
+
|
|
60
|
+
function _formatsMatch(clientFormat, provider) {
|
|
61
|
+
return clientFormat === "anthropic" && _anthropicNative(provider);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Response rewrites the passthrough CANNOT reproduce force the buffered
|
|
66
|
+
* path. Only ANSI markdown rendering remains here: it rewrites whole text
|
|
67
|
+
* blocks, which is impossible on a byte pipe. The routing badge is NOT a
|
|
68
|
+
* disqualifier — it's injected as a synthetic SSE content block before the
|
|
69
|
+
* upstream bytes (same trick the OAuth subscription passthrough ships).
|
|
70
|
+
*/
|
|
71
|
+
function _needsInflightRewrite() {
|
|
72
|
+
try {
|
|
73
|
+
const { enabled: ansiEnabled } = require("../utils/markdown-ansi");
|
|
74
|
+
if (ansiEnabled) return true;
|
|
75
|
+
} catch { /* renderer unavailable — nothing rewrites */ }
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function canPassthrough(body, tier) {
|
|
80
|
+
if (process.env.LYNKR_NATIVE_PASSTHROUGH === "false") return false;
|
|
81
|
+
if (body?.stream !== true) return false;
|
|
82
|
+
if (!_formatsMatch("anthropic", tier?.provider)) return false;
|
|
83
|
+
if (_needsInflightRewrite(body)) return false;
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Synthetic badge block frames (no trailing separator — the frame processor
|
|
89
|
+
* joins). NEVER emitted before message_start: Anthropic's SSE contract opens
|
|
90
|
+
* every stream with message_start, and Claude Code's incremental parser
|
|
91
|
+
* hard-rejects content_block events that precede it (zero events parsed,
|
|
92
|
+
* instant retry — live incident 2026-07-21).
|
|
93
|
+
*/
|
|
94
|
+
function _badgeFrames(badgeText) {
|
|
95
|
+
return [
|
|
96
|
+
`event: content_block_start\ndata: ${JSON.stringify({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } })}`,
|
|
97
|
+
`event: content_block_delta\ndata: ${JSON.stringify({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: badgeText } })}`,
|
|
98
|
+
`event: content_block_stop\ndata: ${JSON.stringify({ type: "content_block_stop", index: 0 })}`,
|
|
99
|
+
];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _readWithIdleTimeout(reader, ms) {
|
|
103
|
+
let timer;
|
|
104
|
+
const timeout = new Promise((_, reject) => {
|
|
105
|
+
timer = setTimeout(() => reject(new Error(`upstream idle > ${ms}ms`)), ms);
|
|
106
|
+
});
|
|
107
|
+
return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Usage arrives in the FINAL message_delta event, so keep a tail buffer
|
|
112
|
+
* rather than a head capture. Best-effort — returns nulls on any miss.
|
|
113
|
+
*/
|
|
114
|
+
function _extractUsageFromTail(tail) {
|
|
115
|
+
let inputTokens = null;
|
|
116
|
+
let outputTokens = null;
|
|
117
|
+
for (const line of tail.split("\n")) {
|
|
118
|
+
if (!line.startsWith("data:")) continue;
|
|
119
|
+
try {
|
|
120
|
+
const evt = JSON.parse(line.slice(5).trim());
|
|
121
|
+
if (evt?.type === "message_start" && evt.message?.usage?.input_tokens != null) {
|
|
122
|
+
inputTokens = evt.message.usage.input_tokens;
|
|
123
|
+
}
|
|
124
|
+
if (evt?.type === "message_delta" && evt.usage) {
|
|
125
|
+
if (evt.usage.output_tokens != null) outputTokens = evt.usage.output_tokens;
|
|
126
|
+
if (evt.usage.input_tokens != null) inputTokens = evt.usage.input_tokens;
|
|
127
|
+
}
|
|
128
|
+
} catch { /* partial event in tail window */ }
|
|
129
|
+
}
|
|
130
|
+
return { inputTokens, outputTokens };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Frame-level processor for piped Anthropic SSE. Two jobs:
|
|
135
|
+
*
|
|
136
|
+
* - badge injection (all providers): the LYNKR_VISIBLE_ROUTING badge block
|
|
137
|
+
* is emitted immediately AFTER the upstream's message_start frame — never
|
|
138
|
+
* before it (protocol contract; see _badgeFrames).
|
|
139
|
+
* - ollama repairs (ollamaRepairs: true), both learned from live Claude
|
|
140
|
+
* Code 2.1.216 aborting the raw stream in <100ms:
|
|
141
|
+
* · drop thinking blocks: Ollama emits them UNSIGNED (no
|
|
142
|
+
* signature_delta), unlike api.anthropic.com, and CC rejects the
|
|
143
|
+
* stream. The buffered path solved this with
|
|
144
|
+
* _liftLeakedThinkingBlocks; streams strip instead.
|
|
145
|
+
* · normalize message_start: Ollama omits stop_reason/stop_sequence.
|
|
146
|
+
*
|
|
147
|
+
* Frames are "event: X\ndata: {...}\n\n" units; anything unparseable passes
|
|
148
|
+
* through untouched.
|
|
149
|
+
*/
|
|
150
|
+
function _createFrameProcessor({ badgeText = null, ollamaRepairs = false } = {}) {
|
|
151
|
+
let buffer = "";
|
|
152
|
+
let badgePending = !!badgeText;
|
|
153
|
+
const droppedIndices = new Set();
|
|
154
|
+
|
|
155
|
+
// Returns null (drop), a string, or an array of frames (injection).
|
|
156
|
+
const processFrame = (frame) => {
|
|
157
|
+
const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
|
|
158
|
+
if (!dataLine) return frame;
|
|
159
|
+
let evt;
|
|
160
|
+
try { evt = JSON.parse(dataLine.slice(5).trim()); } catch { return frame; }
|
|
161
|
+
|
|
162
|
+
if (evt.type === "message_start" && evt.message) {
|
|
163
|
+
let out = frame;
|
|
164
|
+
if (ollamaRepairs) {
|
|
165
|
+
if (!("stop_reason" in evt.message)) evt.message.stop_reason = null;
|
|
166
|
+
if (!("stop_sequence" in evt.message)) evt.message.stop_sequence = null;
|
|
167
|
+
out = `event: message_start\ndata: ${JSON.stringify(evt)}`;
|
|
168
|
+
}
|
|
169
|
+
if (badgePending) {
|
|
170
|
+
badgePending = false;
|
|
171
|
+
return [out, ..._badgeFrames(badgeText)];
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
if (!ollamaRepairs) return frame;
|
|
176
|
+
if (evt.type === "content_block_start" && evt.content_block?.type === "thinking") {
|
|
177
|
+
droppedIndices.add(evt.index);
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (evt.type === "content_block_delta" && droppedIndices.has(evt.index)) return null;
|
|
181
|
+
if (evt.type === "content_block_stop" && droppedIndices.has(evt.index)) {
|
|
182
|
+
droppedIndices.delete(evt.index);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
return frame;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return (chunkText, flush = false) => {
|
|
189
|
+
buffer += chunkText;
|
|
190
|
+
const frames = buffer.split("\n\n");
|
|
191
|
+
buffer = flush ? "" : (frames.pop() ?? "");
|
|
192
|
+
const out = frames.flatMap((f) => {
|
|
193
|
+
const r = processFrame(f);
|
|
194
|
+
return r === null ? [] : Array.isArray(r) ? r : [r];
|
|
195
|
+
});
|
|
196
|
+
if (flush && buffer) out.push(buffer);
|
|
197
|
+
return out.length ? out.join("\n\n") + "\n\n" : "";
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Connect to the upstream with stream:true and pipe bytes to the client.
|
|
203
|
+
* Returns true when the response was handled (success OR mid-stream error
|
|
204
|
+
* already surfaced to the client), false when the router should fall back
|
|
205
|
+
* to the buffered path (no bytes have been written).
|
|
206
|
+
*/
|
|
207
|
+
async function handleNativeStream(req, res, opts = {}) {
|
|
208
|
+
const tier = opts.tier || {};
|
|
209
|
+
const startedAt = Date.now();
|
|
210
|
+
|
|
211
|
+
const body = { ...req.body, stream: true };
|
|
212
|
+
if (tier.model && !body._tierModel) body._tierModel = tier.model;
|
|
213
|
+
|
|
214
|
+
let upstream;
|
|
215
|
+
try {
|
|
216
|
+
// Reuse the provider clients: they own the endpoint, auth headers, model
|
|
217
|
+
// override, internal-field stripping, and body repair.
|
|
218
|
+
const databricks = require("../clients/databricks");
|
|
219
|
+
const provider = _canonicalProvider(tier.provider);
|
|
220
|
+
if (provider === "zai") {
|
|
221
|
+
upstream = await databricks.invokeZai(body, req.headers);
|
|
222
|
+
} else if (provider === "ollama") {
|
|
223
|
+
// The daemon must speak the Anthropic Messages API (v0.14+); on older
|
|
224
|
+
// daemons invokeOllama takes the legacy /api/chat path, which returns
|
|
225
|
+
// its own format — fall back to the buffered converter.
|
|
226
|
+
const { hasAnthropicEndpoint } = require("../clients/ollama-utils");
|
|
227
|
+
if (!(await hasAnthropicEndpoint(config.ollama.endpoint))) {
|
|
228
|
+
logger.debug("[NativeStream] Ollama daemon lacks Anthropic API — falling back to buffered path");
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
upstream = await databricks.invokeOllama(body, req.headers);
|
|
232
|
+
} else {
|
|
233
|
+
upstream = await databricks.invokeAzureAnthropic(body, req.headers);
|
|
234
|
+
}
|
|
235
|
+
} catch (err) {
|
|
236
|
+
logger.warn({ err: err.message }, "[NativeStream] Upstream connect failed — falling back to buffered path");
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!upstream?.ok || !upstream.stream) {
|
|
241
|
+
logger.warn({ status: upstream?.status }, "[NativeStream] Upstream non-200 before first byte — falling back to buffered path");
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const contentType = upstream.contentType || "";
|
|
246
|
+
if (!contentType.includes("text/event-stream")) {
|
|
247
|
+
// Provider answered buffered JSON despite stream:true. Rare; hand the
|
|
248
|
+
// request back to the buffered path (costs a duplicate upstream call).
|
|
249
|
+
logger.warn({ contentType }, "[NativeStream] Upstream ignored stream:true — falling back to buffered path");
|
|
250
|
+
try { upstream.stream.cancel?.()?.catch?.(() => { /* already drained */ }); } catch { /* already drained */ }
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Past the point of no return: headers + bytes go to the client. ──
|
|
255
|
+
res.status(200);
|
|
256
|
+
// Never mirror upstream Content-Length — this response is chunked.
|
|
257
|
+
res.set({
|
|
258
|
+
"Content-Type": contentType,
|
|
259
|
+
"Cache-Control": "no-cache",
|
|
260
|
+
Connection: "keep-alive",
|
|
261
|
+
});
|
|
262
|
+
res.set("X-Lynkr-Provider", tier.provider || "azure-anthropic");
|
|
263
|
+
if (tier.tier) res.set("X-Lynkr-Tier", tier.tier);
|
|
264
|
+
if (tier.model || req.body?.model) res.set("X-Lynkr-Model", tier.model || req.body.model);
|
|
265
|
+
res.set("X-Lynkr-Routing-Method", "native-passthrough-stream");
|
|
266
|
+
if (typeof res.flushHeaders === "function") res.flushHeaders();
|
|
267
|
+
|
|
268
|
+
const reader = upstream.stream.getReader();
|
|
269
|
+
const decoder = new TextDecoder();
|
|
270
|
+
// Frame processing is needed when injecting the badge (which must land
|
|
271
|
+
// AFTER the upstream's message_start) and for ollama's repairs. With
|
|
272
|
+
// neither, the stream stays a pure byte pipe.
|
|
273
|
+
const _wantsBadge = !!(config.routing?.visibleInteraction && opts.badgeText);
|
|
274
|
+
const _isOllama = _canonicalProvider(tier.provider) === "ollama";
|
|
275
|
+
const frameFilter = (_wantsBadge || _isOllama)
|
|
276
|
+
? _createFrameProcessor({ badgeText: _wantsBadge ? opts.badgeText : null, ollamaRepairs: _isOllama })
|
|
277
|
+
: null;
|
|
278
|
+
let tail = ""; // last ~16KB, for the usage-bearing final events
|
|
279
|
+
let streamError = null;
|
|
280
|
+
let firstByteAt = null;
|
|
281
|
+
|
|
282
|
+
const writeWithBackpressure = async (data) => {
|
|
283
|
+
if (!data || data.length === 0) return;
|
|
284
|
+
// Honor backpressure: if the client reads slowly, wait for drain
|
|
285
|
+
// instead of buffering the upstream into memory.
|
|
286
|
+
if (!res.write(data)) {
|
|
287
|
+
await new Promise((resolve) => res.once("drain", resolve));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
while (true) {
|
|
293
|
+
const { value, done } = await _readWithIdleTimeout(reader, IDLE_TIMEOUT_MS);
|
|
294
|
+
if (done) break;
|
|
295
|
+
if (firstByteAt === null) firstByteAt = Date.now();
|
|
296
|
+
|
|
297
|
+
let outText = null;
|
|
298
|
+
if (frameFilter) {
|
|
299
|
+
outText = frameFilter(decoder.decode(value, { stream: true }));
|
|
300
|
+
await writeWithBackpressure(outText);
|
|
301
|
+
} else {
|
|
302
|
+
await writeWithBackpressure(Buffer.from(value));
|
|
303
|
+
outText = decoder.decode(value, { stream: true });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
tail += outText;
|
|
307
|
+
if (tail.length > 16384) tail = tail.slice(-16384);
|
|
308
|
+
}
|
|
309
|
+
if (frameFilter) {
|
|
310
|
+
// Flush the decoder and any partial frame held by the filter.
|
|
311
|
+
const flushed = frameFilter(decoder.decode(), true);
|
|
312
|
+
await writeWithBackpressure(flushed);
|
|
313
|
+
tail += flushed;
|
|
314
|
+
}
|
|
315
|
+
} catch (err) {
|
|
316
|
+
streamError = err;
|
|
317
|
+
logger.warn({ err: err.message }, "[NativeStream] Upstream stream failed mid-flight — surfacing SSE error");
|
|
318
|
+
// cancel() rejects ASYNCHRONOUSLY on a dead socket — a sync try/catch
|
|
319
|
+
// would leak an unhandled rejection.
|
|
320
|
+
try { reader.cancel().catch(() => { /* already dead */ }); } catch { /* already dead */ }
|
|
321
|
+
try { res.write(SSE_ERROR_EVENT(`Lynkr: upstream stream failed — retry (${err.message})`)); } catch { /* client gone */ }
|
|
322
|
+
} finally {
|
|
323
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
324
|
+
}
|
|
325
|
+
res.end();
|
|
326
|
+
|
|
327
|
+
// ── Response finalizer: telemetry runs on stream close, never blocks. ──
|
|
328
|
+
setImmediate(() => {
|
|
329
|
+
try {
|
|
330
|
+
const latencyMs = Date.now() - startedAt;
|
|
331
|
+
const ttftMs = firstByteAt ? firstByteAt - startedAt : null;
|
|
332
|
+
const { inputTokens, outputTokens } = _extractUsageFromTail(tail);
|
|
333
|
+
logger.info({
|
|
334
|
+
provider: tier.provider || "azure-anthropic",
|
|
335
|
+
ttftMs,
|
|
336
|
+
latencyMs,
|
|
337
|
+
outputTokens,
|
|
338
|
+
error: streamError ? streamError.message : null,
|
|
339
|
+
}, "[NativeStream] Stream closed");
|
|
340
|
+
|
|
341
|
+
const { getMetricsCollector } = require("../observability/metrics");
|
|
342
|
+
const mc = getMetricsCollector();
|
|
343
|
+
if (streamError) mc.recordProviderFailure(tier.provider || "azure-anthropic");
|
|
344
|
+
else mc.recordProviderSuccess(tier.provider || "azure-anthropic", latencyMs);
|
|
345
|
+
if (inputTokens || outputTokens) mc.recordTokens(inputTokens || 0, outputTokens || 0);
|
|
346
|
+
|
|
347
|
+
const telemetry = require("../routing/telemetry");
|
|
348
|
+
telemetry.record({
|
|
349
|
+
// request_id is NOT NULL in the telemetry schema — a null id silently
|
|
350
|
+
// drops the whole row.
|
|
351
|
+
request_id: req.headers["request-id"] || req.headers["x-request-id"] || require("crypto").randomUUID(),
|
|
352
|
+
session_id: req.body?._sessionId || req.sessionId || null,
|
|
353
|
+
timestamp: startedAt,
|
|
354
|
+
tier: tier.tier || null,
|
|
355
|
+
provider: tier.provider || "azure-anthropic",
|
|
356
|
+
model: tier.model || req.body?.model || null,
|
|
357
|
+
routing_method: "native-passthrough-stream",
|
|
358
|
+
status_code: streamError ? 599 : 200,
|
|
359
|
+
latency_ms: latencyMs,
|
|
360
|
+
input_tokens: inputTokens,
|
|
361
|
+
output_tokens: outputTokens,
|
|
362
|
+
message_count: req.body?.messages?.length || null,
|
|
363
|
+
tool_count: Array.isArray(req.body?.tools) ? req.body.tools.length : 0,
|
|
364
|
+
was_fallback: false,
|
|
365
|
+
error_type: streamError ? "stream_error" : null,
|
|
366
|
+
});
|
|
367
|
+
} catch (err) {
|
|
368
|
+
logger.debug({ err: err.message }, "[NativeStream] Telemetry finalizer failed (non-fatal)");
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
module.exports = {
|
|
376
|
+
canPassthrough,
|
|
377
|
+
handleNativeStream,
|
|
378
|
+
_createFrameProcessor,
|
|
379
|
+
// Exported for unit tests.
|
|
380
|
+
_formatsMatch,
|
|
381
|
+
_extractUsageFromTail,
|
|
382
|
+
};
|