Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e2053f538 | |||
| c8f98b3364 | |||
| 8a6c92c04e | |||
| 10587998f2 | |||
| c3664ba57f | |||
| 9ee5990321 |
@@ -11,12 +11,17 @@
|
||||
"projectDomain": "push.rocks"
|
||||
},
|
||||
"release": {
|
||||
"targets": {
|
||||
"npm": {
|
||||
"registries": [
|
||||
"https://verdaccio.lossless.digital",
|
||||
"https://registry.npmjs.org"
|
||||
],
|
||||
"accessLevel": "public"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schemaVersion": 2
|
||||
},
|
||||
"@git.zone/tsdoc": {
|
||||
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## Pending
|
||||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
### Features
|
||||
|
||||
- add OpenAI Max device-code authentication and unified prompt caching helpers (openai-auth)
|
||||
- adds OpenAI Max device-code login, token refresh, and ChatGPT Codex provider routing for OpenAI models
|
||||
- introduces reusable SmartAI cache helpers and middleware for prompt/message cache provider options across providers
|
||||
- updates Anthropic prompt caching to use opencode-style breakpoint selection with optional longer retention
|
||||
- exports the new auth and caching APIs and adds tests and documentation for both features
|
||||
|
||||
## 2026-05-06 - 2.2.0 - feat(openai)
|
||||
add getModelSetup() and typed provider options for OpenAI reasoning settings
|
||||
|
||||
|
||||
+20
-15
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartai",
|
||||
"version": "2.2.0",
|
||||
"version": "4.0.1",
|
||||
"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",
|
||||
@@ -41,30 +45,30 @@
|
||||
"buildDocs": "(tsdoc)"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@git.zone/tsbuild": "^4.4.0",
|
||||
"@git.zone/tsrun": "^2.0.3",
|
||||
"@git.zone/tstest": "^3.6.3",
|
||||
"@git.zone/tsbuild": "^4.4.1",
|
||||
"@git.zone/tsrun": "^2.0.4",
|
||||
"@git.zone/tstest": "^3.6.6",
|
||||
"@push.rocks/qenv": "^6.1.4",
|
||||
"@types/json-schema": "^7.0.15",
|
||||
"@types/lodash.clonedeep": "^4.5.9",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"typescript": "^6.0.3",
|
||||
"undici-types": "^8.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.75",
|
||||
"@ai-sdk/google": "^3.0.67",
|
||||
"@ai-sdk/groq": "^3.0.38",
|
||||
"@ai-sdk/mistral": "^3.0.35",
|
||||
"@ai-sdk/openai": "^3.0.62",
|
||||
"@ai-sdk/perplexity": "^3.0.32",
|
||||
"@ai-sdk/anthropic": "^3.0.77",
|
||||
"@ai-sdk/google": "^3.0.73",
|
||||
"@ai-sdk/groq": "^3.0.39",
|
||||
"@ai-sdk/mistral": "^3.0.36",
|
||||
"@ai-sdk/openai": "^3.0.63",
|
||||
"@ai-sdk/perplexity": "^3.0.33",
|
||||
"@ai-sdk/provider": "^3.0.10",
|
||||
"@ai-sdk/xai": "^3.0.88",
|
||||
"@anthropic-ai/sdk": "0.95.0",
|
||||
"@ai-sdk/xai": "^3.0.89",
|
||||
"@anthropic-ai/sdk": "0.95.2",
|
||||
"@push.rocks/smartpdf": "^4.2.2",
|
||||
"ai": "^6.0.175",
|
||||
"openai": "^6.36.0"
|
||||
"ai": "^6.0.180",
|
||||
"openai": "^6.37.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -84,6 +88,7 @@
|
||||
"ts_image/**/*",
|
||||
"ts_document/**/*",
|
||||
"ts_research/**/*",
|
||||
"ts_openai_chatgpt_auth/**/*",
|
||||
"dist_*/**/*",
|
||||
"assets/**/*",
|
||||
".smartconfig.json",
|
||||
|
||||
Generated
+1048
-453
File diff suppressed because it is too large
Load Diff
@@ -10,6 +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/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
|
||||
@@ -43,6 +45,7 @@ The package is a **provider registry** built on the Vercel AI SDK (`ai` v6). The
|
||||
- `qenv.getEnvVarOnDemand()` returns a Promise — must be awaited in tests
|
||||
- OpenAI reasoning options belong in AI SDK `providerOptions`, not model construction options
|
||||
- SmartAI accepts OpenAI model IDs as plain strings, including `gpt-5.5`
|
||||
- ChatGPT-authenticated OpenAI model calls use `https://chatgpt.com/backend-api/codex` plus bearer access token and `ChatGPT-Account-ID`, not `https://api.openai.com/v1`
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
||||
- **🔌 One function, eight providers** — `getModel()` returns a standard `LanguageModelV3`. Switch providers by changing a string.
|
||||
- **🧱 Built on Vercel AI SDK** — Uses `ai` v6 under the hood. Your model works with `generateText()`, `streamText()`, tool calling, structured output, and everything else in the AI SDK ecosystem.
|
||||
- **🏠 Custom Ollama provider** — A full `LanguageModelV3` implementation for Ollama with support for `think` mode, `num_ctx`, auto-tuned temperature for Qwen models, and native tool calling.
|
||||
- **💰 Anthropic prompt caching** — Automatic `cacheControl` middleware reduces cost and latency on repeated calls. Enabled by default, opt out with `promptCaching: false`.
|
||||
- **💰 Prompt caching** — Anthropic cache-control middleware is enabled by default; provider cache helpers are available for agent/session integrations.
|
||||
- **📦 Modular subpath exports** — Vision, audio, image, document, and research capabilities ship as separate imports. Only import what you need.
|
||||
- **⚡ Zero lock-in** — Your code uses standard AI SDK types. Swap providers without touching application logic.
|
||||
|
||||
@@ -107,6 +107,61 @@ 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 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 `openAiChatGptAuth`; SmartAI then routes OpenAI model calls through the ChatGPT Codex backend with the required account headers.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
completeOpenAiChatGptDeviceCodeLogin,
|
||||
getModel,
|
||||
requestOpenAiChatGptDeviceCode,
|
||||
} from '@push.rocks/smartai';
|
||||
|
||||
const deviceCode = await requestOpenAiChatGptDeviceCode();
|
||||
console.log(`Open ${deviceCode.verificationUrl} and enter ${deviceCode.userCode}`);
|
||||
|
||||
const openAiChatGptAuth = await completeOpenAiChatGptDeviceCodeLogin(deviceCode);
|
||||
const model = getModel({
|
||||
provider: 'openai',
|
||||
model: 'gpt-5.5',
|
||||
openAiChatGptAuth,
|
||||
});
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
SmartAI re-exports the most commonly used functions from `ai` for convenience:
|
||||
@@ -250,9 +305,9 @@ console.log(result.text);
|
||||
- **Streaming with reasoning** — `doStream()` emits proper `reasoning-start`, `reasoning-delta`, `reasoning-end` parts alongside text.
|
||||
- **All Ollama options** — `num_ctx`, `top_k`, `top_p`, `repeat_penalty`, `num_predict`, `stop`, `seed`.
|
||||
|
||||
## 💰 Anthropic Prompt Caching
|
||||
## 💰 Prompt Caching
|
||||
|
||||
When using the Anthropic provider, SmartAI automatically wraps the model with caching middleware that adds `cacheControl: { type: 'ephemeral' }` to the last system message and last user message. This can significantly reduce cost and latency for repeated calls with the same system prompt.
|
||||
When using the Anthropic provider, SmartAI automatically wraps the model with caching middleware. The middleware follows the same breakpoint strategy used by opencode: cache the first two system messages and the two most recent non-system messages. This can significantly reduce cost and latency for repeated agent calls with stable system/tool context.
|
||||
|
||||
```typescript
|
||||
// Caching enabled by default
|
||||
@@ -271,6 +326,17 @@ const modelNoCaching = getModel({
|
||||
});
|
||||
```
|
||||
|
||||
Longer Anthropic cache TTL is opt-in:
|
||||
|
||||
```typescript
|
||||
const modelWithOneHourCache = getModel({
|
||||
provider: 'anthropic',
|
||||
model: 'claude-sonnet-4-5-20250929',
|
||||
apiKey: process.env.ANTHROPIC_TOKEN,
|
||||
promptCaching: { retention: '1h' },
|
||||
});
|
||||
```
|
||||
|
||||
You can also use the middleware directly:
|
||||
|
||||
```typescript
|
||||
@@ -281,6 +347,23 @@ const middleware = createAnthropicCachingMiddleware();
|
||||
const cachedModel = wrapLanguageModel({ model: baseModel, middleware });
|
||||
```
|
||||
|
||||
For agent frameworks, SmartAI exports lower-level helpers:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
applySmartAiCacheProviderOptions,
|
||||
createSmartAiCachingMiddleware,
|
||||
} from '@push.rocks/smartai';
|
||||
|
||||
const providerOptions = applySmartAiCacheProviderOptions({
|
||||
provider: 'openai',
|
||||
sessionId: 'stable-session-id',
|
||||
cache: 'auto',
|
||||
});
|
||||
```
|
||||
|
||||
OpenAI request-level cache affinity is only added when a stable `sessionId` or explicit cache `key` is provided. Extended OpenAI retention (`'24h'`) is opt-in.
|
||||
|
||||
## 📦 Subpath Exports
|
||||
|
||||
SmartAI provides specialized capabilities as separate subpath imports. Each one is a focused utility that takes a model (or API key) and does one thing well.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
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 { IOpenAiChatGptTokenData } from '../ts/index.js';
|
||||
import {
|
||||
inspectOpenAiChatGptAuthSources,
|
||||
normalizeOpenAiChatGptAuth,
|
||||
resolveOpenAiChatGptAuth,
|
||||
writeOpenAiChatGptAuthFile,
|
||||
} from '../ts_openai_chatgpt_auth/index.js';
|
||||
|
||||
interface IMockFetchRequest {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
}
|
||||
|
||||
function createJwt(payload: Record<string, unknown>): string {
|
||||
const encode = (value: Record<string, unknown>) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.sig`;
|
||||
}
|
||||
|
||||
function createTokenData(accountId = 'workspace-1', expiresAtSeconds = 4_102_444_800): IOpenAiChatGptTokenData {
|
||||
const accessToken = createJwt({
|
||||
email: 'user@example.com',
|
||||
exp: expiresAtSeconds,
|
||||
'https://api.openai.com/auth': {
|
||||
chatgpt_plan_type: 'pro',
|
||||
chatgpt_user_id: 'user-1',
|
||||
chatgpt_account_id: accountId,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
},
|
||||
});
|
||||
const tokenInfo = smartai.parseOpenAiChatGptTokenInfo(accessToken);
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken: 'refresh-token',
|
||||
accountId,
|
||||
tokenInfo,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
function getHeader(init: RequestInit | undefined, name: string): string | null {
|
||||
return new Headers(init?.headers).get(name);
|
||||
}
|
||||
|
||||
tap.test('requestOpenAiChatGptDeviceCode requests a user code', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: IMockFetchRequest[] = [];
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({ url: String(input), init });
|
||||
return jsonResponse({
|
||||
device_auth_id: 'device-1',
|
||||
usercode: 'ABCD-EFGH',
|
||||
interval: '2',
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const deviceCode = await smartai.requestOpenAiChatGptDeviceCode({
|
||||
issuer: 'https://auth.example.test',
|
||||
clientId: 'client-1',
|
||||
});
|
||||
|
||||
expect(deviceCode).toEqual({
|
||||
verificationUrl: 'https://auth.example.test/codex/device',
|
||||
userCode: 'ABCD-EFGH',
|
||||
deviceAuthId: 'device-1',
|
||||
intervalSeconds: 2,
|
||||
});
|
||||
expect(requests[0].url).toEqual('https://auth.example.test/api/accounts/deviceauth/usercode');
|
||||
expect(JSON.parse(String(requests[0].init?.body))).toEqual({ client_id: 'client-1' });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('completeOpenAiChatGptDeviceCodeLogin polls and exchanges OAuth tokens', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: IMockFetchRequest[] = [];
|
||||
const tokenData = createTokenData('workspace-1');
|
||||
const responses = [
|
||||
jsonResponse({}, 403),
|
||||
jsonResponse({
|
||||
authorization_code: 'auth-code',
|
||||
code_challenge: 'challenge',
|
||||
code_verifier: 'verifier',
|
||||
}),
|
||||
jsonResponse({
|
||||
access_token: tokenData.accessToken,
|
||||
refresh_token: tokenData.refreshToken,
|
||||
}),
|
||||
];
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
requests.push({ url: String(input), init });
|
||||
const response = responses.shift();
|
||||
if (!response) throw new Error('Unexpected fetch call');
|
||||
return response;
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await smartai.completeOpenAiChatGptDeviceCodeLogin({
|
||||
verificationUrl: 'https://auth.example.test/codex/device',
|
||||
userCode: 'ABCD-EFGH',
|
||||
deviceAuthId: 'device-1',
|
||||
intervalSeconds: 1,
|
||||
}, {
|
||||
issuer: 'https://auth.example.test',
|
||||
clientId: 'client-1',
|
||||
forcedChatGptWorkspaceId: 'workspace-1',
|
||||
sleep: async () => undefined,
|
||||
});
|
||||
|
||||
expect(result.accessToken).toEqual(tokenData.accessToken);
|
||||
expect(result.refreshToken).toEqual('refresh-token');
|
||||
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',
|
||||
user_code: 'ABCD-EFGH',
|
||||
});
|
||||
const tokenExchangeBody = new URLSearchParams(String(requests[2].init?.body));
|
||||
expect(tokenExchangeBody.get('grant_type')).toEqual('authorization_code');
|
||||
expect(tokenExchangeBody.get('code')).toEqual('auth-code');
|
||||
expect(tokenExchangeBody.get('redirect_uri')).toEqual('https://auth.example.test/deviceauth/callback');
|
||||
expect(tokenExchangeBody.get('client_id')).toEqual('client-1');
|
||||
expect(tokenExchangeBody.get('code_verifier')).toEqual('verifier');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
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: refreshedToken });
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await smartai.refreshOpenAiChatGptTokenData(tokenData, {
|
||||
issuer: 'https://auth.example.test',
|
||||
clientId: 'client-1',
|
||||
});
|
||||
|
||||
expect(result.accessToken).toEqual(refreshedToken);
|
||||
expect(result.refreshToken).toEqual('refresh-token');
|
||||
expect(JSON.parse(String(requests[0].init?.body))).toEqual({
|
||||
client_id: 'client-1',
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: 'refresh-token',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
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',
|
||||
openAiChatGptAuth: tokenData,
|
||||
});
|
||||
|
||||
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedRequest = { url: String(input), init };
|
||||
return jsonResponse({
|
||||
id: 'resp-1',
|
||||
created_at: 1,
|
||||
model: 'gpt-5.5',
|
||||
output: [{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
id: 'msg-1',
|
||||
content: [{ type: 'output_text', text: 'ok', annotations: [] }],
|
||||
}],
|
||||
usage: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await smartai.generateText({
|
||||
model,
|
||||
system: 'system prompt',
|
||||
prompt: 'hello',
|
||||
});
|
||||
|
||||
expect(capturedRequest?.url).toEqual('https://chatgpt.com/backend-api/codex/responses');
|
||||
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');
|
||||
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' }] },
|
||||
]);
|
||||
} finally {
|
||||
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();
|
||||
@@ -2,6 +2,7 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import { simulateReadableStream } from 'ai';
|
||||
import { MockLanguageModelV3 } from 'ai/test';
|
||||
import type { LanguageModelV3Prompt } from '@ai-sdk/provider';
|
||||
import * as smartai from '../ts/index.js';
|
||||
|
||||
const testQenv = new qenv.Qenv('./', './.nogit/');
|
||||
@@ -53,6 +54,56 @@ tap.test('getModel with anthropic prompt caching returns wrapped model', async (
|
||||
expect(modelNoCaching).toHaveProperty('doGenerate');
|
||||
});
|
||||
|
||||
tap.test('applySmartAiPromptCaching should mark opencode-style breakpoints', async () => {
|
||||
const prompt: LanguageModelV3Prompt = [
|
||||
{ role: 'system', content: 'stable system one' },
|
||||
{ role: 'system', content: 'stable system two' },
|
||||
{ role: 'system', content: 'uncached system three' },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'old user' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'recent assistant' }] },
|
||||
{ role: 'user', content: [{ type: 'text', text: 'recent user' }] },
|
||||
];
|
||||
|
||||
const cached = smartai.applySmartAiPromptCaching(prompt, { provider: 'anthropic' });
|
||||
|
||||
expect((cached[0].providerOptions as any)?.anthropic?.cacheControl?.type).toEqual('ephemeral');
|
||||
expect((cached[1].providerOptions as any)?.anthropic?.cacheControl?.type).toEqual('ephemeral');
|
||||
expect((cached[2].providerOptions as any)?.anthropic).toBeUndefined();
|
||||
expect((cached[3].providerOptions as any)?.anthropic).toBeUndefined();
|
||||
expect((cached[4].providerOptions as any)?.anthropic?.cacheControl?.type).toEqual('ephemeral');
|
||||
expect((cached[5].providerOptions as any)?.anthropic?.cacheControl?.type).toEqual('ephemeral');
|
||||
});
|
||||
|
||||
tap.test('applySmartAiPromptCaching should use content-level options for compatible providers', async () => {
|
||||
const prompt: LanguageModelV3Prompt = [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'cache this' }] },
|
||||
];
|
||||
|
||||
const cached = smartai.applySmartAiPromptCaching(prompt, { provider: 'openaiCompatible' });
|
||||
const userContent = cached[0].role === 'user' ? cached[0].content : [];
|
||||
const lastPart = userContent[0];
|
||||
|
||||
expect((lastPart.providerOptions as any)?.openaiCompatible?.cache_control?.type).toEqual('ephemeral');
|
||||
});
|
||||
|
||||
tap.test('applySmartAiCacheProviderOptions should add OpenAI cache defaults without overriding caller options', async () => {
|
||||
const providerOptions = smartai.applySmartAiCacheProviderOptions({
|
||||
provider: 'openai',
|
||||
sessionId: 'session-123',
|
||||
cache: 'auto',
|
||||
providerOptions: {
|
||||
openai: {
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(providerOptions?.openai?.store).toEqual(false);
|
||||
expect(providerOptions?.openai?.promptCacheKey).toEqual('session-123');
|
||||
expect(providerOptions?.openai?.promptCacheRetention).toEqual('in_memory');
|
||||
expect(providerOptions?.openai?.reasoningEffort).toEqual('high');
|
||||
});
|
||||
|
||||
tap.test('generateText with anthropic model', async () => {
|
||||
const apiKey = await testQenv.getEnvVarOnDemand('ANTHROPIC_TOKEN');
|
||||
if (!apiKey) {
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartai',
|
||||
version: '2.2.0',
|
||||
version: '4.0.1',
|
||||
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.'
|
||||
}
|
||||
|
||||
+39
@@ -1,6 +1,13 @@
|
||||
export { getModel, getModelSetup } from './smartai.classes.smartai.js';
|
||||
export type {
|
||||
IOpenAiProviderOptions,
|
||||
IOpenAiChatGptAuthCredentials,
|
||||
IOpenAiChatGptAuthOptions,
|
||||
IOpenAiChatGptCompleteDeviceCodeOptions,
|
||||
IOpenAiChatGptDeviceCode,
|
||||
IOpenAiChatGptDeviceCodePollOptions,
|
||||
IOpenAiChatGptTokenData,
|
||||
IOpenAiChatGptTokenInfo,
|
||||
ISmartAiModelSetup,
|
||||
ISmartAiOptions,
|
||||
TOpenAiReasoningEffort,
|
||||
@@ -9,9 +16,41 @@ export type {
|
||||
TSmartAiProviderOptions,
|
||||
IOllamaModelOptions,
|
||||
LanguageModelV3,
|
||||
LanguageModelV3Prompt,
|
||||
} from './smartai.interfaces.js';
|
||||
export { createAnthropicCachingMiddleware } from './smartai.middleware.anthropic.js';
|
||||
export {
|
||||
applySmartAiCacheProviderOptions,
|
||||
applySmartAiPromptCaching,
|
||||
createSmartAiCachingMiddleware,
|
||||
getSmartAiCacheProviderOptions,
|
||||
getSmartAiMessageCacheProviderOptions,
|
||||
mergeSmartAiProviderOptions,
|
||||
resolveSmartAiCacheProvider,
|
||||
} from './smartai.cache.js';
|
||||
export type {
|
||||
ISmartAiCacheOptions,
|
||||
TSmartAiCacheRetention,
|
||||
TSmartAiCacheSetting,
|
||||
TSmartAiMessageCacheProvider,
|
||||
} from './smartai.cache.js';
|
||||
export { createOllamaModel } from './smartai.provider.ollama.js';
|
||||
export {
|
||||
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 { IOpenAiChatGptAuthorizationCode } from './smartai.auth.openai.js';
|
||||
|
||||
// Re-export commonly used ai-sdk functions for consumer convenience
|
||||
export { generateText, streamText, tool, jsonSchema } from 'ai';
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type {
|
||||
IOpenAiChatGptAuthCredentials,
|
||||
IOpenAiChatGptAuthOptions,
|
||||
IOpenAiChatGptCompleteDeviceCodeOptions,
|
||||
IOpenAiChatGptDeviceCode,
|
||||
IOpenAiChatGptDeviceCodePollOptions,
|
||||
IOpenAiChatGptTokenData,
|
||||
IOpenAiChatGptTokenInfo,
|
||||
} from './smartai.interfaces.js';
|
||||
|
||||
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 OpenAiChatGptAuthError extends Error {
|
||||
public status?: number;
|
||||
public body?: string;
|
||||
|
||||
constructor(message: string, options: { status?: number; body?: string } = {}) {
|
||||
super(message);
|
||||
this.name = 'OpenAiChatGptAuthError';
|
||||
this.status = options.status;
|
||||
this.body = options.body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IOpenAiChatGptAuthorizationCode {
|
||||
authorizationCode: string;
|
||||
codeChallenge: string;
|
||||
codeVerifier: string;
|
||||
}
|
||||
|
||||
interface IOpenAiChatGptTokenResponse {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
}
|
||||
|
||||
function getFetch(options: IOpenAiChatGptAuthOptions): typeof fetch {
|
||||
const fetchFunction = options.fetch ?? globalThis.fetch;
|
||||
if (!fetchFunction) {
|
||||
throw new OpenAiChatGptAuthError('fetch is not available for OpenAI ChatGPT authentication.');
|
||||
}
|
||||
return fetchFunction;
|
||||
}
|
||||
|
||||
function getIssuer(options: IOpenAiChatGptAuthOptions): string {
|
||||
return (options.issuer ?? OPENAI_CHATGPT_AUTH_ISSUER).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
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 OpenAiChatGptAuthError(`OpenAI ChatGPT auth response is missing ${name}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function asOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : 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 OpenAiChatGptAuthError('OpenAI ChatGPT device-code response has an invalid interval.');
|
||||
}
|
||||
return interval;
|
||||
}
|
||||
|
||||
async function readJson(response: Response, context: string): Promise<unknown> {
|
||||
const body = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new OpenAiChatGptAuthError(`${context} failed with status ${response.status}.`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
throw new OpenAiChatGptAuthError(`${context} returned invalid JSON: ${(error as Error).message}`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: unknown, options: IOpenAiChatGptAuthOptions): Promise<unknown> {
|
||||
const response = await getFetch(options)(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return readJson(response, `POST ${url}`);
|
||||
}
|
||||
|
||||
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' },
|
||||
body: body.toString(),
|
||||
});
|
||||
return readJson(response, `POST ${url}`);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function parseJwtPayload(jwt: string): Record<string, unknown> {
|
||||
const parts = jwt.split('.');
|
||||
if (parts.length !== 3 || !parts[1]) {
|
||||
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 OpenAiChatGptAuthError(`OpenAI ChatGPT token could not be parsed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
email: asOptionalString(claims.email) ?? asOptionalString(profile?.email),
|
||||
chatgptPlanType: asOptionalString(auth?.chatgpt_plan_type),
|
||||
chatgptUserId: asOptionalString(auth?.chatgpt_user_id) ?? asOptionalString(auth?.user_id),
|
||||
chatgptAccountId: asOptionalString(auth?.chatgpt_account_id),
|
||||
chatgptAccountIsFedramp: auth?.chatgpt_account_is_fedramp === true,
|
||||
expiresAt: expiresAtSeconds ? new Date(expiresAtSeconds * 1000).toISOString() : undefined,
|
||||
rawJwt: token,
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
idToken,
|
||||
accountId: tokenInfo.chatgptAccountId,
|
||||
tokenInfo,
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
}, options) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
verificationUrl: `${issuer}/codex/device`,
|
||||
userCode: asString(response.user_code ?? response.usercode, 'user_code'),
|
||||
deviceAuthId: asString(response.device_auth_id, 'device_auth_id'),
|
||||
intervalSeconds: asIntervalSeconds(response.interval),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
const sleepFunction = options.sleep ?? sleep;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const response = await getFetch(options)(pollUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
device_auth_id: deviceCode.deviceAuthId,
|
||||
user_code: deviceCode.userCode,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const body = await readJson(response, `POST ${pollUrl}`) as Record<string, unknown>;
|
||||
return {
|
||||
authorizationCode: asString(body.authorization_code, 'authorization_code'),
|
||||
codeChallenge: asString(body.code_challenge, 'code_challenge'),
|
||||
codeVerifier: asString(body.code_verifier, 'code_verifier'),
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status !== 403 && response.status !== 404) {
|
||||
const body = await response.text();
|
||||
throw new OpenAiChatGptAuthError(`OpenAI ChatGPT device-code polling failed with status ${response.status}.`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
await response.arrayBuffer().catch(() => undefined);
|
||||
const remaining = timeoutMs - (Date.now() - startedAt);
|
||||
await sleepFunction(Math.min(deviceCode.intervalSeconds * 1000, Math.max(remaining, 0)));
|
||||
}
|
||||
|
||||
throw new OpenAiChatGptAuthError('OpenAI ChatGPT device-code login timed out.');
|
||||
}
|
||||
|
||||
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',
|
||||
code: authorizationCode.authorizationCode,
|
||||
redirect_uri: `${issuer}/deviceauth/callback`,
|
||||
client_id: getClientId(options),
|
||||
code_verifier: authorizationCode.codeVerifier,
|
||||
}), options) as IOpenAiChatGptTokenResponse;
|
||||
|
||||
return createTokenData(response);
|
||||
}
|
||||
|
||||
export function ensureOpenAiChatGptWorkspaceAllowed(
|
||||
tokenData: IOpenAiChatGptTokenData,
|
||||
forcedChatGptWorkspaceId?: string,
|
||||
): void {
|
||||
if (!forcedChatGptWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
if (tokenData.tokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) {
|
||||
throw new OpenAiChatGptAuthError(`OpenAI ChatGPT login is restricted to workspace ${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 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 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 createOpenAiChatGptProviderSettings(credentials: IOpenAiChatGptAuthCredentials): {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
headers: Record<string, string>;
|
||||
} {
|
||||
const accountId = credentials.accountId ?? credentials.tokenInfo?.chatgptAccountId;
|
||||
const isFedrampAccount = credentials.tokenInfo?.chatgptAccountIsFedramp === true;
|
||||
const headers: Record<string, string> = {
|
||||
originator: credentials.originator ?? OPENAI_CHATGPT_DEFAULT_ORIGINATOR,
|
||||
};
|
||||
|
||||
if (accountId) {
|
||||
headers['ChatGPT-Account-ID'] = accountId;
|
||||
}
|
||||
if (isFedrampAccount) {
|
||||
headers['X-OpenAI-Fedramp'] = 'true';
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: credentials.accessToken,
|
||||
baseURL: credentials.baseUrl ?? OPENAI_CHATGPT_CODEX_BASE_URL,
|
||||
headers,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import type { JSONObject, JSONValue, LanguageModelV3Middleware, LanguageModelV3Prompt } from '@ai-sdk/provider';
|
||||
import type { TSmartAiProviderOptions } from './smartai.interfaces.js';
|
||||
|
||||
export type TSmartAiMessageCacheProvider =
|
||||
| 'anthropic'
|
||||
| 'openrouter'
|
||||
| 'bedrock'
|
||||
| 'openaiCompatible'
|
||||
| 'copilot'
|
||||
| 'alibaba';
|
||||
|
||||
export type TSmartAiCacheRetention = 'ephemeral' | '1h' | 'in_memory' | '24h';
|
||||
|
||||
export interface ISmartAiCacheOptions {
|
||||
/** Provider-specific message cache marker namespace. Usually inferred from the model. */
|
||||
provider?: TSmartAiMessageCacheProvider;
|
||||
/** Stable session/request key for providers that support request-level prompt cache affinity. */
|
||||
key?: string;
|
||||
/** Short retention is the default; longer retention is opt-in. */
|
||||
retention?: TSmartAiCacheRetention;
|
||||
}
|
||||
|
||||
export type TSmartAiCacheSetting = boolean | 'auto' | ISmartAiCacheOptions;
|
||||
|
||||
function isObject(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === 'object' && input !== null && !Array.isArray(input);
|
||||
}
|
||||
|
||||
function mergeJsonDefaults(defaults: JSONObject, overrides?: JSONObject): JSONObject {
|
||||
const result: JSONObject = { ...defaults };
|
||||
|
||||
if (!overrides) return result;
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
const existing = result[key];
|
||||
if (isObject(existing) && isObject(value)) {
|
||||
result[key] = mergeJsonDefaults(existing as JSONObject, value as JSONObject);
|
||||
continue;
|
||||
}
|
||||
result[key] = value as JSONValue;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mergeSmartAiProviderOptions(
|
||||
defaults?: TSmartAiProviderOptions,
|
||||
overrides?: TSmartAiProviderOptions,
|
||||
): TSmartAiProviderOptions | undefined {
|
||||
if (!defaults) return overrides;
|
||||
if (!overrides) return defaults;
|
||||
return mergeJsonDefaults(defaults as JSONObject, overrides as JSONObject) as TSmartAiProviderOptions;
|
||||
}
|
||||
|
||||
function cacheOptionsFromSetting(cache: TSmartAiCacheSetting | undefined): ISmartAiCacheOptions | undefined {
|
||||
if (cache === false) return undefined;
|
||||
if (cache === undefined || cache === true || cache === 'auto') return {};
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function resolveSmartAiCacheProvider(provider?: string, modelId?: string): TSmartAiMessageCacheProvider | undefined {
|
||||
const providerLower = provider?.toLowerCase() ?? '';
|
||||
const modelLower = modelId?.toLowerCase() ?? '';
|
||||
|
||||
if (providerLower.includes('openrouter')) return 'openrouter';
|
||||
if (providerLower.includes('bedrock')) return 'bedrock';
|
||||
if (providerLower.includes('copilot')) return 'copilot';
|
||||
if (providerLower.includes('alibaba')) return 'alibaba';
|
||||
if (providerLower.includes('openai-compatible') || providerLower.includes('openaicompatible')) {
|
||||
return 'openaiCompatible';
|
||||
}
|
||||
if (providerLower.includes('anthropic')) return 'anthropic';
|
||||
if (modelLower.includes('claude') || modelLower.includes('anthropic')) return 'anthropic';
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getSmartAiMessageCacheProviderOptions(
|
||||
provider: TSmartAiMessageCacheProvider,
|
||||
options: ISmartAiCacheOptions = {},
|
||||
): TSmartAiProviderOptions {
|
||||
const anthropicCacheControl: JSONObject = {
|
||||
type: 'ephemeral',
|
||||
...(options.retention === '1h' ? { ttl: '1h' } : {}),
|
||||
};
|
||||
|
||||
const providerOptions: Record<TSmartAiMessageCacheProvider, JSONObject> = {
|
||||
anthropic: {
|
||||
anthropic: {
|
||||
cacheControl: anthropicCacheControl,
|
||||
},
|
||||
},
|
||||
openrouter: {
|
||||
openrouter: {
|
||||
cacheControl: { type: 'ephemeral' },
|
||||
},
|
||||
},
|
||||
bedrock: {
|
||||
bedrock: {
|
||||
cachePoint: { type: 'default' },
|
||||
},
|
||||
},
|
||||
openaiCompatible: {
|
||||
openaiCompatible: {
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
},
|
||||
copilot: {
|
||||
copilot: {
|
||||
copilot_cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
},
|
||||
alibaba: {
|
||||
alibaba: {
|
||||
cacheControl: { type: 'ephemeral' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return providerOptions[provider] as TSmartAiProviderOptions;
|
||||
}
|
||||
|
||||
function shouldUseMessageLevelOptions(provider: TSmartAiMessageCacheProvider): boolean {
|
||||
return provider === 'anthropic' || provider === 'bedrock';
|
||||
}
|
||||
|
||||
function applyProviderOptionsDefaults<T extends { providerOptions?: TSmartAiProviderOptions }>(
|
||||
item: T,
|
||||
defaults: TSmartAiProviderOptions,
|
||||
): T {
|
||||
return {
|
||||
...item,
|
||||
providerOptions: mergeSmartAiProviderOptions(defaults, item.providerOptions),
|
||||
};
|
||||
}
|
||||
|
||||
function isToolApprovalPart(part: unknown): boolean {
|
||||
if (!isObject(part)) return false;
|
||||
return part.type === 'tool-approval-request' || part.type === 'tool-approval-response';
|
||||
}
|
||||
|
||||
function applyCacheToMessage(
|
||||
message: LanguageModelV3Prompt[number],
|
||||
provider: TSmartAiMessageCacheProvider,
|
||||
options: ISmartAiCacheOptions,
|
||||
): LanguageModelV3Prompt[number] {
|
||||
const providerOptions = getSmartAiMessageCacheProviderOptions(provider, options);
|
||||
const content = message.content;
|
||||
|
||||
if (!shouldUseMessageLevelOptions(provider) && Array.isArray(content) && content.length > 0) {
|
||||
const lastIndex = content.length - 1;
|
||||
const lastPart = content[lastIndex];
|
||||
if (!isToolApprovalPart(lastPart)) {
|
||||
const messageWithArrayContent = message as Extract<LanguageModelV3Prompt[number], { content: unknown[] }>;
|
||||
return {
|
||||
...messageWithArrayContent,
|
||||
content: content.map((part, index) =>
|
||||
index === lastIndex ? applyProviderOptionsDefaults(part, providerOptions) : part,
|
||||
) as typeof messageWithArrayContent.content,
|
||||
} as LanguageModelV3Prompt[number];
|
||||
}
|
||||
}
|
||||
|
||||
return applyProviderOptionsDefaults(message, providerOptions);
|
||||
}
|
||||
|
||||
export function applySmartAiPromptCaching(
|
||||
prompt: LanguageModelV3Prompt,
|
||||
options: ISmartAiCacheOptions = {},
|
||||
): LanguageModelV3Prompt {
|
||||
const provider = options.provider ?? 'anthropic';
|
||||
const targetIndexes = new Set<number>();
|
||||
const nonSystemIndexes: number[] = [];
|
||||
let systemCount = 0;
|
||||
|
||||
for (let i = 0; i < prompt.length; i++) {
|
||||
const message = prompt[i];
|
||||
if (message.role === 'system') {
|
||||
if (systemCount < 2) targetIndexes.add(i);
|
||||
systemCount++;
|
||||
continue;
|
||||
}
|
||||
nonSystemIndexes.push(i);
|
||||
}
|
||||
|
||||
for (const index of nonSystemIndexes.slice(-2)) {
|
||||
targetIndexes.add(index);
|
||||
}
|
||||
|
||||
if (targetIndexes.size === 0) return prompt;
|
||||
|
||||
return prompt.map((message, index) =>
|
||||
targetIndexes.has(index) ? applyCacheToMessage(message, provider, options) : message,
|
||||
) as LanguageModelV3Prompt;
|
||||
}
|
||||
|
||||
export function createSmartAiCachingMiddleware(options: ISmartAiCacheOptions = {}): LanguageModelV3Middleware {
|
||||
return {
|
||||
specificationVersion: 'v3',
|
||||
transformParams: async ({ params }) => ({
|
||||
...params,
|
||||
prompt: applySmartAiPromptCaching(params.prompt, options),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenAiProvider(provider?: string): boolean {
|
||||
const providerLower = provider?.toLowerCase() ?? '';
|
||||
return providerLower === 'openai' || providerLower.startsWith('openai.') || providerLower.includes('@ai-sdk/openai');
|
||||
}
|
||||
|
||||
export function getSmartAiCacheProviderOptions(input: {
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
cache?: TSmartAiCacheSetting;
|
||||
sessionId?: string;
|
||||
}): TSmartAiProviderOptions | undefined {
|
||||
const cacheOptions = cacheOptionsFromSetting(input.cache);
|
||||
if (!cacheOptions) return undefined;
|
||||
|
||||
if (isOpenAiProvider(input.provider)) {
|
||||
const key = cacheOptions.key ?? input.sessionId;
|
||||
return {
|
||||
openai: {
|
||||
store: false,
|
||||
...(key ? { promptCacheKey: key } : {}),
|
||||
...(cacheOptions.retention === '24h' || cacheOptions.retention === 'in_memory'
|
||||
? { promptCacheRetention: cacheOptions.retention }
|
||||
: key
|
||||
? { promptCacheRetention: 'in_memory' }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applySmartAiCacheProviderOptions(input: {
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
providerOptions?: TSmartAiProviderOptions;
|
||||
cache?: TSmartAiCacheSetting;
|
||||
sessionId?: string;
|
||||
}): TSmartAiProviderOptions | undefined {
|
||||
return mergeSmartAiProviderOptions(
|
||||
getSmartAiCacheProviderOptions(input),
|
||||
input.providerOptions,
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ 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 { createOpenAiChatGptInstructionsMiddleware } from './smartai.middleware.openai.js';
|
||||
import { createOpenAiChatGptProviderSettings } from './smartai.auth.openai.js';
|
||||
|
||||
/**
|
||||
* Returns a LanguageModelV3 for the given provider and model.
|
||||
@@ -16,12 +18,24 @@ export function getModel(options: ISmartAiOptions): LanguageModelV3 {
|
||||
if (options.promptCaching === false) return base;
|
||||
return plugins.wrapLanguageModel({
|
||||
model: base,
|
||||
middleware: createAnthropicCachingMiddleware(),
|
||||
middleware: createAnthropicCachingMiddleware(
|
||||
typeof options.promptCaching === 'object' ? options.promptCaching : undefined,
|
||||
),
|
||||
}) as unknown as LanguageModelV3;
|
||||
}
|
||||
case 'openai': {
|
||||
const p = plugins.createOpenAI({ apiKey: options.apiKey });
|
||||
return p(options.model) as LanguageModelV3;
|
||||
const p = plugins.createOpenAI(
|
||||
options.openAiChatGptAuth
|
||||
? createOpenAiChatGptProviderSettings(options.openAiChatGptAuth)
|
||||
: { apiKey: options.apiKey },
|
||||
);
|
||||
const base = p(options.model) as LanguageModelV3;
|
||||
return options.openAiChatGptAuth
|
||||
? plugins.wrapLanguageModel({
|
||||
model: base,
|
||||
middleware: createOpenAiChatGptInstructionsMiddleware(),
|
||||
}) as unknown as LanguageModelV3
|
||||
: base;
|
||||
}
|
||||
case 'google': {
|
||||
const p = plugins.createGoogleGenerativeAI({ apiKey: options.apiKey });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { JSONObject, JSONValue, LanguageModelV3 } from '@ai-sdk/provider';
|
||||
import type { JSONObject, JSONValue, LanguageModelV3, LanguageModelV3Prompt } from '@ai-sdk/provider';
|
||||
import type { ISmartAiCacheOptions } from './smartai.cache.js';
|
||||
|
||||
export type TProvider =
|
||||
| 'anthropic'
|
||||
@@ -14,6 +15,53 @@ export type TOpenAiReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'hi
|
||||
|
||||
export type TOpenAiTextVerbosity = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface IOpenAiChatGptTokenInfo {
|
||||
email?: string;
|
||||
chatgptPlanType?: string;
|
||||
chatgptUserId?: string;
|
||||
chatgptAccountId?: string;
|
||||
chatgptAccountIsFedramp: boolean;
|
||||
expiresAt?: string;
|
||||
rawJwt: string;
|
||||
}
|
||||
|
||||
export interface IOpenAiChatGptAuthCredentials {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
accountId?: string;
|
||||
tokenInfo?: IOpenAiChatGptTokenInfo;
|
||||
baseUrl?: string;
|
||||
originator?: string;
|
||||
}
|
||||
|
||||
export interface IOpenAiChatGptTokenData extends IOpenAiChatGptAuthCredentials {
|
||||
refreshToken: string;
|
||||
tokenInfo: IOpenAiChatGptTokenInfo;
|
||||
}
|
||||
|
||||
export interface IOpenAiChatGptDeviceCode {
|
||||
verificationUrl: string;
|
||||
userCode: string;
|
||||
deviceAuthId: string;
|
||||
intervalSeconds: number;
|
||||
}
|
||||
|
||||
export interface IOpenAiChatGptAuthOptions {
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface IOpenAiChatGptDeviceCodePollOptions extends IOpenAiChatGptAuthOptions {
|
||||
timeoutMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IOpenAiChatGptCompleteDeviceCodeOptions extends IOpenAiChatGptDeviceCodePollOptions {
|
||||
forcedChatGptWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface IOpenAiProviderOptions extends JSONObject {
|
||||
conversation?: string | null;
|
||||
include?: string[] | null;
|
||||
@@ -55,6 +103,11 @@ export interface ISmartAiOptions {
|
||||
provider: TProvider;
|
||||
model: string;
|
||||
apiKey?: string;
|
||||
/**
|
||||
* OpenAI ChatGPT/Codex subscription credentials from the device-code auth flow.
|
||||
* Only used when provider === 'openai'.
|
||||
*/
|
||||
openAiChatGptAuth?: IOpenAiChatGptAuthCredentials;
|
||||
/**
|
||||
* Provider-specific AI SDK generation options.
|
||||
* Pass this to generateText()/streamText() alongside the model.
|
||||
@@ -71,7 +124,7 @@ export interface ISmartAiOptions {
|
||||
* Enable Anthropic prompt caching on system + recent messages.
|
||||
* Only used when provider === 'anthropic'. Default: true.
|
||||
*/
|
||||
promptCaching?: boolean;
|
||||
promptCaching?: boolean | ISmartAiCacheOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,4 +149,4 @@ export interface IOllamaModelOptions {
|
||||
think?: boolean;
|
||||
}
|
||||
|
||||
export type { LanguageModelV3 };
|
||||
export type { LanguageModelV3, LanguageModelV3Prompt };
|
||||
|
||||
@@ -1,38 +1,12 @@
|
||||
import type { LanguageModelV3Middleware, LanguageModelV3Prompt } from '@ai-sdk/provider';
|
||||
import type { LanguageModelV3Middleware } from '@ai-sdk/provider';
|
||||
import { createSmartAiCachingMiddleware } from './smartai.cache.js';
|
||||
import type { ISmartAiCacheOptions } from './smartai.cache.js';
|
||||
|
||||
/**
|
||||
* Creates middleware that adds Anthropic prompt caching directives.
|
||||
* Marks the last system message and last user message with ephemeral cache control,
|
||||
* reducing input token cost and latency on repeated calls.
|
||||
*/
|
||||
export function createAnthropicCachingMiddleware(): LanguageModelV3Middleware {
|
||||
return {
|
||||
specificationVersion: 'v3',
|
||||
transformParams: async ({ params }) => {
|
||||
const messages = [...params.prompt] as Array<Record<string, unknown>>;
|
||||
|
||||
// Find the last system message and last user message
|
||||
let lastSystemIdx = -1;
|
||||
let lastUserIdx = -1;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].role === 'system') lastSystemIdx = i;
|
||||
if (messages[i].role === 'user') lastUserIdx = i;
|
||||
}
|
||||
|
||||
const targets = [lastSystemIdx, lastUserIdx].filter(i => i >= 0);
|
||||
for (const idx of targets) {
|
||||
const msg = { ...messages[idx] };
|
||||
msg.providerOptions = {
|
||||
...(msg.providerOptions as Record<string, unknown> || {}),
|
||||
anthropic: {
|
||||
...((msg.providerOptions as Record<string, unknown>)?.anthropic as Record<string, unknown> || {}),
|
||||
cacheControl: { type: 'ephemeral' },
|
||||
},
|
||||
};
|
||||
messages[idx] = msg;
|
||||
}
|
||||
|
||||
return { ...params, prompt: messages as unknown as LanguageModelV3Prompt };
|
||||
},
|
||||
};
|
||||
export function createAnthropicCachingMiddleware(options: ISmartAiCacheOptions = {}): LanguageModelV3Middleware {
|
||||
return createSmartAiCachingMiddleware({ ...options, provider: 'anthropic' });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!instructions) {
|
||||
return params;
|
||||
}
|
||||
|
||||
const providerOptions = params.providerOptions ?? {};
|
||||
const openAiProviderOptions = providerOptions.openai ?? {};
|
||||
if (isNonEmptyString(openAiProviderOptions.instructions)) {
|
||||
return params;
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
prompt: params.prompt.filter((message) => message.role !== 'system'),
|
||||
providerOptions: {
|
||||
...providerOptions,
|
||||
openai: {
|
||||
...openAiProviderOptions,
|
||||
instructions,
|
||||
} as JSONObject,
|
||||
},
|
||||
} satisfies LanguageModelV3CallOptions;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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