@antseed/router-local-chat 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 ADDED
@@ -0,0 +1,41 @@
1
+ # @antseed/router-local-chat
2
+
3
+ Latency-prioritized router for the Antseed desktop chat application. Optimizes peer selection for interactive conversations where response time matters most.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ antseed plugin add @antseed/router-local-chat
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ antseed connect --router local-chat
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ | Key | Type | Required | Default | Description |
20
+ |-----|------|----------|---------|-------------|
21
+ | `ANTSEED_MIN_REPUTATION` | number | No | 50 | Minimum peer reputation (0-100) |
22
+ | `ANTSEED_MAX_FAILURES` | number | No | 3 | Max failures before cooldown |
23
+ | `ANTSEED_FAILURE_COOLDOWN_MS` | number | No | 30000 | Cooldown duration after failures (ms) |
24
+ | `ANTSEED_MAX_PEER_STALENESS_MS` | number | No | 300000 | Max age of peer info before deprioritizing |
25
+
26
+ ## Scoring Weights
27
+
28
+ Tuned for interactive chat -- latency is weighted highest:
29
+
30
+ | Factor | Weight |
31
+ |--------|--------|
32
+ | latency | 0.35 |
33
+ | capacity | 0.20 |
34
+ | price | 0.15 |
35
+ | reputation | 0.15 |
36
+ | freshness | 0.10 |
37
+ | reliability | 0.05 |
38
+
39
+ ## How It Works
40
+
41
+ Uses `scoreCandidates` and `PeerMetricsTracker` from `@antseed/router-core`. Peers that fail repeatedly are placed on cooldown. Stale peers are deprioritized. Among equal-scored peers, deterministic tie-breaking ensures consistent routing.
@@ -0,0 +1,7 @@
1
+ import type { AntseedRouterPlugin } from '@antseed/node';
2
+ import { formatToolHints } from '@antseed/router-core';
3
+ declare const plugin: AntseedRouterPlugin;
4
+ export default plugin;
5
+ export declare const TOOL_HINTS: import("@antseed/router-core").ToolHint[];
6
+ export { formatToolHints };
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAyB,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAG9E,QAAA,MAAM,MAAM,EAAE,mBAoCb,CAAC;AAEF,eAAe,MAAM,CAAC;AAEtB,eAAO,MAAM,UAAU,2CAAwB,CAAC;AAChD,OAAO,EAAE,eAAe,EAAE,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,43 @@
1
+ import { WELL_KNOWN_TOOL_HINTS, formatToolHints } from '@antseed/router-core';
2
+ import { LocalChatRouter } from './router.js';
3
+ const plugin = {
4
+ name: 'local-chat',
5
+ displayName: 'Local Chat',
6
+ version: '0.1.0',
7
+ type: 'router',
8
+ description: 'Latency-prioritized router for desktop chat applications',
9
+ configSchema: [
10
+ { key: 'ANTSEED_MIN_REPUTATION', label: 'Min Reputation', type: 'number', required: false, default: 50, description: 'Min peer reputation 0-100' },
11
+ { key: 'ANTSEED_MAX_FAILURES', label: 'Max Failures', type: 'number', required: false, default: 3, description: 'Max consecutive failures before excluding peer' },
12
+ { key: 'ANTSEED_FAILURE_COOLDOWN_MS', label: 'Failure Cooldown (ms)', type: 'number', required: false, default: 30000, description: 'Cooldown after repeated failures (ms)' },
13
+ { key: 'ANTSEED_MAX_PEER_STALENESS_MS', label: 'Max Peer Staleness (ms)', type: 'number', required: false, default: 300000, description: 'Peer staleness horizon (ms)' },
14
+ ],
15
+ createRouter(config) {
16
+ const minReputation = config['ANTSEED_MIN_REPUTATION'] ? parseInt(config['ANTSEED_MIN_REPUTATION'], 10) : undefined;
17
+ if (minReputation !== undefined && Number.isNaN(minReputation)) {
18
+ throw new Error('ANTSEED_MIN_REPUTATION must be a valid number');
19
+ }
20
+ const maxFailures = config['ANTSEED_MAX_FAILURES'] ? parseInt(config['ANTSEED_MAX_FAILURES'], 10) : undefined;
21
+ if (maxFailures !== undefined && Number.isNaN(maxFailures)) {
22
+ throw new Error('ANTSEED_MAX_FAILURES must be a valid number');
23
+ }
24
+ const failureCooldownMs = config['ANTSEED_FAILURE_COOLDOWN_MS'] ? parseInt(config['ANTSEED_FAILURE_COOLDOWN_MS'], 10) : undefined;
25
+ if (failureCooldownMs !== undefined && Number.isNaN(failureCooldownMs)) {
26
+ throw new Error('ANTSEED_FAILURE_COOLDOWN_MS must be a valid number');
27
+ }
28
+ const maxPeerStalenessMs = config['ANTSEED_MAX_PEER_STALENESS_MS'] ? parseInt(config['ANTSEED_MAX_PEER_STALENESS_MS'], 10) : undefined;
29
+ if (maxPeerStalenessMs !== undefined && Number.isNaN(maxPeerStalenessMs)) {
30
+ throw new Error('ANTSEED_MAX_PEER_STALENESS_MS must be a valid number');
31
+ }
32
+ return new LocalChatRouter({
33
+ minReputation,
34
+ maxFailures,
35
+ failureCooldownMs,
36
+ maxPeerStalenessMs,
37
+ });
38
+ },
39
+ };
40
+ export default plugin;
41
+ export const TOOL_HINTS = WELL_KNOWN_TOOL_HINTS;
42
+ export { formatToolHints };
43
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,28 @@
1
+ import type { Router, PeerInfo, SerializedHttpRequest } from '@antseed/node';
2
+ export interface LocalChatRouterConfig {
3
+ minReputation?: number;
4
+ maxFailures?: number;
5
+ failureCooldownMs?: number;
6
+ maxPeerStalenessMs?: number;
7
+ now?: () => number;
8
+ }
9
+ export declare class LocalChatRouter implements Router {
10
+ private readonly _minReputation;
11
+ private readonly _maxFailures;
12
+ private readonly _maxPeerStalenessMs;
13
+ private readonly _now;
14
+ private readonly _metrics;
15
+ constructor(config?: LocalChatRouterConfig);
16
+ selectPeer(req: SerializedHttpRequest, peers: PeerInfo[]): PeerInfo | null;
17
+ onResult(peer: PeerInfo, result: {
18
+ success: boolean;
19
+ latencyMs: number;
20
+ tokens: number;
21
+ }): void;
22
+ private _effectiveReputation;
23
+ private _extractRequestedModel;
24
+ private _resolvePeerOfferPrice;
25
+ private _isFiniteNonNegative;
26
+ private _isValidOffer;
27
+ }
28
+ //# sourceMappingURL=router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,qBAAqB,EAAE,MAAM,eAAe,CAAC;AAkB7E,MAAM,WAAW,qBAAqB;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED,qBAAa,eAAgB,YAAW,MAAM;IAC5C,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAS;IACxC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAS;IAC7C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAe;IACpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;gBAElC,MAAM,CAAC,EAAE,qBAAqB;IAY1C,UAAU,CAAC,GAAG,EAAE,qBAAqB,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,QAAQ,GAAG,IAAI;IAoE1E,QAAQ,CACN,IAAI,EAAE,QAAQ,EACd,MAAM,EAAE;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,GAC9D,IAAI;IAOP,OAAO,CAAC,oBAAoB;IAO5B,OAAO,CAAC,sBAAsB;IAkB9B,OAAO,CAAC,sBAAsB;IAgC9B,OAAO,CAAC,oBAAoB;IAI5B,OAAO,CAAC,aAAa;CAMtB"}
package/dist/router.js ADDED
@@ -0,0 +1,138 @@
1
+ import { scoreCandidates, PeerMetricsTracker, } from '@antseed/router-core';
2
+ /** Latency-prioritized weights for desktop chat. */
3
+ const CHAT_WEIGHTS = {
4
+ price: 0.15,
5
+ latency: 0.35,
6
+ capacity: 0.20,
7
+ reputation: 0.15,
8
+ freshness: 0.10,
9
+ reliability: 0.05,
10
+ };
11
+ export class LocalChatRouter {
12
+ _minReputation;
13
+ _maxFailures;
14
+ _maxPeerStalenessMs;
15
+ _now;
16
+ _metrics;
17
+ constructor(config) {
18
+ this._minReputation = config?.minReputation ?? 50;
19
+ this._maxFailures = Math.max(1, config?.maxFailures ?? 3);
20
+ this._maxPeerStalenessMs = Math.max(1, config?.maxPeerStalenessMs ?? 300_000);
21
+ this._now = config?.now ?? (() => Date.now());
22
+ this._metrics = new PeerMetricsTracker({
23
+ maxFailures: this._maxFailures,
24
+ failureCooldownMs: Math.max(1, config?.failureCooldownMs ?? 30_000),
25
+ now: this._now,
26
+ });
27
+ }
28
+ selectPeer(req, peers) {
29
+ const now = this._now();
30
+ const requestedModel = this._extractRequestedModel(req);
31
+ const candidates = [];
32
+ for (const peer of peers) {
33
+ // Reputation filter
34
+ const reputation = this._effectiveReputation(peer);
35
+ if (reputation < this._minReputation) {
36
+ continue;
37
+ }
38
+ // Cooldown filter
39
+ if (this._metrics.isCoolingDown(peer.peerId)) {
40
+ continue;
41
+ }
42
+ // Use first available provider (no preference list for chat)
43
+ const provider = peer.providers[0];
44
+ if (!provider) {
45
+ continue;
46
+ }
47
+ const offer = this._resolvePeerOfferPrice(peer, provider, requestedModel);
48
+ if (!offer) {
49
+ continue;
50
+ }
51
+ candidates.push({
52
+ peer,
53
+ provider,
54
+ providerRank: Number.MAX_SAFE_INTEGER,
55
+ offer,
56
+ });
57
+ }
58
+ if (candidates.length === 0)
59
+ return null;
60
+ if (candidates.length === 1) {
61
+ return candidates[0].peer;
62
+ }
63
+ // Delegate scoring to router-core with latency-prioritized weights
64
+ const scoringInput = candidates.map((c) => ({
65
+ peer: c.peer,
66
+ provider: c.provider,
67
+ providerRank: c.providerRank,
68
+ offer: c.offer,
69
+ metrics: this._metrics.getMetrics(c.peer.peerId),
70
+ }));
71
+ const scored = scoreCandidates(scoringInput, {
72
+ now,
73
+ medianLatency: this._metrics.getMedianLatency(),
74
+ maxPeerStalenessMs: this._maxPeerStalenessMs,
75
+ maxFailures: this._maxFailures,
76
+ weights: CHAT_WEIGHTS,
77
+ });
78
+ return scored[0]?.peer ?? null;
79
+ }
80
+ onResult(peer, result) {
81
+ this._metrics.recordResult(peer.peerId, {
82
+ success: result.success,
83
+ latencyMs: result.latencyMs,
84
+ });
85
+ }
86
+ _effectiveReputation(p) {
87
+ if (p.onChainReputation !== undefined) {
88
+ return p.onChainReputation;
89
+ }
90
+ return p.trustScore ?? p.reputationScore ?? 0;
91
+ }
92
+ _extractRequestedModel(req) {
93
+ const contentType = req.headers['content-type'] ?? req.headers['Content-Type'] ?? '';
94
+ if (!contentType.toLowerCase().includes('application/json')) {
95
+ return null;
96
+ }
97
+ try {
98
+ const parsed = JSON.parse(new TextDecoder().decode(req.body));
99
+ if (!parsed || typeof parsed !== 'object') {
100
+ return null;
101
+ }
102
+ const model = parsed['model'];
103
+ return typeof model === 'string' && model.trim().length > 0 ? model.trim() : null;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ _resolvePeerOfferPrice(peer, provider, model) {
110
+ const providerPricing = peer.providerPricing?.[provider];
111
+ if (model) {
112
+ const modelSpecific = providerPricing?.models?.[model];
113
+ if (modelSpecific && this._isValidOffer(modelSpecific)) {
114
+ return modelSpecific;
115
+ }
116
+ }
117
+ const providerDefaults = providerPricing?.defaults;
118
+ if (providerDefaults && this._isValidOffer(providerDefaults)) {
119
+ return providerDefaults;
120
+ }
121
+ if (this._isFiniteNonNegative(peer.defaultInputUsdPerMillion) &&
122
+ this._isFiniteNonNegative(peer.defaultOutputUsdPerMillion)) {
123
+ return {
124
+ inputUsdPerMillion: peer.defaultInputUsdPerMillion,
125
+ outputUsdPerMillion: peer.defaultOutputUsdPerMillion,
126
+ };
127
+ }
128
+ return null;
129
+ }
130
+ _isFiniteNonNegative(value) {
131
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
132
+ }
133
+ _isValidOffer(offer) {
134
+ return (this._isFiniteNonNegative(offer.inputUsdPerMillion) &&
135
+ this._isFiniteNonNegative(offer.outputUsdPerMillion));
136
+ }
137
+ }
138
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"router.js","sourceRoot":"","sources":["../src/router.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@antseed/router-local-chat",
3
+ "version": "0.1.0",
4
+ "description": "Antseed local chat router plugin — latency-prioritized for desktop",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" }
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "typecheck": "tsc --noEmit",
14
+ "test": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@antseed/router-core": "workspace:*"
18
+ },
19
+ "peerDependencies": {
20
+ "@antseed/node": ">=0.1.0"
21
+ },
22
+ "devDependencies": {
23
+ "typescript": "^5.5.0",
24
+ "vitest": "^2.0.0",
25
+ "@antseed/node": "workspace:*"
26
+ }
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { AntseedRouterPlugin } from '@antseed/node';
2
+ import { WELL_KNOWN_TOOL_HINTS, formatToolHints } from '@antseed/router-core';
3
+ import { LocalChatRouter } from './router.js';
4
+
5
+ const plugin: AntseedRouterPlugin = {
6
+ name: 'local-chat',
7
+ displayName: 'Local Chat',
8
+ version: '0.1.0',
9
+ type: 'router',
10
+ description: 'Latency-prioritized router for desktop chat applications',
11
+ configSchema: [
12
+ { key: 'ANTSEED_MIN_REPUTATION', label: 'Min Reputation', type: 'number', required: false, default: 50, description: 'Min peer reputation 0-100' },
13
+ { key: 'ANTSEED_MAX_FAILURES', label: 'Max Failures', type: 'number', required: false, default: 3, description: 'Max consecutive failures before excluding peer' },
14
+ { key: 'ANTSEED_FAILURE_COOLDOWN_MS', label: 'Failure Cooldown (ms)', type: 'number', required: false, default: 30000, description: 'Cooldown after repeated failures (ms)' },
15
+ { key: 'ANTSEED_MAX_PEER_STALENESS_MS', label: 'Max Peer Staleness (ms)', type: 'number', required: false, default: 300000, description: 'Peer staleness horizon (ms)' },
16
+ ],
17
+ createRouter(config: Record<string, string>) {
18
+ const minReputation = config['ANTSEED_MIN_REPUTATION'] ? parseInt(config['ANTSEED_MIN_REPUTATION'], 10) : undefined;
19
+ if (minReputation !== undefined && Number.isNaN(minReputation)) {
20
+ throw new Error('ANTSEED_MIN_REPUTATION must be a valid number');
21
+ }
22
+ const maxFailures = config['ANTSEED_MAX_FAILURES'] ? parseInt(config['ANTSEED_MAX_FAILURES'], 10) : undefined;
23
+ if (maxFailures !== undefined && Number.isNaN(maxFailures)) {
24
+ throw new Error('ANTSEED_MAX_FAILURES must be a valid number');
25
+ }
26
+ const failureCooldownMs = config['ANTSEED_FAILURE_COOLDOWN_MS'] ? parseInt(config['ANTSEED_FAILURE_COOLDOWN_MS'], 10) : undefined;
27
+ if (failureCooldownMs !== undefined && Number.isNaN(failureCooldownMs)) {
28
+ throw new Error('ANTSEED_FAILURE_COOLDOWN_MS must be a valid number');
29
+ }
30
+ const maxPeerStalenessMs = config['ANTSEED_MAX_PEER_STALENESS_MS'] ? parseInt(config['ANTSEED_MAX_PEER_STALENESS_MS'], 10) : undefined;
31
+ if (maxPeerStalenessMs !== undefined && Number.isNaN(maxPeerStalenessMs)) {
32
+ throw new Error('ANTSEED_MAX_PEER_STALENESS_MS must be a valid number');
33
+ }
34
+ return new LocalChatRouter({
35
+ minReputation,
36
+ maxFailures,
37
+ failureCooldownMs,
38
+ maxPeerStalenessMs,
39
+ });
40
+ },
41
+ };
42
+
43
+ export default plugin;
44
+
45
+ export const TOOL_HINTS = WELL_KNOWN_TOOL_HINTS;
46
+ export { formatToolHints };
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { PeerInfo, SerializedHttpRequest } from '@antseed/node';
3
+ import { LocalChatRouter } from './router.js';
4
+
5
+ function makePeer(overrides?: Partial<PeerInfo>): PeerInfo {
6
+ return {
7
+ peerId: 'a'.repeat(64) as PeerInfo['peerId'],
8
+ lastSeen: Date.now(),
9
+ providers: ['anthropic'],
10
+ reputationScore: 80,
11
+ trustScore: 80,
12
+ defaultInputUsdPerMillion: 10,
13
+ defaultOutputUsdPerMillion: 10,
14
+ providerPricing: {
15
+ anthropic: {
16
+ defaults: {
17
+ inputUsdPerMillion: 10,
18
+ outputUsdPerMillion: 10,
19
+ },
20
+ },
21
+ },
22
+ maxConcurrency: 10,
23
+ currentLoad: 1,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function makeRequest(model?: string): SerializedHttpRequest {
29
+ const payload = model ? { model } : { messages: [{ role: 'user', content: 'hi' }] };
30
+ return {
31
+ requestId: 'req-1',
32
+ method: 'POST',
33
+ path: '/v1/messages',
34
+ headers: { 'content-type': 'application/json' },
35
+ body: new TextEncoder().encode(JSON.stringify(payload)),
36
+ };
37
+ }
38
+
39
+ describe('LocalChatRouter', () => {
40
+ it('prefers lower-latency peer due to latency-prioritized weights', () => {
41
+ let now = 1_000_000;
42
+ const router = new LocalChatRouter({ now: () => now });
43
+
44
+ const fastPeer = makePeer({
45
+ peerId: '1'.repeat(64) as PeerInfo['peerId'],
46
+ lastSeen: now,
47
+ });
48
+ const slowPeer = makePeer({
49
+ peerId: '2'.repeat(64) as PeerInfo['peerId'],
50
+ lastSeen: now,
51
+ providerPricing: {
52
+ anthropic: {
53
+ defaults: { inputUsdPerMillion: 5, outputUsdPerMillion: 5 },
54
+ },
55
+ },
56
+ defaultInputUsdPerMillion: 5,
57
+ defaultOutputUsdPerMillion: 5,
58
+ });
59
+
60
+ // Record latency history
61
+ router.onResult(fastPeer, { success: true, latencyMs: 50, tokens: 100 });
62
+ router.onResult(slowPeer, { success: true, latencyMs: 500, tokens: 100 });
63
+
64
+ const selected = router.selectPeer(makeRequest(), [slowPeer, fastPeer]);
65
+ expect(selected?.peerId).toBe(fastPeer.peerId);
66
+ });
67
+
68
+ it('filters out peers below minimum reputation', () => {
69
+ const router = new LocalChatRouter({ minReputation: 70 });
70
+
71
+ const lowRep = makePeer({
72
+ peerId: '1'.repeat(64) as PeerInfo['peerId'],
73
+ reputationScore: 40,
74
+ trustScore: 40,
75
+ });
76
+ const highRep = makePeer({
77
+ peerId: '2'.repeat(64) as PeerInfo['peerId'],
78
+ reputationScore: 90,
79
+ trustScore: 90,
80
+ });
81
+
82
+ const selected = router.selectPeer(makeRequest(), [lowRep, highRep]);
83
+ expect(selected?.peerId).toBe(highRep.peerId);
84
+ });
85
+
86
+ it('returns null when no peers are available', () => {
87
+ const router = new LocalChatRouter();
88
+ expect(router.selectPeer(makeRequest(), [])).toBeNull();
89
+ });
90
+
91
+ it('puts peers on cooldown after failure threshold', () => {
92
+ let now = 1_000_000;
93
+ const router = new LocalChatRouter({
94
+ maxFailures: 2,
95
+ failureCooldownMs: 500,
96
+ now: () => now,
97
+ });
98
+
99
+ const flaky = makePeer({ peerId: '1'.repeat(64) as PeerInfo['peerId'], lastSeen: now });
100
+ const fallback = makePeer({ peerId: 'f'.repeat(64) as PeerInfo['peerId'], lastSeen: now });
101
+
102
+ router.onResult(flaky, { success: false, latencyMs: 300, tokens: 0 });
103
+ router.onResult(flaky, { success: false, latencyMs: 300, tokens: 0 });
104
+
105
+ expect(router.selectPeer(makeRequest(), [flaky, fallback])?.peerId).toBe(fallback.peerId);
106
+
107
+ now += 501;
108
+ // Selectable when no alternatives exist after cooldown expires.
109
+ expect(router.selectPeer(makeRequest(), [flaky])?.peerId).toBe(flaky.peerId);
110
+ });
111
+ });
package/src/router.ts ADDED
@@ -0,0 +1,191 @@
1
+ import type { Router, PeerInfo, SerializedHttpRequest } from '@antseed/node';
2
+ import {
3
+ scoreCandidates,
4
+ PeerMetricsTracker,
5
+ type TokenPricingUsdPerMillion,
6
+ type ScoringWeights,
7
+ } from '@antseed/router-core';
8
+
9
+ /** Latency-prioritized weights for desktop chat. */
10
+ const CHAT_WEIGHTS: ScoringWeights = {
11
+ price: 0.15,
12
+ latency: 0.35,
13
+ capacity: 0.20,
14
+ reputation: 0.15,
15
+ freshness: 0.10,
16
+ reliability: 0.05,
17
+ };
18
+
19
+ export interface LocalChatRouterConfig {
20
+ minReputation?: number;
21
+ maxFailures?: number;
22
+ failureCooldownMs?: number;
23
+ maxPeerStalenessMs?: number;
24
+ now?: () => number;
25
+ }
26
+
27
+ export class LocalChatRouter implements Router {
28
+ private readonly _minReputation: number;
29
+ private readonly _maxFailures: number;
30
+ private readonly _maxPeerStalenessMs: number;
31
+ private readonly _now: () => number;
32
+ private readonly _metrics: PeerMetricsTracker;
33
+
34
+ constructor(config?: LocalChatRouterConfig) {
35
+ this._minReputation = config?.minReputation ?? 50;
36
+ this._maxFailures = Math.max(1, config?.maxFailures ?? 3);
37
+ this._maxPeerStalenessMs = Math.max(1, config?.maxPeerStalenessMs ?? 300_000);
38
+ this._now = config?.now ?? (() => Date.now());
39
+ this._metrics = new PeerMetricsTracker({
40
+ maxFailures: this._maxFailures,
41
+ failureCooldownMs: Math.max(1, config?.failureCooldownMs ?? 30_000),
42
+ now: this._now,
43
+ });
44
+ }
45
+
46
+ selectPeer(req: SerializedHttpRequest, peers: PeerInfo[]): PeerInfo | null {
47
+ const now = this._now();
48
+ const requestedModel = this._extractRequestedModel(req);
49
+
50
+ const candidates: {
51
+ peer: PeerInfo;
52
+ provider: string;
53
+ providerRank: number;
54
+ offer: TokenPricingUsdPerMillion;
55
+ }[] = [];
56
+
57
+ for (const peer of peers) {
58
+ // Reputation filter
59
+ const reputation = this._effectiveReputation(peer);
60
+ if (reputation < this._minReputation) {
61
+ continue;
62
+ }
63
+
64
+ // Cooldown filter
65
+ if (this._metrics.isCoolingDown(peer.peerId)) {
66
+ continue;
67
+ }
68
+
69
+ // Use first available provider (no preference list for chat)
70
+ const provider = peer.providers[0];
71
+ if (!provider) {
72
+ continue;
73
+ }
74
+
75
+ const offer = this._resolvePeerOfferPrice(peer, provider, requestedModel);
76
+ if (!offer) {
77
+ continue;
78
+ }
79
+
80
+ candidates.push({
81
+ peer,
82
+ provider,
83
+ providerRank: Number.MAX_SAFE_INTEGER,
84
+ offer,
85
+ });
86
+ }
87
+
88
+ if (candidates.length === 0) return null;
89
+
90
+ if (candidates.length === 1) {
91
+ return candidates[0]!.peer;
92
+ }
93
+
94
+ // Delegate scoring to router-core with latency-prioritized weights
95
+ const scoringInput = candidates.map((c) => ({
96
+ peer: c.peer,
97
+ provider: c.provider,
98
+ providerRank: c.providerRank,
99
+ offer: c.offer,
100
+ metrics: this._metrics.getMetrics(c.peer.peerId),
101
+ }));
102
+
103
+ const scored = scoreCandidates(scoringInput, {
104
+ now,
105
+ medianLatency: this._metrics.getMedianLatency(),
106
+ maxPeerStalenessMs: this._maxPeerStalenessMs,
107
+ maxFailures: this._maxFailures,
108
+ weights: CHAT_WEIGHTS,
109
+ });
110
+
111
+ return scored[0]?.peer ?? null;
112
+ }
113
+
114
+ onResult(
115
+ peer: PeerInfo,
116
+ result: { success: boolean; latencyMs: number; tokens: number },
117
+ ): void {
118
+ this._metrics.recordResult(peer.peerId, {
119
+ success: result.success,
120
+ latencyMs: result.latencyMs,
121
+ });
122
+ }
123
+
124
+ private _effectiveReputation(p: PeerInfo): number {
125
+ if (p.onChainReputation !== undefined) {
126
+ return p.onChainReputation;
127
+ }
128
+ return p.trustScore ?? p.reputationScore ?? 0;
129
+ }
130
+
131
+ private _extractRequestedModel(req: SerializedHttpRequest): string | null {
132
+ const contentType = req.headers['content-type'] ?? req.headers['Content-Type'] ?? '';
133
+ if (!contentType.toLowerCase().includes('application/json')) {
134
+ return null;
135
+ }
136
+
137
+ try {
138
+ const parsed = JSON.parse(new TextDecoder().decode(req.body)) as unknown;
139
+ if (!parsed || typeof parsed !== 'object') {
140
+ return null;
141
+ }
142
+ const model = (parsed as Record<string, unknown>)['model'];
143
+ return typeof model === 'string' && model.trim().length > 0 ? model.trim() : null;
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ private _resolvePeerOfferPrice(
150
+ peer: PeerInfo,
151
+ provider: string,
152
+ model: string | null,
153
+ ): TokenPricingUsdPerMillion | null {
154
+ const providerPricing = peer.providerPricing?.[provider];
155
+
156
+ if (model) {
157
+ const modelSpecific = providerPricing?.models?.[model];
158
+ if (modelSpecific && this._isValidOffer(modelSpecific)) {
159
+ return modelSpecific;
160
+ }
161
+ }
162
+
163
+ const providerDefaults = providerPricing?.defaults;
164
+ if (providerDefaults && this._isValidOffer(providerDefaults)) {
165
+ return providerDefaults;
166
+ }
167
+
168
+ if (
169
+ this._isFiniteNonNegative(peer.defaultInputUsdPerMillion) &&
170
+ this._isFiniteNonNegative(peer.defaultOutputUsdPerMillion)
171
+ ) {
172
+ return {
173
+ inputUsdPerMillion: peer.defaultInputUsdPerMillion,
174
+ outputUsdPerMillion: peer.defaultOutputUsdPerMillion,
175
+ };
176
+ }
177
+
178
+ return null;
179
+ }
180
+
181
+ private _isFiniteNonNegative(value: number | undefined): value is number {
182
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
183
+ }
184
+
185
+ private _isValidOffer(offer: TokenPricingUsdPerMillion): boolean {
186
+ return (
187
+ this._isFiniteNonNegative(offer.inputUsdPerMillion) &&
188
+ this._isFiniteNonNegative(offer.outputUsdPerMillion)
189
+ );
190
+ }
191
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/**/*.test.ts"]
9
+ }