fix(openai): use desktop OAuth credentials for model discovery

This commit is contained in:
程序员阿江(Relakkes)
2026-09-08 10:44:06 +08:00
parent 19991d8b01
commit 6c6c4d08a2
9 changed files with 206 additions and 10 deletions
+50
View File
@@ -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()
+3 -2
View File
@@ -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<ApiModelInfo[]> {
return buildOpenAIModelList(await getOpenAICodexModelCatalog())
return buildOpenAIModelList(await getDesktopOpenAICodexModelCatalog())
}
function buildGrokModelList(catalog: GrokModelCatalogEntry[]): ApiModelInfo[] {
@@ -236,7 +237,7 @@ async function getOpenAIAuthModels(): Promise<ApiModelInfo[]> {
return []
}
return getOpenAIModelList()
return buildOpenAIModelList(await getOpenAICodexModelCatalog())
}
async function getStandaloneModelList(): Promise<ApiModelInfo[]> {
@@ -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)
})
})
+11
View File
@@ -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 })
}
+3 -3
View File
@@ -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<string> {
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 }
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -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'
@@ -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,
+14 -3
View File
@@ -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<OpenAIModelCatalogEntry[]> {
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<OpenAIModelCatalogEntry[]> {
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')
}