lynkr 8.0.1 → 9.0.1
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/.lynkr/telemetry.db +0 -0
- package/.lynkr/telemetry.db-shm +0 -0
- package/.lynkr/telemetry.db-wal +0 -0
- package/README.md +195 -321
- package/lynkr-skill.tar.gz +0 -0
- package/package.json +4 -3
- package/src/api/openai-router.js +30 -11
- package/src/api/providers-handler.js +171 -3
- package/src/api/router.js +9 -2
- package/src/clients/circuit-breaker.js +10 -247
- package/src/clients/codex-process.js +342 -0
- package/src/clients/codex-utils.js +143 -0
- package/src/clients/databricks.js +210 -63
- package/src/clients/resilience.js +540 -0
- package/src/clients/retry.js +22 -167
- package/src/config/index.js +57 -0
- package/src/context/compression.js +42 -9
- package/src/context/distill.js +492 -0
- package/src/orchestrator/index.js +46 -6
- package/src/routing/complexity-analyzer.js +258 -5
- package/src/routing/index.js +12 -2
- package/src/routing/latency-tracker.js +148 -0
- package/src/routing/model-tiers.js +2 -0
- package/src/routing/quality-scorer.js +113 -0
- package/src/routing/telemetry.js +464 -0
- package/src/server.js +11 -0
- package/src/tools/code-graph.js +538 -0
- package/src/tools/code-mode.js +304 -0
- package/src/tools/lazy-loader.js +11 -0
- package/src/tools/mcp-remote.js +7 -0
- package/src/tools/smart-selection.js +11 -0
- package/src/utils/payload.js +206 -0
- package/src/utils/perf-timer.js +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lynkr",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "9.0.1",
|
|
4
4
|
"description": "Self-hosted Claude Code & Cursor proxy with Databricks,AWS BedRock,Azure adapters, openrouter, Ollama,llamacpp,LM Studio, workspace tooling, and MCP integration.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
"lynkr-setup": "./scripts/setup.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"prestart": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom up -d headroom 2>/dev/null || echo 'Headroom skipped (disabled or Docker not running)'",
|
|
11
|
+
"prestart": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom up -d --build headroom 2>/dev/null || echo 'Headroom skipped (disabled or Docker not running)'",
|
|
12
12
|
"start": "node index.js 2>&1 | npx pino-pretty --sync",
|
|
13
13
|
"stop": "node -e \"if(process.env.HEADROOM_ENABLED==='true'&&process.env.HEADROOM_DOCKER_ENABLED!=='false'){process.exit(0)}else{process.exit(1)}\" && docker compose --profile headroom down || echo 'Headroom skipped (disabled or Docker not running)'",
|
|
14
14
|
"dev": "nodemon index.js",
|
|
15
15
|
"lint": "eslint src index.js",
|
|
16
16
|
"test": "npm run test:unit && npm run test:performance",
|
|
17
|
-
"test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.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/llamacpp-integration.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",
|
|
17
|
+
"test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/routing.test.js test/hybrid-routing-integration.test.js test/web-tools.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/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/code-mode.test.js",
|
|
18
18
|
"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",
|
|
19
19
|
"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",
|
|
20
20
|
"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",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@babel/parser": "^7.29.0",
|
|
49
49
|
"@babel/traverse": "^7.29.0",
|
|
50
50
|
"@toon-format/toon": "^2.1.0",
|
|
51
|
+
"cockatiel": "^3.2.1",
|
|
51
52
|
"compression": "^1.7.4",
|
|
52
53
|
"diff": "^5.2.0",
|
|
53
54
|
"dotenv": "^16.4.5",
|
package/src/api/openai-router.js
CHANGED
|
@@ -27,6 +27,23 @@ const { IDE_SAFE_TOOLS } = require("../clients/standard-tools");
|
|
|
27
27
|
|
|
28
28
|
const router = express.Router();
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the model name for OpenAI responses.
|
|
32
|
+
* In OpenClaw mode, returns the actual provider/model from routing metadata.
|
|
33
|
+
*/
|
|
34
|
+
function resolveResponseModel(resultBody, requestModel) {
|
|
35
|
+
if (config.openclaw?.enabled && resultBody?._routingMeta) {
|
|
36
|
+
const meta = resultBody._routingMeta;
|
|
37
|
+
if (meta.provider && meta.model) {
|
|
38
|
+
return `${meta.provider}/${meta.model}`;
|
|
39
|
+
}
|
|
40
|
+
if (meta.provider) {
|
|
41
|
+
return meta.provider;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return requestModel;
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
/**
|
|
31
48
|
* Client detection - identifies which AI coding tool is making the request
|
|
32
49
|
* @param {Object} headers - Request headers
|
|
@@ -499,7 +516,8 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
499
516
|
}
|
|
500
517
|
|
|
501
518
|
// Convert to OpenAI format
|
|
502
|
-
const
|
|
519
|
+
const streamModel = resolveResponseModel(result.body, req.body.model);
|
|
520
|
+
const openaiResponse = convertAnthropicToOpenAI(result.body, streamModel);
|
|
503
521
|
|
|
504
522
|
// Debug: Log what we're about to stream
|
|
505
523
|
logger.debug({
|
|
@@ -539,7 +557,7 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
539
557
|
id: openaiResponse.id,
|
|
540
558
|
object: "chat.completion.chunk",
|
|
541
559
|
created: openaiResponse.created,
|
|
542
|
-
model:
|
|
560
|
+
model: streamModel,
|
|
543
561
|
system_fingerprint: "fp_lynkr",
|
|
544
562
|
choices: [{
|
|
545
563
|
index: 0,
|
|
@@ -561,7 +579,7 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
561
579
|
id: openaiResponse.id,
|
|
562
580
|
object: "chat.completion.chunk",
|
|
563
581
|
created: openaiResponse.created,
|
|
564
|
-
model:
|
|
582
|
+
model: streamModel,
|
|
565
583
|
system_fingerprint: "fp_lynkr",
|
|
566
584
|
choices: [{
|
|
567
585
|
index: 0,
|
|
@@ -581,7 +599,7 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
581
599
|
id: openaiResponse.id,
|
|
582
600
|
object: "chat.completion.chunk",
|
|
583
601
|
created: openaiResponse.created,
|
|
584
|
-
model:
|
|
602
|
+
model: streamModel,
|
|
585
603
|
choices: [{
|
|
586
604
|
index: 0,
|
|
587
605
|
delta: {
|
|
@@ -608,7 +626,7 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
608
626
|
id: openaiResponse.id,
|
|
609
627
|
object: "chat.completion.chunk",
|
|
610
628
|
created: openaiResponse.created,
|
|
611
|
-
model:
|
|
629
|
+
model: streamModel,
|
|
612
630
|
system_fingerprint: "fp_lynkr",
|
|
613
631
|
choices: [{
|
|
614
632
|
index: 0,
|
|
@@ -679,7 +697,7 @@ router.post("/chat/completions", async (req, res) => {
|
|
|
679
697
|
}, "Orchestrator result structure");
|
|
680
698
|
|
|
681
699
|
// Convert Anthropic response to OpenAI format
|
|
682
|
-
const openaiResponse = convertAnthropicToOpenAI(result.body, req.body.model);
|
|
700
|
+
const openaiResponse = convertAnthropicToOpenAI(result.body, resolveResponseModel(result.body, req.body.model));
|
|
683
701
|
|
|
684
702
|
// Map tool names for known IDE clients
|
|
685
703
|
if (clientType !== "unknown" && openaiResponse.choices?.[0]?.message?.tool_calls?.length > 0) {
|
|
@@ -1535,7 +1553,8 @@ router.post("/responses", async (req, res) => {
|
|
|
1535
1553
|
}, "=== ORCHESTRATOR RESULT FOR RESPONSES API ===");
|
|
1536
1554
|
|
|
1537
1555
|
// Convert back: Anthropic → OpenAI → Responses
|
|
1538
|
-
const
|
|
1556
|
+
const responsesModel = resolveResponseModel(result.body, req.body.model);
|
|
1557
|
+
const chatResponse = convertAnthropicToOpenAI(result.body, responsesModel);
|
|
1539
1558
|
|
|
1540
1559
|
logger.debug({
|
|
1541
1560
|
chatContent: chatResponse.choices?.[0]?.message?.content?.substring(0, 200),
|
|
@@ -1597,7 +1616,7 @@ router.post("/responses", async (req, res) => {
|
|
|
1597
1616
|
object: "response",
|
|
1598
1617
|
status: "in_progress",
|
|
1599
1618
|
created_at: createdAt,
|
|
1600
|
-
model:
|
|
1619
|
+
model: responsesModel,
|
|
1601
1620
|
output: [],
|
|
1602
1621
|
usage: null
|
|
1603
1622
|
},
|
|
@@ -1614,7 +1633,7 @@ router.post("/responses", async (req, res) => {
|
|
|
1614
1633
|
object: "response",
|
|
1615
1634
|
status: "in_progress",
|
|
1616
1635
|
created_at: createdAt,
|
|
1617
|
-
model:
|
|
1636
|
+
model: responsesModel,
|
|
1618
1637
|
output: [],
|
|
1619
1638
|
usage: null
|
|
1620
1639
|
},
|
|
@@ -1793,7 +1812,7 @@ router.post("/responses", async (req, res) => {
|
|
|
1793
1812
|
object: "response",
|
|
1794
1813
|
status: "completed",
|
|
1795
1814
|
created_at: createdAt,
|
|
1796
|
-
model:
|
|
1815
|
+
model: responsesModel,
|
|
1797
1816
|
output: outputItems,
|
|
1798
1817
|
usage: {
|
|
1799
1818
|
input_tokens: responsesResponse.usage?.prompt_tokens || 0,
|
|
@@ -1846,7 +1865,7 @@ router.post("/responses", async (req, res) => {
|
|
|
1846
1865
|
});
|
|
1847
1866
|
|
|
1848
1867
|
// Convert back: Anthropic → OpenAI → Responses
|
|
1849
|
-
const chatResponse = convertAnthropicToOpenAI(result.body, req.body.model);
|
|
1868
|
+
const chatResponse = convertAnthropicToOpenAI(result.body, resolveResponseModel(result.body, req.body.model));
|
|
1850
1869
|
const responsesResponse = convertChatToResponses(chatResponse);
|
|
1851
1870
|
|
|
1852
1871
|
logger.info({
|
|
@@ -415,8 +415,8 @@ router.get("/health/providers", (req, res) => {
|
|
|
415
415
|
// Get circuit breaker states
|
|
416
416
|
const circuitBreakerStates = {};
|
|
417
417
|
const allBreakers = registry.getAll();
|
|
418
|
-
for (const
|
|
419
|
-
circuitBreakerStates[name] = breaker.state;
|
|
418
|
+
for (const breaker of allBreakers) {
|
|
419
|
+
circuitBreakerStates[breaker.name] = breaker.state;
|
|
420
420
|
}
|
|
421
421
|
|
|
422
422
|
// Get all provider health
|
|
@@ -452,7 +452,8 @@ router.get("/health/providers/:name", (req, res) => {
|
|
|
452
452
|
|
|
453
453
|
// Get circuit breaker state for this provider
|
|
454
454
|
const allBreakers = registry.getAll();
|
|
455
|
-
const
|
|
455
|
+
const breakerState = allBreakers.find((b) => b.name === providerName);
|
|
456
|
+
const circuitState = breakerState?.state || "CLOSED";
|
|
456
457
|
|
|
457
458
|
// Get detailed metrics
|
|
458
459
|
const metrics = healthTracker.getProviderMetrics(providerName);
|
|
@@ -477,4 +478,171 @@ router.get("/health/providers/:name", (req, res) => {
|
|
|
477
478
|
}
|
|
478
479
|
});
|
|
479
480
|
|
|
481
|
+
// ============================================================================
|
|
482
|
+
// Routing Telemetry Endpoints
|
|
483
|
+
// ============================================================================
|
|
484
|
+
|
|
485
|
+
const telemetry = require("../routing/telemetry");
|
|
486
|
+
const { getLatencyTracker } = require("../routing/latency-tracker");
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* GET /v1/routing/stats
|
|
490
|
+
*
|
|
491
|
+
* Aggregated routing telemetry statistics.
|
|
492
|
+
*/
|
|
493
|
+
router.get("/routing/stats", (req, res) => {
|
|
494
|
+
try {
|
|
495
|
+
const since = req.query.since ? Number(req.query.since) : undefined;
|
|
496
|
+
const until = req.query.until ? Number(req.query.until) : undefined;
|
|
497
|
+
const stats = telemetry.getStats({ since, until });
|
|
498
|
+
|
|
499
|
+
if (!stats) {
|
|
500
|
+
return res.json({ object: "routing_stats", data: null, message: "No telemetry data available" });
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Merge latency tracker percentiles
|
|
504
|
+
const latencyTracker = getLatencyTracker();
|
|
505
|
+
const latencyStats = {};
|
|
506
|
+
for (const [provider, pStats] of latencyTracker.getAllStats()) {
|
|
507
|
+
latencyStats[provider] = pStats;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
res.json({
|
|
511
|
+
object: "routing_stats",
|
|
512
|
+
data: { ...stats, latencyPercentiles: latencyStats },
|
|
513
|
+
timestamp: new Date().toISOString(),
|
|
514
|
+
});
|
|
515
|
+
} catch (error) {
|
|
516
|
+
logger.error({ error: error.message }, "Error getting routing stats");
|
|
517
|
+
res.status(500).json({ error: { type: "server_error", message: error.message } });
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* GET /v1/routing/stats/:provider
|
|
523
|
+
*
|
|
524
|
+
* Per-provider routing telemetry.
|
|
525
|
+
*/
|
|
526
|
+
router.get("/routing/stats/:provider", (req, res) => {
|
|
527
|
+
try {
|
|
528
|
+
const provider = req.params.provider.toLowerCase();
|
|
529
|
+
const since = req.query.since ? Number(req.query.since) : undefined;
|
|
530
|
+
const stats = telemetry.getProviderStats(provider, { since });
|
|
531
|
+
|
|
532
|
+
if (!stats) {
|
|
533
|
+
return res.json({ object: "provider_routing_stats", data: null, message: `No data for ${provider}` });
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const latencyTracker = getLatencyTracker();
|
|
537
|
+
const latency = latencyTracker.getStats(provider);
|
|
538
|
+
|
|
539
|
+
res.json({
|
|
540
|
+
object: "provider_routing_stats",
|
|
541
|
+
provider,
|
|
542
|
+
data: { ...stats, latency },
|
|
543
|
+
timestamp: new Date().toISOString(),
|
|
544
|
+
});
|
|
545
|
+
} catch (error) {
|
|
546
|
+
logger.error({ error: error.message }, "Error getting provider routing stats");
|
|
547
|
+
res.status(500).json({ error: { type: "server_error", message: error.message } });
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* GET /v1/routing/telemetry
|
|
553
|
+
*
|
|
554
|
+
* Raw telemetry records (most recent first).
|
|
555
|
+
*/
|
|
556
|
+
router.get("/routing/telemetry", (req, res) => {
|
|
557
|
+
try {
|
|
558
|
+
const filters = {
|
|
559
|
+
provider: req.query.provider,
|
|
560
|
+
tier: req.query.tier,
|
|
561
|
+
since: req.query.since ? Number(req.query.since) : undefined,
|
|
562
|
+
limit: req.query.limit ? Number(req.query.limit) : 100,
|
|
563
|
+
};
|
|
564
|
+
const records = telemetry.query(filters);
|
|
565
|
+
|
|
566
|
+
res.json({ object: "telemetry_list", data: records, count: records.length });
|
|
567
|
+
} catch (error) {
|
|
568
|
+
logger.error({ error: error.message }, "Error querying telemetry");
|
|
569
|
+
res.status(500).json({ error: { type: "server_error", message: error.message } });
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* GET /v1/routing/accuracy
|
|
575
|
+
*
|
|
576
|
+
* Routing accuracy analysis (over/under-provisioned percentages).
|
|
577
|
+
*/
|
|
578
|
+
router.get("/routing/accuracy", (req, res) => {
|
|
579
|
+
try {
|
|
580
|
+
const since = req.query.since ? Number(req.query.since) : undefined;
|
|
581
|
+
const accuracy = telemetry.getRoutingAccuracy({ since });
|
|
582
|
+
|
|
583
|
+
res.json({
|
|
584
|
+
object: "routing_accuracy",
|
|
585
|
+
data: accuracy,
|
|
586
|
+
timestamp: new Date().toISOString(),
|
|
587
|
+
});
|
|
588
|
+
} catch (error) {
|
|
589
|
+
logger.error({ error: error.message }, "Error getting routing accuracy");
|
|
590
|
+
res.status(500).json({ error: { type: "server_error", message: error.message } });
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ── Admin: Hot Reload Config + Reset Circuit Breakers ─────────────────
|
|
595
|
+
|
|
596
|
+
router.post("/admin/reload", (req, res) => {
|
|
597
|
+
try {
|
|
598
|
+
config.reloadConfig();
|
|
599
|
+
const registry = getCircuitBreakerRegistry();
|
|
600
|
+
const states = registry.getAll();
|
|
601
|
+
res.json({
|
|
602
|
+
object: "admin_reload",
|
|
603
|
+
status: "ok",
|
|
604
|
+
reloaded: [
|
|
605
|
+
"modelTiers",
|
|
606
|
+
"apiKeys",
|
|
607
|
+
"providerSettings",
|
|
608
|
+
"circuitBreakers",
|
|
609
|
+
],
|
|
610
|
+
circuitBreakers: states.map(s => ({ name: s.name, state: s.state })),
|
|
611
|
+
timestamp: new Date().toISOString(),
|
|
612
|
+
});
|
|
613
|
+
} catch (error) {
|
|
614
|
+
logger.error({ error: error.message }, "Admin reload failed");
|
|
615
|
+
res.status(500).json({ error: { type: "server_error", message: error.message } });
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
router.post("/admin/circuit-breakers/reset", (req, res) => {
|
|
620
|
+
try {
|
|
621
|
+
const provider = req.query.provider || req.body?.provider;
|
|
622
|
+
const registry = getCircuitBreakerRegistry();
|
|
623
|
+
|
|
624
|
+
if (provider) {
|
|
625
|
+
const breaker = registry.breakers?.get(provider);
|
|
626
|
+
if (breaker) {
|
|
627
|
+
breaker.reset();
|
|
628
|
+
res.json({ object: "circuit_breaker_reset", provider, status: "reset" });
|
|
629
|
+
} else {
|
|
630
|
+
res.status(404).json({ error: { message: `No circuit breaker for provider: ${provider}` } });
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
registry.resetAll();
|
|
634
|
+
const states = registry.getAll();
|
|
635
|
+
res.json({
|
|
636
|
+
object: "circuit_breaker_reset",
|
|
637
|
+
provider: "all",
|
|
638
|
+
status: "reset",
|
|
639
|
+
breakers: states.map(s => ({ name: s.name, state: s.state })),
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
} catch (error) {
|
|
643
|
+
logger.error({ error: error.message }, "Circuit breaker reset failed");
|
|
644
|
+
res.status(500).json({ error: { type: "server_error", message: error.message } });
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
480
648
|
module.exports = router;
|
package/src/api/router.js
CHANGED
|
@@ -136,7 +136,7 @@ router.post("/routing/analyze", async (req, res) => {
|
|
|
136
136
|
const { getModelTierSelector } = require("../routing/model-tiers");
|
|
137
137
|
const { getModelRegistry } = require("../routing/model-registry");
|
|
138
138
|
|
|
139
|
-
const analysis = analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
|
|
139
|
+
const analysis = await analyzeComplexity(req.body, { weighted: req.query.weighted === "true" });
|
|
140
140
|
const agentic = getAgenticDetector().detect(req.body);
|
|
141
141
|
const selector = getModelTierSelector();
|
|
142
142
|
const tier = selector.getTier(analysis.score);
|
|
@@ -210,13 +210,17 @@ router.post("/api/event_logging/batch", (req, res) => {
|
|
|
210
210
|
|
|
211
211
|
router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
212
212
|
try {
|
|
213
|
+
const { createTimer } = require("../utils/perf-timer");
|
|
214
|
+
const timer = createTimer("POST /v1/messages");
|
|
213
215
|
metrics.recordRequest();
|
|
214
216
|
// Support both query parameter (?stream=true) and body parameter ({"stream": true})
|
|
215
217
|
const wantsStream = Boolean(req.query?.stream === 'true' || req.body?.stream);
|
|
216
218
|
const hasTools = Array.isArray(req.body?.tools) && req.body.tools.length > 0;
|
|
219
|
+
timer.mark("parseRequest");
|
|
217
220
|
|
|
218
221
|
// Analyze complexity for routing headers (Phase 3)
|
|
219
|
-
const complexity = analyzeComplexity(req.body);
|
|
222
|
+
const complexity = await analyzeComplexity(req.body);
|
|
223
|
+
timer.mark("analyzeComplexity");
|
|
220
224
|
let preRouteProvider = 'cloud';
|
|
221
225
|
if (complexity.recommendation === 'local') {
|
|
222
226
|
// Use tier config to determine actual provider instead of hardcoding 'ollama'
|
|
@@ -430,6 +434,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
430
434
|
}
|
|
431
435
|
|
|
432
436
|
// Non-streaming or tool-based requests (buffered path)
|
|
437
|
+
timer.mark("preProcessMessage");
|
|
433
438
|
const result = await processMessage({
|
|
434
439
|
payload: req.body,
|
|
435
440
|
headers: req.headers,
|
|
@@ -440,6 +445,8 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => {
|
|
|
440
445
|
maxDurationMs: req.body?.max_duration_ms,
|
|
441
446
|
},
|
|
442
447
|
});
|
|
448
|
+
timer.mark("processMessage");
|
|
449
|
+
timer.done();
|
|
443
450
|
|
|
444
451
|
// Legacy streaming wrapper (for tool-based requests that requested streaming)
|
|
445
452
|
if (wantsStream && hasTools) {
|
|
@@ -1,253 +1,16 @@
|
|
|
1
|
-
const logger = require("../logger");
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
|
-
* Circuit Breaker Pattern
|
|
5
|
-
*
|
|
6
|
-
* States:
|
|
7
|
-
* - CLOSED: Normal operation, requests pass through
|
|
8
|
-
* - OPEN: Failures exceeded threshold, requests fail fast
|
|
9
|
-
* - HALF_OPEN: Testing if service recovered
|
|
2
|
+
* Circuit Breaker Pattern — backed by Cockatiel
|
|
10
3
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - Automatic recovery testing
|
|
14
|
-
* - Minimal overhead in CLOSED state
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const STATE = {
|
|
18
|
-
CLOSED: "CLOSED",
|
|
19
|
-
OPEN: "OPEN",
|
|
20
|
-
HALF_OPEN: "HALF_OPEN",
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
class CircuitBreaker {
|
|
24
|
-
constructor(name, options = {}) {
|
|
25
|
-
this.name = name;
|
|
26
|
-
|
|
27
|
-
// Configuration
|
|
28
|
-
this.failureThreshold = options.failureThreshold || 5; // failures before opening
|
|
29
|
-
this.successThreshold = options.successThreshold || 2; // successes to close from half-open
|
|
30
|
-
this.timeout = options.timeout || 60000; // time to wait before trying again (60s)
|
|
31
|
-
this.resetTimeout = options.resetTimeout || 30000; // time in half-open before resetting (30s)
|
|
32
|
-
|
|
33
|
-
// State
|
|
34
|
-
this.state = STATE.CLOSED;
|
|
35
|
-
this.failureCount = 0;
|
|
36
|
-
this.successCount = 0;
|
|
37
|
-
this.nextAttempt = Date.now();
|
|
38
|
-
this.lastStateChange = Date.now();
|
|
39
|
-
|
|
40
|
-
// Metrics
|
|
41
|
-
this.stats = {
|
|
42
|
-
totalRequests: 0,
|
|
43
|
-
totalFailures: 0,
|
|
44
|
-
totalSuccesses: 0,
|
|
45
|
-
totalRejected: 0,
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Execute function with circuit breaker protection
|
|
51
|
-
*/
|
|
52
|
-
async execute(fn) {
|
|
53
|
-
this.stats.totalRequests++;
|
|
54
|
-
|
|
55
|
-
// Check circuit state
|
|
56
|
-
if (this.state === STATE.OPEN) {
|
|
57
|
-
if (Date.now() < this.nextAttempt) {
|
|
58
|
-
this.stats.totalRejected++;
|
|
59
|
-
throw new CircuitBreakerError(
|
|
60
|
-
`Circuit breaker ${this.name} is OPEN`,
|
|
61
|
-
this.nextAttempt - Date.now()
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Try half-open
|
|
66
|
-
this.transitionTo(STATE.HALF_OPEN);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
try {
|
|
70
|
-
const result = await fn();
|
|
71
|
-
|
|
72
|
-
// Success
|
|
73
|
-
this.onSuccess();
|
|
74
|
-
return result;
|
|
75
|
-
} catch (err) {
|
|
76
|
-
// Failure
|
|
77
|
-
this.onFailure();
|
|
78
|
-
throw err;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Handle successful request
|
|
84
|
-
*/
|
|
85
|
-
onSuccess() {
|
|
86
|
-
this.stats.totalSuccesses++;
|
|
87
|
-
this.failureCount = 0;
|
|
88
|
-
|
|
89
|
-
if (this.state === STATE.HALF_OPEN) {
|
|
90
|
-
this.successCount++;
|
|
91
|
-
|
|
92
|
-
if (this.successCount >= this.successThreshold) {
|
|
93
|
-
this.transitionTo(STATE.CLOSED);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Handle failed request
|
|
100
|
-
*/
|
|
101
|
-
onFailure() {
|
|
102
|
-
this.stats.totalFailures++;
|
|
103
|
-
this.failureCount++;
|
|
104
|
-
this.successCount = 0;
|
|
105
|
-
|
|
106
|
-
if (this.failureCount >= this.failureThreshold) {
|
|
107
|
-
this.transitionTo(STATE.OPEN);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Transition to new state
|
|
113
|
-
*/
|
|
114
|
-
transitionTo(newState) {
|
|
115
|
-
const oldState = this.state;
|
|
116
|
-
|
|
117
|
-
if (oldState === newState) {
|
|
118
|
-
return;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
this.state = newState;
|
|
122
|
-
this.lastStateChange = Date.now();
|
|
123
|
-
|
|
124
|
-
logger.info(
|
|
125
|
-
{
|
|
126
|
-
circuitBreaker: this.name,
|
|
127
|
-
oldState,
|
|
128
|
-
newState,
|
|
129
|
-
failureCount: this.failureCount,
|
|
130
|
-
successCount: this.successCount,
|
|
131
|
-
},
|
|
132
|
-
"Circuit breaker state change"
|
|
133
|
-
);
|
|
134
|
-
|
|
135
|
-
// Set next attempt time when opening
|
|
136
|
-
if (newState === STATE.OPEN) {
|
|
137
|
-
this.nextAttempt = Date.now() + this.timeout;
|
|
138
|
-
logger.warn(
|
|
139
|
-
{
|
|
140
|
-
circuitBreaker: this.name,
|
|
141
|
-
retryAfter: this.timeout,
|
|
142
|
-
},
|
|
143
|
-
"Circuit breaker opened - failing fast"
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Reset counters
|
|
148
|
-
if (newState === STATE.CLOSED) {
|
|
149
|
-
this.failureCount = 0;
|
|
150
|
-
this.successCount = 0;
|
|
151
|
-
logger.info(
|
|
152
|
-
{
|
|
153
|
-
circuitBreaker: this.name,
|
|
154
|
-
},
|
|
155
|
-
"Circuit breaker closed - normal operation resumed"
|
|
156
|
-
);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (newState === STATE.HALF_OPEN) {
|
|
160
|
-
this.successCount = 0;
|
|
161
|
-
logger.info(
|
|
162
|
-
{
|
|
163
|
-
circuitBreaker: this.name,
|
|
164
|
-
},
|
|
165
|
-
"Circuit breaker half-open - testing service recovery"
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Get current state
|
|
172
|
-
*/
|
|
173
|
-
getState() {
|
|
174
|
-
return {
|
|
175
|
-
name: this.name,
|
|
176
|
-
state: this.state,
|
|
177
|
-
failureCount: this.failureCount,
|
|
178
|
-
successCount: this.successCount,
|
|
179
|
-
nextAttempt: this.nextAttempt,
|
|
180
|
-
lastStateChange: this.lastStateChange,
|
|
181
|
-
stats: this.stats,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Manually reset circuit breaker
|
|
187
|
-
*/
|
|
188
|
-
reset() {
|
|
189
|
-
this.transitionTo(STATE.CLOSED);
|
|
190
|
-
this.failureCount = 0;
|
|
191
|
-
this.successCount = 0;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Circuit breaker error
|
|
4
|
+
* This module re-exports Cockatiel-backed adapters from resilience.js
|
|
5
|
+
* while preserving the same API surface for all consumers.
|
|
197
6
|
*/
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* Circuit breaker registry
|
|
209
|
-
*/
|
|
210
|
-
class CircuitBreakerRegistry {
|
|
211
|
-
constructor() {
|
|
212
|
-
this.breakers = new Map();
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Get or create circuit breaker
|
|
217
|
-
*/
|
|
218
|
-
get(name, options) {
|
|
219
|
-
if (!this.breakers.has(name)) {
|
|
220
|
-
this.breakers.set(name, new CircuitBreaker(name, options));
|
|
221
|
-
}
|
|
222
|
-
return this.breakers.get(name);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/**
|
|
226
|
-
* Get all circuit breakers
|
|
227
|
-
*/
|
|
228
|
-
getAll() {
|
|
229
|
-
return Array.from(this.breakers.values()).map((breaker) => breaker.getState());
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Reset all circuit breakers
|
|
234
|
-
*/
|
|
235
|
-
resetAll() {
|
|
236
|
-
for (const breaker of this.breakers.values()) {
|
|
237
|
-
breaker.reset();
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Singleton registry
|
|
243
|
-
let registry = null;
|
|
244
|
-
|
|
245
|
-
function getCircuitBreakerRegistry() {
|
|
246
|
-
if (!registry) {
|
|
247
|
-
registry = new CircuitBreakerRegistry();
|
|
248
|
-
}
|
|
249
|
-
return registry;
|
|
250
|
-
}
|
|
7
|
+
const {
|
|
8
|
+
CockatielCircuitBreaker: CircuitBreaker,
|
|
9
|
+
CircuitBreakerError,
|
|
10
|
+
CockatielRegistry: CircuitBreakerRegistry,
|
|
11
|
+
getCockatielRegistry: getCircuitBreakerRegistry,
|
|
12
|
+
STATE,
|
|
13
|
+
} = require("./resilience");
|
|
251
14
|
|
|
252
15
|
module.exports = {
|
|
253
16
|
CircuitBreaker,
|