@jameslovespancakes/pi-plus 1.0.21 → 1.0.23
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 +27 -4
- package/package.json +1 -1
- package/src/core/anthropic/quota.ts +13 -167
- package/src/core/anthropic/store.ts +2 -0
- package/src/core/anthropic/usage-cache.ts +141 -0
- package/src/core/env.ts +0 -2
- package/src/core/file-lease.ts +32 -0
- package/src/core/gemini/client.ts +51 -4
- package/src/core/gemini/oauth.ts +55 -4
- package/src/core/oauth/callback-server.ts +1 -1
- package/src/core/policy/openrouter.ts +54 -0
- package/src/core/policy/policy.ts +112 -133
- package/src/core/quota/usage-source.ts +30 -92
- package/src/domains/claude-remote/index.ts +21 -14
- package/src/domains/claude-remote/picker.ts +12 -3
- package/src/domains/models/policy-gate.ts +50 -52
- package/src/domains/models/provider-picker.ts +19 -8
- package/src/domains/setup/index.ts +4 -5
- package/src/domains/subscriptions/provider.ts +21 -24
- package/src/domains/subscriptions/providers/gemini.ts +1 -5
- package/src/domains/workflows/runtime/agent-session-providers.ts +7 -2
- package/src/services/usage-service.ts +2 -1
package/src/core/gemini/oauth.ts
CHANGED
|
@@ -6,7 +6,9 @@ import type {
|
|
|
6
6
|
} from "@earendil-works/pi-ai";
|
|
7
7
|
import { startOAuthCallbackServer, type OAuthCallbackServer } from "../oauth/callback-server.ts";
|
|
8
8
|
import { generatePkce, generateState, parseCallback } from "../oauth/pkce.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
geminiEnv, discoverProjectId, fallbackProjectId, fetchUserEmail, fetchUserQuota, GeminiVerificationRequiredError,
|
|
11
|
+
} from "./client.ts";
|
|
10
12
|
import {
|
|
11
13
|
credentialEmail,
|
|
12
14
|
credentialProjectId,
|
|
@@ -140,6 +142,55 @@ async function awaitCode(
|
|
|
140
142
|
return parsed.code;
|
|
141
143
|
}
|
|
142
144
|
|
|
145
|
+
/** OAuth alone does not clear Google's account-verification gate. No inference is sent. */
|
|
146
|
+
export async function confirmGeminiAccess(
|
|
147
|
+
credential: OAuthCredential,
|
|
148
|
+
interaction: ProviderAuthInteraction,
|
|
149
|
+
): Promise<OAuthCredential> {
|
|
150
|
+
let openedUrl: string | undefined;
|
|
151
|
+
// Every retry requires user input; no background polling or endless browser loop.
|
|
152
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
153
|
+
interaction.signal.throwIfAborted();
|
|
154
|
+
interaction.notify({ type: "progress", message: "Confirming Gemini account access…" });
|
|
155
|
+
const signal = AbortSignal.any([interaction.signal, AbortSignal.timeout(30_000)]);
|
|
156
|
+
if (credential.expires <= Date.now() + 60_000) credential = await refresh(credential, signal);
|
|
157
|
+
// Verification may also have blocked login-time project discovery.
|
|
158
|
+
if (attempt > 0 && !credentialProjectId(credential)) {
|
|
159
|
+
const projectId = await discoverProjectId(credential.access, signal);
|
|
160
|
+
if (projectId) credential = { ...credential, projectId } as GeminiCredential;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
await fetchUserQuota(credential.access, requestProjectId(credential), signal);
|
|
164
|
+
interaction.signal.throwIfAborted();
|
|
165
|
+
return credential;
|
|
166
|
+
} catch (error) {
|
|
167
|
+
interaction.signal.throwIfAborted();
|
|
168
|
+
if (!(error instanceof GeminiVerificationRequiredError)) throw error;
|
|
169
|
+
if (attempt === 3) throw new Error("Google verification is still required; Gemini sign-in was not completed. Finish verification and retry.", { cause: error });
|
|
170
|
+
if (error.verificationUrl && error.verificationUrl !== openedUrl) {
|
|
171
|
+
openedUrl = error.verificationUrl;
|
|
172
|
+
interaction.notify({
|
|
173
|
+
type: "auth_url", url: openedUrl,
|
|
174
|
+
instructions: "Complete Google's verification using the account you just signed into. Then return here to check access.",
|
|
175
|
+
});
|
|
176
|
+
} else {
|
|
177
|
+
interaction.notify({ type: "info", message: error.message });
|
|
178
|
+
}
|
|
179
|
+
const action = await interaction.prompt({
|
|
180
|
+
type: "select",
|
|
181
|
+
message: "Gemini sign-in is pending Google verification.",
|
|
182
|
+
options: [
|
|
183
|
+
{ id: "check", label: "I've completed verification — check again" },
|
|
184
|
+
{ id: "cancel", label: "Cancel sign-in" },
|
|
185
|
+
],
|
|
186
|
+
signal: interaction.signal,
|
|
187
|
+
});
|
|
188
|
+
if (action !== "check") throw new Error("Gemini sign-in cancelled; account access was not confirmed.", { cause: error });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
throw new Error("Gemini account access was not confirmed.");
|
|
192
|
+
}
|
|
193
|
+
|
|
143
194
|
async function login(interaction: ProviderAuthInteraction): Promise<OAuthCredential> {
|
|
144
195
|
const { verifier, challenge } = await generatePkce();
|
|
145
196
|
// Independent of the verifier: a leaked callback URL must not disclose it.
|
|
@@ -148,7 +199,7 @@ async function login(interaction: ProviderAuthInteraction): Promise<OAuthCredent
|
|
|
148
199
|
const server = await startOAuthCallbackServer({
|
|
149
200
|
port: CALLBACK_PORT,
|
|
150
201
|
path: CALLBACK_PATH,
|
|
151
|
-
successMessage: "
|
|
202
|
+
successMessage: "Google authorization received. Return to pi to confirm Gemini access and complete any required verification.",
|
|
152
203
|
}).catch((error: unknown) => {
|
|
153
204
|
throw new Error(
|
|
154
205
|
`Could not listen on port ${CALLBACK_PORT} for the Google callback`
|
|
@@ -184,14 +235,14 @@ async function login(interaction: ProviderAuthInteraction): Promise<OAuthCredent
|
|
|
184
235
|
discoverProjectId(token.access_token, interaction.signal),
|
|
185
236
|
]);
|
|
186
237
|
|
|
187
|
-
return {
|
|
238
|
+
return await confirmGeminiAccess({
|
|
188
239
|
type: "oauth",
|
|
189
240
|
access: token.access_token,
|
|
190
241
|
refresh: token.refresh_token,
|
|
191
242
|
expires: expiresAt(token),
|
|
192
243
|
...(projectId && { projectId }),
|
|
193
244
|
...(email && { email }),
|
|
194
|
-
} satisfies GeminiCredential;
|
|
245
|
+
} satisfies GeminiCredential, interaction);
|
|
195
246
|
} finally {
|
|
196
247
|
server.close();
|
|
197
248
|
}
|
|
@@ -46,7 +46,7 @@ function page(title: string, message: string, details?: string): string {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
export function oauthSuccessHtml(message: string): string {
|
|
49
|
-
return page("
|
|
49
|
+
return page("Authorization received", message);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
export function oauthErrorHtml(message: string, details?: string): string {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Api, Model, StreamOptions } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
function officialEndpoint(model: Model<Api>): boolean {
|
|
4
|
+
try {
|
|
5
|
+
const url = new URL(model.baseUrl);
|
|
6
|
+
const path = model.api === "anthropic-messages" ? "/api" : "/api/v1";
|
|
7
|
+
return url.origin === "https://openrouter.ai" && url.pathname.replace(/\/+$/, "") === path
|
|
8
|
+
&& !url.username && !url.password && !url.search && !url.hash;
|
|
9
|
+
} catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Enforce through pi's own routing support, without replacing its provider or tools. */
|
|
13
|
+
export function withOpenRouterZdr(model: Model<Api>, options?: StreamOptions) {
|
|
14
|
+
if (!["openai-completions", "anthropic-messages"].includes(model.api) || !officialEndpoint(model)) {
|
|
15
|
+
throw new Error("OpenRouter On (ZDR) cannot enforce this API or endpoint. Use pi's native OpenRouter Chat Completions or Messages route.");
|
|
16
|
+
}
|
|
17
|
+
const preferences = model.compat && "openRouterRouting" in model.compat ? model.compat.openRouterRouting : undefined;
|
|
18
|
+
return {
|
|
19
|
+
model: model.api === "openai-completions" ? {
|
|
20
|
+
...model,
|
|
21
|
+
compat: {
|
|
22
|
+
...model.compat,
|
|
23
|
+
openRouterRouting: { ...preferences, zdr: true },
|
|
24
|
+
},
|
|
25
|
+
} : model,
|
|
26
|
+
options: {
|
|
27
|
+
...options,
|
|
28
|
+
// Run caller instrumentation first, then enforce the final request's
|
|
29
|
+
// privacy restriction. No retry without ZDR is allowed.
|
|
30
|
+
onPayload: async (payload: unknown, requestModel: Model<Api>) => {
|
|
31
|
+
const replacement = await options?.onPayload?.(payload, requestModel);
|
|
32
|
+
const body = replacement === undefined ? payload : replacement;
|
|
33
|
+
if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)
|
|
34
|
+
|| "input" in body || "instructions" in body) {
|
|
35
|
+
throw new Error("OpenRouter On (ZDR) blocked an unsupported request payload.");
|
|
36
|
+
}
|
|
37
|
+
const routing = "provider" in body ? body.provider : undefined;
|
|
38
|
+
// Pi natively serializes openRouterRouting on Chat Completions. Its
|
|
39
|
+
// Anthropic adapter does not, although OpenRouter's Messages API accepts
|
|
40
|
+
// the same provider preferences. Add only that missing request field.
|
|
41
|
+
if (model.api === "anthropic-messages") {
|
|
42
|
+
if (routing !== undefined && (!routing || typeof routing !== "object" || Array.isArray(routing))) {
|
|
43
|
+
throw new Error("OpenRouter On (ZDR) blocked malformed provider preferences.");
|
|
44
|
+
}
|
|
45
|
+
return { ...body, provider: { ...preferences, ...routing, zdr: true } };
|
|
46
|
+
}
|
|
47
|
+
if (!routing || typeof routing !== "object" || !("zdr" in routing) || routing.zdr !== true) {
|
|
48
|
+
throw new Error("OpenRouter On (ZDR) blocked a request without provider.zdr=true.");
|
|
49
|
+
}
|
|
50
|
+
return body;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -1,45 +1,29 @@
|
|
|
1
1
|
import { readConfig, updateConfig } from "../config.ts";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* Approval policy for model selection.
|
|
5
|
-
*
|
|
6
|
-
* Subscription providers are free to use. Metered providers (OpenRouter and
|
|
7
|
-
* anything else that bills per token) require an explicit approval before a
|
|
8
|
-
* request is allowed to leave the machine.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
3
|
export interface PolicyFile {
|
|
12
4
|
autoApprove: string[];
|
|
13
5
|
requireApproval: string[];
|
|
14
6
|
deny: string[];
|
|
15
7
|
}
|
|
16
8
|
|
|
17
|
-
const DEFAULT_POLICY: PolicyFile = {
|
|
18
|
-
// `google/*` is the metered Gemini API; `gemini/*` is the subscription,
|
|
19
|
-
// which the account's plan has already paid for.
|
|
20
|
-
autoApprove: ["anthropic/*", "openai-codex/*", "gemini/*"],
|
|
21
|
-
requireApproval: ["openrouter/*", "google/*", "openai/*", "xai/*"],
|
|
22
|
-
deny: [],
|
|
23
|
-
};
|
|
24
|
-
|
|
25
9
|
export type Decision =
|
|
26
10
|
| { allowed: true; reason: "auto" | "approved" }
|
|
27
11
|
| { allowed: false; reason: "denied" | "needs-approval"; message: string };
|
|
28
12
|
|
|
29
|
-
|
|
13
|
+
export type ProviderState = "auto" | "approved" | "blocked" | "denied" | "zdr";
|
|
14
|
+
|
|
15
|
+
export interface ProviderApproval {
|
|
16
|
+
provider: string;
|
|
17
|
+
approved: boolean;
|
|
18
|
+
until?: number;
|
|
19
|
+
}
|
|
30
20
|
|
|
31
21
|
export function loadPolicy(): PolicyFile {
|
|
32
22
|
return readConfig().policy;
|
|
33
23
|
}
|
|
34
24
|
|
|
35
25
|
export function savePolicy(next: PolicyFile): void {
|
|
36
|
-
updateConfig((config) => {
|
|
37
|
-
config.policy = {
|
|
38
|
-
autoApprove: next.autoApprove ?? DEFAULT_POLICY.autoApprove,
|
|
39
|
-
requireApproval: next.requireApproval ?? DEFAULT_POLICY.requireApproval,
|
|
40
|
-
deny: next.deny ?? DEFAULT_POLICY.deny,
|
|
41
|
-
};
|
|
42
|
-
});
|
|
26
|
+
updateConfig((config) => { config.policy = next; });
|
|
43
27
|
}
|
|
44
28
|
|
|
45
29
|
function matches(pattern: string, value: string): boolean {
|
|
@@ -51,135 +35,130 @@ function matchesAny(patterns: string[], value: string): boolean {
|
|
|
51
35
|
return patterns.some((pattern) => matches(pattern, value));
|
|
52
36
|
}
|
|
53
37
|
|
|
54
|
-
/**
|
|
55
|
-
* Provider ids that require approval, derived from the policy patterns so the
|
|
56
|
-
* toggle list always reflects the configured file rather than a hardcoded set.
|
|
57
|
-
*/
|
|
38
|
+
/** Configured approval gates, independent of a session's live grants. */
|
|
58
39
|
export function gatedProviders(): string[] {
|
|
59
|
-
const names = loadPolicy().requireApproval
|
|
60
|
-
.map((pattern) => pattern.split("/")[0])
|
|
40
|
+
const names = loadPolicy().requireApproval.map((pattern) => pattern.split("/")[0])
|
|
61
41
|
.filter((name) => name && !name.includes("*"));
|
|
62
42
|
return [...new Set(names)].sort();
|
|
63
43
|
}
|
|
64
44
|
|
|
65
|
-
|
|
45
|
+
/** Runtime grants belong to an extension session, never a shared module or config file. */
|
|
46
|
+
export class ProviderPolicy {
|
|
47
|
+
private readonly approvals = new Map<string, number>();
|
|
48
|
+
private openRouterMode: "off" | "on" | "zdr" | undefined;
|
|
66
49
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) return "denied";
|
|
71
|
-
if (matchesAny(current.requireApproval, `${provider}/*`)) {
|
|
72
|
-
return isApproved(provider) ? "approved" : "blocked";
|
|
50
|
+
reset(): void {
|
|
51
|
+
this.approvals.clear();
|
|
52
|
+
this.openRouterMode = undefined;
|
|
73
53
|
}
|
|
74
|
-
return "auto";
|
|
75
|
-
}
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
*
|
|
80
|
-
* Auto-approved providers are moved into `requireApproval` so the switch is
|
|
81
|
-
* reversible; gated ones just gain or lose their session grant. Denied
|
|
82
|
-
* providers are left alone; `deny` is an explicit, deliberate block.
|
|
83
|
-
*/
|
|
84
|
-
export function toggleProvider(provider: string): ProviderState {
|
|
85
|
-
const state = providerState(provider);
|
|
86
|
-
if (state === "denied") return state;
|
|
87
|
-
|
|
88
|
-
if (state === "auto") {
|
|
89
|
-
const current = loadPolicy();
|
|
90
|
-
savePolicy({
|
|
91
|
-
...current,
|
|
92
|
-
autoApprove: current.autoApprove.filter((pattern) => !matches(pattern, `${provider}/*`) && pattern !== `${provider}/*`),
|
|
93
|
-
requireApproval: [...new Set([...current.requireApproval, `${provider}/*`])],
|
|
94
|
-
});
|
|
95
|
-
revoke(provider);
|
|
96
|
-
return "blocked";
|
|
55
|
+
openRouterZdrRequired(): boolean {
|
|
56
|
+
return this.openRouterMode === "zdr";
|
|
97
57
|
}
|
|
98
58
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
59
|
+
approveOpenRouterZdr(): void {
|
|
60
|
+
this.approve("openrouter");
|
|
61
|
+
this.openRouterMode = "zdr";
|
|
102
62
|
}
|
|
103
63
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
export function approve(provider: string, durationMs?: number): void {
|
|
110
|
-
approvals.set(provider, durationMs ? Date.now() + durationMs : Number.MAX_SAFE_INTEGER);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function revoke(provider: string): void {
|
|
114
|
-
approvals.delete(provider);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function isApproved(provider: string): boolean {
|
|
118
|
-
const until = approvals.get(provider);
|
|
119
|
-
return until !== undefined && Date.now() < until;
|
|
120
|
-
}
|
|
64
|
+
/** No duration means a grant for this session only. */
|
|
65
|
+
approve(provider: string, durationMs?: number): void {
|
|
66
|
+
this.approvals.set(provider, durationMs ? Date.now() + durationMs : Number.MAX_SAFE_INTEGER);
|
|
67
|
+
if (provider === "openrouter") this.openRouterMode = "on";
|
|
68
|
+
}
|
|
121
69
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
revoke(provider);
|
|
126
|
-
return false;
|
|
70
|
+
revoke(provider: string): void {
|
|
71
|
+
this.approvals.delete(provider);
|
|
72
|
+
if (provider === "openrouter") this.openRouterMode = "off";
|
|
127
73
|
}
|
|
128
|
-
approve(provider);
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
74
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
75
|
+
isApproved(provider: string): boolean {
|
|
76
|
+
const until = this.approvals.get(provider);
|
|
77
|
+
return until !== undefined && Date.now() < until;
|
|
78
|
+
}
|
|
137
79
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
80
|
+
providerState(provider: string): ProviderState {
|
|
81
|
+
const current = loadPolicy();
|
|
82
|
+
if (matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) return "denied";
|
|
83
|
+
if (provider === "openrouter" && this.openRouterMode !== undefined) {
|
|
84
|
+
return this.isApproved(provider) ? (this.openRouterZdrRequired() ? "zdr" : "approved") : "blocked";
|
|
85
|
+
}
|
|
86
|
+
if (matchesAny(current.requireApproval, `${provider}/*`)) {
|
|
87
|
+
return this.isApproved(provider) ? "approved" : "blocked";
|
|
88
|
+
}
|
|
89
|
+
return "auto";
|
|
90
|
+
}
|
|
146
91
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
92
|
+
/** Only OpenRouter cycles through a third (ZDR-only) state. */
|
|
93
|
+
toggleProvider(provider: string): ProviderState {
|
|
94
|
+
const state = this.providerState(provider);
|
|
95
|
+
if (state === "denied") return state;
|
|
96
|
+
if (provider === "openrouter") {
|
|
97
|
+
if (state === "auto" || state === "approved") this.approveOpenRouterZdr();
|
|
98
|
+
else if (state === "zdr") this.revoke(provider);
|
|
99
|
+
else this.approve(provider);
|
|
100
|
+
return this.providerState(provider);
|
|
101
|
+
}
|
|
102
|
+
if (state === "auto") {
|
|
103
|
+
const current = loadPolicy();
|
|
104
|
+
savePolicy({
|
|
105
|
+
...current,
|
|
106
|
+
autoApprove: current.autoApprove.filter((pattern) => !matches(pattern, `${provider}/*`)),
|
|
107
|
+
requireApproval: [...new Set([...current.requireApproval, `${provider}/*`])],
|
|
108
|
+
});
|
|
109
|
+
this.revoke(provider);
|
|
110
|
+
return "blocked";
|
|
111
|
+
}
|
|
112
|
+
return this.toggleApproval(provider) ? "approved" : "blocked";
|
|
113
|
+
}
|
|
150
114
|
|
|
151
|
-
|
|
152
|
-
|
|
115
|
+
toggleApproval(provider: string): boolean {
|
|
116
|
+
if (this.isApproved(provider)) { this.revoke(provider); return false; }
|
|
117
|
+
this.approve(provider);
|
|
118
|
+
return true;
|
|
153
119
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
reason: "needs-approval",
|
|
163
|
-
message:
|
|
164
|
-
`${ref} is a metered (pay-per-token) model and is not approved in this session. `
|
|
165
|
-
+ `Use a subscription model such as anthropic/* or openai-codex/*, or ask the user to run `
|
|
166
|
-
+ `/provider approve ${provider}.`,
|
|
167
|
-
};
|
|
120
|
+
|
|
121
|
+
approvalStates(): ProviderApproval[] {
|
|
122
|
+
const ids = new Set([...gatedProviders(), ...this.approvals.keys()]);
|
|
123
|
+
return [...ids].sort().map((provider) => {
|
|
124
|
+
const until = this.approvals.get(provider);
|
|
125
|
+
const approved = this.isApproved(provider);
|
|
126
|
+
return { provider, approved, until: approved && until !== Number.MAX_SAFE_INTEGER ? until : undefined };
|
|
127
|
+
});
|
|
168
128
|
}
|
|
169
129
|
|
|
170
|
-
|
|
171
|
-
|
|
130
|
+
checkModel(provider: string, modelId: string): Decision {
|
|
131
|
+
const current = loadPolicy();
|
|
132
|
+
const ref = `${provider}/${modelId}`;
|
|
133
|
+
if (matchesAny(current.deny, ref) || matchesAny(current.deny, `${provider}/*`) || matchesAny(current.deny, provider)) {
|
|
134
|
+
return { allowed: false, reason: "denied", message: `${ref} is denied by model policy.` };
|
|
135
|
+
}
|
|
136
|
+
// Explicit OpenRouter Off must override even an auto-approve wildcard.
|
|
137
|
+
const explicitOpenRouterMode = provider === "openrouter" && this.openRouterMode !== undefined;
|
|
138
|
+
if (!explicitOpenRouterMode && matchesAny(current.autoApprove, ref)) return { allowed: true, reason: "auto" };
|
|
139
|
+
if (!explicitOpenRouterMode && !matchesAny(current.requireApproval, ref)) return { allowed: true, reason: "auto" };
|
|
140
|
+
if (!this.isApproved(provider)) {
|
|
141
|
+
return {
|
|
142
|
+
allowed: false, reason: "needs-approval",
|
|
143
|
+
message: `${ref} is a metered (pay-per-token) model and is not approved in this session. `
|
|
144
|
+
+ "Use a subscription model such as anthropic/* or openai-codex/*, or ask the user to run "
|
|
145
|
+
+ `/provider approve ${provider}.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return { allowed: true, reason: "approved" };
|
|
149
|
+
}
|
|
172
150
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
151
|
+
policySummary(): string {
|
|
152
|
+
const current = loadPolicy();
|
|
153
|
+
const active = this.approvalStates().filter((entry) => entry.approved).map((entry) =>
|
|
154
|
+
`${entry.provider}${entry.provider === "openrouter" && this.openRouterZdrRequired() ? " (ZDR)" : ""}`
|
|
155
|
+
+ (entry.until ? ` (until ${new Date(entry.until).toLocaleTimeString()})` : " (session)"));
|
|
156
|
+
return [
|
|
157
|
+
"Model approval policy",
|
|
158
|
+
` auto-approved: ${current.autoApprove.join(", ") || "none"}`,
|
|
159
|
+
` needs approval: ${current.requireApproval.join(", ") || "none"}`,
|
|
160
|
+
` denied: ${current.deny.join(", ") || "none"}`,
|
|
161
|
+
` approved now: ${active.join(", ") || "none"}`,
|
|
162
|
+
].join("\n");
|
|
163
|
+
}
|
|
185
164
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { loadOAuthPool, saveOAuthAccount, sharedOAuthPoolStore, type PooledOAuthAccount } from "../accounts/oauth-pool.ts";
|
|
2
2
|
import { refreshAbortSignal, type AccountQuotaState } from "../accounts/routing.ts";
|
|
3
3
|
import { anthropicAccountIdentity, cachedAnthropicAccountIdentity } from "../anthropic/identity.ts";
|
|
4
|
-
import { loadAccounts,
|
|
5
|
-
import {
|
|
4
|
+
import { loadAccounts, type Account as AnthropicAccount } from "../anthropic/store.ts";
|
|
5
|
+
import { ensureAccessToken } from "../anthropic/quota.ts";
|
|
6
|
+
import { readClaudeQuota } from "../anthropic/usage-cache.ts";
|
|
6
7
|
import { claimsOf } from "../codex/store.ts";
|
|
7
|
-
import { fetchUserQuota } from "../gemini/client.ts";
|
|
8
|
+
import { fetchUserQuota, GeminiVerificationRequiredError } from "../gemini/client.ts";
|
|
8
9
|
import { credentialEmail } from "../gemini/credentials.ts";
|
|
9
10
|
import { geminiOAuth, requestProjectId } from "../gemini/oauth.ts";
|
|
10
11
|
import { summarizeGeminiQuota } from "../gemini/quota.ts";
|
|
@@ -15,9 +16,8 @@ import { CLAUDE_FRESH_MS, OBSERVED_PROVIDERS, type UsageRow } from "./pool.ts";
|
|
|
15
16
|
* Fetches subscription quota from the Claude, Codex and Gemini endpoints, and
|
|
16
17
|
* reports what response headers showed for providers with no usage endpoint.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* Scheduling, caching and retention belong to services/usage-service.ts.
|
|
19
|
+
* No pi imports or rendering. Claude quota/cooldown is shared in usage-cache.ts;
|
|
20
|
+
* display scheduling and retention belong to services/usage-service.ts.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
const TIMEOUT_MS = 10_000;
|
|
@@ -101,72 +101,6 @@ function resetToMs(value: unknown): number | undefined {
|
|
|
101
101
|
return undefined;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
async function claudeUsage(group: string, token: string): Promise<{ rows: UsageRow[]; error?: string }> {
|
|
105
|
-
const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
|
106
|
-
headers: { Authorization: `Bearer ${token}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json" },
|
|
107
|
-
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
108
|
-
});
|
|
109
|
-
if (!response.ok) return { rows: [], error: `${group}: HTTP ${response.status}` };
|
|
110
|
-
const body = await response.json() as any;
|
|
111
|
-
|
|
112
|
-
const rows: UsageRow[] = [];
|
|
113
|
-
const push = (label: string, window: any) => {
|
|
114
|
-
const used = pct(window?.utilization);
|
|
115
|
-
if (used === undefined) return;
|
|
116
|
-
rows.push({
|
|
117
|
-
group,
|
|
118
|
-
label,
|
|
119
|
-
remaining: 100 - used,
|
|
120
|
-
resetAt: resetToMs(window?.resets_at),
|
|
121
|
-
capacity: typeof window?.limit_dollars === "number" && window.limit_dollars > 0 ? window.limit_dollars : undefined,
|
|
122
|
-
});
|
|
123
|
-
};
|
|
124
|
-
push("5h", body.five_hour);
|
|
125
|
-
push("7d", body.seven_day);
|
|
126
|
-
// Scoped labels are lower case in every source (the stored snapshot keeps
|
|
127
|
-
// lower-cased ids), or one limit pools as two half-reported windows.
|
|
128
|
-
push("7d opus", body.seven_day_opus ?? body.seven_day_omelette);
|
|
129
|
-
push("7d sonnet", body.seven_day_sonnet);
|
|
130
|
-
|
|
131
|
-
for (const limit of Array.isArray(body.limits) ? body.limits : []) {
|
|
132
|
-
const scoped = limit?.scope?.model?.display_name;
|
|
133
|
-
const used = pct(limit?.percent);
|
|
134
|
-
if (typeof scoped !== "string" || !scoped || used === undefined) continue;
|
|
135
|
-
const label = `7d ${scoped.toLowerCase()}`;
|
|
136
|
-
if (rows.some((row) => row.label === label)) continue;
|
|
137
|
-
rows.push({ group, label, remaining: 100 - used, resetAt: resetToMs(limit?.resets_at) });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const extra = body.extra_usage;
|
|
141
|
-
if (extra?.is_enabled && pct(extra?.utilization) !== undefined) {
|
|
142
|
-
rows.push({ group, label: "Extra", remaining: 100 - pct(extra.utilization)! });
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return rows.length
|
|
146
|
-
? { rows: rows.map((row) => ({ ...row, checkedAt: Date.now() })) }
|
|
147
|
-
: { rows: [], error: `${group}: usage windows unavailable` };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* HUD polling must never wait indefinitely on OAuth refresh or retry it behind
|
|
152
|
-
* a live agent turn. The provider owns request-time refresh/retry policy.
|
|
153
|
-
*/
|
|
154
|
-
async function fallbackAccountToken(account: AnthropicAccount): Promise<string | undefined> {
|
|
155
|
-
const valid = typeof account.expires === "number" && Date.now() + 60_000 < account.expires;
|
|
156
|
-
if (valid && account.access) return account.access;
|
|
157
|
-
if (!account.refresh) return account.access;
|
|
158
|
-
|
|
159
|
-
const refreshed = await refreshToken({ refreshToken: account.refresh, maxRetries: 0 });
|
|
160
|
-
saveAccount({
|
|
161
|
-
...account,
|
|
162
|
-
access: refreshed.access,
|
|
163
|
-
refresh: refreshed.refresh,
|
|
164
|
-
expires: refreshed.expires,
|
|
165
|
-
lastRefreshedAt: Date.now(),
|
|
166
|
-
});
|
|
167
|
-
return refreshed.access;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
104
|
|
|
171
105
|
/**
|
|
172
106
|
* Builds usage rows from a stored quota snapshot.
|
|
@@ -190,11 +124,14 @@ function rowsFromSnapshot(group: string, quota: any): UsageRow[] | undefined {
|
|
|
190
124
|
resetAt: resetToMs(window.resetsAt),
|
|
191
125
|
// Required: pool.isFresh discards any row without it, which would make
|
|
192
126
|
// every cached row pool as "n/a".
|
|
193
|
-
checkedAt: window.checkedAt ?? quota.checkedAt
|
|
127
|
+
checkedAt: window.checkedAt ?? quota.checkedAt,
|
|
128
|
+
capacity: window.capacity,
|
|
129
|
+
stale: Date.now() - (window.checkedAt ?? quota.checkedAt ?? 0) >= CLAUDE_FRESH_MS,
|
|
194
130
|
});
|
|
195
131
|
};
|
|
196
132
|
push("5h", quota.five_hour);
|
|
197
133
|
push("7d", quota.seven_day);
|
|
134
|
+
push("Extra", quota.extra);
|
|
198
135
|
for (const scoped of Array.isArray(quota.scoped) ? quota.scoped : []) {
|
|
199
136
|
if (typeof scoped?.remainingPercent !== "number" || typeof scoped?.id !== "string" || !scoped.id) continue;
|
|
200
137
|
// Snapshots written before the fix restate the 5h/7d windows as "scoped".
|
|
@@ -213,7 +150,7 @@ export async function fetchClaudeRows(
|
|
|
213
150
|
const read = options.readCredential ?? readAuthFile;
|
|
214
151
|
const rows: UsageRow[] = [];
|
|
215
152
|
const errors: string[] = [];
|
|
216
|
-
const accounts: Array<{ group: string; token?: string; account?: AnthropicAccount }> = [];
|
|
153
|
+
const accounts: Array<{ group: string; token?: string; identity?: string; account?: AnthropicAccount }> = [];
|
|
217
154
|
const groups = new Set<string>();
|
|
218
155
|
|
|
219
156
|
let sidecars: AnthropicAccount[] = [];
|
|
@@ -234,9 +171,9 @@ export async function fetchClaudeRows(
|
|
|
234
171
|
// Counting it twice filed every window twice and marked the pool partial.
|
|
235
172
|
const identity = cachedAnthropicAccountIdentity(primary.access)
|
|
236
173
|
?? await anthropicAccountIdentity(primary.access).catch(() => undefined);
|
|
237
|
-
const twin =
|
|
174
|
+
const twin = sidecars.find((account) => account.access === primary.access || (identity && account.identity === identity));
|
|
238
175
|
if (!twin) {
|
|
239
|
-
accounts.push({ group: primaryGroup, token: primary.access });
|
|
176
|
+
accounts.push({ group: primaryGroup, token: primary.access, identity });
|
|
240
177
|
groups.add(primaryGroup);
|
|
241
178
|
}
|
|
242
179
|
}
|
|
@@ -246,28 +183,28 @@ export async function fetchClaudeRows(
|
|
|
246
183
|
errors.push(`${primaryGroup}: ${errorText(error)}`);
|
|
247
184
|
}
|
|
248
185
|
|
|
186
|
+
const seen = new Set<string>();
|
|
249
187
|
for (const account of sidecars) {
|
|
188
|
+
const key = account.identity ?? account.access ?? account.id;
|
|
189
|
+
if (seen.has(key)) continue;
|
|
190
|
+
seen.add(key);
|
|
250
191
|
accounts.push({ group: claudeGroup(account), account });
|
|
251
192
|
groups.add(claudeGroup(account));
|
|
252
193
|
}
|
|
253
194
|
|
|
254
195
|
for (const entry of accounts) {
|
|
255
196
|
try {
|
|
256
|
-
|
|
257
|
-
const cached = rowsFromSnapshot(entry.group, entry.account?.quota);
|
|
258
|
-
if (cached) {
|
|
259
|
-
rows.push(...cached);
|
|
260
|
-
continue;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const token = entry.token ?? (entry.account ? await fallbackAccountToken(entry.account) : undefined);
|
|
197
|
+
const token = entry.token ?? (entry.account ? await ensureAccessToken(entry.account) : undefined);
|
|
264
198
|
if (!token) {
|
|
265
199
|
errors.push(`${entry.group}: no token`);
|
|
266
200
|
continue;
|
|
267
201
|
}
|
|
268
|
-
const result = await
|
|
269
|
-
|
|
270
|
-
|
|
202
|
+
const result = await readClaudeQuota({
|
|
203
|
+
access: token, identity: entry.identity ?? entry.account?.identity,
|
|
204
|
+
id: entry.account?.id, quota: entry.account?.quota,
|
|
205
|
+
});
|
|
206
|
+
rows.push(...(rowsFromSnapshot(entry.group, result.quota) ?? []));
|
|
207
|
+
if (result.error) errors.push(`${entry.group}: ${result.error}`);
|
|
271
208
|
} catch (error) {
|
|
272
209
|
const message = error instanceof Error ? error.message : String(error);
|
|
273
210
|
errors.push(/invalid_grant/i.test(message)
|
|
@@ -436,13 +373,14 @@ export async function fetchGeminiRows(
|
|
|
436
373
|
return rows.length ? { rows } : { rows, error: `${entry.group}: quota unavailable` };
|
|
437
374
|
} catch (error) {
|
|
438
375
|
const message = errorText(error);
|
|
376
|
+
const reauth = entry.account ? `/accounts reauth gemini ${entry.account.label || entry.account.id}` : "/login gemini";
|
|
439
377
|
return {
|
|
440
378
|
rows: [],
|
|
441
|
-
error:
|
|
442
|
-
? `${entry.group}:
|
|
443
|
-
|
|
444
|
-
:
|
|
445
|
-
|
|
379
|
+
error: error instanceof GeminiVerificationRequiredError
|
|
380
|
+
? `${entry.group}: Google account verification required; run ${reauth} to complete verification.`
|
|
381
|
+
: /invalid_grant/i.test(message)
|
|
382
|
+
? `${entry.group}: login expired, run ${reauth}`
|
|
383
|
+
: `${entry.group}: ${message}`,
|
|
446
384
|
};
|
|
447
385
|
}
|
|
448
386
|
}));
|