pi-gemini-oauth-apikey 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # pi-gemini-oauth-apikey
2
+
3
+ Multi-account **Google Cloud Code Assist (Gemini CLI) OAuth rotation pool** for
4
+ [pi](https://pi.dev). Register N Google accounts as separate providers and
5
+ auto-rotate on 429/`RESOURCE_EXHAUSTED` — get ~N × 1500 RPD of free Gemini quota.
6
+
7
+ Complements [`pi-key-pool`](https://github.com/ssdiwu/pi-key-pool) (which rotates
8
+ **API keys**): this package rotates **OAuth accounts** (Code Assist tier,
9
+ ~6× the per-account RPD, separate quota bucket).
10
+
11
+ ## What it does
12
+
13
+ - Registers `gemini-pool-1` … `gemini-pool-N` as OAuth providers
14
+ - Each is independently loggable via `/login`
15
+ - On a 429/rate-limit error, marks the current account exhausted, switches to
16
+ the next available one, and **replays your last message automatically**
17
+ - Time-based cooldown recovery (quota 60s, capacity 30s, network skipped)
18
+
19
+ ## Why
20
+
21
+ `pi-multi-pass` supports multi-account OAuth rotation but its Gemini login
22
+ (`loginGeminiCli`) was removed from upstream pi, so `/subs login google-gemini-cli-N`
23
+ throws `is not a function`. This package reuses
24
+ [`@qraxiss/pi-gemini-auth`](https://github.com/qraxiss/pi-gemini-auth) (which
25
+ restored that OAuth flow as a standalone package) for the login/provider
26
+ implementation, and adds the rotation layer on top. No pi-core patching.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pi install npm:pi-gemini-oauth-apikey
32
+ ```
33
+
34
+ Then restart pi. (You can now uninstall `@qraxiss/pi-gemini-auth` as an active
35
+ extension — this package depends on it as a library instead.)
36
+
37
+ ## Configure
38
+
39
+ Optional. Edit `~/.pi/agent/gemini-pool.json` (defaults to 4 slots if absent):
40
+
41
+ ```json
42
+ {
43
+ "accounts": [
44
+ { "id": "1", "label": "Work Pro" },
45
+ { "id": "2", "label": "Personal Pro" },
46
+ { "id": "3", "label": "Side Pro" },
47
+ { "id": "4", "label": "Backup Pro" }
48
+ ],
49
+ "cooldown": { "quota": 60000, "capacity": 30000, "network": 0 }
50
+ }
51
+ ```
52
+
53
+ ## Login each account
54
+
55
+ ```
56
+ /login → pick "Gemini Pool 1" → browser OAuth (account 1)
57
+ /login → pick "Gemini Pool 2" → account 2
58
+ ... (repeat per account)
59
+ /model gemini-pool-1/gemini-2.5-flash
60
+ ```
61
+
62
+ That's it. When `gemini-pool-1` hits a 429, the turn transparently retries on
63
+ `gemini-pool-2`, and so on. When all are exhausted you get a warning and a
64
+ cooldown timer.
65
+
66
+ ## How it works
67
+
68
+ ```
69
+ before_agent_start → remember last user prompt
70
+ agent_end → if stopReason=="error" && isRateLimit(msg):
71
+ markExhausted(current); pickNext() → setModel + sendUserMessage
72
+ ```
73
+
74
+ `failover.ts` is a pure, unit-tested state machine (round-robin + cooldown +
75
+ auth-gated availability). Provider/OAuth/streaming come from qraxiss.
76
+
77
+ ## Tests
78
+
79
+ ```bash
80
+ node --test --experimental-strip-types test/*.test.ts
81
+ ```
82
+
83
+ Stdlib only (`node:test`) — no test dependencies.
84
+
85
+ ## License
86
+
87
+ MIT
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "pi-gemini-oauth-apikey",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Multi-account Google Gemini (Cloud Code Assist OAuth) rotation pool for pi — register N accounts, auto-rotate on 429. Depends on @qraxiss/pi-gemini-auth for the OAuth/provider implementation.",
6
+ "license": "MIT",
7
+ "author": "vforvaick",
8
+ "keywords": ["pi-package", "pi-extension", "gemini", "oauth", "rotation", "pool", "multi-account"],
9
+ "repository": { "type": "git", "url": "git+https://github.com/vforvaick/pi-gemini-oauth-apikey.git" },
10
+ "dependencies": {
11
+ "@qraxiss/pi-gemini-auth": "^0.5.1"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test --experimental-strip-types test/*.test.ts"
15
+ },
16
+ "pi": {
17
+ "extensions": ["./src/index.ts"]
18
+ }
19
+ }
package/src/config.ts ADDED
@@ -0,0 +1,57 @@
1
+ // ponytail: minimal config loader. ~/.pi/agent/gemini-pool.json optional;
2
+ // defaults to 4 slots so it works zero-config for a typical multi-Pro setup.
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ export interface Account {
8
+ id: string; // provider id: gemini-pool-<id>
9
+ label: string; // /login display name
10
+ }
11
+
12
+ export interface PoolConfig {
13
+ accounts: Account[];
14
+ cooldown?: { capacity?: number; quota?: number; network?: number };
15
+ }
16
+
17
+ export function configPath(): string {
18
+ return join(homedir(), ".pi", "agent", "gemini-pool.json");
19
+ }
20
+
21
+ export function defaultConfig(): PoolConfig {
22
+ return {
23
+ accounts: [1, 2, 3, 4].map((i) => ({
24
+ id: `${i}`,
25
+ label: `Gemini Pool ${i}`,
26
+ })),
27
+ };
28
+ }
29
+
30
+ export function providerName(account: Account): string {
31
+ return `gemini-pool-${account.id}`;
32
+ }
33
+
34
+ export function normalize(raw: unknown): PoolConfig {
35
+ const r = (raw ?? {}) as { accounts?: unknown; cooldown?: unknown };
36
+ const accounts: Account[] = Array.isArray(r.accounts)
37
+ ? r.accounts
38
+ .map((a, i) => {
39
+ const obj = (a ?? {}) as { id?: string; label?: string };
40
+ const id = String(obj.id ?? i + 1);
41
+ return { id, label: obj.label || `Gemini Pool ${id}` };
42
+ })
43
+ .filter((a) => a.id)
44
+ : [];
45
+ if (accounts.length === 0) return defaultConfig();
46
+ return { accounts, cooldown: (r.cooldown ?? {}) as PoolConfig["cooldown"] };
47
+ }
48
+
49
+ export function loadConfig(): PoolConfig {
50
+ const path = configPath();
51
+ if (!existsSync(path)) return defaultConfig();
52
+ try {
53
+ return normalize(JSON.parse(readFileSync(path, "utf-8")));
54
+ } catch {
55
+ return defaultConfig(); // malformed → safe default, don't crash pi
56
+ }
57
+ }
@@ -0,0 +1,95 @@
1
+ // ponytail: pure state machine, zero pi deps, fully unit-testable.
2
+ // Cooldown + round-robin rotation over a fixed member list.
3
+
4
+ export type CooldownReason = "capacity" | "quota" | "network";
5
+
6
+ export interface CooldownConfig {
7
+ capacity: number; // overloaded / 503 / 529 — transient
8
+ quota: number; // 429 / RESOURCE_EXHAUSTED — standard recovery
9
+ network: number; // network errors — don't blame the key (0 = no cooldown)
10
+ }
11
+
12
+ export const DEFAULT_COOLDOWN: CooldownConfig = {
13
+ capacity: 30_000,
14
+ quota: 60_000,
15
+ network: 0,
16
+ };
17
+
18
+ const RATE_LIMIT_PATTERNS = [
19
+ /429/,
20
+ /resource_exhausted/i,
21
+ /quota/i,
22
+ /rate.?limit/i,
23
+ /too many requests/i,
24
+ /overloaded/i,
25
+ /capacity/i,
26
+ /usage.?limit/i,
27
+ /limit.*reached/i,
28
+ ];
29
+
30
+ export function isRateLimitError(msg: string): boolean {
31
+ return RATE_LIMIT_PATTERNS.some((p) => p.test(msg));
32
+ }
33
+
34
+ export function classifyError(msg: string): CooldownReason {
35
+ if (/\b(503|529)\b|overloaded|high demand|capacity/i.test(msg)) return "capacity";
36
+ if (/network|timeout|econn|socket/i.test(msg)) return "network";
37
+ return "quota";
38
+ }
39
+
40
+ export interface FailoverPool {
41
+ members: string[];
42
+ cooldown: CooldownConfig;
43
+ exhausted: Map<string, { reason: CooldownReason; until: number }>;
44
+ }
45
+
46
+ export function createPool(
47
+ members: string[],
48
+ cooldown: CooldownConfig = DEFAULT_COOLDOWN,
49
+ ): FailoverPool {
50
+ return { members, cooldown, exhausted: new Map() };
51
+ }
52
+
53
+ export function markExhausted(
54
+ pool: FailoverPool,
55
+ member: string,
56
+ reason: CooldownReason,
57
+ now: number,
58
+ ): void {
59
+ const cd = pool.cooldown[reason];
60
+ if (cd <= 0) return; // network: don't blame the member
61
+ pool.exhausted.set(member, { reason, until: now + cd });
62
+ }
63
+
64
+ export function isAvailable(
65
+ pool: FailoverPool,
66
+ member: string,
67
+ now: number,
68
+ hasAuth: boolean,
69
+ ): boolean {
70
+ if (!hasAuth) return false;
71
+ const e = pool.exhausted.get(member);
72
+ if (!e) return true;
73
+ if (now >= e.until) {
74
+ pool.exhausted.delete(member); // cooldown elapsed → member recovers
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ // Round-robin from the member after `current`, first available wins. null if all down.
81
+ export function pickNext(
82
+ pool: FailoverPool,
83
+ current: string | null,
84
+ now: number,
85
+ hasAuthFn: (m: string) => boolean,
86
+ ): string | null {
87
+ if (pool.members.length === 0) return null;
88
+ const startIdx = current ? pool.members.indexOf(current) : -1;
89
+ for (let i = 1; i <= pool.members.length; i++) {
90
+ const idx = (startIdx + i) % pool.members.length;
91
+ const m = pool.members[idx];
92
+ if (isAvailable(pool, m, now, hasAuthFn(m))) return m;
93
+ }
94
+ return null;
95
+ }
package/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ // pi-gemini-oauth-apikey: register N Google Cloud Code Assist (Gemini CLI)
2
+ // OAuth accounts as separate providers and auto-rotate on 429.
3
+ //
4
+ // Reuses @qraxiss/pi-gemini-auth for the OAuth/provider implementation (rule 5:
5
+ // already-installed dependency). This package only adds multi-account rotation.
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ GEMINI_CLI_API,
9
+ streamSimpleGoogleGeminiCli,
10
+ } from "@qraxiss/pi-gemini-auth/src/gemini-cli-provider";
11
+ import { geminiOAuthOverride } from "@qraxiss/pi-gemini-auth/src/gemini-oauth";
12
+ import { GEMINI_CLI_MODELS } from "@qraxiss/pi-gemini-auth/src/models";
13
+ import {
14
+ createPool,
15
+ markExhausted,
16
+ pickNext,
17
+ isRateLimitError,
18
+ classifyError,
19
+ DEFAULT_COOLDOWN,
20
+ type CooldownConfig,
21
+ } from "./failover.ts";
22
+ import { loadConfig, providerName, type Account } from "./config.ts";
23
+
24
+ const BASE_URL = "https://cloudcode-pa.googleapis.com";
25
+
26
+ // Clone qraxiss models under a pool provider id (keep id/name/cost etc, swap provider).
27
+ function cloneModelsFor(provider: string) {
28
+ return GEMINI_CLI_MODELS.map((m) => ({ ...m, provider }));
29
+ }
30
+
31
+ export default function geminiPool(pi: ExtensionAPI) {
32
+ const config = loadConfig();
33
+ if (config.accounts.length === 0) return;
34
+
35
+ const cooldown: CooldownConfig = {
36
+ capacity: config.cooldown?.capacity ?? DEFAULT_COOLDOWN.capacity,
37
+ quota: config.cooldown?.quota ?? DEFAULT_COOLDOWN.quota,
38
+ network: config.cooldown?.network ?? DEFAULT_COOLDOWN.network,
39
+ };
40
+
41
+ const members = config.accounts.map(providerName);
42
+ const pool = createPool(members, cooldown);
43
+ const memberSet = new Set(members);
44
+
45
+ // Register each account as its own OAuth provider. /login lists them by label.
46
+ for (const account of config.accounts) {
47
+ const name = providerName(account);
48
+ pi.registerProvider(name, {
49
+ name: account.label,
50
+ api: GEMINI_CLI_API,
51
+ baseUrl: BASE_URL,
52
+ oauth: { ...geminiOAuthOverride, name: account.label },
53
+ streamSimple: streamSimpleGoogleGeminiCli,
54
+ models: cloneModelsFor(name),
55
+ });
56
+ }
57
+
58
+ let lastUserPrompt: string | null = null;
59
+
60
+ pi.on("before_agent_start", async (event: any) => {
61
+ lastUserPrompt = event?.prompt ?? lastUserPrompt;
62
+ });
63
+
64
+ pi.on("agent_end", async (event: any, ctx: any) => {
65
+ const messages = event?.messages;
66
+ if (!Array.isArray(messages) || messages.length === 0) return;
67
+ const last = messages[messages.length - 1];
68
+ if (last?.role !== "assistant" || last.stopReason !== "error") return;
69
+ const errorMsg: string = last.errorMessage || "";
70
+ if (!errorMsg || !isRateLimitError(errorMsg)) return;
71
+
72
+ const currentProvider = ctx?.model?.provider;
73
+ if (!currentProvider || !memberSet.has(currentProvider)) return; // not our pool
74
+
75
+ markExhausted(pool, currentProvider, classifyError(errorMsg), Date.now());
76
+
77
+ const hasAuth = (m: string) => ctx.modelRegistry?.authStorage?.hasAuth(m) ?? false;
78
+ const next = pickNext(pool, currentProvider, Date.now(), hasAuth);
79
+ if (!next) {
80
+ ctx?.ui?.notify?.(
81
+ `[gemini-pool] All ${members.length} members rate-limited. Retry in ~${Math.round(
82
+ cooldown.quota / 1000,
83
+ )}s.`,
84
+ "warning",
85
+ );
86
+ return;
87
+ }
88
+
89
+ const nextModel = ctx?.modelRegistry?.find?.(next, ctx.model?.id);
90
+ if (!nextModel) return;
91
+ const switched = await pi.setModel?.(nextModel);
92
+ if (switched !== false && lastUserPrompt) {
93
+ pi.sendUserMessage?.(lastUserPrompt); // replay the failed turn
94
+ }
95
+ });
96
+ }
@@ -0,0 +1,33 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { normalize, defaultConfig, providerName } from "../src/config.ts";
4
+
5
+ test("defaultConfig: 4 accounts, sequential ids, distinct provider names", () => {
6
+ const d = defaultConfig();
7
+ assert.equal(d.accounts.length, 4);
8
+ assert.deepEqual(
9
+ d.accounts.map(providerName),
10
+ ["gemini-pool-1", "gemini-pool-2", "gemini-pool-3", "gemini-pool-4"],
11
+ );
12
+ });
13
+
14
+ test("normalize: explicit accounts preserved with labels", () => {
15
+ const c = normalize({ accounts: [{ id: "work", label: "Work Pro" }, { id: "8" }] });
16
+ assert.equal(c.accounts.length, 2);
17
+ assert.equal(c.accounts[0].label, "Work Pro");
18
+ assert.equal(providerName(c.accounts[1]), "gemini-pool-8");
19
+ // missing label gets generated default
20
+ assert.match(c.accounts[1].label, /Gemini Pool 8/);
21
+ });
22
+
23
+ test("normalize: empty/garbage input falls back to default (never zero accounts)", () => {
24
+ assert.equal(normalize({ accounts: [] }).accounts.length, 4);
25
+ assert.equal(normalize({}).accounts.length, 4);
26
+ assert.equal(normalize(null).accounts.length, 4);
27
+ assert.equal(normalize("garbage").accounts.length, 4);
28
+ });
29
+
30
+ test("normalize: cooldown passed through when present", () => {
31
+ const c = normalize({ accounts: [{ id: "1" }], cooldown: { quota: 120000 } });
32
+ assert.equal(c.cooldown?.quota, 120000);
33
+ });
@@ -0,0 +1,72 @@
1
+ // ponytail: stdlib only (node:test) — no vitest dep for a ~120-line package.
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import {
5
+ createPool, markExhausted, isAvailable, pickNext,
6
+ isRateLimitError, classifyError, DEFAULT_COOLDOWN,
7
+ } from "../src/failover.ts";
8
+
9
+ const NOW = 1_000_000;
10
+ const has = () => true;
11
+ const hasNot = (_: string) => false;
12
+
13
+ test("isRateLimitError: 429 / quota / RESOURCE_EXHAUSTED → true; unrelated → false", () => {
14
+ assert.equal(isRateLimitError("429 Too Many Requests"), true);
15
+ assert.equal(isRateLimitError("Quota exceeded for metric x"), true);
16
+ assert.equal(isRateLimitError("RESOURCE_EXHAUSTED"), true);
17
+ assert.equal(isRateLimitError("connection refused"), false);
18
+ assert.equal(isRateLimitError(""), false);
19
+ });
20
+
21
+ test("classifyError: capacity vs quota vs network", () => {
22
+ assert.equal(classifyError("503 high demand"), "capacity");
23
+ assert.equal(classifyError("overloaded"), "capacity");
24
+ assert.equal(classifyError("ETIMEDOUT network reset"), "network");
25
+ assert.equal(classifyError("429 quota exceeded"), "quota");
26
+ });
27
+
28
+ test("fresh pool: authed member available, unauthed not", () => {
29
+ const p = createPool(["a", "b", "c"]);
30
+ assert.equal(isAvailable(p, "a", NOW, true), true); // member + authed
31
+ assert.equal(isAvailable(p, "a", NOW, false), false); // member but not authed → skip
32
+ });
33
+
34
+ test("markExhausted makes member unavailable until cooldown elapses", () => {
35
+ const p = createPool(["a", "b"]);
36
+ markExhausted(p, "a", "quota", NOW);
37
+ assert.equal(isAvailable(p, "a", NOW, true), false); // immediately after
38
+ assert.equal(isAvailable(p, "a", NOW + DEFAULT_COOLDOWN.quota - 1, true), false); // just before
39
+ assert.equal(isAvailable(p, "a", NOW + DEFAULT_COOLDOWN.quota, true), true); // at expiry → recovered
40
+ assert.equal(p.exhausted.has("a"), false, "expired entry should be cleared on probe");
41
+ });
42
+
43
+ test("pickNext rotates past current, skipping exhausted and unauthed", () => {
44
+ const p = createPool(["a", "b", "c"]);
45
+ markExhausted(p, "b", "quota", NOW);
46
+ // current=a → next available is c (b exhausted)
47
+ assert.equal(pickNext(p, "a", NOW, has), "c");
48
+ // current=c → wraps to a
49
+ assert.equal(pickNext(p, "c", NOW, has), "a");
50
+ // current=null → first available
51
+ assert.equal(pickNext(p, null, NOW, has), "a");
52
+ });
53
+
54
+ test("pickNext returns null when every member is down", () => {
55
+ const p = createPool(["a", "b"]);
56
+ markExhausted(p, "a", "quota", NOW);
57
+ markExhausted(p, "b", "quota", NOW);
58
+ assert.equal(pickNext(p, "a", NOW, has), null);
59
+ });
60
+
61
+ test("pickNext skips unauthenticated members", () => {
62
+ const p = createPool(["a", "b", "c"]);
63
+ const authedOnly = (m: string) => m === "b";
64
+ assert.equal(pickNext(p, "a", NOW, authedOnly), "b");
65
+ });
66
+
67
+ test("network reason (cooldown 0) never marks exhausted", () => {
68
+ const p = createPool(["a"]);
69
+ markExhausted(p, "a", "network", NOW);
70
+ assert.equal(p.exhausted.has("a"), false);
71
+ assert.equal(isAvailable(p, "a", NOW, true), true);
72
+ });