takomi 2.1.2 → 2.1.3
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/.pi/README.md +124 -124
- package/.pi/agents/architect.md +15 -15
- package/.pi/agents/coder.md +14 -14
- package/.pi/agents/designer.md +17 -17
- package/.pi/agents/orchestrator.md +22 -22
- package/.pi/agents/reviewer.md +16 -16
- package/.pi/extensions/oauth-router/README.md +125 -125
- package/.pi/extensions/oauth-router/commands.ts +380 -380
- package/.pi/extensions/oauth-router/config.ts +200 -200
- package/.pi/extensions/oauth-router/index.ts +41 -41
- package/.pi/extensions/oauth-router/oauth-flow.ts +154 -154
- package/.pi/extensions/oauth-router/oauth-store.ts +121 -121
- package/.pi/extensions/oauth-router/package.json +14 -14
- package/.pi/extensions/oauth-router/policies.ts +27 -27
- package/.pi/extensions/oauth-router/provider.ts +492 -492
- package/.pi/extensions/oauth-router/scripts/vibe-verify.py +98 -98
- package/.pi/extensions/oauth-router/state.ts +174 -174
- package/.pi/extensions/oauth-router/types.ts +153 -153
- package/.pi/extensions/takomi-runtime/command-text.ts +130 -130
- package/.pi/extensions/takomi-runtime/commands.ts +179 -179
- package/.pi/extensions/takomi-runtime/context-panel.ts +282 -282
- package/.pi/extensions/takomi-runtime/index.ts +1288 -1288
- package/.pi/extensions/takomi-runtime/profile.ts +114 -114
- package/.pi/extensions/takomi-runtime/routing-policy.ts +105 -105
- package/.pi/extensions/takomi-runtime/shared.ts +492 -492
- package/.pi/extensions/takomi-runtime/subagent-controller.ts +364 -364
- package/.pi/extensions/takomi-runtime/subagent-render.ts +501 -501
- package/.pi/extensions/takomi-runtime/subagent-types.ts +83 -83
- package/.pi/extensions/takomi-runtime/ui.ts +133 -133
- package/.pi/extensions/takomi-subagents/agent-aliases.ts +18 -18
- package/.pi/extensions/takomi-subagents/agents.ts +113 -113
- package/.pi/extensions/takomi-subagents/delegation-plan.ts +95 -95
- package/.pi/extensions/takomi-subagents/dispatch-helpers.ts +26 -26
- package/.pi/extensions/takomi-subagents/dispatch.ts +215 -215
- package/.pi/extensions/takomi-subagents/index.ts +75 -75
- package/.pi/extensions/takomi-subagents/live-updates.ts +83 -83
- package/.pi/extensions/takomi-subagents/native-render.ts +174 -174
- package/.pi/extensions/takomi-subagents/tool-runner.ts +209 -209
- package/.pi/themes/takomi-noir.json +81 -81
- package/package.json +59 -59
- package/src/doctor.js +87 -84
- package/src/pi-harness.js +355 -351
- package/src/pi-installer.js +193 -171
- package/src/pi-takomi-core/index.ts +4 -4
- package/src/pi-takomi-core/orchestration.ts +402 -402
- package/src/pi-takomi-core/routing.ts +93 -93
- package/src/pi-takomi-core/types.ts +173 -173
- package/src/pi-takomi-core/workflows.ts +299 -299
- package/src/skills-installer.js +101 -101
|
@@ -1,98 +1,98 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import argparse
|
|
5
|
-
import os
|
|
6
|
-
import shutil
|
|
7
|
-
import subprocess
|
|
8
|
-
import sys
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
|
|
11
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
-
REQUIRED = [
|
|
13
|
-
ROOT / "index.ts",
|
|
14
|
-
ROOT / "provider.ts",
|
|
15
|
-
ROOT / "commands.ts",
|
|
16
|
-
ROOT / "oauth-flow.ts",
|
|
17
|
-
ROOT / "oauth-store.ts",
|
|
18
|
-
ROOT / "state.ts",
|
|
19
|
-
ROOT / "policies.ts",
|
|
20
|
-
ROOT / "config.ts",
|
|
21
|
-
ROOT / "types.ts",
|
|
22
|
-
ROOT / "README.md",
|
|
23
|
-
ROOT / "docs" / "Project_Requirements.md",
|
|
24
|
-
ROOT / "docs" / "Coding_Guidelines.md",
|
|
25
|
-
ROOT / "docs" / "Builder_Prompt.md",
|
|
26
|
-
ROOT / "docs" / "issues" / "FR-001.md",
|
|
27
|
-
]
|
|
28
|
-
EXPECTED_MODELS = ["oauth-router", "gpt-4o", "gpt-4.1", "o4-mini", "gpt-5.4"]
|
|
29
|
-
PI_CANDIDATES = [
|
|
30
|
-
os.environ.get("PI_BIN"),
|
|
31
|
-
shutil.which("pi"),
|
|
32
|
-
shutil.which("pi.cmd"),
|
|
33
|
-
str(Path.home() / "AppData" / "Roaming" / "npm" / "pi"),
|
|
34
|
-
str(Path.home() / "AppData" / "Roaming" / "npm" / "pi.cmd"),
|
|
35
|
-
]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def resolve_pi() -> str | None:
|
|
39
|
-
for candidate in PI_CANDIDATES:
|
|
40
|
-
if not candidate:
|
|
41
|
-
continue
|
|
42
|
-
path = Path(candidate)
|
|
43
|
-
if path.exists():
|
|
44
|
-
return str(path)
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def run(cmd: list[str], timeout: int = 90) -> tuple[int, str, str]:
|
|
49
|
-
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
50
|
-
return proc.returncode, proc.stdout, proc.stderr
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def main() -> int:
|
|
54
|
-
parser = argparse.ArgumentParser()
|
|
55
|
-
parser.add_argument("--quick", action="store_true")
|
|
56
|
-
args = parser.parse_args()
|
|
57
|
-
|
|
58
|
-
failed = False
|
|
59
|
-
|
|
60
|
-
for path in REQUIRED:
|
|
61
|
-
if not path.exists():
|
|
62
|
-
print(f"[FAIL] missing: {path}")
|
|
63
|
-
failed = True
|
|
64
|
-
else:
|
|
65
|
-
print(f"[ OK ] found: {path.relative_to(ROOT)}")
|
|
66
|
-
|
|
67
|
-
if args.quick:
|
|
68
|
-
return 1 if failed else 0
|
|
69
|
-
|
|
70
|
-
pi_bin = resolve_pi()
|
|
71
|
-
if not pi_bin:
|
|
72
|
-
print("[FAIL] unable to locate `pi` binary")
|
|
73
|
-
return 1
|
|
74
|
-
|
|
75
|
-
try:
|
|
76
|
-
code, stdout, stderr = run([pi_bin, "--list-models"])
|
|
77
|
-
output = f"{stdout}\n{stderr}"
|
|
78
|
-
if code != 0:
|
|
79
|
-
print("[FAIL] `pi --list-models` failed")
|
|
80
|
-
print(output.strip())
|
|
81
|
-
failed = True
|
|
82
|
-
else:
|
|
83
|
-
print("[ OK ] `pi --list-models` executed")
|
|
84
|
-
for token in EXPECTED_MODELS:
|
|
85
|
-
if token not in output:
|
|
86
|
-
print(f"[FAIL] model token not found in list output: {token}")
|
|
87
|
-
failed = True
|
|
88
|
-
else:
|
|
89
|
-
print(f"[ OK ] model token present: {token}")
|
|
90
|
-
except Exception as exc:
|
|
91
|
-
print(f"[FAIL] unable to execute `pi --list-models`: {exc}")
|
|
92
|
-
failed = True
|
|
93
|
-
|
|
94
|
-
return 1 if failed else 0
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if __name__ == "__main__":
|
|
98
|
-
sys.exit(main())
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
REQUIRED = [
|
|
13
|
+
ROOT / "index.ts",
|
|
14
|
+
ROOT / "provider.ts",
|
|
15
|
+
ROOT / "commands.ts",
|
|
16
|
+
ROOT / "oauth-flow.ts",
|
|
17
|
+
ROOT / "oauth-store.ts",
|
|
18
|
+
ROOT / "state.ts",
|
|
19
|
+
ROOT / "policies.ts",
|
|
20
|
+
ROOT / "config.ts",
|
|
21
|
+
ROOT / "types.ts",
|
|
22
|
+
ROOT / "README.md",
|
|
23
|
+
ROOT / "docs" / "Project_Requirements.md",
|
|
24
|
+
ROOT / "docs" / "Coding_Guidelines.md",
|
|
25
|
+
ROOT / "docs" / "Builder_Prompt.md",
|
|
26
|
+
ROOT / "docs" / "issues" / "FR-001.md",
|
|
27
|
+
]
|
|
28
|
+
EXPECTED_MODELS = ["oauth-router", "gpt-4o", "gpt-4.1", "o4-mini", "gpt-5.4"]
|
|
29
|
+
PI_CANDIDATES = [
|
|
30
|
+
os.environ.get("PI_BIN"),
|
|
31
|
+
shutil.which("pi"),
|
|
32
|
+
shutil.which("pi.cmd"),
|
|
33
|
+
str(Path.home() / "AppData" / "Roaming" / "npm" / "pi"),
|
|
34
|
+
str(Path.home() / "AppData" / "Roaming" / "npm" / "pi.cmd"),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_pi() -> str | None:
|
|
39
|
+
for candidate in PI_CANDIDATES:
|
|
40
|
+
if not candidate:
|
|
41
|
+
continue
|
|
42
|
+
path = Path(candidate)
|
|
43
|
+
if path.exists():
|
|
44
|
+
return str(path)
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run(cmd: list[str], timeout: int = 90) -> tuple[int, str, str]:
|
|
49
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
50
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def main() -> int:
|
|
54
|
+
parser = argparse.ArgumentParser()
|
|
55
|
+
parser.add_argument("--quick", action="store_true")
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
|
|
58
|
+
failed = False
|
|
59
|
+
|
|
60
|
+
for path in REQUIRED:
|
|
61
|
+
if not path.exists():
|
|
62
|
+
print(f"[FAIL] missing: {path}")
|
|
63
|
+
failed = True
|
|
64
|
+
else:
|
|
65
|
+
print(f"[ OK ] found: {path.relative_to(ROOT)}")
|
|
66
|
+
|
|
67
|
+
if args.quick:
|
|
68
|
+
return 1 if failed else 0
|
|
69
|
+
|
|
70
|
+
pi_bin = resolve_pi()
|
|
71
|
+
if not pi_bin:
|
|
72
|
+
print("[FAIL] unable to locate `pi` binary")
|
|
73
|
+
return 1
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
code, stdout, stderr = run([pi_bin, "--list-models"])
|
|
77
|
+
output = f"{stdout}\n{stderr}"
|
|
78
|
+
if code != 0:
|
|
79
|
+
print("[FAIL] `pi --list-models` failed")
|
|
80
|
+
print(output.strip())
|
|
81
|
+
failed = True
|
|
82
|
+
else:
|
|
83
|
+
print("[ OK ] `pi --list-models` executed")
|
|
84
|
+
for token in EXPECTED_MODELS:
|
|
85
|
+
if token not in output:
|
|
86
|
+
print(f"[FAIL] model token not found in list output: {token}")
|
|
87
|
+
failed = True
|
|
88
|
+
else:
|
|
89
|
+
print(f"[ OK ] model token present: {token}")
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
print(f"[FAIL] unable to execute `pi --list-models`: {exc}")
|
|
92
|
+
failed = True
|
|
93
|
+
|
|
94
|
+
return 1 if failed else 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
sys.exit(main())
|
|
@@ -1,174 +1,174 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { STATE_PATH, writeJsonFile } from "./config.ts";
|
|
3
|
-
import type { RouterAccountState, RouterRuntimeState, RoutingPolicyName } from "./types.ts";
|
|
4
|
-
|
|
5
|
-
const DEFAULT_STATE: RouterRuntimeState = {
|
|
6
|
-
version: 1,
|
|
7
|
-
policy: "round-robin",
|
|
8
|
-
rrCursor: 0,
|
|
9
|
-
weightedCursor: 0,
|
|
10
|
-
accounts: {},
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
function normalizeAccountState(accountId: string, input?: Partial<RouterAccountState>): RouterAccountState {
|
|
14
|
-
return {
|
|
15
|
-
accountId,
|
|
16
|
-
authHealth: input?.authHealth === "invalid" ? "invalid" : "ok",
|
|
17
|
-
cooldownUntil: Number.isFinite(input?.cooldownUntil) ? input?.cooldownUntil : undefined,
|
|
18
|
-
penaltyUntil: Number.isFinite(input?.penaltyUntil) ? input?.penaltyUntil : undefined,
|
|
19
|
-
lastUsedAt: Number.isFinite(input?.lastUsedAt) ? input?.lastUsedAt : undefined,
|
|
20
|
-
lastTriedAt: Number.isFinite(input?.lastTriedAt) ? input?.lastTriedAt : undefined,
|
|
21
|
-
lastModel: typeof input?.lastModel === "string" ? input.lastModel : undefined,
|
|
22
|
-
lastStatus: Number.isFinite(input?.lastStatus) ? input?.lastStatus : undefined,
|
|
23
|
-
lastError: typeof input?.lastError === "string" ? input.lastError : undefined,
|
|
24
|
-
failures: Number.isFinite(input?.failures) ? input!.failures! : 0,
|
|
25
|
-
rateLimitCount: Number.isFinite(input?.rateLimitCount) ? input!.rateLimitCount! : 0,
|
|
26
|
-
authFailureCount: Number.isFinite(input?.authFailureCount) ? input!.authFailureCount! : 0,
|
|
27
|
-
successCount: Number.isFinite(input?.successCount) ? input!.successCount! : 0,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export class RouterStateStore {
|
|
32
|
-
private data: RouterRuntimeState;
|
|
33
|
-
|
|
34
|
-
constructor(initialPolicy: RoutingPolicyName) {
|
|
35
|
-
this.data = this.load(initialPolicy);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private load(initialPolicy: RoutingPolicyName): RouterRuntimeState {
|
|
39
|
-
if (!existsSync(STATE_PATH)) {
|
|
40
|
-
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
41
|
-
writeJsonFile(STATE_PATH, initial, true);
|
|
42
|
-
return initial;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8")) as Partial<RouterRuntimeState>;
|
|
47
|
-
const accounts = Object.fromEntries(
|
|
48
|
-
Object.entries(parsed.accounts ?? {}).map(([accountId, state]) => [accountId, normalizeAccountState(accountId, state)]),
|
|
49
|
-
);
|
|
50
|
-
return {
|
|
51
|
-
version: 1,
|
|
52
|
-
policy:
|
|
53
|
-
parsed.policy === "weighted-round-robin" || parsed.policy === "round-robin" ? parsed.policy : initialPolicy,
|
|
54
|
-
rrCursor: Number.isFinite(parsed.rrCursor) ? parsed.rrCursor : 0,
|
|
55
|
-
weightedCursor: Number.isFinite(parsed.weightedCursor) ? parsed.weightedCursor : 0,
|
|
56
|
-
accounts,
|
|
57
|
-
};
|
|
58
|
-
} catch {
|
|
59
|
-
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
60
|
-
writeJsonFile(STATE_PATH, initial, true);
|
|
61
|
-
return initial;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
private save() {
|
|
66
|
-
writeJsonFile(STATE_PATH, this.data, true);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
snapshot(): RouterRuntimeState {
|
|
70
|
-
return JSON.parse(JSON.stringify(this.data)) as RouterRuntimeState;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
getPolicy(defaultPolicy: RoutingPolicyName): RoutingPolicyName {
|
|
74
|
-
if (this.data.policy !== "round-robin" && this.data.policy !== "weighted-round-robin") {
|
|
75
|
-
this.data.policy = defaultPolicy;
|
|
76
|
-
this.save();
|
|
77
|
-
}
|
|
78
|
-
return this.data.policy;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
setPolicy(policy: RoutingPolicyName) {
|
|
82
|
-
this.data.policy = policy;
|
|
83
|
-
this.save();
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
getCursor(policy: RoutingPolicyName): number {
|
|
87
|
-
return policy === "weighted-round-robin" ? this.data.weightedCursor : this.data.rrCursor;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
advanceCursor(policy: RoutingPolicyName, next: number) {
|
|
91
|
-
if (policy === "weighted-round-robin") {
|
|
92
|
-
this.data.weightedCursor = next;
|
|
93
|
-
} else {
|
|
94
|
-
this.data.rrCursor = next;
|
|
95
|
-
}
|
|
96
|
-
this.save();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
ensureAccount(accountId: string): RouterAccountState {
|
|
100
|
-
if (!this.data.accounts[accountId]) {
|
|
101
|
-
this.data.accounts[accountId] = normalizeAccountState(accountId);
|
|
102
|
-
this.save();
|
|
103
|
-
}
|
|
104
|
-
return this.data.accounts[accountId]!;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
pruneAccountIds(validIds: string[]) {
|
|
108
|
-
const valid = new Set(validIds);
|
|
109
|
-
for (const id of Object.keys(this.data.accounts)) {
|
|
110
|
-
if (!valid.has(id)) delete this.data.accounts[id];
|
|
111
|
-
}
|
|
112
|
-
this.save();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
markAttempt(accountId: string, modelId: string) {
|
|
116
|
-
const account = this.ensureAccount(accountId);
|
|
117
|
-
account.lastTriedAt = Date.now();
|
|
118
|
-
account.lastModel = modelId;
|
|
119
|
-
this.save();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
markSuccess(accountId: string, status?: number) {
|
|
123
|
-
const account = this.ensureAccount(accountId);
|
|
124
|
-
account.authHealth = "ok";
|
|
125
|
-
account.lastUsedAt = Date.now();
|
|
126
|
-
account.lastStatus = status;
|
|
127
|
-
account.lastError = undefined;
|
|
128
|
-
account.penaltyUntil = undefined;
|
|
129
|
-
account.failures = 0;
|
|
130
|
-
account.successCount += 1;
|
|
131
|
-
this.save();
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
markRateLimit(accountId: string, retryAfterMs: number, status = 429, message = "Rate limited") {
|
|
135
|
-
const account = this.ensureAccount(accountId);
|
|
136
|
-
const now = Date.now();
|
|
137
|
-
account.lastStatus = status;
|
|
138
|
-
account.lastError = message;
|
|
139
|
-
account.cooldownUntil = now + Math.max(1_000, retryAfterMs);
|
|
140
|
-
account.failures += 1;
|
|
141
|
-
account.rateLimitCount += 1;
|
|
142
|
-
this.save();
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
markAuthFailure(accountId: string, status = 401, message = "Authentication failed") {
|
|
146
|
-
const account = this.ensureAccount(accountId);
|
|
147
|
-
account.authHealth = "invalid";
|
|
148
|
-
account.lastStatus = status;
|
|
149
|
-
account.lastError = message;
|
|
150
|
-
account.failures += 1;
|
|
151
|
-
account.authFailureCount += 1;
|
|
152
|
-
account.cooldownUntil = undefined;
|
|
153
|
-
account.penaltyUntil = undefined;
|
|
154
|
-
this.save();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
markTransientFailure(accountId: string, penaltyMs: number, status = 500, message = "Transient upstream failure") {
|
|
158
|
-
const account = this.ensureAccount(accountId);
|
|
159
|
-
account.lastStatus = status;
|
|
160
|
-
account.lastError = message;
|
|
161
|
-
account.penaltyUntil = Date.now() + Math.max(1_000, penaltyMs);
|
|
162
|
-
account.failures += 1;
|
|
163
|
-
this.save();
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
clearHealth(accountId: string) {
|
|
167
|
-
const account = this.ensureAccount(accountId);
|
|
168
|
-
account.authHealth = "ok";
|
|
169
|
-
account.cooldownUntil = undefined;
|
|
170
|
-
account.penaltyUntil = undefined;
|
|
171
|
-
account.lastError = undefined;
|
|
172
|
-
this.save();
|
|
173
|
-
}
|
|
174
|
-
}
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { STATE_PATH, writeJsonFile } from "./config.ts";
|
|
3
|
+
import type { RouterAccountState, RouterRuntimeState, RoutingPolicyName } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_STATE: RouterRuntimeState = {
|
|
6
|
+
version: 1,
|
|
7
|
+
policy: "round-robin",
|
|
8
|
+
rrCursor: 0,
|
|
9
|
+
weightedCursor: 0,
|
|
10
|
+
accounts: {},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function normalizeAccountState(accountId: string, input?: Partial<RouterAccountState>): RouterAccountState {
|
|
14
|
+
return {
|
|
15
|
+
accountId,
|
|
16
|
+
authHealth: input?.authHealth === "invalid" ? "invalid" : "ok",
|
|
17
|
+
cooldownUntil: Number.isFinite(input?.cooldownUntil) ? input?.cooldownUntil : undefined,
|
|
18
|
+
penaltyUntil: Number.isFinite(input?.penaltyUntil) ? input?.penaltyUntil : undefined,
|
|
19
|
+
lastUsedAt: Number.isFinite(input?.lastUsedAt) ? input?.lastUsedAt : undefined,
|
|
20
|
+
lastTriedAt: Number.isFinite(input?.lastTriedAt) ? input?.lastTriedAt : undefined,
|
|
21
|
+
lastModel: typeof input?.lastModel === "string" ? input.lastModel : undefined,
|
|
22
|
+
lastStatus: Number.isFinite(input?.lastStatus) ? input?.lastStatus : undefined,
|
|
23
|
+
lastError: typeof input?.lastError === "string" ? input.lastError : undefined,
|
|
24
|
+
failures: Number.isFinite(input?.failures) ? input!.failures! : 0,
|
|
25
|
+
rateLimitCount: Number.isFinite(input?.rateLimitCount) ? input!.rateLimitCount! : 0,
|
|
26
|
+
authFailureCount: Number.isFinite(input?.authFailureCount) ? input!.authFailureCount! : 0,
|
|
27
|
+
successCount: Number.isFinite(input?.successCount) ? input!.successCount! : 0,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class RouterStateStore {
|
|
32
|
+
private data: RouterRuntimeState;
|
|
33
|
+
|
|
34
|
+
constructor(initialPolicy: RoutingPolicyName) {
|
|
35
|
+
this.data = this.load(initialPolicy);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private load(initialPolicy: RoutingPolicyName): RouterRuntimeState {
|
|
39
|
+
if (!existsSync(STATE_PATH)) {
|
|
40
|
+
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
41
|
+
writeJsonFile(STATE_PATH, initial, true);
|
|
42
|
+
return initial;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8")) as Partial<RouterRuntimeState>;
|
|
47
|
+
const accounts = Object.fromEntries(
|
|
48
|
+
Object.entries(parsed.accounts ?? {}).map(([accountId, state]) => [accountId, normalizeAccountState(accountId, state)]),
|
|
49
|
+
);
|
|
50
|
+
return {
|
|
51
|
+
version: 1,
|
|
52
|
+
policy:
|
|
53
|
+
parsed.policy === "weighted-round-robin" || parsed.policy === "round-robin" ? parsed.policy : initialPolicy,
|
|
54
|
+
rrCursor: Number.isFinite(parsed.rrCursor) ? parsed.rrCursor : 0,
|
|
55
|
+
weightedCursor: Number.isFinite(parsed.weightedCursor) ? parsed.weightedCursor : 0,
|
|
56
|
+
accounts,
|
|
57
|
+
};
|
|
58
|
+
} catch {
|
|
59
|
+
const initial = { ...DEFAULT_STATE, policy: initialPolicy } satisfies RouterRuntimeState;
|
|
60
|
+
writeJsonFile(STATE_PATH, initial, true);
|
|
61
|
+
return initial;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private save() {
|
|
66
|
+
writeJsonFile(STATE_PATH, this.data, true);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
snapshot(): RouterRuntimeState {
|
|
70
|
+
return JSON.parse(JSON.stringify(this.data)) as RouterRuntimeState;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
getPolicy(defaultPolicy: RoutingPolicyName): RoutingPolicyName {
|
|
74
|
+
if (this.data.policy !== "round-robin" && this.data.policy !== "weighted-round-robin") {
|
|
75
|
+
this.data.policy = defaultPolicy;
|
|
76
|
+
this.save();
|
|
77
|
+
}
|
|
78
|
+
return this.data.policy;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
setPolicy(policy: RoutingPolicyName) {
|
|
82
|
+
this.data.policy = policy;
|
|
83
|
+
this.save();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
getCursor(policy: RoutingPolicyName): number {
|
|
87
|
+
return policy === "weighted-round-robin" ? this.data.weightedCursor : this.data.rrCursor;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
advanceCursor(policy: RoutingPolicyName, next: number) {
|
|
91
|
+
if (policy === "weighted-round-robin") {
|
|
92
|
+
this.data.weightedCursor = next;
|
|
93
|
+
} else {
|
|
94
|
+
this.data.rrCursor = next;
|
|
95
|
+
}
|
|
96
|
+
this.save();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ensureAccount(accountId: string): RouterAccountState {
|
|
100
|
+
if (!this.data.accounts[accountId]) {
|
|
101
|
+
this.data.accounts[accountId] = normalizeAccountState(accountId);
|
|
102
|
+
this.save();
|
|
103
|
+
}
|
|
104
|
+
return this.data.accounts[accountId]!;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pruneAccountIds(validIds: string[]) {
|
|
108
|
+
const valid = new Set(validIds);
|
|
109
|
+
for (const id of Object.keys(this.data.accounts)) {
|
|
110
|
+
if (!valid.has(id)) delete this.data.accounts[id];
|
|
111
|
+
}
|
|
112
|
+
this.save();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
markAttempt(accountId: string, modelId: string) {
|
|
116
|
+
const account = this.ensureAccount(accountId);
|
|
117
|
+
account.lastTriedAt = Date.now();
|
|
118
|
+
account.lastModel = modelId;
|
|
119
|
+
this.save();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
markSuccess(accountId: string, status?: number) {
|
|
123
|
+
const account = this.ensureAccount(accountId);
|
|
124
|
+
account.authHealth = "ok";
|
|
125
|
+
account.lastUsedAt = Date.now();
|
|
126
|
+
account.lastStatus = status;
|
|
127
|
+
account.lastError = undefined;
|
|
128
|
+
account.penaltyUntil = undefined;
|
|
129
|
+
account.failures = 0;
|
|
130
|
+
account.successCount += 1;
|
|
131
|
+
this.save();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
markRateLimit(accountId: string, retryAfterMs: number, status = 429, message = "Rate limited") {
|
|
135
|
+
const account = this.ensureAccount(accountId);
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
account.lastStatus = status;
|
|
138
|
+
account.lastError = message;
|
|
139
|
+
account.cooldownUntil = now + Math.max(1_000, retryAfterMs);
|
|
140
|
+
account.failures += 1;
|
|
141
|
+
account.rateLimitCount += 1;
|
|
142
|
+
this.save();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
markAuthFailure(accountId: string, status = 401, message = "Authentication failed") {
|
|
146
|
+
const account = this.ensureAccount(accountId);
|
|
147
|
+
account.authHealth = "invalid";
|
|
148
|
+
account.lastStatus = status;
|
|
149
|
+
account.lastError = message;
|
|
150
|
+
account.failures += 1;
|
|
151
|
+
account.authFailureCount += 1;
|
|
152
|
+
account.cooldownUntil = undefined;
|
|
153
|
+
account.penaltyUntil = undefined;
|
|
154
|
+
this.save();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
markTransientFailure(accountId: string, penaltyMs: number, status = 500, message = "Transient upstream failure") {
|
|
158
|
+
const account = this.ensureAccount(accountId);
|
|
159
|
+
account.lastStatus = status;
|
|
160
|
+
account.lastError = message;
|
|
161
|
+
account.penaltyUntil = Date.now() + Math.max(1_000, penaltyMs);
|
|
162
|
+
account.failures += 1;
|
|
163
|
+
this.save();
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
clearHealth(accountId: string) {
|
|
167
|
+
const account = this.ensureAccount(accountId);
|
|
168
|
+
account.authHealth = "ok";
|
|
169
|
+
account.cooldownUntil = undefined;
|
|
170
|
+
account.penaltyUntil = undefined;
|
|
171
|
+
account.lastError = undefined;
|
|
172
|
+
this.save();
|
|
173
|
+
}
|
|
174
|
+
}
|