mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(openai): use desktop OAuth credentials for model discovery
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user