@jameslovespancakes/pi-plus 1.0.11 → 1.0.12
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 +44 -20
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +6 -3
- package/src/core/accounts/oauth-pool.ts +102 -0
- package/src/core/accounts/registry.ts +8 -25
- package/src/core/accounts/routing.ts +122 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/quota.ts +8 -53
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +7 -9
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +8 -2
- package/src/core/codex/store.ts +12 -9
- package/src/core/config.ts +4 -17
- package/src/core/store.ts +13 -12
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +18 -37
- package/src/domains/subscriptions/accounts.ts +23 -29
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +38 -103
- package/src/domains/subscriptions/providers/anthropic.ts +5 -17
- package/src/domains/subscriptions/providers/codex.ts +171 -4
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +273 -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 +147 -0
- package/src/domains/workflows/runtime/agent-limits.ts +43 -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 +203 -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 +103 -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 +543 -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 +13 -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 +398 -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 +162 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +635 -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 +581 -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
|
@@ -5,50 +5,36 @@ import {
|
|
|
5
5
|
} from "../../core/anthropic/client-identity.ts";
|
|
6
6
|
import { ANTHROPIC_MODELS } from "../../core/anthropic/models.ts";
|
|
7
7
|
import { applyQuotaHeaders, refreshAllQuota } from "../../core/anthropic/quota.ts";
|
|
8
|
-
import { applyCodexQuotaHeaders } from "../../core/codex/quota.ts";
|
|
9
|
-
import { loadCodexAccounts } from "../../core/codex/store.ts";
|
|
10
8
|
import {
|
|
11
9
|
MAIN_ACCOUNT_ID, familyForModel, selectAccount, type Candidate,
|
|
12
10
|
} from "../../core/anthropic/routing.ts";
|
|
13
11
|
import { getRoutingMode, loadAccounts, saveAccount } from "../../core/anthropic/store.ts";
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Rather than reimplementing the Messages API, this registers pi's built-in
|
|
19
|
-
* `anthropic-messages` implementation and supplies only the credential. pi
|
|
20
|
-
* resolves auth per request, so `getApiKey` is the hook where account routing
|
|
21
|
-
* happens: it returns the token of whichever account the router picked.
|
|
22
|
-
*
|
|
23
|
-
* Consequences of that choice, stated plainly:
|
|
24
|
-
* - `getApiKey` is synchronous, so selection reads the CACHED quota snapshot.
|
|
25
|
-
* A background timer keeps it fresh instead of polling inline.
|
|
26
|
-
* - A mid-stream 429 is handled by pi's retry rather than by migrating the
|
|
27
|
-
* in-flight request to another account. The next request routes elsewhere
|
|
28
|
-
* once the failure is recorded, which is a real behavioural difference from
|
|
29
|
-
* the vendored provider.
|
|
30
|
-
*
|
|
31
|
-
* What this buys: pi's streaming, tool conversion, cost accounting and retry
|
|
32
|
-
* logic are reused unchanged, instead of a 1,500-line reimplementation whose
|
|
33
|
-
* edge cases we could not see.
|
|
14
|
+
* Anthropic uses pi's Messages API client with per-request account routing.
|
|
15
|
+
* Routing reads cached quota; a 429 affects the next request, not its stream.
|
|
34
16
|
*/
|
|
35
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The hook is global, so require both Anthropic's payload shape and a Claude
|
|
20
|
+
* model. This prevents `system` injection into other provider requests.
|
|
21
|
+
*/
|
|
22
|
+
function isAnthropicMessagesPayload(payload: any): boolean {
|
|
23
|
+
if ("instructions" in payload || "input" in payload) return false;
|
|
24
|
+
if (!Array.isArray(payload.messages)) return false;
|
|
25
|
+
const model = typeof payload.model === "string" ? payload.model.toLowerCase() : "";
|
|
26
|
+
return model.startsWith("claude") || ANTHROPIC_MODELS.some((m: any) => m.id === payload.model);
|
|
27
|
+
}
|
|
28
|
+
|
|
36
29
|
let lastSelected: { id: string; at: number } | undefined;
|
|
30
|
+
const accountLastUsed = new Map<string, number>();
|
|
37
31
|
|
|
38
32
|
/** Which account served the most recent request, for the UI. */
|
|
39
33
|
export function lastRoutedAccount(): { id: string; at: number } | undefined {
|
|
40
34
|
return lastSelected;
|
|
41
35
|
}
|
|
42
36
|
|
|
43
|
-
/**
|
|
44
|
-
* Chooses an account and returns its access token.
|
|
45
|
-
*
|
|
46
|
-
* `primary` is pi's own credential, which participates as `main` at order 0.
|
|
47
|
-
* Falls back to the primary token whenever routing has nothing better, so a
|
|
48
|
-
* single-account setup behaves exactly as it did before any of this existed.
|
|
49
|
-
*/
|
|
50
|
-
// `_sessionId` is accepted for signature compatibility with pi's per-request
|
|
51
|
-
// hook; stickiness is derived from stored assignments rather than the id.
|
|
37
|
+
/** Routes to a pooled account, falling back to pi's primary credential. */
|
|
52
38
|
export function routeAccessToken(primary: string, modelId?: string, _sessionId?: string): string {
|
|
53
39
|
const storage = loadAccounts();
|
|
54
40
|
if (!storage) return primary;
|
|
@@ -57,10 +43,23 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
|
|
|
57
43
|
const family = familyForModel(modelId);
|
|
58
44
|
|
|
59
45
|
const candidates: Candidate[] = [
|
|
60
|
-
{
|
|
46
|
+
{
|
|
47
|
+
id: MAIN_ACCOUNT_ID,
|
|
48
|
+
access: primary,
|
|
49
|
+
quota: storage.main?.quota as any,
|
|
50
|
+
order: 0,
|
|
51
|
+
lastUsed: accountLastUsed.get(MAIN_ACCOUNT_ID) ?? Number(storage.main?.lastUsed ?? 0),
|
|
52
|
+
},
|
|
61
53
|
...storage.accounts
|
|
62
54
|
.filter((a) => a.enabled !== false && a.type === "oauth" && a.access)
|
|
63
|
-
.map((a, index) => ({
|
|
55
|
+
.map((a, index) => ({
|
|
56
|
+
id: a.id,
|
|
57
|
+
access: a.access,
|
|
58
|
+
quota: a.quota,
|
|
59
|
+
order: index + 1,
|
|
60
|
+
lastUsed: accountLastUsed.get(a.id) ?? a.lastUsed ?? 0,
|
|
61
|
+
account: a,
|
|
62
|
+
})),
|
|
64
63
|
];
|
|
65
64
|
|
|
66
65
|
// Nothing to choose between: keep pi's own credential.
|
|
@@ -75,10 +74,9 @@ export function routeAccessToken(primary: string, modelId?: string, _sessionId?:
|
|
|
75
74
|
|
|
76
75
|
const now = Date.now();
|
|
77
76
|
lastSelected = { id: picked.candidate.id, at: now };
|
|
77
|
+
accountLastUsed.set(picked.candidate.id, now);
|
|
78
78
|
if (picked.candidate.account) {
|
|
79
|
-
//
|
|
80
|
-
// precision buys nothing. Persisting it on every request rewrote two
|
|
81
|
-
// credential files per call; once a minute is indistinguishable in the UI.
|
|
79
|
+
// Minute precision avoids a credential write on every request.
|
|
82
80
|
const previous = picked.candidate.account.lastUsed ?? 0;
|
|
83
81
|
if (now - previous > 60_000) {
|
|
84
82
|
saveAccount({ ...picked.candidate.account, lastUsed: now });
|
|
@@ -102,9 +100,7 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
102
100
|
baseUrl: "https://api.anthropic.com",
|
|
103
101
|
api: "anthropic-messages",
|
|
104
102
|
models: ANTHROPIC_MODELS,
|
|
105
|
-
// See
|
|
106
|
-
// requests to extra usage rather than plan limits. Delete that import and
|
|
107
|
-
// this line to opt out; nothing else depends on it.
|
|
103
|
+
// Required for subscription billing. See client-identity.ts.
|
|
108
104
|
headers: clientIdentityHeaders(),
|
|
109
105
|
oauth: {
|
|
110
106
|
name: "Anthropic Claude Pro/Max",
|
|
@@ -114,29 +110,15 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
114
110
|
const refreshed = await refreshToken({ refreshToken: credentials.refresh });
|
|
115
111
|
return { access: refreshed.access, refresh: refreshed.refresh, expires: refreshed.expires };
|
|
116
112
|
},
|
|
117
|
-
// The routing hook. pi calls this per request.
|
|
118
113
|
getApiKey: (credentials: any) => routeAccessToken(credentials.access),
|
|
119
114
|
},
|
|
120
115
|
});
|
|
121
116
|
|
|
122
|
-
/**
|
|
123
|
-
* Reshapes the request the way the endpoint requires, then signs it.
|
|
124
|
-
*
|
|
125
|
-
* Three things have to happen, in order:
|
|
126
|
-
* 1. pi's system prompt is split. The documentation paragraph cannot live in
|
|
127
|
-
* `system` at all, or Anthropic answers 400 "Third-party apps now draw
|
|
128
|
-
* from your extra usage"; it moves into the first user message instead.
|
|
129
|
-
* 2. The billing header goes first in `system`, alongside the Claude Code
|
|
130
|
-
* identity line.
|
|
131
|
-
* 3. The body is signed, which must happen last because the checksum covers
|
|
132
|
-
* the canonicalised body.
|
|
133
|
-
*
|
|
134
|
-
* See core/anthropic/client-identity.ts for what all of this is and the
|
|
135
|
-
* caveats around it.
|
|
136
|
-
*/
|
|
117
|
+
/** Rebuilds the Anthropic prompt, then signs the final body. */
|
|
137
118
|
pi.on("before_provider_request", async (event: any) => {
|
|
138
119
|
const payload = event?.payload;
|
|
139
120
|
if (!payload || typeof payload !== "object") return undefined;
|
|
121
|
+
if (!isAnthropicMessagesPayload(payload)) return undefined;
|
|
140
122
|
|
|
141
123
|
const serialized = typeof payload.system === "string"
|
|
142
124
|
? payload.system
|
|
@@ -156,20 +138,10 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
156
138
|
return JSON.parse(signed);
|
|
157
139
|
});
|
|
158
140
|
|
|
159
|
-
/**
|
|
160
|
-
* Free quota refresh for whichever account just served a request.
|
|
161
|
-
*
|
|
162
|
-
* Anthropic returns the same utilisation numbers as the usage endpoint in
|
|
163
|
-
* `anthropic-ratelimit-unified-*` response headers, so the active account
|
|
164
|
-
* stays current without spending a request on asking.
|
|
165
|
-
*
|
|
166
|
-
* `lastRoutedAccount()` is set by `getApiKey` immediately before the call,
|
|
167
|
-
* which is what lets the reply be attributed to the right account.
|
|
168
|
-
*/
|
|
141
|
+
/** Updates the routed account from response quota headers. */
|
|
169
142
|
pi.on("after_provider_response", (event: any) => {
|
|
170
143
|
const routed = lastRoutedAccount();
|
|
171
|
-
//
|
|
172
|
-
// per-account record to update for it.
|
|
144
|
+
// The primary account lives in pi's auth store.
|
|
173
145
|
if (!routed || routed.id === MAIN_ACCOUNT_ID) return;
|
|
174
146
|
try {
|
|
175
147
|
applyQuotaHeaders(routed.id, event?.headers);
|
|
@@ -178,44 +150,7 @@ export function registerAnthropicProvider(pi: ExtensionAPI): void {
|
|
|
178
150
|
}
|
|
179
151
|
});
|
|
180
152
|
|
|
181
|
-
/**
|
|
182
|
-
* The same trick for Codex, which reports usage as `x-codex-*` headers.
|
|
183
|
-
*
|
|
184
|
-
* Codex has no usage endpoint, so headers are the ONLY quota source: an
|
|
185
|
-
* account that is not serving traffic keeps whatever snapshot it last had.
|
|
186
|
-
* Attribution is by `chatgpt-account-id` on the request, since Codex
|
|
187
|
-
* requests carry the account explicitly rather than only in the token.
|
|
188
|
-
*/
|
|
189
|
-
pi.on("after_provider_response", (event: any) => {
|
|
190
|
-
const headers = event?.headers;
|
|
191
|
-
if (!headers) return;
|
|
192
|
-
const hasCodexQuota = Object.keys(headers).some((k) => k.toLowerCase().startsWith("x-codex-"));
|
|
193
|
-
if (!hasCodexQuota) return;
|
|
194
|
-
try {
|
|
195
|
-
const sent = event?.request?.headers ?? {};
|
|
196
|
-
const accountId = sent["chatgpt-account-id"] ?? sent["Chatgpt-Account-Id"];
|
|
197
|
-
const match = loadCodexAccounts().accounts.find(
|
|
198
|
-
(a) => (accountId ? a.accountId === accountId : false) || a.enabled !== false,
|
|
199
|
-
);
|
|
200
|
-
if (match) applyCodexQuotaHeaders(match.id, headers);
|
|
201
|
-
} catch {
|
|
202
|
-
// Bookkeeping only.
|
|
203
|
-
}
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Idle accounts still need polling, because routing compares accounts and
|
|
208
|
-
* response headers only ever refresh the one that served traffic.
|
|
209
|
-
*
|
|
210
|
-
* Triggered by sending a message, not by a timer, and `refreshAllQuota`
|
|
211
|
-
* skips accounts whose snapshot is still fresh. So the poll rate is at most
|
|
212
|
-
* once per QUOTA_FRESH_MS however fast messages are sent, and an idle
|
|
213
|
-
* session costs nothing at all. The previous 5 minute interval ran forever
|
|
214
|
-
* regardless of activity and was being answered with 429s.
|
|
215
|
-
*
|
|
216
|
-
* `input` is the per-message hook; `turn_start` would fire once per LLM
|
|
217
|
-
* response, which is many times per message in a tool-use loop.
|
|
218
|
-
*/
|
|
153
|
+
/** Refreshes stale idle-account quota when a message starts. */
|
|
219
154
|
pi.on("input", async () => {
|
|
220
155
|
void refreshAllQuota().catch(() => {});
|
|
221
156
|
});
|
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
loadAccounts,
|
|
7
7
|
saveAccount,
|
|
8
8
|
setRoutingMode,
|
|
9
|
-
type RoutingMode as StoreRoutingMode,
|
|
10
9
|
} from "../../../core/anthropic/store.ts";
|
|
11
10
|
|
|
12
11
|
/**
|
|
@@ -22,16 +21,6 @@ import {
|
|
|
22
21
|
* the extracted store directly.
|
|
23
22
|
*/
|
|
24
23
|
|
|
25
|
-
/** The registry's vocabulary maps onto the store's routing modes. */
|
|
26
|
-
const MODE_TO_STORE: Record<RoutingMode, StoreRoutingMode> = {
|
|
27
|
-
standard: "main-first",
|
|
28
|
-
optimal: "sticky-balanced",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
function fromStoreMode(mode: string): RoutingMode {
|
|
32
|
-
return mode === "sticky-balanced" ? "optimal" : "standard";
|
|
33
|
-
}
|
|
34
|
-
|
|
35
24
|
async function authenticate(ctx: AccountContext): Promise<{ access: string; refresh: string; expires: number } | undefined> {
|
|
36
25
|
const auth = await authorize("max");
|
|
37
26
|
|
|
@@ -133,17 +122,16 @@ export const anthropicAccounts: AccountProvider = {
|
|
|
133
122
|
|
|
134
123
|
routing: {
|
|
135
124
|
async get(): Promise<RoutingMode> {
|
|
136
|
-
return
|
|
125
|
+
return getRoutingMode(loadAccounts());
|
|
137
126
|
},
|
|
138
127
|
async set(mode: RoutingMode): Promise<RoutingMode> {
|
|
139
|
-
setRoutingMode(
|
|
128
|
+
setRoutingMode(mode);
|
|
140
129
|
return mode;
|
|
141
130
|
},
|
|
142
131
|
describe(mode: RoutingMode): string {
|
|
143
|
-
return mode === "
|
|
144
|
-
? "
|
|
145
|
-
|
|
146
|
-
: "Main subscription first, then eligible fallbacks.";
|
|
132
|
+
return mode === "quota-aware"
|
|
133
|
+
? "Uses the Claude subscription with the most remaining quota."
|
|
134
|
+
: "Uses Claude subscriptions in order, moving on when one is exhausted.";
|
|
147
135
|
},
|
|
148
136
|
},
|
|
149
137
|
};
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ModelAuth, OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai";
|
|
3
|
+
import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
5
|
import type { AccountContext, AccountProvider, ManagedAccount, RoutingMode } from "../../../core/accounts/registry.ts";
|
|
6
|
+
import { normalizeRoutingMode, selectRoutingCandidate, type AccountQuotaState } from "../../../core/accounts/routing.ts";
|
|
3
7
|
import { authorizeCodex, exchangeCodex } from "../../../core/codex/oauth.ts";
|
|
8
|
+
import { applyCodexQuotaHeaders } from "../../../core/codex/quota.ts";
|
|
4
9
|
import {
|
|
10
|
+
MAIN_ACCOUNT_ID,
|
|
5
11
|
claimsOf,
|
|
6
12
|
loadCodexAccounts,
|
|
7
13
|
saveCodexAccount,
|
|
@@ -38,6 +44,18 @@ function describePlan(account: CodexAccount): string {
|
|
|
38
44
|
return account.plan ? `${account.label ?? account.id.slice(0, 8)} (${account.plan})` : (account.label ?? account.id.slice(0, 8));
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
type RoutableCodexAccount = CodexAccount & { access: string; refresh: string; expires: number };
|
|
48
|
+
|
|
49
|
+
const refreshes = new Map<string, Promise<RoutableCodexAccount>>();
|
|
50
|
+
const codexLastUsed = new Map<string, number>();
|
|
51
|
+
|
|
52
|
+
function isRoutableCodexAccount(account: CodexAccount): account is RoutableCodexAccount {
|
|
53
|
+
return account.enabled !== false
|
|
54
|
+
&& typeof account.access === "string"
|
|
55
|
+
&& typeof account.refresh === "string"
|
|
56
|
+
&& typeof account.expires === "number";
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
export const codexAccounts: AccountProvider = {
|
|
42
60
|
id: "openai-codex",
|
|
43
61
|
label: "Codex",
|
|
@@ -131,7 +149,7 @@ export const codexAccounts: AccountProvider = {
|
|
|
131
149
|
|
|
132
150
|
routing: {
|
|
133
151
|
async get(): Promise<RoutingMode> {
|
|
134
|
-
return loadCodexAccounts().routing?.mode
|
|
152
|
+
return codexRoutingMode(loadCodexAccounts().routing?.mode);
|
|
135
153
|
},
|
|
136
154
|
async set(mode: RoutingMode): Promise<RoutingMode> {
|
|
137
155
|
const storage = loadCodexAccounts();
|
|
@@ -140,9 +158,158 @@ export const codexAccounts: AccountProvider = {
|
|
|
140
158
|
return mode;
|
|
141
159
|
},
|
|
142
160
|
describe(mode: RoutingMode): string {
|
|
143
|
-
return mode === "
|
|
144
|
-
? "
|
|
145
|
-
: "
|
|
161
|
+
return mode === "quota-aware"
|
|
162
|
+
? "Uses the Codex subscription with the most remaining quota."
|
|
163
|
+
: "Uses Codex subscriptions in order, moving on when one is exhausted.";
|
|
146
164
|
},
|
|
147
165
|
},
|
|
148
166
|
};
|
|
167
|
+
|
|
168
|
+
export function codexRoutingMode(value: string | undefined): RoutingMode {
|
|
169
|
+
return normalizeRoutingMode(value);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function quotaState(quota: CodexAccount["quota"], blockedUntil?: number): AccountQuotaState | undefined {
|
|
173
|
+
if (!quota) {
|
|
174
|
+
return blockedUntil && blockedUntil > Date.now()
|
|
175
|
+
? { remainingPercent: 0, checkedAt: Date.now(), blockedUntil }
|
|
176
|
+
: undefined;
|
|
177
|
+
}
|
|
178
|
+
const windows = [quota.five_hour, quota.seven_day, ...(quota.scoped ?? [])]
|
|
179
|
+
.filter((window) => window && Number.isFinite(window.remainingPercent));
|
|
180
|
+
if (windows.length === 0) return undefined;
|
|
181
|
+
|
|
182
|
+
const remainingPercent = Math.min(...windows.map((window) => window!.remainingPercent!));
|
|
183
|
+
const resetTimes = windows
|
|
184
|
+
.filter((window) => (window!.remainingPercent ?? 0) <= 0 && window!.resetsAt)
|
|
185
|
+
.map((window) => Date.parse(window!.resetsAt!))
|
|
186
|
+
.filter(Number.isFinite);
|
|
187
|
+
const resetAt = resetTimes.length > 0 ? Math.min(...resetTimes) : undefined;
|
|
188
|
+
return {
|
|
189
|
+
remainingPercent: blockedUntil && blockedUntil > Date.now() ? 0 : remainingPercent,
|
|
190
|
+
resetAt,
|
|
191
|
+
checkedAt: quota.checkedAt ?? Date.now(),
|
|
192
|
+
blockedUntil: blockedUntil && blockedUntil > Date.now()
|
|
193
|
+
? blockedUntil
|
|
194
|
+
: remainingPercent <= 0 ? resetAt ?? Number.POSITIVE_INFINITY : undefined,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function chooseCodexCredential(primary: OAuthCredential): {
|
|
199
|
+
readonly id: string;
|
|
200
|
+
readonly credential: OAuthCredential;
|
|
201
|
+
readonly account?: RoutableCodexAccount;
|
|
202
|
+
} {
|
|
203
|
+
const storage = loadCodexAccounts();
|
|
204
|
+
const mode = codexRoutingMode(storage.routing?.mode);
|
|
205
|
+
const candidates = [
|
|
206
|
+
{
|
|
207
|
+
id: MAIN_ACCOUNT_ID,
|
|
208
|
+
order: 0,
|
|
209
|
+
lastUsed: codexLastUsed.get(MAIN_ACCOUNT_ID) ?? storage.main?.lastUsed ?? 0,
|
|
210
|
+
quota: quotaState(storage.main?.quota, storage.main?.blockedUntil),
|
|
211
|
+
value: { id: MAIN_ACCOUNT_ID, credential: primary },
|
|
212
|
+
},
|
|
213
|
+
...storage.accounts
|
|
214
|
+
.filter(isRoutableCodexAccount)
|
|
215
|
+
.map((account, index) => ({
|
|
216
|
+
id: account.id,
|
|
217
|
+
order: index + 1,
|
|
218
|
+
lastUsed: codexLastUsed.get(account.id) ?? account.lastUsed ?? 0,
|
|
219
|
+
quota: quotaState(account.quota, account.blockedUntil),
|
|
220
|
+
value: {
|
|
221
|
+
id: account.id,
|
|
222
|
+
credential: { ...account, type: "oauth" as const } satisfies OAuthCredential,
|
|
223
|
+
account,
|
|
224
|
+
},
|
|
225
|
+
})),
|
|
226
|
+
];
|
|
227
|
+
return selectRoutingCandidate(candidates, mode)?.value ?? { id: MAIN_ACCOUNT_ID, credential: primary };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function freshCodexAccount(
|
|
231
|
+
oauth: OAuthAuth,
|
|
232
|
+
account: RoutableCodexAccount,
|
|
233
|
+
signal: AbortSignal,
|
|
234
|
+
): Promise<RoutableCodexAccount> {
|
|
235
|
+
if (account.access && account.expires && account.expires > Date.now() + 60_000) return account;
|
|
236
|
+
const active = refreshes.get(account.id);
|
|
237
|
+
if (active) return active;
|
|
238
|
+
|
|
239
|
+
const credential: OAuthCredential = { ...account, type: "oauth" };
|
|
240
|
+
const refresh = oauth.refresh(credential, signal).then((updated) => {
|
|
241
|
+
const next: RoutableCodexAccount = { ...account, ...updated };
|
|
242
|
+
saveCodexAccount(next);
|
|
243
|
+
return next;
|
|
244
|
+
}).finally(() => refreshes.delete(account.id));
|
|
245
|
+
refreshes.set(account.id, refresh);
|
|
246
|
+
return refresh;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function routedCodexAuth(
|
|
250
|
+
oauth: OAuthAuth,
|
|
251
|
+
primary: OAuthCredential,
|
|
252
|
+
): Promise<ModelAuth> {
|
|
253
|
+
const selected = chooseCodexCredential(primary);
|
|
254
|
+
const account = selected.account
|
|
255
|
+
? await freshCodexAccount(oauth, selected.account, new AbortController().signal)
|
|
256
|
+
: undefined;
|
|
257
|
+
const credential: OAuthCredential = account ? { ...account, type: "oauth" } : primary;
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
codexLastUsed.set(selected.id, now);
|
|
260
|
+
const storage = loadCodexAccounts();
|
|
261
|
+
if (selected.id === MAIN_ACCOUNT_ID) {
|
|
262
|
+
if (now - (storage.main?.lastUsed ?? 0) >= 60_000) {
|
|
263
|
+
storage.main = { ...storage.main, lastUsed: now };
|
|
264
|
+
saveCodexAccounts(storage);
|
|
265
|
+
}
|
|
266
|
+
} else if (account && now - (account.lastUsed ?? 0) >= 60_000) {
|
|
267
|
+
saveCodexAccount({ ...account, lastUsed: now });
|
|
268
|
+
}
|
|
269
|
+
return oauth.toAuth(credential);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function markCodexRateLimited(accountId: string, headers: Record<string, string>): void {
|
|
273
|
+
const raw = Object.entries(headers).find(([key]) => key.toLowerCase() === "retry-after")?.[1];
|
|
274
|
+
const seconds = Number(raw);
|
|
275
|
+
const blockedUntil = Date.now() + (Number.isFinite(seconds) && seconds >= 0 ? seconds * 1_000 : 60_000);
|
|
276
|
+
const storage = loadCodexAccounts();
|
|
277
|
+
if (accountId === MAIN_ACCOUNT_ID) {
|
|
278
|
+
storage.main = { ...storage.main, blockedUntil };
|
|
279
|
+
saveCodexAccounts(storage);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const account = storage.accounts.find((candidate) => candidate.id === accountId);
|
|
283
|
+
if (account) saveCodexAccount({ ...account, blockedUntil });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function registerCodexProvider(pi: ExtensionAPI): void {
|
|
287
|
+
const provider = openaiCodexProvider();
|
|
288
|
+
const oauth = provider.auth.oauth;
|
|
289
|
+
if (!oauth) return;
|
|
290
|
+
|
|
291
|
+
const withQuotaObserver = (options: any) => ({
|
|
292
|
+
...options,
|
|
293
|
+
onResponse: async (response: { status: number; headers: Record<string, string> }, model: unknown) => {
|
|
294
|
+
const storage = loadCodexAccounts();
|
|
295
|
+
const account = storage.accounts.find((candidate) => candidate.access === options?.apiKey);
|
|
296
|
+
const accountId = account?.id ?? MAIN_ACCOUNT_ID;
|
|
297
|
+
applyCodexQuotaHeaders(accountId, response.headers);
|
|
298
|
+
if (response.status === 429) markCodexRateLimited(accountId, response.headers);
|
|
299
|
+
await options?.onResponse?.(response, model);
|
|
300
|
+
},
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
pi.registerProvider({
|
|
304
|
+
...provider,
|
|
305
|
+
auth: {
|
|
306
|
+
...provider.auth,
|
|
307
|
+
oauth: {
|
|
308
|
+
...oauth,
|
|
309
|
+
toAuth: (primary) => routedCodexAuth(oauth, primary),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
stream: (model: any, context: any, options: any) => provider.stream(model, context, withQuotaObserver(options)),
|
|
313
|
+
streamSimple: (model: any, context: any, options: any) => provider.streamSimple(model, context, withQuotaObserver(options)),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { kimiCodingProvider } from "@earendil-works/pi-ai/providers/kimi-coding";
|
|
2
|
+
import { xaiProvider } from "@earendil-works/pi-ai/providers/xai";
|
|
3
|
+
import { createPooledOAuthAdapter, type PooledOAuthProviderSpec } from "./oauth-pool.ts";
|
|
4
|
+
|
|
5
|
+
export const KIMI_SPEC: PooledOAuthProviderSpec = {
|
|
6
|
+
id: "kimi-coding",
|
|
7
|
+
label: "Kimi",
|
|
8
|
+
createProvider: kimiCodingProvider,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const XAI_SPEC: PooledOAuthProviderSpec = {
|
|
12
|
+
id: "xai",
|
|
13
|
+
label: "Grok",
|
|
14
|
+
createProvider: xaiProvider,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const kimiAccounts = createPooledOAuthAdapter(KIMI_SPEC);
|
|
18
|
+
export const xaiAccounts = createPooledOAuthAdapter(XAI_SPEC);
|