pi-gproxy 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LeenHawk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # pi-gproxy
2
+
3
+ [English](https://github.com/LeenHawk/pi-gproxy/blob/main/README.md) | [简体中文](https://github.com/LeenHawk/pi-gproxy/blob/main/README.zh-CN.md)
4
+
5
+ Sign in to [Pi](https://github.com/badlogic/pi-mono) with your
6
+ [GPROXY](https://github.com/LeenHawk/gproxy) account, discover the models you can
7
+ access, and use different upstream providers through one gateway.
8
+
9
+ This is a standalone **GPROXY** provider. It does not modify Pi, replace Codex,
10
+ or ask for your upstream provider credentials.
11
+
12
+ ## Requirements
13
+
14
+ - Node.js **22.19.0 or newer**.
15
+ - Pi **0.85.1**, the tested compatibility baseline. Package peer dependencies
16
+ accept `>=0.85.1 <0.86.0`; other versions are not verified.
17
+ - A GPROXY v3 instance with unified account OAuth, such as
18
+ [v3 staging](https://github.com/LeenHawk/gproxy/releases/tag/staging), and a
19
+ GPROXY user account with access to at least one model.
20
+
21
+ ## Install and sign in
22
+
23
+ 1. In **GPROXY Console → Settings → OAuth clients**, enable the preconfigured
24
+ `pi-gproxy` client. Keep the redirect URL
25
+ `http://127.0.0.1/oauth/callback` without a port: browser login allocates a
26
+ local port automatically.
27
+ 2. Install the extension:
28
+
29
+ ```sh
30
+ pi install npm:pi-gproxy
31
+ ```
32
+
33
+ To pin the first release, use `pi install npm:pi-gproxy@0.1.0`.
34
+ 3. Start Pi, run `/login`, and select **GPROXY**.
35
+ 4. Enter your instance origin, for example `https://gproxy.example.com`.
36
+ Use HTTPS, or HTTP on loopback for local testing, such as
37
+ `http://127.0.0.1:8787`. Subpaths, URL credentials, queries and fragments
38
+ are not accepted.
39
+ 5. Choose **Browser** on your own machine, or **Device code** for SSH, containers
40
+ and approval from another device. Sign in to the GPROXY Portal and approve
41
+ the request. Only approve a login you initiated.
42
+ 6. Run `/model` and select a GPROXY model.
43
+
44
+ The login authorizes your GPROXY account, not an upstream account. Your
45
+ administrator's current model permissions still apply.
46
+
47
+ ## Commands
48
+
49
+ | Command | Purpose |
50
+ | --- | --- |
51
+ | `/login` → GPROXY | Sign in through browser PKCE or device authorization. |
52
+ | `/gproxy-refresh` | Force a refresh of models available to this account. |
53
+ | `/fast [on\|off]` | Toggle priority service for a selected GPROXY model that advertises it. |
54
+ | `/logout` | Remove Pi's local credentials; this does not revoke the server session. |
55
+
56
+ `/fast` is scoped to the model and authorization and lasts only for the current
57
+ Pi process. Models without the advertised `priority` tier are not modified.
58
+ Inference uses Pi's standard Responses streaming implementation at
59
+ `/v1/responses`; WebSocket transport is not required.
60
+
61
+ ## Credentials, sessions and billing
62
+
63
+ - Pi stores the OAuth credentials and serializes token refresh. The instance
64
+ origin is bound to the credential; refresh only contacts that instance's
65
+ `/oauth/token`. This extension does not maintain another credential file or
66
+ fall back to OpenAI.
67
+ - Model discovery uses `/v1/models`. Pi's model store caches the catalog per
68
+ instance and authorization, including restoration after a restart. Normal
69
+ refreshes use a 60-second cache window; `/gproxy-refresh` forces a new request.
70
+ - Rejected refresh or model authentication stops use of that authorization and
71
+ prompts you to sign in again.
72
+ - To revoke access on the server, open **GPROXY Portal → Authorized sessions**
73
+ and revoke the session. Pi's built-in `/logout` only removes local credentials.
74
+ - Cancelling device login makes a best-effort request to cancel the pending
75
+ server flow. Browser cancellation closes the local callback listener. An
76
+ authorization that never exchanges a code for tokens is not counted as a login.
77
+ - **Pi's zero-cost placeholder does not mean free usage and is not your GPROXY
78
+ bill.** Actual usage and charges are recorded by GPROXY. This extension does
79
+ not substitute prices from external model catalogs.
80
+ - Context limits, output limits, image input and thinking levels come from
81
+ model discovery. When limits are missing, Pi's required fields use conservative
82
+ defaults of 8192 context tokens and 4096 output tokens. Ask the administrator
83
+ to complete the model metadata.
84
+
85
+ ## Troubleshooting
86
+
87
+ - **Invalid or disabled client:** enable `pi-gproxy` in Console and check the
88
+ registered callback URL.
89
+ - **No GPROXY models:** run `/gproxy-refresh` and check your account's model
90
+ permissions. Complete login normally rather than copying only an auth file:
91
+ Pi also needs the discovered model catalog.
92
+ - **Expired or revoked authorization:** run `/login` again. Re-enabling a client
93
+ does not restore previously revoked sessions.
94
+ - **Cannot use a local browser callback:** cancel and choose device-code login.
95
+
96
+ ## Development
97
+
98
+ ```sh
99
+ git clone https://github.com/LeenHawk/pi-gproxy.git
100
+ cd pi-gproxy
101
+ npm ci
102
+ npm run check
103
+ npm pack --dry-run
104
+ ```
105
+
106
+ Load the checkout with `pi -e /absolute/path/to/pi-gproxy/src/index.ts`, or install
107
+ the local directory with `pi install /absolute/path/to/pi-gproxy`.
108
+
109
+ - `src/auth/`: browser PKCE, device authorization, refresh and cancellation.
110
+ - `src/models.ts`: explicit GPROXY model-to-Pi capability mapping.
111
+ - `src/provider.ts`: native Pi provider, OAuth, catalog cache and Responses streaming.
112
+ - `src/index.ts`: registration and the model-refresh/priority commands.
113
+
114
+ Tests use local simulated endpoints for state checks, PKCE, cancellation,
115
+ serialized refresh and cache isolation. Local gateway integration also exercised
116
+ OpenAI/Claude streaming, tool round trips and settlement with simulated upstreams.
117
+ These checks do not establish production-provider compatibility.
118
+
119
+ ## License
120
+
121
+ [MIT](LICENSE).
@@ -0,0 +1,97 @@
1
+ # pi-gproxy
2
+
3
+ [English](https://github.com/LeenHawk/pi-gproxy/blob/main/README.md) | [简体中文](https://github.com/LeenHawk/pi-gproxy/blob/main/README.zh-CN.md)
4
+
5
+ 用 GPROXY 用户账户登录 [Pi](https://github.com/badlogic/pi-mono),发现账户允许使用的模型,
6
+ 并通过 [GPROXY](https://github.com/LeenHawk/gproxy) 调用不同上游渠道。
7
+
8
+ 这是独立的 **GPROXY** Provider,不修改 Pi,不替换或冒充 Codex,也不要求你提供上游凭证。
9
+
10
+ ## 使用要求
11
+
12
+ - Node.js **22.19.0 或更高**。
13
+ - Pi **0.85.1**,这是已验证的兼容基线。包依赖范围为 `>=0.85.1 <0.86.0`,其他版本尚未验证。
14
+ - 支持统一账户 OAuth 的 GPROXY v3 实例,例如
15
+ [v3 staging](https://github.com/LeenHawk/gproxy/releases/tag/staging),以及至少有一个可用模型的 GPROXY 用户账户。
16
+
17
+ ## 安装和登录
18
+
19
+ 1. 在 **GPROXY Console → 设置 → OAuth 客户端** 中启用预置的 `pi-gproxy`。
20
+ 保留回调地址 `http://127.0.0.1/oauth/callback`,不要填写端口:浏览器登录时会自动分配本机端口。
21
+ 2. 安装扩展:
22
+
23
+ ```sh
24
+ pi install npm:pi-gproxy
25
+ ```
26
+
27
+ 要固定使用首个版本,运行 `pi install npm:pi-gproxy@0.1.0`。
28
+ 3. 启动 Pi,输入 `/login`,选择 **GPROXY**。
29
+ 4. 输入实例根地址,例如 `https://gproxy.example.com`。应使用 HTTPS;
30
+ 本机测试可用 `http://127.0.0.1:8787`。不接受子路径、URL 内的账号密码、查询参数或片段。
31
+ 5. 本机登录选 **Browser**;SSH、容器或跨设备确认选 **Device code**。
32
+ 在 GPROXY Portal 登录并允许授权,只批准由你自己发起的登录请求。
33
+ 6. 输入 `/model`,选择 GPROXY 模型。
34
+
35
+ 授权的是你的 GPROXY 账户,不是上游账户;管理员设置的当前模型权限仍然生效。
36
+
37
+ ## 命令
38
+
39
+ | 命令 | 用途 |
40
+ | --- | --- |
41
+ | `/login` → GPROXY | 使用浏览器 PKCE 或设备码登录。 |
42
+ | `/gproxy-refresh` | 强制刷新当前账户可访问的模型。 |
43
+ | `/fast [on\|off]` | 为当前明确支持优先档位的 GPROXY 模型切换优先服务。 |
44
+ | `/logout` | 删除 Pi 本地凭证,不会撤销服务器会话。 |
45
+
46
+ `/fast` 按模型和授权隔离,只在当前 Pi 进程中保留;不会给没有公布 `priority` 档位的模型强塞参数。
47
+ 推理复用 Pi 的标准 Responses 流式实现访问 `/v1/responses`,不强制使用 WebSocket。
48
+
49
+ ## 凭证、会话和账单
50
+
51
+ - OAuth 凭证只交给 Pi 存储,刷新由 Pi 串行化。实例地址与凭证绑定,只请求原实例的
52
+ `/oauth/token`;扩展不另建一份凭证文件,也不会回退到 OpenAI。
53
+ - 模型通过 `/v1/models` 发现,并使用 Pi 的模型存储,按实例和授权隔离缓存。
54
+ 重启后可恢复同一授权的目录;普通刷新有 60 秒缓存窗口,`/gproxy-refresh` 强制重拉。
55
+ - 刷新或模型认证被拒绝后,停止使用该授权并提示重新登录。
56
+ - 要在服务器上彻底撤销访问,请到 **GPROXY Portal → 授权会话** 撤销对应记录。
57
+ Pi 内置的 `/logout` 只删除本地凭证。
58
+ - 取消设备码登录时,会尽力通知服务器取消待完成流程;取消浏览器登录时会关闭本地回调监听。
59
+ 没有成功换取 token 的授权,不计入登录次数。
60
+ - **Pi 显示的零成本占位不代表免费,也不是 GPROXY 实际账单。**
61
+ 用量和费用以 GPROXY 的记录为准,本扩展不借用外部模型目录的价格冒充实际收费。
62
+ - 上下文、输出上限、图片输入和思考档位来自模型发现。缺少限额信息时,Pi 必填字段暂取保守的
63
+ 8192 上下文 token / 4096 输出 token;请让管理员补全模型元数据。
64
+
65
+ ## 常见问题
66
+
67
+ - **客户端无效或未启用:** 在 Console 启用 `pi-gproxy`,并检查回调地址。
68
+ - **没有 GPROXY 模型:** 运行 `/gproxy-refresh`,检查账户模型权限。
69
+ 请正常完成登录,不要只复制认证文件:Pi 还需要登录后发现的模型目录。
70
+ - **授权过期或已撤销:** 重新运行 `/login`。重新启用客户端不会恢复之前已撤销的会话。
71
+ - **无法使用本机浏览器回调:** 取消当前流程,改用设备码登录。
72
+
73
+ ## 开发者
74
+
75
+ ```sh
76
+ git clone https://github.com/LeenHawk/pi-gproxy.git
77
+ cd pi-gproxy
78
+ npm ci
79
+ npm run check
80
+ npm pack --dry-run
81
+ ```
82
+
83
+ 用 `pi -e /absolute/path/to/pi-gproxy/src/index.ts` 临时加载工作区,
84
+ 或用 `pi install /absolute/path/to/pi-gproxy` 安装本地目录。
85
+
86
+ - `src/auth/`:浏览器 PKCE、设备码、刷新和取消流程。
87
+ - `src/models.ts`:GPROXY 模型到 Pi 模型能力的显式映射。
88
+ - `src/provider.ts`:Pi 原生 Provider、OAuth、目录缓存和 Responses 流式调用。
89
+ - `src/index.ts`:注册扩展、刷新模型和切换优先服务的命令。
90
+
91
+ 测试通过本机模拟端点验证 state、PKCE、取消、串行刷新和缓存隔离。
92
+ 本地网关联调还使用模拟 OpenAI / Claude 上游验证了流式响应、工具多轮调用和结算。
93
+ 这些检查不代表真实生产供应商兼容性验证。
94
+
95
+ ## 许可证
96
+
97
+ [MIT](LICENSE)。
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "pi-gproxy",
3
+ "version": "0.1.0",
4
+ "description": "GPROXY account OAuth and model discovery for Pi",
5
+ "license": "MIT",
6
+ "repository": { "type": "git", "url": "git+https://github.com/LeenHawk/pi-gproxy.git" },
7
+ "homepage": "https://github.com/LeenHawk/pi-gproxy#readme",
8
+ "bugs": { "url": "https://github.com/LeenHawk/pi-gproxy/issues" },
9
+ "keywords": ["pi-package", "pi", "gproxy", "oauth", "llm"],
10
+ "files": ["src/", "README.md", "README.zh-CN.md", "LICENSE"],
11
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
12
+ "type": "module",
13
+ "engines": { "node": ">=22.19.0" },
14
+ "pi": { "extensions": ["./src/index.ts"] },
15
+ "scripts": {
16
+ "typecheck": "tsc --noEmit",
17
+ "lint": "eslint .",
18
+ "test": "node --experimental-strip-types --test test/*.test.ts",
19
+ "check": "npm run typecheck && npm run lint && npm run test",
20
+ "prepublishOnly": "npm run check"
21
+ },
22
+ "peerDependencies": {
23
+ "@earendil-works/pi-ai": ">=0.85.1 <0.86.0",
24
+ "@earendil-works/pi-coding-agent": ">=0.85.1 <0.86.0"
25
+ },
26
+ "devDependencies": {
27
+ "@earendil-works/pi-ai": "0.85.1",
28
+ "@earendil-works/pi-coding-agent": "0.85.1",
29
+ "@eslint/js": "9.39.1",
30
+ "@types/node": "24.10.0",
31
+ "eslint": "9.39.1",
32
+ "typescript": "5.9.3",
33
+ "typescript-eslint": "8.48.0"
34
+ }
35
+ }
@@ -0,0 +1,60 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import type { AddressInfo } from "node:net";
4
+ import type { ProviderAuthInteraction } from "@earendil-works/pi-ai";
5
+ import { CLIENT_ID, SCOPE, post, tokens } from "./wire.ts";
6
+
7
+ export async function browserLogin(instance: string, interaction: ProviderAuthInteraction) {
8
+ const verifier = randomBytes(48).toString("base64url");
9
+ const state = randomBytes(32).toString("base64url");
10
+ const signal = AbortSignal.any([interaction.signal, AbortSignal.timeout(300_000)]);
11
+ let redirect = "";
12
+ let complete: (code: string) => void;
13
+ let fail: (error: Error) => void;
14
+ const callback = new Promise<string>((resolve, reject) => { complete = resolve; fail = reject; });
15
+ void callback.catch(() => {});
16
+ const server = createServer((request, response) => {
17
+ response.setHeader("cache-control", "no-store");
18
+ response.setHeader("referrer-policy", "no-referrer");
19
+ response.setHeader("content-type", "text/plain; charset=utf-8");
20
+ const url = new URL(request.url ?? "/", redirect);
21
+ if (request.method !== "GET" || request.headers.host !== new URL(redirect).host || url.pathname !== "/oauth/callback") {
22
+ response.writeHead(404).end("Not found"); return;
23
+ }
24
+ if (url.searchParams.getAll("state").length !== 1 || url.searchParams.get("state") !== state) {
25
+ response.writeHead(400).end("Invalid authorization state. Return to the login you started."); return;
26
+ }
27
+ if (url.searchParams.has("error")) {
28
+ response.writeHead(400).end("Authorization was denied. Return to Pi.");
29
+ fail(new Error("GPROXY authorization denied.")); return;
30
+ }
31
+ const code = url.searchParams.get("code");
32
+ if (!code || url.searchParams.getAll("code").length !== 1) {
33
+ response.writeHead(400).end("Missing authorization code"); return;
34
+ }
35
+ response.end("GPROXY authorization received. Return to Pi to finish signing in.");
36
+ complete(code);
37
+ });
38
+ const abort = () => fail(signal.reason instanceof Error ? signal.reason : new Error("Login cancelled"));
39
+ signal.addEventListener("abort", abort, { once: true });
40
+ try {
41
+ signal.throwIfAborted();
42
+ await new Promise<void>((resolve, reject) => {
43
+ server.once("error", reject);
44
+ server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
45
+ });
46
+ redirect = `http://127.0.0.1:${(server.address() as AddressInfo).port}/oauth/callback`;
47
+ const authorization = new URL(`${instance}/oauth/authorize`);
48
+ authorization.search = new URLSearchParams({
49
+ response_type: "code", client_id: CLIENT_ID, redirect_uri: redirect, scope: SCOPE,
50
+ code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", state,
51
+ }).toString();
52
+ interaction.notify({ type: "auth_url", url: authorization.href, instructions: "Authorize your GPROXY account in a browser on this machine. For SSH or another device, cancel and choose device code login." });
53
+ const code = await callback;
54
+ return tokens(instance, await post(instance, "/oauth/token", { grant_type: "authorization_code", code, redirect_uri: redirect, code_verifier: verifier }, signal));
55
+ } finally {
56
+ signal.removeEventListener("abort", abort);
57
+ server.closeAllConnections();
58
+ await new Promise<void>((resolve) => server.close(() => resolve()));
59
+ }
60
+ }
@@ -0,0 +1,33 @@
1
+ import { setTimeout } from "node:timers/promises";
2
+ import type { ProviderAuthInteraction } from "@earendil-works/pi-ai";
3
+ import { OAuthRequestError, SCOPE, post, tokens } from "./wire.ts";
4
+
5
+ export async function deviceLogin(instance: string, interaction: ProviderAuthInteraction) {
6
+ const started = await post(instance, "/oauth/device/code", { scope: SCOPE }, interaction.signal);
7
+ if (typeof started.device_code !== "string" || typeof started.user_code !== "string" || typeof started.verification_uri !== "string" || typeof started.expires_in !== "number" || !Number.isFinite(started.expires_in) || started.expires_in <= 0) {
8
+ throw new Error("Invalid GPROXY device authorization response.");
9
+ }
10
+ const verification = new URL(typeof started.verification_uri_complete === "string" ? started.verification_uri_complete : started.verification_uri);
11
+ if (verification.origin !== instance) throw new Error("Device authorization redirected to another instance.");
12
+ let interval = typeof started.interval === "number" && Number.isFinite(started.interval) ? Math.max(1, started.interval) : 5;
13
+ const signal = AbortSignal.any([interaction.signal, AbortSignal.timeout(Math.min(started.expires_in, 900) * 1_000)]);
14
+ interaction.notify({ type: "device_code", userCode: started.user_code, verificationUri: verification.href, intervalSeconds: interval, expiresInSeconds: started.expires_in });
15
+ let finished = false;
16
+ try {
17
+ for (;;) {
18
+ await setTimeout(interval * 1_000, undefined, { signal });
19
+ try {
20
+ const response = await post(instance, "/oauth/token", { grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: started.device_code }, signal);
21
+ const result = tokens(instance, response);
22
+ finished = true;
23
+ return result;
24
+ } catch (error) {
25
+ if (!(error instanceof OAuthRequestError)) throw error;
26
+ if (error.code === "slow_down") { interval += 5; continue; }
27
+ if (error.code !== "authorization_pending") throw error;
28
+ }
29
+ }
30
+ } finally {
31
+ if (!finished) await post(instance, "/oauth/device/cancel", { device_code: started.device_code }, AbortSignal.timeout(5_000)).catch(() => {});
32
+ }
33
+ }
@@ -0,0 +1,34 @@
1
+ import type { OAuthAuth } from "@earendil-works/pi-ai";
2
+ import { browserLogin } from "./browser.ts";
3
+ import { deviceLogin } from "./device.ts";
4
+ import { credential, instanceUrl, OAuthRequestError, post, tokens, type GproxyCredential } from "./wire.ts";
5
+
6
+ export function oauth(invalidate: (value: GproxyCredential) => void, check: (value: GproxyCredential) => void): OAuthAuth {
7
+ return {
8
+ name: "GPROXY", isSubscription: false,
9
+ async login(interaction) {
10
+ const instance = instanceUrl(await interaction.prompt({ type: "text", message: "GPROXY instance URL", placeholder: "https://gproxy.example.com" }));
11
+ const flow = await interaction.prompt({ type: "select", message: "Sign in to GPROXY", options: [
12
+ { id: "browser", label: "Browser (this machine)", description: "Authorization code with PKCE and a local callback." },
13
+ { id: "device", label: "Device code (SSH or another device)", description: "Approve a short code in your GPROXY portal." },
14
+ ] });
15
+ if (flow === "browser") return browserLogin(instance, interaction);
16
+ if (flow === "device") return deviceLogin(instance, interaction);
17
+ throw new Error("GPROXY login cancelled.");
18
+ },
19
+ async refresh(value, signal) {
20
+ const previous = credential(value);
21
+ try {
22
+ return tokens(previous.instance, await post(previous.instance, "/oauth/token", { grant_type: "refresh_token", refresh_token: previous.refresh }, signal), previous);
23
+ } catch (error) {
24
+ if (error instanceof OAuthRequestError && ["invalid_grant", "invalid_client"].includes(error.code)) invalidate(previous);
25
+ throw error;
26
+ }
27
+ },
28
+ async toAuth(value) {
29
+ const current = credential(value);
30
+ check(current);
31
+ return { apiKey: current.access, baseUrl: `${current.instance}/v1` };
32
+ },
33
+ };
34
+ }
@@ -0,0 +1,61 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { OAuthCredential } from "@earendil-works/pi-ai";
3
+
4
+ export const CLIENT_ID = "pi-gproxy";
5
+ export const SCOPE = "gproxy";
6
+
7
+ export type GproxyCredential = OAuthCredential & { instance: string; authorization: string };
8
+
9
+ export class OAuthRequestError extends Error {
10
+ readonly code: string;
11
+ readonly status: number;
12
+
13
+ constructor(code: string, status: number) {
14
+ super(`GPROXY OAuth: ${code} (HTTP ${status}).${["invalid_grant", "invalid_client"].includes(code) ? " Run /login GPROXY again; check that the client is enabled." : ""}`);
15
+ this.code = code;
16
+ this.status = status;
17
+ }
18
+ }
19
+
20
+ export function instanceUrl(input: string): string {
21
+ const url = new URL(input.trim());
22
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
23
+ if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.search || url.hash || url.pathname !== "/") {
24
+ throw new Error("Use an HTTPS instance origin, or HTTP on localhost. Paths, credentials, query strings and fragments are not allowed.");
25
+ }
26
+ return url.origin;
27
+ }
28
+
29
+ export function credential(value: OAuthCredential): GproxyCredential {
30
+ if (typeof value.instance !== "string" || typeof value.authorization !== "string" || !value.authorization || !value.access || !value.refresh || !Number.isFinite(value.expires)) {
31
+ throw new Error("Invalid GPROXY credential. Run /login GPROXY again.");
32
+ }
33
+ return { ...value, instance: instanceUrl(value.instance), authorization: value.authorization };
34
+ }
35
+
36
+ export async function post(instance: string, path: string, values: Record<string, string>, signal: AbortSignal): Promise<Record<string, unknown>> {
37
+ const response = await fetch(`${instance}${path}`, {
38
+ method: "POST", redirect: "error", cache: "no-store", signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
39
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
40
+ body: new URLSearchParams({ client_id: CLIENT_ID, ...values }),
41
+ });
42
+ const payload: unknown = await response.json();
43
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid GPROXY OAuth response.");
44
+ const record = payload as Record<string, unknown>;
45
+ if (!response.ok) {
46
+ const code = typeof record.error === "string" && /^[a-z_]+$/.test(record.error) ? record.error : "request_failed";
47
+ throw new OAuthRequestError(code, response.status);
48
+ }
49
+ return record;
50
+ }
51
+
52
+ export function tokens(instance: string, response: Record<string, unknown>, previous?: GproxyCredential): GproxyCredential {
53
+ if (typeof response.access_token !== "string" || !response.access_token || typeof response.refresh_token !== "string" || !response.refresh_token || typeof response.expires_in !== "number" || !Number.isFinite(response.expires_in) || response.expires_in <= 0 || response.token_type !== "Bearer") {
54
+ throw new Error("GPROXY did not return a complete OAuth token set.");
55
+ }
56
+ return {
57
+ type: "oauth", instance, authorization: previous?.authorization ?? randomUUID(),
58
+ access: response.access_token, refresh: response.refresh_token,
59
+ expires: Date.now() + Math.max(1, response.expires_in - 30) * 1_000,
60
+ };
61
+ }
package/src/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createGproxyProvider } from "./provider.ts";
3
+
4
+ export default function gproxy(pi: ExtensionAPI) {
5
+ const { provider, setFast } = createGproxyProvider();
6
+ pi.registerProvider(provider);
7
+ pi.registerCommand("gproxy-refresh", {
8
+ description: "Refresh models allowed by your GPROXY account",
9
+ handler: async (_args, context) => {
10
+ const result = await context.modelRegistry.refresh({ providers: ["gproxy"], allowNetwork: true, force: true });
11
+ const error = result.errors.get("gproxy");
12
+ context.ui.notify(error?.message ?? `GPROXY: ${provider.getModels().length} models. Billing is shown in the GPROXY portal, not Pi's estimates.`, error ? "error" : "info");
13
+ },
14
+ });
15
+ pi.registerCommand("fast", {
16
+ description: "Toggle GPROXY priority service for a model that advertises it",
17
+ handler: async (args, context) => {
18
+ const value = args.trim();
19
+ if (!["", "on", "off"].includes(value)) { context.ui.notify("Usage: /fast [on|off]", "warning"); return; }
20
+ const enabled = context.model ? setFast(context.model, value === "" ? undefined : value === "on") : undefined;
21
+ context.ui.notify(enabled === undefined ? "Select a GPROXY model that advertises the priority service tier." : `GPROXY priority service ${enabled ? "on" : "off"}. Actual charges are recorded by GPROXY.`, enabled === undefined ? "warning" : "info");
22
+ },
23
+ });
24
+ }
package/src/models.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Api, Model, ModelThinkingLevel, ThinkingLevelMap } from "@earendil-works/pi-ai";
3
+ import type { GproxyCredential } from "./auth/wire.ts";
4
+
5
+ export type GproxyModel = Model<"openai-responses"> & {
6
+ gproxy: { authorization: string; priority: boolean };
7
+ };
8
+
9
+ export function authorizationKey(value: GproxyCredential): string {
10
+ return createHash("sha256").update(`${value.instance}\0${value.authorization}`).digest("hex");
11
+ }
12
+
13
+ function record(value: unknown): Record<string, unknown> {
14
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid GPROXY model catalog.");
15
+ return value as Record<string, unknown>;
16
+ }
17
+
18
+ function identifiers(value: unknown, field: string): string[] {
19
+ if (!Array.isArray(value)) return [];
20
+ return value.flatMap((entry) => {
21
+ if (typeof entry === "string") return [entry];
22
+ if (!entry || typeof entry !== "object") return [];
23
+ const name: unknown = entry[field];
24
+ return typeof name === "string" ? [name] : [];
25
+ });
26
+ }
27
+
28
+ function positive(...values: unknown[]): number | undefined {
29
+ return values.find((value): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0);
30
+ }
31
+
32
+ export function mapModels(payload: unknown, current: GproxyCredential): GproxyModel[] {
33
+ const catalog = record(payload);
34
+ if (!Array.isArray(catalog.data)) throw new Error("GPROXY /v1/models did not return a model list.");
35
+ return catalog.data.map((entry) => {
36
+ const model = record(entry);
37
+ if (typeof model.id !== "string" || !model.id) throw new Error("GPROXY returned a model without an ID.");
38
+ const efforts = identifiers(model.supported_reasoning_levels, "effort");
39
+ const parameters = identifiers(model.supported_parameters, "id");
40
+ const levels: ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
41
+ const thinkingLevelMap: ThinkingLevelMap | undefined = efforts.length ? Object.fromEntries(levels.map((level) => {
42
+ const effort = level === "off" ? "none" : level;
43
+ return [level, efforts.includes(effort) ? effort : null];
44
+ })) : undefined;
45
+ const contextWindow = positive(model.context_window, model.context_length, model.max_context_window) ?? 8_192;
46
+ return {
47
+ id: model.id, name: typeof model.display_name === "string" ? model.display_name : model.id,
48
+ api: "openai-responses", provider: "gproxy", baseUrl: `${current.instance}/v1`,
49
+ reasoning: efforts.some((effort) => effort !== "none") || parameters.includes("reasoning"),
50
+ thinkingLevelMap, input: identifiers(model.input_modalities, "type").includes("image") ? ["text", "image"] : ["text"],
51
+ contextWindow, maxTokens: Math.min(contextWindow, positive(model.max_completion_tokens, model.max_output_tokens) ?? 4_096),
52
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
53
+ compat: { supportsLongCacheRetention: false, supportsStrictMode: false },
54
+ gproxy: { authorization: authorizationKey(current), priority: identifiers(model.service_tiers, "id").includes("priority") },
55
+ };
56
+ });
57
+ }
58
+
59
+ export function matchesAuthorization(model: Model<Api>, current: GproxyCredential): model is GproxyModel {
60
+ const data = (model as Partial<GproxyModel>).gproxy;
61
+ return model.provider === "gproxy" && model.api === "openai-responses" && model.baseUrl === `${current.instance}/v1` && data?.authorization === authorizationKey(current);
62
+ }
@@ -0,0 +1,77 @@
1
+ import type { Api, Model, Provider, RefreshModelsContext, StreamOptions } from "@earendil-works/pi-ai";
2
+ import { openAIResponsesApi } from "@earendil-works/pi-ai/compat";
3
+ import { oauth } from "./auth/index.ts";
4
+ import { credential, type GproxyCredential } from "./auth/wire.ts";
5
+ import { authorizationKey, mapModels, matchesAuthorization, type GproxyModel } from "./models.ts";
6
+
7
+ const { stream, streamSimple } = openAIResponsesApi();
8
+
9
+ export function createGproxyProvider() {
10
+ let models: GproxyModel[] = [];
11
+ const invalid = new Set<string>();
12
+ const fast = new Set<string>();
13
+ const key = (model: GproxyModel) => `${model.gproxy.authorization}:${model.id}`;
14
+ const check = (current: GproxyCredential) => {
15
+ if (invalid.has(authorizationKey(current))) throw new Error("GPROXY authorization is no longer valid. Run /login GPROXY again.");
16
+ };
17
+ const invalidate = (current: GproxyCredential) => {
18
+ invalid.add(authorizationKey(current));
19
+ models = models.filter((model) => !matchesAuthorization(model, current));
20
+ };
21
+ const options = <Options extends StreamOptions>(model: Model<"openai-responses">, original?: Options): Options => ({
22
+ ...original,
23
+ onPayload: async (payload, selected) => {
24
+ const modified = await original?.onPayload?.(payload, selected);
25
+ const body = modified ?? payload;
26
+ const entry = models.find((entry) => entry.id === model.id && entry.baseUrl === model.baseUrl);
27
+ if (entry?.gproxy.priority && fast.has(key(entry)) && body && typeof body === "object") return { ...body, service_tier: "priority" };
28
+ return body;
29
+ },
30
+ } as Options);
31
+
32
+ const provider: Provider<"openai-responses"> = {
33
+ id: "gproxy", name: "GPROXY",
34
+ auth: { oauth: oauth(invalidate, check) },
35
+ getModels: () => models,
36
+ filterModels: (available, value) => {
37
+ if (value?.type !== "oauth") return [];
38
+ const current = credential(value);
39
+ return invalid.has(authorizationKey(current)) ? [] : available.filter((model) => matchesAuthorization(model, current));
40
+ },
41
+ async refreshModels(context: RefreshModelsContext) {
42
+ if (context.credential?.type !== "oauth") {
43
+ await context.publish({ persist: null, update: () => { models = []; } });
44
+ return;
45
+ }
46
+ const current = credential(context.credential);
47
+ check(current);
48
+ const restored = (context.stored?.models ?? []).filter((model) => matchesAuthorization(model, current));
49
+ await context.publish({ update: () => { models = restored; } });
50
+ if (!context.allowNetwork || (!context.force && restored.length && Date.now() - (context.stored?.checkedAt ?? 0) < 60_000)) return;
51
+ const response = await fetch(`${current.instance}/v1/models`, {
52
+ headers: { authorization: `Bearer ${current.access}`, accept: "application/json" },
53
+ signal: AbortSignal.any([context.signal, AbortSignal.timeout(30_000)]), redirect: "error", cache: "no-store",
54
+ });
55
+ if (response.status === 401) {
56
+ invalidate(current);
57
+ await context.publish({ persist: null, update: () => { models = []; } });
58
+ throw new Error("GPROXY authorization was revoked or expired. Run /login GPROXY again.");
59
+ }
60
+ if (!response.ok) throw new Error(`GPROXY model discovery failed (HTTP ${response.status}).`);
61
+ const discovered = mapModels(await response.json(), current);
62
+ await context.publish({ persist: { models: discovered, checkedAt: Date.now() }, update: () => { models = discovered; } });
63
+ },
64
+ stream: (model, context, original) => stream(model, context, options(model, original)),
65
+ streamSimple: (model, context, original) => streamSimple(model, context, options(model, original)),
66
+ };
67
+ return {
68
+ provider,
69
+ setFast(model: Model<Api>, enabled?: boolean): boolean | undefined {
70
+ const current = models.find((entry) => entry.id === model.id && entry.baseUrl === model.baseUrl && model.provider === "gproxy");
71
+ if (!current?.gproxy.priority) return undefined;
72
+ const active = enabled ?? !fast.has(key(current));
73
+ if (active) fast.add(key(current)); else fast.delete(key(current));
74
+ return active;
75
+ },
76
+ };
77
+ }