lynkr 9.4.6 → 9.6.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 +49 -15
- package/install.sh +21 -5
- package/package.json +4 -2
- package/public/dashboard.html +13 -1
- package/scripts/check-native.js +97 -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/clients/databricks.js +215 -4
- package/src/clients/openrouter-utils.js +19 -27
- package/src/clients/prompt-cache-injection.js +49 -0
- package/src/clients/responses-format.js +7 -0
- package/src/clients/tool-call-repair.js +130 -0
- package/src/config/index.js +32 -0
- package/src/context/caveman.js +94 -0
- package/src/context/output-format-guard.js +99 -0
- package/src/context/tool-dedup.js +95 -0
- package/src/context/tool-result-compressor.js +106 -0
- package/src/dashboard/api.js +69 -18
- package/src/orchestrator/bypass.js +135 -0
- package/src/orchestrator/index.js +33 -2
- package/src/routing/index.js +47 -0
- package/src/routing/model-registry.js +89 -26
- package/src/routing/risk-analyzer.js +7 -2
- package/src/routing/session-affinity.js +96 -0
- package/src/routing/telemetry.js +16 -3
- 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
- package/.impeccable/live/config.json +0 -8
|
@@ -1104,6 +1104,64 @@ async function invokeLMStudio(body) {
|
|
|
1104
1104
|
return performJsonRequest(endpoint, { headers, body: lmstudioBody }, "LM Studio");
|
|
1105
1105
|
}
|
|
1106
1106
|
|
|
1107
|
+
/**
|
|
1108
|
+
* Flatten an Anthropic-style content value into a plain string for the
|
|
1109
|
+
* Bedrock Converse API.
|
|
1110
|
+
*
|
|
1111
|
+
* Prompt-cache injection (injectPromptCaching) rewrites string `system`
|
|
1112
|
+
* fields and message `content` into arrays of `{ type, text, cache_control }`
|
|
1113
|
+
* blocks. The Converse API has no `cache_control` concept and expects
|
|
1114
|
+
* `system: [{ text: "<string>" }]` and message content blocks shaped as
|
|
1115
|
+
* `{ text: "<string>" }`. Passing the injected array through unchanged would
|
|
1116
|
+
* either drop the cache markers silently or nest an array under `text`,
|
|
1117
|
+
* producing a ValidationException.
|
|
1118
|
+
*
|
|
1119
|
+
* @param {string|Array|undefined} value - String or array of content blocks
|
|
1120
|
+
* @returns {string} Concatenated plain text
|
|
1121
|
+
*/
|
|
1122
|
+
function flattenContentToText(value) {
|
|
1123
|
+
if (value == null) return "";
|
|
1124
|
+
if (typeof value === "string") return value;
|
|
1125
|
+
if (Array.isArray(value)) {
|
|
1126
|
+
return value
|
|
1127
|
+
.map(block => {
|
|
1128
|
+
if (typeof block === "string") return block;
|
|
1129
|
+
if (block && typeof block === "object") return block.text || block.content || "";
|
|
1130
|
+
return "";
|
|
1131
|
+
})
|
|
1132
|
+
.join("");
|
|
1133
|
+
}
|
|
1134
|
+
return String(value);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Normalize a request body for the Bedrock Converse API.
|
|
1139
|
+
*
|
|
1140
|
+
* Strips `cache_control` markers and flattens any array-shaped `system` /
|
|
1141
|
+
* message `content` (left behind by prompt-cache injection) back into the
|
|
1142
|
+
* plain strings the Converse API expects. Returns a shallow copy with a
|
|
1143
|
+
* normalized `messages` array; the original body is not mutated.
|
|
1144
|
+
*
|
|
1145
|
+
* @param {Object} body - Anthropic-format request body
|
|
1146
|
+
* @returns {Object} Body safe for Converse request construction
|
|
1147
|
+
*/
|
|
1148
|
+
function normalizeBodyForConverse(body) {
|
|
1149
|
+
const normalized = { ...body };
|
|
1150
|
+
|
|
1151
|
+
if (normalized.system !== undefined) {
|
|
1152
|
+
normalized.system = flattenContentToText(normalized.system);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (Array.isArray(normalized.messages)) {
|
|
1156
|
+
normalized.messages = normalized.messages.map(msg => ({
|
|
1157
|
+
...msg,
|
|
1158
|
+
content: flattenContentToText(msg.content),
|
|
1159
|
+
}));
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return normalized;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1107
1165
|
async function invokeBedrock(body) {
|
|
1108
1166
|
// 1. Validate Bearer token
|
|
1109
1167
|
if (!config.bedrock?.apiKey) {
|
|
@@ -1130,7 +1188,10 @@ async function invokeBedrock(body) {
|
|
|
1130
1188
|
}, "=== INJECTING STANDARD TOOLS (Bedrock) ===");
|
|
1131
1189
|
}
|
|
1132
1190
|
|
|
1133
|
-
|
|
1191
|
+
// Normalize away cache_control / array shapes that prompt-cache injection
|
|
1192
|
+
// may have applied: the Converse API expects plain-string system and
|
|
1193
|
+
// message content, not Anthropic cache_control blocks.
|
|
1194
|
+
const bedrockBody = { ...normalizeBodyForConverse(body), tools: toolsToSend };
|
|
1134
1195
|
|
|
1135
1196
|
// 4. Detect model family and convert format
|
|
1136
1197
|
const modelId = body._tierModel || config.bedrock.modelId;
|
|
@@ -1506,10 +1567,16 @@ async function invokeMoonshot(body) {
|
|
|
1506
1567
|
"claude-haiku-4-5-20251001": "kimi-k2-turbo-preview",
|
|
1507
1568
|
"claude-haiku-4-5": "kimi-k2-turbo-preview",
|
|
1508
1569
|
"claude-3-haiku": "kimi-k2-turbo-preview",
|
|
1570
|
+
// moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
|
|
1571
|
+
// context-size pass fails on large tool-bearing payloads). Remap to a
|
|
1572
|
+
// fixed model that's broadly available on api.moonshot.ai.
|
|
1573
|
+
"moonshot-v1-auto": "moonshot-v1-128k",
|
|
1509
1574
|
};
|
|
1510
1575
|
|
|
1511
1576
|
const requestedModel = body._tierModel || body.model || config.moonshot.model;
|
|
1512
|
-
|
|
1577
|
+
let mappedModel = modelMap[requestedModel] || config.moonshot.model || "kimi-k2-turbo-preview";
|
|
1578
|
+
// Guard against the deprecated auto model arriving via config too.
|
|
1579
|
+
if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
|
|
1513
1580
|
|
|
1514
1581
|
// Convert messages using existing utility
|
|
1515
1582
|
const messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
@@ -1522,12 +1589,18 @@ async function invokeMoonshot(body) {
|
|
|
1522
1589
|
messages.unshift({ role: "system", content: systemContent });
|
|
1523
1590
|
}
|
|
1524
1591
|
|
|
1592
|
+
// kimi-k2.x (k2.5 / k2.6 …) are thinking models that only accept
|
|
1593
|
+
// temperature: 1 — any other value 400s with "invalid temperature".
|
|
1594
|
+
const isKimiThinking = /^kimi-k2/i.test(mappedModel);
|
|
1595
|
+
|
|
1525
1596
|
const moonshotBody = {
|
|
1526
1597
|
model: mappedModel,
|
|
1527
1598
|
messages,
|
|
1528
1599
|
max_tokens: body.max_tokens || 16384,
|
|
1529
|
-
|
|
1530
|
-
top_p
|
|
1600
|
+
// kimi-k2.x thinking models pin sampling params: temperature must be 1
|
|
1601
|
+
// and top_p must be 0.95 — any other value 400s.
|
|
1602
|
+
temperature: isKimiThinking ? 1 : (body.temperature ?? 0.7),
|
|
1603
|
+
top_p: isKimiThinking ? 0.95 : (body.top_p ?? 1.0),
|
|
1531
1604
|
stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
|
|
1532
1605
|
};
|
|
1533
1606
|
|
|
@@ -2027,6 +2100,65 @@ async function invokeCodex(body) {
|
|
|
2027
2100
|
};
|
|
2028
2101
|
}
|
|
2029
2102
|
|
|
2103
|
+
/**
|
|
2104
|
+
* Compute request cost in USD from model pricing × token usage.
|
|
2105
|
+
* Registry returns per-1M-token prices ({ input, output }); returns null when
|
|
2106
|
+
* pricing is unknown so we don't record misleading zeros.
|
|
2107
|
+
*/
|
|
2108
|
+
const _unknownCostWarned = new Set();
|
|
2109
|
+
function computeCostUsd(model, inputTokens, outputTokens) {
|
|
2110
|
+
try {
|
|
2111
|
+
const { getModelRegistrySync } = require("../routing/model-registry");
|
|
2112
|
+
const reg = getModelRegistrySync && getModelRegistrySync();
|
|
2113
|
+
const cost = reg?.getCost?.(model);
|
|
2114
|
+
if (!cost) return null;
|
|
2115
|
+
// Unknown model → record null (not a fabricated default), warn once so the
|
|
2116
|
+
// gap is visible and can be fixed via MODEL_PRICE_OVERRIDES.
|
|
2117
|
+
if (cost.unknown) {
|
|
2118
|
+
if (model && !_unknownCostWarned.has(model)) {
|
|
2119
|
+
_unknownCostWarned.add(model);
|
|
2120
|
+
logger.warn({ model }, "[Cost] No pricing for model — recording cost_usd=null. Set MODEL_PRICE_OVERRIDES to fix.");
|
|
2121
|
+
}
|
|
2122
|
+
return null;
|
|
2123
|
+
}
|
|
2124
|
+
if (cost.input == null && cost.output == null) return null;
|
|
2125
|
+
const inUsd = ((inputTokens || 0) / 1e6) * (cost.input || 0);
|
|
2126
|
+
const outUsd = ((outputTokens || 0) / 1e6) * (cost.output || 0);
|
|
2127
|
+
return Number((inUsd + outUsd).toFixed(6));
|
|
2128
|
+
} catch {
|
|
2129
|
+
return null;
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// Telemetry prompt/response text is always captured (truncated) to build the
|
|
2134
|
+
// routing ML training corpus. Stored locally in .lynkr/telemetry.db only.
|
|
2135
|
+
const TELEMETRY_TEXT_MAXLEN = 2000;
|
|
2136
|
+
|
|
2137
|
+
/** Flatten the latest user message to plain text (for telemetry capture). */
|
|
2138
|
+
function captureRequestText(body) {
|
|
2139
|
+
const messages = body?.messages;
|
|
2140
|
+
if (!Array.isArray(messages)) return null;
|
|
2141
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2142
|
+
const m = messages[i];
|
|
2143
|
+
if (m?.role !== "user") continue;
|
|
2144
|
+
let text = "";
|
|
2145
|
+
if (typeof m.content === "string") text = m.content;
|
|
2146
|
+
else if (Array.isArray(m.content)) {
|
|
2147
|
+
text = m.content.filter((b) => b?.type === "text").map((b) => b.text || "").join(" ");
|
|
2148
|
+
}
|
|
2149
|
+
if (text) return text.slice(0, TELEMETRY_TEXT_MAXLEN);
|
|
2150
|
+
}
|
|
2151
|
+
return null;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
/** Flatten an Anthropic response's text blocks to plain text (for telemetry). */
|
|
2155
|
+
function captureResponseText(resultJson) {
|
|
2156
|
+
const content = resultJson?.content;
|
|
2157
|
+
if (!Array.isArray(content)) return null;
|
|
2158
|
+
const text = content.filter((b) => b?.type === "text").map((b) => b.text || "").join(" ");
|
|
2159
|
+
return text ? text.slice(0, TELEMETRY_TEXT_MAXLEN) : null;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2030
2162
|
async function invokeModel(body, options = {}) {
|
|
2031
2163
|
const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
|
|
2032
2164
|
const metricsCollector = getMetricsCollector();
|
|
@@ -2053,6 +2185,13 @@ async function invokeModel(body, options = {}) {
|
|
|
2053
2185
|
const { injectPromptCaching } = require('./prompt-cache-injection');
|
|
2054
2186
|
injectPromptCaching(body, initialProvider);
|
|
2055
2187
|
|
|
2188
|
+
// Always-on markdown formatting guard. Stops formatting-weak backends
|
|
2189
|
+
// (Moonshot/Kimi, Ollama, etc.) from emitting mangled ASCII box-drawing
|
|
2190
|
+
// "diagrams". Keyed off the routing-resolved provider/model; skipped for
|
|
2191
|
+
// Claude-family backends which already format cleanly.
|
|
2192
|
+
const { injectFormatGuard } = require('../context/output-format-guard');
|
|
2193
|
+
injectFormatGuard(body, { provider: initialProvider, model: tierSelectedModel });
|
|
2194
|
+
|
|
2056
2195
|
// Build routing decision object for response headers
|
|
2057
2196
|
const routingDecision = {
|
|
2058
2197
|
provider: initialProvider,
|
|
@@ -2233,6 +2372,9 @@ async function invokeModel(body, options = {}) {
|
|
|
2233
2372
|
circuit_breaker_state: breaker.state,
|
|
2234
2373
|
quality_score: qualityScore,
|
|
2235
2374
|
tokens_per_second: outputTokens && latency > 0 ? outputTokens / (latency / 1000) : null,
|
|
2375
|
+
cost_usd: computeCostUsd(routingDecision.model || body._tierModel, inputTokens, outputTokens),
|
|
2376
|
+
request_text: captureRequestText(body),
|
|
2377
|
+
response_text: captureResponseText(result.json),
|
|
2236
2378
|
});
|
|
2237
2379
|
|
|
2238
2380
|
// Return result with provider info and routing decision for headers
|
|
@@ -2249,6 +2391,71 @@ async function invokeModel(body, options = {}) {
|
|
|
2249
2391
|
healthTracker.recordFailure(initialProvider, err, err.status);
|
|
2250
2392
|
getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
|
|
2251
2393
|
|
|
2394
|
+
// Tier-aware escalate-then-demote fallback (TIER_FALLBACK_ENABLED).
|
|
2395
|
+
// On failure, try a MORE capable tier first (climb toward REASONING); only
|
|
2396
|
+
// if every higher tier is unavailable do we fall downward to SIMPLE/local.
|
|
2397
|
+
// Runs before the flat global fallback below and is never silent.
|
|
2398
|
+
if (config.tierFallback?.enabled && !options._tierFallbackInner && routingDecision.tier) {
|
|
2399
|
+
const { getFallbackChain } = require("../routing/tier-fallback");
|
|
2400
|
+
const chain = getFallbackChain(routingDecision.tier);
|
|
2401
|
+
for (const cand of chain) {
|
|
2402
|
+
try {
|
|
2403
|
+
logger.warn({
|
|
2404
|
+
fromTier: routingDecision.tier,
|
|
2405
|
+
fromProvider: initialProvider,
|
|
2406
|
+
toTier: cand.tier,
|
|
2407
|
+
toProvider: cand.provider,
|
|
2408
|
+
toModel: cand.model,
|
|
2409
|
+
direction: cand.direction,
|
|
2410
|
+
}, "[TierFallback] Primary tier failed — attempting tier fallback");
|
|
2411
|
+
|
|
2412
|
+
const attempt = await invokeModel(
|
|
2413
|
+
{ ...body, _tierModel: cand.model },
|
|
2414
|
+
{
|
|
2415
|
+
forceProvider: cand.provider,
|
|
2416
|
+
_tierFallbackInner: true,
|
|
2417
|
+
disableFallback: true,
|
|
2418
|
+
_cascadeInner: true,
|
|
2419
|
+
workspace,
|
|
2420
|
+
tenantPolicy,
|
|
2421
|
+
}
|
|
2422
|
+
);
|
|
2423
|
+
|
|
2424
|
+
metricsCollector.recordFallbackAttempt(initialProvider, cand.provider, "tier_fallback");
|
|
2425
|
+
logger.warn({
|
|
2426
|
+
servedTier: cand.tier,
|
|
2427
|
+
servedProvider: cand.provider,
|
|
2428
|
+
fromTier: routingDecision.tier,
|
|
2429
|
+
direction: cand.direction,
|
|
2430
|
+
}, "[TierFallback] Served by tier fallback");
|
|
2431
|
+
|
|
2432
|
+
return {
|
|
2433
|
+
...attempt,
|
|
2434
|
+
actualProvider: cand.provider,
|
|
2435
|
+
routingDecision: {
|
|
2436
|
+
...routingDecision,
|
|
2437
|
+
provider: cand.provider,
|
|
2438
|
+
model: cand.model,
|
|
2439
|
+
servedTier: cand.tier,
|
|
2440
|
+
fromTier: routingDecision.tier,
|
|
2441
|
+
fallback: true,
|
|
2442
|
+
fallbackDirection: cand.direction,
|
|
2443
|
+
method: "tier_fallback",
|
|
2444
|
+
},
|
|
2445
|
+
};
|
|
2446
|
+
} catch (innerErr) {
|
|
2447
|
+
logger.warn(
|
|
2448
|
+
{ toProvider: cand.provider, error: innerErr.message },
|
|
2449
|
+
"[TierFallback] Candidate failed, trying next"
|
|
2450
|
+
);
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
2453
|
+
logger.warn(
|
|
2454
|
+
{ fromTier: routingDecision.tier },
|
|
2455
|
+
"[TierFallback] All tier candidates exhausted — falling through"
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2252
2459
|
// Check if we should fallback (any provider can fall back, not just ollama)
|
|
2253
2460
|
const shouldFallback =
|
|
2254
2461
|
isFallbackEnabled() &&
|
|
@@ -2394,6 +2601,9 @@ async function invokeModel(body, options = {}) {
|
|
|
2394
2601
|
{ status_code: 200, output_tokens: fbOutputTokens, tool_calls_made: fbToolCalls, was_fallback: true, retry_count: 0, latency_ms: Date.now() - startTime }
|
|
2395
2602
|
),
|
|
2396
2603
|
tokens_per_second: fbOutputTokens && fallbackLatency > 0 ? fbOutputTokens / (fallbackLatency / 1000) : null,
|
|
2604
|
+
cost_usd: computeCostUsd(routingDecision.model || body._tierModel, fbInputTokens, fbOutputTokens),
|
|
2605
|
+
request_text: captureRequestText(body),
|
|
2606
|
+
response_text: captureResponseText(fallbackResult.json),
|
|
2397
2607
|
});
|
|
2398
2608
|
|
|
2399
2609
|
// Return result with actual provider used (fallback provider) and routing decision
|
|
@@ -2502,4 +2712,5 @@ function destroyHttpAgents() {
|
|
|
2502
2712
|
module.exports = {
|
|
2503
2713
|
invokeModel,
|
|
2504
2714
|
destroyHttpAgents,
|
|
2715
|
+
normalizeBodyForConverse,
|
|
2505
2716
|
};
|
|
@@ -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,33 +147,24 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
}
|
|
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);
|
|
155
|
+
|
|
156
|
+
// Kimi/Moonshot (and some OpenAI-compatible APIs) reject a message whose
|
|
157
|
+
// content is an empty string with "Invalid request: tokenization failed".
|
|
158
|
+
// This happens when a turn had only non-text blocks (thinking / image /
|
|
159
|
+
// stripped content) and flattened to "". Replace empty/whitespace-only
|
|
160
|
+
// content with a single space — but never touch an assistant message that
|
|
161
|
+
// carries tool_calls, where content: null is intentional and required.
|
|
162
|
+
for (const m of converted) {
|
|
163
|
+
if (m.role === 'tool') continue;
|
|
164
|
+
const hasToolCalls = Array.isArray(m.tool_calls) && m.tool_calls.length > 0;
|
|
165
|
+
if (hasToolCalls) continue;
|
|
166
|
+
if (typeof m.content !== 'string' || m.content.trim() === '') {
|
|
167
|
+
m.content = ' ';
|
|
176
168
|
}
|
|
177
169
|
}
|
|
178
170
|
|
|
@@ -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,6 +174,9 @@ 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;
|
|
132
180
|
return injectAnthropicCacheBreakpoints(body);
|
|
133
181
|
}
|
|
134
182
|
|
|
@@ -137,4 +185,5 @@ module.exports = {
|
|
|
137
185
|
injectAnthropicCacheBreakpoints,
|
|
138
186
|
injectGeminiCacheBreakpoints,
|
|
139
187
|
needsCacheInjection,
|
|
188
|
+
modelSupportsCacheControl,
|
|
140
189
|
};
|
|
@@ -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
|
@@ -208,6 +208,11 @@ const tokenBudgetWarning = Number.parseInt(process.env.TOKEN_BUDGET_WARNING ?? "
|
|
|
208
208
|
const tokenBudgetMax = Number.parseInt(process.env.TOKEN_BUDGET_MAX ?? "180000", 10);
|
|
209
209
|
const tokenBudgetEnforcement = process.env.TOKEN_BUDGET_ENFORCEMENT !== "false"; // default true
|
|
210
210
|
|
|
211
|
+
// Caveman terse-output injection (opt-in, off by default)
|
|
212
|
+
const cavemanEnabled = process.env.CAVEMAN_ENABLED === "true";
|
|
213
|
+
const cavemanLevel = (process.env.CAVEMAN_LEVEL ?? "lite").toLowerCase();
|
|
214
|
+
|
|
215
|
+
|
|
211
216
|
// TOON payload compression (opt-in)
|
|
212
217
|
const toonEnabled = process.env.TOON_ENABLED === "true"; // default false
|
|
213
218
|
const toonMinBytes = Number.parseInt(process.env.TOON_MIN_BYTES ?? "4096", 10);
|
|
@@ -513,6 +518,12 @@ const agentsDefaultModel = process.env.AGENTS_DEFAULT_MODEL ?? "haiku";
|
|
|
513
518
|
const agentsMaxSteps = Number.parseInt(process.env.AGENTS_MAX_STEPS ?? "15", 10);
|
|
514
519
|
const agentsTimeout = Number.parseInt(process.env.AGENTS_TIMEOUT ?? "120000", 10);
|
|
515
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
|
+
|
|
516
527
|
// LLM Audit logging configuration
|
|
517
528
|
const auditEnabled = process.env.LLM_AUDIT_ENABLED === "true"; // default false
|
|
518
529
|
const auditLogFile = process.env.LLM_AUDIT_LOG_FILE ?? path.join(process.cwd(), "logs", "llm-audit.log");
|
|
@@ -641,6 +652,10 @@ var config = {
|
|
|
641
652
|
toolResultCompression: {
|
|
642
653
|
enabled: true,
|
|
643
654
|
},
|
|
655
|
+
caveman: {
|
|
656
|
+
enabled: cavemanEnabled,
|
|
657
|
+
level: cavemanLevel,
|
|
658
|
+
},
|
|
644
659
|
server: {
|
|
645
660
|
jsonLimit: process.env.REQUEST_JSON_LIMIT ?? "1gb",
|
|
646
661
|
},
|
|
@@ -766,6 +781,23 @@ var config = {
|
|
|
766
781
|
maxSteps: Number.isNaN(agentsMaxSteps) ? 15 : agentsMaxSteps,
|
|
767
782
|
timeout: Number.isNaN(agentsTimeout) ? 120000 : agentsTimeout,
|
|
768
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
|
+
},
|
|
769
801
|
tests: {
|
|
770
802
|
defaultCommand: testDefaultCommand ? testDefaultCommand.trim() : null,
|
|
771
803
|
defaultArgs: testDefaultArgs,
|