lynkr 9.10.0 → 9.12.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 +46 -11
- package/bin/lynkr-init.js +7 -0
- package/bin/statusline.js +80 -0
- package/bin/wrap.js +10 -6
- package/config/model-tiers.json +5 -5
- package/package.json +9 -4
- package/public/dashboard.html +354 -1
- package/src/api/openai-router.js +120 -2
- package/src/api/router.js +34 -0
- package/src/clients/databricks.js +148 -66
- 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 +79 -0
- package/src/context/compression.js +27 -8
- package/src/context/distill.js +2 -1
- package/src/dashboard/api.js +82 -2
- package/src/dashboard/recommendations/cache-weakspots.js +88 -0
- package/src/dashboard/recommendations/deadweight.js +82 -0
- package/src/dashboard/recommendations/downsize.js +109 -0
- package/src/dashboard/recommendations/index.js +106 -0
- package/src/dashboard/recommendations/side-requests.js +68 -0
- package/src/dashboard/recommendations/wilson.js +24 -0
- package/src/dashboard/router.js +5 -0
- 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 +85 -2
- package/src/orchestrator/sse-transformer.js +9 -3
- package/src/prompts/system.js +20 -6
- package/src/routing/affinity-store.js +68 -1
- 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 +37 -10
- package/src/routing/context-compressor.js +170 -0
- package/src/routing/difficulty-classifier.js +115 -13
- package/src/routing/index.js +161 -53
- package/src/routing/model-registry.js +35 -10
- package/src/routing/session-affinity.js +60 -0
- package/src/routing/telemetry.js +343 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -3
- package/src/tools/code-graph.js +6 -4
|
@@ -310,6 +310,18 @@ async function invokeAzureAnthropic(body, incomingHeaders = {}) {
|
|
|
310
310
|
incomingAuth = null;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
// Anthropic OAuth tokens are only meaningful at api.anthropic.com. When this
|
|
314
|
+
// provider points at any OTHER Anthropic-compatible host (Azure AI Foundry
|
|
315
|
+
// Claude, a corporate gateway), forwarding Claude Code's sk-ant-oat token
|
|
316
|
+
// earns a 401 there — and the configured x-api-key below is the right auth.
|
|
317
|
+
// This also means a subscription token never leaves the box unless the
|
|
318
|
+
// endpoint is Anthropic itself.
|
|
319
|
+
const isAnthropicDirect = String(config.azureAnthropic.endpoint || "").includes("api.anthropic.com");
|
|
320
|
+
if (incomingAuth && !isAnthropicDirect) {
|
|
321
|
+
logger.debug({ endpoint: config.azureAnthropic.endpoint }, "Azure Anthropic: non-Anthropic endpoint — using configured API key instead of client OAuth token");
|
|
322
|
+
incomingAuth = null;
|
|
323
|
+
}
|
|
324
|
+
|
|
313
325
|
// Headers Anthropic uses to verify client identity for subscription OAuth tokens.
|
|
314
326
|
// If we strip these, Anthropic returns 429 rate_limit_error with no rate-limit
|
|
315
327
|
// headers (its terse anti-proxy response). Forward every Anthropic-relevant
|
|
@@ -560,9 +572,20 @@ async function invokeOllama(body, incomingHeaders = {}) {
|
|
|
560
572
|
if (body.tool_choice) ollamaBody.tool_choice = body.tool_choice;
|
|
561
573
|
if (body.metadata) ollamaBody.metadata = body.metadata;
|
|
562
574
|
|
|
563
|
-
// Tools
|
|
575
|
+
// Tools: normalize to Anthropic shape. Callers that stayed OpenAI-format
|
|
576
|
+
// upstream (OpenWorker, aisuite, any client hitting /v1/chat/completions)
|
|
577
|
+
// otherwise ship {type:"function", function:{...}} which Ollama's
|
|
578
|
+
// /v1/messages endpoint rejects with 400 "can't find closing '}' symbol".
|
|
564
579
|
if (supportsTools && Array.isArray(toolsToSend) && toolsToSend.length > 0) {
|
|
565
|
-
ollamaBody.tools = toolsToSend
|
|
580
|
+
ollamaBody.tools = toolsToSend.map(tool =>
|
|
581
|
+
tool?.type === "function" && tool.function
|
|
582
|
+
? {
|
|
583
|
+
name: tool.function.name,
|
|
584
|
+
description: tool.function.description || "",
|
|
585
|
+
input_schema: tool.function.parameters || { type: "object", properties: {} },
|
|
586
|
+
}
|
|
587
|
+
: tool
|
|
588
|
+
);
|
|
566
589
|
}
|
|
567
590
|
|
|
568
591
|
if (config.ollama.keepAlive !== undefined) {
|
|
@@ -1934,6 +1957,21 @@ async function invokeZai(body, incomingHeaders = {}) {
|
|
|
1934
1957
|
}, "=== INJECTING STANDARD TOOLS (Z.AI Anthropic) ===");
|
|
1935
1958
|
}
|
|
1936
1959
|
|
|
1960
|
+
// Normalize to Anthropic tool shape — the /anthropic/v1/messages endpoint
|
|
1961
|
+
// rejects OpenAI-nested tools ({type:"function", function:{name,...}}) with
|
|
1962
|
+
// 422 missing body.tools[0].name. Callers that stayed OpenAI-format upstream
|
|
1963
|
+
// (OpenWorker, aisuite, any client hitting /v1/chat/completions with an
|
|
1964
|
+
// unusual tool shape) get unwrapped here.
|
|
1965
|
+
zaiBody.tools = zaiBody.tools.map(tool =>
|
|
1966
|
+
tool?.type === "function" && tool.function
|
|
1967
|
+
? {
|
|
1968
|
+
name: tool.function.name,
|
|
1969
|
+
description: tool.function.description || "",
|
|
1970
|
+
input_schema: tool.function.parameters || { type: "object", properties: {} },
|
|
1971
|
+
}
|
|
1972
|
+
: tool
|
|
1973
|
+
);
|
|
1974
|
+
|
|
1937
1975
|
headers = {
|
|
1938
1976
|
"Content-Type": "application/json",
|
|
1939
1977
|
"x-api-key": config.zai.apiKey,
|
|
@@ -2019,13 +2057,17 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2019
2057
|
|
|
2020
2058
|
// Model mapping: Anthropic names → Moonshot/Kimi names
|
|
2021
2059
|
const modelMap = {
|
|
2022
|
-
"claude-sonnet-4-5-20250929": "kimi-k2
|
|
2023
|
-
"claude-sonnet-4-5": "kimi-k2
|
|
2024
|
-
"claude-sonnet-4.5": "kimi-k2
|
|
2025
|
-
"claude-3-5-sonnet": "kimi-k2
|
|
2026
|
-
"claude-haiku-4-5-20251001": "kimi-k2
|
|
2027
|
-
"claude-haiku-4-5": "kimi-k2
|
|
2028
|
-
"claude-3-haiku": "kimi-k2
|
|
2060
|
+
"claude-sonnet-4-5-20250929": "kimi-k2.6",
|
|
2061
|
+
"claude-sonnet-4-5": "kimi-k2.6",
|
|
2062
|
+
"claude-sonnet-4.5": "kimi-k2.6",
|
|
2063
|
+
"claude-3-5-sonnet": "kimi-k2.6",
|
|
2064
|
+
"claude-haiku-4-5-20251001": "kimi-k2.6",
|
|
2065
|
+
"claude-haiku-4-5": "kimi-k2.6",
|
|
2066
|
+
"claude-3-haiku": "kimi-k2.6",
|
|
2067
|
+
// Retired ids (gone from /v1/models as of 2026-07: "Not found the model")
|
|
2068
|
+
// that older tier configs may still reference.
|
|
2069
|
+
"kimi-k2-turbo-preview": "kimi-k2.6",
|
|
2070
|
+
"kimi-k2-thinking": "kimi-k3",
|
|
2029
2071
|
// moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
|
|
2030
2072
|
// context-size pass fails on large tool-bearing payloads). Remap to a
|
|
2031
2073
|
// fixed model that's broadly available on api.moonshot.ai.
|
|
@@ -2033,7 +2075,12 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2033
2075
|
};
|
|
2034
2076
|
|
|
2035
2077
|
const requestedModel = body._tierModel || body.model || config.moonshot.model;
|
|
2036
|
-
|
|
2078
|
+
// Honor tier-selected Moonshot ids (e.g. TIER_REASONING=moonshot:kimi-k2.6)
|
|
2079
|
+
// instead of silently swapping in the .env default model.
|
|
2080
|
+
let mappedModel = modelMap[requestedModel]
|
|
2081
|
+
|| (/^(kimi-|moonshot-v1)/i.test(requestedModel || "") ? requestedModel : null)
|
|
2082
|
+
|| config.moonshot.model
|
|
2083
|
+
|| "kimi-k3";
|
|
2037
2084
|
// Guard against the deprecated auto model arriving via config too.
|
|
2038
2085
|
if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
|
|
2039
2086
|
|
|
@@ -2047,19 +2094,23 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2047
2094
|
messages.unshift({ role: "system", content: systemContent });
|
|
2048
2095
|
}
|
|
2049
2096
|
|
|
2050
|
-
// kimi-
|
|
2051
|
-
//
|
|
2052
|
-
|
|
2097
|
+
// Every current kimi-k* model (k2.5, k2.6, k2.7-code, k3, …) pins sampling
|
|
2098
|
+
// params: temperature must be 1 — any other value 400s with "invalid
|
|
2099
|
+
// temperature: only 1 is allowed for this model" (probed live 2026-07-23).
|
|
2100
|
+
// Only the moonshot-v1-* models accept caller-supplied values.
|
|
2101
|
+
const isKimiPinned = /^kimi-k/i.test(mappedModel);
|
|
2053
2102
|
|
|
2054
2103
|
const moonshotBody = {
|
|
2055
2104
|
model: mappedModel,
|
|
2056
2105
|
messages,
|
|
2057
2106
|
max_tokens: body.max_tokens || 16384,
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2107
|
+
temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7),
|
|
2108
|
+
top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0),
|
|
2109
|
+
// Streaming honored since the Phase-2b sse-transformer landed: the raw
|
|
2110
|
+
// OpenAI SSE stream is returned below and reshaped in flight by the
|
|
2111
|
+
// orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered
|
|
2112
|
+
// requests keep the Anthropic conversion path unchanged.
|
|
2113
|
+
stream: body.stream ?? false,
|
|
2063
2114
|
};
|
|
2064
2115
|
|
|
2065
2116
|
if (Array.isArray(body.tools) && body.tools.length > 0) {
|
|
@@ -2097,6 +2148,13 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2097
2148
|
throw err;
|
|
2098
2149
|
}
|
|
2099
2150
|
|
|
2151
|
+
// Streaming request: hand the raw OpenAI SSE stream to the orchestrator's
|
|
2152
|
+
// stream branch (sse-transformer reshapes it). The Anthropic conversion
|
|
2153
|
+
// below is buffered-only — it would consume the stream.
|
|
2154
|
+
if (response?.stream) {
|
|
2155
|
+
return response;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2100
2158
|
const rawMsg = response?.json?.choices?.[0]?.message;
|
|
2101
2159
|
logger.debug({
|
|
2102
2160
|
responseOk: response?.ok,
|
|
@@ -2678,6 +2736,43 @@ function stripLynkrBadges(messages) {
|
|
|
2678
2736
|
return mutated ? out : messages;
|
|
2679
2737
|
}
|
|
2680
2738
|
|
|
2739
|
+
// Single source of truth for provider dispatch. Both the initial call and the
|
|
2740
|
+
// fallback ladder MUST route through invokeProvider — the previous duplicated
|
|
2741
|
+
// if/else chains drifted (the fallback chain was missing ollama, bedrock,
|
|
2742
|
+
// lmstudio and codex), and unknown providers silently dialed databricks,
|
|
2743
|
+
// which under tier routing is a deliberate dead number (see .env note about
|
|
2744
|
+
// the 2026-07-07 self-loop incident). Add new providers HERE, nowhere else;
|
|
2745
|
+
// the registry-coverage unit test fails if this map and
|
|
2746
|
+
// SUPPORTED_MODEL_PROVIDERS fall out of sync.
|
|
2747
|
+
const PROVIDER_INVOKERS = {
|
|
2748
|
+
"azure-openai": invokeAzureOpenAI,
|
|
2749
|
+
"azure-anthropic": invokeAzureAnthropic,
|
|
2750
|
+
ollama: invokeOllama,
|
|
2751
|
+
openrouter: invokeOpenRouter,
|
|
2752
|
+
edenai: invokeEdenAI,
|
|
2753
|
+
openai: invokeOpenAI,
|
|
2754
|
+
llamacpp: invokeLlamaCpp,
|
|
2755
|
+
lmstudio: invokeLMStudio,
|
|
2756
|
+
bedrock: invokeBedrock,
|
|
2757
|
+
zai: invokeZai,
|
|
2758
|
+
vertex: invokeVertex,
|
|
2759
|
+
moonshot: invokeMoonshot,
|
|
2760
|
+
codex: invokeCodex,
|
|
2761
|
+
};
|
|
2762
|
+
|
|
2763
|
+
function invokeProvider(provider, body, incomingHeaders) {
|
|
2764
|
+
const invoke = PROVIDER_INVOKERS[provider];
|
|
2765
|
+
if (!invoke) {
|
|
2766
|
+
// databricks itself, or an unknown name. Loud, not silent: under tier
|
|
2767
|
+
// routing this path used to swallow misconfigured providers.
|
|
2768
|
+
if (provider !== "databricks") {
|
|
2769
|
+
logger.warn({ provider }, "No invoker registered for provider — defaulting to databricks");
|
|
2770
|
+
}
|
|
2771
|
+
return invokeDatabricks(body, incomingHeaders);
|
|
2772
|
+
}
|
|
2773
|
+
return invoke(body, incomingHeaders);
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2681
2776
|
async function invokeModel(body, options = {}) {
|
|
2682
2777
|
const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
|
|
2683
2778
|
const metricsCollector = getMetricsCollector();
|
|
@@ -2834,34 +2929,7 @@ async function invokeModel(body, options = {}) {
|
|
|
2834
2929
|
|
|
2835
2930
|
try {
|
|
2836
2931
|
const result = await breaker.execute(async () => {
|
|
2837
|
-
|
|
2838
|
-
return await invokeAzureOpenAI(body, incomingHeaders);
|
|
2839
|
-
} else if (initialProvider === "azure-anthropic") {
|
|
2840
|
-
return await invokeAzureAnthropic(body, incomingHeaders);
|
|
2841
|
-
} else if (initialProvider === "ollama") {
|
|
2842
|
-
return await invokeOllama(body, incomingHeaders);
|
|
2843
|
-
} else if (initialProvider === "openrouter") {
|
|
2844
|
-
return await invokeOpenRouter(body, incomingHeaders);
|
|
2845
|
-
} else if (initialProvider === "edenai") {
|
|
2846
|
-
return await invokeEdenAI(body, incomingHeaders);
|
|
2847
|
-
} else if (initialProvider === "openai") {
|
|
2848
|
-
return await invokeOpenAI(body, incomingHeaders);
|
|
2849
|
-
} else if (initialProvider === "llamacpp") {
|
|
2850
|
-
return await invokeLlamaCpp(body, incomingHeaders);
|
|
2851
|
-
} else if (initialProvider === "lmstudio") {
|
|
2852
|
-
return await invokeLMStudio(body, incomingHeaders);
|
|
2853
|
-
} else if (initialProvider === "bedrock") {
|
|
2854
|
-
return await invokeBedrock(body, incomingHeaders);
|
|
2855
|
-
} else if (initialProvider === "zai") {
|
|
2856
|
-
return await invokeZai(body, incomingHeaders);
|
|
2857
|
-
} else if (initialProvider === "vertex") {
|
|
2858
|
-
return await invokeVertex(body, incomingHeaders);
|
|
2859
|
-
} else if (initialProvider === "moonshot") {
|
|
2860
|
-
return await invokeMoonshot(body, incomingHeaders);
|
|
2861
|
-
} else if (initialProvider === "codex") {
|
|
2862
|
-
return await invokeCodex(body, incomingHeaders);
|
|
2863
|
-
}
|
|
2864
|
-
return await invokeDatabricks(body, incomingHeaders);
|
|
2932
|
+
return await invokeProvider(initialProvider, body, incomingHeaders);
|
|
2865
2933
|
});
|
|
2866
2934
|
|
|
2867
2935
|
const latency = Date.now() - startTime;
|
|
@@ -2954,6 +3022,9 @@ async function invokeModel(body, options = {}) {
|
|
|
2954
3022
|
candidates: routingResult.candidates ?? null,
|
|
2955
3023
|
pinned: routingResult.pinned ? 1 : 0,
|
|
2956
3024
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3025
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3026
|
+
cache_read_tokens: result.json?.usage?.cache_read_input_tokens ?? null,
|
|
3027
|
+
cache_creation_tokens: result.json?.usage?.cache_creation_input_tokens ?? null,
|
|
2957
3028
|
});
|
|
2958
3029
|
|
|
2959
3030
|
// WS5.4 — feedback loop (success path).
|
|
@@ -3035,6 +3106,14 @@ async function invokeModel(body, options = {}) {
|
|
|
3035
3106
|
healthTracker.recordFailure(initialProvider, err, err.status);
|
|
3036
3107
|
getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
|
|
3037
3108
|
|
|
3109
|
+
// Accumulate every provider failure for this request so the terminal
|
|
3110
|
+
// error can tell the client WHY nothing answered (quota exhausted vs
|
|
3111
|
+
// context overflow vs dead config) instead of only the last rung's
|
|
3112
|
+
// error. Rendered by the SSE writer when the whole chain fails.
|
|
3113
|
+
const failureTrail = [
|
|
3114
|
+
`${initialProvider}${routingDecision?.model ? ` (${routingDecision.model})` : ""}: ${err.status ? `HTTP ${err.status} — ` : ""}${err.message}`,
|
|
3115
|
+
];
|
|
3116
|
+
|
|
3038
3117
|
// undici wraps every network failure as "TypeError: fetch failed" — the
|
|
3039
3118
|
// actionable detail (ENOTFOUND vs ECONNRESET vs ETIMEDOUT vs TLS) lives
|
|
3040
3119
|
// in err.cause. Without this line the z-ai breaker trips on repeated
|
|
@@ -3097,6 +3176,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3097
3176
|
status: attempt.status,
|
|
3098
3177
|
error: attempt.json?.error?.message || attempt.text?.slice(0, 120) || null,
|
|
3099
3178
|
}, "[TierFallback] Candidate answered with an error response, trying next");
|
|
3179
|
+
failureTrail.push(`${cand.provider} (${cand.model}): HTTP ${attempt.status} — ${attempt.json?.error?.message || attempt.text?.slice(0, 120) || "error response"}`);
|
|
3100
3180
|
continue;
|
|
3101
3181
|
}
|
|
3102
3182
|
|
|
@@ -3127,6 +3207,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3127
3207
|
{ toProvider: cand.provider, error: innerErr.message },
|
|
3128
3208
|
"[TierFallback] Candidate failed, trying next"
|
|
3129
3209
|
);
|
|
3210
|
+
failureTrail.push(`${cand.provider} (${cand.model}): ${innerErr.message}`);
|
|
3130
3211
|
}
|
|
3131
3212
|
}
|
|
3132
3213
|
logger.warn(
|
|
@@ -3175,6 +3256,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3175
3256
|
candidates: routingResult.candidates ?? null,
|
|
3176
3257
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3177
3258
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3259
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3178
3260
|
});
|
|
3179
3261
|
|
|
3180
3262
|
// WS5.4 — feedback loop (primary-failed, no fallback). Low quality
|
|
@@ -3194,6 +3276,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3194
3276
|
},
|
|
3195
3277
|
});
|
|
3196
3278
|
|
|
3279
|
+
err.lynkrFailureTrail = failureTrail;
|
|
3197
3280
|
throw err;
|
|
3198
3281
|
}
|
|
3199
3282
|
|
|
@@ -3221,26 +3304,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3221
3304
|
const fallbackStart = Date.now();
|
|
3222
3305
|
|
|
3223
3306
|
const fallbackResult = await fallbackBreaker.execute(async () => {
|
|
3224
|
-
|
|
3225
|
-
return await invokeAzureOpenAI(body, incomingHeaders);
|
|
3226
|
-
} else if (fallbackProvider === "azure-anthropic") {
|
|
3227
|
-
return await invokeAzureAnthropic(body, incomingHeaders);
|
|
3228
|
-
} else if (fallbackProvider === "openrouter") {
|
|
3229
|
-
return await invokeOpenRouter(body, incomingHeaders);
|
|
3230
|
-
} else if (fallbackProvider === "edenai") {
|
|
3231
|
-
return await invokeEdenAI(body, incomingHeaders);
|
|
3232
|
-
} else if (fallbackProvider === "openai") {
|
|
3233
|
-
return await invokeOpenAI(body, incomingHeaders);
|
|
3234
|
-
} else if (fallbackProvider === "llamacpp") {
|
|
3235
|
-
return await invokeLlamaCpp(body, incomingHeaders);
|
|
3236
|
-
} else if (fallbackProvider === "zai") {
|
|
3237
|
-
return await invokeZai(body, incomingHeaders);
|
|
3238
|
-
} else if (fallbackProvider === "vertex") {
|
|
3239
|
-
return await invokeVertex(body, incomingHeaders);
|
|
3240
|
-
} else if (fallbackProvider === "moonshot") {
|
|
3241
|
-
return await invokeMoonshot(body, incomingHeaders);
|
|
3242
|
-
}
|
|
3243
|
-
return await invokeDatabricks(body, incomingHeaders);
|
|
3307
|
+
return await invokeProvider(fallbackProvider, body, incomingHeaders);
|
|
3244
3308
|
});
|
|
3245
3309
|
|
|
3246
3310
|
const fallbackLatency = Date.now() - fallbackStart;
|
|
@@ -3311,6 +3375,9 @@ async function invokeModel(body, options = {}) {
|
|
|
3311
3375
|
candidates: routingResult.candidates ?? null,
|
|
3312
3376
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3313
3377
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3378
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3379
|
+
cache_read_tokens: fallbackResult.json?.usage?.cache_read_input_tokens ?? null,
|
|
3380
|
+
cache_creation_tokens: fallbackResult.json?.usage?.cache_creation_input_tokens ?? null,
|
|
3314
3381
|
});
|
|
3315
3382
|
|
|
3316
3383
|
// WS5.4 — feedback loop (fallback success). The served provider
|
|
@@ -3330,6 +3397,16 @@ async function invokeModel(body, options = {}) {
|
|
|
3330
3397
|
},
|
|
3331
3398
|
});
|
|
3332
3399
|
|
|
3400
|
+
// The last rung also answered with an error — thread the whole trail
|
|
3401
|
+
// into the error payload so the client sees every failure, not just
|
|
3402
|
+
// this one (e.g. "moonshot: quota" matters more than "azure: 404").
|
|
3403
|
+
if (fallbackResult?.ok === false) {
|
|
3404
|
+
failureTrail.push(`${fallbackProvider}: HTTP ${fallbackResult.status} — ${fallbackResult.json?.error?.message || String(fallbackResult.text || "").slice(0, 120) || "error response"}`);
|
|
3405
|
+
if (fallbackResult.json && typeof fallbackResult.json === "object") {
|
|
3406
|
+
fallbackResult.json._lynkr_failure_trail = failureTrail;
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
|
|
3333
3410
|
return {
|
|
3334
3411
|
...fallbackResult,
|
|
3335
3412
|
actualProvider: fallbackProvider,
|
|
@@ -3368,6 +3445,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3368
3445
|
candidates: routingResult.candidates ?? null,
|
|
3369
3446
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3370
3447
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3448
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3371
3449
|
});
|
|
3372
3450
|
|
|
3373
3451
|
// WS5.4 — feedback loop (double failure). quality=0 is a hard
|
|
@@ -3394,6 +3472,8 @@ async function invokeModel(body, options = {}) {
|
|
|
3394
3472
|
}, "Both primary and fallback provider failed");
|
|
3395
3473
|
|
|
3396
3474
|
// Return fallback error (more actionable than Ollama error)
|
|
3475
|
+
failureTrail.push(`${fallbackProvider}: ${fallbackErr.message}`);
|
|
3476
|
+
fallbackErr.lynkrFailureTrail = failureTrail;
|
|
3397
3477
|
throw fallbackErr;
|
|
3398
3478
|
}
|
|
3399
3479
|
}
|
|
@@ -3459,6 +3539,8 @@ module.exports = {
|
|
|
3459
3539
|
invokeAzureAnthropic,
|
|
3460
3540
|
invokeZai,
|
|
3461
3541
|
invokeOllama,
|
|
3542
|
+
invokeMoonshot,
|
|
3543
|
+
PROVIDER_INVOKERS,
|
|
3462
3544
|
stripLynkrBadges,
|
|
3463
3545
|
destroyHttpAgents,
|
|
3464
3546
|
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
|
|