mirror of
https://github.com/earendil-works/pi.git
synced 2026-10-02 08:44:38 +08:00
feat(ai,coding-agent): add alternative sign in for the openai provider
This commit is contained in:
@@ -63,7 +63,7 @@ Unified LLM API with provider collections, automatic auth resolution, token and
|
||||
- **OpenAI**
|
||||
- **Ant Ling**
|
||||
- **Azure OpenAI (Responses)**
|
||||
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
|
||||
- **OpenAI Codex (legacy)** (ChatGPT Plus/Pro subscription, requires OAuth, see below)
|
||||
- **Radius** (API key or OAuth, with a dynamically refreshed gateway catalog)
|
||||
- **TypeSafe** (System One classifier API)
|
||||
- **DeepSeek**
|
||||
@@ -1729,7 +1729,8 @@ Use this when one process needs different provider settings per request, or when
|
||||
Several providers support OAuth authentication instead of static API keys:
|
||||
|
||||
- **Anthropic** (Claude Pro/Max subscription)
|
||||
- **OpenAI Codex** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
|
||||
- **OpenAI** (Sign in with ChatGPT: uses the ChatGPT subscription with the OpenAI API)
|
||||
- **OpenAI Codex (legacy)** (ChatGPT Plus/Pro subscription, access to GPT-5.x Codex models)
|
||||
- **GitHub Copilot** (Copilot subscription)
|
||||
- **OpenRouter** (OAuth PKCE that mints a user-controlled API key)
|
||||
|
||||
@@ -1809,7 +1810,7 @@ Built-in login and refresh flows are private provider implementations. Use provi
|
||||
|
||||
Provider notes:
|
||||
|
||||
**OpenAI Codex**: Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity. Call `cleanupSessionResources(sessionId)` when finished so the pooled connection does not keep the process alive.
|
||||
**OpenAI Codex (legacy)**: Superseded by Sign in with ChatGPT on the OpenAI provider. Requires a ChatGPT Plus or Pro subscription. Provides access to GPT-5.x Codex models with extended context windows and reasoning capabilities. The library automatically handles session-based prompt caching when `sessionId` is provided in stream options unless `cacheRetention` is `"none"`. You can set `transport` in stream options to `"sse"`, `"websocket"`, or `"auto"` for Codex Responses transport selection. When using WebSocket with a `sessionId` and cache retention enabled, connections are reused per session and expire after 5 minutes of inactivity. Call `cleanupSessionResources(sessionId)` when finished so the pooled connection does not keep the process alive.
|
||||
|
||||
**Azure OpenAI (Responses)**: Uses the Responses API only. Set `AZURE_OPENAI_API_KEY` and either `AZURE_OPENAI_BASE_URL` or `AZURE_OPENAI_RESOURCE_NAME`. `AZURE_OPENAI_BASE_URL` supports both `https://<resource>.openai.azure.com` and `https://<resource>.cognitiveservices.azure.com`; root endpoints are normalized to `.../openai/v1` automatically. Use `AZURE_OPENAI_API_VERSION` (defaults to `v1`) to override the API version if needed. Deployment names are treated as model IDs by default, override with `azureDeploymentName` or `AZURE_OPENAI_DEPLOYMENT_NAME_MAP` using comma-separated `model-id=deployment` pairs (for example `gpt-4o-mini=my-deployment,gpt-4o=prod`). Legacy deployment-based URLs are intentionally unsupported.
|
||||
|
||||
|
||||
@@ -31,6 +31,20 @@ import { buildBaseOptions } from "./simple-options.ts";
|
||||
const OPENAI_TOOL_CALL_PROVIDERS = new Set(["openai", "openai-codex", "opencode"]);
|
||||
// OpenAI Responses rejects max_output_tokens below 16: https://github.com/earendil-works/pi/issues/6265
|
||||
const OPENAI_RESPONSES_MIN_OUTPUT_TOKENS = 16;
|
||||
const CHATGPT_USAGE_URL = "https://chatgpt.com/settings/usage";
|
||||
|
||||
/**
|
||||
* OpenAI API keys start with `sk-`; a different credential sent directly to OpenAI
|
||||
* is a Sign in with ChatGPT access token.
|
||||
*/
|
||||
function isChatGPTSignIn(model: Model<"openai-responses">, apiKey: string | undefined): boolean {
|
||||
return (
|
||||
model.provider === "openai" &&
|
||||
model.baseUrl === "https://api.openai.com/v1" &&
|
||||
apiKey !== undefined &&
|
||||
!apiKey.startsWith("sk-")
|
||||
);
|
||||
}
|
||||
|
||||
function hasHeader(headers: ProviderHeaders | undefined, name: string): boolean {
|
||||
if (!headers) return false;
|
||||
@@ -205,10 +219,14 @@ export const stream: StreamFunction<"openai-responses", OpenAIResponsesOptions>
|
||||
delete (block as { customInput?: unknown }).customInput;
|
||||
}
|
||||
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
||||
output.errorMessage = formatProviderError(
|
||||
const errorMessage = formatProviderError(
|
||||
normalizeProviderError(error),
|
||||
`${model.provider === "openai" ? "OpenAI" : model.provider} API error`,
|
||||
);
|
||||
// Sign in with ChatGPT shares the subscription's usage limit with other apps.
|
||||
output.errorMessage = errorMessage.includes("subscription_sharing_usage_limit_exceeded")
|
||||
? `${errorMessage}\nCheck your ChatGPT usage: ${CHATGPT_USAGE_URL}`
|
||||
: errorMessage;
|
||||
stream.push({ type: "error", reason: output.stopReason, error: output });
|
||||
stream.end();
|
||||
}
|
||||
@@ -307,21 +325,23 @@ function buildParams(
|
||||
});
|
||||
|
||||
const cacheRetention = resolveCacheRetention(options?.cacheRetention, options?.env);
|
||||
// Sign in with ChatGPT rejects these request fields.
|
||||
const omitUnsupportedFields = isChatGPTSignIn(model, options?.apiKey);
|
||||
const params: ResponseCreateParamsStreaming = {
|
||||
model: model.id,
|
||||
input: messages,
|
||||
stream: true,
|
||||
prompt_cache_key: cacheRetention === "none" ? undefined : clampOpenAIPromptCacheKey(options?.sessionId),
|
||||
prompt_cache_retention: getPromptCacheRetention(compat, cacheRetention),
|
||||
prompt_cache_options: getPromptCacheOptions(compat, cacheRetention),
|
||||
prompt_cache_retention: omitUnsupportedFields ? undefined : getPromptCacheRetention(compat, cacheRetention),
|
||||
prompt_cache_options: omitUnsupportedFields ? undefined : getPromptCacheOptions(compat, cacheRetention),
|
||||
store: false,
|
||||
};
|
||||
|
||||
if (options?.maxTokens && compat.supportsMaxOutputTokens) {
|
||||
if (options?.maxTokens && compat.supportsMaxOutputTokens && !omitUnsupportedFields) {
|
||||
params.max_output_tokens = Math.max(options.maxTokens, OPENAI_RESPONSES_MIN_OUTPUT_TOKENS);
|
||||
}
|
||||
|
||||
if (options?.temperature !== undefined) {
|
||||
if (options?.temperature !== undefined && !omitUnsupportedFields) {
|
||||
params.temperature = options?.temperature;
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export function lazyOAuth(input: {
|
||||
name: input.name,
|
||||
isSubscription: input.isSubscription,
|
||||
loginLabel: input.loginLabel,
|
||||
login: async (interaction) => (await loaded()).login(interaction),
|
||||
login: async (interaction, options) => (await loaded()).login(interaction, options),
|
||||
refresh: async (credential, signal) => (await loaded()).refresh(credential, signal),
|
||||
toAuth: async (credential) => (await loaded()).toAuth(credential),
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ const importOAuthModule = (specifier: string): Promise<unknown> => {
|
||||
type OAuthFlowLoaders = {
|
||||
anthropic: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiCodex: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openaiChatGPT: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
githubCopilot: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
openrouter: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
kimiCoding: () => OAuthAuth | Promise<OAuthAuth>;
|
||||
@@ -39,6 +40,11 @@ export const loadOpenAICodexOAuth = async (): Promise<OAuthAuth> => {
|
||||
return ((await importOAuthModule("./openai-codex.ts")) as { openaiCodexOAuth: OAuthAuth }).openaiCodexOAuth;
|
||||
};
|
||||
|
||||
export const loadOpenAIChatGPTOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.openaiChatGPT();
|
||||
return ((await importOAuthModule("./openai-chatgpt.ts")) as { openaiChatGPTOAuth: OAuthAuth }).openaiChatGPTOAuth;
|
||||
};
|
||||
|
||||
export const loadGitHubCopilotOAuth = async (): Promise<OAuthAuth> => {
|
||||
if (bundledLoaders) return bundledLoaders.githubCopilot();
|
||||
return ((await importOAuthModule("./github-copilot.ts")) as { githubCopilotOAuth: OAuthAuth }).githubCopilotOAuth;
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* OpenAI Responses API token sharing through Sign in with ChatGPT.
|
||||
*
|
||||
* This public-client flow uses no client secret and sends the resulting user
|
||||
* access token directly to api.openai.com.
|
||||
*/
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { createServer, type Server, type ServerResponse } from "node:http";
|
||||
import { oauthErrorHtml, oauthSuccessHtml } from "../../utils/oauth-page.ts";
|
||||
import { getProviderEnvValue } from "../../utils/provider-env.ts";
|
||||
import type { LoginOptions, OAuthAuth, OAuthCredential, ProviderAuthInteraction } from "../types.ts";
|
||||
import { generatePKCE } from "./pkce.ts";
|
||||
|
||||
// every login registers a new client with this ID; OpenAI returns the issued client ID in the callback
|
||||
const DYNAMIC_CLIENT_ID = "dynamic_agent_client";
|
||||
const AGENT_NAME_HINT = "Pi";
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const AUTHORIZE_URL = "https://auth.openai.com/api/accounts/authorize";
|
||||
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
|
||||
const RESOURCE = "https://api.openai.com/v1";
|
||||
const CALLBACK_HOST = getProviderEnvValue("PI_OAUTH_CALLBACK_HOST") || "127.0.0.1";
|
||||
const CALLBACK_PORT = 1455;
|
||||
const CALLBACK_PATH = "/auth/callback";
|
||||
const REDIRECT_URI = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
|
||||
const DIRECT_TOKEN_SCOPE = "chatgpt.tokens.use.direct";
|
||||
const SCOPE = `openid profile email offline_access resource.invoke ${DIRECT_TOKEN_SCOPE}`;
|
||||
// Refresh this long before the real expiry so a request never starts with a token about to expire.
|
||||
const EXPIRY_MARGIN_MS = 3 * 60 * 1000;
|
||||
|
||||
type AuthorizationResult = {
|
||||
code: string;
|
||||
clientId: string;
|
||||
};
|
||||
|
||||
type CallbackServer = {
|
||||
server: Server;
|
||||
result: Promise<AuthorizationResult>;
|
||||
};
|
||||
|
||||
type TokenResponse = {
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
expires_in?: unknown;
|
||||
id_token?: unknown;
|
||||
scope?: unknown;
|
||||
};
|
||||
|
||||
function randomValue(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function authorizationResultFromCallback(url: URL, expectedState: string): AuthorizationResult {
|
||||
const code = url.searchParams.get("code");
|
||||
if (!code) throw new Error("Missing authorization code");
|
||||
const state = url.searchParams.get("state");
|
||||
if (!state) throw new Error("Missing OAuth state");
|
||||
if (state !== expectedState) throw new Error("OAuth state mismatch");
|
||||
const clientId = url.searchParams.get("client_id")?.trim();
|
||||
if (!clientId) throw new Error("OpenAI OAuth registration callback did not contain an issued client ID");
|
||||
return { code, clientId };
|
||||
}
|
||||
|
||||
function authorizationResultFromManualInput(input: string, expectedState: string): AuthorizationResult {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input.trim());
|
||||
} catch {
|
||||
throw new Error("Paste the full callback URL from the browser");
|
||||
}
|
||||
const expected = new URL(REDIRECT_URI);
|
||||
if (url.origin !== expected.origin || url.pathname !== expected.pathname) {
|
||||
throw new Error(`The pasted callback URL must start with ${REDIRECT_URI}`);
|
||||
}
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) throw new Error(`ChatGPT authorization failed: ${error}`);
|
||||
return authorizationResultFromCallback(url, expectedState);
|
||||
}
|
||||
|
||||
function sendHtml(response: ServerResponse, status: number, body: string): void {
|
||||
response.writeHead(status, { "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function startCallbackServer(expectedState: string): Promise<CallbackServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolveResult!: (result: AuthorizationResult) => void;
|
||||
let rejectResult!: (error: Error) => void;
|
||||
const result = new Promise<AuthorizationResult>((resolveAuthorization, rejectAuthorization) => {
|
||||
resolveResult = resolveAuthorization;
|
||||
rejectResult = rejectAuthorization;
|
||||
});
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url || "", REDIRECT_URI);
|
||||
if (url.pathname !== CALLBACK_PATH) {
|
||||
sendHtml(response, 404, oauthErrorHtml("Callback route not found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const error = url.searchParams.get("error");
|
||||
if (error) {
|
||||
sendHtml(response, 400, oauthErrorHtml("ChatGPT was not connected.", `Error: ${error}`));
|
||||
rejectResult(new Error(`ChatGPT authorization failed: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let authorizationResult: AuthorizationResult;
|
||||
try {
|
||||
authorizationResult = authorizationResultFromCallback(url, expectedState);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid callback";
|
||||
sendHtml(response, 400, oauthErrorHtml(message));
|
||||
return;
|
||||
}
|
||||
|
||||
sendHtml(response, 200, oauthSuccessHtml("ChatGPT authentication completed. You can close this window."));
|
||||
resolveResult(authorizationResult);
|
||||
} catch {
|
||||
sendHtml(response, 500, oauthErrorHtml("Internal error while processing the callback."));
|
||||
}
|
||||
});
|
||||
|
||||
server.once("error", reject);
|
||||
server.listen(CALLBACK_PORT, CALLBACK_HOST, () => {
|
||||
server.removeListener("error", reject);
|
||||
server.on("error", rejectResult);
|
||||
resolve({ server, result });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function requestToken(body: URLSearchParams, signal: AbortSignal): Promise<TokenResponse> {
|
||||
const response = await fetch(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body,
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.text().catch(() => "");
|
||||
throw new Error(`OpenAI OAuth token request failed (${response.status}): ${responseBody || response.statusText}`);
|
||||
}
|
||||
const data: unknown = await response.json();
|
||||
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||
throw new Error("OpenAI OAuth token response must be an object");
|
||||
}
|
||||
return data as TokenResponse;
|
||||
}
|
||||
|
||||
function requireTokenString(value: unknown, field: "access_token" | "refresh_token" | "scope"): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`OpenAI OAuth token response has invalid ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function credentialFromTokenResponse(token: TokenResponse, clientId: string): OAuthCredential {
|
||||
const access = requireTokenString(token.access_token, "access_token");
|
||||
const refresh = requireTokenString(token.refresh_token, "refresh_token");
|
||||
const scope = requireTokenString(token.scope, "scope");
|
||||
if (typeof token.expires_in !== "number" || !Number.isFinite(token.expires_in) || token.expires_in <= 0) {
|
||||
throw new Error("OpenAI OAuth token response has invalid expires_in");
|
||||
}
|
||||
const scopes = scope.trim().split(/\s+/).filter(Boolean);
|
||||
if (!scopes.includes(DIRECT_TOKEN_SCOPE)) {
|
||||
throw new Error(`OpenAI OAuth grant did not include ${DIRECT_TOKEN_SCOPE}`);
|
||||
}
|
||||
return {
|
||||
type: "oauth",
|
||||
access,
|
||||
refresh,
|
||||
expires: Date.now() + token.expires_in * 1000 - EXPIRY_MARGIN_MS,
|
||||
clientId,
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
||||
async function exchangeAuthorizationCode(
|
||||
code: string,
|
||||
verifier: string,
|
||||
clientId: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<OAuthCredential> {
|
||||
const token = await requestToken(
|
||||
new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientId,
|
||||
code,
|
||||
code_verifier: verifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
resource: RESOURCE,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
// Pi does not use the ID token to identify the user or read profile data.
|
||||
// Keep the presence check as part of the token-response contract.
|
||||
if (typeof token.id_token !== "string" || token.id_token.trim().length === 0) {
|
||||
throw new Error("OpenAI OAuth token response did not contain an ID token");
|
||||
}
|
||||
return credentialFromTokenResponse(token, clientId);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential> {
|
||||
const clientId = credential.clientId;
|
||||
if (typeof clientId !== "string" || clientId.trim().length === 0) {
|
||||
throw new Error("Stored OpenAI OAuth credential does not contain an issued client ID; reconnect ChatGPT");
|
||||
}
|
||||
const token = await requestToken(
|
||||
new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: clientId,
|
||||
refresh_token: credential.refresh,
|
||||
resource: RESOURCE,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
return credentialFromTokenResponse(token, clientId);
|
||||
}
|
||||
|
||||
/** OpenAI identifies each installation ("agent host") by a stable URI such as `urn:uuid:<uuid>`. */
|
||||
function agentHostId(deviceId: string | undefined): string {
|
||||
if (!deviceId || !UUID_PATTERN.test(deviceId)) {
|
||||
throw new Error("Sign in with ChatGPT requires a device ID (UUID) for this installation");
|
||||
}
|
||||
return `urn:uuid:${deviceId.toLowerCase()}`;
|
||||
}
|
||||
|
||||
async function loginOpenAIChatGPT(
|
||||
interaction: ProviderAuthInteraction,
|
||||
options?: LoginOptions,
|
||||
): Promise<OAuthCredential> {
|
||||
const hostId = agentHostId(options?.getDeviceId?.());
|
||||
const { verifier, challenge } = await generatePKCE();
|
||||
const state = randomValue();
|
||||
const nonce = randomValue();
|
||||
let callback: CallbackServer | undefined;
|
||||
try {
|
||||
callback = await startCallbackServer(state);
|
||||
} catch (error) {
|
||||
interaction.notify({
|
||||
type: "info",
|
||||
message: `Could not listen on ${REDIRECT_URI}; paste the final redirect URL to continue. ${error instanceof Error ? error.message : String(error)}`,
|
||||
});
|
||||
}
|
||||
|
||||
const authorizationUrl = new URL(AUTHORIZE_URL);
|
||||
authorizationUrl.search = new URLSearchParams({
|
||||
client_id: DYNAMIC_CLIENT_ID,
|
||||
agent_name_hint: AGENT_NAME_HINT,
|
||||
ext_agent_host_id: hostId,
|
||||
response_type: "code",
|
||||
redirect_uri: REDIRECT_URI,
|
||||
resource: RESOURCE,
|
||||
scope: SCOPE,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: "S256",
|
||||
nonce,
|
||||
}).toString();
|
||||
interaction.notify({
|
||||
type: "auth_url",
|
||||
url: authorizationUrl.toString(),
|
||||
instructions:
|
||||
"Complete sign-in in your browser. If the callback does not complete, paste the final redirect URL here.",
|
||||
});
|
||||
|
||||
const manualAbort = new AbortController();
|
||||
const manualCode = interaction
|
||||
.prompt({
|
||||
type: "manual_code",
|
||||
message: "Complete login in your browser, or paste the final redirect URL here:",
|
||||
placeholder: REDIRECT_URI,
|
||||
signal: AbortSignal.any([manualAbort.signal, interaction.signal]),
|
||||
})
|
||||
.then((input) => authorizationResultFromManualInput(input, state));
|
||||
|
||||
try {
|
||||
const result = await (callback ? Promise.race([callback.result, manualCode]) : manualCode);
|
||||
interaction.notify({ type: "progress", message: "Exchanging authorization code for tokens..." });
|
||||
return await exchangeAuthorizationCode(result.code, verifier, result.clientId, interaction.signal);
|
||||
} catch (error) {
|
||||
if (interaction.signal.aborted) throw new Error("Login cancelled");
|
||||
throw error;
|
||||
} finally {
|
||||
manualAbort.abort();
|
||||
callback?.server.close();
|
||||
// close() only stops accepting new connections. Browsers open spare connections ahead of
|
||||
// time, and one that has not sent a request yet stays open and attached to this server.
|
||||
// A later login in the same process starts a new server with a new state, but the browser
|
||||
// may send that login's callback over the spare connection. This server would then handle
|
||||
// it and reject it with "OAuth state mismatch", and the new login would never see it.
|
||||
callback?.server.closeAllConnections();
|
||||
}
|
||||
}
|
||||
|
||||
export const openaiChatGPTOAuth: OAuthAuth = {
|
||||
name: "OpenAI (ChatGPT subscription)",
|
||||
isSubscription: true,
|
||||
loginLabel: "Sign in with ChatGPT",
|
||||
login: loginOpenAIChatGPT,
|
||||
refresh: refreshAccessToken,
|
||||
async toAuth(credential) {
|
||||
return { apiKey: credential.access };
|
||||
},
|
||||
};
|
||||
@@ -198,6 +198,16 @@ export interface ApiKeyAuth {
|
||||
}): Promise<AuthResult | undefined>;
|
||||
}
|
||||
|
||||
/** App-supplied context for `Models.login`. */
|
||||
export interface LoginOptions {
|
||||
/**
|
||||
* Returns the stable ID of this app installation, e.g. sent to OpenAI as its
|
||||
* agent host ID. Called only by login flows that need it, so apps can create
|
||||
* the ID on first use and must return the same ID on every later call.
|
||||
*/
|
||||
getDeviceId?: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth auth. The `refresh`/`toAuth` split lets `Models` own the locked
|
||||
* refresh pattern: `refresh` produces a credential, `toAuth` derives request
|
||||
@@ -213,7 +223,7 @@ export interface OAuthAuth {
|
||||
/** Selector label for the OAuth login option, e.g. "Sign in with SuperGrok or X Premium". */
|
||||
loginLabel?: string;
|
||||
|
||||
login(interaction: ProviderAuthInteraction): Promise<OAuthCredential>;
|
||||
login(interaction: ProviderAuthInteraction, options?: LoginOptions): Promise<OAuthCredential>;
|
||||
|
||||
/**
|
||||
* Exchange the refresh token. Network call; throws on failure
|
||||
|
||||
@@ -3,6 +3,7 @@ import { githubCopilotOAuth } from "./auth/oauth/github-copilot.ts";
|
||||
import { kimiCodingOAuth } from "./auth/oauth/kimi-coding.ts";
|
||||
import { registerBundledOAuthFlowLoaders } from "./auth/oauth/load.ts";
|
||||
import { metaOAuth } from "./auth/oauth/meta.ts";
|
||||
import { openaiChatGPTOAuth } from "./auth/oauth/openai-chatgpt.ts";
|
||||
import { openaiCodexOAuth } from "./auth/oauth/openai-codex.ts";
|
||||
import { openRouterOAuth } from "./auth/oauth/openrouter.ts";
|
||||
import { createRadiusOAuth } from "./auth/oauth/radius.ts";
|
||||
@@ -13,6 +14,7 @@ export function registerBunOAuthFlows(): void {
|
||||
registerBundledOAuthFlowLoaders({
|
||||
anthropic: () => anthropicOAuth,
|
||||
openaiCodex: () => openaiCodexOAuth,
|
||||
openaiChatGPT: () => openaiChatGPTOAuth,
|
||||
githubCopilot: () => githubCopilotOAuth,
|
||||
openrouter: () => openRouterOAuth,
|
||||
kimiCoding: () => kimiCodingOAuth,
|
||||
|
||||
+24
-19
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import type { AuthPrompt, OAuthCredential, Provider } from "./index.ts";
|
||||
@@ -47,26 +48,30 @@ async function login(providerId: string): Promise<void> {
|
||||
if (!provider) throw new Error(`Unknown provider: ${providerId}`);
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const credential = await provider.auth.oauth.login({
|
||||
signal: new AbortController().signal,
|
||||
prompt: (authPrompt) => answerPrompt(rl, authPrompt),
|
||||
notify: (event) => {
|
||||
switch (event.type) {
|
||||
case "auth_url":
|
||||
console.log(`\nOpen this URL in your browser:\n${event.url}`);
|
||||
if (event.instructions) console.log(event.instructions);
|
||||
break;
|
||||
case "device_code":
|
||||
console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
|
||||
console.log(`Enter code: ${event.userCode}`);
|
||||
break;
|
||||
case "info":
|
||||
case "progress":
|
||||
console.log(event.message);
|
||||
break;
|
||||
}
|
||||
// This dev CLI does not persist an installation ID; apps should reuse one across logins.
|
||||
const credential = await provider.auth.oauth.login(
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
prompt: (authPrompt) => answerPrompt(rl, authPrompt),
|
||||
notify: (event) => {
|
||||
switch (event.type) {
|
||||
case "auth_url":
|
||||
console.log(`\nOpen this URL in your browser:\n${event.url}`);
|
||||
if (event.instructions) console.log(event.instructions);
|
||||
break;
|
||||
case "device_code":
|
||||
console.log(`\nOpen this URL in your browser:\n${event.verificationUri}`);
|
||||
console.log(`Enter code: ${event.userCode}`);
|
||||
break;
|
||||
case "info":
|
||||
case "progress":
|
||||
console.log(event.message);
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
{ getDeviceId: randomUUID },
|
||||
);
|
||||
const auth = loadAuth();
|
||||
auth[providerId] = credential;
|
||||
saveAuth(auth);
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AuthType,
|
||||
Credential,
|
||||
CredentialStore,
|
||||
LoginOptions,
|
||||
ProviderAuth,
|
||||
} from "./auth/types.ts";
|
||||
import { InMemoryModelsStore, type ModelsStore, type ModelsStoreEntry } from "./models-store.ts";
|
||||
@@ -301,7 +302,7 @@ export interface Models {
|
||||
getAuth(model: AnyModel, overrides?: AuthResolutionOverrides): Promise<AuthResult | undefined>;
|
||||
|
||||
/** Run a provider-owned login flow and persist its returned credential. */
|
||||
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential>;
|
||||
login(providerId: string, type: AuthType, interaction: AuthInteraction, options?: LoginOptions): Promise<Credential>;
|
||||
|
||||
/** Remove the stored credential for a provider. */
|
||||
logout(providerId: string, options?: AuthOperationOptions): Promise<void>;
|
||||
@@ -752,7 +753,12 @@ class ModelsImpl implements MutableModels {
|
||||
};
|
||||
}
|
||||
|
||||
async login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
|
||||
async login(
|
||||
providerId: string,
|
||||
type: AuthType,
|
||||
interaction: AuthInteraction,
|
||||
options?: LoginOptions,
|
||||
): Promise<Credential> {
|
||||
const signal = operationSignal(interaction.signal);
|
||||
signal.throwIfAborted();
|
||||
const provider = this.providers.get(providerId);
|
||||
@@ -761,7 +767,7 @@ class ModelsImpl implements MutableModels {
|
||||
if (!method?.login) {
|
||||
throw new ModelsError("auth", `${provider.name} does not support ${type} login`);
|
||||
}
|
||||
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal });
|
||||
const loginOperation: Promise<Credential> = method.login({ ...interaction, signal }, options);
|
||||
const credential = await raceWithAbortSignal(loginOperation, signal);
|
||||
let mutationStarted = false;
|
||||
let markMutationStarted: (() => void) | undefined;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { OPENAI_CODEX_MODELS } from "./openai-codex.models.ts";
|
||||
export function openaiCodexProvider(): Provider<"openai-codex-responses"> {
|
||||
return createProvider({
|
||||
id: "openai-codex",
|
||||
name: "OpenAI Codex",
|
||||
name: "OpenAI Codex (legacy)",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
auth: {
|
||||
oauth: lazyOAuth({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openAIResponsesApi } from "../api/openai-responses.lazy.ts";
|
||||
import { envApiKeyAuth } from "../auth/helpers.ts";
|
||||
import { envApiKeyAuth, lazyOAuth } from "../auth/helpers.ts";
|
||||
import { loadOpenAIChatGPTOAuth } from "../auth/oauth/load.ts";
|
||||
import { createProvider, type Provider } from "../models.ts";
|
||||
import { OPENAI_MODELS } from "./openai.models.ts";
|
||||
|
||||
@@ -8,7 +9,15 @@ export function openaiProvider(): Provider<"openai-responses"> {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
auth: { apiKey: envApiKeyAuth("OpenAI API key", ["OPENAI_API_KEY"]) },
|
||||
auth: {
|
||||
apiKey: envApiKeyAuth("OpenAI API key", ["OPENAI_API_KEY"]),
|
||||
oauth: lazyOAuth({
|
||||
name: "OpenAI (ChatGPT subscription)",
|
||||
isSubscription: true,
|
||||
loginLabel: "Sign in with ChatGPT",
|
||||
load: loadOpenAIChatGPTOAuth,
|
||||
}),
|
||||
},
|
||||
models: Object.values(OPENAI_MODELS),
|
||||
api: openAIResponsesApi(),
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
"out of budget",
|
||||
"quota exceeded",
|
||||
"billing",
|
||||
|
||||
// Sign in with ChatGPT: the subscription's shared usage limit, which resets
|
||||
// after hours rather than seconds.
|
||||
"subscription_sharing_usage_limit_exceeded",
|
||||
]);
|
||||
|
||||
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
@@ -89,6 +93,11 @@ const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
|
||||
|
||||
// gRPC based providers (e.g. NVIDIA NIM)
|
||||
"ResourceExhausted",
|
||||
|
||||
// Sign in with ChatGPT: usage or user data temporarily unavailable. Usage
|
||||
// failures can arrive mid-stream without an HTTP 503 in the message.
|
||||
"subscription_sharing_usage_unavailable",
|
||||
"subscription_sharing_user_unavailable",
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -306,7 +306,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
|
||||
try {
|
||||
const s = streamOpenAIResponses(proxyModel, context, {
|
||||
apiKey: "fake-key",
|
||||
apiKey: "sk-fake-key",
|
||||
onPayload: stopAfterPayload((payload) => {
|
||||
capturedPayload = payload;
|
||||
}),
|
||||
@@ -333,7 +333,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
|
||||
try {
|
||||
const s = streamOpenAIResponses(model, context, {
|
||||
apiKey: "fake-key",
|
||||
apiKey: "sk-fake-key",
|
||||
cacheRetention: "long",
|
||||
sessionId: "session-compat-false",
|
||||
onPayload: stopAfterPayload((payload) => {
|
||||
@@ -358,7 +358,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
|
||||
try {
|
||||
const s = streamOpenAIResponses(model, context, {
|
||||
apiKey: "fake-key",
|
||||
apiKey: "sk-fake-key",
|
||||
cacheRetention: "none",
|
||||
sessionId: "session-1",
|
||||
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
|
||||
@@ -385,7 +385,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
|
||||
try {
|
||||
const s = streamOpenAIResponses(model, context, {
|
||||
apiKey: "fake-key",
|
||||
apiKey: "sk-fake-key",
|
||||
cacheRetention: "none",
|
||||
sessionId: "session-1",
|
||||
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
|
||||
@@ -416,7 +416,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => {
|
||||
|
||||
try {
|
||||
const s = streamOpenAIResponses(model, context, {
|
||||
apiKey: "fake-key",
|
||||
apiKey: "sk-fake-key",
|
||||
cacheRetention: "long",
|
||||
sessionId: "session-2",
|
||||
onPayload: stopAfterPayload<OpenAIResponsesCachePayload>((payload) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { InMemoryCredentialStore } from "../src/auth/credential-store.ts";
|
||||
import { anthropicOAuth } from "../src/auth/oauth/anthropic.ts";
|
||||
import { githubCopilotOAuth } from "../src/auth/oauth/github-copilot.ts";
|
||||
import { kimiCodingOAuth } from "../src/auth/oauth/kimi-coding.ts";
|
||||
import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts";
|
||||
import { openaiCodexOAuth } from "../src/auth/oauth/openai-codex.ts";
|
||||
import { openRouterOAuth } from "../src/auth/oauth/openrouter.ts";
|
||||
import { xaiOAuth } from "../src/auth/oauth/xai.ts";
|
||||
@@ -10,6 +11,7 @@ import { createModels } from "../src/models.ts";
|
||||
import * as extensionOAuthCompatibility from "../src/oauth.ts";
|
||||
import { anthropicProvider } from "../src/providers/anthropic.ts";
|
||||
import { githubCopilotProvider } from "../src/providers/github-copilot.ts";
|
||||
import { openaiProvider } from "../src/providers/openai.ts";
|
||||
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
|
||||
@@ -28,7 +30,14 @@ describe.sequential("OAuthAuth adapters", () => {
|
||||
});
|
||||
|
||||
it("identifies only subscription-backed OAuth flows as subscriptions", () => {
|
||||
for (const oauth of [anthropicOAuth, openaiCodexOAuth, githubCopilotOAuth, kimiCodingOAuth, xaiOAuth]) {
|
||||
for (const oauth of [
|
||||
anthropicOAuth,
|
||||
openaiChatGPTOAuth,
|
||||
openaiCodexOAuth,
|
||||
githubCopilotOAuth,
|
||||
kimiCodingOAuth,
|
||||
xaiOAuth,
|
||||
]) {
|
||||
expect(oauth.isSubscription).toBe(true);
|
||||
}
|
||||
expect(openRouterOAuth.isSubscription).not.toBe(true);
|
||||
@@ -39,6 +48,16 @@ describe.sequential("OAuthAuth adapters", () => {
|
||||
expect(auth).toEqual({ apiKey: "token" });
|
||||
});
|
||||
|
||||
it("OpenAI exposes ChatGPT OAuth alongside API-key auth", () => {
|
||||
const provider = openaiProvider();
|
||||
expect(provider.auth.apiKey).toBeDefined();
|
||||
expect(provider.auth.oauth).toMatchObject({
|
||||
name: "OpenAI (ChatGPT subscription)",
|
||||
isSubscription: true,
|
||||
loginLabel: "Sign in with ChatGPT",
|
||||
});
|
||||
});
|
||||
|
||||
it("openai-codex toAuth derives the api key from the access token", async () => {
|
||||
const auth = await openaiCodexOAuth.toAuth({ type: "oauth", access: "token", refresh: "r", expires: 0 });
|
||||
expect(auth).toEqual({ apiKey: "token" });
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { openaiChatGPTOAuth } from "../src/auth/oauth/openai-chatgpt.ts";
|
||||
import type { OAuthCredential, ProviderAuthInteraction } from "../src/auth/types.ts";
|
||||
|
||||
const TOKEN_URL = "https://auth.openai.com/api/accounts/oauth/token";
|
||||
const REQUIRED_SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct";
|
||||
const neverAbortedSignal = new AbortController().signal;
|
||||
const DEVICE_ID = "e61bbe28-07ef-466d-8e5d-a344f94ab305";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function tokenResponse(scope = REQUIRED_SCOPE) {
|
||||
return {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
expires_in: 3600,
|
||||
id_token: "id-token",
|
||||
scope,
|
||||
};
|
||||
}
|
||||
|
||||
function stubTokenEndpoint(response: unknown, inspect?: (body: URLSearchParams) => void) {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
expect(input instanceof Request ? input.url : String(input)).toBe(TOKEN_URL);
|
||||
inspect?.(new URLSearchParams(String(init?.body)));
|
||||
return jsonResponse(response);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function loginInteraction(options?: {
|
||||
callbackClientId?: string;
|
||||
onAuthorize?: (url: URL) => void;
|
||||
}): ProviderAuthInteraction {
|
||||
let authorizeUrl: URL | undefined;
|
||||
return {
|
||||
signal: neverAbortedSignal,
|
||||
notify: (event) => {
|
||||
if (event.type !== "auth_url") return;
|
||||
authorizeUrl = new URL(event.url);
|
||||
options?.onAuthorize?.(authorizeUrl);
|
||||
},
|
||||
prompt: async (prompt) => {
|
||||
if (prompt.type !== "manual_code") throw new Error(`Unexpected prompt: ${prompt.type}`);
|
||||
if (!authorizeUrl) throw new Error("Authorization URL was not emitted before the callback prompt");
|
||||
const callback = new URL(authorizeUrl.searchParams.get("redirect_uri") ?? "");
|
||||
callback.searchParams.set("code", "authorization-code");
|
||||
callback.searchParams.set("state", authorizeUrl.searchParams.get("state") ?? "");
|
||||
if (options?.callbackClientId) callback.searchParams.set("client_id", options.callbackClientId);
|
||||
return callback.toString();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function connectedCredential(): OAuthCredential {
|
||||
return {
|
||||
type: "oauth",
|
||||
access: "old-access",
|
||||
refresh: "old-refresh",
|
||||
expires: 0,
|
||||
clientId: "oaiapp_existing",
|
||||
scopes: REQUIRED_SCOPE.split(" "),
|
||||
};
|
||||
}
|
||||
|
||||
describe("OpenAI ChatGPT OAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("registers a user-owned client and stores its issued ID and granted scopes", async () => {
|
||||
let authorizeUrl: URL | undefined;
|
||||
let exchangeBody: URLSearchParams | undefined;
|
||||
stubTokenEndpoint(tokenResponse(), (body) => {
|
||||
exchangeBody = body;
|
||||
});
|
||||
|
||||
const credential = await openaiChatGPTOAuth.login(
|
||||
loginInteraction({
|
||||
callbackClientId: "oaiapp_issued",
|
||||
onAuthorize: (url) => {
|
||||
authorizeUrl = url;
|
||||
},
|
||||
}),
|
||||
{ getDeviceId: () => DEVICE_ID },
|
||||
);
|
||||
|
||||
expect(authorizeUrl?.searchParams.get("client_id")).toBe("dynamic_agent_client");
|
||||
expect(authorizeUrl?.searchParams.get("agent_name_hint")).toBe("Pi");
|
||||
expect(authorizeUrl?.searchParams.get("ext_agent_host_id")).toBe(`urn:uuid:${DEVICE_ID}`);
|
||||
expect(authorizeUrl?.searchParams.get("scope")).toBe(REQUIRED_SCOPE);
|
||||
expect(authorizeUrl?.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:1455/auth/callback");
|
||||
expect(authorizeUrl?.searchParams.get("resource")).toBe("https://api.openai.com/v1");
|
||||
expect(authorizeUrl?.searchParams.get("code_challenge_method")).toBe("S256");
|
||||
expect(exchangeBody?.get("client_id")).toBe("oaiapp_issued");
|
||||
expect(exchangeBody?.get("code")).toBe("authorization-code");
|
||||
expect(exchangeBody?.get("resource")).toBe("https://api.openai.com/v1");
|
||||
expect(exchangeBody?.get("code_verifier")).toBeTruthy();
|
||||
expect(credential).toMatchObject({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
clientId: "oaiapp_issued",
|
||||
scopes: REQUIRED_SCOPE.split(" "),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects registration without an issued client ID", async () => {
|
||||
const fetchMock = stubTokenEndpoint(tokenResponse());
|
||||
|
||||
await expect(openaiChatGPTOAuth.login(loginInteraction(), { getDeviceId: () => DEVICE_ID })).rejects.toThrow(
|
||||
"registration callback did not contain an issued client ID",
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a token response that did not grant direct token use", async () => {
|
||||
stubTokenEndpoint(tokenResponse("openid profile email offline_access resource.invoke"));
|
||||
|
||||
await expect(
|
||||
openaiChatGPTOAuth.login(loginInteraction({ callbackClientId: "oaiapp_issued" }), {
|
||||
getDeviceId: () => DEVICE_ID,
|
||||
}),
|
||||
).rejects.toThrow("grant did not include chatgpt.tokens.use.direct");
|
||||
});
|
||||
|
||||
it("requires a device ID before starting authorization", async () => {
|
||||
let authorizationStarted = false;
|
||||
const interaction = loginInteraction({
|
||||
onAuthorize: () => {
|
||||
authorizationStarted = true;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(openaiChatGPTOAuth.login(interaction)).rejects.toThrow("requires a device ID");
|
||||
await expect(openaiChatGPTOAuth.login(interaction, { getDeviceId: () => "not-a-uuid" })).rejects.toThrow(
|
||||
"requires a device ID",
|
||||
);
|
||||
expect(authorizationStarted).toBe(false);
|
||||
});
|
||||
|
||||
it("requires refresh responses to rotate the refresh token", async () => {
|
||||
const { refresh_token: _refreshToken, ...responseWithoutRefresh } = tokenResponse();
|
||||
stubTokenEndpoint(responseWithoutRefresh);
|
||||
|
||||
await expect(openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal)).rejects.toThrow(
|
||||
"token response has invalid refresh_token",
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes with the credential's issued client ID and stores replacement scopes", async () => {
|
||||
let refreshBody: URLSearchParams | undefined;
|
||||
stubTokenEndpoint({ ...tokenResponse(), access_token: "new-access", refresh_token: "new-refresh" }, (body) => {
|
||||
refreshBody = body;
|
||||
});
|
||||
|
||||
const before = Date.now();
|
||||
const credential = await openaiChatGPTOAuth.refresh(connectedCredential(), neverAbortedSignal);
|
||||
|
||||
// expires_in is 3600 seconds; the credential expires 3 minutes early so it is refreshed in time.
|
||||
expect(credential.expires).toBeGreaterThanOrEqual(before + (3600 - 180) * 1000);
|
||||
expect(credential.expires).toBeLessThanOrEqual(Date.now() + (3600 - 180) * 1000);
|
||||
|
||||
expect(refreshBody?.get("grant_type")).toBe("refresh_token");
|
||||
expect(refreshBody?.get("client_id")).toBe("oaiapp_existing");
|
||||
expect(refreshBody?.get("refresh_token")).toBe("old-refresh");
|
||||
expect(refreshBody?.get("resource")).toBe("https://api.openai.com/v1");
|
||||
expect(refreshBody?.has("scope")).toBe(false);
|
||||
expect(credential).toMatchObject({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
clientId: "oaiapp_existing",
|
||||
scopes: REQUIRED_SCOPE.split(" "),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const model: Model<"openai-responses"> = {
|
||||
id: "gpt-5-mini",
|
||||
name: "GPT-5 Mini",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
|
||||
tools: [],
|
||||
});
|
||||
|
||||
async function capturePayload(
|
||||
apiKey: string,
|
||||
requestModel: Model<"openai-responses"> = model,
|
||||
): Promise<Record<string, unknown>> {
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
await streamOpenAIResponses(requestModel, context, {
|
||||
apiKey,
|
||||
maxTokens: 1000,
|
||||
temperature: 0.5,
|
||||
cacheRetention: "long",
|
||||
onPayload: (params) => {
|
||||
payload = params as Record<string, unknown>;
|
||||
},
|
||||
fetch: async () => new Response(null, { status: 500 }),
|
||||
}).result();
|
||||
if (!payload) throw new Error("Request payload was not captured");
|
||||
return payload;
|
||||
}
|
||||
|
||||
describe("OpenAI Responses with Sign in with ChatGPT", () => {
|
||||
it("omits request fields that token sharing rejects", async () => {
|
||||
const payload = await capturePayload("chatgpt-access-token");
|
||||
|
||||
expect(payload).not.toHaveProperty("max_output_tokens");
|
||||
expect(payload).not.toHaveProperty("temperature");
|
||||
expect(payload.prompt_cache_retention).toBeUndefined();
|
||||
});
|
||||
|
||||
it("omits prompt_cache_options on models with explicit prompt cache mode", async () => {
|
||||
const explicitCacheModel = { ...model, compat: { supportsExplicitPromptCacheMode: true } };
|
||||
|
||||
const signInPayload = await capturePayload("chatgpt-access-token", explicitCacheModel);
|
||||
const apiKeyPayload = await capturePayload("sk-proj-test", explicitCacheModel);
|
||||
|
||||
expect(signInPayload.prompt_cache_options).toBeUndefined();
|
||||
expect(apiKeyPayload.prompt_cache_options).toEqual({ ttl: "30m" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "OpenAI API keys", apiKey: "sk-proj-test", requestModel: model },
|
||||
{
|
||||
name: "other OpenAI-compatible endpoints",
|
||||
apiKey: "gateway-key",
|
||||
requestModel: { ...model, baseUrl: "https://gateway.example.com/v1" },
|
||||
},
|
||||
])("keeps those fields for $name", async ({ apiKey, requestModel }) => {
|
||||
const payload = await capturePayload(apiKey, requestModel);
|
||||
|
||||
expect(payload.max_output_tokens).toBe(1000);
|
||||
expect(payload.temperature).toBe(0.5);
|
||||
expect(payload.prompt_cache_retention).toBe("24h");
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,7 @@ async function captureOpenAIResponseHeaders(
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{ apiKey: "test-key", ...options },
|
||||
{ apiKey: "sk-test-key", ...options },
|
||||
);
|
||||
|
||||
for await (const event of stream) {
|
||||
@@ -90,7 +90,7 @@ describe("openai-responses provider defaults", () => {
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
@@ -136,7 +136,7 @@ describe("openai-responses provider defaults", () => {
|
||||
],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
toolChoice: "required",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
@@ -187,7 +187,7 @@ describe("openai-responses provider defaults", () => {
|
||||
],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as CapturedResponsesPayload;
|
||||
},
|
||||
@@ -236,7 +236,7 @@ describe("openai-responses provider defaults", () => {
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
@@ -272,7 +272,7 @@ describe("openai-responses provider defaults", () => {
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload;
|
||||
},
|
||||
@@ -313,7 +313,7 @@ describe("openai-responses provider defaults", () => {
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
sessionId,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as Pick<CapturedResponsesPayload, "prompt_cache_key">;
|
||||
@@ -519,7 +519,7 @@ describe("openai-responses provider defaults", () => {
|
||||
systemPrompt: "sys",
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{ apiKey: "test-key", serviceTier },
|
||||
{ apiKey: "sk-test-key", serviceTier },
|
||||
);
|
||||
|
||||
const result = await stream.result();
|
||||
@@ -556,7 +556,7 @@ describe("openai-responses max_output_tokens compat", () => {
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
maxTokens: 1024,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as { max_output_tokens?: number };
|
||||
@@ -593,7 +593,7 @@ describe("openai-responses max_output_tokens compat", () => {
|
||||
messages: [{ role: "user", content: "hi", timestamp: Date.now() }],
|
||||
}),
|
||||
{
|
||||
apiKey: "test-key",
|
||||
apiKey: "sk-test-key",
|
||||
maxTokens: 1024,
|
||||
onPayload: (payload) => {
|
||||
capturedPayload = payload as { max_output_tokens?: number };
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { stream as streamOpenAIResponses } from "../src/api/openai-responses.ts";
|
||||
import type { Model } from "../src/types.ts";
|
||||
import { normalizeContext } from "../src/utils/transcript.ts";
|
||||
|
||||
const usageLimitError = {
|
||||
code: "subscription_sharing_usage_limit_exceeded",
|
||||
message: "Usage limit reached.",
|
||||
};
|
||||
|
||||
const model: Model<"openai-responses"> = {
|
||||
id: "gpt-5-mini",
|
||||
name: "GPT-5 Mini",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 400000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
|
||||
const context = normalizeContext({
|
||||
systemPrompt: "",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi" }], timestamp: 0 }],
|
||||
tools: [],
|
||||
});
|
||||
|
||||
async function getErrorMessage(response: Response): Promise<string | undefined> {
|
||||
const result = await streamOpenAIResponses(model, context, {
|
||||
apiKey: "test",
|
||||
fetch: async () => response,
|
||||
}).result();
|
||||
expect(result.stopReason).toBe("error");
|
||||
return result.errorMessage;
|
||||
}
|
||||
|
||||
describe("OpenAI Responses ChatGPT usage limit", () => {
|
||||
it("links to ChatGPT usage when the request is rejected", async () => {
|
||||
const response = new Response(JSON.stringify({ error: { ...usageLimitError, type: "rate_limit_error" } }), {
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const errorMessage = await getErrorMessage(response);
|
||||
|
||||
expect(errorMessage).toContain("subscription_sharing_usage_limit_exceeded");
|
||||
expect(errorMessage).toContain("Check your ChatGPT usage: https://chatgpt.com/settings/usage");
|
||||
});
|
||||
|
||||
it("links to ChatGPT usage when the stream fails", async () => {
|
||||
const event = {
|
||||
type: "response.failed",
|
||||
sequence_number: 0,
|
||||
response: { id: "resp_failed", status: "failed", error: usageLimitError },
|
||||
};
|
||||
const response = new Response(`event: response.failed\ndata: ${JSON.stringify(event)}\n\n`, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
|
||||
const errorMessage = await getErrorMessage(response);
|
||||
|
||||
expect(errorMessage).toContain("subscription_sharing_usage_limit_exceeded: Usage limit reached.");
|
||||
expect(errorMessage).toContain("Check your ChatGPT usage: https://chatgpt.com/settings/usage");
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,19 @@ describe("provider retry classification", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the ChatGPT subscription usage limit non-retryable", () => {
|
||||
const errorMessage =
|
||||
'OpenAI API error (429): {"code":"subscription_sharing_usage_limit_exceeded","message":"Usage limit reached."}';
|
||||
expect(isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage }))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"subscription_sharing_usage_unavailable: Usage cannot be checked.",
|
||||
"subscription_sharing_user_unavailable: User cannot be loaded.",
|
||||
])("retries temporary ChatGPT subscription errors: %s", (errorMessage) => {
|
||||
expect(isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage }))).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies assistant error messages", () => {
|
||||
expect(
|
||||
isRetryableAssistantError(fauxAssistantMessage("", { stopReason: "error", errorMessage: "overloaded_error" })),
|
||||
|
||||
@@ -59,7 +59,7 @@ export function redactJsonValue(value: unknown): unknown {
|
||||
}
|
||||
|
||||
function redactSettings(settings: Settings): Settings {
|
||||
const { trackingId: _trackingId, ...rest } = settings;
|
||||
const { trackingId: _trackingId, deviceId: _deviceId, ...rest } = settings;
|
||||
return redactJsonValue(rest) as Settings;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type ImageModel,
|
||||
type ImagesContext,
|
||||
type ImagesOptions,
|
||||
type LoginOptions,
|
||||
lazyStream,
|
||||
type Message,
|
||||
type Model,
|
||||
@@ -813,10 +814,15 @@ export class ModelRuntime implements Models {
|
||||
}
|
||||
}
|
||||
|
||||
login(providerId: string, type: AuthType, interaction: AuthInteraction): Promise<Credential> {
|
||||
login(
|
||||
providerId: string,
|
||||
type: AuthType,
|
||||
interaction: AuthInteraction,
|
||||
options?: LoginOptions,
|
||||
): Promise<Credential> {
|
||||
const signal = operationSignal(interaction.signal);
|
||||
return this.enqueueCredentialOperation(providerId, signal, async () => {
|
||||
const credential = await this.models.login(providerId, type, { ...interaction, signal });
|
||||
const credential = await this.models.login(providerId, type, { ...interaction, signal }, options);
|
||||
await this.synchronizeCredentialState(providerId, "login", credential, signal);
|
||||
return credential;
|
||||
});
|
||||
|
||||
@@ -153,6 +153,7 @@ export interface Settings {
|
||||
enableInstallTelemetry?: boolean; // default: true - anonymous version/update ping after changelog-detected updates
|
||||
enableAnalytics?: boolean; // default: false - opt-in analytics data sharing
|
||||
trackingId?: string; // analytics tracking identifier, generated when analytics is enabled
|
||||
deviceId?: string; // stable UUID of this installation, created when a login first needs it; global setting only
|
||||
packages?: PackageSource[]; // Array of npm/git package sources (string or object with filtering)
|
||||
extensions?: string[]; // Array of local extension file paths or directories
|
||||
skills?: string[]; // Array of local skill file paths or directories
|
||||
@@ -1163,6 +1164,20 @@ export class SettingsManager {
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable ID of this installation, e.g. sent to OpenAI as its agent host ID.
|
||||
* Created on first use. Project settings are ignored so a committed project
|
||||
* settings file cannot give every clone the same ID.
|
||||
*/
|
||||
getOrCreateDeviceId(): string {
|
||||
if (!this.globalSettings.deviceId) {
|
||||
this.globalSettings.deviceId = randomUUID();
|
||||
this.markModified("deviceId");
|
||||
this.save();
|
||||
}
|
||||
return this.globalSettings.deviceId;
|
||||
}
|
||||
|
||||
getPackages(): PackageSource[] {
|
||||
return [...(this.settings.packages ?? [])];
|
||||
}
|
||||
|
||||
@@ -342,11 +342,16 @@ export async function openMicro(options: OpenMicroOptions = {}): Promise<OpenMic
|
||||
auth: { providerId, providerName: account.name, authType, notices: [] },
|
||||
});
|
||||
try {
|
||||
await modelRuntime.login(providerId, authType, {
|
||||
signal: loginController.signal,
|
||||
prompt: ({ signal, ...request }: AuthPrompt) => askAuth(request, signal),
|
||||
notify: addAuthNotice,
|
||||
});
|
||||
await modelRuntime.login(
|
||||
providerId,
|
||||
authType,
|
||||
{
|
||||
signal: loginController.signal,
|
||||
prompt: ({ signal, ...request }: AuthPrompt) => askAuth(request, signal),
|
||||
notify: addAuthNotice,
|
||||
},
|
||||
{ getDeviceId: () => settings.getOrCreateDeviceId() },
|
||||
);
|
||||
notice("info", `Logged in to ${account.name}.`);
|
||||
} finally {
|
||||
clearPendingAuth(new Error("Login finished"));
|
||||
|
||||
@@ -6163,11 +6163,16 @@ export class InteractiveMode {
|
||||
providerId: string,
|
||||
method: "api_key" | "oauth",
|
||||
): Promise<void> {
|
||||
await this.session.modelRuntime.login(providerId, method, {
|
||||
signal: dialog.signal,
|
||||
prompt: (prompt) => this.showAuthPrompt(dialog, prompt),
|
||||
notify: (event) => this.notifyAuthDialog(dialog, event),
|
||||
});
|
||||
await this.session.modelRuntime.login(
|
||||
providerId,
|
||||
method,
|
||||
{
|
||||
signal: dialog.signal,
|
||||
prompt: (prompt) => this.showAuthPrompt(dialog, prompt),
|
||||
notify: (event) => this.notifyAuthDialog(dialog, event),
|
||||
},
|
||||
{ getDeviceId: () => this.settingsManager.getOrCreateDeviceId() },
|
||||
);
|
||||
}
|
||||
|
||||
private async showLoginDialog(providerId: string, providerName: string): Promise<void> {
|
||||
|
||||
@@ -111,6 +111,23 @@ describe("SettingsManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviceId", () => {
|
||||
it("creates one global device ID and reuses it in later processes", async () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ theme: "dark" }));
|
||||
writeFileSync(join(projectDir, ".pi", "settings.json"), JSON.stringify({ deviceId: "project-device" }));
|
||||
const first = SettingsManager.create(projectDir, agentDir);
|
||||
|
||||
const deviceId = first.getOrCreateDeviceId();
|
||||
await first.flush();
|
||||
|
||||
expect(deviceId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
expect(first.getOrCreateDeviceId()).toBe(deviceId);
|
||||
expect(SettingsManager.create(projectDir, agentDir).getOrCreateDeviceId()).toBe(deviceId);
|
||||
expect(JSON.parse(readFileSync(settingsPath, "utf-8"))).toEqual({ theme: "dark", deviceId });
|
||||
});
|
||||
});
|
||||
|
||||
describe("packages migration", () => {
|
||||
it("should keep local-only extensions in extensions array", () => {
|
||||
const settingsPath = join(agentDir, "settings.json");
|
||||
|
||||
Reference in New Issue
Block a user