Compare commits

...

1 Commits

Author SHA1 Message Date
Innei 22ad5fc146 init 2026-02-15 15:53:30 +08:00
18 changed files with 831 additions and 2 deletions
@@ -44,6 +44,7 @@ import { default as nvidia } from './nvidia';
import { default as ollama } from './ollama';
import { default as ollamacloud } from './ollamacloud';
import { default as openai } from './openai';
import { default as openaicodex } from './openaiCodex';
import { default as openrouter } from './openrouter';
import { default as perplexity } from './perplexity';
import { default as ppio } from './ppio';
@@ -136,6 +137,7 @@ export const LOBE_DEFAULT_MODEL_LIST = buildDefaultModelList({
ollama,
ollamacloud,
openai,
openaicodex,
openrouter,
perplexity,
ppio,
@@ -209,6 +211,7 @@ export { default as nvidia } from './nvidia';
export { default as ollama } from './ollama';
export { default as ollamacloud } from './ollamacloud';
export { gptImage1ParamsSchema, default as openai, openaiChatModels } from './openai';
export { default as openaicodex } from './openaiCodex';
export { default as openrouter } from './openrouter';
export { default as perplexity } from './perplexity';
export { default as ppio } from './ppio';
@@ -0,0 +1,35 @@
import type { AIChatModelCard } from '../types/aiModel';
const openaiCodexChatModels: AIChatModelCard[] = [
{
abilities: {
functionCall: true,
reasoning: true,
},
contextWindowTokens: 192_000,
description:
'GPT-5 Codex is a coding-optimized model available through ChatGPT Plus/Pro subscription.',
displayName: 'GPT-5 Codex',
enabled: true,
id: 'gpt-5-codex',
maxOutput: 16_384,
type: 'chat',
},
{
abilities: {
functionCall: true,
reasoning: true,
},
contextWindowTokens: 192_000,
description: 'Codex Mini is a compact and efficient coding model for ChatGPT subscribers.',
displayName: 'Codex Mini',
enabled: true,
id: 'codex-mini',
maxOutput: 16_384,
type: 'chat',
},
];
export const allModels = [...openaiCodexChatModels];
export default allModels;
@@ -42,6 +42,7 @@ export enum ModelProvider {
Ollama = 'ollama',
OllamaCloud = 'ollamacloud',
OpenAI = 'openai',
OpenAICodex = 'openaicodex',
OpenRouter = 'openrouter',
Perplexity = 'perplexity',
PPIO = 'ppio',
@@ -45,6 +45,7 @@ import NvidiaProvider from './nvidia';
import OllamaProvider from './ollama';
import OllamaCloudProvider from './ollamacloud';
import OpenAIProvider from './openai';
import OpenAICodexProvider from './openaiCodex';
import OpenRouterProvider from './openrouter';
import PerplexityProvider from './perplexity';
import PPIOProvider from './ppio';
@@ -153,6 +154,7 @@ export const DEFAULT_MODEL_PROVIDER_LIST = [
CloudflareProvider,
GithubProvider,
GithubCopilotProvider,
OpenAICodexProvider,
NewAPIProvider,
BflProvider,
NovitaProvider,
@@ -256,6 +258,7 @@ export { default as NvidiaProviderCard } from './nvidia';
export { default as OllamaProviderCard } from './ollama';
export { default as OllamaCloudProviderCard } from './ollamacloud';
export { default as OpenAIProviderCard } from './openai';
export { default as OpenAICodexProviderCard } from './openaiCodex';
export { default as OpenRouterProviderCard } from './openrouter';
export { default as PerplexityProviderCard } from './perplexity';
export { default as PPIOProviderCard } from './ppio';
@@ -0,0 +1,25 @@
import type { ModelProviderCard } from '@/types/llm';
const OpenAICodex: ModelProviderCard = {
chatModels: [],
checkModel: 'gpt-5-codex',
description: 'Access OpenAI Codex models through your ChatGPT Plus/Pro subscription.',
id: 'openaicodex',
name: 'OpenAI Codex',
settings: {
authType: 'oauthDeviceFlow',
oauthDeviceFlow: {
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
defaultPollingInterval: 5,
deviceCodeEndpoint: 'https://auth0.openai.com/oauth/device/code',
scopes: ['openid', 'profile', 'email', 'offline_access'],
tokenEndpoint: 'https://auth0.openai.com/oauth/token',
},
showApiKey: false,
showChecker: true,
showModelFetcher: false,
},
url: 'https://openai.com/codex/',
};
export default OpenAICodex;
+1
View File
@@ -27,6 +27,7 @@ export { LobeNewAPIAI } from './providers/newapi';
export { LobeOllamaAI } from './providers/ollama';
export { LobeOllamaCloudAI } from './providers/ollamacloud';
export { LobeOpenAI } from './providers/openai';
export { LobeOpenAICodexAI } from './providers/openaiCodex';
export { LobeOpenRouterAI } from './providers/openrouter';
export { LobePerplexityAI } from './providers/perplexity';
export { LobeQwenAI } from './providers/qwen';
@@ -0,0 +1,246 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AgentRuntimeErrorType } from '../../types/error';
import { LobeOpenAICodexAI } from './index';
const mockFetch = vi.fn();
global.fetch = mockFetch;
// A valid-looking JWT for testing (header.payload.signature)
const createMockJWT = (payload: Record<string, any>) => {
const header = btoa(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
const body = btoa(JSON.stringify(payload));
const signature = 'mock-signature';
return `${header}.${body}.${signature}`;
};
const MOCK_ACCOUNT_ID = 'org-test-account-123';
const MOCK_JWT = createMockJWT({
'https://api.openai.com/auth': { account_id: MOCK_ACCOUNT_ID },
'sub': 'user-123',
});
const MOCK_DEVICE_ID = 'test-device-id';
describe('LobeOpenAICodexAI', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('constructor', () => {
it('should throw InvalidOpenAICodexToken when no oauthAccessToken provided', () => {
expect(() => new LobeOpenAICodexAI({})).toThrow();
try {
new LobeOpenAICodexAI({});
} catch (e: any) {
expect(e.errorType).toBe(AgentRuntimeErrorType.InvalidOpenAICodexToken);
}
});
it('should accept a valid oauth access token', () => {
const instance = new LobeOpenAICodexAI({
chatgptAccountId: MOCK_ACCOUNT_ID,
oauthAccessToken: MOCK_JWT,
oaiDeviceId: MOCK_DEVICE_ID,
});
expect(instance).toBeInstanceOf(LobeOpenAICodexAI);
});
it('should parse account ID from JWT when chatgptAccountId not provided', () => {
const instance = new LobeOpenAICodexAI({
oauthAccessToken: MOCK_JWT,
oaiDeviceId: MOCK_DEVICE_ID,
});
expect(instance).toBeInstanceOf(LobeOpenAICodexAI);
});
it('should throw when JWT has no account ID claim', () => {
const badJWT = createMockJWT({ foo: 'bar' });
expect(
() =>
new LobeOpenAICodexAI({
oauthAccessToken: badJWT,
}),
).toThrow();
});
});
describe('models', () => {
it('should return predefined Codex models', async () => {
const instance = new LobeOpenAICodexAI({
chatgptAccountId: MOCK_ACCOUNT_ID,
oauthAccessToken: MOCK_JWT,
oaiDeviceId: MOCK_DEVICE_ID,
});
const models = await instance.models();
expect(models).toBeInstanceOf(Array);
expect(models.length).toBeGreaterThan(0);
const modelIds = models.map((m) => m.id);
expect(modelIds).toContain('gpt-5-codex');
});
});
describe('chat', () => {
it('should call the correct API endpoint with proper headers', async () => {
const instance = new LobeOpenAICodexAI({
chatgptAccountId: MOCK_ACCOUNT_ID,
oauthAccessToken: MOCK_JWT,
oaiDeviceId: MOCK_DEVICE_ID,
});
const mockSSE =
'data: {"type":"response.output_text.delta","delta":"Hello"}\n\ndata: [DONE]\n\n';
const encoder = new TextEncoder();
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(mockSSE));
controller.close();
},
});
mockFetch.mockResolvedValueOnce({
body: mockStream,
ok: true,
status: 200,
});
const response = await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-5-codex',
});
expect(mockFetch).toHaveBeenCalledWith(
'https://chatgpt.com/backend-api/codex/responses',
expect.objectContaining({
headers: expect.objectContaining({
'Authorization': `Bearer ${MOCK_JWT}`,
'Content-Type': 'application/json',
'chatgpt-account-id': MOCK_ACCOUNT_ID,
'oai-device-id': MOCK_DEVICE_ID,
}),
method: 'POST',
}),
);
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body).toHaveProperty('input');
expect(body).toHaveProperty('model', 'gpt-5-codex');
expect(body).toHaveProperty('stream', true);
expect(body).not.toHaveProperty('messages');
expect(response).toBeInstanceOf(Response);
});
it('should include instructions when system message present', async () => {
const instance = new LobeOpenAICodexAI({
chatgptAccountId: MOCK_ACCOUNT_ID,
oauthAccessToken: MOCK_JWT,
oaiDeviceId: MOCK_DEVICE_ID,
});
const mockSSE =
'data: {"type":"response.output_text.delta","delta":"Hi"}\n\ndata: [DONE]\n\n';
const encoder = new TextEncoder();
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(mockSSE));
controller.close();
},
});
mockFetch.mockResolvedValueOnce({
body: mockStream,
ok: true,
status: 200,
});
await instance.chat({
messages: [
{ content: 'You are a helpful assistant', role: 'system' },
{ content: 'Hello', role: 'user' },
],
model: 'gpt-5-codex',
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.instructions).toBe('You are a helpful assistant');
});
it('should include tools when provided', async () => {
const instance = new LobeOpenAICodexAI({
chatgptAccountId: MOCK_ACCOUNT_ID,
oauthAccessToken: MOCK_JWT,
oaiDeviceId: MOCK_DEVICE_ID,
});
const mockSSE = 'data: {"type":"response.output_text.delta","delta":"r"}\n\ndata: [DONE]\n\n';
const encoder = new TextEncoder();
const mockStream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(mockSSE));
controller.close();
},
});
mockFetch.mockResolvedValueOnce({
body: mockStream,
ok: true,
status: 200,
});
await instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-5-codex',
tools: [
{
function: {
description: 'Get weather',
name: 'get_weather',
parameters: { properties: { city: { type: 'string' } }, type: 'object' },
},
type: 'function',
},
],
});
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
expect(body.tools).toHaveLength(1);
expect(body.tools[0].name).toBe('get_weather');
});
it('should throw mapped error on 401 without retrying', async () => {
const instance = new LobeOpenAICodexAI({
chatgptAccountId: MOCK_ACCOUNT_ID,
oauthAccessToken: MOCK_JWT,
oaiDeviceId: MOCK_DEVICE_ID,
});
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
});
await expect(
instance.chat({
messages: [{ content: 'Hello', role: 'user' }],
model: 'gpt-5-codex',
}),
).rejects.toMatchObject({
errorType: AgentRuntimeErrorType.InvalidOpenAICodexToken,
});
expect(mockFetch).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,225 @@
import { type ChatModelCard } from '@lobechat/types';
import { ModelProvider } from 'model-bank';
import { type LobeRuntimeAI } from '../../core/BaseAI';
import { convertOpenAIResponseInputs } from '../../core/contextBuilders/openai';
import { OpenAIResponsesStream } from '../../core/streams/openai/responsesStream';
import { createSSEDataExtractor } from '../../core/streams/protocol';
import { type ChatMethodOptions, type ChatStreamPayload } from '../../types';
import { AgentRuntimeErrorType } from '../../types/error';
import { AgentRuntimeError } from '../../utils/createError';
import { StreamingResponse } from '../../utils/response';
const CODEX_API_URL = 'https://chatgpt.com/backend-api/codex/responses';
const MAX_TOTAL_ATTEMPTS = 5;
const MAX_RATE_LIMIT_RETRIES = 3;
const CODEX_MODELS: ChatModelCard[] = [
{ displayName: 'GPT-5 Codex', enabled: true, id: 'gpt-5-codex', type: 'chat' },
{ displayName: 'Codex Mini', enabled: true, id: 'codex-mini', type: 'chat' },
];
export interface LobeOpenAICodexAIParams {
chatgptAccountId?: string;
oaiDeviceId?: string;
oauthAccessToken?: string;
}
export class LobeOpenAICodexAI implements LobeRuntimeAI {
private chatgptAccountId: string;
private oauthAccessToken: string;
private oaiDeviceId: string;
constructor({ oauthAccessToken, chatgptAccountId, oaiDeviceId }: LobeOpenAICodexAIParams = {}) {
if (!oauthAccessToken) {
throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidOpenAICodexToken, {
message: 'OAuth access token is required for OpenAI Codex',
});
}
this.oauthAccessToken = oauthAccessToken;
this.chatgptAccountId = chatgptAccountId || this.parseAccountIdFromJWT(oauthAccessToken);
this.oaiDeviceId = oaiDeviceId || crypto.randomUUID();
}
async chat(payload: ChatStreamPayload, options?: ChatMethodOptions) {
return this.executeWithRetry(async () => {
const { messages, model, tools, ...rest } = payload;
const input = await convertOpenAIResponseInputs(messages);
const body: Record<string, any> = {
input,
model,
stream: true,
};
if (rest.reasoning?.effort || rest.reasoning_effort) {
body.reasoning = { effort: rest.reasoning?.effort || rest.reasoning_effort };
}
if (payload.messages?.[0]?.role === 'system') {
body.instructions = payload.messages[0].content;
}
if (tools && tools.length > 0) {
body.tools = tools.map((tool) => ({
description: tool.function.description,
name: tool.function.name,
parameters: tool.function.parameters,
type: 'function',
}));
}
const response = await fetch(CODEX_API_URL, {
body: JSON.stringify(body),
headers: {
'Accept': 'text/event-stream',
'Authorization': `Bearer ${this.oauthAccessToken}`,
'Content-Type': 'application/json',
'chatgpt-account-id': this.chatgptAccountId,
'oai-device-id': this.oaiDeviceId,
},
method: 'POST',
signal: options?.signal,
});
if (!response.ok) {
throw { response, status: response.status };
}
const stream = response.body!.pipeThrough(createSSEDataExtractor());
return StreamingResponse(
OpenAIResponsesStream(stream, {
callbacks: options?.callback,
payload: { model, provider: ModelProvider.OpenAICodex },
}),
{ headers: options?.headers },
);
});
}
async models(): Promise<ChatModelCard[]> {
return CODEX_MODELS;
}
private parseAccountIdFromJWT(token: string): string {
try {
const parts = token.split('.');
if (parts.length < 2) {
throw new Error('Invalid JWT format');
}
const payload = parts[1];
const padded = payload.replaceAll('-', '+').replaceAll('_', '/');
const decoded = atob(padded);
const parsed = JSON.parse(decoded);
const accountId =
parsed['https://api.openai.com/auth']?.account_id ||
parsed['chatgpt_account_id'] ||
parsed['account_id'] ||
parsed.sub;
if (!accountId) {
throw new Error('No account ID found in JWT');
}
return accountId;
} catch (e) {
throw AgentRuntimeError.createError(AgentRuntimeErrorType.InvalidOpenAICodexToken, {
message: `Failed to parse account ID from JWT: ${(e as Error).message}`,
});
}
}
private async executeWithRetry<T>(fn: () => Promise<T>): Promise<T> {
let totalAttempts = 0;
let rateLimitAttempts = 0;
while (totalAttempts < MAX_TOTAL_ATTEMPTS) {
totalAttempts++;
try {
return await fn();
} catch (error: any) {
const status = error?.status ?? error?.response?.status;
if (status === 401) {
throw this.mapError(error);
}
if (status === 429 && rateLimitAttempts < MAX_RATE_LIMIT_RETRIES) {
rateLimitAttempts++;
const retryAfter = this.getRetryAfterMs(error) ?? 1000 * Math.pow(2, rateLimitAttempts);
await new Promise<void>((resolve) => {
setTimeout(resolve, Math.min(retryAfter, 10_000));
});
continue;
}
throw this.mapError(error);
}
}
throw AgentRuntimeError.chat({
endpoint: CODEX_API_URL,
error: { message: 'Max retry attempts exceeded' },
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: ModelProvider.OpenAICodex,
});
}
private getRetryAfterMs(error: any): number | undefined {
const header = error?.response?.headers?.get?.('Retry-After');
if (header) {
const seconds = parseInt(header, 10);
if (!isNaN(seconds)) return seconds * 1000;
}
return undefined;
}
private mapError(error: any) {
const status = error?.status ?? error?.response?.status;
switch (status) {
case 401: {
return AgentRuntimeError.chat({
endpoint: CODEX_API_URL,
error,
errorType: AgentRuntimeErrorType.InvalidOpenAICodexToken,
provider: ModelProvider.OpenAICodex,
});
}
case 403: {
return AgentRuntimeError.chat({
endpoint: CODEX_API_URL,
error,
errorType: AgentRuntimeErrorType.PermissionDenied,
provider: ModelProvider.OpenAICodex,
});
}
case 429: {
return AgentRuntimeError.chat({
endpoint: CODEX_API_URL,
error,
errorType: AgentRuntimeErrorType.QuotaLimitReached,
provider: ModelProvider.OpenAICodex,
});
}
default: {
return AgentRuntimeError.chat({
endpoint: CODEX_API_URL,
error,
errorType: AgentRuntimeErrorType.ProviderBizError,
provider: ModelProvider.OpenAICodex,
});
}
}
}
}
export default LobeOpenAICodexAI;
+2
View File
@@ -41,6 +41,7 @@ import { LobeNvidiaAI } from './providers/nvidia';
import { LobeOllamaAI } from './providers/ollama';
import { LobeOllamaCloudAI } from './providers/ollamacloud';
import { LobeOpenAI } from './providers/openai';
import { LobeOpenAICodexAI } from './providers/openaiCodex';
import { LobeOpenRouterAI } from './providers/openrouter';
import { LobePerplexityAI } from './providers/perplexity';
import { LobePPIOAI } from './providers/ppio';
@@ -113,6 +114,7 @@ export const providerRuntimeMap = {
ollama: LobeOllamaAI,
ollamacloud: LobeOllamaCloudAI,
openai: LobeOpenAI,
openaicodex: LobeOpenAICodexAI,
openrouter: LobeOpenRouterAI,
perplexity: LobePerplexityAI,
ppio: LobePPIOAI,
+1 -1
View File
@@ -1,4 +1,3 @@
/* eslint-disable sort-keys-fix/sort-keys-fix */
// ******* Runtime Biz Error ******* //
export const AgentRuntimeErrorType = {
AgentRuntimeError: 'AgentRuntimeError', // Agent Runtime module runtime error
@@ -25,6 +24,7 @@ export const AgentRuntimeErrorType = {
InvalidGithubToken: 'InvalidGithubToken',
InvalidGithubCopilotToken: 'InvalidGithubCopilotToken',
InvalidOpenAICodexToken: 'InvalidOpenAICodexToken',
ConnectionCheckFailed: 'ConnectionCheckFailed',
+5 -1
View File
@@ -1,4 +1,3 @@
/* eslint-disable typescript-sort-keys/interface */
export interface ClientSecretPayload {
/**
* Represents the user's API key
@@ -27,9 +26,14 @@ export interface ClientSecretPayload {
bearerToken?: string;
bearerTokenExpiresAt?: number;
/**
* OpenAI Codex OAuth fields
*/
chatgptAccountId?: string;
cloudflareBaseURLOrAccountID?: string;
customHeaders?: Record<string, string>;
oaiDeviceId?: string;
/**
* GitHub Copilot OAuth fields
*/
@@ -62,6 +62,13 @@ export interface GithubCopilotKeyVault {
oauthAccessToken?: string;
}
export interface OpenAICodexKeyVault {
chatgptAccountId?: string;
oaiDeviceId?: string;
oauthAccessToken?: string;
oauthTokenExpiresAt?: string;
}
export interface SearchEngineKeyVaults {
searchxng?: {
apiKey?: string;
+19
View File
@@ -8,6 +8,7 @@ import {
type CloudflareKeyVault,
type ComfyUIKeyVault,
type GithubCopilotKeyVault,
type OpenAICodexKeyVault,
type OpenAICompatibleKeyVault,
type VertexAIKeyVault,
} from '@lobechat/types';
@@ -32,6 +33,7 @@ type ProviderKeyVaults = OpenAICompatibleKeyVault &
CloudflareKeyVault &
ComfyUIKeyVault &
GithubCopilotKeyVault &
OpenAICodexKeyVault &
VertexAIKeyVault;
/**
@@ -143,6 +145,15 @@ export const buildPayloadFromKeyVaults = (
};
}
case ModelProvider.OpenAICodex: {
return {
chatgptAccountId: keyVaults.chatgptAccountId,
oauthAccessToken: keyVaults.oauthAccessToken,
oaiDeviceId: keyVaults.oaiDeviceId,
runtimeProvider,
};
}
default: {
return {
apiKey: keyVaults.apiKey,
@@ -245,6 +256,14 @@ const getParamsFromPayload = (provider: string, payload: ClientSecretPayload) =>
};
}
case ModelProvider.OpenAICodex: {
return {
chatgptAccountId: payload.chatgptAccountId,
oauthAccessToken: payload.oauthAccessToken,
oaiDeviceId: payload.oaiDeviceId,
};
}
case ModelProvider.ComfyUI: {
const {
COMFYUI_BASE_URL,
@@ -10,6 +10,7 @@ import {
getOAuthService,
GithubCopilotOAuthService,
} from '@/server/services/oauthDeviceFlow/providers/githubCopilot';
import { OpenAICodexOAuthService } from '@/server/services/oauthDeviceFlow/providers/openaiCodex';
const oauthProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
const { ctx } = opts;
@@ -151,6 +152,31 @@ export const oauthDeviceFlowRouter = router({
}
}
// For OpenAI Codex, use the specialized service
if (input.providerId === 'openaicodex' && service instanceof OpenAICodexOAuthService) {
const tokens = await service.completeAuthFlow(config, input.deviceCode);
if (!tokens) {
return { status: 'pending' as const };
}
await ctx.aiProviderModel.updateConfig(
input.providerId,
{
keyVaults: {
chatgptAccountId: tokens.chatgptAccountId,
oauthAccessToken: tokens.oauthAccessToken,
oauthTokenExpiresAt: String(tokens.oauthTokenExpiresAt),
oaiDeviceId: tokens.oaiDeviceId,
},
},
ctx.gateKeeper.encrypt,
KeyVaultsGateKeeper.getUserKeyVaults,
);
return { status: 'success' as const };
}
// Generic OAuth flow
const pollResult = await service.pollForToken(config, input.deviceCode);
@@ -187,10 +213,12 @@ export const oauthDeviceFlowRouter = router({
keyVaults: {
bearerToken: undefined,
bearerTokenExpiresAt: undefined,
chatgptAccountId: undefined,
githubAvatarUrl: undefined,
githubUsername: undefined,
oauthAccessToken: undefined,
oauthTokenExpiresAt: undefined,
oaiDeviceId: undefined,
},
},
ctx.gateKeeper.encrypt,
@@ -0,0 +1,151 @@
// @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { OAuthDeviceFlowService } from '../../index';
import { getOAuthService } from '../../providers/githubCopilot';
import { OpenAICodexOAuthService } from '../../providers/openaiCodex';
const mockFetch = vi.fn();
global.fetch = mockFetch;
const createMockJWT = (payload: Record<string, any>) => {
const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64');
const body = Buffer.from(JSON.stringify(payload)).toString('base64');
const signature = 'mock-signature';
return `${header}.${body}.${signature}`;
};
describe('OpenAICodexOAuthService', () => {
let service: OpenAICodexOAuthService;
const mockConfig = {
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
defaultPollingInterval: 5,
deviceCodeEndpoint: 'https://auth0.openai.com/oauth/device/code',
scopes: ['openid', 'profile', 'email', 'offline_access'],
tokenEndpoint: 'https://auth0.openai.com/oauth/token',
};
beforeEach(() => {
service = new OpenAICodexOAuthService();
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('completeAuthFlow', () => {
it('should complete auth flow successfully with JWT containing account_id', async () => {
const mockJWT = createMockJWT({
'https://api.openai.com/auth': { account_id: 'org-abc123' },
'sub': 'user-xyz',
});
mockFetch.mockResolvedValueOnce({
json: () =>
Promise.resolve({
access_token: mockJWT,
expires_in: 3600,
token_type: 'bearer',
}),
ok: true,
});
const result = await service.completeAuthFlow(mockConfig, 'device-code-123');
expect(result).not.toBeNull();
expect(result!.oauthAccessToken).toBe(mockJWT);
expect(result!.chatgptAccountId).toBe('org-abc123');
expect(result!.oaiDeviceId).toBeDefined();
expect(result!.oauthTokenExpiresAt).toBeGreaterThan(Date.now());
});
it('should fallback to sub claim when account_id not present', async () => {
const mockJWT = createMockJWT({ sub: 'user-fallback-123' });
mockFetch.mockResolvedValueOnce({
json: () =>
Promise.resolve({
access_token: mockJWT,
expires_in: 3600,
token_type: 'bearer',
}),
ok: true,
});
const result = await service.completeAuthFlow(mockConfig, 'device-code-123');
expect(result).not.toBeNull();
expect(result!.chatgptAccountId).toBe('user-fallback-123');
});
it('should return null when poll returns pending status', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ error: 'authorization_pending' }),
ok: true,
});
const result = await service.completeAuthFlow(mockConfig, 'device-code-123');
expect(result).toBeNull();
});
it('should return null when poll returns expired status', async () => {
mockFetch.mockResolvedValueOnce({
json: () => Promise.resolve({ error: 'expired_token' }),
ok: true,
});
const result = await service.completeAuthFlow(mockConfig, 'device-code-123');
expect(result).toBeNull();
});
it('should throw when JWT has no identifiable account claim', async () => {
const badJWT = createMockJWT({ foo: 'bar' });
mockFetch.mockResolvedValueOnce({
json: () =>
Promise.resolve({
access_token: badJWT,
expires_in: 3600,
token_type: 'bearer',
}),
ok: true,
});
await expect(service.completeAuthFlow(mockConfig, 'device-code-123')).rejects.toThrow(
'Failed to parse account ID from OpenAI JWT',
);
});
it('should throw when JWT format is invalid', async () => {
mockFetch.mockResolvedValueOnce({
json: () =>
Promise.resolve({
access_token: 'not-a-jwt',
expires_in: 3600,
token_type: 'bearer',
}),
ok: true,
});
await expect(service.completeAuthFlow(mockConfig, 'device-code-123')).rejects.toThrow(
'Failed to parse account ID from OpenAI JWT',
);
});
});
});
describe('getOAuthService', () => {
it('should return OpenAICodexOAuthService for openaicodex provider', () => {
const service = getOAuthService('openaicodex');
expect(service).toBeInstanceOf(OpenAICodexOAuthService);
});
it('should return base OAuthDeviceFlowService for unknown providers', () => {
const service = getOAuthService('unknown');
expect(service).toBeInstanceOf(OAuthDeviceFlowService);
});
});
@@ -1,6 +1,7 @@
import { type OAuthDeviceFlowConfig } from '@/types/aiProvider';
import { OAuthDeviceFlowService } from '../index';
import { OpenAICodexOAuthService } from './openaiCodex';
export interface CopilotTokenResponse {
expiresAt: number;
@@ -131,5 +132,8 @@ export function getOAuthService(providerId: string): OAuthDeviceFlowService {
if (providerId === 'githubcopilot') {
return new GithubCopilotOAuthService();
}
if (providerId === 'openaicodex') {
return new OpenAICodexOAuthService();
}
return new OAuthDeviceFlowService();
}
@@ -0,0 +1,65 @@
import { type OAuthDeviceFlowConfig } from '@/types/aiProvider';
import { OAuthDeviceFlowService } from '../index';
export interface OpenAICodexTokens {
chatgptAccountId: string;
oaiDeviceId: string;
oauthAccessToken: string;
oauthTokenExpiresAt: number;
}
export class OpenAICodexOAuthService extends OAuthDeviceFlowService {
async completeAuthFlow(
config: OAuthDeviceFlowConfig,
deviceCode: string,
): Promise<OpenAICodexTokens | null> {
const pollResult = await this.pollForToken(config, deviceCode);
if (pollResult.status !== 'success' || !pollResult.tokens) {
return null;
}
const oauthToken = pollResult.tokens.accessToken;
const chatgptAccountId = this.parseAccountIdFromJWT(oauthToken);
const oaiDeviceId = crypto.randomUUID();
const oauthTokenExpiresAt = pollResult.tokens.expiresIn
? Date.now() + pollResult.tokens.expiresIn * 1000
: Date.now() + 3600 * 1000;
return {
chatgptAccountId,
oauthAccessToken: oauthToken,
oauthTokenExpiresAt,
oaiDeviceId,
};
}
private parseAccountIdFromJWT(token: string): string {
try {
const parts = token.split('.');
if (parts.length < 2) {
throw new Error('Invalid JWT format');
}
const payload = parts[1];
const padded = payload.replaceAll('-', '+').replaceAll('_', '/');
const decoded = Buffer.from(padded, 'base64').toString('utf8');
const parsed = JSON.parse(decoded);
const accountId =
parsed['https://api.openai.com/auth']?.account_id ||
parsed['chatgpt_account_id'] ||
parsed['account_id'] ||
parsed.sub;
if (!accountId) {
throw new Error('No account ID found in JWT claims');
}
return accountId;
} catch (e) {
throw new Error(`Failed to parse account ID from OpenAI JWT: ${(e as Error).message}`);
}
}
}
+10
View File
@@ -4,6 +4,7 @@ import {
type ClientSecretPayload,
type CloudflareKeyVault,
type ComfyUIKeyVault,
type OpenAICodexKeyVault,
type OpenAICompatibleKeyVault,
type VertexAIKeyVault,
} from '@lobechat/types';
@@ -25,6 +26,7 @@ export const getProviderAuthPayload = (
AWSBedrockKeyVault &
CloudflareKeyVault &
ComfyUIKeyVault &
OpenAICodexKeyVault &
VertexAIKeyVault,
) => {
switch (provider) {
@@ -98,6 +100,14 @@ export const getProviderAuthPayload = (
};
}
case ModelProvider.OpenAICodex: {
return {
chatgptAccountId: keyVaults?.chatgptAccountId,
oauthAccessToken: keyVaults?.oauthAccessToken,
oaiDeviceId: keyVaults?.oaiDeviceId,
};
}
default: {
return { apiKey: clientApiKeyManager.pick(keyVaults?.apiKey), baseURL: keyVaults?.baseURL };
}