Compare commits

..

8 Commits

Author SHA1 Message Date
jkunz a7ae676184 v4.1.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-18 00:13:37 +00:00
jkunz 269e948453 feat(ocr): add Mistral OCR engine with package export, tests, and documentation 2026-05-18 00:13:27 +00:00
jkunz 1d64ee3edb v4.0.2
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 19:59:36 +00:00
jkunz 4725b55566 fix(openai): strip unsupported ChatGPT prompt cache retention options while preserving prompt cache keys 2026-05-14 19:59:30 +00:00
jkunz 0e2053f538 v4.0.1
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 19:47:26 +00:00
jkunz c8f98b3364 fix(openai): map system prompts to top-level instructions for ChatGPT auth requests 2026-05-14 19:47:17 +00:00
jkunz 8a6c92c04e v4.0.0
Default (tags) / security (push) Failing after 1s
Default (tags) / test (push) Failing after 1s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-05-14 16:44:23 +00:00
jkunz 10587998f2 feat(openai-chatgpt-auth)!: rename ChatGPT auth APIs
Add Node-only auth source helpers for SmartAI, OpenCode, and Codex credentials.
2026-05-14 16:44:15 +00:00
14 changed files with 1059 additions and 153 deletions
+41
View File
@@ -3,6 +3,47 @@
## Pending ## Pending
## 2026-05-18 - 4.1.0
### Features
- add Mistral OCR engine with package export, tests, and documentation (ocr)
- introduces a new `@push.rocks/smartai/ocr` subpath export with `createMistralOcrEngine()` for image OCR via Mistral's Document AI endpoint
- adds OCR request/response types, configurable transport and options, and normalized page/confidence results
- includes mocked transport tests for OCR requests and input validation
- updates package metadata and README content to document the new OCR module
## 2026-05-14 - 4.0.2
### Fixes
- strip unsupported ChatGPT prompt cache retention options while preserving prompt cache keys (openai)
- Removes promptCacheRetention values before sending requests to the ChatGPT Codex backend.
- Keeps prompt_cache_key forwarding intact for OpenAI provider options.
- Only rewrites system prompts into top-level instructions when needed, avoiding unnecessary prompt changes.
## 2026-05-14 - 4.0.1
### Fixes
- map system prompts to top-level instructions for ChatGPT auth requests (openai)
- wrap OpenAI models using ChatGPT auth with middleware that extracts system messages into provider instructions
- remove system messages from the serialized prompt payload to match the ChatGPT Codex backend expectations
- add test coverage to verify authorization headers, workspace routing, and instruction payload mapping
## 2026-05-14 - 4.0.0
### 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 ## 2026-05-14 - 2.3.0
### Features ### Features
+12 -2
View File
@@ -1,8 +1,8 @@
{ {
"name": "@push.rocks/smartai", "name": "@push.rocks/smartai",
"version": "2.3.0", "version": "4.1.0",
"private": false, "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.", "description": "Provider registry and capability utilities for ai-sdk (Vercel AI SDK). Core export returns LanguageModel; subpath exports provide vision, audio, image, document, OCR and research capabilities.",
"main": "dist_ts/index.js", "main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts", "typings": "dist_ts/index.d.ts",
"type": "module", "type": "module",
@@ -27,9 +27,17 @@
"import": "./dist_ts_document/index.js", "import": "./dist_ts_document/index.js",
"types": "./dist_ts_document/index.d.ts" "types": "./dist_ts_document/index.d.ts"
}, },
"./ocr": {
"import": "./dist_ts_ocr/index.js",
"types": "./dist_ts_ocr/index.d.ts"
},
"./research": { "./research": {
"import": "./dist_ts_research/index.js", "import": "./dist_ts_research/index.js",
"types": "./dist_ts_research/index.d.ts" "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", "author": "Task Venture Capital GmbH",
@@ -83,7 +91,9 @@
"ts_audio/**/*", "ts_audio/**/*",
"ts_image/**/*", "ts_image/**/*",
"ts_document/**/*", "ts_document/**/*",
"ts_ocr/**/*",
"ts_research/**/*", "ts_research/**/*",
"ts_openai_chatgpt_auth/**/*",
"dist_*/**/*", "dist_*/**/*",
"assets/**/*", "assets/**/*",
".smartconfig.json", ".smartconfig.json",
+4 -2
View File
@@ -10,13 +10,15 @@ The package is a **provider registry** built on the Vercel AI SDK (`ai` v6). The
- Providers: anthropic, openai, google, groq, mistral, xai, perplexity, ollama - Providers: anthropic, openai, google, groq, mistral, xai, perplexity, ollama
- Anthropic prompt caching via `wrapLanguageModel` middleware (enabled by default) - Anthropic prompt caching via `wrapLanguageModel` middleware (enabled by default)
- Custom Ollama provider implementing `LanguageModelV3` directly (for think, num_ctx support) - 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 ### Subpath Exports
- `@push.rocks/smartai/vision``analyzeImage()` using `generateText` with image content - `@push.rocks/smartai/vision``analyzeImage()` using `generateText` with image content
- `@push.rocks/smartai/audio``textToSpeech()` using OpenAI SDK directly - `@push.rocks/smartai/audio``textToSpeech()` using OpenAI SDK directly
- `@push.rocks/smartai/image``generateImage()`, `editImage()` using OpenAI SDK directly - `@push.rocks/smartai/image``generateImage()`, `editImage()` using OpenAI SDK directly
- `@push.rocks/smartai/document``analyzeDocuments()` using SmartPdf + `generateText` - `@push.rocks/smartai/document``analyzeDocuments()` using SmartPdf + `generateText`
- `@push.rocks/smartai/ocr``createMistralOcrEngine()` using Mistral Document AI OCR endpoint
- `@push.rocks/smartai/research``research()` using `@anthropic-ai/sdk` web_search tool - `@push.rocks/smartai/research``research()` using `@anthropic-ai/sdk` web_search tool
## Dependencies ## Dependencies
@@ -31,7 +33,7 @@ The package is a **provider registry** built on the Vercel AI SDK (`ai` v6). The
## Build ## Build
- `pnpm build``tsbuild tsfolders --allowimplicitany` - `pnpm build``tsbuild tsfolders --allowimplicitany`
- Compiles: ts/, ts_vision/, ts_audio/, ts_image/, ts_document/, ts_research/ - Compiles: ts/, ts_vision/, ts_audio/, ts_image/, ts_document/, ts_ocr/, ts_research/
## Important Notes ## Important Notes
+73 -8
View File
@@ -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 `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 ```typescript
import { import {
completeOpenAiMaxDeviceCodeLogin, completeOpenAiChatGptDeviceCodeLogin,
getModel, getModel,
requestOpenAiMaxDeviceCode, requestOpenAiChatGptDeviceCode,
} from '@push.rocks/smartai'; } from '@push.rocks/smartai';
const deviceCode = await requestOpenAiMaxDeviceCode(); const deviceCode = await requestOpenAiChatGptDeviceCode();
console.log(`Open ${deviceCode.verificationUrl} and enter ${deviceCode.userCode}`); console.log(`Open ${deviceCode.verificationUrl} and enter ${deviceCode.userCode}`);
const openAiMaxAuth = await completeOpenAiMaxDeviceCodeLogin(deviceCode); const openAiChatGptAuth = await completeOpenAiChatGptDeviceCodeLogin(deviceCode);
const model = getModel({ const model = getModel({
provider: 'openai', provider: 'openai',
model: 'gpt-5.5', 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 ### Re-exported AI SDK Functions
@@ -448,6 +479,38 @@ console.log(analysis);
await stopSmartpdf(); await stopSmartpdf();
``` ```
### 🔎 OCR — `@push.rocks/smartai/ocr`
Extract text from images using Mistral Document AI OCR. This uses the documented `https://api.mistral.ai/v1/ocr` endpoint with `mistral-ocr-latest` and returns normalized text plus page-level confidence when requested.
```typescript
import { createMistralOcrEngine } from '@push.rocks/smartai/ocr';
import * as fs from 'fs';
const ocr = createMistralOcrEngine({
apiKey: process.env.MISTRAL_API_KEY,
confidenceScoresGranularity: 'page',
});
const result = await ocr.recognizeImage({
dataBase64: fs.readFileSync('screenshot.png').toString('base64'),
mimeType: 'image/png',
});
console.log(result.text);
console.log(result.confidence);
```
**`createMistralOcrEngine(options)`** accepts:
- `apiKey` — Mistral API key, required unless a custom `transport` is supplied
- `model` — defaults to `mistral-ocr-latest`
- `endpointUrl` — defaults to `https://api.mistral.ai/v1/ocr`
- `confidenceScoresGranularity``'page'` | `'word'`
- `tableFormat``'markdown'` | `'html'`
- `extractHeader` / `extractFooter` — optional document OCR flags
- `transport` — injectable transport for tests or custom HTTP clients
### 🔬 Research — `@push.rocks/smartai/research` ### 🔬 Research — `@push.rocks/smartai/research`
Perform web-search-powered research using Anthropic's `web_search_20250305` tool. Perform web-search-powered research using Anthropic's `web_search_20250305` tool.
@@ -483,6 +546,7 @@ tstest test/test.image.ts --verbose # Image generation
tstest test/test.research.ts --verbose # Web research tstest test/test.research.ts --verbose # Web research
tstest test/test.audio.ts --verbose # Text-to-speech tstest test/test.audio.ts --verbose # Text-to-speech
tstest test/test.document.ts --verbose # Document analysis (needs Chromium) tstest test/test.document.ts --verbose # Document analysis (needs Chromium)
tstest test/test.ocr.ts --verbose # Mistral OCR transport (mocked)
``` ```
Most tests skip gracefully when API keys are not set. The Ollama tests are fully mocked and require no external services. Most tests skip gracefully when API keys are not set. The Ollama tests are fully mocked and require no external services.
@@ -502,6 +566,7 @@ Most tests skip gracefully when API keys are not set. The Ollama tests are fully
├── ts_audio/ # @push.rocks/smartai/audio ├── ts_audio/ # @push.rocks/smartai/audio
├── ts_image/ # @push.rocks/smartai/image ├── ts_image/ # @push.rocks/smartai/image
├── ts_document/ # @push.rocks/smartai/document ├── ts_document/ # @push.rocks/smartai/document
├── ts_ocr/ # @push.rocks/smartai/ocr
└── ts_research/ # @push.rocks/smartai/research └── ts_research/ # @push.rocks/smartai/research
``` ```
+77
View File
@@ -0,0 +1,77 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { createMistralOcrEngine, type ISmartAiMistralOcrTransport } from '../ts_ocr/index.js';
tap.test('createMistralOcrEngine should call Mistral OCR with image data URLs', async () => {
const calls: unknown[] = [];
const mockTransport: ISmartAiMistralOcrTransport = {
process: async (request) => {
calls.push(request);
return {
pages: [
{
index: 0,
markdown: 'hello terminal',
confidence_scores: {
average_page_confidence_score: 0.91,
minimum_page_confidence_score: 0.8,
},
},
],
model: 'mistral-ocr-latest',
usage_info: {
pages_processed: 1,
doc_size_bytes: 12,
},
};
},
};
const ocrEngine = createMistralOcrEngine({
transport: mockTransport,
confidenceScoresGranularity: 'page',
});
const result = await ocrEngine.recognizeImage({
dataBase64: 'iVBORw0KGgo=',
mimeType: 'image/png',
});
expect(calls.length).toEqual(1);
expect((calls[0] as any).model).toEqual('mistral-ocr-latest');
expect((calls[0] as any).document.type).toEqual('image_url');
expect((calls[0] as any).document.image_url).toEqual('data:image/png;base64,iVBORw0KGgo=');
expect((calls[0] as any).confidence_scores_granularity).toEqual('page');
expect(result.text).toEqual('hello terminal');
expect(result.confidence).toEqual(0.91);
expect(result.pages).toEqual([
{
index: 0,
text: 'hello terminal',
confidence: 0.91,
},
]);
});
tap.test('createMistralOcrEngine should validate image input', async () => {
const ocrEngine = createMistralOcrEngine({
transport: {
process: async () => {
throw new Error('should not call OCR');
},
},
});
let error: Error | undefined;
try {
await ocrEngine.recognizeImage({
dataBase64: '',
mimeType: 'image/png',
});
} catch (caughtError) {
error = caughtError instanceof Error ? caughtError : new Error(String(caughtError));
}
expect(error?.message).toEqual('Mistral OCR image input requires dataBase64.');
});
export default tap.start();
+125 -27
View File
@@ -1,6 +1,15 @@
import { tap, expect } from '@git.zone/tstest/tapbundle'; 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 * 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 { interface IMockFetchRequest {
url: string; url: string;
@@ -12,10 +21,10 @@ function createJwt(payload: Record<string, unknown>): string {
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.sig`; return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.sig`;
} }
function createTokenData(accountId = 'workspace-1'): IOpenAiMaxTokenData { function createTokenData(accountId = 'workspace-1', expiresAtSeconds = 4_102_444_800): IOpenAiChatGptTokenData {
const idToken = createJwt({ const accessToken = createJwt({
email: 'user@example.com', email: 'user@example.com',
exp: 4_102_444_800, exp: expiresAtSeconds,
'https://api.openai.com/auth': { 'https://api.openai.com/auth': {
chatgpt_plan_type: 'pro', chatgpt_plan_type: 'pro',
chatgpt_user_id: 'user-1', chatgpt_user_id: 'user-1',
@@ -23,13 +32,12 @@ function createTokenData(accountId = 'workspace-1'): IOpenAiMaxTokenData {
chatgpt_account_is_fedramp: false, chatgpt_account_is_fedramp: false,
}, },
}); });
const idTokenInfo = smartai.parseOpenAiMaxIdToken(idToken); const tokenInfo = smartai.parseOpenAiChatGptTokenInfo(accessToken);
return { return {
idToken, accessToken,
accessToken: 'access-token',
refreshToken: 'refresh-token', refreshToken: 'refresh-token',
accountId, accountId,
idTokenInfo, tokenInfo,
}; };
} }
@@ -44,7 +52,7 @@ function getHeader(init: RequestInit | undefined, name: string): string | null {
return new Headers(init?.headers).get(name); 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 originalFetch = globalThis.fetch;
const requests: IMockFetchRequest[] = []; const requests: IMockFetchRequest[] = [];
@@ -58,7 +66,7 @@ tap.test('requestOpenAiMaxDeviceCode requests a user code', async () => {
}; };
try { try {
const deviceCode = await smartai.requestOpenAiMaxDeviceCode({ const deviceCode = await smartai.requestOpenAiChatGptDeviceCode({
issuer: 'https://auth.example.test', issuer: 'https://auth.example.test',
clientId: 'client-1', 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 originalFetch = globalThis.fetch;
const requests: IMockFetchRequest[] = []; const requests: IMockFetchRequest[] = [];
const tokenData = createTokenData('workspace-1'); const tokenData = createTokenData('workspace-1');
@@ -88,7 +96,6 @@ tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', as
code_verifier: 'verifier', code_verifier: 'verifier',
}), }),
jsonResponse({ jsonResponse({
id_token: tokenData.idToken,
access_token: tokenData.accessToken, access_token: tokenData.accessToken,
refresh_token: tokenData.refreshToken, refresh_token: tokenData.refreshToken,
}), }),
@@ -102,7 +109,7 @@ tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', as
}; };
try { try {
const result = await smartai.completeOpenAiMaxDeviceCodeLogin({ const result = await smartai.completeOpenAiChatGptDeviceCodeLogin({
verificationUrl: 'https://auth.example.test/codex/device', verificationUrl: 'https://auth.example.test/codex/device',
userCode: 'ABCD-EFGH', userCode: 'ABCD-EFGH',
deviceAuthId: 'device-1', deviceAuthId: 'device-1',
@@ -114,9 +121,9 @@ tap.test('completeOpenAiMaxDeviceCodeLogin polls and exchanges OAuth tokens', as
sleep: async () => undefined, sleep: async () => undefined,
}); });
expect(result.accessToken).toEqual('access-token'); expect(result.accessToken).toEqual(tokenData.accessToken);
expect(result.refreshToken).toEqual('refresh-token'); 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(requests.length).toEqual(3);
expect(JSON.parse(String(requests[0].init?.body))).toEqual({ expect(JSON.parse(String(requests[0].init?.body))).toEqual({
device_auth_id: 'device-1', 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 originalFetch = globalThis.fetch;
const requests: IMockFetchRequest[] = []; const requests: IMockFetchRequest[] = [];
const tokenData = createTokenData('workspace-1'); const tokenData = createTokenData('workspace-1');
const refreshedToken = createTokenData('workspace-1', 4_102_445_000).accessToken;
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
requests.push({ url: String(input), init }); requests.push({ url: String(input), init });
return jsonResponse({ access_token: 'new-access-token' }); return jsonResponse({ access_token: refreshedToken });
}; };
try { try {
const result = await smartai.refreshOpenAiMaxTokenData(tokenData, { const result = await smartai.refreshOpenAiChatGptTokenData(tokenData, {
issuer: 'https://auth.example.test', issuer: 'https://auth.example.test',
clientId: 'client-1', clientId: 'client-1',
}); });
expect(result.accessToken).toEqual('new-access-token'); expect(result.accessToken).toEqual(refreshedToken);
expect(result.refreshToken).toEqual('refresh-token'); expect(result.refreshToken).toEqual('refresh-token');
expect(result.idToken).toEqual(tokenData.idToken);
expect(JSON.parse(String(requests[0].init?.body))).toEqual({ expect(JSON.parse(String(requests[0].init?.body))).toEqual({
client_id: 'client-1', client_id: 'client-1',
grant_type: 'refresh_token', 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; const originalFetch = globalThis.fetch;
let capturedRequest: IMockFetchRequest | undefined; let capturedRequest: IMockFetchRequest | undefined;
const tokenData = createTokenData('workspace-1'); const tokenData = createTokenData('workspace-1');
const model = smartai.getModel({ const model = smartai.getModel({
provider: 'openai', provider: 'openai',
model: 'gpt-5.5', model: 'gpt-5.5',
openAiMaxAuth: tokenData, openAiChatGptAuth: tokenData,
}); });
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -192,18 +199,109 @@ tap.test('getModel uses ChatGPT Codex backend for OpenAI Max auth', async () =>
}; };
try { try {
await model.doGenerate({ await smartai.generateText({
prompt: [{ role: 'user', content: [{ type: 'text', text: 'hello' }] }], model,
inputFormat: 'prompt', system: 'system prompt',
} as any); prompt: 'hello',
providerOptions: {
openai: {
promptCacheKey: 'session-1',
promptCacheRetention: 'in_memory',
},
},
});
expect(capturedRequest?.url).toEqual('https://chatgpt.com/backend-api/codex/responses'); 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, 'chatgpt-account-id')).toEqual('workspace-1');
expect(getHeader(capturedRequest?.init, 'originator')).toEqual('smartai'); expect(getHeader(capturedRequest?.init, 'originator')).toEqual('smartai');
const capturedBody = JSON.parse(String(capturedRequest?.init?.body));
expect(capturedBody.instructions).toEqual('system prompt');
expect(capturedBody.input).toEqual([
{ role: 'user', content: [{ type: 'input_text', text: 'hello' }] },
]);
expect(capturedBody.prompt_cache_key).toEqual('session-1');
expect(capturedBody.prompt_cache_retention).toEqual(undefined);
} finally { } finally {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
} }
}); });
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(); export default tap.start();
+2 -2
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartai', name: '@push.rocks/smartai',
version: '2.3.0', version: '4.1.0',
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.' description: 'Provider registry and capability utilities for ai-sdk (Vercel AI SDK). Core export returns LanguageModel; subpath exports provide vision, audio, image, document, OCR and research capabilities.'
} }
+21 -21
View File
@@ -1,13 +1,13 @@
export { getModel, getModelSetup } from './smartai.classes.smartai.js'; export { getModel, getModelSetup } from './smartai.classes.smartai.js';
export type { export type {
IOpenAiProviderOptions, IOpenAiProviderOptions,
IOpenAiMaxAuthCredentials, IOpenAiChatGptAuthCredentials,
IOpenAiMaxAuthOptions, IOpenAiChatGptAuthOptions,
IOpenAiMaxCompleteDeviceCodeOptions, IOpenAiChatGptCompleteDeviceCodeOptions,
IOpenAiMaxDeviceCode, IOpenAiChatGptDeviceCode,
IOpenAiMaxDeviceCodePollOptions, IOpenAiChatGptDeviceCodePollOptions,
IOpenAiMaxIdTokenInfo, IOpenAiChatGptTokenData,
IOpenAiMaxTokenData, IOpenAiChatGptTokenInfo,
ISmartAiModelSetup, ISmartAiModelSetup,
ISmartAiOptions, ISmartAiOptions,
TOpenAiReasoningEffort, TOpenAiReasoningEffort,
@@ -36,21 +36,21 @@ export type {
} from './smartai.cache.js'; } from './smartai.cache.js';
export { createOllamaModel } from './smartai.provider.ollama.js'; export { createOllamaModel } from './smartai.provider.ollama.js';
export { export {
OPENAI_MAX_AUTH_ISSUER, OPENAI_CHATGPT_AUTH_ISSUER,
OPENAI_MAX_CLIENT_ID, OPENAI_CHATGPT_CLIENT_ID,
OPENAI_MAX_CODEX_BASE_URL, OPENAI_CHATGPT_CODEX_BASE_URL,
OPENAI_MAX_DEFAULT_ORIGINATOR, OPENAI_CHATGPT_DEFAULT_ORIGINATOR,
OpenAiMaxAuthError, OpenAiChatGptAuthError,
completeOpenAiMaxDeviceCodeLogin, completeOpenAiChatGptDeviceCodeLogin,
createOpenAiMaxProviderSettings, createOpenAiChatGptProviderSettings,
ensureOpenAiMaxWorkspaceAllowed, ensureOpenAiChatGptWorkspaceAllowed,
exchangeOpenAiMaxAuthorizationCode, exchangeOpenAiChatGptAuthorizationCode,
parseOpenAiMaxIdToken, parseOpenAiChatGptTokenInfo,
pollOpenAiMaxDeviceCode, pollOpenAiChatGptDeviceCode,
refreshOpenAiMaxTokenData, refreshOpenAiChatGptTokenData,
requestOpenAiMaxDeviceCode, requestOpenAiChatGptDeviceCode,
} from './smartai.auth.openai.js'; } 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 // Re-export commonly used ai-sdk functions for consumer convenience
export { generateText, streamText, tool, jsonSchema } from 'ai'; export { generateText, streamText, tool, jsonSchema } from 'ai';
+85 -76
View File
@@ -1,63 +1,63 @@
import type { import type {
IOpenAiMaxAuthCredentials, IOpenAiChatGptAuthCredentials,
IOpenAiMaxAuthOptions, IOpenAiChatGptAuthOptions,
IOpenAiMaxCompleteDeviceCodeOptions, IOpenAiChatGptCompleteDeviceCodeOptions,
IOpenAiMaxDeviceCode, IOpenAiChatGptDeviceCode,
IOpenAiMaxDeviceCodePollOptions, IOpenAiChatGptDeviceCodePollOptions,
IOpenAiMaxIdTokenInfo, IOpenAiChatGptTokenData,
IOpenAiMaxTokenData, IOpenAiChatGptTokenInfo,
} from './smartai.interfaces.js'; } from './smartai.interfaces.js';
export const OPENAI_MAX_AUTH_ISSUER = 'https://auth.openai.com'; export const OPENAI_CHATGPT_AUTH_ISSUER = 'https://auth.openai.com';
export const OPENAI_MAX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; export const OPENAI_CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
export const OPENAI_MAX_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; export const OPENAI_CHATGPT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex';
export const OPENAI_MAX_DEFAULT_ORIGINATOR = 'smartai'; export const OPENAI_CHATGPT_DEFAULT_ORIGINATOR = 'smartai';
const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000; const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000;
export class OpenAiMaxAuthError extends Error { export class OpenAiChatGptAuthError extends Error {
public status?: number; public status?: number;
public body?: string; public body?: string;
constructor(message: string, options: { status?: number; body?: string } = {}) { constructor(message: string, options: { status?: number; body?: string } = {}) {
super(message); super(message);
this.name = 'OpenAiMaxAuthError'; this.name = 'OpenAiChatGptAuthError';
this.status = options.status; this.status = options.status;
this.body = options.body; this.body = options.body;
} }
} }
export interface IOpenAiMaxAuthorizationCode { export interface IOpenAiChatGptAuthorizationCode {
authorizationCode: string; authorizationCode: string;
codeChallenge: string; codeChallenge: string;
codeVerifier: string; codeVerifier: string;
} }
interface IOpenAiMaxTokenResponse { interface IOpenAiChatGptTokenResponse {
id_token?: unknown; id_token?: unknown;
access_token?: unknown; access_token?: unknown;
refresh_token?: unknown; refresh_token?: unknown;
} }
function getFetch(options: IOpenAiMaxAuthOptions): typeof fetch { function getFetch(options: IOpenAiChatGptAuthOptions): typeof fetch {
const fetchFunction = options.fetch ?? globalThis.fetch; const fetchFunction = options.fetch ?? globalThis.fetch;
if (!fetchFunction) { 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; return fetchFunction;
} }
function getIssuer(options: IOpenAiMaxAuthOptions): string { function getIssuer(options: IOpenAiChatGptAuthOptions): string {
return (options.issuer ?? OPENAI_MAX_AUTH_ISSUER).replace(/\/+$/, ''); return (options.issuer ?? OPENAI_CHATGPT_AUTH_ISSUER).replace(/\/+$/, '');
} }
function getClientId(options: IOpenAiMaxAuthOptions): string { function getClientId(options: IOpenAiChatGptAuthOptions): string {
return options.clientId ?? OPENAI_MAX_CLIENT_ID; return options.clientId ?? OPENAI_CHATGPT_CLIENT_ID;
} }
function asString(value: unknown, name: string): string { function asString(value: unknown, name: string): string {
if (typeof value !== 'string' || value.length === 0) { 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; return value;
} }
@@ -69,7 +69,7 @@ function asOptionalString(value: unknown): string | undefined {
function asIntervalSeconds(value: unknown): number { function asIntervalSeconds(value: unknown): number {
const interval = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10); const interval = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
if (!Number.isFinite(interval) || interval <= 0) { 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; return interval;
} }
@@ -77,7 +77,7 @@ function asIntervalSeconds(value: unknown): number {
async function readJson(response: Response, context: string): Promise<unknown> { async function readJson(response: Response, context: string): Promise<unknown> {
const body = await response.text(); const body = await response.text();
if (!response.ok) { if (!response.ok) {
throw new OpenAiMaxAuthError(`${context} failed with status ${response.status}.`, { throw new OpenAiChatGptAuthError(`${context} failed with status ${response.status}.`, {
status: response.status, status: response.status,
body, body,
}); });
@@ -86,14 +86,14 @@ async function readJson(response: Response, context: string): Promise<unknown> {
try { try {
return body ? JSON.parse(body) : {}; return body ? JSON.parse(body) : {};
} catch (error) { } 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, status: response.status,
body, 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, { const response = await getFetch(options)(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -102,7 +102,7 @@ async function postJson(url: string, body: unknown, options: IOpenAiMaxAuthOptio
return readJson(response, `POST ${url}`); 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, { const response = await getFetch(options)(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 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> { function parseJwtPayload(jwt: string): Record<string, unknown> {
const parts = jwt.split('.'); const parts = jwt.split('.');
if (parts.length !== 3 || !parts[1]) { 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 { try {
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as Record<string, unknown>; return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as Record<string, unknown>;
} catch (error) { } 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 { export function parseOpenAiChatGptTokenInfo(token: string): IOpenAiChatGptTokenInfo {
const claims = parseJwtPayload(idToken); const claims = parseJwtPayload(token);
const profile = claims['https://api.openai.com/profile'] as Record<string, unknown> | undefined; 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 auth = claims['https://api.openai.com/auth'] as Record<string, unknown> | undefined;
const expiresAtSeconds = typeof claims.exp === 'number' ? claims.exp : 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), chatgptAccountId: asOptionalString(auth?.chatgpt_account_id),
chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true, chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true,
expiresAt: expiresAtSeconds ? new Date(expiresAtSeconds * 1000).toISOString() : undefined, expiresAt: expiresAtSeconds ? new Date(expiresAtSeconds * 1000).toISOString() : undefined,
rawJwt: idToken, rawJwt: token,
}; };
} }
function createTokenData(response: IOpenAiMaxTokenResponse): IOpenAiMaxTokenData { function createTokenData(
const idToken = asString(response.id_token, 'id_token'); response: IOpenAiChatGptTokenResponse,
const accessToken = asString(response.access_token, 'access_token'); existingTokenData?: IOpenAiChatGptTokenData,
const refreshToken = asString(response.refresh_token, 'refresh_token'); ): IOpenAiChatGptTokenData {
const idTokenInfo = parseOpenAiMaxIdToken(idToken); 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 { return {
idToken,
accessToken, accessToken,
refreshToken, refreshToken,
accountId: idTokenInfo.chatgptAccountId, idToken,
idTokenInfo, accountId: tokenInfo.chatgptAccountId,
tokenInfo,
}; };
} }
export async function requestOpenAiMaxDeviceCode( export async function requestOpenAiChatGptDeviceCode(
options: IOpenAiMaxAuthOptions = {}, options: IOpenAiChatGptAuthOptions = {},
): Promise<IOpenAiMaxDeviceCode> { ): Promise<IOpenAiChatGptDeviceCode> {
const issuer = getIssuer(options); const issuer = getIssuer(options);
const response = await postJson(`${issuer}/api/accounts/deviceauth/usercode`, { const response = await postJson(`${issuer}/api/accounts/deviceauth/usercode`, {
client_id: getClientId(options), client_id: getClientId(options),
@@ -176,10 +185,10 @@ export async function requestOpenAiMaxDeviceCode(
}; };
} }
export async function pollOpenAiMaxDeviceCode( export async function pollOpenAiChatGptDeviceCode(
deviceCode: IOpenAiMaxDeviceCode, deviceCode: IOpenAiChatGptDeviceCode,
options: IOpenAiMaxDeviceCodePollOptions = {}, options: IOpenAiChatGptDeviceCodePollOptions = {},
): Promise<IOpenAiMaxAuthorizationCode> { ): Promise<IOpenAiChatGptAuthorizationCode> {
const issuer = getIssuer(options); const issuer = getIssuer(options);
const pollUrl = `${issuer}/api/accounts/deviceauth/token`; const pollUrl = `${issuer}/api/accounts/deviceauth/token`;
const timeoutMs = options.timeoutMs ?? DEVICE_CODE_TIMEOUT_MS; const timeoutMs = options.timeoutMs ?? DEVICE_CODE_TIMEOUT_MS;
@@ -207,7 +216,7 @@ export async function pollOpenAiMaxDeviceCode(
if (response.status !== 403 && response.status !== 404) { if (response.status !== 403 && response.status !== 404) {
const body = await response.text(); 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, status: response.status,
body, body,
}); });
@@ -218,13 +227,13 @@ export async function pollOpenAiMaxDeviceCode(
await sleepFunction(Math.min(deviceCode.intervalSeconds * 1000, Math.max(remaining, 0))); 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( export async function exchangeOpenAiChatGptAuthorizationCode(
authorizationCode: IOpenAiMaxAuthorizationCode, authorizationCode: IOpenAiChatGptAuthorizationCode,
options: IOpenAiMaxAuthOptions = {}, options: IOpenAiChatGptAuthOptions = {},
): Promise<IOpenAiMaxTokenData> { ): Promise<IOpenAiChatGptTokenData> {
const issuer = getIssuer(options); const issuer = getIssuer(options);
const response = await postForm(`${issuer}/oauth/token`, new URLSearchParams({ const response = await postForm(`${issuer}/oauth/token`, new URLSearchParams({
grant_type: 'authorization_code', grant_type: 'authorization_code',
@@ -232,60 +241,60 @@ export async function exchangeOpenAiMaxAuthorizationCode(
redirect_uri: `${issuer}/deviceauth/callback`, redirect_uri: `${issuer}/deviceauth/callback`,
client_id: getClientId(options), client_id: getClientId(options),
code_verifier: authorizationCode.codeVerifier, code_verifier: authorizationCode.codeVerifier,
}), options) as IOpenAiMaxTokenResponse; }), options) as IOpenAiChatGptTokenResponse;
return createTokenData(response); return createTokenData(response);
} }
export function ensureOpenAiMaxWorkspaceAllowed( export function ensureOpenAiChatGptWorkspaceAllowed(
tokenData: IOpenAiMaxTokenData, tokenData: IOpenAiChatGptTokenData,
forcedChatGptWorkspaceId?: string, forcedChatGptWorkspaceId?: string,
): void { ): void {
if (!forcedChatGptWorkspaceId) { if (!forcedChatGptWorkspaceId) {
return; return;
} }
if (tokenData.idTokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) { if (tokenData.tokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) {
throw new OpenAiMaxAuthError(`OpenAI Max login is restricted to workspace ${forcedChatGptWorkspaceId}.`); throw new OpenAiChatGptAuthError(`OpenAI ChatGPT login is restricted to workspace ${forcedChatGptWorkspaceId}.`);
} }
} }
export async function completeOpenAiMaxDeviceCodeLogin( export async function completeOpenAiChatGptDeviceCodeLogin(
deviceCode: IOpenAiMaxDeviceCode, deviceCode: IOpenAiChatGptDeviceCode,
options: IOpenAiMaxCompleteDeviceCodeOptions = {}, options: IOpenAiChatGptCompleteDeviceCodeOptions = {},
): Promise<IOpenAiMaxTokenData> { ): Promise<IOpenAiChatGptTokenData> {
const authorizationCode = await pollOpenAiMaxDeviceCode(deviceCode, options); const authorizationCode = await pollOpenAiChatGptDeviceCode(deviceCode, options);
const tokenData = await exchangeOpenAiMaxAuthorizationCode(authorizationCode, options); const tokenData = await exchangeOpenAiChatGptAuthorizationCode(authorizationCode, options);
ensureOpenAiMaxWorkspaceAllowed(tokenData, options.forcedChatGptWorkspaceId); ensureOpenAiChatGptWorkspaceAllowed(tokenData, options.forcedChatGptWorkspaceId);
return tokenData; return tokenData;
} }
export async function refreshOpenAiMaxTokenData( export async function refreshOpenAiChatGptTokenData(
tokenData: IOpenAiMaxTokenData, tokenData: IOpenAiChatGptTokenData,
options: IOpenAiMaxAuthOptions = {}, options: IOpenAiChatGptAuthOptions = {},
): Promise<IOpenAiMaxTokenData> { ): Promise<IOpenAiChatGptTokenData> {
const issuer = getIssuer(options); const issuer = getIssuer(options);
const response = await postJson(`${issuer}/oauth/token`, { const response = await postJson(`${issuer}/oauth/token`, {
client_id: getClientId(options), client_id: getClientId(options),
grant_type: 'refresh_token', grant_type: 'refresh_token',
refresh_token: tokenData.refreshToken, refresh_token: tokenData.refreshToken,
}, options) as IOpenAiMaxTokenResponse; }, options) as IOpenAiChatGptTokenResponse;
return createTokenData({ return createTokenData({
id_token: response.id_token ?? tokenData.idToken, id_token: response.id_token ?? tokenData.idToken,
access_token: response.access_token ?? tokenData.accessToken, access_token: response.access_token ?? tokenData.accessToken,
refresh_token: response.refresh_token ?? tokenData.refreshToken, refresh_token: response.refresh_token ?? tokenData.refreshToken,
}); }, tokenData);
} }
export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCredentials): { export function createOpenAiChatGptProviderSettings(credentials: IOpenAiChatGptAuthCredentials): {
apiKey: string; apiKey: string;
baseURL: string; baseURL: string;
headers: Record<string, string>; headers: Record<string, string>;
} { } {
const accountId = credentials.accountId ?? credentials.idTokenInfo?.chatgptAccountId; const accountId = credentials.accountId ?? credentials.tokenInfo?.chatgptAccountId;
const isFedrampAccount = credentials.idTokenInfo?.chatgptAccountIsFedramp === true; const isFedrampAccount = credentials.tokenInfo?.chatgptAccountIsFedramp === true;
const headers: Record<string, string> = { const headers: Record<string, string> = {
originator: credentials.originator ?? OPENAI_MAX_DEFAULT_ORIGINATOR, originator: credentials.originator ?? OPENAI_CHATGPT_DEFAULT_ORIGINATOR,
}; };
if (accountId) { if (accountId) {
@@ -297,7 +306,7 @@ export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCrede
return { return {
apiKey: credentials.accessToken, apiKey: credentials.accessToken,
baseURL: credentials.baseUrl ?? OPENAI_MAX_CODEX_BASE_URL, baseURL: credentials.baseUrl ?? OPENAI_CHATGPT_CODEX_BASE_URL,
headers, headers,
}; };
} }
+11 -4
View File
@@ -2,7 +2,8 @@ import * as plugins from './plugins.js';
import type { ISmartAiModelSetup, ISmartAiOptions, LanguageModelV3 } from './smartai.interfaces.js'; import type { ISmartAiModelSetup, ISmartAiOptions, LanguageModelV3 } from './smartai.interfaces.js';
import { createOllamaModel } from './smartai.provider.ollama.js'; import { createOllamaModel } from './smartai.provider.ollama.js';
import { createAnthropicCachingMiddleware } from './smartai.middleware.anthropic.js'; import { createAnthropicCachingMiddleware } from './smartai.middleware.anthropic.js';
import { createOpenAiMaxProviderSettings } from './smartai.auth.openai.js'; import { createOpenAiChatGptInstructionsMiddleware } from './smartai.middleware.openai.js';
import { createOpenAiChatGptProviderSettings } from './smartai.auth.openai.js';
/** /**
* Returns a LanguageModelV3 for the given provider and model. * Returns a LanguageModelV3 for the given provider and model.
@@ -24,11 +25,17 @@ export function getModel(options: ISmartAiOptions): LanguageModelV3 {
} }
case 'openai': { case 'openai': {
const p = plugins.createOpenAI( const p = plugins.createOpenAI(
options.openAiMaxAuth options.openAiChatGptAuth
? createOpenAiMaxProviderSettings(options.openAiMaxAuth) ? createOpenAiChatGptProviderSettings(options.openAiChatGptAuth)
: { apiKey: options.apiKey }, : { apiKey: options.apiKey },
); );
return p(options.model) as LanguageModelV3; const base = p(options.model) as LanguageModelV3;
return options.openAiChatGptAuth
? plugins.wrapLanguageModel({
model: base,
middleware: createOpenAiChatGptInstructionsMiddleware(),
}) as unknown as LanguageModelV3
: base;
} }
case 'google': { case 'google': {
const p = plugins.createGoogleGenerativeAI({ apiKey: options.apiKey }); const p = plugins.createGoogleGenerativeAI({ apiKey: options.apiKey });
+10 -11
View File
@@ -15,7 +15,7 @@ export type TOpenAiReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'hi
export type TOpenAiTextVerbosity = 'low' | 'medium' | 'high'; export type TOpenAiTextVerbosity = 'low' | 'medium' | 'high';
export interface IOpenAiMaxIdTokenInfo { export interface IOpenAiChatGptTokenInfo {
email?: string; email?: string;
chatgptPlanType?: string; chatgptPlanType?: string;
chatgptUserId?: string; chatgptUserId?: string;
@@ -25,41 +25,40 @@ export interface IOpenAiMaxIdTokenInfo {
rawJwt: string; rawJwt: string;
} }
export interface IOpenAiMaxAuthCredentials { export interface IOpenAiChatGptAuthCredentials {
accessToken: string; accessToken: string;
refreshToken?: string; refreshToken?: string;
idToken?: string; idToken?: string;
accountId?: string; accountId?: string;
idTokenInfo?: IOpenAiMaxIdTokenInfo; tokenInfo?: IOpenAiChatGptTokenInfo;
baseUrl?: string; baseUrl?: string;
originator?: string; originator?: string;
} }
export interface IOpenAiMaxTokenData extends IOpenAiMaxAuthCredentials { export interface IOpenAiChatGptTokenData extends IOpenAiChatGptAuthCredentials {
refreshToken: string; refreshToken: string;
idToken: string; tokenInfo: IOpenAiChatGptTokenInfo;
idTokenInfo: IOpenAiMaxIdTokenInfo;
} }
export interface IOpenAiMaxDeviceCode { export interface IOpenAiChatGptDeviceCode {
verificationUrl: string; verificationUrl: string;
userCode: string; userCode: string;
deviceAuthId: string; deviceAuthId: string;
intervalSeconds: number; intervalSeconds: number;
} }
export interface IOpenAiMaxAuthOptions { export interface IOpenAiChatGptAuthOptions {
issuer?: string; issuer?: string;
clientId?: string; clientId?: string;
fetch?: typeof fetch; fetch?: typeof fetch;
} }
export interface IOpenAiMaxDeviceCodePollOptions extends IOpenAiMaxAuthOptions { export interface IOpenAiChatGptDeviceCodePollOptions extends IOpenAiChatGptAuthOptions {
timeoutMs?: number; timeoutMs?: number;
sleep?: (ms: number) => Promise<void>; sleep?: (ms: number) => Promise<void>;
} }
export interface IOpenAiMaxCompleteDeviceCodeOptions extends IOpenAiMaxDeviceCodePollOptions { export interface IOpenAiChatGptCompleteDeviceCodeOptions extends IOpenAiChatGptDeviceCodePollOptions {
forcedChatGptWorkspaceId?: string; forcedChatGptWorkspaceId?: string;
} }
@@ -108,7 +107,7 @@ export interface ISmartAiOptions {
* OpenAI ChatGPT/Codex subscription credentials from the device-code auth flow. * OpenAI ChatGPT/Codex subscription credentials from the device-code auth flow.
* Only used when provider === 'openai'. * Only used when provider === 'openai'.
*/ */
openAiMaxAuth?: IOpenAiMaxAuthCredentials; openAiChatGptAuth?: IOpenAiChatGptAuthCredentials;
/** /**
* Provider-specific AI SDK generation options. * Provider-specific AI SDK generation options.
* Pass this to generateText()/streamText() alongside the model. * Pass this to generateText()/streamText() alongside the model.
+55
View File
@@ -0,0 +1,55 @@
import type { JSONObject, LanguageModelV3CallOptions, LanguageModelV3Middleware } from '@ai-sdk/provider';
const isNonEmptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
const getSystemInstructions = (prompt: LanguageModelV3CallOptions['prompt']): string | undefined => {
const instructions = prompt
.filter((message) => message.role === 'system')
.map((message) => message.content)
.filter(isNonEmptyString);
return instructions.length > 0 ? instructions.join('\n') : undefined;
};
const hasUnsupportedChatGptCacheRetention = (options: JSONObject): boolean => {
return options.promptCacheRetention !== undefined || options.prompt_cache_retention !== undefined;
};
/**
* ChatGPT's Codex backend requires top-level Responses API instructions.
* The standard OpenAI provider otherwise serializes system prompts as input items.
*/
export function createOpenAiChatGptInstructionsMiddleware(): LanguageModelV3Middleware {
return {
specificationVersion: 'v3',
transformParams: async ({ params }) => {
const instructions = getSystemInstructions(params.prompt);
const providerOptions = params.providerOptions ?? {};
const openAiProviderOptions = providerOptions.openai ?? {};
const shouldApplyInstructions = !!instructions && !isNonEmptyString(openAiProviderOptions.instructions);
const shouldStripCacheRetention = hasUnsupportedChatGptCacheRetention(openAiProviderOptions);
if (!shouldApplyInstructions && !shouldStripCacheRetention) {
return params;
}
const nextOpenAiProviderOptions: JSONObject = { ...openAiProviderOptions };
delete nextOpenAiProviderOptions.promptCacheRetention;
delete nextOpenAiProviderOptions.prompt_cache_retention;
if (shouldApplyInstructions) {
nextOpenAiProviderOptions.instructions = instructions;
}
return {
...params,
prompt: shouldApplyInstructions
? params.prompt.filter((message) => message.role !== 'system')
: params.prompt,
providerOptions: {
...providerOptions,
openai: nextOpenAiProviderOptions,
},
} satisfies LanguageModelV3CallOptions;
},
};
}
+192
View File
@@ -0,0 +1,192 @@
export type TSmartAiOcrImageMimeType =
| 'image/png'
| 'image/jpeg'
| 'image/webp'
| 'image/gif'
| string;
export type TSmartAiMistralOcrTableFormat = 'markdown' | 'html';
export type TSmartAiMistralOcrConfidenceScoresGranularity = 'page' | 'word';
export interface ISmartAiOcrImageInput {
dataBase64: string;
mimeType: TSmartAiOcrImageMimeType;
}
export interface ISmartAiOcrPageResult {
index: number;
text: string;
confidence?: number;
}
export interface ISmartAiOcrResult<TRaw = unknown> {
text: string;
confidence?: number;
pages: ISmartAiOcrPageResult[];
raw: TRaw;
}
export interface ISmartAiOcrEngine {
recognizeImage: (
input: ISmartAiOcrImageInput,
options?: ISmartAiMistralOcrRecognizeOptions
) => Promise<ISmartAiOcrResult<IMistralOcrResponse>>;
}
export interface IMistralOcrPageConfidenceScores {
average_page_confidence_score?: number;
averagePageConfidenceScore?: number;
minimum_page_confidence_score?: number;
minimumPageConfidenceScore?: number;
}
export interface IMistralOcrPageResponse {
index: number;
markdown: string;
confidence_scores?: IMistralOcrPageConfidenceScores | null;
confidenceScores?: IMistralOcrPageConfidenceScores | null;
}
export interface IMistralOcrResponse {
pages: IMistralOcrPageResponse[];
model: string;
document_annotation?: unknown;
documentAnnotation?: unknown;
usage_info?: unknown;
usageInfo?: unknown;
}
export interface IMistralOcrRequest {
model: string;
document: {
type: 'image_url';
image_url: string;
};
include_image_base64?: boolean;
table_format?: TSmartAiMistralOcrTableFormat;
extract_header?: boolean;
extract_footer?: boolean;
confidence_scores_granularity?: TSmartAiMistralOcrConfidenceScoresGranularity;
}
export interface ISmartAiMistralOcrTransport {
process: (request: IMistralOcrRequest) => Promise<IMistralOcrResponse>;
}
export interface ISmartAiMistralOcrOptions {
apiKey?: string;
model?: string;
endpointUrl?: string;
transport?: ISmartAiMistralOcrTransport;
includeImageBase64?: boolean;
tableFormat?: TSmartAiMistralOcrTableFormat;
extractHeader?: boolean;
extractFooter?: boolean;
confidenceScoresGranularity?: TSmartAiMistralOcrConfidenceScoresGranularity;
}
export interface ISmartAiMistralOcrRecognizeOptions {
includeImageBase64?: boolean;
tableFormat?: TSmartAiMistralOcrTableFormat;
extractHeader?: boolean;
extractFooter?: boolean;
confidenceScoresGranularity?: TSmartAiMistralOcrConfidenceScoresGranularity;
}
const defaultMistralOcrModel = 'mistral-ocr-latest';
const defaultMistralOcrEndpointUrl = 'https://api.mistral.ai/v1/ocr';
const createMistralOcrHttpTransport = (options: {
apiKey?: string;
endpointUrl?: string;
}): ISmartAiMistralOcrTransport => {
return {
process: async (request) => {
if (!options.apiKey) {
throw new Error('Mistral OCR requires an apiKey when no custom transport is provided.');
}
const response = await fetch(options.endpointUrl ?? defaultMistralOcrEndpointUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${options.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(request),
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`Mistral OCR request failed with status ${response.status}: ${errorBody}`);
}
return (await response.json()) as IMistralOcrResponse;
},
};
};
const getPageConfidence = (page: IMistralOcrPageResponse): number | undefined => {
const confidenceScores = page.confidence_scores ?? page.confidenceScores;
return (
confidenceScores?.average_page_confidence_score ??
confidenceScores?.averagePageConfidenceScore
);
};
export const createMistralOcrEngine = (
options: ISmartAiMistralOcrOptions = {}
): ISmartAiOcrEngine => {
const transport =
options.transport ??
createMistralOcrHttpTransport({
apiKey: options.apiKey,
endpointUrl: options.endpointUrl,
});
const model = options.model ?? defaultMistralOcrModel;
return {
recognizeImage: async (input, recognizeOptions = {}) => {
if (!input.dataBase64) {
throw new Error('Mistral OCR image input requires dataBase64.');
}
if (!input.mimeType) {
throw new Error('Mistral OCR image input requires mimeType.');
}
const response = await transport.process({
model,
document: {
type: 'image_url',
image_url: `data:${input.mimeType};base64,${input.dataBase64}`,
},
include_image_base64:
recognizeOptions.includeImageBase64 ?? options.includeImageBase64 ?? false,
table_format: recognizeOptions.tableFormat ?? options.tableFormat,
extract_header: recognizeOptions.extractHeader ?? options.extractHeader,
extract_footer: recognizeOptions.extractFooter ?? options.extractFooter,
confidence_scores_granularity:
recognizeOptions.confidenceScoresGranularity ?? options.confidenceScoresGranularity,
});
const pages = response.pages.map((page) => ({
index: page.index,
text: page.markdown,
confidence: getPageConfidence(page),
}));
const pageConfidences = pages
.map((page) => page.confidence)
.filter((confidence): confidence is number => typeof confidence === 'number');
const confidence = pageConfidences.length
? pageConfidences.reduce((sum, value) => sum + value, 0) / pageConfidences.length
: undefined;
return {
text: pages.map((page) => page.text).join('\n\n').trim(),
confidence,
pages,
raw: response,
};
},
};
};
+351
View File
@@ -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;
};