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.
- 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 +16 -22
- 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/health.js +0 -1
- 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/middleware/session.js +0 -0
- package/src/api/openai-router.js +210 -15
- package/src/api/providers-handler.js +16 -1
- package/src/api/router.js +73 -106
- 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/cache/prompt.js +0 -7
- package/src/clients/bedrock-utils.js +2 -1
- package/src/clients/codex-process.js +1 -1
- package/src/clients/codex-utils.js +0 -2
- package/src/clients/databricks.js +337 -46
- package/src/clients/gpt-utils.js +84 -8
- package/src/clients/health-probe.js +157 -0
- package/src/clients/openai-format.js +73 -23
- package/src/clients/provider-capabilities.js +27 -2
- package/src/clients/resilience.js +0 -1
- package/src/clients/responses-format.js +0 -56
- package/src/clients/routing.js +1 -1
- package/src/config/index.js +13 -2
- package/src/context/budget.js +1 -1
- package/src/context/compression.js +127 -19
- package/src/context/tool-result-compressor.js +3 -5
- package/src/dashboard/api.js +1 -1
- package/src/logger/oversized-error-stream.js +1 -1
- package/src/memory/extractor.js +3 -3
- package/src/memory/search.js +2 -2
- package/src/memory/store.js +9 -24
- package/src/memory/tools.js +14 -0
- package/src/observability/metrics.js +52 -3
- package/src/observability/otel.js +180 -0
- package/src/orchestrator/azure-responses-sse.js +223 -0
- package/src/orchestrator/index.js +216 -212
- package/src/orchestrator/passthrough-stream.js +7 -1
- package/src/orchestrator/sse-transformer.js +130 -13
- package/src/policy/safe-commands.js +0 -1
- package/src/routing/bandit.js +39 -0
- package/src/routing/calibration.js +21 -1
- 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/client-profiles.js +26 -0
- package/src/routing/complexity-analyzer.js +5 -8
- package/src/routing/confidence-scorer.js +2 -2
- package/src/routing/cost-optimizer.js +1 -2
- package/src/routing/decide.js +149 -0
- package/src/routing/deescalator.js +38 -1
- package/src/routing/embedding-cache.js +0 -1
- package/src/routing/index.js +202 -86
- package/src/routing/intent-score.js +0 -1
- package/src/routing/knn-router.js +27 -5
- package/src/routing/latency-tracker.js +1 -1
- 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/regret-estimator.js +1 -1
- 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/stores/file-store.js +5 -1
- package/src/tools/web-search-exec.js +182 -0
- package/src/training/trajectory-compressor.js +1 -1
- package/src/workers/pool.js +1 -1
- package/src/workers/worker.js +1 -1
- package/scripts/convert-routellm.py +0 -105
- package/src/diff/comments.js +0 -153
- package/src/policy/web-fallback.js +0 -33
package/src/clients/gpt-utils.js
CHANGED
|
@@ -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
|
|
65
|
-
// absolute paths in one repo share nearly every Jaccard
|
|
66
|
-
// (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8
|
|
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).
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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:
|
|
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.
|
|
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
|
|
package/src/clients/routing.js
CHANGED
|
@@ -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(
|
|
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
|
|
package/src/config/index.js
CHANGED
|
@@ -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();
|
package/src/context/budget.js
CHANGED
|
@@ -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,
|
|
226
|
+
function compressSystemPromptAggressively(systemPromptContent, _payload) {
|
|
227
227
|
let text = typeof systemPromptContent === 'string'
|
|
228
228
|
? systemPromptContent
|
|
229
229
|
: systemPrompt.flattenBlocks(systemPromptContent);
|