lynkr 9.5.0 → 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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Decomposition planner (Phase 2).
3
+ *
4
+ * Turns a complex task into a small subtask DAG using a single model call
5
+ * (plan-and-solve style — plan generated in one shot). Output is validated:
6
+ * ids unique, dependencies reference real ids, no cycles, subtask count capped.
7
+ * If anything fails to parse/validate the planner returns null and the caller
8
+ * falls back to a monolithic solve.
9
+ */
10
+
11
+ const { callModel, extractText } = require("./model-call");
12
+ const logger = require("../../logger");
13
+
14
+ // Agent types the dispatcher knows how to spawn. Planner is steered toward
15
+ // these; unknown types are coerced to general-purpose at dispatch time.
16
+ const KNOWN_AGENT_TYPES = [
17
+ "Explore",
18
+ "Plan",
19
+ "general-purpose",
20
+ "Test",
21
+ "Debug",
22
+ "Fix",
23
+ "Refactor",
24
+ "Documentation",
25
+ ];
26
+
27
+ function buildPlannerPrompt(task, maxSubtasks) {
28
+ return `You are a task-decomposition planner. Break the task below into the SMALLEST set of focused subtasks that can be solved with isolated context. Fewer is better — do NOT over-split. If the task is not genuinely divisible, return a single subtask.
29
+
30
+ Rules:
31
+ - At most ${maxSubtasks} subtasks.
32
+ - Each subtask must be independently solvable given only its prompt plus the results of the subtasks it depends on.
33
+ - Mark dependencies via "dependsOn" (array of subtask ids). Independent subtasks (empty dependsOn) will run in parallel.
34
+ - Prefer assigning each subtask an agent type from: ${KNOWN_AGENT_TYPES.join(", ")}.
35
+ - Keep each subtask prompt self-contained and specific.
36
+
37
+ Respond with ONLY a JSON object, no prose, in exactly this shape:
38
+ {
39
+ "strategy": "one short sentence on how you split it",
40
+ "subtasks": [
41
+ { "id": "s1", "agentType": "Explore", "prompt": "...", "dependsOn": [] }
42
+ ]
43
+ }
44
+
45
+ TASK:
46
+ ${task}`;
47
+ }
48
+
49
+ /**
50
+ * Extract the first balanced JSON object from a string (handles models that
51
+ * wrap JSON in prose or ```json fences).
52
+ */
53
+ function extractJsonObject(text) {
54
+ if (!text) return null;
55
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
56
+ const candidate = fenced ? fenced[1] : text;
57
+
58
+ const start = candidate.indexOf("{");
59
+ if (start === -1) return null;
60
+
61
+ let depth = 0;
62
+ let inString = false;
63
+ let escape = false;
64
+ for (let i = start; i < candidate.length; i++) {
65
+ const ch = candidate[i];
66
+ if (escape) {
67
+ escape = false;
68
+ continue;
69
+ }
70
+ if (ch === "\\") {
71
+ escape = true;
72
+ continue;
73
+ }
74
+ if (ch === '"') {
75
+ inString = !inString;
76
+ continue;
77
+ }
78
+ if (inString) continue;
79
+ if (ch === "{") depth++;
80
+ else if (ch === "}") {
81
+ depth--;
82
+ if (depth === 0) {
83
+ const slice = candidate.slice(start, i + 1);
84
+ try {
85
+ return JSON.parse(slice);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Validate and normalise a parsed plan. Returns a clean plan or null.
97
+ */
98
+ function validatePlan(parsed, maxSubtasks) {
99
+ if (!parsed || !Array.isArray(parsed.subtasks) || parsed.subtasks.length === 0) {
100
+ return null;
101
+ }
102
+
103
+ const subtasks = [];
104
+ const seenIds = new Set();
105
+
106
+ for (let i = 0; i < parsed.subtasks.length && subtasks.length < maxSubtasks; i++) {
107
+ const st = parsed.subtasks[i];
108
+ if (!st || typeof st.prompt !== "string" || st.prompt.trim().length === 0) {
109
+ return null; // malformed subtask → reject whole plan, fall back
110
+ }
111
+ const id = typeof st.id === "string" && st.id.trim() ? st.id.trim() : `s${i + 1}`;
112
+ if (seenIds.has(id)) return null; // duplicate ids
113
+ seenIds.add(id);
114
+
115
+ const agentType = KNOWN_AGENT_TYPES.includes(st.agentType)
116
+ ? st.agentType
117
+ : "general-purpose";
118
+
119
+ const dependsOn = Array.isArray(st.dependsOn)
120
+ ? st.dependsOn.filter((d) => typeof d === "string")
121
+ : [];
122
+
123
+ subtasks.push({ id, agentType, prompt: st.prompt.trim(), dependsOn });
124
+ }
125
+
126
+ // Dependencies must reference real ids and contain no cycles.
127
+ for (const st of subtasks) {
128
+ for (const dep of st.dependsOn) {
129
+ if (!seenIds.has(dep)) return null; // dangling dependency
130
+ }
131
+ }
132
+ if (hasCycle(subtasks)) return null;
133
+
134
+ return {
135
+ strategy: typeof parsed.strategy === "string" ? parsed.strategy : "",
136
+ subtasks,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Cycle detection via DFS colouring.
142
+ */
143
+ function hasCycle(subtasks) {
144
+ const byId = new Map(subtasks.map((s) => [s.id, s]));
145
+ const state = new Map(); // id → 0 unvisited, 1 visiting, 2 done
146
+
147
+ function visit(id) {
148
+ const cur = state.get(id) || 0;
149
+ if (cur === 1) return true; // back-edge → cycle
150
+ if (cur === 2) return false;
151
+ state.set(id, 1);
152
+ const node = byId.get(id);
153
+ for (const dep of node?.dependsOn || []) {
154
+ if (visit(dep)) return true;
155
+ }
156
+ state.set(id, 2);
157
+ return false;
158
+ }
159
+
160
+ for (const s of subtasks) {
161
+ if (visit(s.id)) return true;
162
+ }
163
+ return false;
164
+ }
165
+
166
+ /**
167
+ * Generate a validated plan for a task.
168
+ * @param {Object} params
169
+ * @param {string} params.task
170
+ * @param {string} [params.model="sonnet"] - planning needs reasoning; use a capable model
171
+ * @param {number} [params.maxSubtasks=6]
172
+ * @param {Function} [params.invoke] - injectable model invoker (for tests)
173
+ * @returns {Promise<{strategy:string, subtasks:Array, usage:Object}|null>}
174
+ */
175
+ async function generatePlan({ task, model = "sonnet", maxSubtasks = 6, invoke } = {}) {
176
+ if (!task || typeof task !== "string") return null;
177
+
178
+ let responseJson;
179
+ try {
180
+ responseJson = await callModel({
181
+ messages: [{ role: "user", content: buildPlannerPrompt(task, maxSubtasks) }],
182
+ model,
183
+ maxTokens: 1500,
184
+ temperature: 0.1,
185
+ invoke,
186
+ });
187
+ } catch (err) {
188
+ logger.warn({ err: err.message }, "[Decomposition] Planner model call failed");
189
+ return null;
190
+ }
191
+
192
+ const text = extractText(responseJson);
193
+ const parsed = extractJsonObject(text);
194
+ const plan = validatePlan(parsed, maxSubtasks);
195
+
196
+ if (!plan) {
197
+ logger.warn(
198
+ { preview: text.slice(0, 200) },
199
+ "[Decomposition] Plan failed validation — will fall back to monolithic"
200
+ );
201
+ return null;
202
+ }
203
+
204
+ plan.usage = {
205
+ inputTokens: responseJson?.usage?.input_tokens || 0,
206
+ outputTokens: responseJson?.usage?.output_tokens || 0,
207
+ };
208
+
209
+ logger.info(
210
+ { subtasks: plan.subtasks.length, strategy: plan.strategy },
211
+ "[Decomposition] Plan generated"
212
+ );
213
+ return plan;
214
+ }
215
+
216
+ module.exports = {
217
+ generatePlan,
218
+ validatePlan,
219
+ extractJsonObject,
220
+ hasCycle,
221
+ buildPlannerPrompt,
222
+ KNOWN_AGENT_TYPES,
223
+ };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Result synthesizer (Phase 4).
3
+ *
4
+ * Combines the (compressed) results of all subtasks into one coherent final
5
+ * answer to the original task. Single model call. If synthesis fails, the
6
+ * caller falls back to concatenating the subtask results.
7
+ */
8
+
9
+ const { callModel, extractText } = require("./model-call");
10
+ const logger = require("../../logger");
11
+
12
+ const MAX_RESULT_CHARS = 4000;
13
+
14
+ function buildSynthesisPrompt(task, subtaskResults) {
15
+ const blocks = subtaskResults
16
+ .map((r) => {
17
+ const status = r.success ? "OK" : `FAILED (${r.error})`;
18
+ const body = r.success
19
+ ? truncate(r.result, MAX_RESULT_CHARS)
20
+ : "(no result)";
21
+ return `### Subtask ${r.id} [${r.agentType}] — ${status}\n${body}`;
22
+ })
23
+ .join("\n\n");
24
+
25
+ return `You are synthesizing the results of several subtasks into one final answer for the original request. Integrate the findings into a single coherent response. Resolve overlaps, note any subtask that failed, and do not invent results that no subtask produced.
26
+
27
+ ORIGINAL TASK:
28
+ ${task}
29
+
30
+ SUBTASK RESULTS:
31
+ ${blocks}
32
+
33
+ Write the final answer now.`;
34
+ }
35
+
36
+ function truncate(text, max) {
37
+ if (typeof text !== "string") return String(text ?? "");
38
+ return text.length <= max ? text : text.slice(0, max) + "\n…[truncated]";
39
+ }
40
+
41
+ /**
42
+ * Concatenation fallback used when the synthesis model call fails.
43
+ */
44
+ function concatFallback(subtaskResults) {
45
+ return subtaskResults
46
+ .filter((r) => r.success && r.result)
47
+ .map((r) => `## ${r.id} (${r.agentType})\n${r.result}`)
48
+ .join("\n\n");
49
+ }
50
+
51
+ /**
52
+ * @param {Object} params
53
+ * @param {string} params.task
54
+ * @param {Array} params.subtaskResults - from dispatcher
55
+ * @param {string} [params.model="sonnet"]
56
+ * @param {Function} [params.invoke]
57
+ * @returns {Promise<{text:string, fallback:boolean, usage:Object}>}
58
+ */
59
+ async function synthesize({ task, subtaskResults, model = "sonnet", invoke } = {}) {
60
+ const anySuccess = subtaskResults.some((r) => r.success);
61
+ if (!anySuccess) {
62
+ return { text: "All subtasks failed; no result could be produced.", fallback: true, usage: {} };
63
+ }
64
+
65
+ try {
66
+ const responseJson = await callModel({
67
+ messages: [{ role: "user", content: buildSynthesisPrompt(task, subtaskResults) }],
68
+ model,
69
+ maxTokens: 4096,
70
+ temperature: 0.3,
71
+ invoke,
72
+ });
73
+ const text = extractText(responseJson);
74
+ if (!text) throw new Error("Empty synthesis");
75
+ return {
76
+ text,
77
+ fallback: false,
78
+ usage: {
79
+ inputTokens: responseJson?.usage?.input_tokens || 0,
80
+ outputTokens: responseJson?.usage?.output_tokens || 0,
81
+ },
82
+ };
83
+ } catch (err) {
84
+ logger.warn({ err: err.message }, "[Decomposition] Synthesis failed — concatenating results");
85
+ return { text: concatFallback(subtaskResults), fallback: true, usage: {} };
86
+ }
87
+ }
88
+
89
+ module.exports = { synthesize, buildSynthesisPrompt, concatFallback };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Decomposition telemetry + shadow mode (Phase 6).
3
+ *
4
+ * Appends one JSON line per decomposition decision to
5
+ * data/decomposition-decisions.jsonl so the net token effect can be audited.
6
+ * Because the research is clear that decomposition can COST more than it saves,
7
+ * a shadow mode (TASK_DECOMPOSITION_SHADOW=true) runs the gate + records what it
8
+ * WOULD have done without actually decomposing — so savings can be validated on
9
+ * real traffic before enabling for real.
10
+ */
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const logger = require("../../logger");
15
+
16
+ const LOG_PATH = path.join(__dirname, "../../../data/decomposition-decisions.jsonl");
17
+
18
+ function estimateTokens(text) {
19
+ if (typeof text !== "string") return 0;
20
+ return Math.ceil(text.length / 4);
21
+ }
22
+
23
+ /**
24
+ * Rough net-savings estimate.
25
+ * monolithic ≈ what one big context would have cost (estimated input tokens).
26
+ * decomposed ≈ planning + Σ(subagent in+out) + synthesis.
27
+ * Positive `savedTokens` = decomposition was cheaper.
28
+ */
29
+ function estimateSavings({ monolithicTokens, planUsage, dispatchStats, synthUsage }) {
30
+ const decomposed =
31
+ (planUsage?.inputTokens || 0) +
32
+ (planUsage?.outputTokens || 0) +
33
+ (dispatchStats?.inputTokens || 0) +
34
+ (dispatchStats?.outputTokens || 0) +
35
+ (synthUsage?.inputTokens || 0) +
36
+ (synthUsage?.outputTokens || 0);
37
+ return {
38
+ monolithicTokens: monolithicTokens || 0,
39
+ decomposedTokens: decomposed,
40
+ savedTokens: (monolithicTokens || 0) - decomposed,
41
+ };
42
+ }
43
+
44
+ function record(entry) {
45
+ const line = { timestamp: Date.now(), ...entry };
46
+ try {
47
+ fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
48
+ fs.appendFileSync(LOG_PATH, JSON.stringify(line) + "\n");
49
+ } catch (err) {
50
+ logger.debug({ err: err.message }, "[Decomposition] Telemetry append failed");
51
+ }
52
+ return line;
53
+ }
54
+
55
+ module.exports = { record, estimateSavings, estimateTokens, LOG_PATH };
@@ -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
- const bedrockBody = { ...body, tools: toolsToSend };
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;
@@ -2124,6 +2185,13 @@ async function invokeModel(body, options = {}) {
2124
2185
  const { injectPromptCaching } = require('./prompt-cache-injection');
2125
2186
  injectPromptCaching(body, initialProvider);
2126
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
+
2127
2195
  // Build routing decision object for response headers
2128
2196
  const routingDecision = {
2129
2197
  provider: initialProvider,
@@ -2323,6 +2391,71 @@ async function invokeModel(body, options = {}) {
2323
2391
  healthTracker.recordFailure(initialProvider, err, err.status);
2324
2392
  getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
2325
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
+
2326
2459
  // Check if we should fallback (any provider can fall back, not just ollama)
2327
2460
  const shouldFallback =
2328
2461
  isFallbackEnabled() &&
@@ -2579,4 +2712,5 @@ function destroyHttpAgents() {
2579
2712
  module.exports = {
2580
2713
  invokeModel,
2581
2714
  destroyHttpAgents,
2715
+ normalizeBodyForConverse,
2582
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,35 +147,11 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) {
146
147
  }
147
148
  }
148
149
 
149
- // Fix tool_call_id mismatches: ensure every tool message's tool_call_id
150
- // matches the id in the preceding assistant's tool_calls array.
151
- // IDs can drift when multiple conversion layers (Anthropic↔OpenAI) each
152
- // generate their own IDs.
153
- for (let i = 0; i < converted.length; i++) {
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,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,