diff --git a/readme.md b/readme.md index 1a6f6f6..50076f8 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ If you use Pi on a desktop, most of it carries over: - Project instructions from `AGENTS.md` or `CLAUDE.md` - Import and export of Pi CLI session files (JSONL) -What differs: files go through Acode's `fsOperation` (local, SAF, FTP, SFTP), `bash` only exists on Terminal-backed folders, and OAuth uses a custom tab with device approval or an app callback instead of a localhost server. Pi packages, tmux and the Pi TUI are not included. +What differs: files go through Acode's `fsOperation` (local, SAF, FTP, SFTP), `bash` only exists on Terminal-backed folders, and OAuth uses Acode's native browser and callback support. Pi packages, tmux and the Pi TUI are not included. ## Requirements @@ -102,11 +102,13 @@ Add credentials under **Provider access**. Keys and tokens are kept in Acode's p | -------------- | --------------------- | | OpenRouter | OpenRouter account | | Codex | ChatGPT Plus / Pro | +| ChatGPT | ChatGPT Plus / Pro | | GitHub Copilot | Copilot subscription | | xAI | Grok / X subscription | | Kimi Code | Kimi Code | - **OpenRouter** opens a custom tab and connects when you approve. No code to paste. +- **ChatGPT** uses OpenAI's official Sign in with ChatGPT integration. Tap **Continue with ChatGPT**, approve plan usage, then tap **Return to Acode** if needed. On Android, use Chrome for browser sign-in. One ChatGPT account is saved at a time. **Disconnect** signs out; **Remove account** then forgets the saved account so you can connect another. Reconnecting uses the saved registration directly. Use **Manage usage** to review access and limits in ChatGPT. Requests use the public Responses API and your ChatGPT plan allowance. - **Codex, xAI and Kimi Code** use device codes: tap **Copy code & sign in**, paste the code on the provider's page and approve. For Codex, first enable device-code authorization in ChatGPT under Settings → Security. - **GitHub Copilot** uses github.com by default. **Other sign-in options** accepts an Enterprise domain. diff --git a/src/app/agentController.ts b/src/app/agentController.ts index 4c76517..6f22d74 100644 --- a/src/app/agentController.ts +++ b/src/app/agentController.ts @@ -731,6 +731,7 @@ export class AgentController { this.#state.authFlow = { providerId, status: "connecting", + signedIn: true, message: "Signed in. Loading your models…", }; this.#emit(); @@ -807,11 +808,24 @@ export class AgentController { async removeCredential(providerId: ProviderId): Promise { if (this.#state.authFlow?.providerId === providerId) this.cancelSubscriptionLogin(); - await this.providers.models.logout(providerId); + const flow = this.#state.authFlow; + const revoked = + providerId === "chatgpt" + ? await this.providers.chatgptAccount.disconnect() + : (await this.providers.models.logout(providerId), true); await this.providers.refreshModelAvailability(providerId); if (this.settings.value.providerId === providerId) this.#refreshModels(); - this.#state.authFlow = undefined; + if (this.#state.authFlow === flow) this.#state.authFlow = undefined; this.#emit(); + if (!revoked) + throw new Error( + "Signed out locally. Remote revocation could not be confirmed; disconnect Acode AI Agent in ChatGPT Settings → Usage.", + ); + } + + async forgetChatGPTAccount(): Promise { + if (this.#state.authFlow?.providerId === "chatgpt") this.cancelSubscriptionLogin(); + await this.providers.chatgptAccount.forget(); } async hasCredential(providerId: ProviderId): Promise { diff --git a/src/core/types.ts b/src/core/types.ts index 3fe7f2e..d842046 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -198,6 +198,8 @@ export type PublicAgentState = { providerId: ProviderId; status: "waiting" | "connecting" | "connected" | "error"; browserReturned?: boolean; + /** Credentials are saved; cancelling now would not undo the sign-in. */ + signedIn?: boolean; userCode?: string; verificationUri?: string; message?: string; diff --git a/src/platform/browserLoopback.ts b/src/platform/browserLoopback.ts new file mode 100644 index 0000000..911321f --- /dev/null +++ b/src/platform/browserLoopback.ts @@ -0,0 +1,131 @@ +/** Shared browser and native callback page; no remote assets are needed. */ +export function loopbackPage( + title: string, + message: string, + returnUri: string, + script = "", + state: "loading" | "ready" | "error" = "ready", +): { html: string; policy: string } { + const nonce = crypto.randomUUID(); + const policy = `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; worker-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'`; + const html = `ChatGPT sign-in · Acode

Acode AI Agent

${title}

${message}

Return to Acode

${state === "loading" ? "This will only take a moment." : "If Acode does not open automatically, tap the button."}

${script ? `` : ""}`; + return { html, policy }; +} + +/** Chrome owns the callback after setup; initial page/worker loading still needs Acode. */ +export function browserLoopbackPages( + authorization: string, + state: string, + returnUri: string, + expiresAt: number, +): { start: string; worker: string; policy: string; startPolicy: string } { + const json = (value: unknown) => JSON.stringify(value).replaceAll("<", "\\u003c"); + const callback = loopbackPage( + "You're signed in", + "Returning to Acode to finish connecting your ChatGPT account…", + returnUri, + `(${returnToAcode.toString()})(${json(returnUri)});`, + ); + const start = loopbackPage( + "Connecting to ChatGPT", + "Preparing your secure sign-in…", + returnUri, + `(${prepareBrowser.toString()})(${json(authorization)},${json(state)});`, + "loading", + ); + return { + policy: callback.policy, + startPolicy: start.policy, + start: start.html, + worker: `self.addEventListener('install',event=>event.waitUntil(self.skipWaiting())); +self.addEventListener('activate',event=>event.waitUntil(self.clients.claim())); +self.addEventListener('fetch',event=>{ + const url=new URL(event.request.url); + if(event.request.method!=='GET'||url.pathname!=='/auth/callback')return; + const states=url.searchParams.getAll('state'); + if(states.length!==1||states[0]!==${json(state)}||Date.now()>${expiresAt}){ + event.respondWith(new Response('This sign-in callback is invalid or expired.',{status:400}));return; + } + event.respondWith(new Response(${json(callback.html)},{headers:{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store','Referrer-Policy':'no-referrer','Content-Security-Policy':${json(callback.policy)}}})); + event.waitUntil(self.registration.unregister()); +});`, + }; +} + +// These functions are serialized into browser scripts; keep them self-contained. +async function prepareBrowser(authorization: string, state: string) { + let finished = false; + let registration: ServiceWorkerRegistration | undefined; + let worker: ServiceWorker | undefined; + let changed: (() => void) | undefined; + let timeout: ReturnType | undefined; + try { + await Promise.race([ + (async () => { + const registered = await navigator.serviceWorker.register( + `/auth/sw.js?state=${encodeURIComponent(state)}`, + { scope: "/auth/", updateViaCache: "none" }, + ); + if (finished) { + await registered.unregister(); + return; + } + registration = registered; + worker = + registration.installing ?? registration.waiting ?? registration.active ?? undefined; + if (!worker) throw new Error("Browser callback could not start."); + if (worker.state !== "activated") + await new Promise((resolve, reject) => { + changed = () => { + if (worker!.state === "activated") resolve(); + else if (worker!.state === "redundant") + reject(new Error("Browser callback could not start.")); + }; + worker!.addEventListener("statechange", changed); + changed(); + }); + })(), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("Browser callback preparation timed out.")), + 15_000, + ); + }), + ]); + location.replace(authorization); + } catch { + void registration?.unregister().catch(() => undefined); + document.getElementById("title")!.textContent = "Sign-in could not start"; + document.getElementById("indicator")!.dataset.state = "error"; + document.getElementById("status")!.textContent = + "Return to Acode and try signing in again using Chrome."; + document.getElementById("return")!.hidden = false; + } finally { + finished = true; + clearTimeout(timeout); + if (worker && changed) worker.removeEventListener("statechange", changed); + } +} + +function returnToAcode(returnUri: string) { + const query = new URLSearchParams(location.search); + history.replaceState(null, "", location.pathname); + // Android intent URIs carry their target after the fragment; plain URIs take the query at the end. + const suffix = returnUri.indexOf("#Intent;"); + const split = suffix < 0 ? returnUri.length : suffix; + const target = returnUri.slice(0, split) + "?" + query.toString() + returnUri.slice(split); + document.getElementById("return")!.setAttribute("href", target); + if (query.has("error")) { + document.getElementById("title")!.textContent = "Sign-in cancelled"; + document.getElementById("indicator")!.dataset.state = "error"; + } + document.getElementById("status")!.textContent = query.has("error") + ? "Sign-in was not approved. Return to Acode to try again." + : "Sign-in approved. Return to Acode to finish connecting."; + // Chrome can require a tap for an intent; retain the button if automatic opening is blocked. + try { + location.replace(target); + } catch {} +} diff --git a/src/platform/credentials.ts b/src/platform/credentials.ts index a5d0175..522f35d 100644 --- a/src/platform/credentials.ts +++ b/src/platform/credentials.ts @@ -18,6 +18,7 @@ export class PortableCredentialStore implements CredentialStore { #chains = new Map>(); #extraIds: () => readonly string[]; #pending = new Map(); + #oauthStates = new Map(); constructor(ctx: Acode.PluginContext | null, extraIds: () => readonly string[] = () => []) { this.#ctx = ctx; @@ -41,6 +42,7 @@ export class PortableCredentialStore implements CredentialStore { "openrouter", "openai", "openai-codex", + "chatgpt", "anthropic", "github-copilot", "google", @@ -94,10 +96,15 @@ export class PortableCredentialStore implements CredentialStore { ); } - delete(providerId: string, options?: AuthOperationOptions): Promise { + delete( + providerId: string, + options?: AuthOperationOptions, + beforeDelete?: (current: Credential | undefined) => Promise, + ): Promise { return this.#enqueue( providerId, async () => { + if (beforeDelete) await beforeDelete(await this.read(providerId)); this.#memory.delete(providerId); if (this.#ctx) await this.#ctx.setSecret(`${SECRET_PREFIX}${providerId}`, ""); this.changes.emit(providerId); @@ -110,6 +117,33 @@ export class PortableCredentialStore implements CredentialStore { return this.#enqueue(`pending:${providerId}`, () => this.#readPending(providerId)); } + /** Provider-owned registration/profile state, protected like the active credential. */ + oauthState(providerId: string): Promise { + return this.#enqueue(`state:${providerId}`, () => this.#readOAuthState(providerId)); + } + + updateOAuthState(providerId: string, update: (state: T | undefined) => T): Promise { + return this.#enqueue(`state:${providerId}`, async () => { + const next = update(await this.#readOAuthState(providerId)); + if (this.#ctx) await this.#ctx.setSecret(`oauth-state:${providerId}`, JSON.stringify(next)); + else this.#oauthStates.set(providerId, next); + this.changes.emit(providerId); + return next; + }); + } + + async #readOAuthState(providerId: string): Promise { + if (!this.#ctx) return this.#oauthStates.get(providerId) as T | undefined; + const raw = await this.#ctx.getSecret(`oauth-state:${providerId}`, ""); + if (!raw) return undefined; + try { + const value = JSON.parse(raw); + return value && typeof value === "object" && !Array.isArray(value) ? (value as T) : undefined; + } catch { + return undefined; + } + } + savePendingSignIn(providerId: string, pending: PendingSignIn): Promise { return this.#enqueue(`pending:${providerId}`, async () => { if (this.#ctx) diff --git a/src/platform/loopbackOAuth.ts b/src/platform/loopbackOAuth.ts new file mode 100644 index 0000000..f42d08b --- /dev/null +++ b/src/platform/loopbackOAuth.ts @@ -0,0 +1,248 @@ +import { browserLoopbackPages, loopbackPage } from "./browserLoopback"; + +type CallbackRequest = { + requestId: string; + method: string; + path: string; + query?: string; +}; +type NativeServer = { + stop(success: () => void, failure: (error: string) => void): void; + send(id: string, response: unknown, success: () => void, failure: (error: string) => void): void; + setOnRequestHandler( + handler: (request: CallbackRequest) => void, + failure: (error: string) => void, + ): void; +}; +type CreateServer = ( + port: number, + success: () => void, + failure: (error: string) => void, +) => NativeServer; + +export type LoopbackAuthorization = { code: string; clientId: string }; + +/** Starts a scoped callback listener using Acode's server API. */ +export async function startLoopbackOAuth( + state: string, + clientId?: string, + signal?: AbortSignal, +): Promise<{ + redirectUri: string; + browserUrl(authorization: string): string; + result: Promise; + close(): Promise; +}> { + signal?.throwIfAborted(); + const create = (globalThis as { CreateServer?: CreateServer }).CreateServer; + if (!create) + throw new Error( + "ChatGPT browser sign-in is unavailable in this environment. Use Codex device-code sign-in.", + ); + // Avoid the host's preview port and the well-known Codex/CLI OAuth port. + const port = 49152 + (crypto.getRandomValues(new Uint16Array(1))[0]! % 16384); + const redirectUri = `http://127.0.0.1:${port}/auth/callback`; + const android = + (globalThis as { cordova?: { platformId?: string } }).cordova?.platformId === "android"; + const appPackage = globalThis.BuildInfo?.packageName; + const target = + appPackage && /^[A-Za-z][\w]*(?:\.[A-Za-z][\w]*)+$/.test(appPackage) + ? `package=${appPackage};` + : ""; + const returnUri = android + ? `intent://ai-agent/oauth/chatgpt#Intent;scheme=acode;${target}end` + : "acode://ai-agent/oauth/chatgpt"; + let server!: NativeServer; + let resolve!: (value: LoopbackAuthorization) => void; + let reject!: (error: Error) => void; + let settled = false; + let closed = false; + let pages: ReturnType | undefined; + const intent = (globalThis as { acode?: typeof acode }).acode?.require("intent"); + const onReturn = (event: Acode.IntentEvent & { url?: string }) => { + if (event.module !== "ai-agent" || event.action !== "oauth") return; + let callback: URL; + try { + callback = new URL(event.url ?? `acode://ai-agent/oauth/${event.value}`); + } catch { + return; + } + if ( + callback.protocol !== "acode:" || + callback.host !== "ai-agent" || + callback.pathname !== "/oauth/chatgpt" || + callback.username || + callback.password || + callback.hash + ) + return; + event.preventDefault(); + event.stopPropagation(); + // Only a callback bound to the pending flow can authorize the account. + accept(callback.searchParams); + }; + const result = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + // A startup failure or cancellation can happen before the caller awaits result. + void result.catch(() => undefined); + const fail = (error: Error) => { + if (settled) return; + settled = true; + reject(error); + }; + const accept = (query: URLSearchParams): LoopbackAuthorization | undefined => { + if ( + settled || + Date.now() > expiresAt || + signal?.aborted || + query.getAll("state").length !== 1 || + query.get("state") !== state + ) + return; + if ( + ["access_token", "refresh_token", "id_token", "client_secret"].some((key) => query.has(key)) + ) + return; + if (query.has("error")) { + fail(new Error("ChatGPT authorization was denied. Try again.")); + return; + } + const codes = query.getAll("code"); + const ids = query.getAll("client_id"); + const issued = ids[0] ?? clientId; + if ( + codes.length !== 1 || + !codes[0] || + codes[0].length > 4096 || + ids.length > 1 || + !issued || + !/^[A-Za-z0-9_-]{1,256}$/.test(issued) || + issued === "dynamic_agent_client" || + (clientId && issued !== clientId) + ) + return; + const authorization = { code: codes[0], clientId: issued }; + settled = true; + resolve(authorization); + return authorization; + }; + const expiresAt = Date.now() + 10 * 60_000; + const abort = () => fail(new DOMException("Sign-in cancelled", "AbortError")); + const timer = setTimeout( + () => fail(new Error("ChatGPT sign-in expired. Try again.")), + 10 * 60_000, + ); + signal?.addEventListener("abort", abort, { once: true }); + const close = async () => { + if (closed) return; + closed = true; + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + intent?.removeHandler?.(onReturn); + if (!settled) fail(new DOMException("Sign-in cancelled", "AbortError")); + if (server) await new Promise((done) => server.stop(done, () => done())); + }; + try { + intent?.addHandler?.(onReturn); + await Promise.race([ + new Promise((yes, no) => { + server = create(port, yes, (message) => + no(new Error(message || "Could not start ChatGPT sign-in.")), + ); + }), + result, + ]); + signal?.throwIfAborted(); + server.setOnRequestHandler( + (request) => { + if ( + pages && + request.method === "GET" && + ["/auth/start", "/auth/sw.js"].includes(request.path) && + new URLSearchParams(request.query ?? "").get("state") === state + ) { + const worker = request.path === "/auth/sw.js"; + server.send( + request.requestId, + { + status: 200, + body: worker ? pages.worker : pages.start, + headers: { + "Content-Type": worker ? "application/javascript" : "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": worker + ? "default-src 'none'; script-src 'self'; base-uri 'none'" + : pages.startPolicy, + "X-Content-Type-Options": "nosniff", + }, + }, + () => {}, + () => {}, + ); + return; + } + let status = 400; + let message = "Invalid sign-in callback. Return to the original sign-in page."; + let authorization: LoopbackAuthorization | undefined; + if (request.method !== "GET" || request.path !== "/auth/callback") status = 404; + else { + const query = new URLSearchParams(request.query ?? ""); + const denied = + !settled && + query.getAll("state").length === 1 && + query.get("state") === state && + query.has("error"); + authorization = accept(query); + if (denied) { + message = "ChatGPT was not connected. Return to Acode to try again."; + } else if (authorization) { + status = 200; + message = "Sign-in approved. Return to Acode to finish connecting."; + } + } + const page = loopbackPage( + authorization ? "You're signed in" : "Sign-in not completed", + message, + returnUri, + "", + authorization ? "ready" : "error", + ); + server.send( + request.requestId, + { + status, + body: page.html, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "Content-Security-Policy": page.policy, + }, + }, + () => {}, + () => {}, + ); + }, + (message) => fail(new Error(message || "ChatGPT callback failed.")), + ); + return { + redirectUri, + result, + close, + browserUrl(authorization) { + if (!android || !intent?.addHandler) return authorization; + const url = new URL(authorization); + if (url.origin !== "https://auth.openai.com") + throw new Error("Invalid ChatGPT sign-in URL."); + pages = browserLoopbackPages(url.href, state, returnUri, expiresAt); + return `http://127.0.0.1:${port}/auth/start?state=${encodeURIComponent(state)}`; + }, + }; + } catch (error) { + await close(); + throw error; + } +} diff --git a/src/platform/nativeHttp.ts b/src/platform/nativeHttp.ts index e693842..ffadd6b 100644 --- a/src/platform/nativeHttp.ts +++ b/src/platform/nativeHttp.ts @@ -159,7 +159,7 @@ export const nativeFetch: typeof fetch = async (input, init) => { }, (response) => finish(() => resolve(toFetchResponse(response))), (error) => { - if (typeof error?.status === "number") { + if (typeof error?.status === "number" && error.status >= 200 && error.status <= 599) { finish(() => resolve(toFetchResponse(error))); return; } diff --git a/src/platform/oauthRequest.ts b/src/platform/oauthRequest.ts new file mode 100644 index 0000000..bd1a492 --- /dev/null +++ b/src/platform/oauthRequest.ts @@ -0,0 +1,31 @@ +import { nativeFetch } from "./nativeHttp"; + +/** Bound both headers and JSON-body reads on native HTTP, including older WebViews. */ +export async function oauthRequest( + url: string, + init: RequestInit = {}, + readJson = true, +): Promise<{ + ok: boolean; + status: number; + body: Record; +}> { + const abort = new AbortController(); + const cancel = () => abort.abort(init.signal?.reason); + if (init.signal?.aborted) cancel(); + else init.signal?.addEventListener("abort", cancel, { once: true }); + const timer = setTimeout( + () => abort.abort(new DOMException("Sign-in request timed out.", "TimeoutError")), + 30_000, + ); + try { + const response = await nativeFetch(url, { ...init, signal: abort.signal }); + const body = response.ok && readJson ? await response.json() : {}; + if (!body || typeof body !== "object" || Array.isArray(body)) + throw new Error("Invalid OAuth response."); + return { ok: response.ok, status: response.status, body: body as Record }; + } finally { + clearTimeout(timer); + init.signal?.removeEventListener("abort", cancel); + } +} diff --git a/src/providers/chatgptIdentity.ts b/src/providers/chatgptIdentity.ts new file mode 100644 index 0000000..ab7fa97 --- /dev/null +++ b/src/providers/chatgptIdentity.ts @@ -0,0 +1,79 @@ +import { oauthRequest } from "../platform/oauthRequest"; + +export const CHATGPT_ISSUER = "https://auth.openai.com"; +export type ChatGPTIdentity = { issuer: string; subject: string; email?: string; name?: string }; + +/** Verify a public-client ID token against OpenAI's pinned issuer/JWKS. */ +export async function verifyChatGPTIdentity( + token: string, + clientId: string, + nonce?: string, + signal?: AbortSignal, +): Promise { + const parts = token.split("."); + if (parts.length !== 3) throw new Error("ChatGPT returned an invalid ID token."); + const header = decode(parts[0]!); + const claims = decode(parts[1]!); + if (header.alg !== "RS256" || typeof header.kid !== "string") + throw new Error("ChatGPT returned an unsupported ID token signature."); + const response = await oauthRequest(`${CHATGPT_ISSUER}/.well-known/jwks.json`, { signal }); + if (!response.ok) + throw new Error(`ChatGPT identity verification failed (HTTP ${response.status}).`); + const jwks = response.body as { keys?: (JsonWebKey & { kid?: string })[] }; + const key = jwks.keys?.find( + (key) => + key.kid === header.kid && + key.kty === "RSA" && + (!key.alg || key.alg === "RS256") && + (!key.use || key.use === "sig"), + ); + if (!key) throw new Error("ChatGPT identity signing key was not found. Try again."); + const publicKey = await crypto.subtle.importKey( + "jwk", + key, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + const valid = await crypto.subtle.verify( + "RSASSA-PKCS1-v1_5", + publicKey, + bytes(parts[2]!), + new TextEncoder().encode(`${parts[0]}.${parts[1]}`), + ); + const now = Date.now() / 1000; + const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; + if ( + !valid || + claims.iss !== CHATGPT_ISSUER || + !audiences.includes(clientId) || + (audiences.length > 1 && claims.azp !== clientId) || + (claims.azp !== undefined && claims.azp !== clientId) || + typeof claims.exp !== "number" || + claims.exp <= now || + (typeof claims.nbf === "number" && claims.nbf > now + 60) || + (nonce !== undefined && claims.nonce !== nonce) || + typeof claims.sub !== "string" || + !claims.sub + ) + throw new Error("ChatGPT identity verification failed. Start sign-in again."); + return { + issuer: CHATGPT_ISSUER, + subject: claims.sub, + email: typeof claims.email === "string" ? claims.email : undefined, + name: typeof claims.name === "string" ? claims.name : undefined, + }; +} + +function bytes(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error("ChatGPT returned an invalid ID token."); + const decoded = atob(value.replace(/-/g, "+").replace(/_/g, "/")); + return Uint8Array.from(decoded, (char) => char.charCodeAt(0)); +} +function decode(value: string): Record { + try { + return JSON.parse(new TextDecoder().decode(bytes(value))) as Record; + } catch { + throw new Error("ChatGPT returned an invalid ID token."); + } +} diff --git a/src/providers/chatgptOAuth.ts b/src/providers/chatgptOAuth.ts new file mode 100644 index 0000000..2936737 --- /dev/null +++ b/src/providers/chatgptOAuth.ts @@ -0,0 +1,363 @@ +import type { OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai"; +import type { PortableCredentialStore } from "../platform/credentials"; +import { startLoopbackOAuth } from "../platform/loopbackOAuth"; +import { oauthRequest } from "../platform/oauthRequest"; +import { verifyChatGPTIdentity, type ChatGPTIdentity } from "./chatgptIdentity"; +import { sanitizeModelId } from "./customModels"; + +export const CHATGPT_PROVIDER = "chatgpt"; +export const CHATGPT_USAGE_URL = "https://chatgpt.com/settings/usage"; +export const CHATGPT_RESOURCE = "https://api.openai.com/v1"; +const AUTH_BASE = "https://auth.openai.com"; +const PLAN_SCOPE = "chatgpt.tokens.use.direct"; +// The local sign-out is already done; bound how long the UI waits on remote revocation. +const REVOKE_TIMEOUT_MS = 15_000; +type Json = Record; +export type ChatGPTRegistration = { clientId: string; identity?: ChatGPTIdentity }; +type ChatGPTState = { hostId: string; registration?: ChatGPTRegistration }; + +/** Registrations survive disconnect; rotating tokens remain in the host's protected secret store. */ +export class ChatGPTAccount { + #requests = new Set(); + #disconnects = 0; + constructor(readonly credentials: PortableCredentialStore) {} + + request(signal?: AbortSignal): { signal: AbortSignal; release(): void } { + const abort = new AbortController(); + const cancel = () => abort.abort(signal?.reason); + if (this.#disconnects) abort.abort(new DOMException("ChatGPT was disconnected.", "AbortError")); + else if (signal?.aborted) cancel(); + else signal?.addEventListener("abort", cancel, { once: true }); + this.#requests.add(abort); + return { + signal: abort.signal, + release: () => { + signal?.removeEventListener("abort", cancel); + this.#requests.delete(abort); + }, + }; + } + + async state(): Promise { + const state = await this.credentials.oauthState(CHATGPT_PROVIDER); + if (state && !("registrations" in state)) return state; + const active = await this.credentials.read(CHATGPT_PROVIDER); + const registration = + active?.type === "oauth" && typeof active.clientId === "string" + ? { clientId: active.clientId, identity: active.identity as ChatGPTIdentity | undefined } + : undefined; + return this.credentials.updateOAuthState(CHATGPT_PROVIDER, (existing) => + existing && !("registrations" in existing) + ? existing + : { + hostId: existing?.hostId ?? state?.hostId ?? `urn:uuid:${crypto.randomUUID()}`, + registration, + }, + ); + } + + async save(clientId: string, identity?: ChatGPTIdentity): Promise { + await this.state(); + await this.credentials.updateOAuthState(CHATGPT_PROVIDER, (state) => ({ + hostId: state!.hostId, + registration: { + clientId, + identity: + identity ?? + (state!.registration?.clientId === clientId ? state!.registration.identity : undefined), + }, + })); + } + + async forget(): Promise { + await this.credentials.modify(CHATGPT_PROVIDER, async (current) => { + if (current) throw new Error("Disconnect ChatGPT before removing the account."); + await this.state(); + await this.credentials.updateOAuthState(CHATGPT_PROVIDER, (state) => ({ + hostId: state!.hostId, + })); + return undefined; + }); + } + + async disconnect(): Promise { + this.#disconnects++; + for (const request of this.#requests) + request.abort(new DOMException("ChatGPT was disconnected.", "AbortError")); + this.#requests.clear(); + try { + let removed = undefined as OAuthCredential | undefined; + // Serialize against refresh so revocation uses the latest rotating token. + await this.credentials.delete(CHATGPT_PROVIDER, undefined, async (current) => { + if (current?.type !== "oauth") return; + await this.save(String(current.clientId), current.identity as ChatGPTIdentity); + removed = current; + }); + // Sign out locally first so a slow or offline revocation cannot keep the session alive. + return removed ? await revoke(removed) : true; + } finally { + this.#disconnects--; + } + } +} + +export function createChatGPTOAuth(accounts: ChatGPTAccount): OAuthAuth { + return { + name: "ChatGPT", + isSubscription: true, + loginLabel: "Continue with ChatGPT", + async login(interaction) { + interaction.signal.throwIfAborted(); + const state = await accounts.state(); + const registration = state.registration; + const previous = await accounts.credentials.read(CHATGPT_PROVIDER); + const verifier = randomValue(); + const challenge = base64Url( + new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))), + ); + const oauthState = randomValue(); + const nonce = randomValue(); + const callback = await startLoopbackOAuth( + oauthState, + registration?.clientId, + interaction.signal, + ); + try { + const authorization = new URL(`${AUTH_BASE}/api/accounts/authorize`); + authorization.search = new URLSearchParams({ + client_id: registration?.clientId ?? "dynamic_agent_client", + ...(registration ? {} : { agent_name_hint: "Acode AI Agent" }), + ext_agent_host_id: state.hostId, + response_type: "code", + redirect_uri: callback.redirectUri, + scope: `openid profile email offline_access resource.invoke ${PLAN_SCOPE}`, + resource: CHATGPT_RESOURCE, + state: oauthState, + nonce, + code_challenge_method: "S256", + code_challenge: challenge, + ...(previous?.type === "oauth" && + previous.clientId === registration?.clientId && + typeof previous.idToken === "string" + ? { id_token_hint: previous.idToken } + : {}), + }).toString(); + interaction.notify({ + type: "auth_url", + url: callback.browserUrl(authorization.href), + instructions: "Approve ChatGPT plan access, then tap Return to Acode if needed.", + }); + const approved = await callback.result; + // Keep the issued registration even if this one-use code expires or exchange fails. + await accounts.save(approved.clientId); + interaction.notify({ type: "progress", message: "Verifying ChatGPT sign-in…" }); + const token = await tokenRequest( + { + grant_type: "authorization_code", + client_id: approved.clientId, + code: approved.code, + code_verifier: verifier, + redirect_uri: callback.redirectUri, + resource: CHATGPT_RESOURCE, + }, + interaction.signal, + ); + const idToken = requiredString(token, "id_token"); + const identity = await verifyChatGPTIdentity( + idToken, + approved.clientId, + nonce, + interaction.signal, + ); + if ( + registration?.identity && + (registration.identity.subject !== identity.subject || + registration.identity.issuer !== identity.issuer) + ) + throw new Error( + "ChatGPT returned a different account. Remove the saved account before connecting another.", + ); + const credential = credentialFromToken(token, approved.clientId, identity, idToken); + await accounts.save(approved.clientId, identity); + // Model discovery failure must not discard a valid renewable session. + try { + return await refreshChatGPTModels(credential, interaction.signal); + } catch { + return credential; + } + } finally { + await callback.close(); + } + }, + async refresh(credential, signal) { + const clientId = requiredString(credential, "clientId"); + const token = await tokenRequest( + { + grant_type: "refresh_token", + client_id: clientId, + refresh_token: credential.refresh, + resource: CHATGPT_RESOURCE, + }, + signal, + ); + // RFC 6749 §6 lets a refresh response omit an unchanged refresh token and scope. + token.refresh_token ??= credential.refresh; + if (token.scope === undefined && Array.isArray(credential.scopes)) + token.scope = credential.scopes.join(" "); + let identity = credential.identity as ChatGPTIdentity; + let idToken = typeof credential.idToken === "string" ? credential.idToken : undefined; + if (typeof token.id_token === "string") { + const refreshedIdentity = await verifyChatGPTIdentity( + token.id_token, + clientId, + undefined, + signal, + ); + if ( + !identity || + refreshedIdentity.subject !== identity.subject || + refreshedIdentity.issuer !== identity.issuer + ) + throw new Error("ChatGPT refreshed a different account. Sign in again."); + identity = refreshedIdentity; + idToken = token.id_token; + } + const refreshed = { + ...credential, + ...credentialFromToken(token, clientId, identity, idToken), + }; + return refreshed; + }, + async toAuth(credential) { + if ( + !Array.isArray(credential.scopes) || + !credential.scopes.includes(PLAN_SCOPE) || + !credential.access + ) + throw new Error( + "ChatGPT plan usage is not enabled. Continue with ChatGPT to authorize it.", + ); + return { apiKey: credential.access, baseUrl: CHATGPT_RESOURCE }; + }, + }; +} + +export async function refreshChatGPTModels( + credential: OAuthCredential, + signal?: AbortSignal, +): Promise { + const response = await oauthRequest(`${CHATGPT_RESOURCE}/models`, { + headers: { Authorization: `Bearer ${credential.access}`, Accept: "application/json" }, + signal, + }); + if (!response.ok) throw new Error(`ChatGPT model discovery failed (HTTP ${response.status}).`); + const body = response.body as { models?: Json[] }; + if (!Array.isArray(body.models)) throw new Error("ChatGPT returned an invalid model catalog."); + const availableModels = body.models.flatMap((model) => { + const id = typeof model.slug === "string" ? sanitizeModelId(model.slug) : undefined; + return id && model.visibility === "list" + ? [{ id, name: typeof model.display_name === "string" ? model.display_name : id }] + : []; + }); + return { ...credential, availableModels, availableModelsFetchedAt: Date.now() }; +} + +function credentialFromToken( + token: Json, + clientId: string, + identity: ChatGPTIdentity, + idToken?: string, +): OAuthCredential { + const scopes = requiredString(token, "scope").split(/\s+/); + if (!scopes.includes(PLAN_SCOPE)) + throw new Error("ChatGPT plan usage was not enabled. Sign in again and authorize plan access."); + const lifetime = token.expires_in; + if ( + typeof lifetime !== "number" || + !Number.isFinite(lifetime) || + lifetime <= 0 || + token.token_type?.toString().toLowerCase() !== "bearer" + ) + throw new Error("ChatGPT returned an invalid token lifetime or type."); + return { + type: "oauth", + access: requiredString(token, "access_token"), + refresh: requiredString(token, "refresh_token"), + expires: Date.now() + lifetime * 1000 - 60_000, + clientId, + identity, + idToken, + scopes, + }; +} +async function tokenRequest(fields: Record, signal?: AbortSignal): Promise { + const response = await oauthRequest(`${AUTH_BASE}/api/accounts/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, + body: new URLSearchParams(fields), + signal, + }); + if (!response.ok) + throw new Error(`ChatGPT sign-in failed (HTTP ${response.status}). Start sign-in again.`); + return response.body; +} +async function revoke(credential: OAuthCredential): Promise { + const abort = new AbortController(); + const timer = setTimeout(() => abort.abort(), REVOKE_TIMEOUT_MS); + try { + return await revokeWithin(credential, abort.signal); + } finally { + clearTimeout(timer); + } +} +async function revokeWithin(credential: OAuthCredential, signal: AbortSignal): Promise { + for (let attempt = 0; attempt < 3 && !signal.aborted; attempt++) { + if (attempt) await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1))); + try { + const discovery = await oauthRequest(`${AUTH_BASE}/.well-known/openid-configuration`, { + signal, + }); + if (!discovery.ok) { + if (discovery.status < 500) return false; + continue; + } + const config = discovery.body; + const url = new URL(requiredString(config, "revocation_endpoint")); + if (config.issuer !== AUTH_BASE || url.origin !== AUTH_BASE || url.protocol !== "https:") + return false; + const response = await oauthRequest( + url.href, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + token: credential.refresh, + token_type_hint: "refresh_token", + client_id: requiredString(credential, "clientId"), + }), + signal, + }, + false, + ); + if (response.status === 200) return true; + if (response.status < 500) return false; + } catch { + // Revocation is idempotent; retry transient transport/server failures only. + } + } + return false; +} +function requiredString(body: Json, field: string): string { + const value = body[field]; + if (typeof value !== "string" || !value) throw new Error(`ChatGPT response is missing ${field}.`); + return value; +} +function randomValue(): string { + return base64Url(crypto.getRandomValues(new Uint8Array(32))); +} +function base64Url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} diff --git a/src/providers/chatgptProvider.ts b/src/providers/chatgptProvider.ts new file mode 100644 index 0000000..df0749b --- /dev/null +++ b/src/providers/chatgptProvider.ts @@ -0,0 +1,133 @@ +import type { Model, Provider, StreamOptions } from "@earendil-works/pi-ai"; +import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; +import { + ChatGPTAccount, + createChatGPTOAuth, + CHATGPT_PROVIDER, + CHATGPT_RESOURCE, +} from "./chatgptOAuth"; + +/** Adapt Pi's Responses transport to the ChatGPT-plan HTTP contract. */ +export function chatgptPayload(value: unknown): Record { + const payload = { ...(value as Record) }; + for (const field of [ + "background", + "conversation", + "max_output_tokens", + "max_tool_calls", + "metadata", + "moderation", + "multi_agent", + "prompt", + "prompt_cache_retention", + "prompt_cache_options", + "safety_identifier", + "temperature", + "top_logprobs", + "top_p", + "truncation", + "user", + "previous_response_id", + ]) + delete payload[field]; + payload.store = false; + payload.stream = true; + if (!Array.isArray(payload.input)) + throw new Error("ChatGPT requires a complete message history."); + payload.input = payload.input.map((item: Record) => + item.role === "system" ? { ...item, role: "developer" } : item, + ); + if (Array.isArray(payload.tools)) { + const tools = payload.tools as Record[]; + if ( + tools.some( + (tool) => !["function", "custom", "namespace", "web_search"].includes(String(tool.type)), + ) + ) + throw new Error("This tool is not supported with ChatGPT plan usage."); + const functions = tools.filter((tool) => tool.type === "function" || tool.type === "custom"); + payload.tools = [ + ...tools.filter((tool) => tool.type !== "function" && tool.type !== "custom"), + ...(functions.length + ? [ + { + type: "namespace", + name: "acode", + description: "Acode AI Agent tools", + tools: functions, + }, + ] + : []), + ]; + if (payload.tool_choice && typeof payload.tool_choice === "object") { + const choice = payload.tool_choice as Record; + if (choice.type === "function" || choice.type === "custom") + payload.tool_choice = { ...choice, namespace: "acode" }; + } + } + return payload; +} + +export function createChatGPTProvider(accounts: ChatGPTAccount): Provider { + const openai = openaiProvider(); + const adapt = (model: Model): Model => ({ + ...model, + provider: CHATGPT_PROVIDER, + api: "openai-responses", + baseUrl: CHATGPT_RESOURCE, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { + ...model.compat, + supportsAdditionalTools: false, + supportsToolSearch: false, + supportsMaxOutputTokens: false, + supportsLongCacheRetention: false, + }, + }); + const optionsFor = (options?: T) => ({ + ...options, + onPayload: async (payload: unknown, model: Model) => + chatgptPayload((await options?.onPayload?.(payload, model)) ?? payload), + }); + const withRequest = ( + options: T | undefined, + run: (options: T & { signal: AbortSignal }) => ReturnType, + ) => { + const request = accounts.request(options?.signal); + try { + const stream = run({ ...optionsFor(options), signal: request.signal } as T & { + signal: AbortSignal; + }); + void stream.result().then(request.release, request.release); + return stream; + } catch (error) { + request.release(); + throw error; + } + }; + return { + ...openai, + id: CHATGPT_PROVIDER, + name: "ChatGPT", + baseUrl: CHATGPT_RESOURCE, + auth: { oauth: createChatGPTOAuth(accounts) }, + getModels: () => + openai + .getModels() + .filter((model) => model.api === "openai-responses") + .map(adapt), + getAllModels: undefined, + classify: undefined, + filterAllModels: undefined, + filterModels: (models, credential) => + credential?.type === "oauth" && Array.isArray(credential.availableModels) + ? models.filter((model) => + (credential.availableModels as { id: string }[]).some((entry) => entry.id === model.id), + ) + : [], + stream: (model, context, options) => + withRequest(options, (options) => openai.stream(adapt(model), context, options)), + streamSimple: (model, context, options) => + withRequest(options, (options) => openai.streamSimple(adapt(model), context, options)), + }; +} diff --git a/src/providers/customEndpoints.ts b/src/providers/customEndpoints.ts index 441c424..a82c5d5 100644 --- a/src/providers/customEndpoints.ts +++ b/src/providers/customEndpoints.ts @@ -8,6 +8,7 @@ export const BUILTIN_PROVIDER_IDS = [ "openrouter", "openai", "openai-codex", + "chatgpt", "anthropic", "github-copilot", "google", diff --git a/src/providers/customModels.ts b/src/providers/customModels.ts index a1b690a..1abb0be 100644 --- a/src/providers/customModels.ts +++ b/src/providers/customModels.ts @@ -10,6 +10,7 @@ const PROVIDER_DEFAULTS: Record, "api" | "baseUrl" | "co compat: { supportsDeveloperRole: false, thinkingFormat: "openrouter" }, }, openai: { api: "openai-completions", baseUrl: "https://api.openai.com/v1" }, + chatgpt: { api: "openai-responses", baseUrl: "https://api.openai.com/v1" }, "openai-codex": { api: "openai-codex-responses", baseUrl: "https://chatgpt.com/backend-api" }, anthropic: { api: "anthropic-messages", baseUrl: "https://api.anthropic.com" }, google: { api: "google-generative-ai", baseUrl: "https://generativelanguage.googleapis.com" }, diff --git a/src/providers/portableOAuth.ts b/src/providers/portableOAuth.ts index 132e424..92aa8aa 100644 --- a/src/providers/portableOAuth.ts +++ b/src/providers/portableOAuth.ts @@ -357,6 +357,7 @@ async function loginCodexDevice( const deviceAuthId = requiredString(device, "device_auth_id"); const userCode = requiredString(device, "user_code"); const interval = positiveNumber(device.interval, 5); + const deadline = Date.now() + CODEX_TIMEOUT_SECONDS * 1000; interaction.notify({ type: "device_code", userCode, @@ -370,12 +371,14 @@ async function loginCodexDevice( signal: interaction.signal, notify: interaction.notify, poll: async () => { - const response = await postJsonAllowError( - CODEX_DEVICE_TOKEN_URL, - { - device_auth_id: deviceAuthId, - user_code: userCode, - }, + const response = await retryCodexDns( + () => + postJsonAllowError( + CODEX_DEVICE_TOKEN_URL, + { device_auth_id: deviceAuthId, user_code: userCode }, + interaction.signal, + ), + deadline, interaction.signal, ); if (response.ok) { @@ -404,19 +407,49 @@ async function loginCodexDevice( return { error: `Codex device authorization failed (HTTP ${response.status}).` }; }, }); - const token = await exchangeCodexToken( - { - grant_type: "authorization_code", - client_id: CODEX_CLIENT_ID, - code: code.authorizationCode, - code_verifier: code.codeVerifier, - redirect_uri: `${CODEX_AUTH_BASE}/deviceauth/callback`, - }, + const token = await retryCodexDns( + () => + exchangeCodexToken( + { + grant_type: "authorization_code", + client_id: CODEX_CLIENT_ID, + code: code.authorizationCode, + code_verifier: code.codeVerifier, + redirect_uri: `${CODEX_AUTH_BASE}/deviceauth/callback`, + }, + interaction.signal, + ), + deadline, interaction.signal, ); return codexCredential(token, undefined, interaction.signal); } +async function retryCodexDns( + request: () => Promise, + deadline: number, + signal?: AbortSignal, +): Promise { + while (Date.now() < deadline) { + signal?.throwIfAborted(); + try { + return await request(); + } catch (error) { + signal?.throwIfAborted(); + // DNS failed before sending the request, so even a one-use token exchange is safe to retry. + if ( + !(error instanceof Error) || + !/^(?:Unable to resolve host|No address associated with hostname|Host could not be resolved|(?:java\.net\.)?UnknownHostException)\b/i.test( + error.message, + ) + ) + throw error; + await wait(Math.min(5000, Math.max(0, deadline - Date.now())), signal); + } + } + throw new Error("Device authorization expired. Start sign-in again."); +} + async function exchangeCodexToken( fields: Record, signal?: AbortSignal, diff --git a/src/providers/providerRegistry.ts b/src/providers/providerRegistry.ts index 6214d80..a347bdf 100644 --- a/src/providers/providerRegistry.ts +++ b/src/providers/providerRegistry.ts @@ -44,6 +44,8 @@ import { refreshPortableCodexModels, } from "./portableOAuth"; import { withRemoteCatalog, type RemoteCatalog } from "./remoteCatalog"; +import { ChatGPTAccount, refreshChatGPTModels } from "./chatgptOAuth"; +import { createChatGPTProvider } from "./chatgptProvider"; export type ProviderDescriptor = { id: ProviderId; @@ -80,6 +82,13 @@ export const PROVIDERS: readonly ProviderDescriptor[] = [ apiKey: false, subscriptionLabel: "Sign in with ChatGPT", }, + { + id: "chatgpt", + name: "ChatGPT", + hint: "Use your ChatGPT plan", + apiKey: false, + subscriptionLabel: "Continue with ChatGPT", + }, { id: "anthropic", name: "Anthropic", @@ -230,6 +239,7 @@ export function providerDescriptors( export class ProviderRegistry { readonly models: MutableModels; + readonly chatgptAccount: ChatGPTAccount; #credentials: PortableCredentialStore; #customModels: () => Record; #customEndpoints: () => CustomEndpoint[]; @@ -246,6 +256,7 @@ export class ProviderRegistry { customEndpoints: () => CustomEndpoint[] = () => [], ) { this.#credentials = credentials; + this.chatgptAccount = new ChatGPTAccount(credentials); this.#customModels = customModels; this.#customEndpoints = customEndpoints; const models = createModels({ @@ -256,7 +267,11 @@ export class ProviderRegistry { fileExists: async () => false, }, }); - for (const provider of portableProviders(credentials)) { + for (const provider of portableProviders(credentials, this.chatgptAccount)) { + if (provider.id === "chatgpt") { + models.setProvider(provider); + continue; + } const catalog = withRemoteCatalog(provider, catalogStore); this.#catalogs.set(provider.id, catalog); models.setProvider(catalog.provider); @@ -322,7 +337,7 @@ export class ProviderRegistry { [...this.models.getModels(providerId)].filter((model) => model.input.includes("text")), ).sort((left, right) => left.name.localeCompare(right.name)); const available = this.#availableModelIds.get(providerId); - if (providerId === "openai-codex" && available?.size) { + if ((providerId === "openai-codex" || providerId === "chatgpt") && available?.size) { const known = new Map(catalog.map((model) => [model.id, model])); catalog = [...available].map((id) => known.get(id) ?? createCustomModel(providerId, id)); } @@ -338,6 +353,43 @@ export class ProviderRegistry { /** Cache pi's credential-specific model policy for the synchronous mobile picker. */ async refreshModelAvailability(providerId: ProviderId, force = false): Promise[]> { let credential = await this.#credentials.read(providerId); + if (providerId === "chatgpt") { + if (credential?.type !== "oauth") { + this.#availableModelIds.set(providerId, new Set()); + return []; + } + if ( + force || + !Array.isArray(credential.availableModels) || + typeof credential.availableModelsFetchedAt !== "number" || + Date.now() - credential.availableModelsFetchedAt > 60 * 60_000 + ) { + try { + await this.models.getAuth(providerId); + credential = await this.#credentials.modify(providerId, async (current) => { + if (current?.type !== "oauth") return undefined; + return refreshChatGPTModels(current); + }); + } catch (error) { + credential = await this.#credentials.read(providerId); + if (credential?.type !== "oauth" || !Array.isArray(credential.availableModels)) + throw error; + } + } + const available = + credential?.type === "oauth" && Array.isArray(credential.availableModels) + ? (credential.availableModels as { id: string; name: string }[]) + : []; + this.#availableModelIds.set(providerId, new Set(available.map((model) => model.id))); + for (const entry of available) { + const known = this.models.getModel(providerId, entry.id); + this.#overrides.set(`${providerId}:${entry.id}`, { + ...(known ?? createCustomModel(providerId, entry.id)), + name: entry.name, + }); + } + return this.getModels(providerId); + } if ( providerId === "openai-codex" && credential?.type === "oauth" && @@ -456,7 +508,10 @@ export class ProviderRegistry { } } -function portableProviders(credentials: PortableCredentialStore): Provider[] { +function portableProviders( + credentials: PortableCredentialStore, + accounts: ChatGPTAccount, +): Provider[] { const openrouter = openrouterProvider(); const anthropic = anthropicProvider(); const githubCopilot = githubCopilotProvider(); @@ -469,6 +524,7 @@ function portableProviders(credentials: PortableCredentialStore): Provider[] { auth: { ...openrouter.auth, oauth: createPortableOpenRouterOAuth(credentials) }, }, openaiProvider(), + createChatGPTProvider(accounts), { ...codex, auth: { ...codex.auth, oauth: portableCodexOAuth } }, // Drop pi's built-in Claude Pro / Max OAuth too; Anthropic policy disallows it here. { ...anthropic, auth: { ...anthropic.auth, oauth: undefined } }, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4dc34f8..95d693b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -61,6 +61,7 @@ import { draftFromParts, promptTextFromDraft, type ComposerDraft } from "./compo import { parseSlashCommand } from "../core/slashCommands"; import { showToast } from "../platform/toast"; import { useProviderCredential } from "./useProviderCredential"; +import { CHATGPT_USAGE_URL, type ChatGPTRegistration } from "../providers/chatgptOAuth"; import { AgentIcon } from "./AgentIcon"; type Props = { @@ -1019,6 +1020,50 @@ function SettingsSheet({ const [showKey, setShowKey] = useState(false); const [authPromptValue, setAuthPromptValue] = useState(""); const connected = useProviderCredential(controller, providerId, state.settings.customEndpoints); + const [chatgptAccount, setChatgptAccount] = useState(); + const [credentialAction, setCredentialAction] = useState<{ + providerId: ProviderId; + kind: "disconnect" | "remove"; + }>(); + const credentialActionPending = useRef(false); + const changeCredential = async (kind: "disconnect" | "remove") => { + if (credentialActionPending.current) return; + credentialActionPending.current = true; + setCredentialAction({ providerId, kind }); + try { + if (kind === "remove") await controller.forgetChatGPTAccount(); + else await controller.removeCredential(providerId); + onToast(kind === "remove" ? "ChatGPT account removed" : `${provider.name} disconnected`); + } catch (error) { + onToast(error instanceof Error ? error.message : String(error)); + } finally { + credentialActionPending.current = false; + setCredentialAction(undefined); + } + }; + useEffect(() => { + if (providerId !== "chatgpt") return; + let revision = 0; + const refresh = () => { + const current = ++revision; + void controller.providers.chatgptAccount + .state() + .then(({ registration }) => { + if (current === revision) setChatgptAccount(registration); + }) + .catch(() => { + if (current === revision) setChatgptAccount(undefined); + }); + }; + const unsubscribe = controller.credentials.changes.subscribe((id) => { + if (id === "chatgpt") refresh(); + }); + refresh(); + return () => { + revision++; + unsubscribe(); + }; + }, [controller, providerId]); const [endpointView, setEndpointView] = useState<{ endpoint?: CustomEndpoint } | null>(null); useEffect(() => { setKey(""); @@ -1030,6 +1075,10 @@ function SettingsSheet({ ); const authFlow = state.authFlow?.providerId === providerId ? state.authFlow : undefined; const signingIn = authFlow?.status === "waiting" || authFlow?.status === "connecting"; + const disconnecting = + credentialAction?.providerId === providerId && credentialAction.kind === "disconnect"; + const removing = + credentialAction?.providerId === providerId && credentialAction.kind === "remove"; const canContinueSignIn = authFlow?.status === "waiting" && authFlow.browserReturned && Boolean(authFlow.verificationUri); const authPromptKey = `${providerId}:${authFlow?.prompt?.type ?? ""}:${authFlow?.prompt?.message ?? ""}`; @@ -1090,7 +1139,9 @@ function SettingsSheet({ }} /> -
+
{!signingIn && (

{connected === undefined @@ -1100,6 +1151,18 @@ function SettingsSheet({ : `Connect to ${provider.name}`}

)} + {providerId === "chatgpt" && !signingIn && (chatgptAccount || connected) && ( +
+ {chatgptAccount && ( +

+ {chatgptAccount.identity?.email ?? + chatgptAccount.identity?.name ?? + "Saved ChatGPT account"} +

+ )} + {connected &&

Using your ChatGPT plan

} +
+ )} {provider.apiKey && connected === false && !signingIn && @@ -1154,7 +1217,7 @@ function SettingsSheet({ )} - {connected && !signingIn && ( - + {!signingIn && (connected || providerId === "chatgpt" || disconnecting) && ( +
+ {(connected || disconnecting) && ( + + )} + {providerId === "chatgpt" && ( + <> + {connected === false && chatgptAccount && !disconnecting && ( + + )} + + + )} +
)} {selectedEndpoint && (