diff --git a/changelog.md b/changelog.md index 100b684..ea7ebf9 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,13 @@ ## Pending +### Breaking Changes + +- rename OpenAI ChatGPT/Codex subscription auth APIs from `OpenAiMax`/`openAiMaxAuth` to `OpenAiChatGpt`/`openAiChatGptAuth` + +### Features + +- add Node-only `@push.rocks/smartai/openai-chatgpt-auth` helpers to inspect, normalize, resolve, refresh, and write SmartAI, OpenCode, and Codex ChatGPT auth sources ## 2026-05-14 - 2.3.0 diff --git a/package.json b/package.json index cab14e9..454eaff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@push.rocks/smartai", - "version": "2.3.0", + "version": "3.0.0", "private": false, "description": "Provider registry and capability utilities for ai-sdk (Vercel AI SDK). Core export returns LanguageModel; subpath exports provide vision, audio, image, document and research capabilities.", "main": "dist_ts/index.js", @@ -30,6 +30,10 @@ "./research": { "import": "./dist_ts_research/index.js", "types": "./dist_ts_research/index.d.ts" + }, + "./openai-chatgpt-auth": { + "import": "./dist_ts_openai_chatgpt_auth/index.js", + "types": "./dist_ts_openai_chatgpt_auth/index.d.ts" } }, "author": "Task Venture Capital GmbH", @@ -84,6 +88,7 @@ "ts_image/**/*", "ts_document/**/*", "ts_research/**/*", + "ts_openai_chatgpt_auth/**/*", "dist_*/**/*", "assets/**/*", ".smartconfig.json", diff --git a/readme.hints.md b/readme.hints.md index 152aac9..5d2d377 100644 --- a/readme.hints.md +++ b/readme.hints.md @@ -10,7 +10,8 @@ The package is a **provider registry** built on the Vercel AI SDK (`ai` v6). The - Providers: anthropic, openai, google, groq, mistral, xai, perplexity, ollama - Anthropic prompt caching via `wrapLanguageModel` middleware (enabled by default) - Custom Ollama provider implementing `LanguageModelV3` directly (for think, num_ctx support) -- OpenAI ChatGPT/Max device-code auth in `smartai.auth.openai.ts`; `openAiMaxAuth` routes OpenAI models to the ChatGPT Codex backend +- OpenAI ChatGPT/Codex device-code auth in `smartai.auth.openai.ts`; `openAiChatGptAuth` routes OpenAI models to the ChatGPT Codex backend +- Node-only local auth source helpers live in `ts_openai_chatgpt_auth/` and support SmartAI, OpenCode, and Codex auth file formats ### Subpath Exports - `@push.rocks/smartai/vision` — `analyzeImage()` using `generateText` with image content diff --git a/readme.md b/readme.md index e45de47..0418961 100644 --- a/readme.md +++ b/readme.md @@ -107,29 +107,60 @@ console.log(result.text); OpenAI `reasoningEffort` supports `'none'`, `'minimal'`, `'low'`, `'medium'`, `'high'`, and `'xhigh'`. Model IDs are accepted as strings, so new IDs like `'gpt-5.5'` can be used before upstream model unions are updated. -### OpenAI Max / ChatGPT Auth +### OpenAI ChatGPT / Codex Auth -SmartAI can request ChatGPT subscription-backed Codex credentials with OpenAI's device-code flow. The returned credentials are passed to `getModel()` through `openAiMaxAuth`; SmartAI then routes OpenAI model calls through the ChatGPT Codex backend with the required account headers. +SmartAI can request ChatGPT subscription-backed Codex credentials with OpenAI's device-code flow. The returned credentials are passed to `getModel()` through `openAiChatGptAuth`; SmartAI then routes OpenAI model calls through the ChatGPT Codex backend with the required account headers. ```typescript import { - completeOpenAiMaxDeviceCodeLogin, + completeOpenAiChatGptDeviceCodeLogin, getModel, - requestOpenAiMaxDeviceCode, + requestOpenAiChatGptDeviceCode, } from '@push.rocks/smartai'; -const deviceCode = await requestOpenAiMaxDeviceCode(); +const deviceCode = await requestOpenAiChatGptDeviceCode(); console.log(`Open ${deviceCode.verificationUrl} and enter ${deviceCode.userCode}`); -const openAiMaxAuth = await completeOpenAiMaxDeviceCodeLogin(deviceCode); +const openAiChatGptAuth = await completeOpenAiChatGptDeviceCodeLogin(deviceCode); const model = getModel({ provider: 'openai', model: 'gpt-5.5', - openAiMaxAuth, + openAiChatGptAuth, }); ``` -Use `refreshOpenAiMaxTokenData(openAiMaxAuth)` before stored credentials expire, or after receiving an unauthorized response. +Use `refreshOpenAiChatGptTokenData(openAiChatGptAuth)` before stored credentials expire, or after receiving an unauthorized response. + +Node.js consumers can inspect and resolve local ChatGPT auth files through the Node-only subpath. This supports SmartAI's canonical auth file, OpenCode's `~/.local/share/opencode/auth.json`, and Codex's `~/.codex/auth.json` without exposing token values in inspection results. + +```typescript +import { + inspectOpenAiChatGptAuthSources, + resolveOpenAiChatGptAuth, +} from '@push.rocks/smartai/openai-chatgpt-auth'; + +const sources = await inspectOpenAiChatGptAuthSources({ + sources: ['smartai', 'opencode', 'codex'], +}); + +const resolved = await resolveOpenAiChatGptAuth({ + sources: ['smartai', 'opencode', 'codex'], + refresh: 'ifNeeded', + writeBack: { + smartai: true, + opencode: false, + codex: false, + }, +}); + +if (resolved) { + const model = getModel({ + provider: 'openai', + model: 'gpt-5.5', + openAiChatGptAuth: resolved.tokenData, + }); +} +``` ### Re-exported AI SDK Functions diff --git a/test/test.openai-auth.node.ts b/test/test.openai-auth.node.ts index e869d69..4757ab8 100644 --- a/test/test.openai-auth.node.ts +++ b/test/test.openai-auth.node.ts @@ -1,6 +1,15 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; import * as smartai from '../ts/index.js'; -import type { IOpenAiMaxTokenData } from '../ts/index.js'; +import type { IOpenAiChatGptTokenData } from '../ts/index.js'; +import { + inspectOpenAiChatGptAuthSources, + normalizeOpenAiChatGptAuth, + resolveOpenAiChatGptAuth, + writeOpenAiChatGptAuthFile, +} from '../ts_openai_chatgpt_auth/index.js'; interface IMockFetchRequest { url: string; @@ -12,10 +21,10 @@ function createJwt(payload: Record): string { return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.sig`; } -function createTokenData(accountId = 'workspace-1'): IOpenAiMaxTokenData { - const idToken = createJwt({ +function createTokenData(accountId = 'workspace-1', expiresAtSeconds = 4_102_444_800): IOpenAiChatGptTokenData { + const accessToken = createJwt({ email: 'user@example.com', - exp: 4_102_444_800, + exp: expiresAtSeconds, 'https://api.openai.com/auth': { chatgpt_plan_type: 'pro', chatgpt_user_id: 'user-1', @@ -23,13 +32,12 @@ function createTokenData(accountId = 'workspace-1'): IOpenAiMaxTokenData { chatgpt_account_is_fedramp: false, }, }); - const idTokenInfo = smartai.parseOpenAiMaxIdToken(idToken); + const tokenInfo = smartai.parseOpenAiChatGptTokenInfo(accessToken); return { - idToken, - accessToken: 'access-token', + accessToken, refreshToken: 'refresh-token', accountId, - idTokenInfo, + tokenInfo, }; } @@ -44,7 +52,7 @@ function getHeader(init: RequestInit | undefined, name: string): string | null { return new Headers(init?.headers).get(name); } -tap.test('requestOpenAiMaxDeviceCode requests a user code', async () => { +tap.test('requestOpenAiChatGptDeviceCode requests a user code', async () => { const originalFetch = globalThis.fetch; const requests: IMockFetchRequest[] = []; @@ -58,7 +66,7 @@ tap.test('requestOpenAiMaxDeviceCode requests a user code', async () => { }; try { - const deviceCode = await smartai.requestOpenAiMaxDeviceCode({ + const deviceCode = await smartai.requestOpenAiChatGptDeviceCode({ issuer: 'https://auth.example.test', clientId: 'client-1', }); @@ -76,7 +84,7 @@ tap.test('requestOpenAiMaxDeviceCode requests a user code', async () => { } }); -tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', async () => { +tap.test('completeOpenAiChatGptDeviceCodeLogin polls and exchanges OAuth tokens', async () => { const originalFetch = globalThis.fetch; const requests: IMockFetchRequest[] = []; const tokenData = createTokenData('workspace-1'); @@ -88,7 +96,6 @@ tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', as code_verifier: 'verifier', }), jsonResponse({ - id_token: tokenData.idToken, access_token: tokenData.accessToken, refresh_token: tokenData.refreshToken, }), @@ -102,7 +109,7 @@ tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', as }; try { - const result = await smartai.completeOpenAiMaxDeviceCodeLogin({ + const result = await smartai.completeOpenAiChatGptDeviceCodeLogin({ verificationUrl: 'https://auth.example.test/codex/device', userCode: 'ABCD-EFGH', deviceAuthId: 'device-1', @@ -114,9 +121,9 @@ tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', as sleep: async () => undefined, }); - expect(result.accessToken).toEqual('access-token'); + expect(result.accessToken).toEqual(tokenData.accessToken); expect(result.refreshToken).toEqual('refresh-token'); - expect(result.idTokenInfo.chatgptAccountId).toEqual('workspace-1'); + expect(result.tokenInfo.chatgptAccountId).toEqual('workspace-1'); expect(requests.length).toEqual(3); expect(JSON.parse(String(requests[0].init?.body))).toEqual({ device_auth_id: 'device-1', @@ -133,25 +140,25 @@ tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', as } }); -tap.test('refreshOpenAiMaxTokenData refreshes and preserves omitted token fields', async () => { +tap.test('refreshOpenAiChatGptTokenData refreshes and preserves omitted token fields', async () => { const originalFetch = globalThis.fetch; const requests: IMockFetchRequest[] = []; const tokenData = createTokenData('workspace-1'); + const refreshedToken = createTokenData('workspace-1', 4_102_445_000).accessToken; globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { requests.push({ url: String(input), init }); - return jsonResponse({ access_token: 'new-access-token' }); + return jsonResponse({ access_token: refreshedToken }); }; try { - const result = await smartai.refreshOpenAiMaxTokenData(tokenData, { + const result = await smartai.refreshOpenAiChatGptTokenData(tokenData, { issuer: 'https://auth.example.test', clientId: 'client-1', }); - expect(result.accessToken).toEqual('new-access-token'); + expect(result.accessToken).toEqual(refreshedToken); expect(result.refreshToken).toEqual('refresh-token'); - expect(result.idToken).toEqual(tokenData.idToken); expect(JSON.parse(String(requests[0].init?.body))).toEqual({ client_id: 'client-1', grant_type: 'refresh_token', @@ -162,14 +169,14 @@ tap.test('refreshOpenAiMaxTokenData refreshes and preserves omitted token fields } }); -tap.test('getModel uses ChatGPT Codex backend for OpenAI Max auth', async () => { +tap.test('getModel uses ChatGPT Codex backend for OpenAI ChatGPT auth', async () => { const originalFetch = globalThis.fetch; let capturedRequest: IMockFetchRequest | undefined; const tokenData = createTokenData('workspace-1'); const model = smartai.getModel({ provider: 'openai', model: 'gpt-5.5', - openAiMaxAuth: tokenData, + openAiChatGptAuth: tokenData, }); globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -198,7 +205,7 @@ tap.test('getModel uses ChatGPT Codex backend for OpenAI Max auth', async () => } as any); expect(capturedRequest?.url).toEqual('https://chatgpt.com/backend-api/codex/responses'); - expect(getHeader(capturedRequest?.init, 'authorization')).toEqual('Bearer access-token'); + expect(getHeader(capturedRequest?.init, 'authorization')).toEqual(`Bearer ${tokenData.accessToken}`); expect(getHeader(capturedRequest?.init, 'chatgpt-account-id')).toEqual('workspace-1'); expect(getHeader(capturedRequest?.init, 'originator')).toEqual('smartai'); } finally { @@ -206,4 +213,81 @@ tap.test('getModel uses ChatGPT Codex backend for OpenAI Max auth', async () => } }); +tap.test('normalizes OpenCode and Codex auth file formats', async () => { + const tokenData = createTokenData('workspace-2'); + const opencodeAuth = normalizeOpenAiChatGptAuth({ + openai: { + type: 'oauth', + access: tokenData.accessToken, + refresh: tokenData.refreshToken, + expires: Date.parse(tokenData.tokenInfo.expiresAt!), + accountId: 'workspace-2', + }, + }, 'opencode'); + const codexAuth = normalizeOpenAiChatGptAuth({ + tokens: { + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + account_id: 'workspace-2', + }, + }, 'codex'); + + expect(opencodeAuth?.accountId).toEqual('workspace-2'); + expect(opencodeAuth?.tokenInfo.chatgptAccountId).toEqual('workspace-2'); + expect(codexAuth?.accountId).toEqual('workspace-2'); + expect(codexAuth?.tokenInfo.chatgptAccountId).toEqual('workspace-2'); +}); + +tap.test('inspects and resolves OpenAI ChatGPT auth sources without exposing tokens', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartai-auth-sources-')); + try { + const tokenData = createTokenData('workspace-3'); + const opencodePath = path.join(tempDir, 'opencode-auth.json'); + await fs.writeFile(opencodePath, JSON.stringify({ + openai: { + type: 'oauth', + access: tokenData.accessToken, + refresh: tokenData.refreshToken, + expires: Date.parse(tokenData.tokenInfo.expiresAt!), + accountId: 'workspace-3', + }, + anthropic: { type: 'oauth', access: 'keep-me' }, + })); + + const inspections = await inspectOpenAiChatGptAuthSources({ + sources: [{ source: 'opencode', filePath: opencodePath }], + }); + const resolved = await resolveOpenAiChatGptAuth({ + sources: [{ source: 'opencode', filePath: opencodePath }], + }); + + expect(inspections).toHaveLength(1); + expect(inspections[0]!.usable).toEqual(true); + expect(JSON.stringify(inspections)).not.toInclude(tokenData.accessToken); + expect(resolved?.source).toEqual('opencode'); + expect(resolved?.tokenData.accountId).toEqual('workspace-3'); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + +tap.test('writes OpenCode auth while preserving unrelated providers', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'smartai-opencode-write-')); + try { + const tokenData = createTokenData('workspace-4'); + const opencodePath = path.join(tempDir, 'auth.json'); + await fs.writeFile(opencodePath, JSON.stringify({ anthropic: { type: 'oauth', access: 'keep-me' } })); + + await writeOpenAiChatGptAuthFile(opencodePath, tokenData, 'opencode'); + const written = JSON.parse(await fs.readFile(opencodePath, 'utf8')) as any; + + expect(written.anthropic.access).toEqual('keep-me'); + expect(written.openai.type).toEqual('oauth'); + expect(written.openai.accountId).toEqual('workspace-4'); + expect(written.openai.access).toEqual(tokenData.accessToken); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +}); + export default tap.start(); diff --git a/ts/index.ts b/ts/index.ts index 9f49767..bccd323 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,13 +1,13 @@ export { getModel, getModelSetup } from './smartai.classes.smartai.js'; export type { IOpenAiProviderOptions, - IOpenAiMaxAuthCredentials, - IOpenAiMaxAuthOptions, - IOpenAiMaxCompleteDeviceCodeOptions, - IOpenAiMaxDeviceCode, - IOpenAiMaxDeviceCodePollOptions, - IOpenAiMaxIdTokenInfo, - IOpenAiMaxTokenData, + IOpenAiChatGptAuthCredentials, + IOpenAiChatGptAuthOptions, + IOpenAiChatGptCompleteDeviceCodeOptions, + IOpenAiChatGptDeviceCode, + IOpenAiChatGptDeviceCodePollOptions, + IOpenAiChatGptTokenData, + IOpenAiChatGptTokenInfo, ISmartAiModelSetup, ISmartAiOptions, TOpenAiReasoningEffort, @@ -36,21 +36,21 @@ export type { } from './smartai.cache.js'; export { createOllamaModel } from './smartai.provider.ollama.js'; export { - OPENAI_MAX_AUTH_ISSUER, - OPENAI_MAX_CLIENT_ID, - OPENAI_MAX_CODEX_BASE_URL, - OPENAI_MAX_DEFAULT_ORIGINATOR, - OpenAiMaxAuthError, - completeOpenAiMaxDeviceCodeLogin, - createOpenAiMaxProviderSettings, - ensureOpenAiMaxWorkspaceAllowed, - exchangeOpenAiMaxAuthorizationCode, - parseOpenAiMaxIdToken, - pollOpenAiMaxDeviceCode, - refreshOpenAiMaxTokenData, - requestOpenAiMaxDeviceCode, + OPENAI_CHATGPT_AUTH_ISSUER, + OPENAI_CHATGPT_CLIENT_ID, + OPENAI_CHATGPT_CODEX_BASE_URL, + OPENAI_CHATGPT_DEFAULT_ORIGINATOR, + OpenAiChatGptAuthError, + completeOpenAiChatGptDeviceCodeLogin, + createOpenAiChatGptProviderSettings, + ensureOpenAiChatGptWorkspaceAllowed, + exchangeOpenAiChatGptAuthorizationCode, + parseOpenAiChatGptTokenInfo, + pollOpenAiChatGptDeviceCode, + refreshOpenAiChatGptTokenData, + requestOpenAiChatGptDeviceCode, } from './smartai.auth.openai.js'; -export type { IOpenAiMaxAuthorizationCode } from './smartai.auth.openai.js'; +export type { IOpenAiChatGptAuthorizationCode } from './smartai.auth.openai.js'; // Re-export commonly used ai-sdk functions for consumer convenience export { generateText, streamText, tool, jsonSchema } from 'ai'; diff --git a/ts/smartai.auth.openai.ts b/ts/smartai.auth.openai.ts index dcc77cc..b95666b 100644 --- a/ts/smartai.auth.openai.ts +++ b/ts/smartai.auth.openai.ts @@ -1,63 +1,63 @@ import type { - IOpenAiMaxAuthCredentials, - IOpenAiMaxAuthOptions, - IOpenAiMaxCompleteDeviceCodeOptions, - IOpenAiMaxDeviceCode, - IOpenAiMaxDeviceCodePollOptions, - IOpenAiMaxIdTokenInfo, - IOpenAiMaxTokenData, + IOpenAiChatGptAuthCredentials, + IOpenAiChatGptAuthOptions, + IOpenAiChatGptCompleteDeviceCodeOptions, + IOpenAiChatGptDeviceCode, + IOpenAiChatGptDeviceCodePollOptions, + IOpenAiChatGptTokenData, + IOpenAiChatGptTokenInfo, } from './smartai.interfaces.js'; -export const OPENAI_MAX_AUTH_ISSUER = 'https://auth.openai.com'; -export const OPENAI_MAX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; -export const OPENAI_MAX_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; -export const OPENAI_MAX_DEFAULT_ORIGINATOR = 'smartai'; +export const OPENAI_CHATGPT_AUTH_ISSUER = 'https://auth.openai.com'; +export const OPENAI_CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +export const OPENAI_CHATGPT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; +export const OPENAI_CHATGPT_DEFAULT_ORIGINATOR = 'smartai'; const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000; -export class OpenAiMaxAuthError extends Error { +export class OpenAiChatGptAuthError extends Error { public status?: number; public body?: string; constructor(message: string, options: { status?: number; body?: string } = {}) { super(message); - this.name = 'OpenAiMaxAuthError'; + this.name = 'OpenAiChatGptAuthError'; this.status = options.status; this.body = options.body; } } -export interface IOpenAiMaxAuthorizationCode { +export interface IOpenAiChatGptAuthorizationCode { authorizationCode: string; codeChallenge: string; codeVerifier: string; } -interface IOpenAiMaxTokenResponse { +interface IOpenAiChatGptTokenResponse { id_token?: unknown; access_token?: unknown; refresh_token?: unknown; } -function getFetch(options: IOpenAiMaxAuthOptions): typeof fetch { +function getFetch(options: IOpenAiChatGptAuthOptions): typeof fetch { const fetchFunction = options.fetch ?? globalThis.fetch; if (!fetchFunction) { - throw new OpenAiMaxAuthError('fetch is not available for OpenAI Max authentication.'); + throw new OpenAiChatGptAuthError('fetch is not available for OpenAI ChatGPT authentication.'); } return fetchFunction; } -function getIssuer(options: IOpenAiMaxAuthOptions): string { - return (options.issuer ?? OPENAI_MAX_AUTH_ISSUER).replace(/\/+$/, ''); +function getIssuer(options: IOpenAiChatGptAuthOptions): string { + return (options.issuer ?? OPENAI_CHATGPT_AUTH_ISSUER).replace(/\/+$/, ''); } -function getClientId(options: IOpenAiMaxAuthOptions): string { - return options.clientId ?? OPENAI_MAX_CLIENT_ID; +function getClientId(options: IOpenAiChatGptAuthOptions): string { + return options.clientId ?? OPENAI_CHATGPT_CLIENT_ID; } function asString(value: unknown, name: string): string { if (typeof value !== 'string' || value.length === 0) { - throw new OpenAiMaxAuthError(`OpenAI Max auth response is missing ${name}.`); + throw new OpenAiChatGptAuthError(`OpenAI ChatGPT auth response is missing ${name}.`); } return value; } @@ -69,7 +69,7 @@ function asOptionalString(value: unknown): string | undefined { function asIntervalSeconds(value: unknown): number { const interval = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(interval) || interval <= 0) { - throw new OpenAiMaxAuthError('OpenAI Max device-code response has an invalid interval.'); + throw new OpenAiChatGptAuthError('OpenAI ChatGPT device-code response has an invalid interval.'); } return interval; } @@ -77,7 +77,7 @@ function asIntervalSeconds(value: unknown): number { async function readJson(response: Response, context: string): Promise { const body = await response.text(); if (!response.ok) { - throw new OpenAiMaxAuthError(`${context} failed with status ${response.status}.`, { + throw new OpenAiChatGptAuthError(`${context} failed with status ${response.status}.`, { status: response.status, body, }); @@ -86,14 +86,14 @@ async function readJson(response: Response, context: string): Promise { try { return body ? JSON.parse(body) : {}; } catch (error) { - throw new OpenAiMaxAuthError(`${context} returned invalid JSON: ${(error as Error).message}`, { + throw new OpenAiChatGptAuthError(`${context} returned invalid JSON: ${(error as Error).message}`, { status: response.status, body, }); } } -async function postJson(url: string, body: unknown, options: IOpenAiMaxAuthOptions): Promise { +async function postJson(url: string, body: unknown, options: IOpenAiChatGptAuthOptions): Promise { const response = await getFetch(options)(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -102,7 +102,7 @@ async function postJson(url: string, body: unknown, options: IOpenAiMaxAuthOptio return readJson(response, `POST ${url}`); } -async function postForm(url: string, body: URLSearchParams, options: IOpenAiMaxAuthOptions): Promise { +async function postForm(url: string, body: URLSearchParams, options: IOpenAiChatGptAuthOptions): Promise { const response = await getFetch(options)(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, @@ -118,18 +118,18 @@ function sleep(ms: number): Promise { function parseJwtPayload(jwt: string): Record { const parts = jwt.split('.'); if (parts.length !== 3 || !parts[1]) { - throw new OpenAiMaxAuthError('OpenAI Max auth returned an invalid ID token.'); + throw new OpenAiChatGptAuthError('OpenAI ChatGPT auth returned an invalid token.'); } try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as Record; } catch (error) { - throw new OpenAiMaxAuthError(`OpenAI Max ID token could not be parsed: ${(error as Error).message}`); + throw new OpenAiChatGptAuthError(`OpenAI ChatGPT token could not be parsed: ${(error as Error).message}`); } } -export function parseOpenAiMaxIdToken(idToken: string): IOpenAiMaxIdTokenInfo { - const claims = parseJwtPayload(idToken); +export function parseOpenAiChatGptTokenInfo(token: string): IOpenAiChatGptTokenInfo { + const claims = parseJwtPayload(token); const profile = claims['https://api.openai.com/profile'] as Record | undefined; const auth = claims['https://api.openai.com/auth'] as Record | undefined; const expiresAtSeconds = typeof claims.exp === 'number' ? claims.exp : undefined; @@ -141,28 +141,37 @@ export function parseOpenAiMaxIdToken(idToken: string): IOpenAiMaxIdTokenInfo { chatgptAccountId: asOptionalString(auth?.chatgpt_account_id), chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true, expiresAt: expiresAtSeconds ? new Date(expiresAtSeconds * 1000).toISOString() : undefined, - rawJwt: idToken, + rawJwt: token, }; } -function createTokenData(response: IOpenAiMaxTokenResponse): IOpenAiMaxTokenData { - const idToken = asString(response.id_token, 'id_token'); - const accessToken = asString(response.access_token, 'access_token'); - const refreshToken = asString(response.refresh_token, 'refresh_token'); - const idTokenInfo = parseOpenAiMaxIdToken(idToken); +function createTokenData( + response: IOpenAiChatGptTokenResponse, + existingTokenData?: IOpenAiChatGptTokenData, +): IOpenAiChatGptTokenData { + const accessToken = asOptionalString(response.access_token) ?? existingTokenData?.accessToken; + const refreshToken = asOptionalString(response.refresh_token) ?? existingTokenData?.refreshToken; + const idToken = asOptionalString(response.id_token) ?? existingTokenData?.idToken; + if (!accessToken) { + throw new OpenAiChatGptAuthError('OpenAI ChatGPT auth response is missing access_token.'); + } + if (!refreshToken) { + throw new OpenAiChatGptAuthError('OpenAI ChatGPT auth response is missing refresh_token.'); + } + const tokenInfo = parseOpenAiChatGptTokenInfo(idToken ?? accessToken); return { - idToken, accessToken, refreshToken, - accountId: idTokenInfo.chatgptAccountId, - idTokenInfo, + idToken, + accountId: tokenInfo.chatgptAccountId, + tokenInfo, }; } -export async function requestOpenAiMaxDeviceCode( - options: IOpenAiMaxAuthOptions = {}, -): Promise { +export async function requestOpenAiChatGptDeviceCode( + options: IOpenAiChatGptAuthOptions = {}, +): Promise { const issuer = getIssuer(options); const response = await postJson(`${issuer}/api/accounts/deviceauth/usercode`, { client_id: getClientId(options), @@ -176,10 +185,10 @@ export async function requestOpenAiMaxDeviceCode( }; } -export async function pollOpenAiMaxDeviceCode( - deviceCode: IOpenAiMaxDeviceCode, - options: IOpenAiMaxDeviceCodePollOptions = {}, -): Promise { +export async function pollOpenAiChatGptDeviceCode( + deviceCode: IOpenAiChatGptDeviceCode, + options: IOpenAiChatGptDeviceCodePollOptions = {}, +): Promise { const issuer = getIssuer(options); const pollUrl = `${issuer}/api/accounts/deviceauth/token`; const timeoutMs = options.timeoutMs ?? DEVICE_CODE_TIMEOUT_MS; @@ -207,7 +216,7 @@ export async function pollOpenAiMaxDeviceCode( if (response.status !== 403 && response.status !== 404) { const body = await response.text(); - throw new OpenAiMaxAuthError(`OpenAI Max device-code polling failed with status ${response.status}.`, { + throw new OpenAiChatGptAuthError(`OpenAI ChatGPT device-code polling failed with status ${response.status}.`, { status: response.status, body, }); @@ -218,13 +227,13 @@ export async function pollOpenAiMaxDeviceCode( await sleepFunction(Math.min(deviceCode.intervalSeconds * 1000, Math.max(remaining, 0))); } - throw new OpenAiMaxAuthError('OpenAI Max device-code login timed out.'); + throw new OpenAiChatGptAuthError('OpenAI ChatGPT device-code login timed out.'); } -export async function exchangeOpenAiMaxAuthorizationCode( - authorizationCode: IOpenAiMaxAuthorizationCode, - options: IOpenAiMaxAuthOptions = {}, -): Promise { +export async function exchangeOpenAiChatGptAuthorizationCode( + authorizationCode: IOpenAiChatGptAuthorizationCode, + options: IOpenAiChatGptAuthOptions = {}, +): Promise { const issuer = getIssuer(options); const response = await postForm(`${issuer}/oauth/token`, new URLSearchParams({ grant_type: 'authorization_code', @@ -232,60 +241,60 @@ export async function exchangeOpenAiMaxAuthorizationCode( redirect_uri: `${issuer}/deviceauth/callback`, client_id: getClientId(options), code_verifier: authorizationCode.codeVerifier, - }), options) as IOpenAiMaxTokenResponse; + }), options) as IOpenAiChatGptTokenResponse; return createTokenData(response); } -export function ensureOpenAiMaxWorkspaceAllowed( - tokenData: IOpenAiMaxTokenData, +export function ensureOpenAiChatGptWorkspaceAllowed( + tokenData: IOpenAiChatGptTokenData, forcedChatGptWorkspaceId?: string, ): void { if (!forcedChatGptWorkspaceId) { return; } - if (tokenData.idTokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) { - throw new OpenAiMaxAuthError(`OpenAI Max login is restricted to workspace ${forcedChatGptWorkspaceId}.`); + if (tokenData.tokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) { + throw new OpenAiChatGptAuthError(`OpenAI ChatGPT login is restricted to workspace ${forcedChatGptWorkspaceId}.`); } } -export async function completeOpenAiMaxDeviceCodeLogin( - deviceCode: IOpenAiMaxDeviceCode, - options: IOpenAiMaxCompleteDeviceCodeOptions = {}, -): Promise { - const authorizationCode = await pollOpenAiMaxDeviceCode(deviceCode, options); - const tokenData = await exchangeOpenAiMaxAuthorizationCode(authorizationCode, options); - ensureOpenAiMaxWorkspaceAllowed(tokenData, options.forcedChatGptWorkspaceId); +export async function completeOpenAiChatGptDeviceCodeLogin( + deviceCode: IOpenAiChatGptDeviceCode, + options: IOpenAiChatGptCompleteDeviceCodeOptions = {}, +): Promise { + const authorizationCode = await pollOpenAiChatGptDeviceCode(deviceCode, options); + const tokenData = await exchangeOpenAiChatGptAuthorizationCode(authorizationCode, options); + ensureOpenAiChatGptWorkspaceAllowed(tokenData, options.forcedChatGptWorkspaceId); return tokenData; } -export async function refreshOpenAiMaxTokenData( - tokenData: IOpenAiMaxTokenData, - options: IOpenAiMaxAuthOptions = {}, -): Promise { +export async function refreshOpenAiChatGptTokenData( + tokenData: IOpenAiChatGptTokenData, + options: IOpenAiChatGptAuthOptions = {}, +): Promise { const issuer = getIssuer(options); const response = await postJson(`${issuer}/oauth/token`, { client_id: getClientId(options), grant_type: 'refresh_token', refresh_token: tokenData.refreshToken, - }, options) as IOpenAiMaxTokenResponse; + }, options) as IOpenAiChatGptTokenResponse; return createTokenData({ id_token: response.id_token ?? tokenData.idToken, access_token: response.access_token ?? tokenData.accessToken, refresh_token: response.refresh_token ?? tokenData.refreshToken, - }); + }, tokenData); } -export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCredentials): { +export function createOpenAiChatGptProviderSettings(credentials: IOpenAiChatGptAuthCredentials): { apiKey: string; baseURL: string; headers: Record; } { - const accountId = credentials.accountId ?? credentials.idTokenInfo?.chatgptAccountId; - const isFedrampAccount = credentials.idTokenInfo?.chatgptAccountIsFedramp === true; + const accountId = credentials.accountId ?? credentials.tokenInfo?.chatgptAccountId; + const isFedrampAccount = credentials.tokenInfo?.chatgptAccountIsFedramp === true; const headers: Record = { - originator: credentials.originator ?? OPENAI_MAX_DEFAULT_ORIGINATOR, + originator: credentials.originator ?? OPENAI_CHATGPT_DEFAULT_ORIGINATOR, }; if (accountId) { @@ -297,7 +306,7 @@ export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCrede return { apiKey: credentials.accessToken, - baseURL: credentials.baseUrl ?? OPENAI_MAX_CODEX_BASE_URL, + baseURL: credentials.baseUrl ?? OPENAI_CHATGPT_CODEX_BASE_URL, headers, }; } diff --git a/ts/smartai.classes.smartai.ts b/ts/smartai.classes.smartai.ts index 658c99a..a87aeef 100644 --- a/ts/smartai.classes.smartai.ts +++ b/ts/smartai.classes.smartai.ts @@ -2,7 +2,7 @@ import * as plugins from './plugins.js'; import type { ISmartAiModelSetup, ISmartAiOptions, LanguageModelV3 } from './smartai.interfaces.js'; import { createOllamaModel } from './smartai.provider.ollama.js'; import { createAnthropicCachingMiddleware } from './smartai.middleware.anthropic.js'; -import { createOpenAiMaxProviderSettings } from './smartai.auth.openai.js'; +import { createOpenAiChatGptProviderSettings } from './smartai.auth.openai.js'; /** * Returns a LanguageModelV3 for the given provider and model. @@ -24,8 +24,8 @@ export function getModel(options: ISmartAiOptions): LanguageModelV3 { } case 'openai': { const p = plugins.createOpenAI( - options.openAiMaxAuth - ? createOpenAiMaxProviderSettings(options.openAiMaxAuth) + options.openAiChatGptAuth + ? createOpenAiChatGptProviderSettings(options.openAiChatGptAuth) : { apiKey: options.apiKey }, ); return p(options.model) as LanguageModelV3; diff --git a/ts/smartai.interfaces.ts b/ts/smartai.interfaces.ts index 523f67e..c275775 100644 --- a/ts/smartai.interfaces.ts +++ b/ts/smartai.interfaces.ts @@ -15,7 +15,7 @@ export type TOpenAiReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'hi export type TOpenAiTextVerbosity = 'low' | 'medium' | 'high'; -export interface IOpenAiMaxIdTokenInfo { +export interface IOpenAiChatGptTokenInfo { email?: string; chatgptPlanType?: string; chatgptUserId?: string; @@ -25,41 +25,40 @@ export interface IOpenAiMaxIdTokenInfo { rawJwt: string; } -export interface IOpenAiMaxAuthCredentials { +export interface IOpenAiChatGptAuthCredentials { accessToken: string; refreshToken?: string; idToken?: string; accountId?: string; - idTokenInfo?: IOpenAiMaxIdTokenInfo; + tokenInfo?: IOpenAiChatGptTokenInfo; baseUrl?: string; originator?: string; } -export interface IOpenAiMaxTokenData extends IOpenAiMaxAuthCredentials { +export interface IOpenAiChatGptTokenData extends IOpenAiChatGptAuthCredentials { refreshToken: string; - idToken: string; - idTokenInfo: IOpenAiMaxIdTokenInfo; + tokenInfo: IOpenAiChatGptTokenInfo; } -export interface IOpenAiMaxDeviceCode { +export interface IOpenAiChatGptDeviceCode { verificationUrl: string; userCode: string; deviceAuthId: string; intervalSeconds: number; } -export interface IOpenAiMaxAuthOptions { +export interface IOpenAiChatGptAuthOptions { issuer?: string; clientId?: string; fetch?: typeof fetch; } -export interface IOpenAiMaxDeviceCodePollOptions extends IOpenAiMaxAuthOptions { +export interface IOpenAiChatGptDeviceCodePollOptions extends IOpenAiChatGptAuthOptions { timeoutMs?: number; sleep?: (ms: number) => Promise; } -export interface IOpenAiMaxCompleteDeviceCodeOptions extends IOpenAiMaxDeviceCodePollOptions { +export interface IOpenAiChatGptCompleteDeviceCodeOptions extends IOpenAiChatGptDeviceCodePollOptions { forcedChatGptWorkspaceId?: string; } @@ -108,7 +107,7 @@ export interface ISmartAiOptions { * OpenAI ChatGPT/Codex subscription credentials from the device-code auth flow. * Only used when provider === 'openai'. */ - openAiMaxAuth?: IOpenAiMaxAuthCredentials; + openAiChatGptAuth?: IOpenAiChatGptAuthCredentials; /** * Provider-specific AI SDK generation options. * Pass this to generateText()/streamText() alongside the model. diff --git a/ts_openai_chatgpt_auth/index.ts b/ts_openai_chatgpt_auth/index.ts new file mode 100644 index 0000000..f3b45d5 --- /dev/null +++ b/ts_openai_chatgpt_auth/index.ts @@ -0,0 +1,351 @@ +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + parseOpenAiChatGptTokenInfo, + refreshOpenAiChatGptTokenData, +} from '../ts/smartai.auth.openai.js'; +import type { + IOpenAiChatGptAuthOptions, + IOpenAiChatGptTokenData, + IOpenAiChatGptTokenInfo, +} from '../ts/smartai.interfaces.js'; + +export type TOpenAiChatGptAuthSource = 'smartai' | 'opencode' | 'codex'; +export type TOpenAiChatGptAuthFileFormat = TOpenAiChatGptAuthSource | 'auto'; + +export interface IOpenAiChatGptAuthSourceConfig { + source: TOpenAiChatGptAuthSource; + filePath?: string; + format?: TOpenAiChatGptAuthFileFormat; + writeBack?: boolean; +} + +export interface IOpenAiChatGptAuthSourceInspection { + source: TOpenAiChatGptAuthSource; + filePath: string; + exists: boolean; + usable: boolean; + expired?: boolean; + accountId?: string; + email?: string; + plan?: string; + expiresAt?: string; + error?: string; +} + +export interface IInspectOpenAiChatGptAuthSourcesOptions { + sources?: Array; + homeDir?: string; + now?: Date; +} + +export interface IResolveOpenAiChatGptAuthOptions extends IInspectOpenAiChatGptAuthSourcesOptions { + refresh?: 'ifNeeded' | false; + writeBack?: Partial>; + authOptions?: IOpenAiChatGptAuthOptions; +} + +export interface IResolvedOpenAiChatGptAuth { + source: TOpenAiChatGptAuthSource; + filePath: string; + tokenData: IOpenAiChatGptTokenData; + refreshed: boolean; +} + +interface INormalizedOpenAiChatGptAuthSourceConfig { + source: TOpenAiChatGptAuthSource; + filePath: string; + format: TOpenAiChatGptAuthFileFormat; + writeBack?: boolean; +} + +const defaultSources: TOpenAiChatGptAuthSource[] = ['smartai', 'opencode', 'codex']; +const refreshWindowMs = 5 * 60 * 1000; + +export const getDefaultOpenAiChatGptAuthPath = ( + source: TOpenAiChatGptAuthSource, + homeDir = os.homedir(), +): string => { + switch (source) { + case 'smartai': + return path.join(homeDir, '.git.zone', 'ide', 'openai-chatgpt-auth.json'); + case 'opencode': + return path.join(homeDir, '.local', 'share', 'opencode', 'auth.json'); + case 'codex': + return path.join(homeDir, '.codex', 'auth.json'); + default: + throw new Error(`Unsupported OpenAI ChatGPT auth source: ${source satisfies never}`); + } +}; + +export const normalizeOpenAiChatGptAuth = ( + input: unknown, + format: TOpenAiChatGptAuthFileFormat = 'auto', +): IOpenAiChatGptTokenData | undefined => { + if (format === 'auto') { + return normalizeOpenAiChatGptAuth(input, 'smartai') + ?? normalizeOpenAiChatGptAuth(input, 'opencode') + ?? normalizeOpenAiChatGptAuth(input, 'codex'); + } + + if (!isRecord(input)) return undefined; + if (format === 'opencode') { + return normalizeOpenCodeAuth(input); + } + return normalizeTokenObject(isRecord(input.tokens) ? input.tokens : input); +}; + +export const readOpenAiChatGptAuthFile = async ( + filePath: string, + format: TOpenAiChatGptAuthFileFormat = 'auto', +): Promise => { + const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown; + return normalizeOpenAiChatGptAuth(parsed, format); +}; + +export const inspectOpenAiChatGptAuthSources = async ( + options: IInspectOpenAiChatGptAuthSourcesOptions = {}, +): Promise => { + const now = options.now ?? new Date(); + const sourceConfigs = normalizeSourceConfigs(options.sources, options.homeDir); + return Promise.all(sourceConfigs.map(async (sourceConfig) => { + try { + const tokenData = await readOpenAiChatGptAuthFile(sourceConfig.filePath!, sourceConfig.format ?? sourceConfig.source); + if (!tokenData) { + return { + source: sourceConfig.source, + filePath: sourceConfig.filePath!, + exists: true, + usable: false, + error: 'No OpenAI ChatGPT auth token found in file.', + }; + } + return toInspection(sourceConfig.source, sourceConfig.filePath!, tokenData, now); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + return { + source: sourceConfig.source, + filePath: sourceConfig.filePath!, + exists: nodeError.code !== 'ENOENT', + usable: false, + error: nodeError.code === 'ENOENT' ? undefined : nodeError.message, + }; + } + })); +}; + +export const resolveOpenAiChatGptAuth = async ( + options: IResolveOpenAiChatGptAuthOptions = {}, +): Promise => { + const now = options.now ?? new Date(); + const sourceConfigs = normalizeSourceConfigs(options.sources, options.homeDir); + const refresh = options.refresh ?? 'ifNeeded'; + + for (const sourceConfig of sourceConfigs) { + let tokenData: IOpenAiChatGptTokenData | undefined; + try { + tokenData = await readOpenAiChatGptAuthFile(sourceConfig.filePath!, sourceConfig.format ?? sourceConfig.source); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') continue; + continue; + } + if (!tokenData) continue; + + const shouldRefresh = refresh === 'ifNeeded' && shouldRefreshToken(tokenData, now); + const writeBack = sourceConfig.writeBack ?? options.writeBack?.[sourceConfig.source] ?? sourceConfig.source === 'smartai'; + if (!shouldRefresh) { + return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData, refreshed: false }; + } + + if (!writeBack) { + if (!isExpired(tokenData, now)) { + return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData, refreshed: false }; + } + continue; + } + + try { + const refreshed = await refreshOpenAiChatGptTokenData(tokenData, options.authOptions ?? {}); + await writeOpenAiChatGptAuthFile(sourceConfig.filePath!, refreshed, sourceConfig.format ?? sourceConfig.source); + return { source: sourceConfig.source, filePath: sourceConfig.filePath!, tokenData: refreshed, refreshed: true }; + } catch { + continue; + } + } + + return undefined; +}; + +export const writeOpenAiChatGptAuthFile = async ( + filePath: string, + tokenData: IOpenAiChatGptTokenData, + format: TOpenAiChatGptAuthFileFormat = 'smartai', +): Promise => { + const current = await readJsonFileIfExists(filePath); + const payload = format === 'opencode' + ? toOpenCodeAuthFile(current, tokenData) + : format === 'codex' + ? toCodexAuthFile(current, tokenData) + : toSmartAiAuthFile(tokenData); + await writeJsonAtomic(filePath, payload); +}; + +const normalizeSourceConfigs = ( + sources: Array | undefined, + homeDir = os.homedir(), +): INormalizedOpenAiChatGptAuthSourceConfig[] => { + return (sources ?? defaultSources).map((sourceInput) => { + const sourceConfig = typeof sourceInput === 'string' ? { source: sourceInput } : sourceInput; + return { + source: sourceConfig.source, + filePath: sourceConfig.filePath ?? getDefaultOpenAiChatGptAuthPath(sourceConfig.source, homeDir), + format: sourceConfig.format ?? sourceConfig.source, + writeBack: sourceConfig.writeBack, + }; + }); +}; + +const toInspection = ( + source: TOpenAiChatGptAuthSource, + filePath: string, + tokenData: IOpenAiChatGptTokenData, + now: Date, +): IOpenAiChatGptAuthSourceInspection => { + const expired = isExpired(tokenData, now); + return { + source, + filePath, + exists: true, + usable: !expired, + expired, + accountId: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId, + email: tokenData.tokenInfo.email, + plan: tokenData.tokenInfo.chatgptPlanType, + expiresAt: tokenData.tokenInfo.expiresAt, + }; +}; + +const normalizeTokenObject = (input: Record): IOpenAiChatGptTokenData | undefined => { + const accessToken = stringValue(input.accessToken) ?? stringValue(input.access_token); + const refreshToken = stringValue(input.refreshToken) ?? stringValue(input.refresh_token); + const idToken = stringValue(input.idToken) ?? stringValue(input.id_token) ?? stringValue((input.tokenInfo as Record | undefined)?.rawJwt); + const accountId = stringValue(input.accountId) ?? stringValue(input.account_id); + return createTokenDataFromValues({ accessToken, refreshToken, idToken, accountId }); +}; + +const normalizeOpenCodeAuth = (input: Record): IOpenAiChatGptTokenData | undefined => { + if (!isRecord(input.openai)) return undefined; + const accessToken = stringValue(input.openai.access); + const refreshToken = stringValue(input.openai.refresh); + const accountId = stringValue(input.openai.accountId); + const expiresAt = typeof input.openai.expires === 'number' ? new Date(input.openai.expires).toISOString() : undefined; + return createTokenDataFromValues({ accessToken, refreshToken, accountId, fallbackExpiresAt: expiresAt }); +}; + +const createTokenDataFromValues = (input: { + accessToken?: string; + refreshToken?: string; + idToken?: string; + accountId?: string; + fallbackExpiresAt?: string; +}): IOpenAiChatGptTokenData | undefined => { + if (!input.accessToken || !input.refreshToken) return undefined; + const parseToken = input.idToken ?? input.accessToken; + const tokenInfo = parseTokenInfo(parseToken, input.accountId, input.fallbackExpiresAt); + return { + accessToken: input.accessToken, + refreshToken: input.refreshToken, + idToken: input.idToken, + accountId: input.accountId ?? tokenInfo.chatgptAccountId, + tokenInfo, + }; +}; + +const parseTokenInfo = (token: string, accountId?: string, fallbackExpiresAt?: string): IOpenAiChatGptTokenInfo => { + try { + const tokenInfo = parseOpenAiChatGptTokenInfo(token); + return { + ...tokenInfo, + chatgptAccountId: tokenInfo.chatgptAccountId ?? accountId, + expiresAt: tokenInfo.expiresAt ?? fallbackExpiresAt, + }; + } catch { + return { + chatgptAccountId: accountId, + chatgptAccountIsFedramp: false, + expiresAt: fallbackExpiresAt, + rawJwt: token, + }; + } +}; + +const toSmartAiAuthFile = (tokenData: IOpenAiChatGptTokenData): Record => ({ + accessToken: tokenData.accessToken, + refreshToken: tokenData.refreshToken, + idToken: tokenData.idToken, + accountId: tokenData.accountId, + tokenInfo: tokenData.tokenInfo, +}); + +const toCodexAuthFile = (current: Record, tokenData: IOpenAiChatGptTokenData): Record => ({ + ...current, + OPENAI_API_KEY: current.OPENAI_API_KEY ?? null, + tokens: { + ...(isRecord(current.tokens) ? current.tokens : {}), + id_token: tokenData.idToken, + access_token: tokenData.accessToken, + refresh_token: tokenData.refreshToken, + account_id: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId, + }, + last_refresh: new Date().toISOString(), +}); + +const toOpenCodeAuthFile = (current: Record, tokenData: IOpenAiChatGptTokenData): Record => ({ + ...current, + openai: { + ...(isRecord(current.openai) ? current.openai : {}), + type: 'oauth', + refresh: tokenData.refreshToken, + access: tokenData.accessToken, + expires: tokenData.tokenInfo.expiresAt ? Date.parse(tokenData.tokenInfo.expiresAt) : undefined, + accountId: tokenData.accountId ?? tokenData.tokenInfo.chatgptAccountId, + }, +}); + +const shouldRefreshToken = (tokenData: IOpenAiChatGptTokenData, now: Date): boolean => { + if (!tokenData.tokenInfo.expiresAt) return false; + return Date.parse(tokenData.tokenInfo.expiresAt) - now.getTime() < refreshWindowMs; +}; + +const isExpired = (tokenData: IOpenAiChatGptTokenData, now: Date): boolean => { + if (!tokenData.tokenInfo.expiresAt) return false; + return Date.parse(tokenData.tokenInfo.expiresAt) <= now.getTime(); +}; + +const readJsonFileIfExists = async (filePath: string): Promise> => { + try { + const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown; + return isRecord(parsed) ? parsed : {}; + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') return {}; + throw error; + } +}; + +const writeJsonAtomic = async (filePath: string, payload: unknown): Promise => { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; + await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 }); + await fs.writeFile(tempPath, `${JSON.stringify(payload, undefined, 2)}\n`, { mode: 0o600 }); + await fs.rename(tempPath, filePath); +}; + +const isRecord = (value: unknown): value is Record => { + return !!value && typeof value === 'object' && !Array.isArray(value); +}; + +const stringValue = (value: unknown): string | undefined => { + return typeof value === 'string' && value.length > 0 ? value : undefined; +};