@antseed/router-core 0.1.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 +74 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/peer-metrics.d.ts +27 -0
- package/dist/peer-metrics.d.ts.map +1 -0
- package/dist/peer-metrics.js +72 -0
- package/dist/peer-metrics.js.map +1 -0
- package/dist/peer-scorer.d.ts +52 -0
- package/dist/peer-scorer.d.ts.map +1 -0
- package/dist/peer-scorer.js +130 -0
- package/dist/peer-scorer.js.map +1 -0
- package/dist/tool-hints.d.ts +7 -0
- package/dist/tool-hints.d.ts.map +1 -0
- package/dist/tool-hints.js +10 -0
- package/dist/tool-hints.js.map +1 -0
- package/package.json +22 -0
- package/src/index.ts +17 -0
- package/src/peer-metrics.test.ts +192 -0
- package/src/peer-metrics.ts +88 -0
- package/src/peer-scorer.test.ts +195 -0
- package/src/peer-scorer.ts +196 -0
- package/src/tool-hints.ts +15 -0
- package/tsconfig.json +9 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @antseed/router-core
|
|
2
|
+
|
|
3
|
+
Shared infrastructure for building Antseed router plugins. Provides multi-factor peer scoring and per-peer metrics tracking.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @antseed/router-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Peer dependency: `@antseed/node >= 0.1.0`
|
|
12
|
+
|
|
13
|
+
## Key Exports
|
|
14
|
+
|
|
15
|
+
### scoreCandidates
|
|
16
|
+
|
|
17
|
+
Scores and ranks peers using a weighted multi-factor algorithm:
|
|
18
|
+
|
|
19
|
+
| Factor | Description |
|
|
20
|
+
|--------|-------------|
|
|
21
|
+
| **price** | Lower price scores higher |
|
|
22
|
+
| **latency** | Lower latency EMA scores higher |
|
|
23
|
+
| **capacity** | More available capacity scores higher |
|
|
24
|
+
| **reputation** | Higher trust/reputation scores higher |
|
|
25
|
+
| **freshness** | More recently seen peers score higher |
|
|
26
|
+
| **reliability** | Lower failure rate scores higher |
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { scoreCandidates, DEFAULT_WEIGHTS } from '@antseed/router-core';
|
|
30
|
+
|
|
31
|
+
const scored = scoreCandidates(peers, {
|
|
32
|
+
weights: DEFAULT_WEIGHTS,
|
|
33
|
+
metrics: tracker,
|
|
34
|
+
now: Date.now(),
|
|
35
|
+
maxPeerStalenessMs: 300_000,
|
|
36
|
+
});
|
|
37
|
+
// Returns ScoredCandidate[] sorted by score descending
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### PeerMetricsTracker
|
|
41
|
+
|
|
42
|
+
Tracks per-peer performance metrics for routing decisions.
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { PeerMetricsTracker } from '@antseed/router-core';
|
|
46
|
+
|
|
47
|
+
const tracker = new PeerMetricsTracker({ maxFailures: 3, failureCooldownMs: 30_000 });
|
|
48
|
+
|
|
49
|
+
// Record results
|
|
50
|
+
tracker.recordSuccess(peerId, latencyMs);
|
|
51
|
+
tracker.recordFailure(peerId);
|
|
52
|
+
|
|
53
|
+
// Check if peer is on cooldown
|
|
54
|
+
tracker.isOnCooldown(peerId, Date.now());
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Tool Hints
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { WELL_KNOWN_TOOL_HINTS, formatToolHints } from '@antseed/router-core';
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Default Scoring Weights
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const DEFAULT_WEIGHTS = {
|
|
67
|
+
price: 0.25,
|
|
68
|
+
latency: 0.25,
|
|
69
|
+
capacity: 0.15,
|
|
70
|
+
reputation: 0.15,
|
|
71
|
+
freshness: 0.10,
|
|
72
|
+
reliability: 0.10,
|
|
73
|
+
};
|
|
74
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { scoreCandidates, DEFAULT_WEIGHTS, type ScoringWeights, type ScoredCandidate, type TokenPricingUsdPerMillion, type PeerMetrics, } from './peer-scorer.js';
|
|
2
|
+
export { PeerMetricsTracker, type PeerMetricsTrackerConfig, } from './peer-metrics.js';
|
|
3
|
+
export { WELL_KNOWN_TOOL_HINTS, formatToolHints, type ToolHint, } from './tool-hints.js';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,eAAe,EACf,KAAK,cAAc,EACnB,KAAK,eAAe,EACpB,KAAK,yBAAyB,EAC9B,KAAK,WAAW,GACjB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,kBAAkB,EAClB,KAAK,wBAAwB,GAC9B,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,qBAAqB,EACrB,eAAe,EACf,KAAK,QAAQ,GACd,MAAM,iBAAiB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,eAAe,EACf,eAAe,GAKhB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,kBAAkB,GAEnB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EACL,qBAAqB,EACrB,eAAe,GAEhB,MAAM,iBAAiB,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PeerMetrics } from './peer-scorer.js';
|
|
2
|
+
export interface PeerMetricsTrackerConfig {
|
|
3
|
+
latencyAlpha?: number;
|
|
4
|
+
maxFailures?: number;
|
|
5
|
+
failureCooldownMs?: number;
|
|
6
|
+
now?: () => number;
|
|
7
|
+
}
|
|
8
|
+
export declare class PeerMetricsTracker {
|
|
9
|
+
private readonly _latencyMap;
|
|
10
|
+
private readonly _failureStreakMap;
|
|
11
|
+
private readonly _totalFailureMap;
|
|
12
|
+
private readonly _attemptMap;
|
|
13
|
+
private readonly _cooldownUntilMap;
|
|
14
|
+
private readonly _latencyAlpha;
|
|
15
|
+
private readonly _maxFailures;
|
|
16
|
+
private readonly _failureCooldownMs;
|
|
17
|
+
private readonly _now;
|
|
18
|
+
constructor(config?: PeerMetricsTrackerConfig);
|
|
19
|
+
recordResult(peerId: string, result: {
|
|
20
|
+
success: boolean;
|
|
21
|
+
latencyMs: number;
|
|
22
|
+
}): void;
|
|
23
|
+
getMetrics(peerId: string): PeerMetrics;
|
|
24
|
+
isCoolingDown(peerId: string): boolean;
|
|
25
|
+
getMedianLatency(): number;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=peer-metrics.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer-metrics.d.ts","sourceRoot":"","sources":["../src/peer-metrics.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAEnD,MAAM,WAAW,wBAAwB;IACvC,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;IACxD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA4B;IAC9D,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA4B;IAC7D,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA4B;IACxD,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA4B;IAE9D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAQ;IACrC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAQ;IAC3C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAc;gBAEvB,MAAM,CAAC,EAAE,wBAAwB;IAO7C,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI;IA6BnF,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW;IAUvC,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO;IAWtC,gBAAgB,IAAI,MAAM;CAS3B"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export class PeerMetricsTracker {
|
|
2
|
+
_latencyMap = new Map();
|
|
3
|
+
_failureStreakMap = new Map();
|
|
4
|
+
_totalFailureMap = new Map();
|
|
5
|
+
_attemptMap = new Map();
|
|
6
|
+
_cooldownUntilMap = new Map();
|
|
7
|
+
_latencyAlpha;
|
|
8
|
+
_maxFailures;
|
|
9
|
+
_failureCooldownMs;
|
|
10
|
+
_now;
|
|
11
|
+
constructor(config) {
|
|
12
|
+
this._latencyAlpha = config?.latencyAlpha ?? 0.3;
|
|
13
|
+
this._maxFailures = Math.max(1, config?.maxFailures ?? 3);
|
|
14
|
+
this._failureCooldownMs = Math.max(1, config?.failureCooldownMs ?? 30_000);
|
|
15
|
+
this._now = config?.now ?? (() => Date.now());
|
|
16
|
+
}
|
|
17
|
+
recordResult(peerId, result) {
|
|
18
|
+
const now = this._now();
|
|
19
|
+
const attempts = (this._attemptMap.get(peerId) ?? 0) + 1;
|
|
20
|
+
this._attemptMap.set(peerId, attempts);
|
|
21
|
+
if (result.success) {
|
|
22
|
+
// Update latency EMA
|
|
23
|
+
const prev = this._latencyMap.get(peerId) ?? result.latencyMs;
|
|
24
|
+
this._latencyMap.set(peerId, prev * (1 - this._latencyAlpha) + result.latencyMs * this._latencyAlpha);
|
|
25
|
+
// Reset failure count on success
|
|
26
|
+
this._failureStreakMap.delete(peerId);
|
|
27
|
+
this._cooldownUntilMap.delete(peerId);
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const currentStreak = this._failureStreakMap.get(peerId) ?? 0;
|
|
31
|
+
const nextStreak = currentStreak + 1;
|
|
32
|
+
this._failureStreakMap.set(peerId, nextStreak);
|
|
33
|
+
this._totalFailureMap.set(peerId, (this._totalFailureMap.get(peerId) ?? 0) + 1);
|
|
34
|
+
if (nextStreak >= this._maxFailures) {
|
|
35
|
+
const penaltyPower = Math.min(4, nextStreak - this._maxFailures);
|
|
36
|
+
const cooldownMs = this._failureCooldownMs * (2 ** penaltyPower);
|
|
37
|
+
this._cooldownUntilMap.set(peerId, now + cooldownMs);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
getMetrics(peerId) {
|
|
42
|
+
return {
|
|
43
|
+
latencyEma: this._latencyMap.get(peerId),
|
|
44
|
+
failureStreak: this._failureStreakMap.get(peerId) ?? 0,
|
|
45
|
+
totalFailures: this._totalFailureMap.get(peerId) ?? 0,
|
|
46
|
+
totalAttempts: this._attemptMap.get(peerId) ?? 0,
|
|
47
|
+
cooldownUntil: this._cooldownUntilMap.get(peerId),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
isCoolingDown(peerId) {
|
|
51
|
+
const now = this._now();
|
|
52
|
+
const until = this._cooldownUntilMap.get(peerId);
|
|
53
|
+
if (until === undefined)
|
|
54
|
+
return false;
|
|
55
|
+
if (until <= now) {
|
|
56
|
+
this._cooldownUntilMap.delete(peerId);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
getMedianLatency() {
|
|
62
|
+
const values = [...this._latencyMap.values()];
|
|
63
|
+
if (values.length === 0)
|
|
64
|
+
return 500;
|
|
65
|
+
values.sort((a, b) => a - b);
|
|
66
|
+
const mid = Math.floor(values.length / 2);
|
|
67
|
+
return values.length % 2 === 0
|
|
68
|
+
? (values[mid - 1] + values[mid]) / 2
|
|
69
|
+
: values[mid];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=peer-metrics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer-metrics.js","sourceRoot":"","sources":["../src/peer-metrics.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { PeerInfo } from '@antseed/node';
|
|
2
|
+
export interface TokenPricingUsdPerMillion {
|
|
3
|
+
inputUsdPerMillion: number;
|
|
4
|
+
outputUsdPerMillion: number;
|
|
5
|
+
}
|
|
6
|
+
export interface ScoringWeights {
|
|
7
|
+
price: number;
|
|
8
|
+
latency: number;
|
|
9
|
+
capacity: number;
|
|
10
|
+
reputation: number;
|
|
11
|
+
freshness: number;
|
|
12
|
+
reliability: number;
|
|
13
|
+
}
|
|
14
|
+
export declare const DEFAULT_WEIGHTS: ScoringWeights;
|
|
15
|
+
export interface PeerMetrics {
|
|
16
|
+
latencyEma: number | undefined;
|
|
17
|
+
failureStreak: number;
|
|
18
|
+
totalFailures: number;
|
|
19
|
+
totalAttempts: number;
|
|
20
|
+
cooldownUntil: number | undefined;
|
|
21
|
+
}
|
|
22
|
+
export interface ScoredCandidate {
|
|
23
|
+
peer: PeerInfo;
|
|
24
|
+
provider: string;
|
|
25
|
+
providerRank: number;
|
|
26
|
+
offer: TokenPricingUsdPerMillion;
|
|
27
|
+
score: number;
|
|
28
|
+
}
|
|
29
|
+
interface CandidateInput {
|
|
30
|
+
peer: PeerInfo;
|
|
31
|
+
provider: string;
|
|
32
|
+
providerRank: number;
|
|
33
|
+
offer: TokenPricingUsdPerMillion;
|
|
34
|
+
metrics: PeerMetrics;
|
|
35
|
+
}
|
|
36
|
+
interface ScoringContext {
|
|
37
|
+
now: number;
|
|
38
|
+
medianLatency: number;
|
|
39
|
+
maxPeerStalenessMs: number;
|
|
40
|
+
maxFailures: number;
|
|
41
|
+
weights?: Partial<ScoringWeights>;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Score and rank candidates using the composite scoring algorithm.
|
|
45
|
+
*
|
|
46
|
+
* Calculates normalization context (min/max price, latency, capacity),
|
|
47
|
+
* scores each candidate with weighted factors, and returns candidates
|
|
48
|
+
* sorted by score (highest first).
|
|
49
|
+
*/
|
|
50
|
+
export declare function scoreCandidates(candidates: CandidateInput[], context: ScoringContext): ScoredCandidate[];
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=peer-scorer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer-scorer.d.ts","sourceRoot":"","sources":["../src/peer-scorer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAE7C,MAAM,WAAW,yBAAyB;IACxC,kBAAkB,EAAE,MAAM,CAAA;IAC1B,mBAAmB,EAAE,MAAM,CAAA;CAC5B;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,eAAO,MAAM,eAAe,EAAE,cAO7B,CAAA;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IAC9B,aAAa,EAAE,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,CAAA;IACrB,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;CAClC;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,QAAQ,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,KAAK,EAAE,yBAAyB,CAAA;IAChC,KAAK,EAAE,MAAM,CAAA;CACd;AAED,UAAU,cAAc;IACtB,IAAI,EAAE,QAAQ,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,KAAK,EAAE,yBAAyB,CAAA;IAChC,OAAO,EAAE,WAAW,CAAA;CACrB;AAED,UAAU,cAAc;IACtB,GAAG,EAAE,MAAM,CAAA;IACX,aAAa,EAAE,MAAM,CAAA;IACrB,kBAAkB,EAAE,MAAM,CAAA;IAC1B,WAAW,EAAE,MAAM,CAAA;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,CAAC,CAAA;CAClC;AAgDD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,cAAc,EAAE,EAC5B,OAAO,EAAE,cAAc,GACtB,eAAe,EAAE,CAkFnB"}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export const DEFAULT_WEIGHTS = {
|
|
2
|
+
price: 0.30,
|
|
3
|
+
latency: 0.25,
|
|
4
|
+
capacity: 0.20,
|
|
5
|
+
reputation: 0.10,
|
|
6
|
+
freshness: 0.10,
|
|
7
|
+
reliability: 0.05,
|
|
8
|
+
};
|
|
9
|
+
function normalizedInverted(value, min, range) {
|
|
10
|
+
if (range <= 0) {
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
13
|
+
return 1 - (value - min) / range;
|
|
14
|
+
}
|
|
15
|
+
function effectiveReputation(p) {
|
|
16
|
+
if (p.onChainReputation !== undefined) {
|
|
17
|
+
return p.onChainReputation;
|
|
18
|
+
}
|
|
19
|
+
return p.trustScore ?? p.reputationScore ?? 0;
|
|
20
|
+
}
|
|
21
|
+
function availableCapacity(p) {
|
|
22
|
+
const max = p.maxConcurrency ?? 1;
|
|
23
|
+
const current = p.currentLoad ?? 0;
|
|
24
|
+
return Math.max(0, max - current);
|
|
25
|
+
}
|
|
26
|
+
function freshnessFactor(p, now, maxPeerStalenessMs) {
|
|
27
|
+
if (!Number.isFinite(p.lastSeen))
|
|
28
|
+
return 0.5;
|
|
29
|
+
const age = Math.max(0, now - p.lastSeen);
|
|
30
|
+
if (age >= maxPeerStalenessMs)
|
|
31
|
+
return 0;
|
|
32
|
+
return 1 - (age / maxPeerStalenessMs);
|
|
33
|
+
}
|
|
34
|
+
function reliabilityFactor(metrics, maxFailures) {
|
|
35
|
+
const historicalPenalty = metrics.totalAttempts > 0 ? metrics.totalFailures / metrics.totalAttempts : 0;
|
|
36
|
+
const streakPenalty = Math.min(1, metrics.failureStreak / maxFailures);
|
|
37
|
+
return Math.max(0, 1 - Math.max(historicalPenalty, streakPenalty));
|
|
38
|
+
}
|
|
39
|
+
function peerLatency(metrics, medianLatency) {
|
|
40
|
+
return metrics.latencyEma ?? medianLatency;
|
|
41
|
+
}
|
|
42
|
+
function tieBreak(a, aLatencyEma, b, bLatencyEma) {
|
|
43
|
+
const latA = aLatencyEma ?? Number.POSITIVE_INFINITY;
|
|
44
|
+
const latB = bLatencyEma ?? Number.POSITIVE_INFINITY;
|
|
45
|
+
if (latA !== latB) {
|
|
46
|
+
return latA - latB;
|
|
47
|
+
}
|
|
48
|
+
return a.peerId.localeCompare(b.peerId);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Score and rank candidates using the composite scoring algorithm.
|
|
52
|
+
*
|
|
53
|
+
* Calculates normalization context (min/max price, latency, capacity),
|
|
54
|
+
* scores each candidate with weighted factors, and returns candidates
|
|
55
|
+
* sorted by score (highest first).
|
|
56
|
+
*/
|
|
57
|
+
export function scoreCandidates(candidates, context) {
|
|
58
|
+
if (candidates.length === 0)
|
|
59
|
+
return [];
|
|
60
|
+
const w = { ...DEFAULT_WEIGHTS, ...context.weights };
|
|
61
|
+
// --- Normalization context ---
|
|
62
|
+
const knownPrices = candidates
|
|
63
|
+
.map((c) => c.offer.inputUsdPerMillion)
|
|
64
|
+
.filter((value) => Number.isFinite(value));
|
|
65
|
+
const fallbackPrice = knownPrices.length > 0 ? Math.max(...knownPrices) * 1.25 : 1;
|
|
66
|
+
let minPrice = knownPrices.length > 0 ? Math.min(...knownPrices) : fallbackPrice;
|
|
67
|
+
let maxPrice = knownPrices.length > 0 ? Math.max(...knownPrices) : fallbackPrice;
|
|
68
|
+
let minLatency = Number.POSITIVE_INFINITY;
|
|
69
|
+
let maxLatency = 0;
|
|
70
|
+
let maxCap = 0;
|
|
71
|
+
for (const c of candidates) {
|
|
72
|
+
const price = c.offer.inputUsdPerMillion;
|
|
73
|
+
if (price < minPrice)
|
|
74
|
+
minPrice = price;
|
|
75
|
+
if (price > maxPrice)
|
|
76
|
+
maxPrice = price;
|
|
77
|
+
const lat = peerLatency(c.metrics, context.medianLatency);
|
|
78
|
+
if (lat < minLatency)
|
|
79
|
+
minLatency = lat;
|
|
80
|
+
if (lat > maxLatency)
|
|
81
|
+
maxLatency = lat;
|
|
82
|
+
const cap = availableCapacity(c.peer);
|
|
83
|
+
if (cap > maxCap)
|
|
84
|
+
maxCap = cap;
|
|
85
|
+
}
|
|
86
|
+
const priceRange = maxPrice - minPrice;
|
|
87
|
+
const latencyRange = maxLatency - minLatency;
|
|
88
|
+
// --- Score each candidate ---
|
|
89
|
+
const scored = [];
|
|
90
|
+
for (const c of candidates) {
|
|
91
|
+
// Price factor (lower is better, inverted)
|
|
92
|
+
const price = c.offer.inputUsdPerMillion ?? fallbackPrice;
|
|
93
|
+
const priceFactor = normalizedInverted(price, minPrice, priceRange);
|
|
94
|
+
// Latency factor (lower is better, inverted)
|
|
95
|
+
const lat = peerLatency(c.metrics, context.medianLatency);
|
|
96
|
+
const latencyFactor = normalizedInverted(lat, minLatency, latencyRange);
|
|
97
|
+
// Capacity factor (higher is better)
|
|
98
|
+
const capFactor = maxCap > 0
|
|
99
|
+
? availableCapacity(c.peer) / maxCap
|
|
100
|
+
: 1;
|
|
101
|
+
// Reputation factor (higher is better, normalized 0-100 to 0-1)
|
|
102
|
+
const repFactor = effectiveReputation(c.peer) / 100;
|
|
103
|
+
const fresh = freshnessFactor(c.peer, context.now, context.maxPeerStalenessMs);
|
|
104
|
+
const reliability = reliabilityFactor(c.metrics, context.maxFailures);
|
|
105
|
+
const score = w.price * priceFactor +
|
|
106
|
+
w.latency * latencyFactor +
|
|
107
|
+
w.capacity * capFactor +
|
|
108
|
+
w.reputation * repFactor +
|
|
109
|
+
w.freshness * fresh +
|
|
110
|
+
w.reliability * reliability;
|
|
111
|
+
scored.push({
|
|
112
|
+
peer: c.peer,
|
|
113
|
+
provider: c.provider,
|
|
114
|
+
providerRank: c.providerRank,
|
|
115
|
+
offer: c.offer,
|
|
116
|
+
score,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// Sort by score descending, tie-break by latency then peerId
|
|
120
|
+
scored.sort((a, b) => {
|
|
121
|
+
if (Math.abs(a.score - b.score) < 1e-9) {
|
|
122
|
+
const aMetrics = candidates.find((c) => c.peer.peerId === a.peer.peerId);
|
|
123
|
+
const bMetrics = candidates.find((c) => c.peer.peerId === b.peer.peerId);
|
|
124
|
+
return tieBreak(a.peer, aMetrics?.metrics.latencyEma, b.peer, bMetrics?.metrics.latencyEma);
|
|
125
|
+
}
|
|
126
|
+
return b.score - a.score;
|
|
127
|
+
});
|
|
128
|
+
return scored;
|
|
129
|
+
}
|
|
130
|
+
//# sourceMappingURL=peer-scorer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"peer-scorer.js","sourceRoot":"","sources":["../src/peer-scorer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-hints.d.ts","sourceRoot":"","sources":["../src/tool-hints.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,MAAM,CAAA;CACf;AAED,eAAO,MAAM,qBAAqB,EAAE,QAAQ,EAK3C,CAAA;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,CAE7E"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const WELL_KNOWN_TOOL_HINTS = [
|
|
2
|
+
{ name: 'Claude Code', envVar: 'ANTHROPIC_BASE_URL' },
|
|
3
|
+
{ name: 'Aider', envVar: 'OPENAI_API_BASE' },
|
|
4
|
+
{ name: 'Continue.dev', envVar: 'OPENAI_BASE_URL' },
|
|
5
|
+
{ name: 'Codex', envVar: 'OPENAI_BASE_URL' },
|
|
6
|
+
];
|
|
7
|
+
export function formatToolHints(hints, proxyUrl) {
|
|
8
|
+
return hints.map(hint => `export ${hint.envVar}=${proxyUrl} # ${hint.name}`);
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=tool-hints.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-hints.js","sourceRoot":"","sources":["../src/tool-hints.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,qBAAqB,GAAe;IAC/C,EAAE,IAAI,EAAE,aAAa,EAAE,MAAM,EAAE,oBAAoB,EAAE;IACrD,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE;IAC5C,EAAE,IAAI,EAAE,cAAc,EAAE,MAAM,EAAE,iBAAiB,EAAE;IACnD,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE;CAC7C,CAAA;AAED,MAAM,UAAU,eAAe,CAAC,KAAiB,EAAE,QAAgB;IACjE,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,UAAU,IAAI,CAAC,MAAM,IAAI,QAAQ,QAAQ,IAAI,CAAC,IAAI,EAAE,CAAC,CAAA;AAChF,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@antseed/router-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared router infrastructure for Antseed plugins",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prebuild": "rm -rf dist",
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@antseed/node": ">=0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.5.0",
|
|
19
|
+
"vitest": "^2.0.0",
|
|
20
|
+
"@antseed/node": "workspace:*"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
scoreCandidates,
|
|
3
|
+
DEFAULT_WEIGHTS,
|
|
4
|
+
type ScoringWeights,
|
|
5
|
+
type ScoredCandidate,
|
|
6
|
+
type TokenPricingUsdPerMillion,
|
|
7
|
+
type PeerMetrics,
|
|
8
|
+
} from './peer-scorer.js'
|
|
9
|
+
export {
|
|
10
|
+
PeerMetricsTracker,
|
|
11
|
+
type PeerMetricsTrackerConfig,
|
|
12
|
+
} from './peer-metrics.js'
|
|
13
|
+
export {
|
|
14
|
+
WELL_KNOWN_TOOL_HINTS,
|
|
15
|
+
formatToolHints,
|
|
16
|
+
type ToolHint,
|
|
17
|
+
} from './tool-hints.js'
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { PeerMetricsTracker } from './peer-metrics.js'
|
|
3
|
+
|
|
4
|
+
describe('PeerMetricsTracker', () => {
|
|
5
|
+
describe('latency EMA calculation', () => {
|
|
6
|
+
it('initializes EMA to the first latency value', () => {
|
|
7
|
+
const tracker = new PeerMetricsTracker()
|
|
8
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
9
|
+
|
|
10
|
+
const metrics = tracker.getMetrics('peer1')
|
|
11
|
+
// First call: prev = 100 (default to result), EMA = 100 * 0.7 + 100 * 0.3 = 100
|
|
12
|
+
expect(metrics.latencyEma).toBe(100)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('applies exponential moving average on subsequent results', () => {
|
|
16
|
+
const tracker = new PeerMetricsTracker({ latencyAlpha: 0.3 })
|
|
17
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
18
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 200 })
|
|
19
|
+
|
|
20
|
+
const metrics = tracker.getMetrics('peer1')
|
|
21
|
+
// After first: EMA = 100
|
|
22
|
+
// After second: EMA = 100 * 0.7 + 200 * 0.3 = 70 + 60 = 130
|
|
23
|
+
expect(metrics.latencyEma).toBe(130)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('respects custom latencyAlpha', () => {
|
|
27
|
+
const tracker = new PeerMetricsTracker({ latencyAlpha: 0.5 })
|
|
28
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
29
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 200 })
|
|
30
|
+
|
|
31
|
+
const metrics = tracker.getMetrics('peer1')
|
|
32
|
+
// After first: EMA = 100
|
|
33
|
+
// After second: EMA = 100 * 0.5 + 200 * 0.5 = 150
|
|
34
|
+
expect(metrics.latencyEma).toBe(150)
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('failure streak tracking', () => {
|
|
39
|
+
it('increments failure streak on consecutive failures', () => {
|
|
40
|
+
const tracker = new PeerMetricsTracker()
|
|
41
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
42
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
43
|
+
|
|
44
|
+
const metrics = tracker.getMetrics('peer1')
|
|
45
|
+
expect(metrics.failureStreak).toBe(2)
|
|
46
|
+
expect(metrics.totalFailures).toBe(2)
|
|
47
|
+
expect(metrics.totalAttempts).toBe(2)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('success resets failure streak', () => {
|
|
51
|
+
const tracker = new PeerMetricsTracker()
|
|
52
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
53
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
54
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
55
|
+
|
|
56
|
+
const metrics = tracker.getMetrics('peer1')
|
|
57
|
+
expect(metrics.failureStreak).toBe(0)
|
|
58
|
+
// Total failures and attempts remain
|
|
59
|
+
expect(metrics.totalFailures).toBe(2)
|
|
60
|
+
expect(metrics.totalAttempts).toBe(3)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('tracks total failures independently from streak', () => {
|
|
64
|
+
const tracker = new PeerMetricsTracker()
|
|
65
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
66
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
67
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
68
|
+
|
|
69
|
+
const metrics = tracker.getMetrics('peer1')
|
|
70
|
+
expect(metrics.failureStreak).toBe(1)
|
|
71
|
+
expect(metrics.totalFailures).toBe(2)
|
|
72
|
+
expect(metrics.totalAttempts).toBe(3)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('cooldown timing', () => {
|
|
77
|
+
it('enters cooldown after maxFailures consecutive failures', () => {
|
|
78
|
+
let now = 1_000_000
|
|
79
|
+
const tracker = new PeerMetricsTracker({
|
|
80
|
+
maxFailures: 2,
|
|
81
|
+
failureCooldownMs: 500,
|
|
82
|
+
now: () => now,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
86
|
+
expect(tracker.isCoolingDown('peer1')).toBe(false)
|
|
87
|
+
|
|
88
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
89
|
+
expect(tracker.isCoolingDown('peer1')).toBe(true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('cooldown expires after failureCooldownMs', () => {
|
|
93
|
+
let now = 1_000_000
|
|
94
|
+
const tracker = new PeerMetricsTracker({
|
|
95
|
+
maxFailures: 2,
|
|
96
|
+
failureCooldownMs: 500,
|
|
97
|
+
now: () => now,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
101
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
102
|
+
expect(tracker.isCoolingDown('peer1')).toBe(true)
|
|
103
|
+
|
|
104
|
+
now += 501
|
|
105
|
+
expect(tracker.isCoolingDown('peer1')).toBe(false)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('success clears cooldown', () => {
|
|
109
|
+
let now = 1_000_000
|
|
110
|
+
const tracker = new PeerMetricsTracker({
|
|
111
|
+
maxFailures: 2,
|
|
112
|
+
failureCooldownMs: 500,
|
|
113
|
+
now: () => now,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
117
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
118
|
+
expect(tracker.isCoolingDown('peer1')).toBe(true)
|
|
119
|
+
|
|
120
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
121
|
+
expect(tracker.isCoolingDown('peer1')).toBe(false)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('applies exponential backoff for repeated failure streaks beyond threshold', () => {
|
|
125
|
+
let now = 1_000_000
|
|
126
|
+
const tracker = new PeerMetricsTracker({
|
|
127
|
+
maxFailures: 2,
|
|
128
|
+
failureCooldownMs: 500,
|
|
129
|
+
now: () => now,
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// 2 failures: cooldown = 500 * 2^0 = 500
|
|
133
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
134
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
135
|
+
|
|
136
|
+
const metrics2 = tracker.getMetrics('peer1')
|
|
137
|
+
expect(metrics2.cooldownUntil).toBe(now + 500)
|
|
138
|
+
|
|
139
|
+
// 3rd failure: cooldown = 500 * 2^1 = 1000
|
|
140
|
+
now += 501 // past first cooldown
|
|
141
|
+
tracker.recordResult('peer1', { success: false, latencyMs: 100 })
|
|
142
|
+
|
|
143
|
+
const metrics3 = tracker.getMetrics('peer1')
|
|
144
|
+
expect(metrics3.cooldownUntil).toBe(now + 1000)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('median latency', () => {
|
|
149
|
+
it('returns 500 when no latencies recorded', () => {
|
|
150
|
+
const tracker = new PeerMetricsTracker()
|
|
151
|
+
expect(tracker.getMedianLatency()).toBe(500)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('returns single value when only one peer has latency', () => {
|
|
155
|
+
const tracker = new PeerMetricsTracker()
|
|
156
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 200 })
|
|
157
|
+
expect(tracker.getMedianLatency()).toBe(200)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('returns median of odd number of values', () => {
|
|
161
|
+
const tracker = new PeerMetricsTracker()
|
|
162
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
163
|
+
tracker.recordResult('peer2', { success: true, latencyMs: 300 })
|
|
164
|
+
tracker.recordResult('peer3', { success: true, latencyMs: 200 })
|
|
165
|
+
|
|
166
|
+
expect(tracker.getMedianLatency()).toBe(200)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('returns average of two middle values for even count', () => {
|
|
170
|
+
const tracker = new PeerMetricsTracker()
|
|
171
|
+
tracker.recordResult('peer1', { success: true, latencyMs: 100 })
|
|
172
|
+
tracker.recordResult('peer2', { success: true, latencyMs: 200 })
|
|
173
|
+
tracker.recordResult('peer3', { success: true, latencyMs: 300 })
|
|
174
|
+
tracker.recordResult('peer4', { success: true, latencyMs: 400 })
|
|
175
|
+
|
|
176
|
+
expect(tracker.getMedianLatency()).toBe(250)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('getMetrics', () => {
|
|
181
|
+
it('returns zeroed metrics for unknown peer', () => {
|
|
182
|
+
const tracker = new PeerMetricsTracker()
|
|
183
|
+
const metrics = tracker.getMetrics('unknown')
|
|
184
|
+
|
|
185
|
+
expect(metrics.latencyEma).toBeUndefined()
|
|
186
|
+
expect(metrics.failureStreak).toBe(0)
|
|
187
|
+
expect(metrics.totalFailures).toBe(0)
|
|
188
|
+
expect(metrics.totalAttempts).toBe(0)
|
|
189
|
+
expect(metrics.cooldownUntil).toBeUndefined()
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { PeerMetrics } from './peer-scorer.js'
|
|
2
|
+
|
|
3
|
+
export interface PeerMetricsTrackerConfig {
|
|
4
|
+
latencyAlpha?: number // Default: 0.3
|
|
5
|
+
maxFailures?: number // Default: 3
|
|
6
|
+
failureCooldownMs?: number // Default: 30000
|
|
7
|
+
now?: () => number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class PeerMetricsTracker {
|
|
11
|
+
private readonly _latencyMap = new Map<string, number>()
|
|
12
|
+
private readonly _failureStreakMap = new Map<string, number>()
|
|
13
|
+
private readonly _totalFailureMap = new Map<string, number>()
|
|
14
|
+
private readonly _attemptMap = new Map<string, number>()
|
|
15
|
+
private readonly _cooldownUntilMap = new Map<string, number>()
|
|
16
|
+
|
|
17
|
+
private readonly _latencyAlpha: number
|
|
18
|
+
private readonly _maxFailures: number
|
|
19
|
+
private readonly _failureCooldownMs: number
|
|
20
|
+
private readonly _now: () => number
|
|
21
|
+
|
|
22
|
+
constructor(config?: PeerMetricsTrackerConfig) {
|
|
23
|
+
this._latencyAlpha = config?.latencyAlpha ?? 0.3
|
|
24
|
+
this._maxFailures = Math.max(1, config?.maxFailures ?? 3)
|
|
25
|
+
this._failureCooldownMs = Math.max(1, config?.failureCooldownMs ?? 30_000)
|
|
26
|
+
this._now = config?.now ?? (() => Date.now())
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
recordResult(peerId: string, result: { success: boolean; latencyMs: number }): void {
|
|
30
|
+
const now = this._now()
|
|
31
|
+
const attempts = (this._attemptMap.get(peerId) ?? 0) + 1
|
|
32
|
+
this._attemptMap.set(peerId, attempts)
|
|
33
|
+
|
|
34
|
+
if (result.success) {
|
|
35
|
+
// Update latency EMA
|
|
36
|
+
const prev = this._latencyMap.get(peerId) ?? result.latencyMs
|
|
37
|
+
this._latencyMap.set(
|
|
38
|
+
peerId,
|
|
39
|
+
prev * (1 - this._latencyAlpha) + result.latencyMs * this._latencyAlpha,
|
|
40
|
+
)
|
|
41
|
+
// Reset failure count on success
|
|
42
|
+
this._failureStreakMap.delete(peerId)
|
|
43
|
+
this._cooldownUntilMap.delete(peerId)
|
|
44
|
+
} else {
|
|
45
|
+
const currentStreak = this._failureStreakMap.get(peerId) ?? 0
|
|
46
|
+
const nextStreak = currentStreak + 1
|
|
47
|
+
this._failureStreakMap.set(peerId, nextStreak)
|
|
48
|
+
this._totalFailureMap.set(peerId, (this._totalFailureMap.get(peerId) ?? 0) + 1)
|
|
49
|
+
|
|
50
|
+
if (nextStreak >= this._maxFailures) {
|
|
51
|
+
const penaltyPower = Math.min(4, nextStreak - this._maxFailures)
|
|
52
|
+
const cooldownMs = this._failureCooldownMs * (2 ** penaltyPower)
|
|
53
|
+
this._cooldownUntilMap.set(peerId, now + cooldownMs)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getMetrics(peerId: string): PeerMetrics {
|
|
59
|
+
return {
|
|
60
|
+
latencyEma: this._latencyMap.get(peerId),
|
|
61
|
+
failureStreak: this._failureStreakMap.get(peerId) ?? 0,
|
|
62
|
+
totalFailures: this._totalFailureMap.get(peerId) ?? 0,
|
|
63
|
+
totalAttempts: this._attemptMap.get(peerId) ?? 0,
|
|
64
|
+
cooldownUntil: this._cooldownUntilMap.get(peerId),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isCoolingDown(peerId: string): boolean {
|
|
69
|
+
const now = this._now()
|
|
70
|
+
const until = this._cooldownUntilMap.get(peerId)
|
|
71
|
+
if (until === undefined) return false
|
|
72
|
+
if (until <= now) {
|
|
73
|
+
this._cooldownUntilMap.delete(peerId)
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getMedianLatency(): number {
|
|
80
|
+
const values = [...this._latencyMap.values()]
|
|
81
|
+
if (values.length === 0) return 500
|
|
82
|
+
values.sort((a, b) => a - b)
|
|
83
|
+
const mid = Math.floor(values.length / 2)
|
|
84
|
+
return values.length % 2 === 0
|
|
85
|
+
? (values[mid - 1]! + values[mid]!) / 2
|
|
86
|
+
: values[mid]!
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { PeerInfo } from '@antseed/node'
|
|
3
|
+
import { scoreCandidates, DEFAULT_WEIGHTS } from './peer-scorer.js'
|
|
4
|
+
import type { PeerMetrics, TokenPricingUsdPerMillion } from './peer-scorer.js'
|
|
5
|
+
|
|
6
|
+
function makePeerId(char: string): PeerInfo['peerId'] {
|
|
7
|
+
return char.repeat(64) as PeerInfo['peerId']
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function makePeer(overrides?: Partial<PeerInfo>): PeerInfo {
|
|
11
|
+
return {
|
|
12
|
+
peerId: makePeerId('a'),
|
|
13
|
+
lastSeen: 1_000_000,
|
|
14
|
+
providers: ['anthropic'],
|
|
15
|
+
reputationScore: 80,
|
|
16
|
+
trustScore: 80,
|
|
17
|
+
maxConcurrency: 10,
|
|
18
|
+
currentLoad: 1,
|
|
19
|
+
...overrides,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultMetrics: PeerMetrics = {
|
|
24
|
+
latencyEma: undefined,
|
|
25
|
+
failureStreak: 0,
|
|
26
|
+
totalFailures: 0,
|
|
27
|
+
totalAttempts: 0,
|
|
28
|
+
cooldownUntil: undefined,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const defaultOffer: TokenPricingUsdPerMillion = {
|
|
32
|
+
inputUsdPerMillion: 10,
|
|
33
|
+
outputUsdPerMillion: 10,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const defaultContext = {
|
|
37
|
+
now: 1_000_000,
|
|
38
|
+
medianLatency: 500,
|
|
39
|
+
maxPeerStalenessMs: 300_000,
|
|
40
|
+
maxFailures: 3,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe('scoreCandidates', () => {
|
|
44
|
+
it('returns correct score ordering — cheaper peer ranks higher', () => {
|
|
45
|
+
const cheapPeer = makePeer({ peerId: makePeerId('1') })
|
|
46
|
+
const expensivePeer = makePeer({ peerId: makePeerId('2') })
|
|
47
|
+
|
|
48
|
+
const result = scoreCandidates(
|
|
49
|
+
[
|
|
50
|
+
{
|
|
51
|
+
peer: expensivePeer,
|
|
52
|
+
provider: 'anthropic',
|
|
53
|
+
providerRank: 0,
|
|
54
|
+
offer: { inputUsdPerMillion: 100, outputUsdPerMillion: 100 },
|
|
55
|
+
metrics: defaultMetrics,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
peer: cheapPeer,
|
|
59
|
+
provider: 'anthropic',
|
|
60
|
+
providerRank: 0,
|
|
61
|
+
offer: { inputUsdPerMillion: 5, outputUsdPerMillion: 5 },
|
|
62
|
+
metrics: defaultMetrics,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
defaultContext,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
expect(result).toHaveLength(2)
|
|
69
|
+
expect(result[0]!.peer.peerId).toBe(cheapPeer.peerId)
|
|
70
|
+
expect(result[1]!.peer.peerId).toBe(expensivePeer.peerId)
|
|
71
|
+
expect(result[0]!.score).toBeGreaterThan(result[1]!.score)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('custom weights change ranking — zero out price, maximize latency', () => {
|
|
75
|
+
const cheapButSlow = makePeer({ peerId: makePeerId('1') })
|
|
76
|
+
const expensiveButFast = makePeer({ peerId: makePeerId('2') })
|
|
77
|
+
|
|
78
|
+
const result = scoreCandidates(
|
|
79
|
+
[
|
|
80
|
+
{
|
|
81
|
+
peer: cheapButSlow,
|
|
82
|
+
provider: 'anthropic',
|
|
83
|
+
providerRank: 0,
|
|
84
|
+
offer: { inputUsdPerMillion: 1, outputUsdPerMillion: 1 },
|
|
85
|
+
metrics: { ...defaultMetrics, latencyEma: 1000 },
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
peer: expensiveButFast,
|
|
89
|
+
provider: 'anthropic',
|
|
90
|
+
providerRank: 0,
|
|
91
|
+
offer: { inputUsdPerMillion: 100, outputUsdPerMillion: 100 },
|
|
92
|
+
metrics: { ...defaultMetrics, latencyEma: 50 },
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
{
|
|
96
|
+
...defaultContext,
|
|
97
|
+
weights: {
|
|
98
|
+
price: 0,
|
|
99
|
+
latency: 1.0,
|
|
100
|
+
capacity: 0,
|
|
101
|
+
reputation: 0,
|
|
102
|
+
freshness: 0,
|
|
103
|
+
reliability: 0,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(result[0]!.peer.peerId).toBe(expensiveButFast.peerId)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('handles single candidate', () => {
|
|
112
|
+
const peer = makePeer({ peerId: makePeerId('1') })
|
|
113
|
+
|
|
114
|
+
const result = scoreCandidates(
|
|
115
|
+
[
|
|
116
|
+
{
|
|
117
|
+
peer,
|
|
118
|
+
provider: 'anthropic',
|
|
119
|
+
providerRank: 0,
|
|
120
|
+
offer: defaultOffer,
|
|
121
|
+
metrics: defaultMetrics,
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
defaultContext,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
expect(result).toHaveLength(1)
|
|
128
|
+
expect(result[0]!.peer.peerId).toBe(peer.peerId)
|
|
129
|
+
expect(result[0]!.score).toBeGreaterThan(0)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('handles all equal scores — tie-broken by peerId', () => {
|
|
133
|
+
const peerA = makePeer({ peerId: makePeerId('a') })
|
|
134
|
+
const peerB = makePeer({ peerId: makePeerId('b') })
|
|
135
|
+
|
|
136
|
+
const result = scoreCandidates(
|
|
137
|
+
[
|
|
138
|
+
{
|
|
139
|
+
peer: peerB,
|
|
140
|
+
provider: 'anthropic',
|
|
141
|
+
providerRank: 0,
|
|
142
|
+
offer: defaultOffer,
|
|
143
|
+
metrics: defaultMetrics,
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
peer: peerA,
|
|
147
|
+
provider: 'anthropic',
|
|
148
|
+
providerRank: 0,
|
|
149
|
+
offer: defaultOffer,
|
|
150
|
+
metrics: defaultMetrics,
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
defaultContext,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
expect(result).toHaveLength(2)
|
|
157
|
+
// Both have identical inputs so scores should be equal; tie-break by peerId ascending
|
|
158
|
+
expect(result[0]!.peer.peerId).toBe(peerA.peerId)
|
|
159
|
+
expect(result[1]!.peer.peerId).toBe(peerB.peerId)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('returns empty array for empty candidates', () => {
|
|
163
|
+
const result = scoreCandidates([], defaultContext)
|
|
164
|
+
expect(result).toHaveLength(0)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('uses default weights when none provided', () => {
|
|
168
|
+
const peer = makePeer({ peerId: makePeerId('1') })
|
|
169
|
+
|
|
170
|
+
const result = scoreCandidates(
|
|
171
|
+
[
|
|
172
|
+
{
|
|
173
|
+
peer,
|
|
174
|
+
provider: 'anthropic',
|
|
175
|
+
providerRank: 0,
|
|
176
|
+
offer: defaultOffer,
|
|
177
|
+
metrics: defaultMetrics,
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
defaultContext,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
// With a single candidate, all normalized-inverted factors are 1.0
|
|
184
|
+
// Score = sum of all weights = 1.0 (minus any freshness/reputation adjustments)
|
|
185
|
+
expect(result[0]!.score).toBeGreaterThan(0)
|
|
186
|
+
expect(result[0]!.score).toBeLessThanOrEqual(
|
|
187
|
+
DEFAULT_WEIGHTS.price +
|
|
188
|
+
DEFAULT_WEIGHTS.latency +
|
|
189
|
+
DEFAULT_WEIGHTS.capacity +
|
|
190
|
+
DEFAULT_WEIGHTS.reputation +
|
|
191
|
+
DEFAULT_WEIGHTS.freshness +
|
|
192
|
+
DEFAULT_WEIGHTS.reliability,
|
|
193
|
+
)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import type { PeerInfo } from '@antseed/node'
|
|
2
|
+
|
|
3
|
+
export interface TokenPricingUsdPerMillion {
|
|
4
|
+
inputUsdPerMillion: number
|
|
5
|
+
outputUsdPerMillion: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScoringWeights {
|
|
9
|
+
price: number
|
|
10
|
+
latency: number
|
|
11
|
+
capacity: number
|
|
12
|
+
reputation: number
|
|
13
|
+
freshness: number
|
|
14
|
+
reliability: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_WEIGHTS: ScoringWeights = {
|
|
18
|
+
price: 0.30,
|
|
19
|
+
latency: 0.25,
|
|
20
|
+
capacity: 0.20,
|
|
21
|
+
reputation: 0.10,
|
|
22
|
+
freshness: 0.10,
|
|
23
|
+
reliability: 0.05,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PeerMetrics {
|
|
27
|
+
latencyEma: number | undefined
|
|
28
|
+
failureStreak: number
|
|
29
|
+
totalFailures: number
|
|
30
|
+
totalAttempts: number
|
|
31
|
+
cooldownUntil: number | undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ScoredCandidate {
|
|
35
|
+
peer: PeerInfo
|
|
36
|
+
provider: string
|
|
37
|
+
providerRank: number
|
|
38
|
+
offer: TokenPricingUsdPerMillion
|
|
39
|
+
score: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface CandidateInput {
|
|
43
|
+
peer: PeerInfo
|
|
44
|
+
provider: string
|
|
45
|
+
providerRank: number
|
|
46
|
+
offer: TokenPricingUsdPerMillion
|
|
47
|
+
metrics: PeerMetrics
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ScoringContext {
|
|
51
|
+
now: number
|
|
52
|
+
medianLatency: number
|
|
53
|
+
maxPeerStalenessMs: number
|
|
54
|
+
maxFailures: number
|
|
55
|
+
weights?: Partial<ScoringWeights>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizedInverted(value: number, min: number, range: number): number {
|
|
59
|
+
if (range <= 0) {
|
|
60
|
+
return 1
|
|
61
|
+
}
|
|
62
|
+
return 1 - (value - min) / range
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function effectiveReputation(p: PeerInfo): number {
|
|
66
|
+
if (p.onChainReputation !== undefined) {
|
|
67
|
+
return p.onChainReputation
|
|
68
|
+
}
|
|
69
|
+
return p.trustScore ?? p.reputationScore ?? 0
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function availableCapacity(p: PeerInfo): number {
|
|
73
|
+
const max = p.maxConcurrency ?? 1
|
|
74
|
+
const current = p.currentLoad ?? 0
|
|
75
|
+
return Math.max(0, max - current)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function freshnessFactor(p: PeerInfo, now: number, maxPeerStalenessMs: number): number {
|
|
79
|
+
if (!Number.isFinite(p.lastSeen)) return 0.5
|
|
80
|
+
const age = Math.max(0, now - p.lastSeen)
|
|
81
|
+
if (age >= maxPeerStalenessMs) return 0
|
|
82
|
+
return 1 - (age / maxPeerStalenessMs)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function reliabilityFactor(metrics: PeerMetrics, maxFailures: number): number {
|
|
86
|
+
const historicalPenalty = metrics.totalAttempts > 0 ? metrics.totalFailures / metrics.totalAttempts : 0
|
|
87
|
+
const streakPenalty = Math.min(1, metrics.failureStreak / maxFailures)
|
|
88
|
+
return Math.max(0, 1 - Math.max(historicalPenalty, streakPenalty))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function peerLatency(metrics: PeerMetrics, medianLatency: number): number {
|
|
92
|
+
return metrics.latencyEma ?? medianLatency
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function tieBreak(a: PeerInfo, aLatencyEma: number | undefined, b: PeerInfo, bLatencyEma: number | undefined): number {
|
|
96
|
+
const latA = aLatencyEma ?? Number.POSITIVE_INFINITY
|
|
97
|
+
const latB = bLatencyEma ?? Number.POSITIVE_INFINITY
|
|
98
|
+
if (latA !== latB) {
|
|
99
|
+
return latA - latB
|
|
100
|
+
}
|
|
101
|
+
return a.peerId.localeCompare(b.peerId)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Score and rank candidates using the composite scoring algorithm.
|
|
106
|
+
*
|
|
107
|
+
* Calculates normalization context (min/max price, latency, capacity),
|
|
108
|
+
* scores each candidate with weighted factors, and returns candidates
|
|
109
|
+
* sorted by score (highest first).
|
|
110
|
+
*/
|
|
111
|
+
export function scoreCandidates(
|
|
112
|
+
candidates: CandidateInput[],
|
|
113
|
+
context: ScoringContext,
|
|
114
|
+
): ScoredCandidate[] {
|
|
115
|
+
if (candidates.length === 0) return []
|
|
116
|
+
|
|
117
|
+
const w: ScoringWeights = { ...DEFAULT_WEIGHTS, ...context.weights }
|
|
118
|
+
|
|
119
|
+
// --- Normalization context ---
|
|
120
|
+
const knownPrices = candidates
|
|
121
|
+
.map((c) => c.offer.inputUsdPerMillion)
|
|
122
|
+
.filter((value) => Number.isFinite(value))
|
|
123
|
+
const fallbackPrice = knownPrices.length > 0 ? Math.max(...knownPrices) * 1.25 : 1
|
|
124
|
+
let minPrice = knownPrices.length > 0 ? Math.min(...knownPrices) : fallbackPrice
|
|
125
|
+
let maxPrice = knownPrices.length > 0 ? Math.max(...knownPrices) : fallbackPrice
|
|
126
|
+
let minLatency = Number.POSITIVE_INFINITY
|
|
127
|
+
let maxLatency = 0
|
|
128
|
+
let maxCap = 0
|
|
129
|
+
|
|
130
|
+
for (const c of candidates) {
|
|
131
|
+
const price = c.offer.inputUsdPerMillion
|
|
132
|
+
if (price < minPrice) minPrice = price
|
|
133
|
+
if (price > maxPrice) maxPrice = price
|
|
134
|
+
|
|
135
|
+
const lat = peerLatency(c.metrics, context.medianLatency)
|
|
136
|
+
if (lat < minLatency) minLatency = lat
|
|
137
|
+
if (lat > maxLatency) maxLatency = lat
|
|
138
|
+
|
|
139
|
+
const cap = availableCapacity(c.peer)
|
|
140
|
+
if (cap > maxCap) maxCap = cap
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const priceRange = maxPrice - minPrice
|
|
144
|
+
const latencyRange = maxLatency - minLatency
|
|
145
|
+
|
|
146
|
+
// --- Score each candidate ---
|
|
147
|
+
const scored: ScoredCandidate[] = []
|
|
148
|
+
|
|
149
|
+
for (const c of candidates) {
|
|
150
|
+
// Price factor (lower is better, inverted)
|
|
151
|
+
const price = c.offer.inputUsdPerMillion ?? fallbackPrice
|
|
152
|
+
const priceFactor = normalizedInverted(price, minPrice, priceRange)
|
|
153
|
+
|
|
154
|
+
// Latency factor (lower is better, inverted)
|
|
155
|
+
const lat = peerLatency(c.metrics, context.medianLatency)
|
|
156
|
+
const latencyFactor = normalizedInverted(lat, minLatency, latencyRange)
|
|
157
|
+
|
|
158
|
+
// Capacity factor (higher is better)
|
|
159
|
+
const capFactor = maxCap > 0
|
|
160
|
+
? availableCapacity(c.peer) / maxCap
|
|
161
|
+
: 1
|
|
162
|
+
|
|
163
|
+
// Reputation factor (higher is better, normalized 0-100 to 0-1)
|
|
164
|
+
const repFactor = effectiveReputation(c.peer) / 100
|
|
165
|
+
const fresh = freshnessFactor(c.peer, context.now, context.maxPeerStalenessMs)
|
|
166
|
+
const reliability = reliabilityFactor(c.metrics, context.maxFailures)
|
|
167
|
+
|
|
168
|
+
const score =
|
|
169
|
+
w.price * priceFactor +
|
|
170
|
+
w.latency * latencyFactor +
|
|
171
|
+
w.capacity * capFactor +
|
|
172
|
+
w.reputation * repFactor +
|
|
173
|
+
w.freshness * fresh +
|
|
174
|
+
w.reliability * reliability
|
|
175
|
+
|
|
176
|
+
scored.push({
|
|
177
|
+
peer: c.peer,
|
|
178
|
+
provider: c.provider,
|
|
179
|
+
providerRank: c.providerRank,
|
|
180
|
+
offer: c.offer,
|
|
181
|
+
score,
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sort by score descending, tie-break by latency then peerId
|
|
186
|
+
scored.sort((a, b) => {
|
|
187
|
+
if (Math.abs(a.score - b.score) < 1e-9) {
|
|
188
|
+
const aMetrics = candidates.find((c) => c.peer.peerId === a.peer.peerId)
|
|
189
|
+
const bMetrics = candidates.find((c) => c.peer.peerId === b.peer.peerId)
|
|
190
|
+
return tieBreak(a.peer, aMetrics?.metrics.latencyEma, b.peer, bMetrics?.metrics.latencyEma)
|
|
191
|
+
}
|
|
192
|
+
return b.score - a.score
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
return scored
|
|
196
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ToolHint {
|
|
2
|
+
name: string
|
|
3
|
+
envVar: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const WELL_KNOWN_TOOL_HINTS: ToolHint[] = [
|
|
7
|
+
{ name: 'Claude Code', envVar: 'ANTHROPIC_BASE_URL' },
|
|
8
|
+
{ name: 'Aider', envVar: 'OPENAI_API_BASE' },
|
|
9
|
+
{ name: 'Continue.dev', envVar: 'OPENAI_BASE_URL' },
|
|
10
|
+
{ name: 'Codex', envVar: 'OPENAI_BASE_URL' },
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
export function formatToolHints(hints: ToolHint[], proxyUrl: string): string[] {
|
|
14
|
+
return hints.map(hint => `export ${hint.envVar}=${proxyUrl} # ${hint.name}`)
|
|
15
|
+
}
|