mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22ad5fc146 |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,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',
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user