lynkr 9.9.1 → 9.11.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 +50 -9
- package/bin/cli.js +2 -0
- package/bin/lynkr-init.js +11 -12
- package/bin/lynkr-reset.js +71 -0
- package/config/model-tiers.json +199 -52
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/scripts/validate-difficulty-classifier.js +27 -6
- package/src/api/providers-handler.js +0 -1
- package/src/api/router.js +309 -160
- package/src/clients/databricks.js +215 -71
- package/src/clients/gpt-utils.js +8 -1
- package/src/clients/ollama-utils.js +19 -10
- package/src/clients/prompt-cache-injection.js +93 -30
- package/src/config/index.js +82 -43
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +5 -1
- package/src/memory/distiller.js +350 -0
- package/src/memory/extractor.js +0 -8
- package/src/memory/format.js +17 -2
- package/src/memory/index.js +10 -0
- package/src/memory/retriever.js +7 -2
- package/src/memory/skills-cache.js +180 -0
- package/src/memory/tencentdb-launcher.js +459 -0
- package/src/memory/wiki.js +171 -0
- package/src/orchestrator/index.js +202 -1237
- package/src/orchestrator/passthrough-stream.js +382 -0
- package/src/orchestrator/sse-transformer.js +408 -0
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +85 -4
- package/src/routing/agentic-detector.js +9 -1
- package/src/routing/cache-economics.js +123 -0
- package/src/routing/cache-switch-cost.js +194 -0
- package/src/routing/calibration.js +8 -1
- package/src/routing/complexity-analyzer.js +7 -1
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +161 -17
- package/src/routing/index.js +196 -56
- package/src/routing/intent-score.js +20 -2
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +68 -1
- package/src/routing/side-channel-detector.js +103 -0
- package/src/routing/telemetry.js +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -49
- package/src/tools/code-graph.js +6 -4
- package/src/agents/context-manager.js +0 -236
- package/src/agents/decomposition/dispatcher.js +0 -185
- package/src/agents/decomposition/gate.js +0 -136
- package/src/agents/decomposition/index.js +0 -183
- package/src/agents/decomposition/model-call.js +0 -75
- package/src/agents/decomposition/planner.js +0 -223
- package/src/agents/decomposition/synthesizer.js +0 -89
- package/src/agents/decomposition/telemetry.js +0 -55
- package/src/agents/definitions/loader.js +0 -653
- package/src/agents/executor.js +0 -457
- package/src/agents/index.js +0 -165
- package/src/agents/parallel-coordinator.js +0 -68
- package/src/agents/reflector.js +0 -331
- package/src/agents/skillbook.js +0 -331
- package/src/agents/store.js +0 -259
- package/src/edits/index.js +0 -171
- package/src/indexer/babel-parser.js +0 -213
- package/src/indexer/index.js +0 -1629
- package/src/indexer/navigation/index.js +0 -32
- package/src/indexer/navigation/providers/treeSitter.js +0 -36
- package/src/indexer/parser.js +0 -443
- package/src/tasks/store.js +0 -349
- package/src/tests/coverage.js +0 -173
- package/src/tests/index.js +0 -171
- package/src/tests/store.js +0 -213
- package/src/tools/agent-task.js +0 -145
- package/src/tools/code-mode.js +0 -304
- package/src/tools/decompose.js +0 -91
- package/src/tools/edits.js +0 -94
- package/src/tools/execution.js +0 -171
- package/src/tools/git.js +0 -1346
- package/src/tools/index.js +0 -306
- package/src/tools/indexer.js +0 -360
- package/src/tools/lazy-loader.js +0 -366
- package/src/tools/mcp-remote.js +0 -88
- package/src/tools/mcp.js +0 -116
- package/src/tools/process.js +0 -167
- package/src/tools/smart-selection.js +0 -180
- package/src/tools/stubs.js +0 -55
- package/src/tools/tasks.js +0 -260
- package/src/tools/tests.js +0 -132
- package/src/tools/tinyfish.js +0 -358
- package/src/tools/truncate.js +0 -106
- package/src/tools/web-client.js +0 -71
- package/src/tools/web.js +0 -415
- package/src/tools/workspace.js +0 -204
|
@@ -299,8 +299,16 @@ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
|
|
|
299
299
|
}
|
|
300
300
|
|
|
301
301
|
// OAuth passthrough: prefer incoming Bearer token (Claude Pro/Max subscription)
|
|
302
|
-
// over a configured API key
|
|
303
|
-
|
|
302
|
+
// over a configured API key — but ONLY when it is actually an Anthropic
|
|
303
|
+
// token (sk-ant-*). OpenAI-shape clients (Codex, goose) send their own
|
|
304
|
+
// provider's Bearer, and forwarding that to api.anthropic.com earns a
|
|
305
|
+
// guaranteed 401 "Invalid bearer token" (live incident 2026-07-21, Codex
|
|
306
|
+
// via tier fallback).
|
|
307
|
+
let incomingAuth = incomingHeaders?.authorization || incomingHeaders?.Authorization;
|
|
308
|
+
if (incomingAuth && incomingAuth.startsWith("Bearer ") && !incomingAuth.slice("Bearer ".length).startsWith("sk-ant-")) {
|
|
309
|
+
logger.debug({ tokenShape: incomingAuth.slice(7, 15) + "…" }, "Azure Anthropic: ignoring non-Anthropic bearer token from client");
|
|
310
|
+
incomingAuth = null;
|
|
311
|
+
}
|
|
304
312
|
|
|
305
313
|
// Headers Anthropic uses to verify client identity for subscription OAuth tokens.
|
|
306
314
|
// If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
|
|
@@ -552,9 +560,20 @@ async function invokeOllama(body, incomingHeaders = {}) {
|
|
|
552
560
|
if (body.tool_choice) ollamaBody.tool_choice = body.tool_choice;
|
|
553
561
|
if (body.metadata) ollamaBody.metadata = body.metadata;
|
|
554
562
|
|
|
555
|
-
// Tools
|
|
563
|
+
// Tools: normalize to Anthropic shape. Callers that stayed OpenAI-format
|
|
564
|
+
// upstream (OpenWorker, aisuite, any client hitting /v1/chat/completions)
|
|
565
|
+
// otherwise ship {type:"function", function:{...}} which Ollama's
|
|
566
|
+
// /v1/messages endpoint rejects with 400 "can't find closing '}' symbol".
|
|
556
567
|
if (supportsTools && Array.isArray(toolsToSend) && toolsToSend.length > 0) {
|
|
557
|
-
ollamaBody.tools = toolsToSend
|
|
568
|
+
ollamaBody.tools = toolsToSend.map(tool =>
|
|
569
|
+
tool?.type === "function" && tool.function
|
|
570
|
+
? {
|
|
571
|
+
name: tool.function.name,
|
|
572
|
+
description: tool.function.description || "",
|
|
573
|
+
input_schema: tool.function.parameters || { type: "object", properties: {} },
|
|
574
|
+
}
|
|
575
|
+
: tool
|
|
576
|
+
);
|
|
558
577
|
}
|
|
559
578
|
|
|
560
579
|
if (config.ollama.keepAlive !== undefined) {
|
|
@@ -1374,13 +1393,37 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
|
|
|
1374
1393
|
headers["Authorization"] = `Bearer ${config.llamacpp.apiKey}`;
|
|
1375
1394
|
}
|
|
1376
1395
|
|
|
1377
|
-
|
|
1396
|
+
let messages = convertAnthropicMessagesToOpenRouter(body.messages || []);
|
|
1378
1397
|
|
|
1379
1398
|
if (body.system) {
|
|
1380
1399
|
messages.unshift({ role: "system", content: body.system });
|
|
1381
1400
|
}
|
|
1382
1401
|
|
|
1383
|
-
//
|
|
1402
|
+
// Fold EVERY system-role message into one leading system message. Claude
|
|
1403
|
+
// Code 2.1.216+ sends system-role entries inside `messages` (a leading one
|
|
1404
|
+
// plus a trailing billing-header-tagged one), and strict Jinja chat
|
|
1405
|
+
// templates (Qwen-family, e.g. Ornith) raise "System message must be at
|
|
1406
|
+
// the beginning" for any non-leading system message. Folding preserves
|
|
1407
|
+
// the content the old consecutive-dedup used to silently DROP.
|
|
1408
|
+
const systemParts = [];
|
|
1409
|
+
const nonSystem = [];
|
|
1410
|
+
for (const msg of messages) {
|
|
1411
|
+
if (msg.role === "system") {
|
|
1412
|
+
const text = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content);
|
|
1413
|
+
if (text && text.trim()) systemParts.push(text);
|
|
1414
|
+
} else {
|
|
1415
|
+
nonSystem.push(msg);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (systemParts.length > 0) {
|
|
1419
|
+
nonSystem.unshift({ role: "system", content: systemParts.join("\n\n") });
|
|
1420
|
+
if (systemParts.length > 1) {
|
|
1421
|
+
logger.debug({ folded: systemParts.length }, "llama.cpp: folded system messages into one leading system message");
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
messages = nonSystem;
|
|
1425
|
+
|
|
1426
|
+
// Deduplicate consecutive messages with same role (llama.cpp rejects this)
|
|
1384
1427
|
const deduplicated = [];
|
|
1385
1428
|
let lastRole = null;
|
|
1386
1429
|
for (const msg of messages) {
|
|
@@ -1460,7 +1503,20 @@ async function invokeLlamaCpp(body, incomingHeaders = {}) {
|
|
|
1460
1503
|
}))
|
|
1461
1504
|
}, "=== LLAMA.CPP REQUEST ===");
|
|
1462
1505
|
|
|
1463
|
-
|
|
1506
|
+
const result = await performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp");
|
|
1507
|
+
|
|
1508
|
+
// Context overflow is a CAPACITY error, not a request error: the local
|
|
1509
|
+
// model's slot is simply too small for this conversation (llama-server
|
|
1510
|
+
// splits -c across --parallel slots). Returning the 400 as-is fails the
|
|
1511
|
+
// request AND records a provider "success" (nothing threw), so the
|
|
1512
|
+
// tier-fallback ladder never rescues it. Throw instead — same pattern as
|
|
1513
|
+
// _throwIfOllamaError — so invokeModel escalates to a bigger-context tier.
|
|
1514
|
+
const _llamaErrMsg = result?.json?.error?.message || "";
|
|
1515
|
+
if (!result?.ok && /exceeds the available context size/i.test(_llamaErrMsg)) {
|
|
1516
|
+
throw new Error(`llama.cpp context overflow: ${_llamaErrMsg}`);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
return result;
|
|
1464
1520
|
}
|
|
1465
1521
|
|
|
1466
1522
|
async function invokeLMStudio(body, incomingHeaders = {}) {
|
|
@@ -1889,6 +1945,21 @@ async function invokeZai(body, incomingHeaders = {}) {
|
|
|
1889
1945
|
}, "=== INJECTING STANDARD TOOLS (Z.AI Anthropic) ===");
|
|
1890
1946
|
}
|
|
1891
1947
|
|
|
1948
|
+
// Normalize to Anthropic tool shape — the /anthropic/v1/messages endpoint
|
|
1949
|
+
// rejects OpenAI-nested tools ({type:"function", function:{name,...}}) with
|
|
1950
|
+
// 422 missing body.tools[0].name. Callers that stayed OpenAI-format upstream
|
|
1951
|
+
// (OpenWorker, aisuite, any client hitting /v1/chat/completions with an
|
|
1952
|
+
// unusual tool shape) get unwrapped here.
|
|
1953
|
+
zaiBody.tools = zaiBody.tools.map(tool =>
|
|
1954
|
+
tool?.type === "function" && tool.function
|
|
1955
|
+
? {
|
|
1956
|
+
name: tool.function.name,
|
|
1957
|
+
description: tool.function.description || "",
|
|
1958
|
+
input_schema: tool.function.parameters || { type: "object", properties: {} },
|
|
1959
|
+
}
|
|
1960
|
+
: tool
|
|
1961
|
+
);
|
|
1962
|
+
|
|
1892
1963
|
headers = {
|
|
1893
1964
|
"Content-Type": "application/json",
|
|
1894
1965
|
"x-api-key": config.zai.apiKey,
|
|
@@ -1974,13 +2045,17 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
1974
2045
|
|
|
1975
2046
|
// Model mapping: Anthropic names → Moonshot/Kimi names
|
|
1976
2047
|
const modelMap = {
|
|
1977
|
-
"claude-sonnet-4-5-20250929": "kimi-k2
|
|
1978
|
-
"claude-sonnet-4-5": "kimi-k2
|
|
1979
|
-
"claude-sonnet-4.5": "kimi-k2
|
|
1980
|
-
"claude-3-5-sonnet": "kimi-k2
|
|
1981
|
-
"claude-haiku-4-5-20251001": "kimi-k2
|
|
1982
|
-
"claude-haiku-4-5": "kimi-k2
|
|
1983
|
-
"claude-3-haiku": "kimi-k2
|
|
2048
|
+
"claude-sonnet-4-5-20250929": "kimi-k2.6",
|
|
2049
|
+
"claude-sonnet-4-5": "kimi-k2.6",
|
|
2050
|
+
"claude-sonnet-4.5": "kimi-k2.6",
|
|
2051
|
+
"claude-3-5-sonnet": "kimi-k2.6",
|
|
2052
|
+
"claude-haiku-4-5-20251001": "kimi-k2.6",
|
|
2053
|
+
"claude-haiku-4-5": "kimi-k2.6",
|
|
2054
|
+
"claude-3-haiku": "kimi-k2.6",
|
|
2055
|
+
// Retired ids (gone from /v1/models as of 2026-07: "Not found the model")
|
|
2056
|
+
// that older tier configs may still reference.
|
|
2057
|
+
"kimi-k2-turbo-preview": "kimi-k2.6",
|
|
2058
|
+
"kimi-k2-thinking": "kimi-k3",
|
|
1984
2059
|
// moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
|
|
1985
2060
|
// context-size pass fails on large tool-bearing payloads). Remap to a
|
|
1986
2061
|
// fixed model that's broadly available on api.moonshot.ai.
|
|
@@ -1988,7 +2063,12 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
1988
2063
|
};
|
|
1989
2064
|
|
|
1990
2065
|
const requestedModel = body._tierModel || body.model || config.moonshot.model;
|
|
1991
|
-
|
|
2066
|
+
// Honor tier-selected Moonshot ids (e.g. TIER_REASONING=moonshot:kimi-k2.6)
|
|
2067
|
+
// instead of silently swapping in the .env default model.
|
|
2068
|
+
let mappedModel = modelMap[requestedModel]
|
|
2069
|
+
|| (/^(kimi-|moonshot-v1)/i.test(requestedModel || "") ? requestedModel : null)
|
|
2070
|
+
|| config.moonshot.model
|
|
2071
|
+
|| "kimi-k3";
|
|
1992
2072
|
// Guard against the deprecated auto model arriving via config too.
|
|
1993
2073
|
if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
|
|
1994
2074
|
|
|
@@ -2002,18 +2082,18 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2002
2082
|
messages.unshift({ role: "system", content: systemContent });
|
|
2003
2083
|
}
|
|
2004
2084
|
|
|
2005
|
-
// kimi-
|
|
2006
|
-
//
|
|
2007
|
-
|
|
2085
|
+
// Every current kimi-k* model (k2.5, k2.6, k2.7-code, k3, …) pins sampling
|
|
2086
|
+
// params: temperature must be 1 — any other value 400s with "invalid
|
|
2087
|
+
// temperature: only 1 is allowed for this model" (probed live 2026-07-23).
|
|
2088
|
+
// Only the moonshot-v1-* models accept caller-supplied values.
|
|
2089
|
+
const isKimiPinned = /^kimi-k/i.test(mappedModel);
|
|
2008
2090
|
|
|
2009
2091
|
const moonshotBody = {
|
|
2010
2092
|
model: mappedModel,
|
|
2011
2093
|
messages,
|
|
2012
2094
|
max_tokens: body.max_tokens || 16384,
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
temperature: isKimiThinking ? 1 : (body.temperature ?? 0.7),
|
|
2016
|
-
top_p: isKimiThinking ? 0.95 : (body.top_p ?? 1.0),
|
|
2095
|
+
temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
|
|
2096
|
+
top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
|
|
2017
2097
|
stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
|
|
2018
2098
|
};
|
|
2019
2099
|
|
|
@@ -2633,6 +2713,43 @@ function stripLynkrBadges(messages) {
|
|
|
2633
2713
|
return mutated ? out : messages;
|
|
2634
2714
|
}
|
|
2635
2715
|
|
|
2716
|
+
// Single source of truth for provider dispatch. Both the initial call and the
|
|
2717
|
+
// fallback ladder MUST route through invokeProvider — the previous duplicated
|
|
2718
|
+
// if/else chains drifted (the fallback chain was missing ollama, bedrock,
|
|
2719
|
+
// lmstudio and codex), and unknown providers silently dialed databricks,
|
|
2720
|
+
// which under tier routing is a deliberate dead number (see .env note about
|
|
2721
|
+
// the 2026-07-07 self-loop incident). Add new providers HERE, nowhere else;
|
|
2722
|
+
// the registry-coverage unit test fails if this map and
|
|
2723
|
+
// SUPPORTED_MODEL_PROVIDERS fall out of sync.
|
|
2724
|
+
const PROVIDER_INVOKERS = {
|
|
2725
|
+
"azure-openai": invokeAzureOpenAI,
|
|
2726
|
+
"azure-anthropic": invokeAzureAnthropic,
|
|
2727
|
+
ollama: invokeOllama,
|
|
2728
|
+
openrouter: invokeOpenRouter,
|
|
2729
|
+
edenai: invokeEdenAI,
|
|
2730
|
+
openai: invokeOpenAI,
|
|
2731
|
+
llamacpp: invokeLlamaCpp,
|
|
2732
|
+
lmstudio: invokeLMStudio,
|
|
2733
|
+
bedrock: invokeBedrock,
|
|
2734
|
+
zai: invokeZai,
|
|
2735
|
+
vertex: invokeVertex,
|
|
2736
|
+
moonshot: invokeMoonshot,
|
|
2737
|
+
codex: invokeCodex,
|
|
2738
|
+
};
|
|
2739
|
+
|
|
2740
|
+
function invokeProvider(provider, body, incomingHeaders) {
|
|
2741
|
+
const invoke = PROVIDER_INVOKERS[provider];
|
|
2742
|
+
if (!invoke) {
|
|
2743
|
+
// databricks itself, or an unknown name. Loud, not silent: under tier
|
|
2744
|
+
// routing this path used to swallow misconfigured providers.
|
|
2745
|
+
if (provider !== "databricks") {
|
|
2746
|
+
logger.warn({ provider }, "No invoker registered for provider — defaulting to databricks");
|
|
2747
|
+
}
|
|
2748
|
+
return invokeDatabricks(body, incomingHeaders);
|
|
2749
|
+
}
|
|
2750
|
+
return invoke(body, incomingHeaders);
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2636
2753
|
async function invokeModel(body, options = {}) {
|
|
2637
2754
|
const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
|
|
2638
2755
|
const metricsCollector = getMetricsCollector();
|
|
@@ -2681,7 +2798,13 @@ async function invokeModel(body, options = {}) {
|
|
|
2681
2798
|
_queryText: body._queryText ?? null,
|
|
2682
2799
|
}
|
|
2683
2800
|
: await determineProviderSmart(body, { workspace, tenantPolicy });
|
|
2684
|
-
|
|
2801
|
+
// Canonicalize the provider name ONCE. Tier configs write z.ai as "z-ai";
|
|
2802
|
+
// every dispatch case, breaker, and health key below uses "zai". Without
|
|
2803
|
+
// this the dispatch chain falls through to the Databricks default — whose
|
|
2804
|
+
// base is a deliberate fail-fast dummy port in tier-routing setups — so
|
|
2805
|
+
// every "z-ai" tier request died with fetch's bad-port blocklist while
|
|
2806
|
+
// z.ai itself was perfectly healthy (live incident 2026-07-21/22).
|
|
2807
|
+
const initialProvider = routingResult.provider === "z-ai" ? "zai" : routingResult.provider;
|
|
2685
2808
|
const tierSelectedModel = routingResult.model;
|
|
2686
2809
|
|
|
2687
2810
|
// Inject tier-selected model into body so provider functions can use it
|
|
@@ -2783,34 +2906,7 @@ async function invokeModel(body, options = {}) {
|
|
|
2783
2906
|
|
|
2784
2907
|
try {
|
|
2785
2908
|
const result = await breaker.execute(async () => {
|
|
2786
|
-
|
|
2787
|
-
return await invokeAzureOpenAI(body, incomingHeaders);
|
|
2788
|
-
} else if (initialProvider === "azure-anthropic") {
|
|
2789
|
-
return await invokeAzureAnthropic(body, incomingHeaders);
|
|
2790
|
-
} else if (initialProvider === "ollama") {
|
|
2791
|
-
return await invokeOllama(body, incomingHeaders);
|
|
2792
|
-
} else if (initialProvider === "openrouter") {
|
|
2793
|
-
return await invokeOpenRouter(body, incomingHeaders);
|
|
2794
|
-
} else if (initialProvider === "edenai") {
|
|
2795
|
-
return await invokeEdenAI(body, incomingHeaders);
|
|
2796
|
-
} else if (initialProvider === "openai") {
|
|
2797
|
-
return await invokeOpenAI(body, incomingHeaders);
|
|
2798
|
-
} else if (initialProvider === "llamacpp") {
|
|
2799
|
-
return await invokeLlamaCpp(body, incomingHeaders);
|
|
2800
|
-
} else if (initialProvider === "lmstudio") {
|
|
2801
|
-
return await invokeLMStudio(body, incomingHeaders);
|
|
2802
|
-
} else if (initialProvider === "bedrock") {
|
|
2803
|
-
return await invokeBedrock(body, incomingHeaders);
|
|
2804
|
-
} else if (initialProvider === "zai") {
|
|
2805
|
-
return await invokeZai(body, incomingHeaders);
|
|
2806
|
-
} else if (initialProvider === "vertex") {
|
|
2807
|
-
return await invokeVertex(body, incomingHeaders);
|
|
2808
|
-
} else if (initialProvider === "moonshot") {
|
|
2809
|
-
return await invokeMoonshot(body, incomingHeaders);
|
|
2810
|
-
} else if (initialProvider === "codex") {
|
|
2811
|
-
return await invokeCodex(body, incomingHeaders);
|
|
2812
|
-
}
|
|
2813
|
-
return await invokeDatabricks(body, incomingHeaders);
|
|
2909
|
+
return await invokeProvider(initialProvider, body, incomingHeaders);
|
|
2814
2910
|
});
|
|
2815
2911
|
|
|
2816
2912
|
const latency = Date.now() - startTime;
|
|
@@ -2903,6 +2999,7 @@ async function invokeModel(body, options = {}) {
|
|
|
2903
2999
|
candidates: routingResult.candidates ?? null,
|
|
2904
3000
|
pinned: routingResult.pinned ? 1 : 0,
|
|
2905
3001
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3002
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
2906
3003
|
});
|
|
2907
3004
|
|
|
2908
3005
|
// WS5.4 — feedback loop (success path).
|
|
@@ -2984,6 +3081,29 @@ async function invokeModel(body, options = {}) {
|
|
|
2984
3081
|
healthTracker.recordFailure(initialProvider, err, err.status);
|
|
2985
3082
|
getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
|
|
2986
3083
|
|
|
3084
|
+
// Accumulate every provider failure for this request so the terminal
|
|
3085
|
+
// error can tell the client WHY nothing answered (quota exhausted vs
|
|
3086
|
+
// context overflow vs dead config) instead of only the last rung's
|
|
3087
|
+
// error. Rendered by the SSE writer when the whole chain fails.
|
|
3088
|
+
const failureTrail = [
|
|
3089
|
+
`${initialProvider}${routingDecision?.model ? ` (${routingDecision.model})` : ""}: ${err.status ? `HTTP ${err.status} — ` : ""}${err.message}`,
|
|
3090
|
+
];
|
|
3091
|
+
|
|
3092
|
+
// undici wraps every network failure as "TypeError: fetch failed" — the
|
|
3093
|
+
// actionable detail (ENOTFOUND vs ECONNRESET vs ETIMEDOUT vs TLS) lives
|
|
3094
|
+
// in err.cause. Without this line the z-ai breaker trips on repeated
|
|
3095
|
+
// "fetch failed" with nothing to diagnose (live incidents 2026-07-21/22:
|
|
3096
|
+
// ~5ms failures from a long-lived process while fresh processes succeed).
|
|
3097
|
+
logger.warn({
|
|
3098
|
+
provider: initialProvider,
|
|
3099
|
+
model: routingDecision?.model ?? null,
|
|
3100
|
+
error: err.message,
|
|
3101
|
+
causeCode: err.cause?.code ?? null,
|
|
3102
|
+
causeMessage: err.cause?.message ?? null,
|
|
3103
|
+
causeErrors: Array.isArray(err.cause?.errors) ? err.cause.errors.map((e) => e.code || e.message).slice(0, 4) : null,
|
|
3104
|
+
failLatencyMs: failLatency,
|
|
3105
|
+
}, "Provider invocation failed");
|
|
3106
|
+
|
|
2987
3107
|
// Tier-aware escalate-then-demote fallback (TIER_FALLBACK_ENABLED).
|
|
2988
3108
|
// On failure, try a MORE capable tier first (climb toward REASONING); only
|
|
2989
3109
|
// if every higher tier is unavailable do we fall downward to SIMPLE/local.
|
|
@@ -3011,9 +3131,30 @@ async function invokeModel(body, options = {}) {
|
|
|
3011
3131
|
_cascadeInner: true,
|
|
3012
3132
|
workspace,
|
|
3013
3133
|
tenantPolicy,
|
|
3134
|
+
// Forward the client's headers: azure-anthropic in wrap mode
|
|
3135
|
+
// authenticates with the incoming OAuth Bearer token, so a
|
|
3136
|
+
// fallback attempt without headers is guaranteed to fail with
|
|
3137
|
+
// "requires authentication".
|
|
3138
|
+
headers: incomingHeaders,
|
|
3014
3139
|
}
|
|
3015
3140
|
);
|
|
3016
3141
|
|
|
3142
|
+
// A candidate that answers with an error is a FAILED candidate,
|
|
3143
|
+
// not a serve. Provider clients return {ok:false} on 4xx instead
|
|
3144
|
+
// of throwing, and serving that here hands the client a 401/403
|
|
3145
|
+
// while healthier rungs below never get tried (live incident
|
|
3146
|
+
// 2026-07-21: Codex request served azure-anthropic's 401 while
|
|
3147
|
+
// llamacpp sat idle one rung down).
|
|
3148
|
+
if (attempt?.ok === false) {
|
|
3149
|
+
logger.warn({
|
|
3150
|
+
toProvider: cand.provider,
|
|
3151
|
+
status: attempt.status,
|
|
3152
|
+
error: attempt.json?.error?.message || attempt.text?.slice(0, 120) || null,
|
|
3153
|
+
}, "[TierFallback] Candidate answered with an error response, trying next");
|
|
3154
|
+
failureTrail.push(`${cand.provider} (${cand.model}): HTTP ${attempt.status} — ${attempt.json?.error?.message || attempt.text?.slice(0, 120) || "error response"}`);
|
|
3155
|
+
continue;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3017
3158
|
metricsCollector.recordFallbackAttempt(initialProvider, cand.provider, "tier_fallback");
|
|
3018
3159
|
logger.warn({
|
|
3019
3160
|
servedTier: cand.tier,
|
|
@@ -3041,6 +3182,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3041
3182
|
{ toProvider: cand.provider, error: innerErr.message },
|
|
3042
3183
|
"[TierFallback] Candidate failed, trying next"
|
|
3043
3184
|
);
|
|
3185
|
+
failureTrail.push(`${cand.provider} (${cand.model}): ${innerErr.message}`);
|
|
3044
3186
|
}
|
|
3045
3187
|
}
|
|
3046
3188
|
logger.warn(
|
|
@@ -3089,6 +3231,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3089
3231
|
candidates: routingResult.candidates ?? null,
|
|
3090
3232
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3091
3233
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3234
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3092
3235
|
});
|
|
3093
3236
|
|
|
3094
3237
|
// WS5.4 — feedback loop (primary-failed, no fallback). Low quality
|
|
@@ -3108,6 +3251,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3108
3251
|
},
|
|
3109
3252
|
});
|
|
3110
3253
|
|
|
3254
|
+
err.lynkrFailureTrail = failureTrail;
|
|
3111
3255
|
throw err;
|
|
3112
3256
|
}
|
|
3113
3257
|
|
|
@@ -3135,26 +3279,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3135
3279
|
const fallbackStart = Date.now();
|
|
3136
3280
|
|
|
3137
3281
|
const fallbackResult = await fallbackBreaker.execute(async () => {
|
|
3138
|
-
|
|
3139
|
-
return await invokeAzureOpenAI(body, incomingHeaders);
|
|
3140
|
-
} else if (fallbackProvider === "azure-anthropic") {
|
|
3141
|
-
return await invokeAzureAnthropic(body, incomingHeaders);
|
|
3142
|
-
} else if (fallbackProvider === "openrouter") {
|
|
3143
|
-
return await invokeOpenRouter(body, incomingHeaders);
|
|
3144
|
-
} else if (fallbackProvider === "edenai") {
|
|
3145
|
-
return await invokeEdenAI(body, incomingHeaders);
|
|
3146
|
-
} else if (fallbackProvider === "openai") {
|
|
3147
|
-
return await invokeOpenAI(body, incomingHeaders);
|
|
3148
|
-
} else if (fallbackProvider === "llamacpp") {
|
|
3149
|
-
return await invokeLlamaCpp(body, incomingHeaders);
|
|
3150
|
-
} else if (fallbackProvider === "zai") {
|
|
3151
|
-
return await invokeZai(body, incomingHeaders);
|
|
3152
|
-
} else if (fallbackProvider === "vertex") {
|
|
3153
|
-
return await invokeVertex(body, incomingHeaders);
|
|
3154
|
-
} else if (fallbackProvider === "moonshot") {
|
|
3155
|
-
return await invokeMoonshot(body, incomingHeaders);
|
|
3156
|
-
}
|
|
3157
|
-
return await invokeDatabricks(body, incomingHeaders);
|
|
3282
|
+
return await invokeProvider(fallbackProvider, body, incomingHeaders);
|
|
3158
3283
|
});
|
|
3159
3284
|
|
|
3160
3285
|
const fallbackLatency = Date.now() - fallbackStart;
|
|
@@ -3225,6 +3350,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3225
3350
|
candidates: routingResult.candidates ?? null,
|
|
3226
3351
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3227
3352
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3353
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3228
3354
|
});
|
|
3229
3355
|
|
|
3230
3356
|
// WS5.4 — feedback loop (fallback success). The served provider
|
|
@@ -3244,6 +3370,16 @@ async function invokeModel(body, options = {}) {
|
|
|
3244
3370
|
},
|
|
3245
3371
|
});
|
|
3246
3372
|
|
|
3373
|
+
// The last rung also answered with an error — thread the whole trail
|
|
3374
|
+
// into the error payload so the client sees every failure, not just
|
|
3375
|
+
// this one (e.g. "moonshot: quota" matters more than "azure: 404").
|
|
3376
|
+
if (fallbackResult?.ok === false) {
|
|
3377
|
+
failureTrail.push(`${fallbackProvider}: HTTP ${fallbackResult.status} — ${fallbackResult.json?.error?.message || String(fallbackResult.text || "").slice(0, 120) || "error response"}`);
|
|
3378
|
+
if (fallbackResult.json && typeof fallbackResult.json === "object") {
|
|
3379
|
+
fallbackResult.json._lynkr_failure_trail = failureTrail;
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
|
|
3247
3383
|
return {
|
|
3248
3384
|
...fallbackResult,
|
|
3249
3385
|
actualProvider: fallbackProvider,
|
|
@@ -3282,6 +3418,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3282
3418
|
candidates: routingResult.candidates ?? null,
|
|
3283
3419
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3284
3420
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3421
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3285
3422
|
});
|
|
3286
3423
|
|
|
3287
3424
|
// WS5.4 — feedback loop (double failure). quality=0 is a hard
|
|
@@ -3308,6 +3445,8 @@ async function invokeModel(body, options = {}) {
|
|
|
3308
3445
|
}, "Both primary and fallback provider failed");
|
|
3309
3446
|
|
|
3310
3447
|
// Return fallback error (more actionable than Ollama error)
|
|
3448
|
+
failureTrail.push(`${fallbackProvider}: ${fallbackErr.message}`);
|
|
3449
|
+
fallbackErr.lynkrFailureTrail = failureTrail;
|
|
3311
3450
|
throw fallbackErr;
|
|
3312
3451
|
}
|
|
3313
3452
|
}
|
|
@@ -3370,6 +3509,11 @@ function destroyHttpAgents() {
|
|
|
3370
3509
|
|
|
3371
3510
|
module.exports = {
|
|
3372
3511
|
invokeModel,
|
|
3512
|
+
invokeAzureAnthropic,
|
|
3513
|
+
invokeZai,
|
|
3514
|
+
invokeOllama,
|
|
3515
|
+
invokeMoonshot,
|
|
3516
|
+
PROVIDER_INVOKERS,
|
|
3373
3517
|
stripLynkrBadges,
|
|
3374
3518
|
destroyHttpAgents,
|
|
3375
3519
|
normalizeBodyForConverse,
|
package/src/clients/gpt-utils.js
CHANGED
|
@@ -61,7 +61,14 @@ function areSimilarToolCalls(call1, call2) {
|
|
|
61
61
|
|
|
62
62
|
// Only search-style tools get fuzzy matching; mutating tools with
|
|
63
63
|
// near-identical args may be intentional repeats.
|
|
64
|
-
|
|
64
|
+
// 'read' is deliberately NOT fuzzy-matched: its argument is a file path, and
|
|
65
|
+
// absolute paths in one repo share nearly every Jaccard token
|
|
66
|
+
// (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8 and
|
|
67
|
+
// merged into one "repeated call" signature (live incident: an opencode
|
|
68
|
+
// code-trace reading server.js, openai-router.js and orchestrator/index.js
|
|
69
|
+
// was flagged as a loop). A genuine re-read of the same file is caught by
|
|
70
|
+
// the exact-match branch above.
|
|
71
|
+
const searchTools = ['grep', 'glob', 'search', 'find', 'bash', 'shell'];
|
|
65
72
|
const toolName = (name1 || '').toLowerCase();
|
|
66
73
|
const isSearchTool = searchTools.some(t => toolName.includes(t));
|
|
67
74
|
|
|
@@ -128,17 +128,26 @@ function convertAnthropicToolsToOllama(anthropicTools) {
|
|
|
128
128
|
return [];
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
return anthropicTools.map(tool =>
|
|
132
|
-
|
|
133
|
-
function:
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
131
|
+
return anthropicTools.map(tool => {
|
|
132
|
+
// Shape-detect: already OpenAI/Ollama-format tools pass through untouched.
|
|
133
|
+
// Without this, an OpenAI-shape tool ({type:"function", function:{...}})
|
|
134
|
+
// reaching this helper produces {function:{name:undefined,...}}, which
|
|
135
|
+
// Ollama's legacy /api/chat endpoint rejects.
|
|
136
|
+
if (tool?.type === "function" && tool.function) {
|
|
137
|
+
return tool;
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
type: "function",
|
|
141
|
+
function: {
|
|
142
|
+
name: tool.name,
|
|
143
|
+
description: tool.description || "",
|
|
144
|
+
parameters: tool.input_schema || {
|
|
145
|
+
type: "object",
|
|
146
|
+
properties: {},
|
|
147
|
+
},
|
|
139
148
|
},
|
|
140
|
-
}
|
|
141
|
-
})
|
|
149
|
+
};
|
|
150
|
+
});
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
module.exports = {
|
|
@@ -4,9 +4,24 @@
|
|
|
4
4
|
* Injects `cache_control` breakpoints into requests for providers
|
|
5
5
|
* that support explicit prompt caching (Anthropic, Bedrock, Vertex/Gemini).
|
|
6
6
|
*
|
|
7
|
-
* Strategy: "
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Strategy: "stable_hierarchy" — up to 4 breakpoints ordered by stability
|
|
8
|
+
* (Phase 5, cache-aware routing):
|
|
9
|
+
* 1. Tools block — never moves (tools render before system in the
|
|
10
|
+
* provider's prefix, so this read point survives system edits)
|
|
11
|
+
* 2. System prompt — never moves
|
|
12
|
+
* 3. Frozen history boundary — advances only every K user turns
|
|
13
|
+
* (K = config.memory.distillation.refreshEveryTurns, default 5; shared
|
|
14
|
+
* with the distiller's freeze window). Deterministic from the message
|
|
15
|
+
* list, so consecutive requests inside a bucket mark the same bytes.
|
|
16
|
+
* 4. Rolling marker on the newest message — pays the 1.25x write on the
|
|
17
|
+
* per-turn delta once so the next turn reads it at 0.1x. Kept
|
|
18
|
+
* deliberately: dropping it would re-pay full input price on
|
|
19
|
+
* everything after the boundary every turn until the next refresh.
|
|
20
|
+
*
|
|
21
|
+
* The previous "system_and_3" strategy rolled breakpoints 2-4 across the
|
|
22
|
+
* last three messages; markers moved every turn, and history-rewriting
|
|
23
|
+
* layers (distiller) invalidated the prefix wholesale. Stability of the
|
|
24
|
+
* marked bytes is what compounds hits.
|
|
10
25
|
*
|
|
11
26
|
* Providers with automatic caching (OpenAI, DeepSeek) need no injection.
|
|
12
27
|
*
|
|
@@ -17,12 +32,64 @@ const logger = require('../logger');
|
|
|
17
32
|
|
|
18
33
|
const CACHE_MARKER = { type: 'ephemeral' };
|
|
19
34
|
const MAX_BREAKPOINTS = 4;
|
|
35
|
+
const DEFAULT_BOUNDARY_EVERY_TURNS = 5;
|
|
36
|
+
|
|
37
|
+
function _boundaryEveryTurns() {
|
|
38
|
+
try {
|
|
39
|
+
const config = require('../config');
|
|
40
|
+
const k = config.memory?.distillation?.refreshEveryTurns;
|
|
41
|
+
return Number.isFinite(k) && k > 0 ? k : DEFAULT_BOUNDARY_EVERY_TURNS;
|
|
42
|
+
} catch {
|
|
43
|
+
return DEFAULT_BOUNDARY_EVERY_TURNS;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Mark the last content block of a message; converts string content. */
|
|
48
|
+
function _markMessage(msg) {
|
|
49
|
+
if (!msg) return false;
|
|
50
|
+
if (typeof msg.content === 'string') {
|
|
51
|
+
msg.content = [{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: msg.content,
|
|
54
|
+
cache_control: CACHE_MARKER,
|
|
55
|
+
}];
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(msg.content) && msg.content.length > 0) {
|
|
59
|
+
const lastBlock = msg.content[msg.content.length - 1];
|
|
60
|
+
if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
|
|
61
|
+
lastBlock.cache_control = CACHE_MARKER;
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Index of the frozen-boundary message: the bucket-th user-role message,
|
|
70
|
+
* where bucket = floor(userTurns / K) * K. Deterministic in the message
|
|
71
|
+
* list, so every request inside a K-turn bucket marks the same message —
|
|
72
|
+
* the marked prefix bytes stay identical until the bucket advances.
|
|
73
|
+
*
|
|
74
|
+
* @returns {number} message index, or -1 when the conversation is too
|
|
75
|
+
* young (bucket < K) or the boundary can't be placed.
|
|
76
|
+
*/
|
|
77
|
+
function _frozenBoundaryIndex(messages, everyTurns) {
|
|
78
|
+
if (!Array.isArray(messages) || messages.length === 0) return -1;
|
|
79
|
+
const userIdx = [];
|
|
80
|
+
for (let i = 0; i < messages.length; i++) {
|
|
81
|
+
if (messages[i]?.role === 'user') userIdx.push(i);
|
|
82
|
+
}
|
|
83
|
+
const bucket = Math.floor(userIdx.length / everyTurns) * everyTurns;
|
|
84
|
+
if (bucket < everyTurns) return -1;
|
|
85
|
+
return userIdx[bucket - 1];
|
|
86
|
+
}
|
|
20
87
|
|
|
21
88
|
/**
|
|
22
89
|
* Inject cache_control breakpoints into an Anthropic-format request body.
|
|
23
90
|
* Mutates the body in-place for zero-copy performance.
|
|
24
91
|
*
|
|
25
|
-
* @param {Object} body - Request body with system and messages
|
|
92
|
+
* @param {Object} body - Request body with system, tools, and messages
|
|
26
93
|
* @returns {number} Number of breakpoints injected
|
|
27
94
|
*/
|
|
28
95
|
function injectAnthropicCacheBreakpoints(body) {
|
|
@@ -30,7 +97,16 @@ function injectAnthropicCacheBreakpoints(body) {
|
|
|
30
97
|
|
|
31
98
|
let injected = 0;
|
|
32
99
|
|
|
33
|
-
// Breakpoint 1:
|
|
100
|
+
// Breakpoint 1: tools block — most stable prefix region.
|
|
101
|
+
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
102
|
+
const lastTool = body.tools[body.tools.length - 1];
|
|
103
|
+
if (lastTool && typeof lastTool === 'object' && !lastTool.cache_control) {
|
|
104
|
+
lastTool.cache_control = CACHE_MARKER;
|
|
105
|
+
injected++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Breakpoint 2: system prompt.
|
|
34
110
|
if (body.system) {
|
|
35
111
|
if (typeof body.system === 'string') {
|
|
36
112
|
// Convert string system to array format for cache_control support
|
|
@@ -50,32 +126,19 @@ function injectAnthropicCacheBreakpoints(body) {
|
|
|
50
126
|
}
|
|
51
127
|
}
|
|
52
128
|
|
|
53
|
-
// Breakpoints 2-4: Last 3 non-system messages
|
|
54
129
|
if (Array.isArray(body.messages) && body.messages.length > 0) {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
text: msg.content,
|
|
68
|
-
cache_control: CACHE_MARKER,
|
|
69
|
-
}];
|
|
70
|
-
injected++;
|
|
71
|
-
} else if (Array.isArray(msg.content) && msg.content.length > 0) {
|
|
72
|
-
// Mark the last content block in this message
|
|
73
|
-
const lastBlock = msg.content[msg.content.length - 1];
|
|
74
|
-
if (lastBlock && typeof lastBlock === 'object' && !lastBlock.cache_control) {
|
|
75
|
-
lastBlock.cache_control = CACHE_MARKER;
|
|
76
|
-
injected++;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
130
|
+
const lastIdx = body.messages.length - 1;
|
|
131
|
+
|
|
132
|
+
// Breakpoint 3: frozen history boundary (advances every K user turns).
|
|
133
|
+
const boundaryIdx = _frozenBoundaryIndex(body.messages, _boundaryEveryTurns());
|
|
134
|
+
if (boundaryIdx >= 0 && boundaryIdx < lastIdx && injected < MAX_BREAKPOINTS) {
|
|
135
|
+
if (_markMessage(body.messages[boundaryIdx])) injected++;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Breakpoint 4: rolling marker on the newest message — caches this
|
|
139
|
+
// turn's delta so the next turn reads it instead of re-paying input.
|
|
140
|
+
if (injected < MAX_BREAKPOINTS) {
|
|
141
|
+
if (_markMessage(body.messages[lastIdx])) injected++;
|
|
79
142
|
}
|
|
80
143
|
}
|
|
81
144
|
|