lynkr 9.14.1 → 9.14.3

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 (96) 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 +16 -22
  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/health.js +0 -1
  17. package/src/api/mcp-broker.js +149 -0
  18. package/src/api/middleware/budget-enforcer.js +77 -7
  19. package/src/api/middleware/budget.js +36 -2
  20. package/src/api/middleware/session.js +0 -0
  21. package/src/api/openai-router.js +210 -15
  22. package/src/api/providers-handler.js +16 -1
  23. package/src/api/router.js +73 -106
  24. package/src/auth-mode.js +40 -4
  25. package/src/budget/index.js +86 -0
  26. package/src/cache/embeddings.js +125 -35
  27. package/src/cache/onnx-embedder.js +92 -0
  28. package/src/cache/prompt.js +0 -7
  29. package/src/clients/bedrock-utils.js +2 -1
  30. package/src/clients/codex-process.js +1 -1
  31. package/src/clients/codex-utils.js +0 -2
  32. package/src/clients/databricks.js +337 -46
  33. package/src/clients/gpt-utils.js +84 -8
  34. package/src/clients/health-probe.js +157 -0
  35. package/src/clients/openai-format.js +73 -23
  36. package/src/clients/provider-capabilities.js +27 -2
  37. package/src/clients/resilience.js +0 -1
  38. package/src/clients/responses-format.js +0 -56
  39. package/src/clients/routing.js +1 -1
  40. package/src/config/index.js +13 -2
  41. package/src/context/budget.js +1 -1
  42. package/src/context/compression.js +127 -19
  43. package/src/context/tool-result-compressor.js +3 -5
  44. package/src/dashboard/api.js +1 -1
  45. package/src/logger/oversized-error-stream.js +1 -1
  46. package/src/memory/extractor.js +3 -3
  47. package/src/memory/search.js +2 -2
  48. package/src/memory/store.js +9 -24
  49. package/src/memory/tools.js +14 -0
  50. package/src/observability/metrics.js +52 -3
  51. package/src/observability/otel.js +180 -0
  52. package/src/orchestrator/azure-responses-sse.js +223 -0
  53. package/src/orchestrator/index.js +216 -212
  54. package/src/orchestrator/passthrough-stream.js +7 -1
  55. package/src/orchestrator/sse-transformer.js +130 -13
  56. package/src/policy/safe-commands.js +0 -1
  57. package/src/routing/bandit.js +39 -0
  58. package/src/routing/calibration.js +21 -1
  59. package/src/routing/capabilities.js +88 -0
  60. package/src/routing/capability-seeds/benchmarks-dir.js +58 -0
  61. package/src/routing/capability-seeds/family-heuristics.js +77 -0
  62. package/src/routing/capability-seeds/family.js +105 -0
  63. package/src/routing/capability-seeds/models-dev.js +64 -0
  64. package/src/routing/capability-seeds/normalize.js +160 -0
  65. package/src/routing/capability-seeds/registry.js +125 -0
  66. package/src/routing/capability-seeds/sources.js +69 -0
  67. package/src/routing/capability-seeds/swebench.js +66 -0
  68. package/src/routing/client-profiles.js +26 -0
  69. package/src/routing/complexity-analyzer.js +5 -8
  70. package/src/routing/confidence-scorer.js +2 -2
  71. package/src/routing/cost-optimizer.js +1 -2
  72. package/src/routing/decide.js +149 -0
  73. package/src/routing/deescalator.js +38 -1
  74. package/src/routing/embedding-cache.js +0 -1
  75. package/src/routing/index.js +202 -86
  76. package/src/routing/intent-score.js +0 -1
  77. package/src/routing/knn-router.js +27 -5
  78. package/src/routing/latency-tracker.js +1 -1
  79. package/src/routing/model-registry.js +25 -0
  80. package/src/routing/model-slots.js +65 -0
  81. package/src/routing/model-tiers.js +2 -0
  82. package/src/routing/ope.js +255 -0
  83. package/src/routing/openai-model-slots.js +82 -0
  84. package/src/routing/regret-estimator.js +1 -1
  85. package/src/routing/shortfall.js +290 -0
  86. package/src/routing/stuck-detector.js +127 -0
  87. package/src/routing/telemetry.js +11 -2
  88. package/src/server.js +41 -3
  89. package/src/stores/file-store.js +5 -1
  90. package/src/tools/web-search-exec.js +182 -0
  91. package/src/training/trajectory-compressor.js +1 -1
  92. package/src/workers/pool.js +1 -1
  93. package/src/workers/worker.js +1 -1
  94. package/scripts/convert-routellm.py +0 -105
  95. package/src/diff/comments.js +0 -153
  96. package/src/policy/web-fallback.js +0 -33
