feat(openai-auth): add OpenAI Max device-code authentication and unified prompt caching helpers
This commit is contained in:
+39
@@ -1,6 +1,13 @@
|
||||
export { getModel, getModelSetup } from './smartai.classes.smartai.js';
|
||||
export type {
|
||||
IOpenAiProviderOptions,
|
||||
IOpenAiMaxAuthCredentials,
|
||||
IOpenAiMaxAuthOptions,
|
||||
IOpenAiMaxCompleteDeviceCodeOptions,
|
||||
IOpenAiMaxDeviceCode,
|
||||
IOpenAiMaxDeviceCodePollOptions,
|
||||
IOpenAiMaxIdTokenInfo,
|
||||
IOpenAiMaxTokenData,
|
||||
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_MAX_AUTH_ISSUER,
|
||||
OPENAI_MAX_CLIENT_ID,
|
||||
OPENAI_MAX_CODEX_BASE_URL,
|
||||
OPENAI_MAX_DEFAULT_ORIGINATOR,
|
||||
OpenAiMaxAuthError,
|
||||
completeOpenAiMaxDeviceCodeLogin,
|
||||
createOpenAiMaxProviderSettings,
|
||||
ensureOpenAiMaxWorkspaceAllowed,
|
||||
exchangeOpenAiMaxAuthorizationCode,
|
||||
parseOpenAiMaxIdToken,
|
||||
pollOpenAiMaxDeviceCode,
|
||||
refreshOpenAiMaxTokenData,
|
||||
requestOpenAiMaxDeviceCode,
|
||||
} from './smartai.auth.openai.js';
|
||||
export type { IOpenAiMaxAuthorizationCode } 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,303 @@
|
||||
import type {
|
||||
IOpenAiMaxAuthCredentials,
|
||||
IOpenAiMaxAuthOptions,
|
||||
IOpenAiMaxCompleteDeviceCodeOptions,
|
||||
IOpenAiMaxDeviceCode,
|
||||
IOpenAiMaxDeviceCodePollOptions,
|
||||
IOpenAiMaxIdTokenInfo,
|
||||
IOpenAiMaxTokenData,
|
||||
} from './smartai.interfaces.js';
|
||||
|
||||
export const OPENAI_MAX_AUTH_ISSUER = 'https://auth.openai.com';
|
||||
export const OPENAI_MAX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||
export const OPENAI_MAX_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex';
|
||||
export const OPENAI_MAX_DEFAULT_ORIGINATOR = 'smartai';
|
||||
|
||||
const DEVICE_CODE_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export class OpenAiMaxAuthError extends Error {
|
||||
public status?: number;
|
||||
public body?: string;
|
||||
|
||||
constructor(message: string, options: { status?: number; body?: string } = {}) {
|
||||
super(message);
|
||||
this.name = 'OpenAiMaxAuthError';
|
||||
this.status = options.status;
|
||||
this.body = options.body;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxAuthorizationCode {
|
||||
authorizationCode: string;
|
||||
codeChallenge: string;
|
||||
codeVerifier: string;
|
||||
}
|
||||
|
||||
interface IOpenAiMaxTokenResponse {
|
||||
id_token?: unknown;
|
||||
access_token?: unknown;
|
||||
refresh_token?: unknown;
|
||||
}
|
||||
|
||||
function getFetch(options: IOpenAiMaxAuthOptions): typeof fetch {
|
||||
const fetchFunction = options.fetch ?? globalThis.fetch;
|
||||
if (!fetchFunction) {
|
||||
throw new OpenAiMaxAuthError('fetch is not available for OpenAI Max authentication.');
|
||||
}
|
||||
return fetchFunction;
|
||||
}
|
||||
|
||||
function getIssuer(options: IOpenAiMaxAuthOptions): string {
|
||||
return (options.issuer ?? OPENAI_MAX_AUTH_ISSUER).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getClientId(options: IOpenAiMaxAuthOptions): string {
|
||||
return options.clientId ?? OPENAI_MAX_CLIENT_ID;
|
||||
}
|
||||
|
||||
function asString(value: unknown, name: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
throw new OpenAiMaxAuthError(`OpenAI Max auth response is missing ${name}.`);
|
||||
}
|
||||
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 OpenAiMaxAuthError('OpenAI Max 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 OpenAiMaxAuthError(`${context} failed with status ${response.status}.`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
throw new OpenAiMaxAuthError(`${context} returned invalid JSON: ${(error as Error).message}`, {
|
||||
status: response.status,
|
||||
body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url: string, body: unknown, options: IOpenAiMaxAuthOptions): 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: IOpenAiMaxAuthOptions): 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 OpenAiMaxAuthError('OpenAI Max auth returned an invalid ID token.');
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||
} catch (error) {
|
||||
throw new OpenAiMaxAuthError(`OpenAI Max ID token could not be parsed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOpenAiMaxIdToken(idToken: string): IOpenAiMaxIdTokenInfo {
|
||||
const claims = parseJwtPayload(idToken);
|
||||
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: idToken,
|
||||
};
|
||||
}
|
||||
|
||||
function createTokenData(response: IOpenAiMaxTokenResponse): IOpenAiMaxTokenData {
|
||||
const idToken = asString(response.id_token, 'id_token');
|
||||
const accessToken = asString(response.access_token, 'access_token');
|
||||
const refreshToken = asString(response.refresh_token, 'refresh_token');
|
||||
const idTokenInfo = parseOpenAiMaxIdToken(idToken);
|
||||
|
||||
return {
|
||||
idToken,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accountId: idTokenInfo.chatgptAccountId,
|
||||
idTokenInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestOpenAiMaxDeviceCode(
|
||||
options: IOpenAiMaxAuthOptions = {},
|
||||
): Promise<IOpenAiMaxDeviceCode> {
|
||||
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 pollOpenAiMaxDeviceCode(
|
||||
deviceCode: IOpenAiMaxDeviceCode,
|
||||
options: IOpenAiMaxDeviceCodePollOptions = {},
|
||||
): Promise<IOpenAiMaxAuthorizationCode> {
|
||||
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 OpenAiMaxAuthError(`OpenAI Max 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 OpenAiMaxAuthError('OpenAI Max device-code login timed out.');
|
||||
}
|
||||
|
||||
export async function exchangeOpenAiMaxAuthorizationCode(
|
||||
authorizationCode: IOpenAiMaxAuthorizationCode,
|
||||
options: IOpenAiMaxAuthOptions = {},
|
||||
): Promise<IOpenAiMaxTokenData> {
|
||||
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 IOpenAiMaxTokenResponse;
|
||||
|
||||
return createTokenData(response);
|
||||
}
|
||||
|
||||
export function ensureOpenAiMaxWorkspaceAllowed(
|
||||
tokenData: IOpenAiMaxTokenData,
|
||||
forcedChatGptWorkspaceId?: string,
|
||||
): void {
|
||||
if (!forcedChatGptWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
if (tokenData.idTokenInfo.chatgptAccountId !== forcedChatGptWorkspaceId) {
|
||||
throw new OpenAiMaxAuthError(`OpenAI Max login is restricted to workspace ${forcedChatGptWorkspaceId}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function completeOpenAiMaxDeviceCodeLogin(
|
||||
deviceCode: IOpenAiMaxDeviceCode,
|
||||
options: IOpenAiMaxCompleteDeviceCodeOptions = {},
|
||||
): Promise<IOpenAiMaxTokenData> {
|
||||
const authorizationCode = await pollOpenAiMaxDeviceCode(deviceCode, options);
|
||||
const tokenData = await exchangeOpenAiMaxAuthorizationCode(authorizationCode, options);
|
||||
ensureOpenAiMaxWorkspaceAllowed(tokenData, options.forcedChatGptWorkspaceId);
|
||||
return tokenData;
|
||||
}
|
||||
|
||||
export async function refreshOpenAiMaxTokenData(
|
||||
tokenData: IOpenAiMaxTokenData,
|
||||
options: IOpenAiMaxAuthOptions = {},
|
||||
): Promise<IOpenAiMaxTokenData> {
|
||||
const issuer = getIssuer(options);
|
||||
const response = await postJson(`${issuer}/oauth/token`, {
|
||||
client_id: getClientId(options),
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: tokenData.refreshToken,
|
||||
}, options) as IOpenAiMaxTokenResponse;
|
||||
|
||||
return createTokenData({
|
||||
id_token: response.id_token ?? tokenData.idToken,
|
||||
access_token: response.access_token ?? tokenData.accessToken,
|
||||
refresh_token: response.refresh_token ?? tokenData.refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
export function createOpenAiMaxProviderSettings(credentials: IOpenAiMaxAuthCredentials): {
|
||||
apiKey: string;
|
||||
baseURL: string;
|
||||
headers: Record<string, string>;
|
||||
} {
|
||||
const accountId = credentials.accountId ?? credentials.idTokenInfo?.chatgptAccountId;
|
||||
const isFedrampAccount = credentials.idTokenInfo?.chatgptAccountIsFedramp === true;
|
||||
const headers: Record<string, string> = {
|
||||
originator: credentials.originator ?? OPENAI_MAX_DEFAULT_ORIGINATOR,
|
||||
};
|
||||
|
||||
if (accountId) {
|
||||
headers['ChatGPT-Account-ID'] = accountId;
|
||||
}
|
||||
if (isFedrampAccount) {
|
||||
headers['X-OpenAI-Fedramp'] = 'true';
|
||||
}
|
||||
|
||||
return {
|
||||
apiKey: credentials.accessToken,
|
||||
baseURL: credentials.baseUrl ?? OPENAI_MAX_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,7 @@ import * as plugins from './plugins.js';
|
||||
import type { ISmartAiModelSetup, ISmartAiOptions, LanguageModelV3 } from './smartai.interfaces.js';
|
||||
import { createOllamaModel } from './smartai.provider.ollama.js';
|
||||
import { createAnthropicCachingMiddleware } from './smartai.middleware.anthropic.js';
|
||||
import { createOpenAiMaxProviderSettings } from './smartai.auth.openai.js';
|
||||
|
||||
/**
|
||||
* Returns a LanguageModelV3 for the given provider and model.
|
||||
@@ -16,11 +17,17 @@ 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 });
|
||||
const p = plugins.createOpenAI(
|
||||
options.openAiMaxAuth
|
||||
? createOpenAiMaxProviderSettings(options.openAiMaxAuth)
|
||||
: { apiKey: options.apiKey },
|
||||
);
|
||||
return p(options.model) as LanguageModelV3;
|
||||
}
|
||||
case 'google': {
|
||||
|
||||
@@ -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,54 @@ export type TOpenAiReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'hi
|
||||
|
||||
export type TOpenAiTextVerbosity = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface IOpenAiMaxIdTokenInfo {
|
||||
email?: string;
|
||||
chatgptPlanType?: string;
|
||||
chatgptUserId?: string;
|
||||
chatgptAccountId?: string;
|
||||
chatgptAccountIsFedramp: boolean;
|
||||
expiresAt?: string;
|
||||
rawJwt: string;
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxAuthCredentials {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
idToken?: string;
|
||||
accountId?: string;
|
||||
idTokenInfo?: IOpenAiMaxIdTokenInfo;
|
||||
baseUrl?: string;
|
||||
originator?: string;
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxTokenData extends IOpenAiMaxAuthCredentials {
|
||||
refreshToken: string;
|
||||
idToken: string;
|
||||
idTokenInfo: IOpenAiMaxIdTokenInfo;
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxDeviceCode {
|
||||
verificationUrl: string;
|
||||
userCode: string;
|
||||
deviceAuthId: string;
|
||||
intervalSeconds: number;
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxAuthOptions {
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
fetch?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxDeviceCodePollOptions extends IOpenAiMaxAuthOptions {
|
||||
timeoutMs?: number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface IOpenAiMaxCompleteDeviceCodeOptions extends IOpenAiMaxDeviceCodePollOptions {
|
||||
forcedChatGptWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface IOpenAiProviderOptions extends JSONObject {
|
||||
conversation?: string | null;
|
||||
include?: string[] | null;
|
||||
@@ -55,6 +104,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'.
|
||||
*/
|
||||
openAiMaxAuth?: IOpenAiMaxAuthCredentials;
|
||||
/**
|
||||
* Provider-specific AI SDK generation options.
|
||||
* Pass this to generateText()/streamText() alongside the model.
|
||||
@@ -71,7 +125,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 +150,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' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user