lynkr 9.5.0 → 9.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -1
- package/bin/cli.js +2 -0
- package/bin/wrap.js +686 -0
- package/package.json +4 -3
- package/scripts/build-knn-index.js +1 -1
- package/scripts/convert-routellm.py +105 -0
- package/src/agents/decomposition/dispatcher.js +185 -0
- package/src/agents/decomposition/gate.js +136 -0
- package/src/agents/decomposition/index.js +183 -0
- package/src/agents/decomposition/model-call.js +75 -0
- package/src/agents/decomposition/planner.js +223 -0
- package/src/agents/decomposition/synthesizer.js +89 -0
- package/src/agents/decomposition/telemetry.js +55 -0
- package/src/api/router.js +694 -21
- package/src/auth-mode.js +116 -0
- package/src/clients/databricks.js +551 -68
- package/src/clients/openrouter-utils.js +6 -29
- package/src/clients/prompt-cache-injection.js +64 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +23 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/orchestrator/index.js +120 -60
- package/src/routing/index.js +31 -4
- package/src/routing/knn-router.js +9 -2
- package/src/routing/model-tiers.js +34 -0
- package/src/routing/tier-fallback.js +91 -0
- package/src/server.js +2 -0
- package/src/tools/decompose.js +91 -0
- package/src/tools/lazy-loader.js +8 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const logger = require("../logger");
|
|
2
|
+
const { repairToolCallIds } = require("./tool-call-repair");
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Convert Anthropic tool format to OpenAI/OpenRouter format
|
|
@@ -146,35 +147,11 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
const msg = converted[i];
|
|
155
|
-
if (msg.role !== 'tool') continue;
|
|
156
|
-
|
|
157
|
-
// Find the nearest preceding assistant with tool_calls
|
|
158
|
-
for (let j = i - 1; j >= 0; j--) {
|
|
159
|
-
const prev = converted[j];
|
|
160
|
-
if (prev.role === 'user') break;
|
|
161
|
-
if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
|
|
162
|
-
if (!prev.tool_calls.some(tc => tc.id === msg.tool_call_id)) {
|
|
163
|
-
// Mismatch — pick the first unmatched tool_call id
|
|
164
|
-
const usedIds = new Set();
|
|
165
|
-
for (let k = j + 1; k < converted.length; k++) {
|
|
166
|
-
if (converted[k].role === 'tool' && k !== i) usedIds.add(converted[k].tool_call_id);
|
|
167
|
-
}
|
|
168
|
-
const available = prev.tool_calls.find(tc => !usedIds.has(tc.id));
|
|
169
|
-
if (available) {
|
|
170
|
-
logger.info({ from: msg.tool_call_id, to: available.id }, "Fixed tool_call_id mismatch");
|
|
171
|
-
msg.tool_call_id = available.id;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
break;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
150
|
+
// Repair tool_call_id linkage before handing to OpenAI-compatible providers:
|
|
151
|
+
// backfill blank assistant tool_call ids, re-link drifted/blank tool_call_ids
|
|
152
|
+
// to the nearest preceding assistant tool_call, and drop orphan tool results.
|
|
153
|
+
// Moonshot/Kimi (and others) hard-400 on any empty or unmatched tool_call_id.
|
|
154
|
+
repairToolCallIds(converted);
|
|
178
155
|
|
|
179
156
|
// Kimi/Moonshot (and some OpenAI-compatible APIs) reject a message whose
|
|
180
157
|
// content is an empty string with "Invalid request: tokenization failed".
|
|
@@ -119,6 +119,51 @@ function needsCacheInjection(provider) {
|
|
|
119
119
|
return EXPLICIT_CACHE_PROVIDERS.has(provider);
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Model families that do NOT support cache_control breakpoints. cache_control
|
|
123
|
+
// is an Anthropic construct; on aggregating providers (Bedrock, OpenRouter) it
|
|
124
|
+
// only applies to models that natively understand it (Claude, and Gemini via
|
|
125
|
+
// proxy). Injecting markers onto these families produces request shapes the
|
|
126
|
+
// upstream model rejects or silently ignores.
|
|
127
|
+
const NON_CACHE_MODEL_PATTERNS = [
|
|
128
|
+
/(^|[./-])titan/i,
|
|
129
|
+
/(^|[./-])nova/i,
|
|
130
|
+
/(^|[./-])llama/i,
|
|
131
|
+
/(^|[./-])mistral/i,
|
|
132
|
+
/(^|[./-])mixtral/i,
|
|
133
|
+
/(^|[./-])cohere/i,
|
|
134
|
+
/(^|[./-])command/i, // cohere command-*
|
|
135
|
+
/(^|[./-])j2/i, // ai21 jurassic
|
|
136
|
+
/(^|[./-])jamba/i,
|
|
137
|
+
/(^|[./-])deepseek/i,
|
|
138
|
+
/(^|[./-])qwen/i,
|
|
139
|
+
/(^|[./-])gpt/i,
|
|
140
|
+
/(^|[./-])openai/i,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Determine whether the model targeted by this request supports cache_control.
|
|
145
|
+
*
|
|
146
|
+
* Some providers in EXPLICIT_CACHE_PROVIDERS (notably bedrock and openrouter)
|
|
147
|
+
* route to many model families, only some of which understand cache_control.
|
|
148
|
+
* This guard inspects the resolved model id and blocks injection for families
|
|
149
|
+
* that are known not to support it. When the model id is absent or
|
|
150
|
+
* unrecognized, injection is allowed (fail-open) — Claude/Gemini-style ids and
|
|
151
|
+
* Anthropic-only providers fall through to true.
|
|
152
|
+
*
|
|
153
|
+
* @param {Object} body - Request body (may carry the resolved model id)
|
|
154
|
+
* @param {string} provider - Provider name
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
function modelSupportsCacheControl(body, provider) {
|
|
158
|
+
// Providers that only ever route to Anthropic models always support it.
|
|
159
|
+
if (provider === 'azure-anthropic' || provider === 'databricks') return true;
|
|
160
|
+
|
|
161
|
+
const modelId = body && (body._tierModel || body.model);
|
|
162
|
+
if (!modelId || typeof modelId !== 'string') return true; // unknown → fail open
|
|
163
|
+
|
|
164
|
+
return !NON_CACHE_MODEL_PATTERNS.some(re => re.test(modelId));
|
|
165
|
+
}
|
|
166
|
+
|
|
122
167
|
/**
|
|
123
168
|
* Inject provider-side prompt caching into the request body.
|
|
124
169
|
* Call this before sending to the provider.
|
|
@@ -129,12 +174,31 @@ function needsCacheInjection(provider) {
|
|
|
129
174
|
*/
|
|
130
175
|
function injectPromptCaching(body, provider) {
|
|
131
176
|
if (!needsCacheInjection(provider)) return 0;
|
|
177
|
+
// Gate on model capability: a provider may support cache_control in general
|
|
178
|
+
// while the specific routed model does not.
|
|
179
|
+
if (!modelSupportsCacheControl(body, provider)) return 0;
|
|
180
|
+
// If the client (e.g. Claude Code) already attached cache_control breakpoints,
|
|
181
|
+
// don't add more. Anthropic caps at 4 breakpoints per request and stacking ours
|
|
182
|
+
// on top has caused 400/429 errors on OAuth subscription requests.
|
|
183
|
+
if (hasExistingCacheControl(body)) return 0;
|
|
132
184
|
return injectAnthropicCacheBreakpoints(body);
|
|
133
185
|
}
|
|
134
186
|
|
|
187
|
+
function hasExistingCacheControl(body) {
|
|
188
|
+
if (!body) return false;
|
|
189
|
+
const scan = (obj) => {
|
|
190
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
191
|
+
if (Array.isArray(obj)) return obj.some(scan);
|
|
192
|
+
if (obj.cache_control) return true;
|
|
193
|
+
return Object.values(obj).some(scan);
|
|
194
|
+
};
|
|
195
|
+
return scan(body.system) || scan(body.messages) || scan(body.tools);
|
|
196
|
+
}
|
|
197
|
+
|
|
135
198
|
module.exports = {
|
|
136
199
|
injectPromptCaching,
|
|
137
200
|
injectAnthropicCacheBreakpoints,
|
|
138
201
|
injectGeminiCacheBreakpoints,
|
|
139
202
|
needsCacheInjection,
|
|
203
|
+
modelSupportsCacheControl,
|
|
140
204
|
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
const logger = require("../logger");
|
|
11
|
+
const { repairToolCallIds } = require("./tool-call-repair");
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Map client tool names back to Lynkr tool names
|
|
@@ -203,6 +204,12 @@ function convertResponsesToChat(responsesRequest) {
|
|
|
203
204
|
return cleaned;
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
// Repair tool_call_id linkage now, before anything downstream consumes the
|
|
208
|
+
// converted array. Codex bundled-plugin (Browser/Computer-use) calls and
|
|
209
|
+
// synthetic tool outputs can arrive without a usable call_id, which
|
|
210
|
+
// otherwise flattens to a blank tool_call_id and 400s at the provider.
|
|
211
|
+
repairToolCallIds(messages);
|
|
212
|
+
|
|
206
213
|
logger.info({
|
|
207
214
|
originalCount: input.length,
|
|
208
215
|
filteredCount: messages.length,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call id repair for OpenAI-format message arrays.
|
|
3
|
+
*
|
|
4
|
+
* OpenAI-compatible providers (Moonshot/Kimi, OpenAI, OpenRouter, …) reject a
|
|
5
|
+
* request with `Invalid request: tool_call_id <x> is not found` whenever a
|
|
6
|
+
* `tool` message references an id that has no matching entry in a preceding
|
|
7
|
+
* assistant `tool_calls` array. This happens in practice when:
|
|
8
|
+
*
|
|
9
|
+
* 1. A `tool` message's tool_call_id is empty/missing — e.g. Codex Desktop's
|
|
10
|
+
* bundled-plugin (Browser/Computer-use) function_call_output items, and
|
|
11
|
+
* synthetic "unsupported call: shell" outputs, arrive without a usable
|
|
12
|
+
* `call_id`, so both the assistant tool_call id and the tool_call_id
|
|
13
|
+
* flatten to "". (The error shows a blank id: "tool_call_id is not found".)
|
|
14
|
+
* 2. An assistant tool_calls entry has an empty/missing id.
|
|
15
|
+
* 3. Ids drift across multiple conversion layers (Responses↔Chat↔Anthropic),
|
|
16
|
+
* leaving a `tool` message pointing at an id no assistant ever issued.
|
|
17
|
+
*
|
|
18
|
+
* This helper repairs all three in place: it backfills synthetic ids onto
|
|
19
|
+
* assistant tool_calls that lack one, re-links each `tool` message to an unused
|
|
20
|
+
* tool_call id on the nearest preceding assistant, and drops any `tool` message
|
|
21
|
+
* that has no assistant tool_call to attach to (a dangling result is a hard
|
|
22
|
+
* 400 at the provider, so dropping it is strictly safer than forwarding it).
|
|
23
|
+
*
|
|
24
|
+
* @module clients/tool-call-repair
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const logger = require("../logger");
|
|
28
|
+
|
|
29
|
+
function isBlankId(id) {
|
|
30
|
+
return !id || String(id).trim() === "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Repair tool_call_id linkage in an OpenAI chat-format message array, in place.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array} messages - OpenAI chat-format messages (role/content, with
|
|
37
|
+
* assistant `tool_calls` and `tool` `tool_call_id`). Mutated in place.
|
|
38
|
+
* @returns {Array} the same array reference, with orphan tool messages removed.
|
|
39
|
+
*/
|
|
40
|
+
function repairToolCallIds(messages) {
|
|
41
|
+
if (!Array.isArray(messages) || messages.length === 0) return messages;
|
|
42
|
+
|
|
43
|
+
let synthCounter = 0;
|
|
44
|
+
const nextSyntheticId = () => `call_auto_${synthCounter++}`;
|
|
45
|
+
|
|
46
|
+
// Pass 1 — guarantee every assistant tool_call has a non-empty id.
|
|
47
|
+
for (const msg of messages) {
|
|
48
|
+
if (msg && msg.role === "assistant" && Array.isArray(msg.tool_calls)) {
|
|
49
|
+
for (const tc of msg.tool_calls) {
|
|
50
|
+
if (tc && isBlankId(tc.id)) {
|
|
51
|
+
tc.id = nextSyntheticId();
|
|
52
|
+
logger.info({ assignedId: tc.id }, "Backfilled missing assistant tool_call id");
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Pass 2 — relink (or drop) every tool message.
|
|
59
|
+
const repaired = [];
|
|
60
|
+
let dropped = 0;
|
|
61
|
+
for (let i = 0; i < messages.length; i++) {
|
|
62
|
+
const msg = messages[i];
|
|
63
|
+
if (!msg || msg.role !== "tool") {
|
|
64
|
+
repaired.push(msg);
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Nearest preceding assistant that carries tool_calls (stop at a user turn).
|
|
69
|
+
let assistant = null;
|
|
70
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
71
|
+
const prev = messages[j];
|
|
72
|
+
if (!prev) continue;
|
|
73
|
+
if (prev.role === "user") break;
|
|
74
|
+
if (prev.role === "assistant" && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) {
|
|
75
|
+
assistant = prev;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const matches =
|
|
81
|
+
assistant &&
|
|
82
|
+
!isBlankId(msg.tool_call_id) &&
|
|
83
|
+
assistant.tool_calls.some((tc) => tc.id === msg.tool_call_id);
|
|
84
|
+
|
|
85
|
+
if (matches) {
|
|
86
|
+
repaired.push(msg);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (assistant) {
|
|
91
|
+
// Pick the first tool_call id not already consumed by an earlier result.
|
|
92
|
+
const usedIds = new Set(
|
|
93
|
+
repaired.filter((r) => r && r.role === "tool" && r.tool_call_id).map((r) => r.tool_call_id)
|
|
94
|
+
);
|
|
95
|
+
const available = assistant.tool_calls.find((tc) => !usedIds.has(tc.id));
|
|
96
|
+
if (available) {
|
|
97
|
+
logger.info(
|
|
98
|
+
{ from: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id, to: available.id },
|
|
99
|
+
"Repaired tool_call_id linkage"
|
|
100
|
+
);
|
|
101
|
+
msg.tool_call_id = available.id;
|
|
102
|
+
repaired.push(msg);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// No assistant tool_call to attach to — drop the orphan rather than let it
|
|
108
|
+
// 400 the whole request at the provider.
|
|
109
|
+
dropped++;
|
|
110
|
+
logger.warn(
|
|
111
|
+
{
|
|
112
|
+
tool_call_id: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id,
|
|
113
|
+
contentPreview: typeof msg.content === "string" ? msg.content.slice(0, 80) : "",
|
|
114
|
+
},
|
|
115
|
+
"Dropped orphan tool message with no matching tool_call"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (dropped > 0) {
|
|
120
|
+
logger.info({ dropped, before: messages.length, after: repaired.length }, "Removed orphan tool messages");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Rewrite the array contents in place so callers holding this reference see
|
|
124
|
+
// the repaired result.
|
|
125
|
+
messages.length = 0;
|
|
126
|
+
for (const m of repaired) messages.push(m);
|
|
127
|
+
return messages;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = { repairToolCallIds, isBlankId };
|
package/src/config/index.js
CHANGED
|
@@ -518,6 +518,12 @@ const agentsDefaultModel = process.env.AGENTS_DEFAULT_MODEL ?? "haiku";
|
|
|
518
518
|
const agentsMaxSteps = Number.parseInt(process.env.AGENTS_MAX_STEPS ?? "15", 10);
|
|
519
519
|
const agentsTimeout = Number.parseInt(process.env.AGENTS_TIMEOUT ?? "120000", 10);
|
|
520
520
|
|
|
521
|
+
// Task decomposition configuration (opt-in; builds on the agents subsystem).
|
|
522
|
+
// Single env toggle; everything else is hardcoded below.
|
|
523
|
+
const taskDecompositionEnabled = process.env.TASK_DECOMPOSITION_ENABLED === "true";
|
|
524
|
+
|
|
525
|
+
// Tier-aware fallback (escalate-then-demote) is always on (hardcoded).
|
|
526
|
+
|
|
521
527
|
// LLM Audit logging configuration
|
|
522
528
|
const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
|
|
523
529
|
const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
|
|
@@ -775,6 +781,23 @@ var config = {
|
|
|
775
781
|
maxSteps: Number.isNaN(agentsMaxSteps) ? 15 : agentsMaxSteps,
|
|
776
782
|
timeout: Number.isNaN(agentsTimeout) ? 120000 : agentsTimeout,
|
|
777
783
|
},
|
|
784
|
+
taskDecomposition: {
|
|
785
|
+
enabled: taskDecompositionEnabled, // only env-driven knob (TASK_DECOMPOSITION_ENABLED)
|
|
786
|
+
// Hardcoded defaults — tune here if needed.
|
|
787
|
+
shadow: false,
|
|
788
|
+
planModel: "sonnet",
|
|
789
|
+
synthModel: "sonnet",
|
|
790
|
+
minConfidence: 0.5,
|
|
791
|
+
gate: {
|
|
792
|
+
minComplexity: 60,
|
|
793
|
+
minTokens: 3000,
|
|
794
|
+
maxSubtasks: 6,
|
|
795
|
+
minIndependentUnits: 2,
|
|
796
|
+
},
|
|
797
|
+
},
|
|
798
|
+
tierFallback: {
|
|
799
|
+
enabled: true, // always on (hardcoded): escalate-then-demote on provider failure; floor = SIMPLE
|
|
800
|
+
},
|
|
778
801
|
tests: {
|
|
779
802
|
defaultCommand: testDefaultCommand ? testDefaultCommand.trim() : null,
|
|
780
803
|
defaultArgs: testDefaultArgs,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output Format Guard
|
|
3
|
+
*
|
|
4
|
+
* Appends a short formatting instruction to the system prompt so weaker
|
|
5
|
+
* backends (Moonshot/Kimi, Ollama, etc.) stop emitting mangled ASCII/Unicode
|
|
6
|
+
* box-drawing "diagrams" that render as garbage in clients. The actual backend
|
|
7
|
+
* is decided by tier routing — so even when the client asks for "claude-opus",
|
|
8
|
+
* the request may be served by a model that formats poorly. This normalizes the
|
|
9
|
+
* presentation without changing which model serves the request.
|
|
10
|
+
*
|
|
11
|
+
* Always-on (no env flag). It is skipped only for Claude-family backends, which
|
|
12
|
+
* already produce clean GitHub-flavored markdown — injecting there would just
|
|
13
|
+
* waste tokens. The skip biases toward injecting: if we can't tell, we inject
|
|
14
|
+
* (a false-inject is harmless ~50 tokens; a false-skip leaves the garble).
|
|
15
|
+
*
|
|
16
|
+
* Keyed off the ROUTING-RESOLVED provider/model, never the client's requested
|
|
17
|
+
* body.model (which is just a label once tier routing is on).
|
|
18
|
+
*
|
|
19
|
+
* @module context/output-format-guard
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const logger = require("../logger");
|
|
23
|
+
|
|
24
|
+
const MARKER = "[fmt-guard]";
|
|
25
|
+
|
|
26
|
+
// Model names that already produce clean markdown.
|
|
27
|
+
const CLEAN_FORMATTER_RE = /\b(claude|sonnet|opus|haiku)\b/i;
|
|
28
|
+
// Providers that are always Claude-backed.
|
|
29
|
+
const CLEAN_PROVIDERS = new Set(["azure-anthropic"]);
|
|
30
|
+
|
|
31
|
+
const GUARD_TEXT =
|
|
32
|
+
`${MARKER} Formatting rules for your response: use plain GitHub-flavored markdown only. ` +
|
|
33
|
+
"Do NOT draw diagrams or boxes with ASCII or Unicode line-drawing characters " +
|
|
34
|
+
"(such as ┌ ─ │ └ ├ ┤ ┬ ┴ ╔ ═ ║), and do NOT wrap headings or code in decorative borders. " +
|
|
35
|
+
"Represent structure and relationships with normal markdown headings, nested bullet lists, " +
|
|
36
|
+
"numbered lists, or tables. Use standard triple-backtick fenced code blocks for code. " +
|
|
37
|
+
"Keep code, file paths, commands, and URLs exact.";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the resolved backend already formats cleanly (→ skip injection).
|
|
41
|
+
* @param {string} provider - routing-resolved provider
|
|
42
|
+
* @param {string} model - routing-resolved model (NOT the client's requested model)
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function producesCleanMarkdown(provider, model) {
|
|
46
|
+
if (CLEAN_PROVIDERS.has(String(provider || "").toLowerCase())) return true;
|
|
47
|
+
if (model && CLEAN_FORMATTER_RE.test(String(model))) return true;
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Append the guard text to a system prompt that may be a string or an array of
|
|
53
|
+
* Anthropic content blocks. Idempotent via MARKER. Pure — returns the new value.
|
|
54
|
+
*/
|
|
55
|
+
function appendToSystem(system, text) {
|
|
56
|
+
// String (or empty) system prompt.
|
|
57
|
+
if (system == null || typeof system === "string") {
|
|
58
|
+
const base = system || "";
|
|
59
|
+
if (base.includes(MARKER)) return base;
|
|
60
|
+
return base ? `${base}\n\n${text}` : text;
|
|
61
|
+
}
|
|
62
|
+
// Anthropic array-of-blocks system prompt.
|
|
63
|
+
if (Array.isArray(system)) {
|
|
64
|
+
const already = system.some(
|
|
65
|
+
(b) => typeof b?.text === "string" && b.text.includes(MARKER)
|
|
66
|
+
);
|
|
67
|
+
if (already) return system;
|
|
68
|
+
return [...system, { type: "text", text }];
|
|
69
|
+
}
|
|
70
|
+
return system;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Inject the formatting guard into body.system in place, unless the resolved
|
|
75
|
+
* backend already formats cleanly. Always-on; idempotent.
|
|
76
|
+
*
|
|
77
|
+
* @param {object} body - request body (mutated in place)
|
|
78
|
+
* @param {object} [opts]
|
|
79
|
+
* @param {string} [opts.provider] - routing-resolved provider
|
|
80
|
+
* @param {string} [opts.model] - routing-resolved model
|
|
81
|
+
* @returns {object} body
|
|
82
|
+
*/
|
|
83
|
+
function injectFormatGuard(body, opts = {}) {
|
|
84
|
+
if (!body) return body;
|
|
85
|
+
const { provider, model } = opts;
|
|
86
|
+
if (producesCleanMarkdown(provider, model)) return body;
|
|
87
|
+
|
|
88
|
+
body.system = appendToSystem(body.system, GUARD_TEXT);
|
|
89
|
+
logger.debug({ provider, model }, "[FormatGuard] Injected markdown formatting guard");
|
|
90
|
+
return body;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
injectFormatGuard,
|
|
95
|
+
producesCleanMarkdown,
|
|
96
|
+
appendToSystem,
|
|
97
|
+
MARKER,
|
|
98
|
+
GUARD_TEXT,
|
|
99
|
+
};
|
|
@@ -965,22 +965,48 @@ function stripThinkingBlocks(text) {
|
|
|
965
965
|
/**
|
|
966
966
|
* Convert legacy Ollama /api/chat response to Anthropic Messages format.
|
|
967
967
|
* Used when Ollama < v0.14.0 (no native Anthropic endpoint).
|
|
968
|
+
*
|
|
969
|
+
* Critical for MiniMax M2/M2.5 (and other interleaved-thinking models):
|
|
970
|
+
* preserve <think>...</think> from message.content AND Ollama's native
|
|
971
|
+
* message.thinking field as Anthropic thinking blocks. Dropping them breaks
|
|
972
|
+
* the model's long-horizon agent loop — vendor-quantified at Tau^2 -35.9%,
|
|
973
|
+
* BrowseComp -40.1% (https://www.minimax.io/news/why-is-interleaved-thinking-important-for-m2).
|
|
968
974
|
*/
|
|
969
975
|
function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
|
|
970
976
|
const message = ollamaResponse?.message ?? {};
|
|
971
|
-
const rawContent = message.content
|
|
977
|
+
const rawContent = typeof message.content === "string" ? message.content : "";
|
|
978
|
+
const nativeThinking = typeof message.thinking === "string" ? message.thinking : "";
|
|
972
979
|
const toolCalls = message.tool_calls || [];
|
|
973
980
|
|
|
981
|
+
// Extract <think>...</think> blocks from content (concatenate if multiple).
|
|
982
|
+
// What remains becomes the text body.
|
|
983
|
+
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
|
|
984
|
+
const thinkMatches = [];
|
|
985
|
+
let textBody = rawContent;
|
|
986
|
+
let m;
|
|
987
|
+
while ((m = thinkRegex.exec(rawContent)) !== null) {
|
|
988
|
+
thinkMatches.push(m[1]);
|
|
989
|
+
}
|
|
990
|
+
textBody = textBody.replace(thinkRegex, "").trim();
|
|
991
|
+
|
|
992
|
+
const combinedThinking = [nativeThinking, ...thinkMatches]
|
|
993
|
+
.map(s => (s || "").trim())
|
|
994
|
+
.filter(Boolean)
|
|
995
|
+
.join("\n\n");
|
|
996
|
+
|
|
974
997
|
const contentItems = [];
|
|
975
998
|
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
contentItems.push({ type: "text", text: cleanedContent });
|
|
980
|
-
}
|
|
999
|
+
// 1. Thinking block FIRST (Mini-Agent reference order: thinking → text → tool_use)
|
|
1000
|
+
if (combinedThinking) {
|
|
1001
|
+
contentItems.push({ type: "thinking", thinking: combinedThinking });
|
|
981
1002
|
}
|
|
982
1003
|
|
|
983
|
-
//
|
|
1004
|
+
// 2. Text body (after <think> tags removed)
|
|
1005
|
+
if (textBody) {
|
|
1006
|
+
contentItems.push({ type: "text", text: textBody });
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// 3. Tool calls converted to Anthropic tool_use
|
|
984
1010
|
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
|
|
985
1011
|
for (const toolCall of toolCalls) {
|
|
986
1012
|
const func = toolCall.function || {};
|
|
@@ -1008,6 +1034,9 @@ function ollamaToAnthropicResponse(ollamaResponse, requestedModel) {
|
|
|
1008
1034
|
const inputTokens = ollamaResponse.prompt_eval_count ?? 0;
|
|
1009
1035
|
const outputTokens = ollamaResponse.eval_count ?? 0;
|
|
1010
1036
|
|
|
1037
|
+
// stop_reason derived from tool_calls presence, NOT done_reason.
|
|
1038
|
+
// Ollama emits done_reason="stop" even when tool_calls are present
|
|
1039
|
+
// (ollama/ollama#12557) — naive mapping would falsely halt Claude Code's loop.
|
|
1011
1040
|
return {
|
|
1012
1041
|
id: `msg_${Date.now()}`,
|
|
1013
1042
|
type: "message",
|
|
@@ -1104,7 +1133,16 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
|
|
|
1104
1133
|
|
|
1105
1134
|
function sanitizePayload(payload) {
|
|
1106
1135
|
const { clonePayloadSmart } = require("../utils/payload");
|
|
1107
|
-
|
|
1136
|
+
// Honor a forceProvider marker (set by the OAuth tier-routing path) so the
|
|
1137
|
+
// tool-format / system-flatten / strip-thinking branches downstream match
|
|
1138
|
+
// the actual destination provider, not the static MODEL_PROVIDER default.
|
|
1139
|
+
// Without this, a TIER_SIMPLE=ollama:... user gets the "databricks" branch
|
|
1140
|
+
// running normaliseTools — which wraps tools in OpenAI {type:"function",...}
|
|
1141
|
+
// shape, leaving Ollama with tools named "function" and a model that
|
|
1142
|
+
// (correctly) reports no real tools available.
|
|
1143
|
+
const providerType = payload?._forceProvider
|
|
1144
|
+
|| config.modelProvider?.type
|
|
1145
|
+
|| "databricks";
|
|
1108
1146
|
const willFlatten = providerType !== "azure-anthropic";
|
|
1109
1147
|
const clean = clonePayloadSmart(payload ?? {}, { willFlatten });
|
|
1110
1148
|
const requestedModel =
|
|
@@ -1260,55 +1298,16 @@ function sanitizePayload(payload) {
|
|
|
1260
1298
|
}));
|
|
1261
1299
|
delete clean.tool_choice;
|
|
1262
1300
|
} else if (providerType === "ollama") {
|
|
1263
|
-
//
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
//
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
const lastMessage = clean.messages[clean.messages.length - 1];
|
|
1273
|
-
if (lastMessage?.role !== "user") {
|
|
1274
|
-
return false;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
const content = typeof lastMessage.content === "string"
|
|
1278
|
-
? lastMessage.content
|
|
1279
|
-
: "";
|
|
1280
|
-
|
|
1281
|
-
const trimmed = content.trim().toLowerCase();
|
|
1282
|
-
|
|
1283
|
-
// Simple greetings
|
|
1284
|
-
if (/^(hi|hello|hey|good morning|good afternoon|good evening|howdy|greetings)[\s\.\!\?]*$/.test(trimmed)) {
|
|
1285
|
-
return "greeting";
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
// Conversational phrases that don't need tools (thanks, farewells, acknowledgements)
|
|
1289
|
-
if (/^(thanks|thank you|thx|ty|bye|goodbye|see you|ok|okay|cool|nice|great|awesome|sure|got it|sounds good|no worries|np|cheers)[\s\.\!\?]*$/.test(trimmed)) {
|
|
1290
|
-
return "conversational";
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
return false;
|
|
1294
|
-
})();
|
|
1295
|
-
|
|
1296
|
-
if (isConversational) {
|
|
1297
|
-
// Strip all tools for simple conversational messages
|
|
1298
|
-
delete clean.tools;
|
|
1299
|
-
delete clean.tool_choice;
|
|
1300
|
-
logger.debug({
|
|
1301
|
-
model: config.ollama?.model,
|
|
1302
|
-
reason: isConversational,
|
|
1303
|
-
}, "Ollama conversational mode - tools removed");
|
|
1304
|
-
} else if (modelSupportsTools && Array.isArray(clean.tools) && clean.tools.length > 0) {
|
|
1305
|
-
// Keep all tools — Ollama receives them in Anthropic format (native API)
|
|
1306
|
-
// or they get converted to OpenAI format in invokeOllama (legacy API)
|
|
1301
|
+
// Always pass tools through to Ollama in Anthropic format when they exist.
|
|
1302
|
+
// Ollama (v0.14+ native /v1/messages) accepts the Anthropic tool shape; if
|
|
1303
|
+
// the underlying model doesn't actually emit tool_use blocks, the model
|
|
1304
|
+
// simply responds conversationally — which is the correct fallback. Don't
|
|
1305
|
+
// strip the tools array based on heuristics about user intent or a
|
|
1306
|
+
// hardcoded "model supports tools" check, both of which produce
|
|
1307
|
+
// tool-blind responses ("I don't have file system access") when the
|
|
1308
|
+
// client (Claude Code) is clearly in an agentic session.
|
|
1309
|
+
if (Array.isArray(clean.tools) && clean.tools.length > 0) {
|
|
1307
1310
|
clean.tools = ensureAnthropicToolFormat(clean.tools);
|
|
1308
|
-
} else {
|
|
1309
|
-
// Remove tools for models without tool support
|
|
1310
|
-
delete clean.tools;
|
|
1311
|
-
delete clean.tool_choice;
|
|
1312
1311
|
}
|
|
1313
1312
|
} else if (providerType === "openrouter") {
|
|
1314
1313
|
// OpenRouter supports tools - keep them as-is
|
|
@@ -1902,8 +1901,29 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1902
1901
|
}, 'Estimated token usage before model call');
|
|
1903
1902
|
}
|
|
1904
1903
|
|
|
1905
|
-
// Apply Headroom compression if enabled
|
|
1906
|
-
|
|
1904
|
+
// Apply Headroom compression if enabled.
|
|
1905
|
+
//
|
|
1906
|
+
// Headroom is configured for a single provider (HEADROOM_PROVIDER, default
|
|
1907
|
+
// 'anthropic'). Its Tool Crusher rewrites tool results compactly, Cache
|
|
1908
|
+
// Aligner restructures messages to maximize that provider's prompt-cache
|
|
1909
|
+
// hit pattern, and Smart Crusher does semantic compression — all tuned for
|
|
1910
|
+
// Anthropic. Sending the compressed output to a different model family
|
|
1911
|
+
// (Ollama, OpenAI, etc.) yields output the receiver reads as "garbled tool
|
|
1912
|
+
// result" and the agent loop stalls.
|
|
1913
|
+
//
|
|
1914
|
+
// Gate Headroom on providers matching HEADROOM_PROVIDER. By default that's
|
|
1915
|
+
// Claude-family; an operator who switches HEADROOM_PROVIDER=openai gets the
|
|
1916
|
+
// analogous gate.
|
|
1917
|
+
const headroomProviderMap = {
|
|
1918
|
+
'anthropic': new Set(['azure-anthropic', 'bedrock', 'vertex', 'openrouter']),
|
|
1919
|
+
'openai': new Set(['azure-openai', 'openai', 'openrouter']),
|
|
1920
|
+
'google': new Set(['vertex', 'openrouter']),
|
|
1921
|
+
};
|
|
1922
|
+
const headroomProvider = process.env.HEADROOM_PROVIDER || 'anthropic';
|
|
1923
|
+
const headroomSafeProviders = headroomProviderMap[headroomProvider] || new Set();
|
|
1924
|
+
const headroomCompatible = headroomSafeProviders.has(providerType);
|
|
1925
|
+
|
|
1926
|
+
if (isHeadroomEnabled() && headroomCompatible && cleanPayload.messages && cleanPayload.messages.length > 0) {
|
|
1907
1927
|
try {
|
|
1908
1928
|
const compressionResult = await headroomCompress(
|
|
1909
1929
|
cleanPayload.messages,
|
|
@@ -1945,6 +1965,12 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
1945
1965
|
} catch (headroomErr) {
|
|
1946
1966
|
logger.warn({ err: headroomErr, sessionId: session?.id ?? null }, 'Headroom compression failed, using original messages');
|
|
1947
1967
|
}
|
|
1968
|
+
} else if (isHeadroomEnabled() && !headroomCompatible) {
|
|
1969
|
+
logger.debug({
|
|
1970
|
+
providerType,
|
|
1971
|
+
headroomProvider,
|
|
1972
|
+
reason: 'provider_mismatch',
|
|
1973
|
+
}, 'Headroom skipped — provider does not match HEADROOM_PROVIDER family');
|
|
1948
1974
|
}
|
|
1949
1975
|
|
|
1950
1976
|
// Generate correlation ID for request/response pairing
|
|
@@ -2003,15 +2029,49 @@ IMPORTANT TOOL USAGE RULES:
|
|
|
2003
2029
|
|
|
2004
2030
|
// Caveman terse-output injection (opt-in): nudge the model toward shorter
|
|
2005
2031
|
// responses to reduce output tokens.
|
|
2006
|
-
|
|
2032
|
+
//
|
|
2033
|
+
// Default safe-set is the Claude-family + capable instruction-following
|
|
2034
|
+
// models. Operators can override via LYNKR_CAVEMAN_SAFE_PROVIDERS=a,b,c.
|
|
2035
|
+
// (Some smaller / older models read "respond like a terse caveman" too
|
|
2036
|
+
// literally and produce broken telegraphic English — keep them out of the
|
|
2037
|
+
// set if you see that degradation.)
|
|
2038
|
+
const DEFAULT_CAVEMAN_SAFE = [
|
|
2039
|
+
'azure-anthropic',
|
|
2040
|
+
'bedrock',
|
|
2041
|
+
'vertex',
|
|
2042
|
+
'openrouter',
|
|
2043
|
+
'ollama',
|
|
2044
|
+
'openai',
|
|
2045
|
+
'azure-openai',
|
|
2046
|
+
'moonshot',
|
|
2047
|
+
'zai',
|
|
2048
|
+
'databricks',
|
|
2049
|
+
];
|
|
2050
|
+
const cavemanSafeEnv = process.env.LYNKR_CAVEMAN_SAFE_PROVIDERS;
|
|
2051
|
+
const CAVEMAN_SAFE_PROVIDERS = new Set(
|
|
2052
|
+
cavemanSafeEnv
|
|
2053
|
+
? cavemanSafeEnv.split(',').map(s => s.trim()).filter(Boolean)
|
|
2054
|
+
: DEFAULT_CAVEMAN_SAFE
|
|
2055
|
+
);
|
|
2056
|
+
if (config.caveman?.enabled === true && CAVEMAN_SAFE_PROVIDERS.has(providerType)) {
|
|
2007
2057
|
const { injectCaveman } = require("../context/caveman");
|
|
2008
2058
|
cleanPayload.system = injectCaveman(cleanPayload.system);
|
|
2059
|
+
} else if (config.caveman?.enabled === true) {
|
|
2060
|
+
logger.debug({ providerType }, 'Caveman injection skipped (provider not in safe set)');
|
|
2009
2061
|
}
|
|
2010
2062
|
|
|
2011
2063
|
if (agentTimer) agentTimer.mark("preInvokeModel");
|
|
2012
2064
|
let databricksResponse;
|
|
2065
|
+
// Honor a body-level forceProvider marker (set by the OAuth tier-routing
|
|
2066
|
+
// path in the router) so the orchestrator's internal tier router can't
|
|
2067
|
+
// re-pick a different provider mid-flight.
|
|
2068
|
+
const invokeOpts = { headers };
|
|
2069
|
+
if (cleanPayload._forceProvider) {
|
|
2070
|
+
invokeOpts.forceProvider = cleanPayload._forceProvider;
|
|
2071
|
+
delete cleanPayload._forceProvider;
|
|
2072
|
+
}
|
|
2013
2073
|
try {
|
|
2014
|
-
databricksResponse = await invokeModel(cleanPayload);
|
|
2074
|
+
databricksResponse = await invokeModel(cleanPayload, invokeOpts);
|
|
2015
2075
|
if (agentTimer) agentTimer.mark("invokeModel");
|
|
2016
2076
|
} catch (modelError) {
|
|
2017
2077
|
const isConnectionError = modelError.cause?.code === 'ECONNREFUSED'
|