mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge branch 'feat/chatgpt-official-oauth-provider'
This commit is contained in:
commit
dffe83b83a
@ -81,6 +81,10 @@ vi.mock('../components/settings/ClaudeOfficialLogin', () => ({
|
||||
ClaudeOfficialLogin: () => <div data-testid="claude-official-login" />,
|
||||
}))
|
||||
|
||||
vi.mock('../components/settings/ChatGPTOfficialLogin', () => ({
|
||||
ChatGPTOfficialLogin: () => <div data-testid="chatgpt-official-login" />,
|
||||
}))
|
||||
|
||||
vi.mock('../pages/AdapterSettings', () => ({
|
||||
AdapterSettings: () => <div>Adapter Settings Mock</div>,
|
||||
}))
|
||||
@ -913,6 +917,16 @@ describe('Settings > Providers tab', () => {
|
||||
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not query ChatGPT OAuth status before providers finish loading', () => {
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.activeId = 'openai-official'
|
||||
providerStoreState.hasLoadedProviders = false
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
expect(screen.queryByTestId('chatgpt-official-login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows official OAuth status only after official provider is confirmed active', () => {
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.activeId = null
|
||||
@ -923,6 +937,20 @@ describe('Settings > Providers tab', () => {
|
||||
expect(screen.getByTestId('claude-official-login')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows ChatGPT Official as the active built-in provider', () => {
|
||||
providerStoreState.providers = []
|
||||
providerStoreState.activeId = 'openai-official'
|
||||
providerStoreState.hasLoadedProviders = true
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
const openAIProvider = screen.getByTestId('openai-official-provider')
|
||||
expect(within(openAIProvider).getByText('ChatGPT Official')).toBeInTheDocument()
|
||||
expect(within(openAIProvider).getByText('Default')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('chatgpt-official-login')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('claude-official-login')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('requires confirmation before deleting a provider', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
|
||||
38
desktop/src/api/hahaOpenAIOAuth.ts
Normal file
38
desktop/src/api/hahaOpenAIOAuth.ts
Normal file
@ -0,0 +1,38 @@
|
||||
// desktop/src/api/hahaOpenAIOAuth.ts
|
||||
|
||||
import { api, getBaseUrl } from './client'
|
||||
|
||||
export type HahaOpenAIOAuthStatus =
|
||||
| { loggedIn: false }
|
||||
| {
|
||||
loggedIn: true
|
||||
expiresAt: number | null
|
||||
email: string | null
|
||||
accountId: string | null
|
||||
}
|
||||
|
||||
function currentServerPort(): number {
|
||||
const port = new URL(getBaseUrl()).port
|
||||
const parsed = Number.parseInt(port, 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error(`Cannot determine server port from baseUrl: ${getBaseUrl()}`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
export const hahaOpenAIOAuthApi = {
|
||||
start() {
|
||||
return api.post<{ authorizeUrl: string; state: string }>(
|
||||
'/api/haha-openai-oauth/start',
|
||||
{ serverPort: currentServerPort() },
|
||||
)
|
||||
},
|
||||
|
||||
status() {
|
||||
return api.get<HahaOpenAIOAuthStatus>('/api/haha-openai-oauth')
|
||||
},
|
||||
|
||||
logout() {
|
||||
return api.delete<{ ok: true }>('/api/haha-openai-oauth')
|
||||
},
|
||||
}
|
||||
@ -16,7 +16,7 @@ type PresetsResponse = { presets: ProviderPreset[] }
|
||||
type TestResultResponse = { result: ProviderTestResult }
|
||||
type AuthStatusResponse = {
|
||||
hasAuth: boolean
|
||||
source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none'
|
||||
source: 'cc-haha-provider' | 'openai-oauth' | 'original-settings' | 'env' | 'none'
|
||||
activeProvider?: string
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import { useChatStore } from '../../stores/chatStore'
|
||||
import { useProviderStore } from '../../stores/providerStore'
|
||||
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { OPENAI_OFFICIAL_PROVIDER_ID } from '../../constants/openaiOfficialProvider'
|
||||
import type { ModelInfo } from '../../types/settings'
|
||||
|
||||
const MODELS: ModelInfo[] = [
|
||||
@ -118,6 +119,56 @@ describe('ModelSelector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the ChatGPT Official catalog when that built-in provider is active', async () => {
|
||||
const openAIModels: ModelInfo[] = [
|
||||
{
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
description: 'Best for coding and agentic work',
|
||||
context: '',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
description: 'Latest general-purpose model',
|
||||
context: '',
|
||||
},
|
||||
]
|
||||
const setSessionRuntime = vi.fn()
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
availableModels: openAIModels,
|
||||
currentModel: openAIModels[0],
|
||||
activeProviderName: 'ChatGPT Official',
|
||||
})
|
||||
useProviderStore.setState({
|
||||
providers: [],
|
||||
activeId: OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
hasLoadedProviders: true,
|
||||
isLoading: true,
|
||||
})
|
||||
useChatStore.setState({
|
||||
setSessionRuntime,
|
||||
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||
|
||||
render(<ModelSelector runtimeKey="session-openai" />)
|
||||
|
||||
await clickByRole(/GPT-5\.3 Codex/i)
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /GPT-5\.5/ }))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(useSessionRuntimeStore.getState().selections['session-openai']).toEqual({
|
||||
providerId: OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
modelId: 'gpt-5.5',
|
||||
})
|
||||
expect(setSessionRuntime).toHaveBeenCalledWith('session-openai', {
|
||||
providerId: OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
modelId: 'gpt-5.5',
|
||||
})
|
||||
})
|
||||
|
||||
it('portals the dropdown outside clipping containers and positions it below the trigger', async () => {
|
||||
useSettingsStore.setState({
|
||||
locale: 'en',
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { OFFICIAL_DEFAULT_MODEL_ID, OFFICIAL_MODELS } from '../../constants/modelCatalog'
|
||||
import {
|
||||
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
OPENAI_OFFICIAL_MODELS,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
} from '../../constants/openaiOfficialProvider'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useProviderStore } from '../../stores/providerStore'
|
||||
@ -43,12 +48,17 @@ const VIEWPORT_MARGIN = 16
|
||||
const DROPDOWN_MAX_HEIGHT = 420
|
||||
const DROPDOWN_MIN_HEIGHT = 180
|
||||
|
||||
function officialChoices(availableModels: ModelInfo[], isDefault: boolean, officialName: string): ProviderChoice {
|
||||
function officialChoices(
|
||||
providerId: string | null,
|
||||
models: ModelInfo[],
|
||||
isDefault: boolean,
|
||||
officialName: string,
|
||||
): ProviderChoice {
|
||||
return {
|
||||
providerId: null,
|
||||
providerId,
|
||||
providerName: officialName,
|
||||
isDefault,
|
||||
models: availableModels.length > 0 ? availableModels : OFFICIAL_MODELS,
|
||||
models,
|
||||
}
|
||||
}
|
||||
|
||||
@ -89,10 +99,24 @@ function buildProviderChoices(
|
||||
activeId: string | null,
|
||||
availableModels: ModelInfo[],
|
||||
officialName: string,
|
||||
openAIOfficialName: string,
|
||||
labels: Record<'main' | 'haiku' | 'sonnet' | 'opus', string>,
|
||||
): ProviderChoice[] {
|
||||
const claudeOfficialModels = activeId === null && availableModels.length > 0
|
||||
? availableModels
|
||||
: OFFICIAL_MODELS
|
||||
const openAIOfficialModels = activeId === OPENAI_OFFICIAL_PROVIDER_ID && availableModels.length > 0
|
||||
? availableModels
|
||||
: OPENAI_OFFICIAL_MODELS
|
||||
|
||||
return [
|
||||
officialChoices(availableModels, activeId === null, officialName),
|
||||
officialChoices(null, claudeOfficialModels, activeId === null, officialName),
|
||||
officialChoices(
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
openAIOfficialModels,
|
||||
activeId === OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
openAIOfficialName,
|
||||
),
|
||||
...providers.map((provider) => ({
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
@ -116,7 +140,11 @@ function resolveDefaultRuntimeSelection(
|
||||
|
||||
return {
|
||||
providerId: inferredProviderId,
|
||||
modelId: currentModelId ?? OFFICIAL_DEFAULT_MODEL_ID,
|
||||
modelId: currentModelId ?? (
|
||||
inferredProviderId === OPENAI_OFFICIAL_PROVIDER_ID
|
||||
? OPENAI_OFFICIAL_DEFAULT_MODEL_ID
|
||||
: OFFICIAL_DEFAULT_MODEL_ID
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@ -258,8 +286,9 @@ export function ModelSelector({
|
||||
() => buildProviderChoices(
|
||||
providers,
|
||||
activeId,
|
||||
activeId === null ? availableModels : OFFICIAL_MODELS,
|
||||
availableModels,
|
||||
t('settings.providers.officialName'),
|
||||
t('settings.providers.openaiOfficialName'),
|
||||
roleLabels,
|
||||
),
|
||||
[activeId, availableModels, providers, roleLabels, t],
|
||||
|
||||
123
desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx
Normal file
123
desktop/src/components/settings/ChatGPTOfficialLogin.test.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
const {
|
||||
copyTextToClipboardMock,
|
||||
logoutMock,
|
||||
shellOpenMock,
|
||||
startMock,
|
||||
statusMock,
|
||||
} = vi.hoisted(() => ({
|
||||
copyTextToClipboardMock: vi.fn(),
|
||||
logoutMock: vi.fn(),
|
||||
shellOpenMock: vi.fn(),
|
||||
startMock: vi.fn(),
|
||||
statusMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@tauri-apps/plugin-shell', () => ({
|
||||
open: shellOpenMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../api/hahaOpenAIOAuth', () => ({
|
||||
hahaOpenAIOAuthApi: {
|
||||
start: startMock,
|
||||
status: statusMock,
|
||||
logout: logoutMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../chat/clipboard', () => ({
|
||||
copyTextToClipboard: copyTextToClipboardMock,
|
||||
}))
|
||||
|
||||
import { ChatGPTOfficialLogin } from './ChatGPTOfficialLogin'
|
||||
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
|
||||
const initialOAuthState = useHahaOpenAIOAuthStore.getState()
|
||||
|
||||
describe('ChatGPTOfficialLogin', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
startMock.mockReset()
|
||||
statusMock.mockReset()
|
||||
logoutMock.mockReset()
|
||||
shellOpenMock.mockReset()
|
||||
copyTextToClipboardMock.mockReset()
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useHahaOpenAIOAuthStore.setState({
|
||||
...initialOAuthState,
|
||||
status: null,
|
||||
isPolling: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
useHahaOpenAIOAuthStore.getState().stopPolling()
|
||||
useHahaOpenAIOAuthStore.setState(initialOAuthState)
|
||||
})
|
||||
vi.useRealTimers()
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps an actionable authorization link when shell open fails', async () => {
|
||||
const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state'
|
||||
statusMock.mockResolvedValue({ loggedIn: false })
|
||||
startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' })
|
||||
shellOpenMock.mockRejectedValue(new Error('shell unavailable'))
|
||||
copyTextToClipboardMock.mockResolvedValue(true)
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
render(<ChatGPTOfficialLogin />)
|
||||
|
||||
await screen.findByRole('button', { name: 'Sign in with ChatGPT' })
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' }))
|
||||
})
|
||||
|
||||
expect(shellOpenMock).toHaveBeenCalledWith(authorizeUrl)
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('[ChatGPTOfficialLogin] shellOpen failed:', expect.any(Error))
|
||||
expect(screen.getByText(/Unable to open browser/)).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' }))
|
||||
})
|
||||
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl)
|
||||
expect(useHahaOpenAIOAuthStore.getState().error).toBeNull()
|
||||
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true)
|
||||
expect(screen.queryByText(/Unable to open browser/)).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Copy authorization link' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the authorization link available when copy fails', async () => {
|
||||
const authorizeUrl = 'https://chatgpt.com/oauth/authorize?state=openai-state'
|
||||
statusMock.mockResolvedValue({ loggedIn: false })
|
||||
startMock.mockResolvedValue({ authorizeUrl, state: 'openai-state' })
|
||||
shellOpenMock.mockRejectedValue(new Error('shell unavailable'))
|
||||
copyTextToClipboardMock.mockResolvedValue(false)
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
render(<ChatGPTOfficialLogin />)
|
||||
|
||||
await screen.findByRole('button', { name: 'Sign in with ChatGPT' })
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in with ChatGPT' }))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy authorization link' }))
|
||||
})
|
||||
|
||||
expect(copyTextToClipboardMock).toHaveBeenCalledWith(authorizeUrl)
|
||||
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false)
|
||||
expect(screen.getByText(/Unable to copy authorization link/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Copy authorization link' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
144
desktop/src/components/settings/ChatGPTOfficialLogin.tsx
Normal file
144
desktop/src/components/settings/ChatGPTOfficialLogin.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
// desktop/src/components/settings/ChatGPTOfficialLogin.tsx
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { open as shellOpen } from '@tauri-apps/plugin-shell'
|
||||
import { Copy, LogIn, LogOut } from 'lucide-react'
|
||||
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { copyTextToClipboard } from '../chat/clipboard'
|
||||
|
||||
export function ChatGPTOfficialLogin() {
|
||||
const t = useTranslation()
|
||||
const [manualAuthorizeUrl, setManualAuthorizeUrl] = useState<string | null>(null)
|
||||
const {
|
||||
status,
|
||||
isLoading,
|
||||
error,
|
||||
fetchStatus,
|
||||
login,
|
||||
logout,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
} = useHahaOpenAIOAuthStore()
|
||||
|
||||
useEffect(() => {
|
||||
void fetchStatus()
|
||||
return () => stopPolling()
|
||||
}, [fetchStatus, stopPolling])
|
||||
|
||||
useEffect(() => {
|
||||
if (status?.loggedIn) {
|
||||
setManualAuthorizeUrl(null)
|
||||
}
|
||||
}, [status?.loggedIn])
|
||||
|
||||
const handleLogin = async () => {
|
||||
setManualAuthorizeUrl(null)
|
||||
try {
|
||||
const { authorizeUrl } = await login()
|
||||
setManualAuthorizeUrl(authorizeUrl)
|
||||
try {
|
||||
await shellOpen(authorizeUrl)
|
||||
setManualAuthorizeUrl(null)
|
||||
startPolling()
|
||||
} catch (err) {
|
||||
console.error('[ChatGPTOfficialLogin] shellOpen failed:', err)
|
||||
useHahaOpenAIOAuthStore.setState({
|
||||
error: t('settings.chatgptOfficialLogin.openBrowserFailed'),
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// store.login() errors are already captured into store.error
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyAuthorizeUrl = async () => {
|
||||
if (!manualAuthorizeUrl) return
|
||||
const copied = await copyTextToClipboard(manualAuthorizeUrl)
|
||||
if (copied) {
|
||||
setManualAuthorizeUrl(null)
|
||||
useHahaOpenAIOAuthStore.setState({ error: null })
|
||||
startPolling()
|
||||
return
|
||||
}
|
||||
useHahaOpenAIOAuthStore.setState({
|
||||
error: t('settings.chatgptOfficialLogin.copyLinkFailed'),
|
||||
})
|
||||
}
|
||||
|
||||
const manualAuthorizeButton = manualAuthorizeUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyAuthorizeUrl}
|
||||
className="inline-flex items-center gap-1.5 self-start rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1.5 text-xs transition-colors hover:bg-[var(--color-surface-hover)]"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('settings.chatgptOfficialLogin.copyAuthorizeUrl')}
|
||||
</button>
|
||||
) : null
|
||||
|
||||
if (status === null) {
|
||||
if (error) {
|
||||
return (
|
||||
<div data-testid="chatgpt-official-login" className="flex flex-col gap-2">
|
||||
<div className="text-xs text-[var(--color-error)]">
|
||||
{t('settings.chatgptOfficialLogin.errorPrefix')}{error}
|
||||
</div>
|
||||
{manualAuthorizeButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div data-testid="chatgpt-official-login" className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status.loggedIn) {
|
||||
const accountLabel = status.email || status.accountId || t('settings.chatgptOfficialLogin.accountUnknown')
|
||||
return (
|
||||
<div data-testid="chatgpt-official-login" className="flex items-center gap-3 text-sm">
|
||||
<span className="text-[var(--color-success)]">
|
||||
{t('settings.chatgptOfficialLogin.loggedInPrefix')} {accountLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border-separator)] bg-[var(--color-surface)] px-3 py-1 text-xs transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{isLoading
|
||||
? t('settings.chatgptOfficialLogin.logoutProcessing')
|
||||
: t('settings.chatgptOfficialLogin.logoutButton')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="chatgpt-official-login" className="flex flex-col gap-2">
|
||||
<div className="text-sm text-[var(--color-text-secondary)]">
|
||||
{t('settings.chatgptOfficialLogin.intro')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 self-start rounded-md bg-[image:var(--gradient-btn-primary)] px-4 py-2 text-sm text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-opacity hover:brightness-105 disabled:opacity-50"
|
||||
>
|
||||
<LogIn className="h-4 w-4" aria-hidden="true" />
|
||||
{isLoading
|
||||
? t('settings.chatgptOfficialLogin.loginStarting')
|
||||
: t('settings.chatgptOfficialLogin.loginButton')}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--color-error)]">
|
||||
{t('settings.chatgptOfficialLogin.errorPrefix')}{error}
|
||||
</div>
|
||||
)}
|
||||
{manualAuthorizeButton}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
desktop/src/constants/openaiOfficialProvider.ts
Normal file
32
desktop/src/constants/openaiOfficialProvider.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { ModelInfo } from '../types/settings'
|
||||
|
||||
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
|
||||
export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex'
|
||||
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'
|
||||
|
||||
export const OPENAI_OFFICIAL_MODELS: ModelInfo[] = [
|
||||
{
|
||||
id: OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
name: 'GPT-5.3 Codex',
|
||||
description: 'Best for coding and agentic work',
|
||||
context: '',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
description: 'Strong general-purpose model',
|
||||
context: '',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
description: 'Latest general-purpose model',
|
||||
context: '',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-mini',
|
||||
name: 'GPT-5.4 Mini',
|
||||
description: 'Fastest for quick tasks',
|
||||
context: '',
|
||||
},
|
||||
]
|
||||
@ -273,12 +273,27 @@ export const en = {
|
||||
'settings.claudeOfficialLogin.errorPrefix': 'Error: ',
|
||||
'settings.claudeOfficialLogin.openBrowserFailed': 'Failed to open browser; please visit the authorization URL manually.',
|
||||
|
||||
// Settings > ChatGPT Official Login
|
||||
'settings.chatgptOfficialLogin.intro': 'Sign in with ChatGPT to use GPT models from desktop sessions.',
|
||||
'settings.chatgptOfficialLogin.loginButton': 'Sign in with ChatGPT',
|
||||
'settings.chatgptOfficialLogin.loginStarting': 'Starting sign-in...',
|
||||
'settings.chatgptOfficialLogin.logoutButton': 'Sign out',
|
||||
'settings.chatgptOfficialLogin.logoutProcessing': 'Signing out...',
|
||||
'settings.chatgptOfficialLogin.loggedInPrefix': 'Signed in as',
|
||||
'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT account',
|
||||
'settings.chatgptOfficialLogin.openBrowserFailed': 'Unable to open browser. Copy the authorization link and open it manually.',
|
||||
'settings.chatgptOfficialLogin.copyAuthorizeUrl': 'Copy authorization link',
|
||||
'settings.chatgptOfficialLogin.copyLinkFailed': 'Unable to copy authorization link.',
|
||||
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth error: ',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': 'Providers',
|
||||
'settings.providers.description': 'Manage API providers for model access.',
|
||||
'settings.providers.addProvider': 'Add Provider',
|
||||
'settings.providers.officialName': 'Claude Official',
|
||||
'settings.providers.officialDesc': 'Anthropic native — no API key required',
|
||||
'settings.providers.openaiOfficialName': 'ChatGPT Official',
|
||||
'settings.providers.openaiOfficialDesc': 'OpenAI OAuth via your ChatGPT account — no API key required',
|
||||
'settings.providers.connected': 'Connected ({latency}ms)',
|
||||
'settings.providers.failed': 'Failed: {error}',
|
||||
'settings.providers.connectivityOk': '① Connectivity ({latency}ms)',
|
||||
|
||||
@ -275,12 +275,27 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.claudeOfficialLogin.errorPrefix': '错误:',
|
||||
'settings.claudeOfficialLogin.openBrowserFailed': '无法打开浏览器,请手动访问授权链接。',
|
||||
|
||||
// Settings > ChatGPT Official Login
|
||||
'settings.chatgptOfficialLogin.intro': '登录 ChatGPT 后,就可以在桌面端会话里使用 GPT 模型。',
|
||||
'settings.chatgptOfficialLogin.loginButton': '登录 ChatGPT',
|
||||
'settings.chatgptOfficialLogin.loginStarting': '正在启动登录...',
|
||||
'settings.chatgptOfficialLogin.logoutButton': '退出登录',
|
||||
'settings.chatgptOfficialLogin.logoutProcessing': '正在退出...',
|
||||
'settings.chatgptOfficialLogin.loggedInPrefix': '已登录',
|
||||
'settings.chatgptOfficialLogin.accountUnknown': 'ChatGPT 账号',
|
||||
'settings.chatgptOfficialLogin.openBrowserFailed': '无法打开浏览器。请复制授权链接并手动打开。',
|
||||
'settings.chatgptOfficialLogin.copyAuthorizeUrl': '复制授权链接',
|
||||
'settings.chatgptOfficialLogin.copyLinkFailed': '无法复制授权链接。',
|
||||
'settings.chatgptOfficialLogin.errorPrefix': 'ChatGPT OAuth 错误:',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': '服务商',
|
||||
'settings.providers.description': '管理 API 服务商以访问模型。',
|
||||
'settings.providers.addProvider': '添加服务商',
|
||||
'settings.providers.officialName': 'Claude 官方',
|
||||
'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥',
|
||||
'settings.providers.openaiOfficialName': 'ChatGPT 官方',
|
||||
'settings.providers.openaiOfficialDesc': '通过 ChatGPT 账号完成 OpenAI OAuth — 无需 API 密钥',
|
||||
'settings.providers.connected': '已连接 ({latency}ms)',
|
||||
'settings.providers.failed': '失败: {error}',
|
||||
'settings.providers.connectivityOk': '① 连通 ({latency}ms)',
|
||||
|
||||
@ -52,6 +52,8 @@ describe('provider settings JSON helpers', () => {
|
||||
ANTHROPIC_BASE_URL: 'https://old.example.com',
|
||||
ANTHROPIC_MODEL: 'old-model',
|
||||
CLAUDE_CODE_MODEL_CONTEXT_WINDOWS: '{"old":100000}',
|
||||
CC_HAHA_OPENAI_OAUTH_PROVIDER: '1',
|
||||
OPENAI_CODEX_OAUTH_FILE: '/tmp/openai-oauth.json',
|
||||
CC_HAHA_SEND_DISABLED_THINKING: '1',
|
||||
USER_DEFINED: 'keep-me',
|
||||
},
|
||||
|
||||
@ -20,9 +20,11 @@ const PROVIDER_SETTINGS_JSON_ENV_KEYS = new Set([
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME',
|
||||
'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES',
|
||||
'ANTHROPIC_SMALL_FAST_MODEL',
|
||||
'CC_HAHA_OPENAI_OAUTH_PROVIDER',
|
||||
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
|
||||
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
|
||||
'CLAUDE_CODE_SUBAGENT_MODEL',
|
||||
'OPENAI_CODEX_OAUTH_FILE',
|
||||
])
|
||||
|
||||
function getEnvRecord(raw: string): Record<string, unknown> | null {
|
||||
|
||||
@ -32,6 +32,8 @@ import { ActivitySettings } from './ActivitySettings'
|
||||
import { MemorySettings } from './MemorySettings'
|
||||
import { useUIStore, type SettingsTab } from '../stores/uiStore'
|
||||
import { ClaudeOfficialLogin } from '../components/settings/ClaudeOfficialLogin'
|
||||
import { ChatGPTOfficialLogin } from '../components/settings/ChatGPTOfficialLogin'
|
||||
import { OPENAI_OFFICIAL_PROVIDER_ID } from '../constants/openaiOfficialProvider'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
import { formatBytes } from '../lib/formatBytes'
|
||||
import { isTauriRuntime } from '../lib/desktopRuntime'
|
||||
@ -216,7 +218,8 @@ function ProviderSettings() {
|
||||
await fetchSettings()
|
||||
}
|
||||
|
||||
const isOfficialActive = hasLoadedProviders && activeId === null
|
||||
const isClaudeOfficialActive = hasLoadedProviders && activeId === null
|
||||
const isOpenAIOfficialActive = hasLoadedProviders && activeId === OPENAI_OFFICIAL_PROVIDER_ID
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
@ -233,21 +236,22 @@ function ProviderSettings() {
|
||||
|
||||
{/* Official provider — always visible at top */}
|
||||
<div
|
||||
data-testid="claude-official-provider"
|
||||
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
|
||||
isOfficialActive
|
||||
isClaudeOfficialActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-3.5"
|
||||
onClick={() => !isOfficialActive && handleActivateOfficial()}
|
||||
onClick={() => !isClaudeOfficialActive && handleActivateOfficial()}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isClaudeOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.officialName')}</span>
|
||||
{isOfficialActive && (
|
||||
{isClaudeOfficialActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
|
||||
)}
|
||||
</div>
|
||||
@ -255,13 +259,44 @@ function ProviderSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOfficialActive && (
|
||||
{isClaudeOfficialActive && (
|
||||
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
|
||||
<ClaudeOfficialLogin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="openai-official-provider"
|
||||
className={`relative flex flex-col rounded-xl border transition-all mb-2 ${
|
||||
isOpenAIOfficialActive
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface-container)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] hover:border-[var(--color-border-focus)] cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-4 px-4 py-3.5"
|
||||
onClick={() => !isOpenAIOfficialActive && handleActivate(OPENAI_OFFICIAL_PROVIDER_ID)}
|
||||
>
|
||||
<span className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${isOpenAIOfficialActive ? 'bg-[var(--color-success)]' : 'bg-[var(--color-text-tertiary)]'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.providers.openaiOfficialName')}</span>
|
||||
{isOpenAIOfficialActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded border border-[var(--color-brand)]/18 bg-[var(--color-brand)]/14 text-[var(--color-brand)] leading-none">{t('settings.providers.default')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">{t('settings.providers.openaiOfficialDesc')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpenAIOfficialActive && (
|
||||
<div className="px-4 pb-4 pt-3 border-t border-[var(--color-border-separator)]">
|
||||
<ChatGPTOfficialLogin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Saved providers */}
|
||||
{isLoading && providers.length === 0 ? (
|
||||
<div className="flex justify-center py-8">
|
||||
|
||||
97
desktop/src/stores/hahaOpenAIOAuthStore.test.ts
Normal file
97
desktop/src/stores/hahaOpenAIOAuthStore.test.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { startMock, statusMock, logoutMock } = vi.hoisted(() => ({
|
||||
startMock: vi.fn(),
|
||||
statusMock: vi.fn(),
|
||||
logoutMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/hahaOpenAIOAuth', () => ({
|
||||
hahaOpenAIOAuthApi: {
|
||||
start: startMock,
|
||||
status: statusMock,
|
||||
logout: logoutMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { useHahaOpenAIOAuthStore } from './hahaOpenAIOAuthStore'
|
||||
|
||||
const initialState = useHahaOpenAIOAuthStore.getState()
|
||||
|
||||
describe('hahaOpenAIOAuthStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
startMock.mockReset()
|
||||
statusMock.mockReset()
|
||||
logoutMock.mockReset()
|
||||
useHahaOpenAIOAuthStore.setState({
|
||||
...initialState,
|
||||
status: null,
|
||||
isPolling: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useHahaOpenAIOAuthStore.getState().stopPolling()
|
||||
useHahaOpenAIOAuthStore.setState(initialState)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('login returns authorizeUrl without starting polling', async () => {
|
||||
startMock.mockResolvedValue({
|
||||
authorizeUrl: 'http://localhost:3456/callback/openai?state=openai-state',
|
||||
state: 'openai-state',
|
||||
})
|
||||
|
||||
const result = await useHahaOpenAIOAuthStore.getState().login()
|
||||
|
||||
expect(result.authorizeUrl).toContain('/callback/openai')
|
||||
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false)
|
||||
})
|
||||
|
||||
it('startPolling stops after OpenAI OAuth status becomes logged in', async () => {
|
||||
statusMock
|
||||
.mockResolvedValueOnce({ loggedIn: false })
|
||||
.mockResolvedValueOnce({
|
||||
loggedIn: true,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
|
||||
useHahaOpenAIOAuthStore.getState().startPolling()
|
||||
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(true)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
expect(useHahaOpenAIOAuthStore.getState().status).toMatchObject({
|
||||
loggedIn: true,
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_123',
|
||||
})
|
||||
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false)
|
||||
})
|
||||
|
||||
it('logout clears status and stops polling', async () => {
|
||||
logoutMock.mockResolvedValue({ ok: true })
|
||||
useHahaOpenAIOAuthStore.setState({
|
||||
status: {
|
||||
loggedIn: true,
|
||||
expiresAt: Date.now() + 60_000,
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_123',
|
||||
},
|
||||
})
|
||||
useHahaOpenAIOAuthStore.getState().startPolling()
|
||||
|
||||
await useHahaOpenAIOAuthStore.getState().logout()
|
||||
|
||||
expect(logoutMock).toHaveBeenCalledTimes(1)
|
||||
expect(useHahaOpenAIOAuthStore.getState().status).toEqual({ loggedIn: false })
|
||||
expect(useHahaOpenAIOAuthStore.getState().isPolling).toBe(false)
|
||||
})
|
||||
})
|
||||
101
desktop/src/stores/hahaOpenAIOAuthStore.ts
Normal file
101
desktop/src/stores/hahaOpenAIOAuthStore.ts
Normal file
@ -0,0 +1,101 @@
|
||||
// desktop/src/stores/hahaOpenAIOAuthStore.ts
|
||||
|
||||
import { create } from 'zustand'
|
||||
import {
|
||||
hahaOpenAIOAuthApi,
|
||||
type HahaOpenAIOAuthStatus,
|
||||
} from '../api/hahaOpenAIOAuth'
|
||||
|
||||
const POLL_INTERVAL_MS = 2_000
|
||||
|
||||
type HahaOpenAIOAuthState = {
|
||||
status: HahaOpenAIOAuthStatus | null
|
||||
isPolling: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
fetchStatus: () => Promise<void>
|
||||
login: () => Promise<{ authorizeUrl: string }>
|
||||
logout: () => Promise<void>
|
||||
startPolling: () => void
|
||||
stopPolling: () => void
|
||||
}
|
||||
|
||||
export const useHahaOpenAIOAuthStore = create<HahaOpenAIOAuthState>((set, get) => {
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return {
|
||||
status: null,
|
||||
isPolling: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchStatus: async () => {
|
||||
try {
|
||||
const status = await hahaOpenAIOAuthApi.status()
|
||||
set({ status, error: null })
|
||||
} catch (err) {
|
||||
set({ error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
},
|
||||
|
||||
login: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const res = await hahaOpenAIOAuthApi.start()
|
||||
set({ isLoading: false })
|
||||
return { authorizeUrl: res.authorizeUrl }
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
get().stopPolling()
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
await hahaOpenAIOAuthApi.logout()
|
||||
set({ status: { loggedIn: false }, isLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
startPolling: () => {
|
||||
if (pollTimer) return
|
||||
set({ isPolling: true })
|
||||
|
||||
const scheduleNext = () => {
|
||||
pollTimer = setTimeout(async () => {
|
||||
pollTimer = null
|
||||
await get().fetchStatus()
|
||||
const cur = get().status
|
||||
if (cur && cur.loggedIn) {
|
||||
get().stopPolling()
|
||||
return
|
||||
}
|
||||
if (get().isPolling) {
|
||||
scheduleNext()
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
}
|
||||
scheduleNext()
|
||||
},
|
||||
|
||||
stopPolling: () => {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
set({ isPolling: false })
|
||||
},
|
||||
}
|
||||
})
|
||||
@ -7,6 +7,8 @@ const {
|
||||
runtimeStoreState,
|
||||
setSessionRuntimeMock,
|
||||
setSelectionMock,
|
||||
settingsSetModelMock,
|
||||
settingsFetchAllMock,
|
||||
} = vi.hoisted(() => ({
|
||||
providersApiMock: {
|
||||
list: vi.fn(),
|
||||
@ -32,6 +34,8 @@ const {
|
||||
},
|
||||
setSessionRuntimeMock: vi.fn(),
|
||||
setSelectionMock: vi.fn(),
|
||||
settingsSetModelMock: vi.fn(),
|
||||
settingsFetchAllMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api/providers', () => ({
|
||||
@ -59,8 +63,8 @@ vi.mock('./sessionRuntimeStore', () => ({
|
||||
vi.mock('./settingsStore', () => ({
|
||||
useSettingsStore: {
|
||||
getState: () => ({
|
||||
setModel: vi.fn(),
|
||||
fetchAll: vi.fn(),
|
||||
setModel: settingsSetModelMock,
|
||||
fetchAll: settingsFetchAllMock,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
@ -147,4 +151,33 @@ describe('providerStore runtime refresh', () => {
|
||||
expect(setSelectionMock).not.toHaveBeenCalled()
|
||||
expect(setSessionRuntimeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets the OpenAI default model when activating built-in ChatGPT Official', async () => {
|
||||
providersApiMock.activate.mockResolvedValue({ ok: true })
|
||||
providersApiMock.list.mockResolvedValue({
|
||||
providers: [],
|
||||
activeId: 'openai-official',
|
||||
})
|
||||
|
||||
const { useProviderStore } = await import('./providerStore')
|
||||
await useProviderStore.getState().activateProvider('openai-official')
|
||||
|
||||
expect(settingsSetModelMock).toHaveBeenCalledWith('gpt-5.3-codex')
|
||||
expect(settingsFetchAllMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets the provider main model when activating a saved provider', async () => {
|
||||
const provider = makeProvider()
|
||||
providersApiMock.activate.mockResolvedValue({ ok: true })
|
||||
providersApiMock.list.mockResolvedValue({
|
||||
providers: [provider],
|
||||
activeId: provider.id,
|
||||
})
|
||||
|
||||
const { useProviderStore } = await import('./providerStore')
|
||||
await useProviderStore.getState().activateProvider(provider.id)
|
||||
|
||||
expect(settingsSetModelMock).toHaveBeenCalledWith('model-main')
|
||||
expect(settingsFetchAllMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,6 +6,10 @@ import { useChatStore } from './chatStore'
|
||||
import { useSessionRuntimeStore } from './sessionRuntimeStore'
|
||||
import { useSettingsStore } from './settingsStore'
|
||||
import { OFFICIAL_DEFAULT_MODEL_ID } from '../constants/modelCatalog'
|
||||
import {
|
||||
OPENAI_OFFICIAL_DEFAULT_MODEL_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
} from '../constants/openaiOfficialProvider'
|
||||
import type {
|
||||
SavedProvider,
|
||||
CreateProviderInput,
|
||||
@ -145,12 +149,17 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
await get().fetchProviders()
|
||||
// 更新默认 provider 时,同步刷新默认 model,避免 settings.json 里残留
|
||||
// 旧 provider 的 model id 导致默认选择指向不存在的模型。
|
||||
const provider = get().providers.find((p) => p.id === id)
|
||||
if (provider) {
|
||||
const settings = useSettingsStore.getState()
|
||||
await settings.setModel(provider.models.main)
|
||||
const settings = useSettingsStore.getState()
|
||||
if (id === OPENAI_OFFICIAL_PROVIDER_ID) {
|
||||
await settings.setModel(OPENAI_OFFICIAL_DEFAULT_MODEL_ID)
|
||||
await settings.fetchAll()
|
||||
return
|
||||
}
|
||||
|
||||
const provider = get().providers.find((p) => p.id === id)
|
||||
if (!provider) return
|
||||
await settings.setModel(provider.models.main)
|
||||
await settings.fetchAll()
|
||||
},
|
||||
|
||||
activateOfficial: async () => {
|
||||
|
||||
@ -9,6 +9,8 @@ export type ProviderAuthStrategy =
|
||||
| 'dual_same_token'
|
||||
| 'dual_dummy'
|
||||
|
||||
export type ProviderRuntimeKind = 'anthropic_compatible' | 'openai_oauth'
|
||||
|
||||
export type ModelMapping = {
|
||||
main: string
|
||||
haiku: string
|
||||
@ -26,6 +28,7 @@ export type SavedProvider = {
|
||||
authStrategy?: ProviderAuthStrategy
|
||||
baseUrl: string
|
||||
apiFormat: ApiFormat
|
||||
runtimeKind?: ProviderRuntimeKind
|
||||
models: ModelMapping
|
||||
autoCompactWindow?: number
|
||||
modelContextWindows?: ModelContextWindows
|
||||
@ -39,6 +42,7 @@ export type CreateProviderInput = {
|
||||
authStrategy?: ProviderAuthStrategy
|
||||
baseUrl: string
|
||||
apiFormat?: ApiFormat
|
||||
runtimeKind?: ProviderRuntimeKind
|
||||
models: ModelMapping
|
||||
autoCompactWindow?: number
|
||||
modelContextWindows?: ModelContextWindows
|
||||
@ -51,6 +55,7 @@ export type UpdateProviderInput = {
|
||||
authStrategy?: ProviderAuthStrategy
|
||||
baseUrl?: string
|
||||
apiFormat?: ApiFormat
|
||||
runtimeKind?: ProviderRuntimeKind
|
||||
models?: ModelMapping
|
||||
autoCompactWindow?: number | null
|
||||
modelContextWindows?: ModelContextWindows | null
|
||||
|
||||
@ -376,6 +376,49 @@ describe('ConversationService', () => {
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('forced-official-token')
|
||||
})
|
||||
|
||||
test('buildChildEnv does not inject Claude OAuth when ChatGPT Official is active', async () => {
|
||||
const providerService = new ProviderService()
|
||||
await providerService.activateProvider('openai-official')
|
||||
|
||||
const { hahaOAuthService } = await import('../services/hahaOAuthService.js')
|
||||
await hahaOAuthService.saveTokens({
|
||||
accessToken: 'claude-oauth-token-that-must-not-be-used',
|
||||
refreshToken: 'claude-refresh-token',
|
||||
expiresAt: Date.now() + 30 * 60_000,
|
||||
scopes: ['user:inference'],
|
||||
subscriptionType: 'max',
|
||||
})
|
||||
|
||||
const service = new ConversationService() as any
|
||||
const env = (await service.buildChildEnv('/tmp')) as Record<string, string>
|
||||
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildChildEnv injects ChatGPT Official runtime env for session-scoped provider selection', async () => {
|
||||
const service = new ConversationService() as any
|
||||
const env = (await service.buildChildEnv('/tmp', undefined, {
|
||||
providerId: 'openai-official',
|
||||
})) as Record<string, string>
|
||||
|
||||
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1')
|
||||
expect(env.OPENAI_CODEX_OAUTH_FILE).toBe(
|
||||
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
|
||||
)
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex')
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4')
|
||||
expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
|
||||
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined()
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildChildEnv does not leak inherited CLAUDE_CODE_OAUTH_TOKEN when official token is unavailable', async () => {
|
||||
const ccHahaDir = path.join(tmpDir, 'cc-haha')
|
||||
await fs.mkdir(ccHahaDir, { recursive: true })
|
||||
|
||||
@ -6,8 +6,11 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { createServer } from 'net'
|
||||
import { handleHahaOpenAIOAuthApi } from '../api/haha-openai-oauth.js'
|
||||
import { hahaOpenAIOAuthService } from '../services/hahaOpenAIOAuthService.js'
|
||||
import { startServer } from '../index.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
@ -44,6 +47,22 @@ function buildReq(
|
||||
return { req, url, segments }
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate test port')))
|
||||
return
|
||||
}
|
||||
const port = address.port
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('POST /api/haha-openai-oauth/start', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
@ -157,3 +176,24 @@ describe('DELETE /api/haha-openai-oauth', () => {
|
||||
expect(await hahaOpenAIOAuthService.loadTokens()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /auth/callback', () => {
|
||||
beforeEach(setup)
|
||||
afterEach(teardown)
|
||||
|
||||
test('routes the OpenAI Codex redirect path to the desktop callback page', async () => {
|
||||
const port = await getFreePort()
|
||||
const originalServerPort = ProviderService.getServerPort()
|
||||
const server = startServer(port, '127.0.0.1')
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/auth/callback`)
|
||||
expect(res.status).toBe(200)
|
||||
const html = await res.text()
|
||||
expect(html).toContain('OpenAI Login Failed')
|
||||
expect(html).toContain('Missing code or state parameter')
|
||||
} finally {
|
||||
server.stop(true)
|
||||
ProviderService.setServerPort(originalServerPort)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
* Unit tests for HahaOpenAIOAuthService — haha 自管 OpenAI OAuth 的核心 service 层。
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import {
|
||||
HahaOpenAIOAuthService,
|
||||
getHahaOpenAIOAuthFilePath,
|
||||
type StoredOpenAIOAuthTokens,
|
||||
} from '../services/hahaOpenAIOAuthService.js'
|
||||
|
||||
@ -46,12 +47,14 @@ describe('HahaOpenAIOAuthService — file storage', () => {
|
||||
accessToken: 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.mock-access',
|
||||
refreshToken: 'eyJhbGciOiJSUzI1NiJ9.mock-refresh',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
idToken: 'mock-id-token',
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
||||
}
|
||||
await service.saveTokens(tokens)
|
||||
|
||||
const oauthPath = path.join(tmpDir, 'cc-haha', 'openai-oauth.json')
|
||||
const oauthPath = getHahaOpenAIOAuthFilePath()
|
||||
const stat = await fs.stat(oauthPath)
|
||||
if (process.platform !== 'win32') {
|
||||
expect(stat.mode & 0o777).toBe(0o600)
|
||||
@ -72,6 +75,36 @@ describe('HahaOpenAIOAuthService — file storage', () => {
|
||||
await service.deleteTokens()
|
||||
expect(await service.loadTokens()).toBeNull()
|
||||
})
|
||||
|
||||
test('saveTokens cleans up tmp file when rename fails', async () => {
|
||||
const renameSpy = spyOn(fs, 'rename').mockImplementation(async () => {
|
||||
const error = new Error('rename failed') as NodeJS.ErrnoException
|
||||
error.code = 'EXDEV'
|
||||
throw error
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(
|
||||
service.saveTokens({
|
||||
accessToken: 'sensitive-access',
|
||||
refreshToken: 'sensitive-refresh',
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
idToken: 'sensitive-id-token',
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
}),
|
||||
).rejects.toThrow('rename failed')
|
||||
} finally {
|
||||
renameSpy.mockRestore()
|
||||
}
|
||||
|
||||
const oauthPath = getHahaOpenAIOAuthFilePath()
|
||||
const files = await fs.readdir(path.dirname(oauthPath))
|
||||
expect(
|
||||
files.filter((name) => name.startsWith('openai-oauth.json.tmp.')),
|
||||
).toEqual([])
|
||||
expect(await service.loadTokens()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('HahaOpenAIOAuthService — session management', () => {
|
||||
@ -167,6 +200,31 @@ describe('HahaOpenAIOAuthService — ensureFreshAccessToken', () => {
|
||||
expect(loaded?.accessToken).toBe('new-fresh-token')
|
||||
})
|
||||
|
||||
test('preserves existing refresh token and id token when refresh omits them', async () => {
|
||||
await service.saveTokens({
|
||||
accessToken: 'expired',
|
||||
refreshToken: 'refresh-to-preserve',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
idToken: 'id-token-to-preserve',
|
||||
email: 'test@example.com',
|
||||
accountId: 'acct_123',
|
||||
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
||||
})
|
||||
|
||||
service.setRefreshFn(async () => ({
|
||||
access_token: 'new-access-token',
|
||||
expires_in: 3600,
|
||||
}))
|
||||
|
||||
const fresh = await service.ensureFreshAccessToken()
|
||||
expect(fresh).toBe('new-access-token')
|
||||
|
||||
const loaded = await service.loadTokens()
|
||||
expect(loaded?.refreshToken).toBe('refresh-to-preserve')
|
||||
expect(loaded?.idToken).toBe('id-token-to-preserve')
|
||||
expect(loaded?.clientId).toBe('app_EMoamEEZ73f0CkXaXp7hrann')
|
||||
})
|
||||
|
||||
test('returns null when refresh fails', async () => {
|
||||
await service.saveTokens({
|
||||
accessToken: 'expired',
|
||||
|
||||
@ -301,6 +301,143 @@ describe('ProviderService', () => {
|
||||
expect(fetched.name).toBe(added.name)
|
||||
})
|
||||
|
||||
describe('ChatGPT Official provider metadata', () => {
|
||||
test('normalizes the built-in ChatGPT provider as an active provider id', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'providers.json'),
|
||||
JSON.stringify({ activeId: 'openai-official', providers: [] }),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const svc = new ProviderService()
|
||||
const result = await svc.listProviders()
|
||||
|
||||
expect(result.activeId).toBe('openai-official')
|
||||
expect(result.providers).toEqual([])
|
||||
})
|
||||
|
||||
test('returns built-in ChatGPT provider metadata without persisting secrets', async () => {
|
||||
const svc = new ProviderService()
|
||||
const provider = await svc.getProvider('openai-official')
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
id: 'openai-official',
|
||||
presetId: 'openai-official',
|
||||
name: 'ChatGPT Official',
|
||||
apiKey: '',
|
||||
apiFormat: 'openai_responses',
|
||||
runtimeKind: 'openai_oauth',
|
||||
models: {
|
||||
main: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5.4-mini',
|
||||
sonnet: 'gpt-5.4',
|
||||
opus: 'gpt-5.3-codex',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('activating ChatGPT Official writes OpenAI OAuth runtime env without Anthropic auth or proxy env', async () => {
|
||||
const svc = new ProviderService()
|
||||
|
||||
await svc.activateProvider('openai-official')
|
||||
|
||||
const config = await readProvidersConfig()
|
||||
const settings = await readSettings()
|
||||
expect(config.activeId).toBe('openai-official')
|
||||
const env = settings.env as Record<string, string>
|
||||
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1')
|
||||
expect(env.OPENAI_CODEX_OAUTH_FILE).toBe(
|
||||
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
|
||||
)
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex')
|
||||
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini')
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4')
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex')
|
||||
expect(typeof env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBe('string')
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
})
|
||||
|
||||
test('activating ChatGPT Official clears stale managed provider env', async () => {
|
||||
const svc = new ProviderService()
|
||||
const provider = await svc.addProvider(sampleInput({
|
||||
apiFormat: 'openai_responses',
|
||||
baseUrl: 'https://api.example.com/openai',
|
||||
models: {
|
||||
main: 'provider-main',
|
||||
haiku: 'provider-haiku',
|
||||
sonnet: 'provider-sonnet',
|
||||
opus: 'provider-opus',
|
||||
},
|
||||
}))
|
||||
await svc.activateProvider(provider.id)
|
||||
expect(((await readSettings()).env as Record<string, string>).ANTHROPIC_BASE_URL).toContain('/proxy')
|
||||
|
||||
await svc.activateProvider('openai-official')
|
||||
|
||||
const settings = await readSettings()
|
||||
const env = settings.env as Record<string, string>
|
||||
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1')
|
||||
expect(env.OPENAI_CODEX_OAUTH_FILE).toBe(
|
||||
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
|
||||
)
|
||||
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
|
||||
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
|
||||
})
|
||||
|
||||
test('auth status reports ChatGPT Official from the desktop OpenAI token file', async () => {
|
||||
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
|
||||
JSON.stringify({
|
||||
accessToken: 'openai-access',
|
||||
refreshToken: 'openai-refresh',
|
||||
expiresAt: Date.now() + 60 * 60_000,
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_123',
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const svc = new ProviderService()
|
||||
await svc.activateProvider('openai-official')
|
||||
|
||||
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
|
||||
hasAuth: true,
|
||||
source: 'openai-oauth',
|
||||
activeProvider: 'ChatGPT Official',
|
||||
})
|
||||
})
|
||||
|
||||
test('auth status reports ChatGPT Official as unauthenticated when the OpenAI token file is missing', async () => {
|
||||
const svc = new ProviderService()
|
||||
await svc.activateProvider('openai-official')
|
||||
|
||||
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
|
||||
hasAuth: false,
|
||||
source: 'none',
|
||||
activeProvider: 'ChatGPT Official',
|
||||
})
|
||||
})
|
||||
|
||||
test('activating another provider clears ChatGPT Official runtime markers', async () => {
|
||||
const svc = new ProviderService()
|
||||
const provider = await svc.addProvider(sampleInput())
|
||||
|
||||
await svc.activateProvider('openai-official')
|
||||
await svc.activateProvider(provider.id)
|
||||
|
||||
const env = (await readSettings()).env as Record<string, string>
|
||||
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
|
||||
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-test-key-123')
|
||||
})
|
||||
})
|
||||
|
||||
test('should throw 404 for non-existent id', async () => {
|
||||
const svc = new ProviderService()
|
||||
|
||||
@ -770,6 +907,14 @@ describe('ProviderService', () => {
|
||||
expect(active).toBeNull()
|
||||
})
|
||||
|
||||
test('should return null for explicit ChatGPT Official proxy lookup', async () => {
|
||||
const svc = new ProviderService()
|
||||
|
||||
const active = await svc.getProviderForProxy('openai-official')
|
||||
|
||||
expect(active).toBeNull()
|
||||
})
|
||||
|
||||
test('should return the active provider proxy config', async () => {
|
||||
const svc = new ProviderService()
|
||||
const provider = await svc.addProvider(sampleInput())
|
||||
@ -781,6 +926,15 @@ describe('ProviderService', () => {
|
||||
expect(active!.apiKey).toBe(provider.apiKey)
|
||||
expect(active!.apiFormat).toBe('anthropic')
|
||||
})
|
||||
|
||||
test('should return null when ChatGPT Official is the active provider', async () => {
|
||||
const svc = new ProviderService()
|
||||
await svc.activateProvider('openai-official')
|
||||
|
||||
const active = await svc.getProviderForProxy()
|
||||
|
||||
expect(active).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleProxyRequest', () => {
|
||||
|
||||
@ -626,6 +626,65 @@ describe('Models API', () => {
|
||||
expect(globalSettings.model).toBeUndefined()
|
||||
})
|
||||
|
||||
it('GET /api/models should return the OpenAI model catalog when ChatGPT Official is active', async () => {
|
||||
const providerSvc = new ProviderService()
|
||||
await providerSvc.activateProvider('openai-official')
|
||||
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as {
|
||||
models: Array<{ id: string; name: string }>
|
||||
provider: { id: string; name: string } | null
|
||||
}
|
||||
expect(body.provider).toEqual({
|
||||
id: 'openai-official',
|
||||
name: 'ChatGPT Official',
|
||||
})
|
||||
expect(body.models.map((model) => model.id)).toEqual([
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.4',
|
||||
'gpt-5.5',
|
||||
'gpt-5.4-mini',
|
||||
])
|
||||
})
|
||||
|
||||
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()
|
||||
await settingsSvc.updateUserSettings({ model: 'claude-haiku-4-5' })
|
||||
await providerSvc.activateProvider('openai-official')
|
||||
|
||||
const { req, url, segments } = makeRequest('PUT', '/api/models/current', {
|
||||
modelId: 'gpt-5.5',
|
||||
})
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const managedSettings = await providerSvc.getManagedSettings()
|
||||
expect(managedSettings.model).toBe('gpt-5.5')
|
||||
|
||||
const globalSettings = await settingsSvc.getUserSettings()
|
||||
expect(globalSettings.model).toBe('claude-haiku-4-5')
|
||||
})
|
||||
|
||||
it('GET /api/models/current should read current GPT model from managed settings when ChatGPT Official is active', async () => {
|
||||
const providerSvc = new ProviderService()
|
||||
await providerSvc.activateProvider('openai-official')
|
||||
await providerSvc.updateManagedSettings({ model: 'gpt-5.5' })
|
||||
|
||||
const { req, url, segments } = makeRequest('GET', '/api/models/current')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.model).toMatchObject({
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
})
|
||||
})
|
||||
|
||||
it('GET /api/effort should return default effort level', async () => {
|
||||
const { req, url, segments } = makeRequest('GET', '/api/effort')
|
||||
const res = await handleModelsApi(req, url, segments)
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
* Haha OpenAI OAuth REST API
|
||||
*
|
||||
* POST /api/haha-openai-oauth/start — 生成 PKCE+state,返回 authorize URL
|
||||
* GET /callback/openai — 用户浏览器 redirect 到此,完成 token 交换
|
||||
* GET /auth/callback — 用户浏览器 redirect 到此,完成 token 交换
|
||||
* GET /callback/openai — 兼容旧路径
|
||||
* GET /api/haha-openai-oauth — 查询当前登录状态(不回传 token 本体)
|
||||
* DELETE /api/haha-openai-oauth — 登出,删除 token 文件
|
||||
*/
|
||||
|
||||
@ -14,6 +14,11 @@ import { attributionHeaderEnvForModel } from '../services/attributionHeaderPolic
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
import { hasOpenAIAuthLogin } from '../../utils/auth.js'
|
||||
import { OPENAI_CODEX_MODEL_CATALOG } from '../../services/openaiAuth/models.js'
|
||||
import {
|
||||
OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
OPENAI_OFFICIAL_PROVIDER_NAME,
|
||||
isOpenAIOfficialProviderId,
|
||||
} from '../services/openaiOfficialProvider.js'
|
||||
|
||||
// ─── Fallback models (used when no provider is configured) ────────────────────
|
||||
|
||||
@ -110,6 +115,15 @@ function buildProviderModelList(models: {
|
||||
return modelList
|
||||
}
|
||||
|
||||
function buildOpenAIModelList(): ApiModelInfo[] {
|
||||
return OPENAI_CODEX_MODEL_CATALOG.map(model => ({
|
||||
id: model.value,
|
||||
name: model.label,
|
||||
description: model.description,
|
||||
context: '',
|
||||
}))
|
||||
}
|
||||
|
||||
function getEnvConfiguredAnthropicModels(): ApiModelInfo[] {
|
||||
return buildProviderModelList({
|
||||
main: process.env.ANTHROPIC_MODEL?.trim() || '',
|
||||
@ -124,12 +138,7 @@ function getOpenAIAuthModels(): ApiModelInfo[] {
|
||||
return []
|
||||
}
|
||||
|
||||
return OPENAI_CODEX_MODEL_CATALOG.map(model => ({
|
||||
id: model.value,
|
||||
name: model.label,
|
||||
description: model.description,
|
||||
context: '',
|
||||
}))
|
||||
return buildOpenAIModelList()
|
||||
}
|
||||
|
||||
function getStandaloneModelList(): ApiModelInfo[] {
|
||||
@ -190,6 +199,16 @@ export async function handleModelsApi(
|
||||
|
||||
async function handleModelsList(): Promise<Response> {
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
if (isOpenAIOfficialProviderId(activeId)) {
|
||||
return Response.json({
|
||||
models: buildOpenAIModelList(),
|
||||
provider: {
|
||||
id: OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
name: OPENAI_OFFICIAL_PROVIDER_NAME,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
if (activeProvider) {
|
||||
const modelList = buildProviderModelList(activeProvider.models)
|
||||
@ -205,8 +224,9 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
if (req.method === 'GET') {
|
||||
// Build the full model list: prefer active provider's models, fall back to defaults
|
||||
const { providers, activeId } = await providerService.listProviders()
|
||||
const isOpenAIProviderActive = isOpenAIOfficialProviderId(activeId)
|
||||
const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null
|
||||
const settings = activeProvider
|
||||
const settings = activeProvider || isOpenAIProviderActive
|
||||
? await providerService.getManagedSettings()
|
||||
: await settingsService.getUserSettings()
|
||||
const explicitModel = (settings.model as string) || ''
|
||||
@ -217,7 +237,10 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
let currentModelId: string
|
||||
let currentModelName: string
|
||||
|
||||
if (activeProvider) {
|
||||
if (isOpenAIProviderActive) {
|
||||
currentModelId = explicitModel || env.ANTHROPIC_MODEL || 'gpt-5.3-codex'
|
||||
currentModelName = currentModelId
|
||||
} else if (activeProvider) {
|
||||
// Provider is active — only use the provider-managed cc-haha settings.
|
||||
// This avoids leaking global ~/.claude/settings.json model choices into
|
||||
// the active provider flow.
|
||||
@ -238,9 +261,11 @@ async function handleCurrentModel(req: Request): Promise<Response> {
|
||||
const lookupId = contextTier ? `${currentModelId}:${contextTier}` : currentModelId
|
||||
|
||||
// Build available models for name lookup
|
||||
const availableModels = activeProvider
|
||||
? buildProviderModelList(activeProvider.models)
|
||||
: getStandaloneModelList()
|
||||
const availableModels = isOpenAIProviderActive
|
||||
? buildOpenAIModelList()
|
||||
: activeProvider
|
||||
? buildProviderModelList(activeProvider.models)
|
||||
: getStandaloneModelList()
|
||||
|
||||
const modelEntry = availableModels.find((m) => m.id === lookupId)
|
||||
|| availableModels.find((m) => m.id === currentModelId)
|
||||
|
||||
@ -15,6 +15,7 @@ import { handleProxyRequest } from './proxy/handler.js'
|
||||
import { ProviderService } from './services/providerService.js'
|
||||
import { handleHahaOAuthCallback } from './api/haha-oauth.js'
|
||||
import { handleHahaOpenAIOAuthCallback } from './api/haha-openai-oauth.js'
|
||||
import { OPENAI_CODEX_REDIRECT_PATH } from '../services/openaiAuth/client.js'
|
||||
import { ensureDesktopCliLauncherInstalled } from './services/desktopCliLauncherService.js'
|
||||
import { enableConfigs } from '../utils/config.js'
|
||||
import { diagnosticsService } from './services/diagnosticsService.js'
|
||||
@ -267,7 +268,10 @@ export function startServer(port = PORT, host = HOST) {
|
||||
return handleHahaOAuthCallback(url)
|
||||
}
|
||||
|
||||
if (url.pathname === '/callback/openai') {
|
||||
if (
|
||||
url.pathname === OPENAI_CODEX_REDIRECT_PATH ||
|
||||
url.pathname === '/callback/openai'
|
||||
) {
|
||||
return handleHahaOpenAIOAuthCallback(url)
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,10 @@ import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { ProviderService } from './providerService.js'
|
||||
import {
|
||||
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
} from './openaiOfficialProvider.js'
|
||||
import { sessionService } from './sessionService.js'
|
||||
import { diagnosticsService } from './diagnosticsService.js'
|
||||
import {
|
||||
@ -888,6 +892,8 @@ export class ConversationService {
|
||||
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
|
||||
'CLAUDE_CODE_ATTRIBUTION_HEADER',
|
||||
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
|
||||
] as const
|
||||
|
||||
const cleanEnv = await getProcessEnvWithTerminalShellEnvironment()
|
||||
@ -1044,6 +1050,8 @@ export class ConversationService {
|
||||
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
|
||||
'CLAUDE_CODE_ATTRIBUTION_HEADER',
|
||||
'CLAUDE_CODE_MODEL_CONTEXT_WINDOWS',
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
|
||||
].some((key) => typeof env[key] === 'string' && env[key]!.trim().length > 0)
|
||||
} catch {
|
||||
return false
|
||||
@ -1074,6 +1082,9 @@ export class ConversationService {
|
||||
const raw = fs.readFileSync(settingsPath, 'utf-8')
|
||||
const parsed = JSON.parse(raw) as { env?: Record<string, string> }
|
||||
const env = parsed.env ?? {}
|
||||
if (env[OPENAI_OAUTH_PROVIDER_ENV_KEY] === '1') {
|
||||
return false
|
||||
}
|
||||
const hasProviderEnv = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
|
||||
@ -23,20 +23,20 @@ import {
|
||||
refreshOpenAITokens,
|
||||
isOpenAITokenExpired,
|
||||
normalizeOpenAITokens,
|
||||
withRefreshedAccessToken,
|
||||
OPENAI_CODEX_OAUTH_PORT,
|
||||
OPENAI_CODEX_REDIRECT_PATH,
|
||||
} from '../../services/openaiAuth/client.js'
|
||||
import type {
|
||||
OpenAIOAuthTokens,
|
||||
OpenAIOAuthTokenResponse,
|
||||
} from '../../services/openaiAuth/types.js'
|
||||
import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js'
|
||||
|
||||
export type StoredOpenAIOAuthTokens = {
|
||||
accessToken: string
|
||||
refreshToken: string | null
|
||||
expiresAt: number | null
|
||||
idToken?: string | null
|
||||
email: string | null
|
||||
accountId: string | null
|
||||
clientId?: string | null
|
||||
}
|
||||
|
||||
export type OpenAIOAuthSession = {
|
||||
@ -53,6 +53,12 @@ type OpenAIRefreshFn = (
|
||||
|
||||
const SESSION_TTL_MS = 5 * 60 * 1000
|
||||
|
||||
export function getHahaOpenAIOAuthFilePath(): string {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'cc-haha', 'openai-oauth.json')
|
||||
}
|
||||
|
||||
export class HahaOpenAIOAuthService {
|
||||
private sessions = new Map<string, OpenAIOAuthSession>()
|
||||
private refreshFn: OpenAIRefreshFn = refreshOpenAITokens
|
||||
@ -61,10 +67,8 @@ export class HahaOpenAIOAuthService {
|
||||
this.refreshFn = fn
|
||||
}
|
||||
|
||||
private getOAuthFilePath(): string {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'cc-haha', 'openai-oauth.json')
|
||||
getOAuthFilePath(): string {
|
||||
return getHahaOpenAIOAuthFilePath()
|
||||
}
|
||||
|
||||
async loadTokens(): Promise<StoredOpenAIOAuthTokens | null> {
|
||||
@ -80,9 +84,17 @@ export class HahaOpenAIOAuthService {
|
||||
async saveTokens(tokens: StoredOpenAIOAuthTokens): Promise<void> {
|
||||
const filePath = this.getOAuthFilePath()
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
||||
const tmp = `${filePath}.tmp.${process.pid}`
|
||||
await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 })
|
||||
await fs.rename(tmp, filePath)
|
||||
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}`
|
||||
let renamed = false
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(tokens, null, 2), { mode: 0o600 })
|
||||
await fs.rename(tmp, filePath)
|
||||
renamed = true
|
||||
} finally {
|
||||
if (!renamed) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteTokens(): Promise<void> {
|
||||
@ -161,8 +173,10 @@ export class HahaOpenAIOAuthService {
|
||||
accessToken: normalized.accessToken,
|
||||
refreshToken: normalized.refreshToken,
|
||||
expiresAt: normalized.expiresAt,
|
||||
idToken: normalized.idToken ?? null,
|
||||
email: normalized.email ?? null,
|
||||
accountId: normalized.accountId ?? null,
|
||||
clientId: normalized.clientId ?? null,
|
||||
}
|
||||
await this.saveTokens(tokens)
|
||||
return tokens
|
||||
@ -180,13 +194,26 @@ export class HahaOpenAIOAuthService {
|
||||
|
||||
try {
|
||||
const refreshed = await this.refreshFn(tokens.refreshToken)
|
||||
const normalized = normalizeOpenAITokens(refreshed)
|
||||
const normalized = withRefreshedAccessToken(
|
||||
{
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken,
|
||||
expiresAt: tokens.expiresAt,
|
||||
...(tokens.idToken ? { idToken: tokens.idToken } : {}),
|
||||
...(tokens.email ? { email: tokens.email } : {}),
|
||||
...(tokens.accountId ? { accountId: tokens.accountId } : {}),
|
||||
...(tokens.clientId ? { clientId: tokens.clientId } : {}),
|
||||
},
|
||||
refreshed,
|
||||
)
|
||||
const updated: StoredOpenAIOAuthTokens = {
|
||||
accessToken: normalized.accessToken,
|
||||
refreshToken: normalized.refreshToken ?? tokens.refreshToken,
|
||||
refreshToken: normalized.refreshToken,
|
||||
expiresAt: normalized.expiresAt,
|
||||
email: normalized.email ?? tokens.email,
|
||||
accountId: normalized.accountId ?? tokens.accountId,
|
||||
idToken: normalized.idToken ?? null,
|
||||
email: normalized.email ?? null,
|
||||
accountId: normalized.accountId ?? null,
|
||||
clientId: normalized.clientId ?? null,
|
||||
}
|
||||
await this.saveTokens(updated)
|
||||
return updated
|
||||
|
||||
64
src/server/services/openaiOfficialProvider.ts
Normal file
64
src/server/services/openaiOfficialProvider.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { OPENAI_CODEX_API_ENDPOINT } from '../../services/openaiAuth/client.js'
|
||||
import {
|
||||
OPENAI_DEFAULT_HAIKU_MODEL,
|
||||
OPENAI_DEFAULT_MAIN_MODEL,
|
||||
OPENAI_DEFAULT_SONNET_MODEL,
|
||||
getOpenAIContextWindowForModel,
|
||||
} from '../../services/openaiAuth/models.js'
|
||||
import { MODEL_CONTEXT_WINDOWS_ENV_KEY } from '../../utils/model/modelContextWindows.js'
|
||||
import { getHahaOpenAIOAuthFilePath } from './hahaOpenAIOAuthService.js'
|
||||
import type { SavedProvider } from '../types/provider.js'
|
||||
|
||||
export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official'
|
||||
export const OPENAI_OFFICIAL_PROVIDER_NAME = 'ChatGPT Official'
|
||||
export const OPENAI_OAUTH_PROVIDER_ENV_KEY = 'CC_HAHA_OPENAI_OAUTH_PROVIDER'
|
||||
export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE'
|
||||
|
||||
export function isOpenAIOfficialProviderId(
|
||||
id: string | null | undefined,
|
||||
): boolean {
|
||||
return id === OPENAI_OFFICIAL_PROVIDER_ID
|
||||
}
|
||||
|
||||
const openAIModels: SavedProvider['models'] = {
|
||||
main: OPENAI_DEFAULT_MAIN_MODEL,
|
||||
haiku: OPENAI_DEFAULT_HAIKU_MODEL,
|
||||
sonnet: OPENAI_DEFAULT_SONNET_MODEL,
|
||||
opus: OPENAI_DEFAULT_MAIN_MODEL,
|
||||
}
|
||||
|
||||
const modelContextWindows = Object.fromEntries(
|
||||
Object.values(openAIModels)
|
||||
.map((model) => [model, getOpenAIContextWindowForModel(model)] as const)
|
||||
.filter((entry): entry is readonly [string, number] => entry[1] !== null),
|
||||
)
|
||||
|
||||
export const OPENAI_OFFICIAL_PROVIDER: SavedProvider = {
|
||||
id: OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
presetId: OPENAI_OFFICIAL_PROVIDER_ID,
|
||||
name: OPENAI_OFFICIAL_PROVIDER_NAME,
|
||||
apiKey: '',
|
||||
authStrategy: 'dual_dummy',
|
||||
baseUrl: new URL('/backend-api/codex', OPENAI_CODEX_API_ENDPOINT)
|
||||
.toString()
|
||||
.replace(/\/+$/, ''),
|
||||
apiFormat: 'openai_responses',
|
||||
runtimeKind: 'openai_oauth',
|
||||
models: openAIModels,
|
||||
modelContextWindows,
|
||||
}
|
||||
|
||||
export function buildOpenAIOfficialRuntimeEnv(): Record<string, string> {
|
||||
const modelContextWindows = OPENAI_OFFICIAL_PROVIDER.modelContextWindows ?? {}
|
||||
return {
|
||||
[OPENAI_OAUTH_PROVIDER_ENV_KEY]: '1',
|
||||
[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]: getHahaOpenAIOAuthFilePath(),
|
||||
...(Object.keys(modelContextWindows).length > 0 && {
|
||||
[MODEL_CONTEXT_WINDOWS_ENV_KEY]: JSON.stringify(modelContextWindows),
|
||||
}),
|
||||
ANTHROPIC_MODEL: OPENAI_OFFICIAL_PROVIDER.models.main,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: OPENAI_OFFICIAL_PROVIDER.models.haiku,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: OPENAI_OFFICIAL_PROVIDER.models.sonnet,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: OPENAI_OFFICIAL_PROVIDER.models.opus,
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { normalizeLegacyDeepSeekManagedEnv } from '../../utils/providerManagedEnvCompat.js'
|
||||
import { isOpenAIOfficialProviderId } from './openaiOfficialProvider.js'
|
||||
|
||||
export const CURRENT_PROVIDER_INDEX_SCHEMA_VERSION = 1
|
||||
|
||||
@ -136,7 +137,10 @@ function migrateProvidersIndex(value: unknown): JsonObject {
|
||||
: typeof _legacyActiveProviderId === 'string'
|
||||
? _legacyActiveProviderId
|
||||
: null
|
||||
const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId)
|
||||
const activeId = rawActiveId && (
|
||||
providers.some((provider) => provider.id === rawActiveId) ||
|
||||
isOpenAIOfficialProviderId(rawActiveId)
|
||||
)
|
||||
? rawActiveId
|
||||
: null
|
||||
|
||||
|
||||
@ -23,6 +23,14 @@ import {
|
||||
ATTRIBUTION_HEADER_ENV_KEY,
|
||||
attributionHeaderEnvForModel,
|
||||
} from './attributionHeaderPolicy.js'
|
||||
import {
|
||||
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
|
||||
OPENAI_OFFICIAL_PROVIDER,
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
buildOpenAIOfficialRuntimeEnv,
|
||||
isOpenAIOfficialProviderId,
|
||||
} from './openaiOfficialProvider.js'
|
||||
import { hahaOpenAIOAuthService } from './hahaOpenAIOAuthService.js'
|
||||
import {
|
||||
CURRENT_PROVIDER_INDEX_SCHEMA_VERSION,
|
||||
ensurePersistentStorageUpgraded,
|
||||
@ -53,6 +61,8 @@ const MANAGED_ENV_KEYS = [
|
||||
'CLAUDE_CODE_AUTO_COMPACT_WINDOW',
|
||||
ATTRIBUTION_HEADER_ENV_KEY,
|
||||
MODEL_CONTEXT_WINDOWS_ENV_KEY,
|
||||
OPENAI_OAUTH_PROVIDER_ENV_KEY,
|
||||
OPENAI_CODEX_OAUTH_FILE_ENV_KEY,
|
||||
] as const
|
||||
|
||||
const CUSTOM_PROVIDER_MODEL_CAPABILITIES = 'thinking,effort,adaptive_thinking,max_effort'
|
||||
@ -80,12 +90,18 @@ function isProviderModels(value: unknown): value is SavedProvider['models'] {
|
||||
|
||||
function isSavedProvider(value: unknown): value is SavedProvider {
|
||||
if (!isRecord(value)) return false
|
||||
const runtimeKind = value.runtimeKind
|
||||
return (
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.presetId === 'string' &&
|
||||
typeof value.name === 'string' &&
|
||||
typeof value.apiKey === 'string' &&
|
||||
typeof value.baseUrl === 'string' &&
|
||||
(
|
||||
runtimeKind === undefined ||
|
||||
runtimeKind === 'anthropic_compatible' ||
|
||||
runtimeKind === 'openai_oauth'
|
||||
) &&
|
||||
isProviderModels(value.models)
|
||||
)
|
||||
}
|
||||
@ -100,20 +116,34 @@ function normalizeModelMapping(models: SavedProvider['models']): SavedProvider['
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSavedProvider(provider: SavedProvider): SavedProvider {
|
||||
return {
|
||||
...provider,
|
||||
apiFormat: provider.apiFormat ?? 'anthropic',
|
||||
runtimeKind: provider.runtimeKind ?? 'anthropic_compatible',
|
||||
models: normalizeModelMapping(provider.models),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProvidersIndex(value: unknown): ProvidersIndex | null {
|
||||
if (!isRecord(value) || !Array.isArray(value.providers)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { activeProviderId: _legacyActiveProviderId, ...rest } = value
|
||||
const providers = value.providers.filter(isSavedProvider)
|
||||
const providers = value.providers
|
||||
.filter(isSavedProvider)
|
||||
.map((provider) => normalizeSavedProvider(provider))
|
||||
const rawActiveId =
|
||||
typeof value.activeId === 'string'
|
||||
? value.activeId
|
||||
: typeof _legacyActiveProviderId === 'string'
|
||||
? _legacyActiveProviderId
|
||||
: null
|
||||
const activeId = rawActiveId && providers.some((provider) => provider.id === rawActiveId)
|
||||
const activeId = rawActiveId && (
|
||||
providers.some((provider) => provider.id === rawActiveId) ||
|
||||
isOpenAIOfficialProviderId(rawActiveId)
|
||||
)
|
||||
? rawActiveId
|
||||
: null
|
||||
|
||||
@ -256,6 +286,10 @@ export class ProviderService {
|
||||
}
|
||||
|
||||
async getProvider(id: string): Promise<SavedProvider> {
|
||||
if (isOpenAIOfficialProviderId(id)) {
|
||||
return OPENAI_OFFICIAL_PROVIDER
|
||||
}
|
||||
|
||||
const index = await this.readIndex()
|
||||
const provider = index.providers.find((p) => p.id === id)
|
||||
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
@ -273,6 +307,7 @@ export class ProviderService {
|
||||
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
|
||||
baseUrl: input.baseUrl,
|
||||
apiFormat: input.apiFormat ?? 'anthropic',
|
||||
runtimeKind: input.runtimeKind ?? 'anthropic_compatible',
|
||||
models: normalizeModelMapping(input.models),
|
||||
...(input.autoCompactWindow !== undefined && { autoCompactWindow: input.autoCompactWindow }),
|
||||
...(input.modelContextWindows !== undefined && { modelContextWindows: input.modelContextWindows }),
|
||||
@ -297,6 +332,7 @@ export class ProviderService {
|
||||
...(input.authStrategy !== undefined && { authStrategy: input.authStrategy }),
|
||||
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
|
||||
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
|
||||
...(input.runtimeKind !== undefined && { runtimeKind: input.runtimeKind }),
|
||||
...(input.models !== undefined && { models: normalizeModelMapping(input.models) }),
|
||||
...(typeof input.autoCompactWindow === 'number' && { autoCompactWindow: input.autoCompactWindow }),
|
||||
...(input.modelContextWindows !== undefined && input.modelContextWindows !== null && { modelContextWindows: input.modelContextWindows }),
|
||||
@ -336,13 +372,17 @@ export class ProviderService {
|
||||
|
||||
async activateProvider(id: string): Promise<void> {
|
||||
const index = await this.readIndex()
|
||||
const provider = index.providers.find((p) => p.id === id)
|
||||
const provider = isOpenAIOfficialProviderId(id)
|
||||
? OPENAI_OFFICIAL_PROVIDER
|
||||
: index.providers.find((p) => p.id === id)
|
||||
if (!provider) throw ApiError.notFound(`Provider not found: ${id}`)
|
||||
|
||||
index.activeId = id
|
||||
await this.writeIndex(index)
|
||||
|
||||
if (provider.presetId === 'official') {
|
||||
if (provider.runtimeKind === 'openai_oauth') {
|
||||
await this.syncToSettings(provider)
|
||||
} else if (provider.presetId === 'official') {
|
||||
await this.clearProviderFromSettings()
|
||||
} else {
|
||||
await this.syncToSettings(provider)
|
||||
@ -362,6 +402,10 @@ export class ProviderService {
|
||||
provider: SavedProvider,
|
||||
options?: { proxyPath?: string },
|
||||
): Record<string, string> {
|
||||
if (provider.runtimeKind === 'openai_oauth') {
|
||||
return buildOpenAIOfficialRuntimeEnv()
|
||||
}
|
||||
|
||||
const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic'
|
||||
const proxyPath = options?.proxyPath ?? '/proxy'
|
||||
const baseUrl = needsProxy
|
||||
@ -467,12 +511,28 @@ export class ProviderService {
|
||||
*/
|
||||
async checkAuthStatus(): Promise<{
|
||||
hasAuth: boolean
|
||||
source: 'cc-haha-provider' | 'original-settings' | 'env' | 'none'
|
||||
source: 'cc-haha-provider' | 'openai-oauth' | 'original-settings' | 'env' | 'none'
|
||||
activeProvider?: string
|
||||
}> {
|
||||
// 1. Check cc-haha active provider
|
||||
const index = await this.readIndex()
|
||||
if (index.activeId) {
|
||||
if (isOpenAIOfficialProviderId(index.activeId)) {
|
||||
const tokens = await hahaOpenAIOAuthService.ensureFreshTokens()
|
||||
if (tokens?.accessToken && tokens.refreshToken) {
|
||||
return {
|
||||
hasAuth: true,
|
||||
source: 'openai-oauth',
|
||||
activeProvider: OPENAI_OFFICIAL_PROVIDER.name,
|
||||
}
|
||||
}
|
||||
return {
|
||||
hasAuth: false,
|
||||
source: 'none',
|
||||
activeProvider: OPENAI_OFFICIAL_PROVIDER.name,
|
||||
}
|
||||
}
|
||||
|
||||
const provider = index.providers.find(p => p.id === index.activeId)
|
||||
if (provider) {
|
||||
const presetDefaultEnv = getPresetDefaultEnv(provider.presetId)
|
||||
@ -513,6 +573,9 @@ export class ProviderService {
|
||||
apiFormat: ApiFormat
|
||||
} | null> {
|
||||
if (providerId) {
|
||||
if (isOpenAIOfficialProviderId(providerId)) {
|
||||
return null
|
||||
}
|
||||
const provider = await this.getProvider(providerId)
|
||||
return {
|
||||
baseUrl: provider.baseUrl,
|
||||
@ -523,7 +586,10 @@ export class ProviderService {
|
||||
|
||||
const index = await this.readIndex()
|
||||
if (!index.activeId) return null
|
||||
const provider = index.providers.find((p) => p.id === index.activeId)
|
||||
if (isOpenAIOfficialProviderId(index.activeId)) {
|
||||
return null
|
||||
}
|
||||
const provider = await this.getProvider(index.activeId).catch(() => null)
|
||||
if (!provider) return null
|
||||
return {
|
||||
baseUrl: provider.baseUrl,
|
||||
|
||||
@ -23,6 +23,12 @@ export const ProviderAuthStrategySchema = z.enum([
|
||||
])
|
||||
export type ProviderAuthStrategy = z.infer<typeof ProviderAuthStrategySchema>
|
||||
|
||||
export const ProviderRuntimeKindSchema = z.enum([
|
||||
'anthropic_compatible',
|
||||
'openai_oauth',
|
||||
])
|
||||
export type ProviderRuntimeKind = z.infer<typeof ProviderRuntimeKindSchema>
|
||||
|
||||
export const ModelMappingSchema = z.object({
|
||||
main: z.string(),
|
||||
haiku: z.string(),
|
||||
@ -44,6 +50,7 @@ export const SavedProviderSchema = z.object({
|
||||
authStrategy: ProviderAuthStrategySchema.optional(),
|
||||
baseUrl: z.string(),
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'),
|
||||
models: ModelMappingSchema,
|
||||
autoCompactWindow: AutoCompactWindowSchema.optional(),
|
||||
modelContextWindows: ModelContextWindowsSchema.optional(),
|
||||
@ -63,6 +70,7 @@ export const CreateProviderSchema = z.object({
|
||||
authStrategy: ProviderAuthStrategySchema.optional(),
|
||||
baseUrl: z.string(),
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
runtimeKind: ProviderRuntimeKindSchema.default('anthropic_compatible'),
|
||||
models: ModelMappingSchema,
|
||||
autoCompactWindow: AutoCompactWindowSchema.optional(),
|
||||
modelContextWindows: ModelContextWindowsSchema.optional(),
|
||||
@ -75,6 +83,7 @@ export const UpdateProviderSchema = z.object({
|
||||
authStrategy: ProviderAuthStrategySchema.optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
apiFormat: ApiFormatSchema.optional(),
|
||||
runtimeKind: ProviderRuntimeKindSchema.optional(),
|
||||
models: ModelMappingSchema.optional(),
|
||||
autoCompactWindow: AutoCompactWindowSchema.nullable().optional(),
|
||||
modelContextWindows: ModelContextWindowsSchema.nullable().optional(),
|
||||
|
||||
@ -52,6 +52,36 @@ describe('resolveAnthropicClientApiKey', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldUseOpenAICodexTransport', () => {
|
||||
test('lets ChatGPT Official marker override a saved Claude subscriber login', async () => {
|
||||
const { shouldUseOpenAICodexTransport } = await import('./client.js')
|
||||
|
||||
expect(shouldUseOpenAICodexTransport({
|
||||
hasOpenAIAuth: true,
|
||||
isClaudeSubscriber: true,
|
||||
forceOpenAICodex: true,
|
||||
isOpenAIModel: true,
|
||||
hasAnthropicAuthToken: false,
|
||||
hasExplicitApiKey: false,
|
||||
hasFallbackApiKey: false,
|
||||
})).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps Claude subscriber transport when ChatGPT Official is not selected', async () => {
|
||||
const { shouldUseOpenAICodexTransport } = await import('./client.js')
|
||||
|
||||
expect(shouldUseOpenAICodexTransport({
|
||||
hasOpenAIAuth: true,
|
||||
isClaudeSubscriber: true,
|
||||
forceOpenAICodex: false,
|
||||
isOpenAIModel: true,
|
||||
hasAnthropicAuthToken: false,
|
||||
hasExplicitApiKey: false,
|
||||
hasFallbackApiKey: false,
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAnthropicClient', () => {
|
||||
test('passes bearer-token provider auth without an SDK api key', async () => {
|
||||
const { getAnthropicClient } = await import('./client.js')
|
||||
|
||||
@ -110,6 +110,31 @@ export function resolveAnthropicClientApiKey({
|
||||
return explicitApiKey || getFallbackApiKey()
|
||||
}
|
||||
|
||||
export function shouldUseOpenAICodexTransport({
|
||||
hasOpenAIAuth,
|
||||
isClaudeSubscriber,
|
||||
forceOpenAICodex,
|
||||
isOpenAIModel,
|
||||
hasAnthropicAuthToken,
|
||||
hasExplicitApiKey,
|
||||
hasFallbackApiKey,
|
||||
}: {
|
||||
hasOpenAIAuth: boolean
|
||||
isClaudeSubscriber: boolean
|
||||
forceOpenAICodex: boolean
|
||||
isOpenAIModel: boolean
|
||||
hasAnthropicAuthToken: boolean
|
||||
hasExplicitApiKey: boolean
|
||||
hasFallbackApiKey: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
hasOpenAIAuth &&
|
||||
(!isClaudeSubscriber || forceOpenAICodex) &&
|
||||
(isOpenAIModel ||
|
||||
(!hasAnthropicAuthToken && !hasExplicitApiKey && !hasFallbackApiKey))
|
||||
)
|
||||
}
|
||||
|
||||
export async function getAnthropicClient({
|
||||
apiKey,
|
||||
maxRetries,
|
||||
@ -158,14 +183,24 @@ export async function getAnthropicClient({
|
||||
logForDebugging('[API:auth] OAuth token check complete')
|
||||
|
||||
const isOpenAIModel = model ? isOpenAIResponsesModel(model) : false
|
||||
const usingOpenAICodex =
|
||||
shouldUseOpenAICodexAuth() &&
|
||||
!isClaudeAISubscriber() &&
|
||||
(isOpenAIModel ||
|
||||
(!process.env.ANTHROPIC_AUTH_TOKEN &&
|
||||
!(apiKey || getAnthropicApiKey())))
|
||||
const isClaudeSubscriber = isClaudeAISubscriber()
|
||||
const forceOpenAICodex = isEnvTruthy(process.env.CC_HAHA_OPENAI_OAUTH_PROVIDER)
|
||||
const hasOpenAIAuth = shouldUseOpenAICodexAuth()
|
||||
const hasFallbackApiKey = hasOpenAIAuth &&
|
||||
!process.env.ANTHROPIC_AUTH_TOKEN &&
|
||||
!apiKey &&
|
||||
!!getAnthropicApiKey()
|
||||
const usingOpenAICodex = shouldUseOpenAICodexTransport({
|
||||
hasOpenAIAuth,
|
||||
isClaudeSubscriber,
|
||||
forceOpenAICodex,
|
||||
isOpenAIModel,
|
||||
hasAnthropicAuthToken: !!process.env.ANTHROPIC_AUTH_TOKEN,
|
||||
hasExplicitApiKey: !!apiKey,
|
||||
hasFallbackApiKey,
|
||||
})
|
||||
|
||||
if (!isClaudeAISubscriber() && !usingOpenAICodex) {
|
||||
if (!isClaudeSubscriber && !usingOpenAICodex) {
|
||||
await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession())
|
||||
}
|
||||
|
||||
@ -334,12 +369,12 @@ export async function getAnthropicClient({
|
||||
|
||||
// Determine authentication method based on available tokens
|
||||
const clientConfig: ConstructorParameters<typeof Anthropic>[0] = {
|
||||
apiKey: isClaudeAISubscriber()
|
||||
? null
|
||||
: usingOpenAICodex
|
||||
? OPENAI_OAUTH_DUMMY_KEY
|
||||
apiKey: usingOpenAICodex
|
||||
? OPENAI_OAUTH_DUMMY_KEY
|
||||
: isClaudeSubscriber
|
||||
? null
|
||||
: resolveAnthropicClientApiKey({ explicitApiKey: apiKey }),
|
||||
authToken: isClaudeAISubscriber()
|
||||
authToken: isClaudeSubscriber && !usingOpenAICodex
|
||||
? getClaudeAIOAuthTokens()?.accessToken
|
||||
: undefined,
|
||||
// Set baseURL from OAuth config when using staging OAuth
|
||||
|
||||
@ -69,6 +69,7 @@ export async function refreshOpenAITokens(
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
scope: 'openid profile email',
|
||||
}).toString(),
|
||||
})
|
||||
|
||||
@ -112,6 +113,10 @@ export function normalizeOpenAITokens(
|
||||
parseOpenAIJwtClaims(response.id_token) ??
|
||||
parseOpenAIJwtClaims(response.access_token)
|
||||
|
||||
if (!response.refresh_token) {
|
||||
throw new Error('OpenAI OAuth response did not include a refresh token')
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
@ -119,6 +124,7 @@ export function normalizeOpenAITokens(
|
||||
idToken: response.id_token,
|
||||
accountId: extractOpenAIAccountId(claims),
|
||||
email: claims?.email,
|
||||
clientId: OPENAI_CODEX_CLIENT_ID,
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,12 +136,17 @@ export function withRefreshedAccessToken(
|
||||
existing: OpenAIOAuthTokens,
|
||||
refreshed: OpenAIOAuthTokenResponse,
|
||||
): OpenAIOAuthTokens {
|
||||
const next = normalizeOpenAITokens(refreshed)
|
||||
const claims =
|
||||
parseOpenAIJwtClaims(refreshed.id_token) ??
|
||||
parseOpenAIJwtClaims(refreshed.access_token)
|
||||
|
||||
return {
|
||||
...next,
|
||||
accountId: next.accountId ?? existing.accountId,
|
||||
email: next.email ?? existing.email,
|
||||
idToken: next.idToken ?? existing.idToken,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token ?? existing.refreshToken,
|
||||
expiresAt: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
|
||||
idToken: refreshed.id_token ?? existing.idToken,
|
||||
accountId: extractOpenAIAccountId(claims) ?? existing.accountId,
|
||||
email: claims?.email ?? existing.email,
|
||||
clientId: existing.clientId ?? OPENAI_CODEX_CLIENT_ID,
|
||||
}
|
||||
}
|
||||
|
||||
91
src/services/openaiAuth/fetch.test.ts
Normal file
91
src/services/openaiAuth/fetch.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import * as fs from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import { OPENAI_CODEX_API_ENDPOINT } from './client.js'
|
||||
import { buildOpenAICodexFetch } from './fetch.js'
|
||||
import { clearOpenAIOAuthTokenCache } from './storage.js'
|
||||
|
||||
describe('buildOpenAICodexFetch', () => {
|
||||
let tmpDir: string
|
||||
let originalTokenFile: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openai-codex-fetch-'))
|
||||
originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = path.join(tmpDir, 'openai-oauth.json')
|
||||
clearOpenAIOAuthTokenCache()
|
||||
await fs.writeFile(
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE,
|
||||
JSON.stringify({
|
||||
accessToken: 'access-for-chatgpt',
|
||||
refreshToken: 'refresh-for-chatgpt',
|
||||
expiresAt: Date.now() + 60 * 60_000,
|
||||
accountId: 'acct_fetch',
|
||||
email: 'user@example.com',
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalTokenFile === undefined) {
|
||||
delete process.env.OPENAI_CODEX_OAUTH_FILE
|
||||
} else {
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile
|
||||
}
|
||||
clearOpenAIOAuthTokenCache()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test('maps Anthropic messages to ChatGPT Codex responses endpoint with account header', async () => {
|
||||
const upstreamCalls: Array<{
|
||||
url: string
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown>
|
||||
}> = []
|
||||
const fetchOverride: typeof fetch = async (input, init) => {
|
||||
const headers = new Headers(init?.headers)
|
||||
upstreamCalls.push({
|
||||
url: String(input),
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
|
||||
})
|
||||
return Response.json({
|
||||
id: 'resp_123',
|
||||
object: 'response',
|
||||
created_at: 1_779_118_000,
|
||||
model: 'gpt-5.5',
|
||||
status: 'completed',
|
||||
output: [{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'ok' }],
|
||||
}],
|
||||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||||
})
|
||||
}
|
||||
|
||||
const openAIFetch = buildOpenAICodexFetch(fetchOverride, 'test')
|
||||
const response = await openAIFetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5.5',
|
||||
max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'Say ok' }],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(upstreamCalls).toHaveLength(1)
|
||||
expect(upstreamCalls[0].url).toBe(OPENAI_CODEX_API_ENDPOINT)
|
||||
expect(upstreamCalls[0].headers.authorization).toBe('Bearer access-for-chatgpt')
|
||||
expect(upstreamCalls[0].headers['chatgpt-account-id']).toBe('acct_fetch')
|
||||
expect(upstreamCalls[0].body.model).toBe('gpt-5.5')
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
type: 'message',
|
||||
model: 'gpt-5.5',
|
||||
content: [{ type: 'text', text: 'ok' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
320
src/services/openaiAuth/storage.test.ts
Normal file
320
src/services/openaiAuth/storage.test.ts
Normal file
@ -0,0 +1,320 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
|
||||
import * as fs from 'fs'
|
||||
import * as fsp from 'fs/promises'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import {
|
||||
clearOpenAIOAuthTokenCache,
|
||||
deleteOpenAIOAuthTokens,
|
||||
getOpenAIOAuthTokens,
|
||||
getOpenAIOAuthTokensAsync,
|
||||
saveOpenAIOAuthTokens,
|
||||
} from './storage.js'
|
||||
import { plainTextStorage } from '../../utils/secureStorage/plainTextStorage.js'
|
||||
import type { OpenAIOAuthTokens } from './types.js'
|
||||
|
||||
describe('OpenAI OAuth desktop token file storage', () => {
|
||||
let tmpDir: string
|
||||
let tokenPath: string
|
||||
let originalTokenFile: string | undefined
|
||||
let originalConfigDir: string | undefined
|
||||
let originalHome: string | undefined
|
||||
let originalUserProfile: string | undefined
|
||||
let originalCwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'openai-oauth-storage-'))
|
||||
tokenPath = path.join(tmpDir, 'openai-oauth.json')
|
||||
originalTokenFile = process.env.OPENAI_CODEX_OAUTH_FILE
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
originalHome = process.env.HOME
|
||||
originalUserProfile = process.env.USERPROFILE
|
||||
originalCwd = process.cwd()
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.HOME = tmpDir
|
||||
process.env.USERPROFILE = tmpDir
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath
|
||||
clearOpenAIOAuthTokenCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
plainTextStorage.delete()
|
||||
if (originalTokenFile === undefined) {
|
||||
delete process.env.OPENAI_CODEX_OAUTH_FILE
|
||||
} else {
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = originalTokenFile
|
||||
}
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME
|
||||
} else {
|
||||
process.env.HOME = originalHome
|
||||
}
|
||||
if (originalUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE
|
||||
} else {
|
||||
process.env.USERPROFILE = originalUserProfile
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
clearOpenAIOAuthTokenCache()
|
||||
await fsp.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function seedSecureStorage(tokens: OpenAIOAuthTokens): void {
|
||||
delete process.env.OPENAI_CODEX_OAUTH_FILE
|
||||
clearOpenAIOAuthTokenCache()
|
||||
expect(
|
||||
plainTextStorage.update({ openaiCodexOauth: tokens }).success,
|
||||
).toBe(true)
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPath
|
||||
clearOpenAIOAuthTokenCache()
|
||||
}
|
||||
|
||||
function unsetTokenFileOverride(): void {
|
||||
delete process.env.OPENAI_CODEX_OAUTH_FILE
|
||||
clearOpenAIOAuthTokenCache()
|
||||
}
|
||||
|
||||
test('reads desktop token file synchronously', async () => {
|
||||
await fsp.writeFile(
|
||||
tokenPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'desktop-access',
|
||||
refreshToken: 'desktop-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
idToken: 'desktop-id-token',
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_desktop',
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const tokens = getOpenAIOAuthTokens()
|
||||
|
||||
expect(tokens).toMatchObject({
|
||||
accessToken: 'desktop-access',
|
||||
refreshToken: 'desktop-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
idToken: 'desktop-id-token',
|
||||
email: 'user@example.com',
|
||||
accountId: 'acct_desktop',
|
||||
})
|
||||
})
|
||||
|
||||
test('reads desktop token file asynchronously', async () => {
|
||||
await fsp.writeFile(
|
||||
tokenPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'async-access',
|
||||
refreshToken: 'async-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
email: null,
|
||||
accountId: null,
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const tokens = await getOpenAIOAuthTokensAsync()
|
||||
|
||||
expect(tokens?.accessToken).toBe('async-access')
|
||||
expect(tokens?.refreshToken).toBe('async-refresh')
|
||||
})
|
||||
|
||||
test('writes refreshed tokens back to the desktop token file', async () => {
|
||||
const result = saveOpenAIOAuthTokens({
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'fresh-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
idToken: 'fresh-id-token',
|
||||
email: 'fresh@example.com',
|
||||
accountId: 'acct_fresh',
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(tokenPath, 'utf-8'),
|
||||
) as Record<string, unknown>
|
||||
expect(raw).toMatchObject({
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'fresh-refresh',
|
||||
idToken: 'fresh-id-token',
|
||||
email: 'fresh@example.com',
|
||||
accountId: 'acct_fresh',
|
||||
})
|
||||
if (process.platform !== 'win32') {
|
||||
expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
test('file-backed save clears legacy secure storage tokens', async () => {
|
||||
seedSecureStorage({
|
||||
accessToken: 'secure-access',
|
||||
refreshToken: 'secure-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
})
|
||||
|
||||
const result = saveOpenAIOAuthTokens({
|
||||
accessToken: 'file-access',
|
||||
refreshToken: 'file-refresh',
|
||||
expiresAt: 4_100_000_000_123,
|
||||
})
|
||||
|
||||
expect(result).toEqual({ success: true })
|
||||
expect(getOpenAIOAuthTokens()?.accessToken).toBe('file-access')
|
||||
|
||||
unsetTokenFileOverride()
|
||||
expect(getOpenAIOAuthTokens()).toBeNull()
|
||||
await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('deletes the desktop token file when the env override is set', async () => {
|
||||
await fsp.writeFile(
|
||||
tokenPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'desktop-access',
|
||||
refreshToken: 'desktop-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
expect(deleteOpenAIOAuthTokens()).toBe(true)
|
||||
expect(fs.existsSync(tokenPath)).toBe(false)
|
||||
})
|
||||
|
||||
test('returns null from both getters when env override is set but file is missing', async () => {
|
||||
seedSecureStorage({
|
||||
accessToken: 'secure-access',
|
||||
refreshToken: 'secure-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
})
|
||||
|
||||
expect(getOpenAIOAuthTokens()).toBeNull()
|
||||
await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('returns null from both getters when env override file contains corrupt json', async () => {
|
||||
seedSecureStorage({
|
||||
accessToken: 'secure-access',
|
||||
refreshToken: 'secure-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
})
|
||||
await fsp.writeFile(tokenPath, '{ definitely-not-json', 'utf-8')
|
||||
|
||||
expect(getOpenAIOAuthTokens()).toBeNull()
|
||||
await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('does not fall back to secure storage after deleting env override file', async () => {
|
||||
seedSecureStorage({
|
||||
accessToken: 'secure-access',
|
||||
refreshToken: 'secure-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
})
|
||||
await fsp.writeFile(
|
||||
tokenPath,
|
||||
JSON.stringify({
|
||||
accessToken: 'desktop-access',
|
||||
refreshToken: 'desktop-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
expect(deleteOpenAIOAuthTokens()).toBe(true)
|
||||
expect(getOpenAIOAuthTokens()).toBeNull()
|
||||
await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull()
|
||||
|
||||
unsetTokenFileOverride()
|
||||
expect(getOpenAIOAuthTokens()).toBeNull()
|
||||
await expect(getOpenAIOAuthTokensAsync()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
test('reloads sync tokens when OPENAI_CODEX_OAUTH_FILE changes without clearing cache', async () => {
|
||||
const tokenPathA = path.join(tmpDir, 'openai-oauth-a.json')
|
||||
const tokenPathB = path.join(tmpDir, 'openai-oauth-b.json')
|
||||
await fsp.writeFile(
|
||||
tokenPathA,
|
||||
JSON.stringify({
|
||||
accessToken: 'access-a',
|
||||
refreshToken: 'refresh-a',
|
||||
expiresAt: 4_100_000_000_001,
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
await fsp.writeFile(
|
||||
tokenPathB,
|
||||
JSON.stringify({
|
||||
accessToken: 'access-b',
|
||||
refreshToken: 'refresh-b',
|
||||
expiresAt: 4_100_000_000_002,
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPathA
|
||||
expect(getOpenAIOAuthTokens()?.accessToken).toBe('access-a')
|
||||
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = tokenPathB
|
||||
expect(getOpenAIOAuthTokens()?.accessToken).toBe('access-b')
|
||||
})
|
||||
|
||||
test('prefers env-pinned file authority when OPENAI_CODEX_OAUTH_FILE matches the secure-storage sentinel', async () => {
|
||||
const sentinelPath = '__secure-storage__'
|
||||
seedSecureStorage({
|
||||
accessToken: 'secure-access',
|
||||
refreshToken: 'secure-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
})
|
||||
|
||||
process.chdir(tmpDir)
|
||||
process.env.OPENAI_CODEX_OAUTH_FILE = sentinelPath
|
||||
await fsp.writeFile(
|
||||
path.join(tmpDir, sentinelPath),
|
||||
JSON.stringify({
|
||||
accessToken: 'file-access',
|
||||
refreshToken: 'file-refresh',
|
||||
expiresAt: 4_100_000_000_123,
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
clearOpenAIOAuthTokenCache()
|
||||
|
||||
expect(getOpenAIOAuthTokens()).toMatchObject({
|
||||
accessToken: 'file-access',
|
||||
refreshToken: 'file-refresh',
|
||||
expiresAt: 4_100_000_000_123,
|
||||
})
|
||||
await expect(getOpenAIOAuthTokensAsync()).resolves.toMatchObject({
|
||||
accessToken: 'file-access',
|
||||
refreshToken: 'file-refresh',
|
||||
expiresAt: 4_100_000_000_123,
|
||||
})
|
||||
})
|
||||
|
||||
test('cleans up tmp file if desktop token rename fails', async () => {
|
||||
const renameSyncSpy = spyOn(fs, 'renameSync').mockImplementation(() => {
|
||||
const error = new Error('rename failed') as NodeJS.ErrnoException
|
||||
error.code = 'EXDEV'
|
||||
throw error
|
||||
})
|
||||
|
||||
const result = saveOpenAIOAuthTokens({
|
||||
accessToken: 'fresh-access',
|
||||
refreshToken: 'fresh-refresh',
|
||||
expiresAt: 4_100_000_000_000,
|
||||
})
|
||||
|
||||
renameSyncSpy.mockRestore()
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
const tmpFiles = (await fsp.readdir(tmpDir)).filter((name) =>
|
||||
name.startsWith('openai-oauth.json.tmp.'),
|
||||
)
|
||||
expect(tmpFiles).toEqual([])
|
||||
})
|
||||
})
|
||||
@ -1,3 +1,6 @@
|
||||
import * as fs from 'fs'
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { getSecureStorage } from '../../utils/secureStorage/index.js'
|
||||
import { errorMessage } from '../../utils/errors.js'
|
||||
@ -5,20 +8,154 @@ import { logError } from '../../utils/log.js'
|
||||
import type { OpenAIOAuthTokens } from './types.js'
|
||||
|
||||
const STORAGE_KEY = 'openaiCodexOauth'
|
||||
export const OPENAI_CODEX_OAUTH_FILE_ENV_KEY = 'OPENAI_CODEX_OAUTH_FILE'
|
||||
|
||||
type SecureStorageShape = Record<string, unknown> & {
|
||||
openaiCodexOauth?: OpenAIOAuthTokens
|
||||
}
|
||||
|
||||
const SECURE_STORAGE_CACHE_KEY = 'secure-storage'
|
||||
const FILE_CACHE_KEY_PREFIX = 'file:'
|
||||
const FILE_BACKED_STORAGE_MARKER_FILE = 'openai-oauth-file-backed'
|
||||
|
||||
function getDesktopTokenFilePath(): string | null {
|
||||
const filePath = process.env[OPENAI_CODEX_OAUTH_FILE_ENV_KEY]?.trim()
|
||||
return filePath ? filePath : null
|
||||
}
|
||||
|
||||
function getCcHahaDir(): string {
|
||||
const configDir =
|
||||
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
return path.join(configDir, 'cc-haha')
|
||||
}
|
||||
|
||||
function getFileBackedStorageMarkerPath(): string {
|
||||
return path.join(getCcHahaDir(), FILE_BACKED_STORAGE_MARKER_FILE)
|
||||
}
|
||||
|
||||
function markFileBackedStorageUsed(): void {
|
||||
try {
|
||||
fs.mkdirSync(getCcHahaDir(), { recursive: true })
|
||||
fs.writeFileSync(getFileBackedStorageMarkerPath(), '1\n', { mode: 0o600 })
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function clearFileBackedStorageMarker(): void {
|
||||
try {
|
||||
fs.rmSync(getFileBackedStorageMarkerPath(), { force: true })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isSecureStorageFallbackDisabled(): boolean {
|
||||
return fs.existsSync(getFileBackedStorageMarkerPath())
|
||||
}
|
||||
|
||||
function normalizeTokenFile(value: unknown): OpenAIOAuthTokens | null {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||||
|
||||
const record = value as Record<string, unknown>
|
||||
if (
|
||||
typeof record.accessToken !== 'string' ||
|
||||
typeof record.refreshToken !== 'string' ||
|
||||
typeof record.expiresAt !== 'number'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: record.accessToken,
|
||||
refreshToken: record.refreshToken,
|
||||
expiresAt: record.expiresAt,
|
||||
...(typeof record.idToken === 'string' && { idToken: record.idToken }),
|
||||
...(typeof record.accountId === 'string' && {
|
||||
accountId: record.accountId,
|
||||
}),
|
||||
...(typeof record.email === 'string' && { email: record.email }),
|
||||
...(typeof record.clientId === 'string' && { clientId: record.clientId }),
|
||||
}
|
||||
}
|
||||
|
||||
function readDesktopTokenFileSync(filePath = getDesktopTokenFilePath()): OpenAIOAuthTokens | null {
|
||||
if (!filePath) return null
|
||||
|
||||
try {
|
||||
return normalizeTokenFile(JSON.parse(fs.readFileSync(filePath, 'utf-8')))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logError(error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readDesktopTokenFileAsync(
|
||||
filePath = getDesktopTokenFilePath(),
|
||||
): Promise<OpenAIOAuthTokens | null> {
|
||||
if (!filePath) return null
|
||||
|
||||
try {
|
||||
const raw = await fs.promises.readFile(filePath, 'utf-8')
|
||||
return normalizeTokenFile(JSON.parse(raw))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logError(error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function writeDesktopTokenFileSync(tokens: OpenAIOAuthTokens): boolean {
|
||||
const filePath = getDesktopTokenFilePath()
|
||||
if (!filePath) return false
|
||||
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
||||
const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}`
|
||||
let renamed = false
|
||||
|
||||
try {
|
||||
fs.writeFileSync(tmpFile, JSON.stringify(tokens, null, 2) + '\n', {
|
||||
mode: 0o600,
|
||||
})
|
||||
fs.renameSync(tmpFile, filePath)
|
||||
renamed = true
|
||||
return true
|
||||
} finally {
|
||||
if (!renamed) {
|
||||
try {
|
||||
fs.rmSync(tmpFile, { force: true })
|
||||
} catch (cleanupError) {
|
||||
if ((cleanupError as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
logError(cleanupError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): {
|
||||
success: boolean
|
||||
warning?: string
|
||||
} {
|
||||
try {
|
||||
if (writeDesktopTokenFileSync(tokens)) {
|
||||
markFileBackedStorageUsed()
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
const storage = getSecureStorage()
|
||||
const data = (storage.read() ?? {}) as SecureStorageShape
|
||||
data[STORAGE_KEY] = tokens
|
||||
const result = storage.update(data)
|
||||
if (result.success) {
|
||||
clearFileBackedStorageMarker()
|
||||
}
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return result
|
||||
} catch (error) {
|
||||
@ -30,18 +167,46 @@ export function saveOpenAIOAuthTokens(tokens: OpenAIOAuthTokens): {
|
||||
}
|
||||
}
|
||||
|
||||
export const getOpenAIOAuthTokens = memoize((): OpenAIOAuthTokens | null => {
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = storage.read() as SecureStorageShape | null
|
||||
return data?.openaiCodexOauth ?? null
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
const getOpenAIOAuthTokensCached = memoize(
|
||||
(cacheKey: string): OpenAIOAuthTokens | null => {
|
||||
if (cacheKey.startsWith(FILE_CACHE_KEY_PREFIX)) {
|
||||
return readDesktopTokenFileSync(
|
||||
cacheKey.slice(FILE_CACHE_KEY_PREFIX.length),
|
||||
)
|
||||
}
|
||||
|
||||
if (isSecureStorageFallbackDisabled()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = storage.read() as SecureStorageShape | null
|
||||
return data?.openaiCodexOauth ?? null
|
||||
} catch (error) {
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function getOpenAIOAuthTokens(): OpenAIOAuthTokens | null {
|
||||
const filePath = getDesktopTokenFilePath()
|
||||
return getOpenAIOAuthTokensCached(
|
||||
filePath ? `${FILE_CACHE_KEY_PREFIX}${filePath}` : SECURE_STORAGE_CACHE_KEY,
|
||||
)
|
||||
}
|
||||
|
||||
export async function getOpenAIOAuthTokensAsync(): Promise<OpenAIOAuthTokens | null> {
|
||||
const filePath = getDesktopTokenFilePath()
|
||||
if (filePath) {
|
||||
return readDesktopTokenFileAsync(filePath)
|
||||
}
|
||||
|
||||
if (isSecureStorageFallbackDisabled()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = getSecureStorage()
|
||||
const data = (await storage.readAsync()) as SecureStorageShape | null
|
||||
@ -53,11 +218,19 @@ export async function getOpenAIOAuthTokensAsync(): Promise<OpenAIOAuthTokens | n
|
||||
}
|
||||
|
||||
export function clearOpenAIOAuthTokenCache(): void {
|
||||
getOpenAIOAuthTokens.cache?.clear?.()
|
||||
getOpenAIOAuthTokensCached.cache?.clear?.()
|
||||
}
|
||||
|
||||
export function deleteOpenAIOAuthTokens(): boolean {
|
||||
try {
|
||||
const filePath = getDesktopTokenFilePath()
|
||||
if (filePath) {
|
||||
fs.rmSync(filePath, { force: true })
|
||||
markFileBackedStorageUsed()
|
||||
clearOpenAIOAuthTokenCache()
|
||||
return true
|
||||
}
|
||||
|
||||
const storage = getSecureStorage()
|
||||
const data = (storage.read() ?? {}) as SecureStorageShape
|
||||
delete data[STORAGE_KEY]
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
export type OpenAIOAuthTokenResponse = {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
refresh_token?: string
|
||||
id_token?: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
token_type?: string
|
||||
}
|
||||
|
||||
export type OpenAIOAuthTokens = {
|
||||
@ -12,6 +14,7 @@ export type OpenAIOAuthTokens = {
|
||||
idToken?: string
|
||||
accountId?: string
|
||||
email?: string
|
||||
clientId?: string
|
||||
}
|
||||
|
||||
export type OpenAIJwtClaims = {
|
||||
|
||||
@ -41,6 +41,8 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([
|
||||
'CLAUDE_CODE_SKIP_VERTEX_AUTH',
|
||||
'CLAUDE_CODE_SKIP_FOUNDRY_AUTH',
|
||||
'CLAUDE_CODE_SKIP_AZURE_OPENAI_AUTH',
|
||||
'CC_HAHA_OPENAI_OAUTH_PROVIDER',
|
||||
'OPENAI_CODEX_OAUTH_FILE',
|
||||
// Model defaults — often set to provider-specific ID formats
|
||||
'ANTHROPIC_MODEL',
|
||||
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user