@@ -25,7 +25,7 @@ function stringSimilarity(s1, s2) {
25
25
  // Tokenize by whitespace and common delimiters
26
26
  const tokenize = (s) => new Set(
27
27
  s.toLowerCase()
28
- .split(/[\s\-_\/\.\,\:\;]+/)
28
+ .split(/[\s\-_/.,:;]+/)
29
29
  .filter(t => t.length > 0)
30
30
  );
31
31
 
@@ -38,6 +38,45 @@ function stringSimilarity(s1, s2) {
38
38
  return union.size > 0 ? intersection.size / union.size : 0;
39
39
  }
40
40
 
41
+ // Common argument keys a Read-style tool might carry its target path under,
42
+ // across different clients/harnesses.
43
+ const FILE_PATH_ARG_KEYS = ['file_path', 'path', 'filePath', 'filename', 'file'];
44
+
45
+ /**
46
+ * Pull a file-path argument out of a (possibly stringified) args object,
47
+ * trying the common key spellings different clients use.
48
+ * @param {string|Object} args
49
+ * @returns {string|null}
50
+ */
51
+ function extractFilePathArg(args) {
52
+ let obj = args;
53
+ if (typeof obj === 'string') {
54
+ try { obj = JSON.parse(obj); } catch { return null; }
55
+ }
56
+ if (!obj || typeof obj !== 'object') return null;
57
+ for (const key of FILE_PATH_ARG_KEYS) {
58
+ if (typeof obj[key] === 'string' && obj[key]) return obj[key];
59
+ }
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Extract a read window [start, end) from read-tool args (offset/limit in
65
+ * lines, the convention Claude Code, opencode, and Cursor all share).
66
+ * Missing offset → 0; missing limit → open-ended (whole rest of the file).
67
+ * @param {string|Object} args
68
+ * @returns {{ start: number, end: number }}
69
+ */
70
+ function extractReadWindow(args) {
71
+ let obj = args;
72
+ if (typeof obj === 'string') {
73
+ try { obj = JSON.parse(obj); } catch { obj = null; }
74
+ }
75
+ const start = Number.isFinite(obj?.offset) ? obj.offset : 0;
76
+ const limit = Number.isFinite(obj?.limit) && obj.limit > 0 ? obj.limit : Infinity;
77
+ return { start, end: start + limit };
78
+ }
79
+
41
80
  /**
42
81
  * Check if two tool calls are semantically similar
43
82
  * @param {Object} call1 - First tool call {name, arguments}
@@ -59,17 +98,53 @@ function areSimilarToolCalls(call1, call2) {
59
98
 
60
99
  if (argsStr1 === argsStr2) return true;
61
100
 
101
+ const toolName = (name1 || '').toLowerCase();
102
+
103
+ // Read-style tools: NOT Jaccard-fuzzy-matched (see below for why), and NOT
104
+ // matched on path alone either. This rule has flip-flopped through two live
105
+ // incidents, so the history matters:
106
+ //
107
+ // 1. Jaccard on paths merged reads of DIFFERENT files (repo paths share
108
+ // nearly every token) → fixed by exact path equality.
109
+ // 2. Exact-args-only let an agent re-read one file at OVERLAPPING offsets
110
+ // ~10 times, restating the same conclusion, without ever tripping the
111
+ // guard → fixed by counting any same-path read as similar.
112
+ // 3. That overcorrected: paging through DISJOINT regions of one large
113
+ // file is exactly how a coding agent works on it (live incident: a
114
+ // 4,000-line settings.rs read at offsets 975/3485/3800 tripped the
115
+ // warn threshold, and the injected "STOP calling this tool" order
116
+ // killed the work turn — repeatedly).
117
+ //
118
+ // The rule that satisfies all three: same file + OVERLAPPING (or identical)
119
+ // windows → similar (re-reading content you already have); same file +
120
+ // DISJOINT windows → paging, not a loop. Whole-file reads (no offset/limit)
121
+ // have an open-ended window, so they overlap everything in that file —
122
+ // repeated whole-file re-reads still count, incident #2 stays fixed.
123
+ if (toolName.includes('read')) {
124
+ const path1 = extractFilePathArg(args1);
125
+ const path2 = extractFilePathArg(args2);
126
+ if (path1 && path2 && path1 === path2) {
127
+ const w1 = extractReadWindow(args1);
128
+ const w2 = extractReadWindow(args2);
129
+ const overlaps = w1.start < w2.end && w2.start < w1.end;
130
+ if (overlaps) {
131
+ logger.debug({ tool: name1, path: path1, w1, w2 }, "Same-file overlapping re-read detected");
132
+ return true;
133
+ }
134
+ return false;
135
+ }
136
+ return false;
137
+ }
138
+
62
139
  // Only search-style tools get fuzzy matching; mutating tools with
63
140
  // near-identical args may be intentional repeats.
64
- // 'read' is deliberately NOT fuzzy-matched: its argument is a file path, and
65
- // absolute paths in one repo share nearly every Jaccard token
66
- // (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8 and
67
- // merged into one "repeated call" signature (live incident: an opencode
141
+ // 'read' is handled above, deliberately NOT via Jaccard: its argument is a
142
+ // file path, and absolute paths in one repo share nearly every Jaccard
143
+ // token (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8
144
+ // and merged into one "repeated call" signature (live incident: an opencode
68
145
  // code-trace reading server.js, openai-router.js and orchestrator/index.js
69
- // was flagged as a loop). A genuine re-read of the same file is caught by
70
- // the exact-match branch above.
146
+ // was flagged as a loop).
71
147
  const searchTools = ['grep', 'glob', 'search', 'find', 'bash', 'shell'];
72
- const toolName = (name1 || '').toLowerCase();
73
148
  const isSearchTool = searchTools.some(t => toolName.includes(t));
74
149
 
75
150
  if (isSearchTool) {
@@ -91,4 +166,5 @@ function areSimilarToolCalls(call1, call2) {
91
166
 
92
167
  module.exports = {
93
168
  areSimilarToolCalls,
169
+ extractReadWindow,
94
170
  };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Synthetic circuit-breaker health probe (ROUTING-NOTES §1 gap audit).
3
+ *
4
+ * Problem: cockatiel's halfOpenAfter is a pure timer — after it elapses, the
5
+ * NEXT LIVE USER REQUEST is the recovery probe. A user pays the latency/cost
6
+ * of testing a dead provider, and a provider with no traffic never recovers.
7
+ *
8
+ * Fix (the pattern LiteLLM ships as health-check-driven routing): a
9
+ * background loop that, for every breaker currently OPEN or HALF_OPEN, runs
10
+ * a cheap synthetic request *through the breaker* (`breaker.execute(probe)`)
11
+ * on a schedule. A success closes the circuit via cockatiel's normal
12
+ * half-open transition; a failure re-opens it. Healthy breakers are never
13
+ * probed — the loop is zero-cost when everything is up.
14
+ *
15
+ * Probes are registered per provider name (the breaker registry key).
16
+ * Built-in probes cover the local providers (ollama / llamacpp / lmstudio),
17
+ * whose cheap GET endpoints are well-known, free, and exactly where the
18
+ * dead-upstream-hang problem historically lived. Cloud providers can be
19
+ * added via registerHealthProbe(name, fn); without one, their recovery
20
+ * stays as before (next live request) — no behavior regression.
21
+ */
22
+
23
+ const config = require('../config');
24
+ const logger = require('../logger');
25
+ const { getCockatielRegistry } = require('./resilience');
26
+
27
+ const PROBE_INTERVAL_MS = Number.parseInt(process.env.LYNKR_HEALTH_PROBE_INTERVAL_MS, 10) || 30_000;
28
+ const PROBE_TIMEOUT_MS = Number.parseInt(process.env.LYNKR_HEALTH_PROBE_TIMEOUT_MS, 10) || 5_000;
29
+ const ENABLED = process.env.LYNKR_HEALTH_PROBE_ENABLED !== 'false';
30
+
31
+ /** @type {Map<string, () => Promise<void>>} providerName → probe fn (throws on unhealthy) */
32
+ const probes = new Map();
33
+
34
+ async function _cheapGet(url, headers = {}) {
35
+ const response = await fetch(url, {
36
+ method: 'GET',
37
+ headers,
38
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
39
+ });
40
+ if (!response.ok) {
41
+ throw new Error(`health probe ${url} → ${response.status}`);
42
+ }
43
+ // Drain the (small) body so the socket is released cleanly.
44
+ await response.arrayBuffer().catch(() => {});
45
+ }
46
+
47
+ function _registerBuiltins() {
48
+ if (config.ollama?.endpoint) {
49
+ probes.set('ollama', () => _cheapGet(`${config.ollama.endpoint}/api/tags`));
50
+ }
51
+ if (config.llamacpp?.endpoint) {
52
+ const headers = config.llamacpp.apiKey
53
+ ? { Authorization: `Bearer ${config.llamacpp.apiKey}` }
54
+ : {};
55
+ probes.set('llamacpp', () => _cheapGet(`${config.llamacpp.endpoint}/v1/models`, headers));
56
+ }
57
+ if (config.lmstudio?.endpoint) {
58
+ const headers = config.lmstudio.apiKey
59
+ ? { Authorization: `Bearer ${config.lmstudio.apiKey}` }
60
+ : {};
61
+ probes.set('lmstudio', () => _cheapGet(`${config.lmstudio.endpoint}/v1/models`, headers));
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Register (or override) a synthetic probe for a provider. The function must
67
+ * resolve when the provider is healthy and throw/reject when it is not.
68
+ * @param {string} providerName — must match the circuit breaker registry key
69
+ * @param {() => Promise<void>} probeFn
70
+ */
71
+ function registerHealthProbe(providerName, probeFn) {
72
+ probes.set(providerName, probeFn);
73
+ }
74
+
75
+ class HealthProber {
76
+ constructor() {
77
+ this.timer = null;
78
+ this.stats = { sweeps: 0, probesSent: 0, recoveries: 0, failures: 0, lastSweepAt: null };
79
+ }
80
+
81
+ start() {
82
+ if (this.timer || !ENABLED) return;
83
+ _registerBuiltins();
84
+ this.timer = setInterval(() => {
85
+ this.sweep().catch((err) => {
86
+ logger.debug({ error: err.message }, '[HealthProbe] sweep error');
87
+ });
88
+ }, PROBE_INTERVAL_MS);
89
+ // Never keep the process alive just to probe.
90
+ this.timer.unref?.();
91
+ logger.info(
92
+ { intervalMs: PROBE_INTERVAL_MS, providers: [...probes.keys()] },
93
+ '[HealthProbe] Synthetic circuit-breaker probing started'
94
+ );
95
+ }
96
+
97
+ stop() {
98
+ if (this.timer) {
99
+ clearInterval(this.timer);
100
+ this.timer = null;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * One probing pass: probe every registered provider whose breaker is not
106
+ * closed. Runs probes through the breaker itself so state transitions use
107
+ * cockatiel's own half-open machinery.
108
+ */
109
+ async sweep() {
110
+ this.stats.sweeps += 1;
111
+ this.stats.lastSweepAt = Date.now();
112
+ const registry = getCockatielRegistry();
113
+ const work = [];
114
+ for (const [providerName, probeFn] of probes) {
115
+ const breaker = registry.breakers.get(providerName);
116
+ if (!breaker || breaker.state === 'CLOSED') continue;
117
+ work.push(
118
+ (async () => {
119
+ this.stats.probesSent += 1;
120
+ try {
121
+ await breaker.execute(probeFn);
122
+ this.stats.recoveries += 1;
123
+ logger.info({ provider: providerName }, '[HealthProbe] Provider recovered via synthetic probe — circuit closed without a live request paying for the test');
124
+ } catch (err) {
125
+ // Expected while the provider is still down (or the circuit is
126
+ // OPEN and not yet past halfOpenAfter, where execute rejects
127
+ // immediately without dialing). Stay quiet; the breaker's own
128
+ // state logging covers transitions.
129
+ this.stats.failures += 1;
130
+ logger.debug({ provider: providerName, error: err.message }, '[HealthProbe] probe failed');
131
+ }
132
+ })()
133
+ );
134
+ }
135
+ await Promise.all(work);
136
+ }
137
+
138
+ getStatus() {
139
+ return {
140
+ enabled: ENABLED,
141
+ running: !!this.timer,
142
+ intervalMs: PROBE_INTERVAL_MS,
143
+ registeredProviders: [...probes.keys()],
144
+ ...this.stats,
145
+ };
146
+ }
147
+ }
148
+
149
+ let prober = null;
150
+
151
+ /** @returns {HealthProber} singleton */
152
+ function getHealthProber() {
153
+ if (!prober) prober = new HealthProber();
154
+ return prober;
155
+ }
156
+
157
+ module.exports = { getHealthProber, registerHealthProbe };
@@ -113,21 +113,48 @@ function convertOpenAIToAnthropic(openaiRequest) {
113
113
  });
114
114
  }
115
115
  } else if (msg.role === "tool") {
116
- // OpenAI tool response → Anthropic tool_result
117
- const previousMsg = anthropicMessages[anthropicMessages.length - 1];
118
-
119
- // Tool results must follow assistant message with tool_use
120
- // Add as separate user message with tool_result
121
- anthropicMessages.push({
122
- role: "user",
123
- content: [
124
- {
125
- type: "tool_result",
126
- tool_use_id: msg.tool_call_id,
127
- content: msg.content
128
- }
129
- ]
130
- });
116
+ // OpenAI tool response → Anthropic tool_result. Two Anthropic rules
117
+ // apply (violating either 400s the request):
118
+ // 1. Every tool_use_id must match a tool_use block in the preceding
119
+ // assistant message — orphans are rejected as "unexpected
120
+ // tool_use_id".
121
+ // 2. All tool_results for one assistant turn must arrive in a SINGLE
122
+ // user message — OpenAI sends parallel tool results as consecutive
123
+ // role:"tool" messages, which must merge, not split.
124
+ const lastAssistant = anthropicMessages.findLast?.(m => m.role === "assistant")
125
+ ?? [...anthropicMessages].reverse().find(m => m.role === "assistant");
126
+ const hasMatchingToolUse = Array.isArray(lastAssistant?.content) &&
127
+ lastAssistant.content.some(b => b?.type === "tool_use" && b.id === msg.tool_call_id);
128
+
129
+ if (!hasMatchingToolUse) {
130
+ // Orphaned result (history truncated or ids rewritten by the client):
131
+ // degrade to plain text so the request survives instead of 400ing.
132
+ logger.warn({ toolCallId: msg.tool_call_id }, "Orphaned tool result — no matching tool_use in prior assistant message; converting to text");
133
+ const text = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
134
+ anthropicMessages.push({
135
+ role: "user",
136
+ content: [{ type: "text", text: `[Tool result ${msg.tool_call_id}]: ${text}` }]
137
+ });
138
+ continue;
139
+ }
140
+
141
+ const resultBlock = {
142
+ type: "tool_result",
143
+ tool_use_id: msg.tool_call_id,
144
+ content: msg.content
145
+ };
146
+
147
+ const prev = anthropicMessages[anthropicMessages.length - 1];
148
+ const prevIsToolResultCarrier = prev?.role === "user" &&
149
+ Array.isArray(prev.content) &&
150
+ prev.content.length > 0 &&
151
+ prev.content.every(b => b?.type === "tool_result");
152
+
153
+ if (prevIsToolResultCarrier) {
154
+ prev.content.push(resultBlock);
155
+ } else {
156
+ anthropicMessages.push({ role: "user", content: [resultBlock] });
157
+ }
131
158
  }
132
159
  }
133
160
 
@@ -261,7 +288,10 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
261
288
 
262
289
  // Build OpenAI response
263
290
  // Ensure ID has the chatcmpl- prefix that OpenAI clients expect
264
- const responseId = id && id.startsWith("chatcmpl-") ? id : `chatcmpl-${Date.now()}`;
291
+ // Preserve the upstream id whatever its prefix (msg_… from Anthropic-shaped
292
+ // providers) — clients don't validate the prefix and a stable id keeps
293
+ // request tracing intact across the format conversion.
294
+ const responseId = id || `chatcmpl-${Date.now()}`;
265
295
  const openaiResponse = {
266
296
  id: responseId,
267
297
  object: "chat.completion",
@@ -277,11 +307,23 @@ function convertAnthropicToOpenAI(anthropicResponse, model = "claude-3-5-sonnet-
277
307
  finish_reason: mapStopReason(stop_reason)
278
308
  }
279
309
  ],
280
- usage: {
281
- prompt_tokens: usage?.input_tokens || 0,
282
- completion_tokens: usage?.output_tokens || 0,
283
- total_tokens: (usage?.input_tokens || 0) + (usage?.output_tokens || 0)
284
- }
310
+ // OpenAI semantics: prompt_tokens INCLUDES cached tokens (Anthropic's
311
+ // input_tokens excludes cache reads/writes). Dropping cache tokens made
312
+ // clients like opencode undercount context — with prompt caching on,
313
+ // most of a turn's input is cache-read (issue: sidebar token drift).
314
+ usage: (() => {
315
+ const cacheRead = usage?.cache_read_input_tokens || 0;
316
+ const cacheWrite = usage?.cache_creation_input_tokens || 0;
317
+ const promptTokens = (usage?.input_tokens || 0) + cacheRead + cacheWrite;
318
+ const completionTokens = usage?.output_tokens || 0;
319
+ const u = {
320
+ prompt_tokens: promptTokens,
321
+ completion_tokens: completionTokens,
322
+ total_tokens: promptTokens + completionTokens
323
+ };
324
+ if (cacheRead > 0) u.prompt_tokens_details = { cached_tokens: cacheRead };
325
+ return u;
326
+ })()
285
327
  };
286
328
 
287
329
  // Add citations if present
@@ -427,10 +469,18 @@ function convertAnthropicStreamChunkToOpenAI(chunk, model = "claude-3-5-sonnet-2
427
469
  finish_reason: mapStopReason(stopReason)
428
470
  }
429
471
  ],
