feat(openai-chatgpt-auth)!: rename ChatGPT auth APIs
Add Node-only auth source helpers for SmartAI, OpenCode, and Codex credentials.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+6
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+107
-23
@@ -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, unknown>): 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();
|
||||
|
||||
+21
-21
@@ -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';
|
||||
|
||||
+85
-76
@@ -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<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
async function postJson(url: string, body: unknown, options: IOpenAiChatGptAuthOptions): Promise<unknown> {
|
||||
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<unknown> {
|
||||
async function postForm(url: string, body: URLSearchParams, options: IOpenAiChatGptAuthOptions): Promise<unknown> {
|
||||
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<void> {
|
||||
function parseJwtPayload(jwt: string): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
} 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<string, unknown> | undefined;
|
||||
const auth = claims['https://api.openai.com/auth'] as Record<string, unknown> | 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<IOpenAiMaxDeviceCode> {
|
||||
export async function requestOpenAiChatGptDeviceCode(
|
||||
options: IOpenAiChatGptAuthOptions = {},
|
||||
): Promise<IOpenAiChatGptDeviceCode> {
|
||||
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<IOpenAiMaxAuthorizationCode> {
|
||||
export async function pollOpenAiChatGptDeviceCode(
|
||||
deviceCode: IOpenAiChatGptDeviceCode,
|
||||
options: IOpenAiChatGptDeviceCodePollOptions = {},
|
||||
): Promise<IOpenAiChatGptAuthorizationCode> {
|
||||
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<IOpenAiMaxTokenData> {
|
||||
export async function exchangeOpenAiChatGptAuthorizationCode(
|
||||
authorizationCode: IOpenAiChatGptAuthorizationCode,
|
||||
options: IOpenAiChatGptAuthOptions = {},
|
||||
): Promise<IOpenAiChatGptTokenData> {
|
||||
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<IOpenAiMaxTokenData> {
|
||||
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<IOpenAiChatGptTokenData> {
|
||||
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<IOpenAiMaxTokenData> {
|
||||
export async function refreshOpenAiChatGptTokenData(
|
||||
tokenData: IOpenAiChatGptTokenData,
|
||||
options: IOpenAiChatGptAuthOptions = {},
|
||||
): Promise<IOpenAiChatGptTokenData> {
|
||||
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<string, string>;
|
||||
} {
|
||||
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<string, string> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+10
-11
@@ -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<void>;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<TOpenAiChatGptAuthSource | IOpenAiChatGptAuthSourceConfig>;
|
||||
homeDir?: string;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
export interface IResolveOpenAiChatGptAuthOptions extends IInspectOpenAiChatGptAuthSourcesOptions {
|
||||
refresh?: 'ifNeeded' | false;
|
||||
writeBack?: Partial<Record<TOpenAiChatGptAuthSource, boolean>>;
|
||||
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<IOpenAiChatGptTokenData | undefined> => {
|
||||
const parsed = JSON.parse(await fs.readFile(filePath, 'utf8')) as unknown;
|
||||
return normalizeOpenAiChatGptAuth(parsed, format);
|
||||
};
|
||||
|
||||
export const inspectOpenAiChatGptAuthSources = async (
|
||||
options: IInspectOpenAiChatGptAuthSourcesOptions = {},
|
||||
): Promise<IOpenAiChatGptAuthSourceInspection[]> => {
|
||||
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<IResolvedOpenAiChatGptAuth | undefined> => {
|
||||
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<void> => {
|
||||
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<TOpenAiChatGptAuthSource | IOpenAiChatGptAuthSourceConfig> | 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<string, unknown>): 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<string, unknown> | undefined)?.rawJwt);
|
||||
const accountId = stringValue(input.accountId) ?? stringValue(input.account_id);
|
||||
return createTokenDataFromValues({ accessToken, refreshToken, idToken, accountId });
|
||||
};
|
||||
|
||||
const normalizeOpenCodeAuth = (input: Record<string, unknown>): 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<string, unknown> => ({
|
||||
accessToken: tokenData.accessToken,
|
||||
refreshToken: tokenData.refreshToken,
|
||||
idToken: tokenData.idToken,
|
||||
accountId: tokenData.accountId,
|
||||
tokenInfo: tokenData.tokenInfo,
|
||||
});
|
||||
|
||||
const toCodexAuthFile = (current: Record<string, unknown>, tokenData: IOpenAiChatGptTokenData): Record<string, unknown> => ({
|
||||
...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<string, unknown>, tokenData: IOpenAiChatGptTokenData): Record<string, unknown> => ({
|
||||
...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<Record<string, unknown>> => {
|
||||
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<void> => {
|
||||
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<string, unknown> => {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
};
|
||||
|
||||
const stringValue = (value: unknown): string | undefined => {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
};
|
||||
Reference in New Issue
Block a user