lynkr 9.10.0 → 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/bin/lynkr-init.js +7 -0
- package/config/model-tiers.json +5 -5
- package/package.json +3 -3
- package/public/dashboard.html +29 -0
- package/src/api/router.js +34 -0
- package/src/clients/databricks.js +120 -65
- 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 +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 +85 -2
- 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 +7 -1
- 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 +139 -2
- package/src/routing/verifier.js +41 -2
- package/src/server.js +36 -3
- package/src/tools/code-graph.js +6 -4
package/bin/lynkr-init.js
CHANGED
|
@@ -236,6 +236,13 @@ const BASELINE_ENV = {
|
|
|
236
236
|
HEADROOM_CCR: 'true',
|
|
237
237
|
HEADROOM_CCR_TTL: '300',
|
|
238
238
|
|
|
239
|
+
// ── TencentDB-Agent-Memory sidecar (team memory hub) ──────────────────
|
|
240
|
+
// Launches agentmemory/memory-core + memory-hub containers on
|
|
241
|
+
// `lynkr start` (Panel UI :8125). First start pulls two Docker Hub
|
|
242
|
+
// images; skips gracefully when Docker isn't running. See .env.example
|
|
243
|
+
// for all knobs.
|
|
244
|
+
TENCENTDB_MEMORY_ENABLED: 'true',
|
|
245
|
+
|
|
239
246
|
// ── Memory + token tracking ───────────────────────────────────────────
|
|
240
247
|
MEMORY_ENABLED: 'true',
|
|
241
248
|
MEMORY_RETRIEVAL_LIMIT: '5',
|
package/config/model-tiers.json
CHANGED
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"GLM-4-Flash"
|
|
54
54
|
],
|
|
55
55
|
"moonshot": [
|
|
56
|
-
"kimi-k2
|
|
56
|
+
"kimi-k2.6"
|
|
57
57
|
]
|
|
58
58
|
}
|
|
59
59
|
},
|
|
@@ -108,7 +108,7 @@
|
|
|
108
108
|
"GLM-4.7"
|
|
109
109
|
],
|
|
110
110
|
"moonshot": [
|
|
111
|
-
"kimi-k2
|
|
111
|
+
"kimi-k2.6"
|
|
112
112
|
]
|
|
113
113
|
}
|
|
114
114
|
},
|
|
@@ -157,7 +157,7 @@
|
|
|
157
157
|
"GLM-4.7"
|
|
158
158
|
],
|
|
159
159
|
"moonshot": [
|
|
160
|
-
"kimi-k2
|
|
160
|
+
"kimi-k2.6"
|
|
161
161
|
]
|
|
162
162
|
}
|
|
163
163
|
},
|
|
@@ -203,8 +203,8 @@
|
|
|
203
203
|
"deepseek-r1"
|
|
204
204
|
],
|
|
205
205
|
"moonshot": [
|
|
206
|
-
"kimi-
|
|
207
|
-
"kimi-k2
|
|
206
|
+
"kimi-k3",
|
|
207
|
+
"kimi-k2.6"
|
|
208
208
|
]
|
|
209
209
|
}
|
|
210
210
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lynkr",
|
|
3
|
-
"version": "9.
|
|
3
|
+
"version": "9.11.0",
|
|
4
4
|
"files": [
|
|
5
5
|
"index.js",
|
|
6
6
|
"install.sh",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"dev": "nodemon index.js",
|
|
37
37
|
"lint": "eslint src index.js",
|
|
38
38
|
"test": "npm run test:unit && npm run test:performance",
|
|
39
|
-
"test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js",
|
|
40
|
-
"test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js",
|
|
39
|
+
"test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js",
|
|
40
|
+
"test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js",
|
|
41
41
|
"test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js",
|
|
42
42
|
"test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js",
|
|
43
43
|
"test:benchmark": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-benchmark.js",
|
package/public/dashboard.html
CHANGED
|
@@ -493,6 +493,35 @@ const App = {
|
|
|
493
493
|
` : emptyState('No routing data for last 24h')}
|
|
494
494
|
`, 'mb-6')}
|
|
495
495
|
|
|
496
|
+
<!-- Cache Economics (cache-aware routing receipt) -->
|
|
497
|
+
${card(`
|
|
498
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-4">Cache Economics (last ${d.window || '24h'})</h3>
|
|
499
|
+
${d.cacheEconomics && d.cacheEconomics.decisions > 0 ? `
|
|
500
|
+
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
501
|
+
<div class="text-center">
|
|
502
|
+
<p class="text-2xl font-bold text-green-400">${fmt.usd(d.cacheEconomics.totalDollarsSaved)}</p>
|
|
503
|
+
<p class="text-xs text-slate-500 mt-1">Cache $ saved by routing</p>
|
|
504
|
+
<p class="text-xs text-slate-600">(holds + cleared switches)</p>
|
|
505
|
+
</div>
|
|
506
|
+
<div class="text-center">
|
|
507
|
+
<p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.holds)}</p>
|
|
508
|
+
<p class="text-xs text-slate-500 mt-1">Switches held</p>
|
|
509
|
+
<p class="text-xs text-slate-600">(warm prefix protected)</p>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="text-center">
|
|
512
|
+
<p class="text-2xl font-bold">${fmt.num(d.cacheEconomics.switches)}</p>
|
|
513
|
+
<p class="text-xs text-slate-500 mt-1">Switches cleared</p>
|
|
514
|
+
<p class="text-xs text-slate-600">(break-even ≤ remaining turns)</p>
|
|
515
|
+
</div>
|
|
516
|
+
<div class="text-center">
|
|
517
|
+
<p class="text-2xl font-bold text-amber-400">${fmt.usd(d.cacheEconomics.dollarsSavedByHolds)}</p>
|
|
518
|
+
<p class="text-xs text-slate-500 mt-1">Saved by holding pins</p>
|
|
519
|
+
<p class="text-xs text-slate-600">(cache re-writes avoided)</p>
|
|
520
|
+
</div>
|
|
521
|
+
</div>
|
|
522
|
+
` : emptyState('No gated switch decisions yet — appears once a pinned session hits a downgrade decision')}
|
|
523
|
+
`, 'mb-6')}
|
|
524
|
+
|
|
496
525
|
<!-- Provider Stats -->
|
|
497
526
|
${card(`
|
|
498
527
|
<h3 class="text-sm font-semibold text-slate-300 mb-3">Provider Stats (last ${d.window || '24h'})</h3>
|
package/src/api/router.js
CHANGED
|
@@ -579,6 +579,30 @@ function _buildStreamBadge(tier) {
|
|
|
579
579
|
return `*[Lynkr] ${tier.tier || "—"} → ${tier.model || "—"} (${tier.provider || "—"}) · score ${tier.score ?? "—"}${pin}*\n\n`;
|
|
580
580
|
}
|
|
581
581
|
|
|
582
|
+
/**
|
|
583
|
+
* Terminal upstream errors carry no content array — without this, the synth
|
|
584
|
+
* SSE writers render an empty message and the turn goes silent in the TUI
|
|
585
|
+
* (only the routing badge shows). Returns visible-text content blocks for an
|
|
586
|
+
* error result, using the fallback chain's failure trail when the provider
|
|
587
|
+
* layer recorded one (so "moonshot: quota exhausted" isn't hidden behind the
|
|
588
|
+
* last rung's unrelated error), or null for normal responses.
|
|
589
|
+
*/
|
|
590
|
+
function _buildFailureBlocks(result, msg) {
|
|
591
|
+
if (result.terminationReason !== "api_error" && Array.isArray(msg?.content)) return null;
|
|
592
|
+
const trail = Array.isArray(msg?._lynkr_failure_trail) ? msg._lynkr_failure_trail : null;
|
|
593
|
+
const lastErr = msg?.error?.message || msg?.message
|
|
594
|
+
|| (typeof msg === "string" ? msg : JSON.stringify(msg ?? {}).slice(0, 200));
|
|
595
|
+
const trailText = (trail || [String(lastErr)]).map((t) => ` · ${t}`).join("\n");
|
|
596
|
+
const quotaHit = result.status === 429 || /rate.?limit|quota|TPD|TPM/i.test(trailText);
|
|
597
|
+
return [{
|
|
598
|
+
type: "text",
|
|
599
|
+
text:
|
|
600
|
+
`⚠ [Lynkr] ${quotaHit ? "Provider quota exhausted — no fallback could serve this request" : "No provider could serve this request"} (HTTP ${result.status ?? "?"}).\n\n` +
|
|
601
|
+
`${trailText}\n` +
|
|
602
|
+
(quotaHit ? `\nAll fallback candidates failed after the quota hit. Wait for the quota window to reset or repoint the affected tier in .env.\n` : ""),
|
|
603
|
+
}];
|
|
604
|
+
}
|
|
605
|
+
|
|
582
606
|
/**
|
|
583
607
|
* Scan the entire payload for tool_use or tool_result content blocks.
|
|
584
608
|
* Unlike session-affinity.payloadHasToolHistory (which checks only the last
|
|
@@ -1611,6 +1635,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1611
1635
|
// tool_use with no tool_use block).
|
|
1612
1636
|
let contentBlocks = (msg.content || []).slice();
|
|
1613
1637
|
|
|
1638
|
+
const failureBlocks = _buildFailureBlocks(result, msg);
|
|
1639
|
+
if (failureBlocks) contentBlocks = failureBlocks;
|
|
1640
|
+
|
|
1614
1641
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1615
1642
|
// users can see which tier/provider/model handled the request inside
|
|
1616
1643
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
@@ -1807,6 +1834,9 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1807
1834
|
// tool_use with no tool_use block).
|
|
1808
1835
|
let contentBlocks = (msg.content || []).slice();
|
|
1809
1836
|
|
|
1837
|
+
const failureBlocks = _buildFailureBlocks(result, msg);
|
|
1838
|
+
if (failureBlocks) contentBlocks = failureBlocks;
|
|
1839
|
+
|
|
1810
1840
|
// When LYNKR_VISIBLE_ROUTING=true, prepend a one-line routing badge so
|
|
1811
1841
|
// users can see which tier/provider/model handled the request inside
|
|
1812
1842
|
// Claude Code's TUI (TUI only renders content blocks; unknown top-level
|
|
@@ -1969,6 +1999,10 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
1969
1999
|
}
|
|
1970
2000
|
|
|
1971
2001
|
metrics.recordResponse(result.status);
|
|
2002
|
+
// finalBody is usually a JSON string; Express defaults string bodies to
|
|
2003
|
+
// text/html, which Claude Code rejects as a malformed 200. Only default —
|
|
2004
|
+
// an upstream content-type copied from result.headers above still wins.
|
|
2005
|
+
if (!res.getHeader("Content-Type")) res.type("application/json");
|
|
1972
2006
|
res.status(result.status).send(finalBody);
|
|
1973
2007
|
} catch (error) {
|
|
1974
2008
|
next(error);
|
|
@@ -560,9 +560,20 @@ async function invokeOllama(body, incomingHeaders = {}) {
|
|
|
560
560
|
if (body.tool_choice) ollamaBody.tool_choice = body.tool_choice;
|
|
561
561
|
if (body.metadata) ollamaBody.metadata = body.metadata;
|
|
562
562
|
|
|
563
|
-
// 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".
|
|
564
567
|
if (supportsTools && Array.isArray(toolsToSend) && toolsToSend.length > 0) {
|
|
565
|
-
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
|
+
);
|
|
566
577
|
}
|
|
567
578
|
|
|
568
579
|
if (config.ollama.keepAlive !== undefined) {
|
|
@@ -1934,6 +1945,21 @@ async function invokeZai(body, incomingHeaders = {}) {
|
|
|
1934
1945
|
}, "=== INJECTING STANDARD TOOLS (Z.AI Anthropic) ===");
|
|
1935
1946
|
}
|
|
1936
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
|
+
|
|
1937
1963
|
headers = {
|
|
1938
1964
|
"Content-Type": "application/json",
|
|
1939
1965
|
"x-api-key": config.zai.apiKey,
|
|
@@ -2019,13 +2045,17 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2019
2045
|
|
|
2020
2046
|
// Model mapping: Anthropic names → Moonshot/Kimi names
|
|
2021
2047
|
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
|
|
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",
|
|
2029
2059
|
// moonshot-v1-auto 400s with "tokenization failed" (its server-side auto
|
|
2030
2060
|
// context-size pass fails on large tool-bearing payloads). Remap to a
|
|
2031
2061
|
// fixed model that's broadly available on api.moonshot.ai.
|
|
@@ -2033,7 +2063,12 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2033
2063
|
};
|
|
2034
2064
|
|
|
2035
2065
|
const requestedModel = body._tierModel || body.model || config.moonshot.model;
|
|
2036
|
-
|
|
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";
|
|
2037
2072
|
// Guard against the deprecated auto model arriving via config too.
|
|
2038
2073
|
if (mappedModel === "moonshot-v1-auto") mappedModel = "moonshot-v1-128k";
|
|
2039
2074
|
|
|
@@ -2047,18 +2082,18 @@ async function invokeMoonshot(body, incomingHeaders = {}) {
|
|
|
2047
2082
|
messages.unshift({ role: "system", content: systemContent });
|
|
2048
2083
|
}
|
|
2049
2084
|
|
|
2050
|
-
// kimi-
|
|
2051
|
-
//
|
|
2052
|
-
|
|
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);
|
|
2053
2090
|
|
|
2054
2091
|
const moonshotBody = {
|
|
2055
2092
|
model: mappedModel,
|
|
2056
2093
|
messages,
|
|
2057
2094
|
max_tokens: body.max_tokens || 16384,
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
temperature: isKimiThinking ? 1 : (body.temperature ?? 0.7),
|
|
2061
|
-
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),
|
|
2062
2097
|
stream: false, // Force non-streaming - OpenAI SSE to Anthropic SSE conversion not implemented
|
|
2063
2098
|
};
|
|
2064
2099
|
|
|
@@ -2678,6 +2713,43 @@ function stripLynkrBadges(messages) {
|
|
|
2678
2713
|
return mutated ? out : messages;
|
|
2679
2714
|
}
|
|
2680
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
|
+
|
|
2681
2753
|
async function invokeModel(body, options = {}) {
|
|
2682
2754
|
const { determineProviderSmart, isFallbackEnabled, getFallbackProvider } = require("./routing");
|
|
2683
2755
|
const metricsCollector = getMetricsCollector();
|
|
@@ -2834,34 +2906,7 @@ async function invokeModel(body, options = {}) {
|
|
|
2834
2906
|
|
|
2835
2907
|
try {
|
|
2836
2908
|
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);
|
|
2909
|
+
return await invokeProvider(initialProvider, body, incomingHeaders);
|
|
2865
2910
|
});
|
|
2866
2911
|
|
|
2867
2912
|
const latency = Date.now() - startTime;
|
|
@@ -2954,6 +2999,7 @@ async function invokeModel(body, options = {}) {
|
|
|
2954
2999
|
candidates: routingResult.candidates ?? null,
|
|
2955
3000
|
pinned: routingResult.pinned ? 1 : 0,
|
|
2956
3001
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3002
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
2957
3003
|
});
|
|
2958
3004
|
|
|
2959
3005
|
// WS5.4 — feedback loop (success path).
|
|
@@ -3035,6 +3081,14 @@ async function invokeModel(body, options = {}) {
|
|
|
3035
3081
|
healthTracker.recordFailure(initialProvider, err, err.status);
|
|
3036
3082
|
getLatencyTracker().record(initialProvider, routingDecision?.model, failLatency);
|
|
3037
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
|
+
|
|
3038
3092
|
// undici wraps every network failure as "TypeError: fetch failed" — the
|
|
3039
3093
|
// actionable detail (ENOTFOUND vs ECONNRESET vs ETIMEDOUT vs TLS) lives
|
|
3040
3094
|
// in err.cause. Without this line the z-ai breaker trips on repeated
|
|
@@ -3097,6 +3151,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3097
3151
|
status: attempt.status,
|
|
3098
3152
|
error: attempt.json?.error?.message || attempt.text?.slice(0, 120) || null,
|
|
3099
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"}`);
|
|
3100
3155
|
continue;
|
|
3101
3156
|
}
|
|
3102
3157
|
|
|
@@ -3127,6 +3182,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3127
3182
|
{ toProvider: cand.provider, error: innerErr.message },
|
|
3128
3183
|
"[TierFallback] Candidate failed, trying next"
|
|
3129
3184
|
);
|
|
3185
|
+
failureTrail.push(`${cand.provider} (${cand.model}): ${innerErr.message}`);
|
|
3130
3186
|
}
|
|
3131
3187
|
}
|
|
3132
3188
|
logger.warn(
|
|
@@ -3175,6 +3231,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3175
3231
|
candidates: routingResult.candidates ?? null,
|
|
3176
3232
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3177
3233
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3234
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3178
3235
|
});
|
|
3179
3236
|
|
|
3180
3237
|
// WS5.4 — feedback loop (primary-failed, no fallback). Low quality
|
|
@@ -3194,6 +3251,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3194
3251
|
},
|
|
3195
3252
|
});
|
|
3196
3253
|
|
|
3254
|
+
err.lynkrFailureTrail = failureTrail;
|
|
3197
3255
|
throw err;
|
|
3198
3256
|
}
|
|
3199
3257
|
|
|
@@ -3221,26 +3279,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3221
3279
|
const fallbackStart = Date.now();
|
|
3222
3280
|
|
|
3223
3281
|
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);
|
|
3282
|
+
return await invokeProvider(fallbackProvider, body, incomingHeaders);
|
|
3244
3283
|
});
|
|
3245
3284
|
|
|
3246
3285
|
const fallbackLatency = Date.now() - fallbackStart;
|
|
@@ -3311,6 +3350,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3311
3350
|
candidates: routingResult.candidates ?? null,
|
|
3312
3351
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3313
3352
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3353
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3314
3354
|
});
|
|
3315
3355
|
|
|
3316
3356
|
// WS5.4 — feedback loop (fallback success). The served provider
|
|
@@ -3330,6 +3370,16 @@ async function invokeModel(body, options = {}) {
|
|
|
3330
3370
|
},
|
|
3331
3371
|
});
|
|
3332
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
|
+
|
|
3333
3383
|
return {
|
|
3334
3384
|
...fallbackResult,
|
|
3335
3385
|
actualProvider: fallbackProvider,
|
|
@@ -3368,6 +3418,7 @@ async function invokeModel(body, options = {}) {
|
|
|
3368
3418
|
candidates: routingResult.candidates ?? null,
|
|
3369
3419
|
pinned: routingResult.pinned ? 1 : 0,
|
|
3370
3420
|
switch_reason: routingResult.switch_reason ?? null,
|
|
3421
|
+
cache_decision: routingResult._cacheDecision ?? null,
|
|
3371
3422
|
});
|
|
3372
3423
|
|
|
3373
3424
|
// WS5.4 — feedback loop (double failure). quality=0 is a hard
|
|
@@ -3394,6 +3445,8 @@ async function invokeModel(body, options = {}) {
|
|
|
3394
3445
|
}, "Both primary and fallback provider failed");
|
|
3395
3446
|
|
|
3396
3447
|
// Return fallback error (more actionable than Ollama error)
|
|
3448
|
+
failureTrail.push(`${fallbackProvider}: ${fallbackErr.message}`);
|
|
3449
|
+
fallbackErr.lynkrFailureTrail = failureTrail;
|
|
3397
3450
|
throw fallbackErr;
|
|
3398
3451
|
}
|
|
3399
3452
|
}
|
|
@@ -3459,6 +3512,8 @@ module.exports = {
|
|
|
3459
3512
|
invokeAzureAnthropic,
|
|
3460
3513
|
invokeZai,
|
|
3461
3514
|
invokeOllama,
|
|
3515
|
+
invokeMoonshot,
|
|
3516
|
+
PROVIDER_INVOKERS,
|
|
3462
3517
|
stripLynkrBadges,
|
|
3463
3518
|
destroyHttpAgents,
|
|
3464
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 = {
|