472
+ // Anthropic's message_delta usage carries output_tokens and (on newer
473
+ // API versions) cumulative input_tokens — forward whatever is present
474
+ // instead of hardcoding prompt_tokens: 0.
430
475
  usage: usage ? {
431
- prompt_tokens: 0, // Not available in streaming
476
+ prompt_tokens: (usage.input_tokens || 0) +
477
+ (usage.cache_read_input_tokens || 0) +
478
+ (usage.cache_creation_input_tokens || 0),
432
479
  completion_tokens: usage.output_tokens || 0,
433
- total_tokens: usage.output_tokens || 0
480
+ total_tokens: (usage.input_tokens || 0) +
481
+ (usage.cache_read_input_tokens || 0) +
482
+ (usage.cache_creation_input_tokens || 0) +
483
+ (usage.output_tokens || 0)
434
484
  } : undefined
435
485
  };
436
486
  } else if (eventType === "message_stop") {
@@ -1,5 +1,3 @@
1
- const config = require("../config");
2
-
3
1
  const NATIVE_THINKING_PROVIDERS = new Set(["azure-anthropic", "databricks"]);
4
2
 
5
3
  const NATIVE_THINKING_BEDROCK_MODELS = [
@@ -34,8 +32,35 @@ function getThinkingBehavior(providerType, model) {
34
32
  return "none";
35
33
  }
36
34
 
35
+ /**
36
+ * Anthropic-shaped `thinking` request param to send upstream, honoring an
37
+ * explicit client request and otherwise defaulting to disabled.
38
+ *
39
+ * Confirmed live (2026-08-27) against both endpoints directly: Baidu
40
+ * Qianfan's `glm-5.2` and Moonshot's Kimi (`kimi-k3`) both emit verbose
41
+ * `reasoning_content` on EVERY call by default, sharing the same token
42
+ * budget as the visible answer — with no instruction at all, a baseline
43
+ * call to either returned ~500-600 chars of reasoning_content, an EMPTY
44
+ * `content`, and `finish_reason:"length"` (the whole max_tokens budget
45
+ * spent on reasoning, none left for the actual answer). The GLM/Qwen
46
+ * convention `enable_thinking:false` does NOT suppress this on either
47
+ * endpoint — only this Anthropic-shaped `{type:"disabled"}` param does
48
+ * (also confirmed live, on both). Not a caveman-specific issue — the same
49
+ * budget waste happens on every request to these two providers; caveman's
50
+ * elaborate constraint set just makes it worse, since the model has more to
51
+ * visibly deliberate against.
52
+ *
53
+ * @param {Object} body - incoming Anthropic-format request body
54
+ * @returns {Object} the `thinking` param to send upstream
55
+ */
56
+ function resolveThinkingParam(body) {
57
+ if (body?.thinking && typeof body.thinking === "object") return body.thinking;
58
+ return { type: "disabled" };
59
+ }
60
+
37
61
  module.exports = {
38
62
  supportsNativeThinking,
39
63
  supportsReasoningContent,
40
64
  getThinkingBehavior,
65
+ resolveThinkingParam,
41
66
  };
@@ -195,7 +195,6 @@ class CockatielCircuitBreaker {
195
195
  this._lastStateChange = Date.now();
196
196
 
197
197
  // Recreate the policy to reset state
198
- const oldPolicy = this._policy;
199
198
  this._policy = circuitBreaker(handleAll, {
200
199
  breaker: new ConsecutiveBreaker(this.failureThreshold),
201
200
  halfOpenAfter: this.halfOpenAfter,
@@ -10,61 +10,6 @@
10
10
  const logger = require("../logger");
11
11
  const { repairToolCallIds } = require("./tool-call-repair");
12
12
 
13
- /**
14
- * Map client tool names back to Lynkr tool names
15
- * Used when processing incoming function_call messages from various AI coding clients
16
- * Supports: Codex CLI, Cline (VS Code), Continue.dev
17
- * @param {string} clientToolName - Client tool name (e.g., shell_command, execute_command, read_file)
18
- * @returns {string} Lynkr tool name (e.g., Bash, Read)
19
- */
20
- function mapClientToolToLynkr(clientToolName) {
21
- const reverseMapping = {
22
- // ============== CODEX CLI ==============
23
- "shell": "Bash",
24
- "shell_command": "Bash",
25
- "read_file": "Read",
26
- "write_file": "Write",
27
- "apply_patch": "Edit",
28
- "glob_file_search": "Glob",
29
- "rg": "Grep",
30
- "list_dir": "ListDir",
31
-
32
- // ============== CLINE (VS Code) ==============
33
- "execute_command": "Bash",
34
- // "read_file" already mapped above
35
- "write_to_file": "Write",
36
- "replace_in_file": "Edit",
37
- "search_files": "Grep",
38
- "list_files": "ListDir",
39
-
40
- // ============== KILO CODE (Fork of Cline) ==============
41
- // Most tools same as Cline, but apply_diff is different
42
- "apply_diff": "Edit",
43
- "codebase_search": "Grep",
44
- "delete_file": "Bash", // No direct equivalent, use Bash rm
45
- "browser_action": "WebFetch", // Approximate mapping
46
-
47
- // ============== CONTINUE.DEV ==============
48
- "run_terminal_command": "Bash",
49
- // "read_file" already mapped above
50
- "create_new_file": "Write",
51
- "edit_existing_file": "Edit",
52
- "exact_search": "Grep",
53
- "read_currently_open_file": "Read",
54
-
55
- // ============== Lowercase Lynkr tools (pass-through) ==============
56
- "bash": "Bash",
57
- "read": "Read",
58
- "write": "Write",
59
- "edit": "Edit",
60
- "glob": "Glob",
61
- "grep": "Grep",
62
- "listdir": "ListDir"
63
- };
64
-
65
- return reverseMapping[clientToolName] || clientToolName;
66
- }
67
-
68
13
  /**
69
14
  * Convert Responses API request to Chat Completions format
70
15
  * @param {Object} responsesRequest - Responses API format request
@@ -189,7 +134,6 @@ function convertResponsesToChat(responsesRequest) {
189
134
  }, "Converted multimodal content array to string");
190
135
  } else {
191
136
  // No text found, keep as array (might be image-only)
192
- content = content;
193
137
  }
194
138
  }
195
139
 
@@ -15,7 +15,7 @@ const config = require('../config');
15
15
 
16
16
  // Synchronous version for benchmarking/tests
17
17
  // (when tiers are disabled, routing is purely static)
18
- function determineProviderSync(payload) {
18
+ function determineProviderSync(_payload) {
19
19
  const primaryProvider = config.modelProvider?.type || 'databricks';
20
20
  const defaultModel = config.modelProvider?.defaultModel || 'databricks-claude-sonnet-4-5';
21
21
 
@@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) {
62
62
  return path.resolve(normalised);
63
63
  }
64
64
 
65
- const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu"]);
65
+ const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu", "fireworks"]);
66
66
  const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
67
67
 
68
68
  // Validate MODEL_PROVIDER early with a clear error message
@@ -153,6 +153,11 @@ const baiduApiKey = process.env.BAIDU_API_KEY?.trim() || null;
153
153
  const baiduEndpoint = process.env.BAIDU_ENDPOINT?.trim() || "https://qianfan.baidubce.com/v2/chat/completions";
154
154
  const baiduModel = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
155
155
 
156
+ // Fireworks AI configuration - OpenAI-compatible serverless inference API
157
+ const fireworksApiKey = process.env.FIREWORKS_API_KEY?.trim() || null;
158
+ const fireworksEndpoint = process.env.FIREWORKS_ENDPOINT?.trim() || "https://api.fireworks.ai/inference/v1/chat/completions";
159
+ const fireworksModel = process.env.FIREWORKS_MODEL?.trim() || "accounts/fireworks/models/kimi-k2-instruct-0905";
160
+
156
161
  // Vertex AI (Google Gemini) configuration
157
162
  const vertexApiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null;
158
163
  const vertexModel = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash";
@@ -545,7 +550,6 @@ const agentsEnabled = process.env.AGENTS_ENABLED === "true";
545
550
  // LLM Audit logging configuration
546
551
  const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
547
552
  const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
548
- const auditMaxContentLength = Number.parseInt(process.env.LLM_AUDIT_MAX_CONTENT_LENGTH ?? "5000", 10); // Legacy fallback
549
553
  const auditMaxSystemLength = Number.parseInt(process.env.LLM_AUDIT_MAX_SYSTEM_LENGTH ?? "2000", 10);
550
554
  const auditMaxUserLength = Number.parseInt(process.env.LLM_AUDIT_MAX_USER_LENGTH ?? "3000", 10);
551
555
  const auditMaxResponseLength = Number.parseInt(process.env.LLM_AUDIT_MAX_RESPONSE_LENGTH ?? "3000", 10);
@@ -663,6 +667,11 @@ var config = {
663
667
  endpoint: baiduEndpoint,
664
668
  model: baiduModel,
665
669
  },
670
+ fireworks: {
671
+ apiKey: fireworksApiKey,
672
+ endpoint: fireworksEndpoint,
673
+ model: fireworksModel,
674
+ },
666
675
  codex: {
667
676
  enabled: process.env.CODEX_ENABLED !== "false",
668
677
  binaryPath: process.env.CODEX_BINARY_PATH?.trim() || null,
@@ -1126,6 +1135,8 @@ function reloadConfig() {
1126
1135
  config.moonshot.model = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview";
1127
1136
  config.baidu.apiKey = process.env.BAIDU_API_KEY?.trim() || null;
1128
1137
  config.baidu.model = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k";
1138
+ config.fireworks.apiKey = process.env.FIREWORKS_API_KEY?.trim() || null;
1139
+ config.fireworks.model = process.env.FIREWORKS_MODEL?.trim() || "accounts/fireworks/models/kimi-k2-instruct-0905";
1129
1140
 
1130
1141
  // Model provider settings
1131
1142
  const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase();
@@ -223,7 +223,7 @@ function finalizeBudgetEnforcement(original, optimized, strategy, initialCheck,
223
223
  /**
224
224
  * Compress system prompt aggressively for budget enforcement
225
225
  */
226
- function compressSystemPromptAggressively(systemPromptContent, payload) {
226
+ function compressSystemPromptAggressively(systemPromptContent, _payload) {
227
227
  let text = typeof systemPromptContent === 'string'
228
228
  ? systemPromptContent
229
229
  : systemPrompt.flattenBlocks(systemPromptContent);