lynkr 9.14.2 → 9.14.7
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 +5 -1
- package/bin/cli.js +14 -6
- package/bin/lynkr-desktop-token.js +108 -0
- package/bin/lynkr-init.js +13 -1
- package/bin/lynkr-restart.js +125 -0
- package/bin/opencode-setup.js +175 -0
- package/bin/run.js +92 -0
- package/config/model-capabilities.json +20 -0
- package/config/model-capability-seeds.json +75 -0
- package/config/model-tiers.json +13 -1
- package/package.json +10 -4
- package/scripts/claude-desktop.js +212 -0
- package/scripts/ope-report.js +78 -0
- package/scripts/seed-capabilities.js +230 -0
- package/src/api/claude-desktop-gateway.js +123 -0
- package/src/api/mcp-broker.js +149 -0
- package/src/api/middleware/budget-enforcer.js +77 -7
- package/src/api/middleware/budget.js +36 -2
- package/src/api/openai-router.js +125 -4
- package/src/api/providers-handler.js +16 -0
- package/src/api/router.js +67 -103
- package/src/auth-mode.js +40 -4
- package/src/budget/index.js +86 -0
- package/src/cache/embeddings.js +125 -35
- package/src/cache/onnx-embedder.js +92 -0
- package/src/clients/databricks.js +285 -21
- package/src/clients/gpt-utils.js +83 -7
- package/src/clients/health-probe.js +157 -0
- package/src/clients/provider-capabilities.js +27 -0
- package/src/config/index.js +13 -1
- package/src/context/compression.js +127 -19
- package/src/dashboard/api.js +1 -0
- package/src/observability/metrics.js +52 -0
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +219 -136
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +62 -3
- package/src/routing/bandit.js +39 -0
- package/src/routing/capabilities.js +88 -0
- package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
- package/src/routing/capability-seeds/family-heuristics.js +77 -0
- package/src/routing/capability-seeds/family.js +105 -0
- package/src/routing/capability-seeds/models-dev.js +64 -0
- package/src/routing/capability-seeds/normalize.js +160 -0
- package/src/routing/capability-seeds/registry.js +125 -0
- package/src/routing/capability-seeds/sources.js +69 -0
- package/src/routing/capability-seeds/swebench.js +66 -0
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +37 -0
- package/src/routing/index.js +202 -86
- package/src/routing/model-registry.js +25 -0
- package/src/routing/model-slots.js +65 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/ope.js +255 -0
- package/src/routing/openai-model-slots.js +82 -0
- package/src/routing/shortfall.js +290 -0
- package/src/routing/stuck-detector.js +127 -0
- package/src/routing/telemetry.js +11 -2
- package/src/server.js +41 -3
- package/src/tools/web-search-exec.js +182 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry GenAI metrics export (ROUTING-NOTES §1 gap audit).
|
|
3
|
+
*
|
|
4
|
+
* Lynkr already tracks every number the OTel GenAI semantic conventions
|
|
5
|
+
* care about — this module gives them semconv-aligned names and an export
|
|
6
|
+
* path, WITHOUT adding the @opentelemetry/* dependency tree: OTLP/HTTP is
|
|
7
|
+
* just JSON over POST, and the payload below follows the OTLP 1.x
|
|
8
|
+
* ExportMetricsServiceRequest shape that any collector accepts.
|
|
9
|
+
*
|
|
10
|
+
* Enabled when OTEL_EXPORTER_OTLP_ENDPOINT (the standard env var) or
|
|
11
|
+
* LYNKR_OTEL_ENDPOINT is set. Pushes every LYNKR_OTEL_EXPORT_INTERVAL_MS
|
|
12
|
+
* (default 60s) to `<endpoint>/v1/metrics`. Optional headers via the
|
|
13
|
+
* standard OTEL_EXPORTER_OTLP_HEADERS ("key=value,key2=value2").
|
|
14
|
+
*
|
|
15
|
+
* Emitted metrics (GenAI semconv where one exists, lynkr.* where not):
|
|
16
|
+
* gen_ai.client.token.usage sum, attr gen_ai.token.type=input|output
|
|
17
|
+
* gen_ai.client.operation.duration gauge (p50/p95/p99, unit ms)
|
|
18
|
+
* lynkr.cost.usd sum
|
|
19
|
+
* lynkr.requests sum (+ lynkr.request.errors)
|
|
20
|
+
*
|
|
21
|
+
* @module observability/otel
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const logger = require('../logger');
|
|
25
|
+
const { getMetricsCollector } = require('./metrics');
|
|
26
|
+
|
|
27
|
+
const EXPORT_INTERVAL_MS = Number.parseInt(process.env.LYNKR_OTEL_EXPORT_INTERVAL_MS, 10) || 60_000;
|
|
28
|
+
|
|
29
|
+
function _endpoint() {
|
|
30
|
+
const raw = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || process.env.LYNKR_OTEL_ENDPOINT || null;
|
|
31
|
+
if (!raw) return null;
|
|
32
|
+
return raw.replace(/\/+$/, '');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _headers() {
|
|
36
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
37
|
+
const raw = process.env.OTEL_EXPORTER_OTLP_HEADERS;
|
|
38
|
+
if (raw) {
|
|
39
|
+
for (const pair of raw.split(',')) {
|
|
40
|
+
const idx = pair.indexOf('=');
|
|
41
|
+
if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return headers;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _sumPoint(value, attributes, startNanos, nowNanos) {
|
|
48
|
+
return {
|
|
49
|
+
attributes,
|
|
50
|
+
startTimeUnixNano: startNanos,
|
|
51
|
+
timeUnixNano: nowNanos,
|
|
52
|
+
asDouble: value,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build an OTLP/HTTP ExportMetricsServiceRequest from the collector's
|
|
58
|
+
* current snapshot. Exposed for tests.
|
|
59
|
+
*/
|
|
60
|
+
function buildOtlpPayload(metrics, { startTimeMs, nowMs }) {
|
|
61
|
+
const startNanos = String(startTimeMs * 1e6);
|
|
62
|
+
const nowNanos = String(nowMs * 1e6);
|
|
63
|
+
const attr = (key, value) => ({ key, value: { stringValue: value } });
|
|
64
|
+
|
|
65
|
+
const sums = (name, unit, points) => ({
|
|
66
|
+
name,
|
|
67
|
+
unit,
|
|
68
|
+
sum: {
|
|
69
|
+
aggregationTemporality: 2, // CUMULATIVE
|
|
70
|
+
isMonotonic: true,
|
|
71
|
+
dataPoints: points,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const gauge = (name, unit, points) => ({ name, unit, gauge: { dataPoints: points } });
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
resourceMetrics: [{
|
|
79
|
+
resource: {
|
|
80
|
+
attributes: [
|
|
81
|
+
attr('service.name', 'lynkr'),
|
|
82
|
+
attr('service.version', process.env.npm_package_version || 'unknown'),
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
scopeMetrics: [{
|
|
86
|
+
scope: { name: 'lynkr.observability', version: '1' },
|
|
87
|
+
metrics: [
|
|
88
|
+
sums('gen_ai.client.token.usage', '{token}', [
|
|
89
|
+
_sumPoint(metrics.tokens_input_total, [attr('gen_ai.token.type', 'input')], startNanos, nowNanos),
|
|
90
|
+
_sumPoint(metrics.tokens_output_total, [attr('gen_ai.token.type', 'output')], startNanos, nowNanos),
|
|
91
|
+
]),
|
|
92
|
+
gauge('gen_ai.client.operation.duration', 'ms', [
|
|
93
|
+
{ attributes: [attr('quantile', '0.5')], timeUnixNano: nowNanos, asDouble: metrics.latency_ms.median },
|
|
94
|
+
{ attributes: [attr('quantile', '0.95')], timeUnixNano: nowNanos, asDouble: metrics.latency_ms.p95 },
|
|
95
|
+
{ attributes: [attr('quantile', '0.99')], timeUnixNano: nowNanos, asDouble: metrics.latency_ms.p99 },
|
|
96
|
+
]),
|
|
97
|
+
sums('lynkr.cost.usd', 'usd', [
|
|
98
|
+
_sumPoint(metrics.cost_usd_total, [], startNanos, nowNanos),
|
|
99
|
+
]),
|
|
100
|
+
sums('lynkr.requests', '{request}', [
|
|
101
|
+
_sumPoint(metrics.requests_total, [], startNanos, nowNanos),
|
|
102
|
+
]),
|
|
103
|
+
sums('lynkr.request.errors', '{request}', [
|
|
104
|
+
_sumPoint(metrics.requests_errors_total, [], startNanos, nowNanos),
|
|
105
|
+
]),
|
|
106
|
+
],
|
|
107
|
+
}],
|
|
108
|
+
}],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class OtelExporter {
|
|
113
|
+
constructor() {
|
|
114
|
+
this.timer = null;
|
|
115
|
+
this.startTimeMs = Date.now();
|
|
116
|
+
this.stats = { exports: 0, failures: 0, lastExportAt: null };
|
|
117
|
+
this._warnedOnce = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
start() {
|
|
121
|
+
const endpoint = _endpoint();
|
|
122
|
+
if (!endpoint || this.timer) return;
|
|
123
|
+
this.timer = setInterval(() => {
|
|
124
|
+
this.exportOnce().catch(() => {});
|
|
125
|
+
}, EXPORT_INTERVAL_MS);
|
|
126
|
+
this.timer.unref?.();
|
|
127
|
+
logger.info({ endpoint, intervalMs: EXPORT_INTERVAL_MS }, '[Otel] GenAI metrics export started');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stop() {
|
|
131
|
+
if (this.timer) {
|
|
132
|
+
clearInterval(this.timer);
|
|
133
|
+
this.timer = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async exportOnce() {
|
|
138
|
+
const endpoint = _endpoint();
|
|
139
|
+
if (!endpoint) return false;
|
|
140
|
+
const metrics = getMetricsCollector().getMetrics();
|
|
141
|
+
const payload = buildOtlpPayload(metrics, { startTimeMs: this.startTimeMs, nowMs: Date.now() });
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetch(`${endpoint}/v1/metrics`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: _headers(),
|
|
146
|
+
body: JSON.stringify(payload),
|
|
147
|
+
signal: AbortSignal.timeout(10_000),
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) throw new Error(`OTLP export → ${response.status}`);
|
|
150
|
+
await response.arrayBuffer().catch(() => {});
|
|
151
|
+
this.stats.exports += 1;
|
|
152
|
+
this.stats.lastExportAt = Date.now();
|
|
153
|
+
this._warnedOnce = false;
|
|
154
|
+
return true;
|
|
155
|
+
} catch (err) {
|
|
156
|
+
this.stats.failures += 1;
|
|
157
|
+
// Warn once per failure streak, then stay quiet — a down collector
|
|
158
|
+
// must not spam logs at every interval.
|
|
159
|
+
if (!this._warnedOnce) {
|
|
160
|
+
this._warnedOnce = true;
|
|
161
|
+
logger.warn({ endpoint, error: err.message }, '[Otel] metrics export failing (will keep retrying quietly)');
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
getStatus() {
|
|
168
|
+
return { enabled: !!_endpoint(), running: !!this.timer, intervalMs: EXPORT_INTERVAL_MS, ...this.stats };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let exporter = null;
|
|
173
|
+
|
|
174
|
+
/** @returns {OtelExporter} singleton */
|
|
175
|
+
function getOtelExporter() {
|
|
176
|
+
if (!exporter) exporter = new OtelExporter();
|
|
177
|
+
return exporter;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { getOtelExporter, buildOtlpPayload };
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Azure OpenAI Responses API — streaming adapter.
|
|
3
|
+
*
|
|
4
|
+
* The Responses API's streaming event vocabulary (response.created,
|
|
5
|
+
* response.output_item.added, response.content_part.added,
|
|
6
|
+
* response.output_text.delta/.done, response.function_call_arguments.delta/
|
|
7
|
+
* .done, response.output_item.done, response.completed) is structurally
|
|
8
|
+
* different from OpenAI Chat Completions' generic `choices[0].delta` chunk
|
|
9
|
+
* shape, which is what sse-transformer.js's `_openaiToAnthropicEvents`
|
|
10
|
+
* understands.
|
|
11
|
+
*
|
|
12
|
+
* Rather than duplicate that function's Anthropic-SSE emission logic (badge
|
|
13
|
+
* injection, message_start/message_stop, the mid-stream-error and
|
|
14
|
+
* no-finish-reason safety branches, usage extraction) for a second wire
|
|
15
|
+
* format, this module translates Responses-API events into SYNTHETIC OpenAI
|
|
16
|
+
* Chat-Completions SSE TEXT (`data: {"choices":[...]}\n\n`) and hands that
|
|
17
|
+
* off unchanged to the existing, already-tested transformer. Net effect:
|
|
18
|
+
* `invokeAzureOpenAI` returns a stream from this module exactly the way
|
|
19
|
+
* invokeMoonshot/invokeBaidu/invokeOpenAI already return a raw upstream
|
|
20
|
+
* stream — the orchestrator's existing `sseTransform.openaiToAnthropicSSE`
|
|
21
|
+
* call site needs zero changes to consume it.
|
|
22
|
+
*
|
|
23
|
+
* Verified against two live captures from this deployment's actual Azure
|
|
24
|
+
* endpoint (2026-08-26, api-version=2025-04-01-preview, model gpt-5.6-sol):
|
|
25
|
+
* one plain-text response, one forced tool-call response. Re-verify against
|
|
26
|
+
* a live capture if Azure's preview API version changes this shape.
|
|
27
|
+
*
|
|
28
|
+
* Tool-call dedup: the buffered Responses-API conversion in databricks.js
|
|
29
|
+
* (invokeAzureOpenAI) filters out exact duplicate {name, arguments}
|
|
30
|
+
* function-call signatures within one response (an observed GPT-5.x quirk).
|
|
31
|
+
* To preserve that guarantee here without needing the FULL response in hand
|
|
32
|
+
* first, this module buffers a function_call's argument fragments locally
|
|
33
|
+
* (never emitting them) until that call's response.function_call_arguments.done
|
|
34
|
+
* fires, checks the signature, and only then emits the accumulated deltas —
|
|
35
|
+
* as one burst — or silently drops them if it's a repeat. This means
|
|
36
|
+
* function-call arguments have a small, per-call delay (between that call's
|
|
37
|
+
* first and last argument fragment) while text streams fully live with zero
|
|
38
|
+
* delay — a deliberate tradeoff to keep the existing dedup guarantee, not an
|
|
39
|
+
* oversight.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const logger = require("../logger");
|
|
43
|
+
const { _sseDataLines } = require("./sse-transformer");
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {ReadableStream|AsyncIterable} rawResponsesStream - raw Azure
|
|
47
|
+
* Responses API SSE byte stream (stream:true was set on the request).
|
|
48
|
+
* @param {Object} [opts]
|
|
49
|
+
* @param {string} [opts.model] - fallback model id for synthesized chunks.
|
|
50
|
+
* @returns {ReadableStream} synthetic OpenAI Chat-Completions SSE byte stream.
|
|
51
|
+
*/
|
|
52
|
+
function azureResponsesToOpenAIChunks(rawResponsesStream, opts = {}) {
|
|
53
|
+
const generator = _toOpenAIChunkText(rawResponsesStream, opts);
|
|
54
|
+
const encoder = new TextEncoder();
|
|
55
|
+
return new ReadableStream({
|
|
56
|
+
async pull(controller) {
|
|
57
|
+
try {
|
|
58
|
+
const { value, done } = await generator.next();
|
|
59
|
+
if (done) {
|
|
60
|
+
controller.close();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
controller.enqueue(encoder.encode(value));
|
|
64
|
+
} catch (err) {
|
|
65
|
+
controller.error(err);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async cancel() {
|
|
69
|
+
try { await generator.return(); } catch { /* already finished */ }
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function* _toOpenAIChunkText(rawResponsesStream, opts) {
|
|
75
|
+
const id = `chatcmpl-azresp-${Date.now()}`;
|
|
76
|
+
const created = Math.floor(Date.now() / 1000);
|
|
77
|
+
const model = opts.model || "gpt-5.6-sol";
|
|
78
|
+
|
|
79
|
+
const chunk = (delta, extra = {}) => `data: ${JSON.stringify({
|
|
80
|
+
id, object: "chat.completion.chunk", created, model,
|
|
81
|
+
choices: [{ index: 0, delta, finish_reason: null }],
|
|
82
|
+
...extra,
|
|
83
|
+
})}\n\n`;
|
|
84
|
+
|
|
85
|
+
const finishChunk = (finishReason, usage) => `data: ${JSON.stringify({
|
|
86
|
+
id, object: "chat.completion.chunk", created, model,
|
|
87
|
+
choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
|
|
88
|
+
...(usage ? { usage } : {}),
|
|
89
|
+
})}\n\n`;
|
|
90
|
+
|
|
91
|
+
// output_index -> {kind: "message"|"function_call", openaiToolIndex,
|
|
92
|
+
// name, callId, argBuf: string}. argBuf only used for function_call.
|
|
93
|
+
const items = new Map();
|
|
94
|
+
let nextToolIndex = 0;
|
|
95
|
+
const seenToolSignatures = new Set();
|
|
96
|
+
let sawFunctionCall = false;
|
|
97
|
+
let sawAnyOutput = false;
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
for await (const payload of _sseDataLines(rawResponsesStream)) {
|
|
101
|
+
if (payload === "[DONE]") break;
|
|
102
|
+
let ev;
|
|
103
|
+
try { ev = JSON.parse(payload); } catch { continue; }
|
|
104
|
+
|
|
105
|
+
switch (ev.type) {
|
|
106
|
+
case "response.output_item.added": {
|
|
107
|
+
const item = ev.item || {};
|
|
108
|
+
if (item.type === "function_call") {
|
|
109
|
+
items.set(ev.output_index, {
|
|
110
|
+
kind: "function_call",
|
|
111
|
+
openaiToolIndex: nextToolIndex++,
|
|
112
|
+
name: item.name || "",
|
|
113
|
+
callId: item.call_id || item.id || "",
|
|
114
|
+
argBuf: "",
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
items.set(ev.output_index, { kind: "message" });
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "response.output_text.delta": {
|
|
123
|
+
sawAnyOutput = true;
|
|
124
|
+
if (ev.delta) yield chunk({ content: ev.delta });
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "response.function_call_arguments.delta": {
|
|
129
|
+
const state = items.get(ev.output_index);
|
|
130
|
+
if (state && state.kind === "function_call") {
|
|
131
|
+
state.argBuf += ev.delta || "";
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "response.function_call_arguments.done": {
|
|
137
|
+
const state = items.get(ev.output_index);
|
|
138
|
+
if (!state || state.kind !== "function_call") break;
|
|
139
|
+
sawAnyOutput = true;
|
|
140
|
+
sawFunctionCall = true;
|
|
141
|
+
const finalArgs = typeof ev.arguments === "string" ? ev.arguments : state.argBuf;
|
|
142
|
+
const signature = `${state.name}:${finalArgs}`;
|
|
143
|
+
if (seenToolSignatures.has(signature)) {
|
|
144
|
+
logger.warn({
|
|
145
|
+
name: state.name,
|
|
146
|
+
argsPreview: finalArgs.slice(0, 120),
|
|
147
|
+
}, "[AzureResponsesSSE] Filtered duplicate streamed tool call");
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
seenToolSignatures.add(signature);
|
|
151
|
+
// First delta for this tool carries id/name (empty arguments);
|
|
152
|
+
// second carries the complete arguments as one fragment — matches
|
|
153
|
+
// real OpenAI streaming shape, which sse-transformer.js's toolAcc
|
|
154
|
+
// merge logic already expects (id/name first-non-null-wins, args
|
|
155
|
+
// concatenated across fragments).
|
|
156
|
+
yield chunk({
|
|
157
|
+
tool_calls: [{
|
|
158
|
+
index: state.openaiToolIndex,
|
|
159
|
+
id: state.callId,
|
|
160
|
+
type: "function",
|
|
161
|
+
function: { name: state.name, arguments: "" },
|
|
162
|
+
}],
|
|
163
|
+
});
|
|
164
|
+
yield chunk({
|
|
165
|
+
tool_calls: [{
|
|
166
|
+
index: state.openaiToolIndex,
|
|
167
|
+
function: { arguments: finalArgs },
|
|
168
|
+
}],
|
|
169
|
+
});
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case "response.completed": {
|
|
174
|
+
const usageRaw = ev.response?.usage;
|
|
175
|
+
const usage = usageRaw ? {
|
|
176
|
+
prompt_tokens: usageRaw.input_tokens ?? 0,
|
|
177
|
+
completion_tokens: usageRaw.output_tokens ?? 0,
|
|
178
|
+
total_tokens: usageRaw.total_tokens
|
|
179
|
+
?? ((usageRaw.input_tokens ?? 0) + (usageRaw.output_tokens ?? 0)),
|
|
180
|
+
prompt_tokens_details: {
|
|
181
|
+
cached_tokens: usageRaw.input_tokens_details?.cached_tokens ?? 0,
|
|
182
|
+
},
|
|
183
|
+
} : undefined;
|
|
184
|
+
const finishReason = sawFunctionCall ? "tool_calls" : "stop";
|
|
185
|
+
yield finishChunk(finishReason, usage);
|
|
186
|
+
yield "data: [DONE]\n\n";
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case "response.failed":
|
|
191
|
+
case "response.incomplete": {
|
|
192
|
+
logger.warn({
|
|
193
|
+
type: ev.type,
|
|
194
|
+
error: ev.response?.error || ev.error || null,
|
|
195
|
+
}, "[AzureResponsesSSE] Upstream reported failure mid-stream");
|
|
196
|
+
// No finish_reason chunk, no [DONE] — matches sse-transformer.js's
|
|
197
|
+
// own no-finish-reason safety branch (its EOF-without-finish_reason
|
|
198
|
+
// case), which synthesizes an Anthropic error event rather than a
|
|
199
|
+
// false-success message_stop when downstream sees the SSE end here.
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
default:
|
|
204
|
+
break; // response.created/.in_progress, content_part.*, output_item.done — no chunk needed
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
logger.warn({ err: err.message }, "[AzureResponsesSSE] Upstream stream failed mid-flight");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Stream ended without response.completed and without sawAnyOutput ever
|
|
213
|
+
// firing a finish — same "don't fake success" discipline as above.
|
|
214
|
+
if (!sawAnyOutput) {
|
|
215
|
+
logger.warn("[AzureResponsesSSE] Upstream stream ended with no output and no response.completed");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = {
|
|
220
|
+
azureResponsesToOpenAIChunks,
|
|
221
|
+
// Exported for unit tests.
|
|
222
|
+
_toOpenAIChunkText,
|
|
223
|
+
};
|