@oh-my-pi/pi-ai 6.8.0 → 6.8.2
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/package.json +2 -2
- package/src/cli.ts +12 -0
- package/src/providers/google-gemini-cli.ts +6 -1
- package/src/providers/openai-codex-responses.ts +3 -2
- package/src/storage.ts +9 -3
- package/src/utils/oauth/anthropic.ts +6 -1
- package/src/utils/oauth/callback-server.ts +22 -13
- package/src/utils/oauth/cursor.ts +6 -2
- package/src/utils/oauth/github-copilot.ts +15 -3
- package/src/utils/oauth/openai-codex.ts +54 -108
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "6.8.
|
|
3
|
+
"version": "6.8.2",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"test": "bun test"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@oh-my-pi/pi-utils": "6.8.
|
|
20
|
+
"@oh-my-pi/pi-utils": "6.8.2",
|
|
21
21
|
"@anthropic-ai/sdk": "0.71.2",
|
|
22
22
|
"@aws-sdk/client-bedrock-runtime": "^3.968.0",
|
|
23
23
|
"@bufbuild/protobuf": "^2.10.2",
|
package/src/cli.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { createInterface } from "readline";
|
|
|
3
3
|
import { CliAuthStorage } from "./storage";
|
|
4
4
|
import "./utils/migrate-env";
|
|
5
5
|
import { loginAnthropic } from "./utils/oauth/anthropic";
|
|
6
|
+
import { loginCursor } from "./utils/oauth/cursor";
|
|
6
7
|
import { loginGitHubCopilot } from "./utils/oauth/github-copilot";
|
|
7
8
|
import { loginAntigravity } from "./utils/oauth/google-antigravity";
|
|
8
9
|
import { loginGeminiCli } from "./utils/oauth/google-gemini-cli";
|
|
@@ -88,6 +89,17 @@ async function login(provider: OAuthProvider): Promise<void> {
|
|
|
88
89
|
});
|
|
89
90
|
break;
|
|
90
91
|
|
|
92
|
+
case "cursor":
|
|
93
|
+
credentials = await loginCursor(
|
|
94
|
+
(url) => {
|
|
95
|
+
console.log(`\nOpen this URL in your browser:\n${url}\n`);
|
|
96
|
+
},
|
|
97
|
+
() => {
|
|
98
|
+
console.log("Waiting for browser authentication...");
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
break;
|
|
102
|
+
|
|
91
103
|
default:
|
|
92
104
|
throw new Error(`Unknown provider: ${provider}`);
|
|
93
105
|
}
|
|
@@ -753,7 +753,12 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
|
|
|
753
753
|
|
|
754
754
|
if (emptyAttempt > 0) {
|
|
755
755
|
const backoffMs = EMPTY_STREAM_BASE_DELAY_MS * 2 ** (emptyAttempt - 1);
|
|
756
|
-
|
|
756
|
+
try {
|
|
757
|
+
await abortableSleep(backoffMs, options?.signal);
|
|
758
|
+
} catch {
|
|
759
|
+
// Normalize AbortError to expected message for consistent error handling
|
|
760
|
+
throw new Error("Request was aborted");
|
|
761
|
+
}
|
|
757
762
|
|
|
758
763
|
if (!requestUrl) {
|
|
759
764
|
throw new Error("Missing request URL");
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
|
+
import { abortableSleep } from "@oh-my-pi/pi-utils";
|
|
2
3
|
import type {
|
|
3
4
|
ResponseFunctionToolCall,
|
|
4
5
|
ResponseInput,
|
|
@@ -440,13 +441,13 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
|
|
|
440
441
|
}
|
|
441
442
|
if (signal?.aborted) return response;
|
|
442
443
|
const delay = getRetryDelayMs(response, attempt);
|
|
443
|
-
await
|
|
444
|
+
await abortableSleep(delay, signal);
|
|
444
445
|
} catch (error) {
|
|
445
446
|
if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
|
|
446
447
|
throw error;
|
|
447
448
|
}
|
|
448
449
|
const delay = CODEX_RETRY_DELAY_MS * (attempt + 1);
|
|
449
|
-
await
|
|
450
|
+
await abortableSleep(delay, signal);
|
|
450
451
|
}
|
|
451
452
|
attempt += 1;
|
|
452
453
|
}
|
package/src/storage.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { Database } from "bun:sqlite";
|
|
7
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import type { OAuthCredentials } from "./utils/oauth/types";
|
|
@@ -88,13 +88,19 @@ export class CliAuthStorage {
|
|
|
88
88
|
private deleteByProviderStmt: ReturnType<Database["prepare"]>;
|
|
89
89
|
|
|
90
90
|
constructor(dbPath: string = getAgentDbPath()) {
|
|
91
|
-
// Ensure directory exists
|
|
91
|
+
// Ensure directory exists with secure permissions
|
|
92
92
|
const dir = dirname(dbPath);
|
|
93
93
|
if (!existsSync(dir)) {
|
|
94
|
-
mkdirSync(dir, { recursive: true });
|
|
94
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
this.db = new Database(dbPath);
|
|
98
|
+
// Harden database file permissions to prevent credential leakage
|
|
99
|
+
try {
|
|
100
|
+
chmodSync(dbPath, 0o600);
|
|
101
|
+
} catch {
|
|
102
|
+
// Ignore chmod failures (e.g., Windows)
|
|
103
|
+
}
|
|
98
104
|
this.initializeSchema();
|
|
99
105
|
|
|
100
106
|
this.insertStmt = this.db.prepare(
|
|
@@ -63,7 +63,12 @@ class AnthropicOAuthFlow extends OAuthCallbackFlow {
|
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
if (!tokenResponse.ok) {
|
|
66
|
-
|
|
66
|
+
let error: string;
|
|
67
|
+
try {
|
|
68
|
+
error = await tokenResponse.text();
|
|
69
|
+
} catch {
|
|
70
|
+
error = `HTTP ${tokenResponse.status}`;
|
|
71
|
+
}
|
|
67
72
|
throw new Error(`Token exchange failed: ${error}`);
|
|
68
73
|
}
|
|
69
74
|
|
|
@@ -158,12 +158,14 @@ export abstract class OAuthCallbackFlow {
|
|
|
158
158
|
resultState = { ok: true, code, state };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
// Signal to waitForCallback
|
|
161
|
+
// Signal to waitForCallback - capture refs before they could be cleared
|
|
162
|
+
const resolve = this.callbackResolve;
|
|
163
|
+
const reject = this.callbackReject;
|
|
162
164
|
queueMicrotask(() => {
|
|
163
165
|
if (resultState.ok) {
|
|
164
|
-
|
|
166
|
+
resolve?.({ code: resultState.code, state: resultState.state });
|
|
165
167
|
} else {
|
|
166
|
-
|
|
168
|
+
reject?.(resultState.error ?? "Unknown error");
|
|
167
169
|
}
|
|
168
170
|
});
|
|
169
171
|
|
|
@@ -195,17 +197,24 @@ export abstract class OAuthCallbackFlow {
|
|
|
195
197
|
});
|
|
196
198
|
|
|
197
199
|
// Manual input race (if supported)
|
|
200
|
+
// Errors from manual input should not abort the flow - only successful input wins the race
|
|
198
201
|
if (this.ctrl.onManualCodeInput) {
|
|
199
|
-
const manualPromise = this.ctrl
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
202
|
+
const manualPromise = this.ctrl
|
|
203
|
+
.onManualCodeInput()
|
|
204
|
+
.then((input): CallbackResult => {
|
|
205
|
+
const parsed = parseCallbackInput(input);
|
|
206
|
+
if (!parsed.code) {
|
|
207
|
+
throw new Error("No authorization code found in input");
|
|
208
|
+
}
|
|
209
|
+
if (expectedState && parsed.state && parsed.state !== expectedState) {
|
|
210
|
+
throw new Error("State mismatch - possible CSRF attack");
|
|
211
|
+
}
|
|
212
|
+
return { code: parsed.code, state: parsed.state ?? "" };
|
|
213
|
+
})
|
|
214
|
+
.catch((): Promise<CallbackResult> => {
|
|
215
|
+
// On manual input error, wait forever - let callback or abort signal win
|
|
216
|
+
return new Promise(() => {});
|
|
217
|
+
});
|
|
209
218
|
|
|
210
219
|
return Promise.race([callbackPromise, manualPromise]);
|
|
211
220
|
}
|
|
@@ -126,12 +126,16 @@ export async function refreshCursorToken(apiKeyOrRefreshToken: string): Promise<
|
|
|
126
126
|
|
|
127
127
|
function getTokenExpiry(token: string): number {
|
|
128
128
|
try {
|
|
129
|
-
const
|
|
129
|
+
const parts = token.split(".");
|
|
130
|
+
if (parts.length !== 3) {
|
|
131
|
+
return Date.now() + 3600 * 1000;
|
|
132
|
+
}
|
|
133
|
+
const payload = parts[1];
|
|
130
134
|
if (!payload) {
|
|
131
135
|
return Date.now() + 3600 * 1000;
|
|
132
136
|
}
|
|
133
137
|
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
|
134
|
-
if (decoded.exp) {
|
|
138
|
+
if (decoded && typeof decoded === "object" && typeof decoded.exp === "number") {
|
|
135
139
|
return decoded.exp * 1000 - 5 * 60 * 1000;
|
|
136
140
|
}
|
|
137
141
|
} catch {
|
|
@@ -174,20 +174,32 @@ async function pollForGitHubAccessToken(
|
|
|
174
174
|
if (raw && typeof raw === "object" && typeof (raw as DeviceTokenErrorResponse).error === "string") {
|
|
175
175
|
const err = (raw as DeviceTokenErrorResponse).error;
|
|
176
176
|
if (err === "authorization_pending") {
|
|
177
|
-
|
|
177
|
+
try {
|
|
178
|
+
await abortableSleep(intervalMs, signal);
|
|
179
|
+
} catch {
|
|
180
|
+
throw new Error("Login cancelled");
|
|
181
|
+
}
|
|
178
182
|
continue;
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
if (err === "slow_down") {
|
|
182
186
|
intervalMs += 5000;
|
|
183
|
-
|
|
187
|
+
try {
|
|
188
|
+
await abortableSleep(intervalMs, signal);
|
|
189
|
+
} catch {
|
|
190
|
+
throw new Error("Login cancelled");
|
|
191
|
+
}
|
|
184
192
|
continue;
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
throw new Error(`Device flow failed: ${err}`);
|
|
188
196
|
}
|
|
189
197
|
|
|
190
|
-
|
|
198
|
+
try {
|
|
199
|
+
await abortableSleep(intervalMs, signal);
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error("Login cancelled");
|
|
202
|
+
}
|
|
191
203
|
}
|
|
192
204
|
|
|
193
205
|
throw new Error("Device flow timed out");
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* OpenAI Codex (ChatGPT OAuth) flow
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { OAuthCallbackFlow
|
|
5
|
+
import { OAuthCallbackFlow } from "./callback-server";
|
|
6
6
|
import { generatePKCE } from "./pkce";
|
|
7
7
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
8
8
|
|
|
@@ -40,11 +40,16 @@ function getAccountId(accessToken: string): string | null {
|
|
|
40
40
|
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
interface PKCE {
|
|
44
|
+
verifier: string;
|
|
45
|
+
challenge: string;
|
|
46
|
+
}
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
|
|
49
|
+
constructor(
|
|
50
|
+
ctrl: OAuthController,
|
|
51
|
+
private readonly pkce: PKCE,
|
|
52
|
+
) {
|
|
48
53
|
super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
|
|
49
54
|
}
|
|
50
55
|
|
|
@@ -52,16 +57,12 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
|
|
|
52
57
|
state: string,
|
|
53
58
|
redirectUri: string,
|
|
54
59
|
): Promise<{ url: string; instructions?: string }> {
|
|
55
|
-
const pkce = await generatePKCE();
|
|
56
|
-
this.verifier = pkce.verifier;
|
|
57
|
-
this.challenge = pkce.challenge;
|
|
58
|
-
|
|
59
60
|
const searchParams = new URLSearchParams({
|
|
60
61
|
response_type: "code",
|
|
61
62
|
client_id: CLIENT_ID,
|
|
62
63
|
redirect_uri: redirectUri,
|
|
63
64
|
scope: SCOPE,
|
|
64
|
-
code_challenge: this.challenge,
|
|
65
|
+
code_challenge: this.pkce.challenge,
|
|
65
66
|
code_challenge_method: "S256",
|
|
66
67
|
state,
|
|
67
68
|
id_token_add_organizations: "true",
|
|
@@ -74,113 +75,58 @@ class OpenAICodexOAuthFlow extends OAuthCallbackFlow {
|
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
protected async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
return exchangeCodeForToken(code, this.pkce.verifier, redirectUri);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function exchangeCodeForToken(code: string, verifier: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
83
|
+
const tokenResponse = await fetch(TOKEN_URL, {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
86
|
+
body: new URLSearchParams({
|
|
87
|
+
grant_type: "authorization_code",
|
|
88
|
+
client_id: CLIENT_ID,
|
|
89
|
+
code,
|
|
90
|
+
code_verifier: verifier,
|
|
91
|
+
redirect_uri: redirectUri,
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!tokenResponse.ok) {
|
|
96
|
+
throw new Error(`Token exchange failed: ${tokenResponse.status}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const tokenData = (await tokenResponse.json()) as {
|
|
100
|
+
access_token?: string;
|
|
101
|
+
refresh_token?: string;
|
|
102
|
+
expires_in?: number;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
|
|
106
|
+
throw new Error("Token response missing required fields");
|
|
107
|
+
}
|
|
88
108
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const tokenData = (await tokenResponse.json()) as {
|
|
94
|
-
access_token?: string;
|
|
95
|
-
refresh_token?: string;
|
|
96
|
-
expires_in?: number;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
|
|
100
|
-
throw new Error("Token response missing required fields");
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const accountId = getAccountId(tokenData.access_token);
|
|
104
|
-
if (!accountId) {
|
|
105
|
-
throw new Error("Failed to extract accountId from token");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return {
|
|
109
|
-
access: tokenData.access_token,
|
|
110
|
-
refresh: tokenData.refresh_token,
|
|
111
|
-
expires: Date.now() + tokenData.expires_in * 1000,
|
|
112
|
-
accountId,
|
|
113
|
-
};
|
|
109
|
+
const accountId = getAccountId(tokenData.access_token);
|
|
110
|
+
if (!accountId) {
|
|
111
|
+
throw new Error("Failed to extract accountId from token");
|
|
114
112
|
}
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
access: tokenData.access_token,
|
|
116
|
+
refresh: tokenData.refresh_token,
|
|
117
|
+
expires: Date.now() + tokenData.expires_in * 1000,
|
|
118
|
+
accountId,
|
|
119
|
+
};
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
/**
|
|
118
123
|
* Login with OpenAI Codex OAuth
|
|
119
124
|
*/
|
|
120
125
|
export async function loginOpenAICodex(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
return await flow.login();
|
|
125
|
-
} catch (error) {
|
|
126
|
-
// Callback failed - fall back to onPrompt if available
|
|
127
|
-
if (!ctrl.onPrompt) {
|
|
128
|
-
throw error;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
ctrl.onProgress?.("Callback server failed, falling back to manual input");
|
|
126
|
+
const pkce = await generatePKCE();
|
|
127
|
+
const flow = new OpenAICodexOAuthFlow(ctrl, pkce);
|
|
132
128
|
|
|
133
|
-
|
|
134
|
-
message: "Paste the authorization code (or full redirect URL):",
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
const parsed = parseCallbackInput(input);
|
|
138
|
-
if (!parsed.code) {
|
|
139
|
-
throw new Error("No authorization code found in input");
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const redirectUri = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
|
143
|
-
|
|
144
|
-
// Manual token exchange
|
|
145
|
-
const pkce = await generatePKCE();
|
|
146
|
-
const tokenResponse = await fetch(TOKEN_URL, {
|
|
147
|
-
method: "POST",
|
|
148
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
149
|
-
body: new URLSearchParams({
|
|
150
|
-
grant_type: "authorization_code",
|
|
151
|
-
client_id: CLIENT_ID,
|
|
152
|
-
code: parsed.code,
|
|
153
|
-
code_verifier: pkce.verifier,
|
|
154
|
-
redirect_uri: redirectUri,
|
|
155
|
-
}),
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
if (!tokenResponse.ok) {
|
|
159
|
-
throw new Error(`Token exchange failed: ${tokenResponse.status}`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const tokenData = (await tokenResponse.json()) as {
|
|
163
|
-
access_token?: string;
|
|
164
|
-
refresh_token?: string;
|
|
165
|
-
expires_in?: number;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
if (!tokenData.access_token || !tokenData.refresh_token || typeof tokenData.expires_in !== "number") {
|
|
169
|
-
throw new Error("Token response missing required fields");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const accountId = getAccountId(tokenData.access_token);
|
|
173
|
-
if (!accountId) {
|
|
174
|
-
throw new Error("Failed to extract accountId from token");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return {
|
|
178
|
-
access: tokenData.access_token,
|
|
179
|
-
refresh: tokenData.refresh_token,
|
|
180
|
-
expires: Date.now() + tokenData.expires_in * 1000,
|
|
181
|
-
accountId,
|
|
182
|
-
};
|
|
183
|
-
}
|
|
129
|
+
return flow.login();
|
|
184
130
|
}
|
|
185
131
|
|
|
186
132
|
/**
|