diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 99ceb622..55236f09 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -13,6 +13,7 @@ import { handleModelsApi } from '../api/models.js' import { handleStatusApi, resetUsage, addUsage } from '../api/status.js' import { ProviderService } from '../services/providerService.js' import { hahaOAuthService } from '../services/hahaOAuthService.js' +import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js' import { clearOpenAICodexModelCatalogCache, } from '../../services/openaiAuth/modelCatalog.js' @@ -1162,6 +1163,55 @@ describe('Models API', () => { }) }) + it('GET /api/models discovers models using the desktop ChatGPT account', async () => { + const providerSvc = new ProviderService() + await providerSvc.activateProvider('openai-official') + await hahaOpenAIOAuthService.saveTokens({ + accessToken: 'desktop-catalog-token', + refreshToken: null, + expiresAt: null, + accountId: 'desktop-account', + email: 'desktop@example.test', + }) + const originalFetch = globalThis.fetch + const requests: Headers[] = [] + clearOpenAICodexModelCatalogCache() + globalThis.fetch = (async (_input, init) => { + requests.push(new Headers(init?.headers)) + return Response.json({ models: [{ + slug: 'gpt-desktop-account-only', + display_name: 'Desktop account model', + visibility: 'list', + default_reasoning_level: 'high', + supported_reasoning_levels: [{ effort: 'low' }, { effort: 'high' }], + context_window: 300_000, + }] }) + }) as typeof fetch + try { + const { req, url, segments } = makeRequest('GET', '/api/models') + expect((await handleModelsApi(req, url, segments)).status).toBe(200) + // Let the immediate background catalog response settle before reading it. + await new Promise(resolve => setTimeout(resolve, 0)) + const res = await handleModelsApi(req, url, segments) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.models).toEqual([{ + id: 'gpt-desktop-account-only', + name: 'Desktop account model', + description: '', + context: '285000', + defaultReasoningEffort: 'high', + supportedReasoningEfforts: ['low', 'high'], + }]) + expect(requests).toHaveLength(1) + expect(requests[0]?.get('Authorization')).toBe('Bearer desktop-catalog-token') + expect(requests[0]?.get('ChatGPT-Account-Id')).toBe('desktop-account') + } finally { + globalThis.fetch = originalFetch + clearOpenAICodexModelCatalogCache() + } + }) + it('PUT /api/models/current should persist GPT model to managed settings when ChatGPT Official is active', async () => { const settingsSvc = new SettingsService() const providerSvc = new ProviderService() diff --git a/src/server/api/models.ts b/src/server/api/models.ts index 5e11bcff..b1da9da4 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -14,6 +14,7 @@ import { attributionHeaderEnvForModel } from '../services/attributionHeaderPolic import { ApiError, errorResponse } from '../middleware/errorHandler.js' import { hasOpenAIAuthLogin } from '../../utils/auth.js' import { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js' +import { getDesktopOpenAICodexModelCatalog } from '../services/openaiModelCatalog.js' import { OPENAI_DEFAULT_MAIN_MODEL, type OpenAIModelCatalogEntry, @@ -188,7 +189,7 @@ function buildOpenAIModelList(catalog: OpenAIModelCatalogEntry[]): ApiModelInfo[ } async function getOpenAIModelList(): Promise { - return buildOpenAIModelList(await getOpenAICodexModelCatalog()) + return buildOpenAIModelList(await getDesktopOpenAICodexModelCatalog()) } function buildGrokModelList(catalog: GrokModelCatalogEntry[]): ApiModelInfo[] { @@ -236,7 +237,7 @@ async function getOpenAIAuthModels(): Promise { return [] } - return getOpenAIModelList() + return buildOpenAIModelList(await getOpenAICodexModelCatalog()) } async function getStandaloneModelList(): Promise { diff --git a/src/server/services/openaiModelCatalog.test.ts b/src/server/services/openaiModelCatalog.test.ts new file mode 100644 index 00000000..e7f950e2 --- /dev/null +++ b/src/server/services/openaiModelCatalog.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { clearOpenAICodexModelCatalogCache } from '../../services/openaiAuth/modelCatalog.js' +import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js' +import { clearOpenAIOAuthTokenCache } from '../../services/openaiAuth/storage.js' +import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' +import { getDesktopOpenAICodexModelCatalog } from './openaiModelCatalog.js' + +describe('desktop OpenAI model catalog credentials', () => { + let directory: string + let originalConfig: string | undefined + let originalTokenFile: string | undefined + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'desktop-openai-models-')) + originalConfig = process.env.CLAUDE_CONFIG_DIR + originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE + process.env.CLAUDE_CONFIG_DIR = directory + process.env.OPENAI_CODEX_OAUTH_FILE = join(directory, 'cli-oauth.json') + await writeFile(process.env.OPENAI_CODEX_OAUTH_FILE, JSON.stringify({ + accessToken: 'other-cli-token', + accountId: 'other-cli-account', + expiresAt: Date.now() + 3_600_000, + })) + clearOpenAIOAuthTokenCache() + clearOpenAICodexModelCatalogCache() + }) + + afterEach(async () => { + if (originalConfig === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfig + if (originalTokenFile === undefined) delete process.env.OPENAI_CODEX_OAUTH_FILE + else process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile + clearOpenAIOAuthTokenCache() + clearOpenAICodexModelCatalogCache() + await rm(directory, { recursive: true, force: true }) + }) + + const saveAccount = async (accountId: string) => { + await hahaOpenAIOAuthService.saveTokens({ + accessToken: `desktop-token-${accountId}`, + refreshToken: null, + expiresAt: null, + accountId, + email: `${accountId}@example.test`, + }) + } + + test('uses desktop credentials and isolates account switches and logout from CLI credentials', async () => { + const requests: Headers[] = [] + const fetchOverride: typeof fetch = (async (_input, init) => { + const headers = new Headers(init?.headers) + requests.push(headers) + return Response.json({ models: [{ + slug: `model-${headers.get('ChatGPT-Account-Id')}`, + display_name: 'Account model', + visibility: 'list', + }] }) + }) as typeof fetch + + await saveAccount('account-a') + const first = await getDesktopOpenAICodexModelCatalog({ fetchOverride, forceRefresh: true }) + expect(first.map(model => model.value)).toEqual(['model-account-a']) + expect(requests[0]?.get('Authorization')).toBe('Bearer desktop-token-account-a') + expect(requests[0]?.get('ChatGPT-Account-Id')).toBe('account-a') + expect(await getDesktopOpenAICodexModelCatalog({ fetchOverride })).toEqual(first) + expect(requests).toHaveLength(1) + + await saveAccount('account-b') + const second = await getDesktopOpenAICodexModelCatalog({ fetchOverride, forceRefresh: true }) + expect(second.map(model => model.value)).toEqual(['model-account-b']) + expect(requests[1]?.get('Authorization')).toBe('Bearer desktop-token-account-b') + expect(await getDesktopOpenAICodexModelCatalog({ fetchOverride })).toEqual(second) + + await hahaOpenAIOAuthService.deleteTokens() + expect(await getDesktopOpenAICodexModelCatalog({ fetchOverride, forceRefresh: true })) + .toEqual(OPENAI_CODEX_MODEL_CATALOG) + expect(requests).toHaveLength(2) + }) + + test('unreadable desktop tokens fall back without consulting CLI credentials', async () => { + await saveAccount('account-a') + await writeFile(hahaOpenAIOAuthService.getOAuthFilePath(), 'invalid JSON') + let calls = 0 + const fetchOverride = (async () => { + calls += 1 + throw new Error('unexpected request') + }) as typeof fetch + expect(await getDesktopOpenAICodexModelCatalog({ fetchOverride, forceRefresh: true })) + .toEqual(OPENAI_CODEX_MODEL_CATALOG) + expect(calls).toBe(0) + }) +}) diff --git a/src/server/services/openaiModelCatalog.ts b/src/server/services/openaiModelCatalog.ts new file mode 100644 index 00000000..613a68c6 --- /dev/null +++ b/src/server/services/openaiModelCatalog.ts @@ -0,0 +1,11 @@ +import { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js' +import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js' + +/** Desktop discovery must use the same account as its official provider runtime. */ +export async function getDesktopOpenAICodexModelCatalog(options?: { + fetchOverride?: typeof fetch + forceRefresh?: boolean +}) { + const tokens = await hahaOpenAIOAuthService.ensureFreshTokens().catch(() => null) + return getOpenAICodexModelCatalog({ ...options, tokens }) +} diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index f5730049..18b218c8 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -32,7 +32,7 @@ import { } from '../services/providerRuntimeEnv.js' import { isOpenAIOfficialProviderId } from '../services/openaiOfficialProvider.js' import { isGrokOfficialProviderId } from '../services/grokOfficialProvider.js' -import { getOpenAICodexModelCatalog } from '../../services/openaiAuth/modelCatalog.js' +import { getDesktopOpenAICodexModelCatalog } from '../services/openaiModelCatalog.js' import { OPENAI_DEFAULT_MAIN_MODEL, getOpenAIModelCatalogEntry, @@ -4097,7 +4097,7 @@ type RuntimeSettings = { } async function getDefaultOpenAIReasoningEffort(modelId: string): Promise { - const catalog = await getOpenAICodexModelCatalog() + const catalog = await getDesktopOpenAICodexModelCatalog() return getOpenAIModelCatalogEntry(modelId, catalog)?.defaultReasoningEffort ?? 'medium' } @@ -4142,7 +4142,7 @@ async function resolveRuntimeEffort( return { valid: false } } - const catalog = await getOpenAICodexModelCatalog() + const catalog = await getDesktopOpenAICodexModelCatalog() const model = getOpenAIModelCatalogEntry(modelId, catalog) return !model || model.supportedReasoningEfforts.includes(effort) ? { valid: true, effort } diff --git a/src/services/openaiAuth/client.test.ts b/src/services/openaiAuth/client.test.ts index f7abe58d..feb3107d 100644 --- a/src/services/openaiAuth/client.test.ts +++ b/src/services/openaiAuth/client.test.ts @@ -75,7 +75,7 @@ describe('OpenAI Codex OAuth client', () => { 'application/x-www-form-urlencoded', ) expect(tokenRequestHeaders.get('User-Agent')).toBe( - OPENAI_CODEX_TOKEN_USER_AGENT, + 'codex-cli/0.153.4', ) expect(tokenRequestBody).toContain('grant_type=authorization_code') expect(tokenRequestBody).toContain('client_id=app_EMoamEEZ73f0CkXaXp7hrann') diff --git a/src/services/openaiAuth/client.ts b/src/services/openaiAuth/client.ts index de3853a2..1739a6c4 100644 --- a/src/services/openaiAuth/client.ts +++ b/src/services/openaiAuth/client.ts @@ -11,7 +11,7 @@ export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com' export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann' export const OPENAI_CODEX_API_ENDPOINT = 'https://chatgpt.com/backend-api/codex/responses' -export const OPENAI_CODEX_CLIENT_VERSION = '0.144.0' +export const OPENAI_CODEX_CLIENT_VERSION = '0.153.4' export const OPENAI_CODEX_ORIGINATOR = 'codex_cli_rs' export const OPENAI_CODEX_OAUTH_PORT = 1455 export const OPENAI_CODEX_REDIRECT_PATH = '/auth/callback' diff --git a/src/services/openaiAuth/modelCatalog.test.ts b/src/services/openaiAuth/modelCatalog.test.ts index 4edead08..b7541b8f 100644 --- a/src/services/openaiAuth/modelCatalog.test.ts +++ b/src/services/openaiAuth/modelCatalog.test.ts @@ -92,6 +92,34 @@ describe('OpenAI Codex model catalog', () => { ]) }) + test('uses explicitly supplied credentials instead of CLI storage', async () => { + const models = await getOpenAICodexModelCatalog({ + tokens: { accessToken: 'desktop-token', accountId: 'desktop-account' }, + forceRefresh: true, + fetchOverride: async (_input, init) => { + const headers = new Headers(init?.headers) + expect(headers.get('Authorization')).toBe('Bearer desktop-token') + expect(headers.get('ChatGPT-Account-Id')).toBe('desktop-account') + return Response.json({ models: [{ slug: 'gpt-6-astra', visibility: 'list' }] }) + }, + }) + expect(models.map(model => model.value)).toEqual(['gpt-6-astra']) + }) + + test('explicit logout never requests a catalog with CLI credentials', async () => { + let requests = 0 + const models = await getOpenAICodexModelCatalog({ + tokens: null, + forceRefresh: true, + fetchOverride: async () => { + requests += 1 + return Response.json({ models: [{ slug: 'cli-only', visibility: 'list' }] }) + }, + }) + expect(requests).toBe(0) + expect(models).toEqual(OPENAI_CODEX_MODEL_CATALOG) + }) + test('falls back to the bundled GPT-5.6 catalog when the endpoint fails', async () => { const models = await getOpenAICodexModelCatalog({ forceRefresh: true, diff --git a/src/services/openaiAuth/modelCatalog.ts b/src/services/openaiAuth/modelCatalog.ts index e672b245..0dae22ed 100644 --- a/src/services/openaiAuth/modelCatalog.ts +++ b/src/services/openaiAuth/modelCatalog.ts @@ -93,10 +93,19 @@ function normalizeRemoteModel(model: RemoteModelInfo): OpenAIModelCatalogEntry | } } +export type OpenAIModelCatalogTokens = { + accessToken: string + accountId?: string | null + email?: string | null +} + export async function fetchOpenAICodexModelCatalog( fetchOverride: typeof fetch = globalThis.fetch, + suppliedTokens?: OpenAIModelCatalogTokens | null, ): Promise { - const tokens = await ensureFreshOpenAITokens() + const tokens = suppliedTokens === undefined + ? await ensureFreshOpenAITokens() + : suppliedTokens if (!tokens) { throw new Error('OpenAI OAuth token is unavailable') } @@ -135,15 +144,17 @@ export async function fetchOpenAICodexModelCatalog( export async function getOpenAICodexModelCatalog(options?: { fetchOverride?: typeof fetch forceRefresh?: boolean + /** Explicit desktop credentials; null must never fall through to CLI storage. */ + tokens?: OpenAIModelCatalogTokens | null }): Promise { - const tokens = getOpenAIOAuthTokens() + const tokens = options?.tokens === undefined ? getOpenAIOAuthTokens() : options.tokens const accountKey = tokens ? tokens.accountId ?? tokens.email ?? 'authenticated-default' : 'logged-out' return catalogCache.resolve({ accountKey, fetchCatalog: async () => { - const models = await fetchOpenAICodexModelCatalog(options?.fetchOverride) + const models = await fetchOpenAICodexModelCatalog(options?.fetchOverride, options?.tokens) if (models.length === 0) { throw new Error('OpenAI models endpoint returned no visible models') }