@jameslovespancakes/pi-plus 1.0.11 → 1.0.13
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 +67 -22
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +9 -4
- package/src/core/accounts/oauth-pool.ts +137 -0
- package/src/core/accounts/registry.ts +12 -25
- package/src/core/accounts/routing.ts +144 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/identity.ts +54 -0
- package/src/core/anthropic/oauth.ts +3 -0
- package/src/core/anthropic/quota.ts +232 -78
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +11 -10
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +46 -8
- package/src/core/codex/store.ts +29 -16
- package/src/core/config.ts +16 -17
- package/src/core/store.ts +13 -12
- package/src/domains/agents/format.ts +97 -0
- package/src/domains/agents/index.ts +58 -47
- package/src/domains/compact/archive.ts +140 -0
- package/src/domains/compact/chunking.ts +166 -0
- package/src/domains/compact/index.ts +452 -0
- package/src/domains/compact/jev.ts +242 -0
- package/src/domains/compact/policy.ts +255 -0
- package/src/domains/compact/types.ts +79 -0
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +61 -40
- package/src/domains/subscriptions/accounts.ts +53 -36
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +79 -103
- package/src/domains/subscriptions/providers/anthropic.ts +42 -22
- package/src/domains/subscriptions/providers/codex.ts +146 -113
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +325 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +177 -0
- package/src/domains/workflows/runtime/agent-failure.ts +14 -0
- package/src/domains/workflows/runtime/agent-limits.ts +66 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +225 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +79 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +579 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +28 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +435 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +164 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +636 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +615 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
- package/src/ui/usage-bars.ts +1 -2
- package/src/core/codex/oauth.ts +0 -129
|
@@ -1,17 +1,7 @@
|
|
|
1
|
+
import { selectRoutingCandidate, type AccountQuotaState } from "../accounts/routing.ts";
|
|
1
2
|
import { MAIN_ACCOUNT_ID, type Account, type QuotaSnapshot, type QuotaWindow, type RoutingMode } from "./store.ts";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
* Account selection and sticky session assignment.
|
|
5
|
-
*
|
|
6
|
-
* Two jobs:
|
|
7
|
-
* 1. score each candidate by how much work it can absorb
|
|
8
|
-
* 2. keep a session on one account, because Anthropic's prompt cache is
|
|
9
|
-
* per-account and migrating throws it away
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** How long each window takes to refill completely. */
|
|
13
|
-
export const WINDOW_HOURS = { five_hour: 5, seven_day: 168, scoped: 168 } as const;
|
|
14
|
-
export type WindowKey = keyof typeof WINDOW_HOURS;
|
|
4
|
+
/** Claude quota normalization and account selection. */
|
|
15
5
|
|
|
16
6
|
export type Family = "fable" | "opus" | "general";
|
|
17
7
|
|
|
@@ -28,25 +18,10 @@ export interface Candidate {
|
|
|
28
18
|
quota?: QuotaSnapshot;
|
|
29
19
|
/** Config order; `main` is 0, so it wins ties. */
|
|
30
20
|
order: number;
|
|
21
|
+
lastUsed?: number;
|
|
31
22
|
account?: Account;
|
|
32
23
|
}
|
|
33
24
|
|
|
34
|
-
/**
|
|
35
|
-
* Hours of wall-clock recovery per point of quota spent.
|
|
36
|
-
*
|
|
37
|
-
* Expressed against the window's own length rather than its distance to reset:
|
|
38
|
-
* a 5h window regenerates 34x faster than a 7d one, so a point spent there is
|
|
39
|
-
* far cheaper. Returns Infinity at or below zero, which collapses the account's
|
|
40
|
-
* weight to 0 and removes it from selection.
|
|
41
|
-
*/
|
|
42
|
-
export function recoveryCost(window: QuotaWindow | undefined, key: WindowKey, reserve = 0): number {
|
|
43
|
-
const remaining = window?.remainingPercent;
|
|
44
|
-
if (!Number.isFinite(remaining)) return Infinity;
|
|
45
|
-
const spendable = Math.max(0, (remaining as number) - reserve);
|
|
46
|
-
if (spendable <= 0) return Infinity;
|
|
47
|
-
return WINDOW_HOURS[key] / spendable;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
25
|
/** Scoped windows are model-specific; match the one governing this model. */
|
|
51
26
|
export function scopedWindowFor(quota: QuotaSnapshot | undefined, modelId?: string): QuotaWindow | undefined {
|
|
52
27
|
const scoped = quota?.scoped;
|
|
@@ -59,110 +34,60 @@ export function scopedWindowFor(quota: QuotaSnapshot | undefined, modelId?: stri
|
|
|
59
34
|
}) ?? scoped[0];
|
|
60
35
|
}
|
|
61
36
|
|
|
62
|
-
/**
|
|
63
|
-
* Capacity score. Higher is better, 0 means unusable.
|
|
64
|
-
*
|
|
65
|
-
* Costs are summed and inverted rather than taking the minimum window, so a
|
|
66
|
-
* cheap fast-refilling window still contributes instead of being masked by a
|
|
67
|
-
* slow one. Any window at zero yields Infinity and zeroes the account, which
|
|
68
|
-
* preserves the hard exclusion rule.
|
|
69
|
-
*/
|
|
70
|
-
export function candidateWeight(candidate: Candidate, family: Family, modelId?: string, reserve = 0): number {
|
|
71
|
-
const quota = candidate.quota;
|
|
72
|
-
if (!quota) return 0;
|
|
73
|
-
|
|
74
|
-
const costs = [
|
|
75
|
-
recoveryCost(quota.five_hour, "five_hour", reserve),
|
|
76
|
-
recoveryCost(quota.seven_day, "seven_day", reserve),
|
|
77
|
-
];
|
|
78
|
-
|
|
79
|
-
// Fable requests are additionally gated by their scoped window.
|
|
80
|
-
if (family === "fable") {
|
|
81
|
-
const scoped = scopedWindowFor(quota, modelId);
|
|
82
|
-
if (scoped) costs.push(recoveryCost(scoped, "scoped", reserve));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const total = costs.reduce((sum, c) => sum + c, 0);
|
|
86
|
-
return Number.isFinite(total) && total > 0 ? 1 / total : 0;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface Assignment {
|
|
90
|
-
accountId: string;
|
|
91
|
-
family: Family;
|
|
92
|
-
assignedAt: number;
|
|
93
|
-
lastSeenAt: number;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
37
|
export interface SelectInput {
|
|
97
38
|
candidates: Candidate[];
|
|
98
39
|
family: Family;
|
|
99
40
|
modelId?: string;
|
|
100
41
|
mode: RoutingMode;
|
|
101
|
-
/** Existing sticky assignment for this session, if any. */
|
|
102
|
-
assignment?: Assignment;
|
|
103
42
|
now?: number;
|
|
104
43
|
}
|
|
105
44
|
|
|
106
45
|
export interface Selection {
|
|
107
46
|
candidate: Candidate;
|
|
108
|
-
reason: "
|
|
47
|
+
reason: "quota-aware" | "sequential" | "only";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function routingQuota(candidate: Candidate, family: Family, modelId?: string): AccountQuotaState | undefined {
|
|
51
|
+
const quota = candidate.quota;
|
|
52
|
+
if (!quota) return undefined;
|
|
53
|
+
const windows: (QuotaWindow | undefined)[] = [quota.five_hour, quota.seven_day];
|
|
54
|
+
if (family === "fable") windows.push(scopedWindowFor(quota, modelId));
|
|
55
|
+
const measured = windows.filter((window) => window && Number.isFinite(window.remainingPercent));
|
|
56
|
+
if (measured.length === 0) return undefined;
|
|
57
|
+
|
|
58
|
+
const remainingPercent = Math.min(...measured.map((window) => window!.remainingPercent!));
|
|
59
|
+
const resetTimes = measured
|
|
60
|
+
.filter((window) => (window!.remainingPercent ?? 0) <= 0 && window!.resetsAt)
|
|
61
|
+
.map((window) => Date.parse(window!.resetsAt!))
|
|
62
|
+
.filter(Number.isFinite);
|
|
63
|
+
const resetAt = resetTimes.length > 0 ? Math.min(...resetTimes) : undefined;
|
|
64
|
+
return {
|
|
65
|
+
remainingPercent,
|
|
66
|
+
resetAt,
|
|
67
|
+
checkedAt: quota.checkedAt ?? Date.now(),
|
|
68
|
+
blockedUntil: remainingPercent <= 0 ? resetAt ?? Number.POSITIVE_INFINITY : undefined,
|
|
69
|
+
};
|
|
109
70
|
}
|
|
110
71
|
|
|
111
|
-
/**
|
|
112
|
-
* Picks an account.
|
|
113
|
-
*
|
|
114
|
-
* `main-first` and `fallback-first` are simple orderings. `sticky-balanced`
|
|
115
|
-
* keeps the session where it is while that account is still viable, and
|
|
116
|
-
* otherwise takes the highest-weight candidate.
|
|
117
|
-
*/
|
|
72
|
+
/** Picks an account using the shared sequential or quota-aware policy. */
|
|
118
73
|
export function selectAccount(input: SelectInput): Selection | undefined {
|
|
119
|
-
const usable = input.candidates.filter((
|
|
74
|
+
const usable = input.candidates.filter((candidate) => candidate.access);
|
|
120
75
|
if (usable.length === 0) return undefined;
|
|
121
76
|
if (usable.length === 1) return { candidate: usable[0], reason: "only" };
|
|
122
77
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
// Stay put while the assigned account can still serve: migrating discards
|
|
136
|
-
// the prompt cache, which costs more than a slightly better weight gains.
|
|
137
|
-
if (input.assignment) {
|
|
138
|
-
const held = weighted.find((e) => e.candidate.id === input.assignment!.accountId);
|
|
139
|
-
if (held) return { candidate: held.candidate, reason: "sticky" };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
weighted.sort((a, b) =>
|
|
143
|
-
b.weight - a.weight
|
|
144
|
-
|| a.candidate.order - b.candidate.order
|
|
145
|
-
|| a.candidate.id.localeCompare(b.candidate.id));
|
|
146
|
-
return { candidate: weighted[0].candidate, reason: "weighted" };
|
|
78
|
+
const selected = selectRoutingCandidate(
|
|
79
|
+
usable.map((candidate) => ({
|
|
80
|
+
id: candidate.id,
|
|
81
|
+
order: candidate.order,
|
|
82
|
+
lastUsed: candidate.lastUsed ?? candidate.account?.lastUsed ?? 0,
|
|
83
|
+
quota: routingQuota(candidate, input.family, input.modelId),
|
|
84
|
+
value: candidate,
|
|
85
|
+
})),
|
|
86
|
+
input.mode,
|
|
87
|
+
input.now,
|
|
88
|
+
);
|
|
89
|
+
return selected ? { candidate: selected.value, reason: input.mode } : undefined;
|
|
147
90
|
}
|
|
148
91
|
|
|
149
|
-
/** Seconds until the soonest window that would unblock this model resets. */
|
|
150
|
-
export function retryAfterSeconds(candidates: Candidate[], family: Family, modelId?: string, now = Date.now()): number {
|
|
151
|
-
const resets: number[] = [];
|
|
152
|
-
for (const candidate of candidates) {
|
|
153
|
-
const quota = candidate.quota;
|
|
154
|
-
if (!quota) continue;
|
|
155
|
-
const windows: (QuotaWindow | undefined)[] = [quota.five_hour, quota.seven_day];
|
|
156
|
-
if (family === "fable") windows.push(scopedWindowFor(quota, modelId));
|
|
157
|
-
for (const w of windows) {
|
|
158
|
-
if (!w?.resetsAt) continue;
|
|
159
|
-
if ((w.remainingPercent ?? 0) > 0) continue;
|
|
160
|
-
const at = Date.parse(w.resetsAt);
|
|
161
|
-
if (Number.isFinite(at) && at > now) resets.push(at);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
if (resets.length === 0) return 60;
|
|
165
|
-
return Math.max(1, Math.ceil((Math.min(...resets) - now) / 1000));
|
|
166
|
-
}
|
|
167
92
|
|
|
168
93
|
export { MAIN_ACCOUNT_ID };
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
+
import { normalizeRoutingMode, type AccountRoutingMode } from "../accounts/routing.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Anthropic account store.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* is worth preserving: durable identity in `anthropic-auth.json`, live secrets
|
|
11
|
-
* and volatile quota in `anthropic-auth-state.json`.
|
|
9
|
+
* Keeps durable identity in `anthropic-auth.json` and live secrets and quota
|
|
10
|
+
* in `anthropic-auth-state.json`.
|
|
12
11
|
*
|
|
13
12
|
* anthropic-auth.json id, label, type, enabled, addedAt, routing
|
|
14
13
|
* anthropic-auth-state.json access, refresh, expires, quota, lastUsed, ...
|
|
@@ -49,12 +48,15 @@ export interface Account {
|
|
|
49
48
|
expires?: number;
|
|
50
49
|
lastUsed?: number;
|
|
51
50
|
lastRefreshedAt?: number;
|
|
51
|
+
lastRefreshError?: string;
|
|
52
|
+
/** Stable provider account UUID; survives OAuth token rotation. */
|
|
53
|
+
identity?: string;
|
|
52
54
|
authLineageId?: string;
|
|
53
55
|
quota?: QuotaSnapshot;
|
|
54
56
|
apiKey?: string;
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
export type RoutingMode =
|
|
59
|
+
export type RoutingMode = AccountRoutingMode;
|
|
58
60
|
|
|
59
61
|
export interface Storage {
|
|
60
62
|
version?: number;
|
|
@@ -69,7 +71,7 @@ export interface Storage {
|
|
|
69
71
|
const CONFIG_FIELDS = ["id", "label", "type", "enabled", "addedAt", "baseURL", "authHeader"] as const;
|
|
70
72
|
/** Fields that belong in the state file. */
|
|
71
73
|
const STATE_FIELDS = [
|
|
72
|
-
"authLineageId", "access", "refresh", "expires", "lastUsed",
|
|
74
|
+
"authLineageId", "identity", "access", "refresh", "expires", "lastUsed",
|
|
73
75
|
"lastRefreshedAt", "lastRefreshError", "lastQuotaRefreshError", "quota", "profile", "prime", "apiKey",
|
|
74
76
|
] as const;
|
|
75
77
|
|
|
@@ -136,7 +138,7 @@ const pick = <T extends object>(source: any, keys: readonly string[]): T =>
|
|
|
136
138
|
Object.fromEntries(keys.filter((k) => source?.[k] !== undefined).map((k) => [k, source[k]])) as T;
|
|
137
139
|
|
|
138
140
|
export function emptyStorage(): Storage {
|
|
139
|
-
return { version: 1, accounts: [], routing: { mode: "
|
|
141
|
+
return { version: 1, accounts: [], routing: { mode: "sequential" } };
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/**
|
|
@@ -208,8 +210,7 @@ export const isOAuthAccount = (a: Account): boolean => a.type === "oauth" && !!a
|
|
|
208
210
|
export const isUsable = (a: Account): boolean => a.enabled !== false && isOAuthAccount(a);
|
|
209
211
|
|
|
210
212
|
export function getRoutingMode(storage: Storage | undefined): RoutingMode {
|
|
211
|
-
|
|
212
|
-
return mode === "sticky-balanced" || mode === "fallback-first" || mode === "main-first" ? mode : "main-first";
|
|
213
|
+
return normalizeRoutingMode(storage?.routing?.mode as string | undefined);
|
|
213
214
|
}
|
|
214
215
|
|
|
215
216
|
export function setRoutingMode(mode: RoutingMode, config = configPath()): Storage {
|
|
@@ -228,7 +229,7 @@ export function setRoutingMode(mode: RoutingMode, config = configPath()): Storag
|
|
|
228
229
|
* the state file is normal.
|
|
229
230
|
*
|
|
230
231
|
* This is easy to mistake for stale data and delete. Doing so breaks
|
|
231
|
-
*
|
|
232
|
+
* sequential routing and orphans every routing assignment targeting `main`.
|
|
232
233
|
*/
|
|
233
234
|
export const MAIN_ACCOUNT_ID = "main";
|
|
234
235
|
|
|
@@ -82,7 +82,15 @@ interface QualityStore {
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
let cache: QualityStore | undefined;
|
|
85
|
+
let cachedRecords: QualityRecord[] = [];
|
|
85
86
|
let inFlight: Promise<string[]> | undefined;
|
|
87
|
+
const lookupCache = new Map<string, QualityLookup>();
|
|
88
|
+
|
|
89
|
+
function setCache(next: QualityStore): void {
|
|
90
|
+
cache = next;
|
|
91
|
+
cachedRecords = Object.values(next.records);
|
|
92
|
+
lookupCache.clear();
|
|
93
|
+
}
|
|
86
94
|
|
|
87
95
|
function agentDir(): string {
|
|
88
96
|
return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
@@ -119,18 +127,18 @@ function loadCache(): void {
|
|
|
119
127
|
const parsed = JSON.parse(readFileSync(cachePath(), "utf8")) as QualityStore & { fetchedAt?: number };
|
|
120
128
|
if (!parsed?.records) return;
|
|
121
129
|
// Migrate the pre-store format, which only had `fetchedAt`.
|
|
122
|
-
|
|
130
|
+
setCache({
|
|
123
131
|
records: parsed.records,
|
|
124
132
|
checkedAt: parsed.checkedAt ?? parsed.fetchedAt,
|
|
125
133
|
lastModified: parsed.lastModified,
|
|
126
134
|
etag: parsed.etag,
|
|
127
135
|
source: parsed.source ?? "artificial-analysis",
|
|
128
|
-
};
|
|
136
|
+
});
|
|
129
137
|
} catch { /* first run */ }
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
function persist(next: QualityStore): void {
|
|
133
|
-
|
|
141
|
+
setCache(next);
|
|
134
142
|
try {
|
|
135
143
|
writeFileSync(cachePath(), JSON.stringify(next), "utf8");
|
|
136
144
|
} catch { /* cache write is best effort */ }
|
|
@@ -289,26 +297,36 @@ export interface QualityLookup {
|
|
|
289
297
|
*/
|
|
290
298
|
export function qualityFor(modelId: string, thinkingLevel?: string): QualityLookup {
|
|
291
299
|
loadCache();
|
|
300
|
+
const key = `${modelId}\0${thinkingLevel ?? ""}`;
|
|
301
|
+
const cached = lookupCache.get(key);
|
|
302
|
+
if (cached) return cached;
|
|
303
|
+
|
|
292
304
|
const records = cache?.records ?? {};
|
|
293
305
|
const base = normalizeSlug(modelId);
|
|
294
|
-
|
|
295
306
|
const levelled = thinkingLevel && thinkingLevel !== "off" ? records[`${base}-${thinkingLevel}`] : undefined;
|
|
296
|
-
if (levelled) return { record: levelled, efficiency: costEfficiency(levelled), confidence: "measured", basis: levelled.slug };
|
|
297
|
-
|
|
298
307
|
const exact = records[base];
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
308
|
+
let result: QualityLookup;
|
|
309
|
+
|
|
310
|
+
if (levelled) {
|
|
311
|
+
result = { record: levelled, efficiency: costEfficiency(levelled), confidence: "measured", basis: levelled.slug };
|
|
312
|
+
} else if (exact) {
|
|
313
|
+
result = { record: exact, efficiency: costEfficiency(exact), confidence: "measured", basis: exact.slug };
|
|
314
|
+
} else {
|
|
315
|
+
let best: QualityRecord | undefined;
|
|
316
|
+
for (const record of cachedRecords) {
|
|
317
|
+
if (!base.includes(record.slug) && !record.slug.includes(base)) continue;
|
|
318
|
+
if (!best || record.slug.length > best.slug.length) best = record;
|
|
319
|
+
}
|
|
320
|
+
result = best
|
|
321
|
+
? { record: best, efficiency: costEfficiency(best), confidence: "inferred", basis: `nearest match ${best.slug}` }
|
|
322
|
+
: { efficiency: {}, confidence: "unrated", basis: "no Artificial Analysis entry" };
|
|
305
323
|
}
|
|
306
|
-
if (best) return { record: best, efficiency: costEfficiency(best), confidence: "inferred", basis: `nearest match ${best.slug}` };
|
|
307
324
|
|
|
308
|
-
|
|
325
|
+
lookupCache.set(key, result);
|
|
326
|
+
return result;
|
|
309
327
|
}
|
|
310
328
|
|
|
311
329
|
export function allRecords(): QualityRecord[] {
|
|
312
330
|
loadCache();
|
|
313
|
-
return
|
|
331
|
+
return [...cachedRecords];
|
|
314
332
|
}
|
package/src/core/codex/quota.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { QuotaSnapshot } from "../anthropic/store.ts";
|
|
2
|
-
import { loadCodexAccounts, saveCodexAccount } from "./store.ts";
|
|
2
|
+
import { MAIN_ACCOUNT_ID, loadCodexAccounts, saveCodexAccount, saveCodexAccounts } from "./store.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Codex quota, read from response headers.
|
|
@@ -73,16 +73,54 @@ export function parseCodexQuotaHeaders(
|
|
|
73
73
|
} as QuotaSnapshot;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* True when two snapshots carry the same reading. `checkedAt` moves on every
|
|
78
|
+
* response, so comparing it would defeat the whole check.
|
|
79
|
+
*/
|
|
80
|
+
function sameQuota(left: QuotaSnapshot | undefined, right: QuotaSnapshot | undefined): boolean {
|
|
81
|
+
if (!left || !right) return false;
|
|
82
|
+
const compare = (snapshot: QuotaSnapshot) => {
|
|
83
|
+
const window = (value: QuotaSnapshot["five_hour"]) =>
|
|
84
|
+
value ? { used: value.usedPercent, resets: value.resetsAt, minutes: (value as any).windowMinutes } : undefined;
|
|
85
|
+
return JSON.stringify({
|
|
86
|
+
five_hour: window(snapshot.five_hour),
|
|
87
|
+
seven_day: window(snapshot.seven_day),
|
|
88
|
+
plan: (snapshot as any).plan,
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
return compare(left) === compare(right);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Merges a header-derived snapshot into the stored account.
|
|
96
|
+
*
|
|
97
|
+
* Runs from `onResponse` on every single reply, so it is deliberately quiet
|
|
98
|
+
* and cheap: unchanged readings never touch the disk, and a failed write is
|
|
99
|
+
* swallowed. Throwing here would reject the awaited `onResponse` inside the
|
|
100
|
+
* provider and kill an in-flight stream — quota telemetry must never do that.
|
|
101
|
+
*/
|
|
77
102
|
export function applyCodexQuotaHeaders(
|
|
78
103
|
accountId: string,
|
|
79
104
|
headers: Record<string, unknown> | undefined,
|
|
80
105
|
now = Date.now(),
|
|
81
106
|
): boolean {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
107
|
+
try {
|
|
108
|
+
const quota = parseCodexQuotaHeaders(headers, now);
|
|
109
|
+
if (!quota) return false;
|
|
110
|
+
const storage = loadCodexAccounts();
|
|
111
|
+
if (accountId === MAIN_ACCOUNT_ID) {
|
|
112
|
+
if (sameQuota(storage.main?.quota, quota)) return false;
|
|
113
|
+
storage.main = { ...storage.main, quota };
|
|
114
|
+
saveCodexAccounts(storage);
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
const account = storage.accounts.find((candidate) => candidate.id === accountId);
|
|
118
|
+
if (!account) return false;
|
|
119
|
+
if (sameQuota(account.quota, quota)) return false;
|
|
120
|
+
saveCodexAccount({ ...account, quota });
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
// Best effort: a quota write must never break the request that produced it.
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
88
126
|
}
|
package/src/core/codex/store.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { readJson, writeJson } from "../store.ts";
|
|
4
4
|
import type { QuotaSnapshot } from "../anthropic/store.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -26,38 +26,51 @@ export interface CodexAccount {
|
|
|
26
26
|
plan?: string;
|
|
27
27
|
addedAt?: number;
|
|
28
28
|
lastRefreshedAt?: number;
|
|
29
|
+
lastUsed?: number;
|
|
30
|
+
blockedUntil?: number;
|
|
29
31
|
quota?: QuotaSnapshot;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
export interface CodexStorage {
|
|
33
35
|
accounts: CodexAccount[];
|
|
36
|
+
main?: { quota?: QuotaSnapshot; lastUsed?: number; blockedUntil?: number };
|
|
34
37
|
routing?: { mode?: string };
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export const MAIN_ACCOUNT_ID = "main";
|
|
38
41
|
|
|
39
|
-
function
|
|
40
|
-
return join(homedir(), ".pi", "agent", "codex-accounts.json");
|
|
42
|
+
export function codexAccountsPath(): string {
|
|
43
|
+
return process.env.PI_PLUS_CODEX_ACCOUNTS_FILE ?? join(homedir(), ".pi", "agent", "codex-accounts.json");
|
|
41
44
|
}
|
|
42
45
|
|
|
43
|
-
export function loadCodexAccounts(path =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
46
|
+
export function loadCodexAccounts(path = codexAccountsPath()): CodexStorage {
|
|
47
|
+
const raw = readJson<Partial<CodexStorage>>(path, {});
|
|
48
|
+
return {
|
|
49
|
+
accounts: Array.isArray(raw?.accounts) ? raw.accounts : [],
|
|
50
|
+
main: raw?.main,
|
|
51
|
+
routing: raw?.routing,
|
|
52
|
+
};
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Atomic write (temp file + rename), matching every other store in the repo.
|
|
57
|
+
*
|
|
58
|
+
* A truncate-in-place `writeFileSync` here let a concurrent reader observe a
|
|
59
|
+
* half-written file; `loadCodexAccounts` swallows the parse error and returns
|
|
60
|
+
* an empty account list, so the next save persisted that emptiness and the
|
|
61
|
+
* user's accounts disappeared. On Windows the same overlap surfaces as
|
|
62
|
+
* EPERM/EBUSY. Callers that mutate credentials want to hear about a failed
|
|
63
|
+
* write, so this still throws — only the per-response quota path swallows it.
|
|
64
|
+
*/
|
|
65
|
+
export function saveCodexAccounts(storage: CodexStorage, path = codexAccountsPath()): void {
|
|
55
66
|
// 0600: these are live OAuth credentials.
|
|
56
|
-
|
|
67
|
+
if (!writeJson(path, storage, true, 0o600)) {
|
|
68
|
+
throw new Error(`Could not write Codex accounts to ${path}`);
|
|
69
|
+
}
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
/** Inserts or replaces one account, leaving the others untouched. */
|
|
60
|
-
export function saveCodexAccount(account: CodexAccount, path =
|
|
73
|
+
export function saveCodexAccount(account: CodexAccount, path = codexAccountsPath()): void {
|
|
61
74
|
const storage = loadCodexAccounts(path);
|
|
62
75
|
const index = storage.accounts.findIndex((a) => a.id === account.id);
|
|
63
76
|
if (index >= 0) storage.accounts[index] = { ...storage.accounts[index], ...account };
|
|
@@ -65,7 +78,7 @@ export function saveCodexAccount(account: CodexAccount, path = storePath()): voi
|
|
|
65
78
|
saveCodexAccounts(storage, path);
|
|
66
79
|
}
|
|
67
80
|
|
|
68
|
-
export function removeCodexAccount(id: string, path =
|
|
81
|
+
export function removeCodexAccount(id: string, path = codexAccountsPath()): boolean {
|
|
69
82
|
const storage = loadCodexAccounts(path);
|
|
70
83
|
const before = storage.accounts.length;
|
|
71
84
|
storage.accounts = storage.accounts.filter((a) => a.id !== id);
|
package/src/core/config.ts
CHANGED
|
@@ -2,21 +2,8 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { agentPath, readJson, writeJson } from "./store.ts";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* ```json
|
|
8
|
-
* {
|
|
9
|
-
* "env": { "ARTIFICIAL_ANALYSIS_API_KEY": "aa_…", "AGENT_BOARD_URL": "…" },
|
|
10
|
-
* "policy": { "autoApprove": [], "requireApproval": [], "deny": [] },
|
|
11
|
-
* "remote": { "injectStatus": true, "defaults": {}, "workers": [] }
|
|
12
|
-
* }
|
|
13
|
-
* ```
|
|
14
|
-
*
|
|
15
|
-
* Pi's own `settings.json` and `models.json` are deliberately NOT absorbed:
|
|
16
|
-
* pi reads those from fixed paths and folding them in here would break it.
|
|
17
|
-
*
|
|
18
|
-
* Every legacy file is migrated on first read and left on disk, so downgrading
|
|
19
|
-
* keeps working.
|
|
5
|
+
* Unified pi-plus config. Pi's own settings stay separate, while legacy files
|
|
6
|
+
* migrate without being deleted.
|
|
20
7
|
*/
|
|
21
8
|
|
|
22
9
|
export const CONFIG_FILE = "pi-plus.json";
|
|
@@ -33,20 +20,28 @@ export interface RemoteSection {
|
|
|
33
20
|
workers: Record<string, unknown>[];
|
|
34
21
|
}
|
|
35
22
|
|
|
23
|
+
export type BetterCompactMode = "off" | "on" | "jev";
|
|
24
|
+
|
|
25
|
+
export interface CompactSection {
|
|
26
|
+
better: BetterCompactMode;
|
|
27
|
+
}
|
|
28
|
+
|
|
36
29
|
export interface PiPlusConfig {
|
|
37
30
|
env: Record<string, string>;
|
|
38
31
|
policy: PolicySection;
|
|
39
32
|
remote: RemoteSection;
|
|
33
|
+
compact: CompactSection;
|
|
40
34
|
}
|
|
41
35
|
|
|
42
36
|
const DEFAULTS: PiPlusConfig = {
|
|
43
37
|
env: {},
|
|
44
38
|
policy: {
|
|
45
|
-
autoApprove: ["anthropic/*", "openai-codex/*"],
|
|
46
|
-
requireApproval: ["openrouter/*", "google/*", "openai/*"
|
|
39
|
+
autoApprove: ["anthropic/*", "openai-codex/*", "kimi-coding/*", "xai/*"],
|
|
40
|
+
requireApproval: ["openrouter/*", "google/*", "openai/*"],
|
|
47
41
|
deny: [],
|
|
48
42
|
},
|
|
49
43
|
remote: { injectStatus: true, workers: [] },
|
|
44
|
+
compact: { better: "off" },
|
|
50
45
|
};
|
|
51
46
|
|
|
52
47
|
/** Legacy file -> section, applied only when that section is still absent. */
|
|
@@ -116,6 +111,7 @@ export function configPath(): string {
|
|
|
116
111
|
}
|
|
117
112
|
|
|
118
113
|
function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
|
|
114
|
+
const better = raw?.compact?.better;
|
|
119
115
|
return {
|
|
120
116
|
env: raw?.env && typeof raw.env === "object" ? { ...raw.env } : {},
|
|
121
117
|
policy: {
|
|
@@ -130,6 +126,9 @@ function normalize(raw: Partial<PiPlusConfig> | undefined): PiPlusConfig {
|
|
|
130
126
|
defaults: raw?.remote?.defaults,
|
|
131
127
|
workers: Array.isArray(raw?.remote?.workers) ? raw.remote.workers : [],
|
|
132
128
|
},
|
|
129
|
+
compact: {
|
|
130
|
+
better: better === "on" || better === "jev" ? better : "off",
|
|
131
|
+
},
|
|
133
132
|
};
|
|
134
133
|
}
|
|
135
134
|
|
package/src/core/store.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
|
-
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* Small JSON config helper shared by every domain.
|
|
7
|
-
*
|
|
8
|
-
* Writes go through a temp file + rename so a crash mid-write cannot leave a
|
|
9
|
-
* truncated config behind; several of these files hold auth state.
|
|
10
|
-
*/
|
|
5
|
+
/** Shared JSON storage with atomic writes. */
|
|
11
6
|
|
|
12
7
|
export function agentDir(): string {
|
|
13
8
|
return process.env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
@@ -25,19 +20,25 @@ export function readJson<T>(path: string, fallback: T): T {
|
|
|
25
20
|
}
|
|
26
21
|
}
|
|
27
22
|
|
|
28
|
-
/** Best-effort atomic write.
|
|
29
|
-
export function writeJson(path: string, value: unknown, pretty = false): boolean {
|
|
23
|
+
/** Best-effort atomic write. */
|
|
24
|
+
export function writeJson(path: string, value: unknown, pretty = false, mode?: number): boolean {
|
|
30
25
|
const temp = `${path}.${process.pid}.tmp`;
|
|
26
|
+
const content = JSON.stringify(value, undefined, pretty ? 2 : undefined);
|
|
27
|
+
const options = { encoding: "utf8" as const, ...(mode === undefined ? {} : { mode }) };
|
|
28
|
+
|
|
31
29
|
try {
|
|
32
|
-
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
writeFileSync(temp, content, options);
|
|
33
32
|
renameSync(temp, path);
|
|
34
33
|
return true;
|
|
35
34
|
} catch {
|
|
36
35
|
try {
|
|
37
|
-
writeFileSync(path,
|
|
36
|
+
writeFileSync(path, content, options);
|
|
38
37
|
return true;
|
|
39
38
|
} catch {
|
|
40
39
|
return false;
|
|
40
|
+
} finally {
|
|
41
|
+
try { rmSync(temp, { force: true }); } catch { /* best effort */ }
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
}
|