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.
Files changed (61) hide show
  1. package/README.md +5 -1
  2. package/bin/cli.js +14 -6
  3. package/bin/lynkr-desktop-token.js +108 -0
  4. package/bin/lynkr-init.js +13 -1
  5. package/bin/lynkr-restart.js +125 -0
  6. package/bin/opencode-setup.js +175 -0
  7. package/bin/run.js +92 -0
  8. package/config/model-capabilities.json +20 -0
  9. package/config/model-capability-seeds.json +75 -0
  10. package/config/model-tiers.json +13 -1
  11. package/package.json +10 -4
  12. package/scripts/claude-desktop.js +212 -0
  13. package/scripts/ope-report.js +78 -0
  14. package/scripts/seed-capabilities.js +230 -0
  15. package/src/api/claude-desktop-gateway.js +123 -0
  16. package/src/api/mcp-broker.js +149 -0
  17. package/src/api/middleware/budget-enforcer.js +77 -7
  18. package/src/api/middleware/budget.js +36 -2
  19. package/src/api/openai-router.js +125 -4
  20. package/src/api/providers-handler.js +16 -0
  21. package/src/api/router.js +67 -103
  22. package/src/auth-mode.js +40 -4
  23. package/src/budget/index.js +86 -0
  24. package/src/cache/embeddings.js +125 -35
  25. package/src/cache/onnx-embedder.js +92 -0
  26. package/src/clients/databricks.js +285 -21
  27. package/src/clients/gpt-utils.js +83 -7
  28. package/src/clients/health-probe.js +157 -0
  29. package/src/clients/provider-capabilities.js +27 -0
  30. package/src/config/index.js +13 -1
  31. package/src/context/compression.js +127 -19
  32. package/src/dashboard/api.js +1 -0
  33. package/src/observability/metrics.js +52 -0
  34. package/src/observability/otel.js +180 -0
  35. package/src/orchestrator/azure-responses-sse.js +223 -0
  36. package/src/orchestrator/index.js +219 -136
  37. package/src/orchestrator/passthrough-stream.js +7 -1
  38. package/src/orchestrator/sse-transformer.js +62 -3
  39. package/src/routing/bandit.js +39 -0
  40. package/src/routing/capabilities.js +88 -0
  41. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  42. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  43. package/src/routing/capability-seeds/family.js +105 -0
  44. package/src/routing/capability-seeds/models-dev.js +64 -0
  45. package/src/routing/capability-seeds/normalize.js +160 -0
  46. package/src/routing/capability-seeds/registry.js +125 -0
  47. package/src/routing/capability-seeds/sources.js +69 -0
  48. package/src/routing/capability-seeds/swebench.js +66 -0
  49. package/src/routing/decide.js +149 -0
  50. package/src/routing/deescalator.js +37 -0
  51. package/src/routing/index.js +202 -86
  52. package/src/routing/model-registry.js +25 -0
  53. package/src/routing/model-slots.js +65 -0
  54. package/src/routing/model-tiers.js +2 -0
  55. package/src/routing/ope.js +255 -0
  56. package/src/routing/openai-model-slots.js +82 -0
  57. package/src/routing/shortfall.js +290 -0
  58. package/src/routing/stuck-detector.js +127 -0
  59. package/src/routing/telemetry.js +11 -2
  60. package/src/server.js +41 -3
  61. 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
+ };