Skip to content

Commit f1f0914

Browse files
authored
feat(providers): add DeepSeek account OAuth sign-in (#1548)
Add a built-in "DeepSeek account" provider next to ChatGPT, Grok and Claude official sign-in. Login follows the DeepSeek Harness flow: PKCE through the platform's auth_init / auth_exchange endpoints with a loopback callback. The resulting opaque account token is stored 0600 and sent as x-dsh-auth-token to the existing api.deepseek.com/anthropic endpoint, so no protocol translation or refresh is needed. - the CLI fetch layer swaps the Anthropic key headers for the account token per request, only for the DeepSeek inference origin, with redirects refused and a local non-retryable 401 when signed out - grants from a non-production platform origin or dsh_mock_ tokens never authenticate production requests - sign-out is local first and revokes remotely; a superseded or failed login revokes the token it minted - settings status re-checks the grant with the platform and treats only an explicit rejection as signed out - desktop: settings card with login risk confirmation, model selector and empty session entry, five locales; docs updated
1 parent 7261ada commit f1f0914

63 files changed

Lines changed: 3591 additions & 55 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎desktop/src/__tests__/generalSettings.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2325,6 +2325,7 @@ describe('Settings > Providers tab', () => {
23252325
'openai-official-provider',
23262326
'claude-official-provider',
23272327
'grok-official-provider',
2328+
'deepseek-account-provider',
23282329
])
23292330
})
23302331

@@ -2340,6 +2341,7 @@ describe('Settings > Providers tab', () => {
23402341
'claude-official-provider',
23412342
'openai-official-provider',
23422343
'grok-official-provider',
2344+
'deepseek-account-provider',
23432345
])
23442346
})
23452347

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
const { postMock, getMock, deleteMock } = vi.hoisted(() => ({
4+
postMock: vi.fn(),
5+
getMock: vi.fn(),
6+
deleteMock: vi.fn(),
7+
}))
8+
9+
vi.mock('./client', () => ({
10+
api: { post: postMock, get: getMock, delete: deleteMock },
11+
}))
12+
13+
import { hahaDeepSeekOAuthApi } from './hahaDeepSeekOAuth'
14+
15+
describe('hahaDeepSeekOAuthApi', () => {
16+
beforeEach(() => {
17+
postMock.mockReset()
18+
getMock.mockReset()
19+
deleteMock.mockReset()
20+
})
21+
22+
it('starts the login with an empty body, adding the locale only when given', async () => {
23+
postMock.mockResolvedValue({ authorizeUrl: 'https://example.invalid/auth', state: 's' })
24+
25+
await hahaDeepSeekOAuthApi.start()
26+
await hahaDeepSeekOAuthApi.start('zh-TW')
27+
28+
expect(postMock).toHaveBeenNthCalledWith(1, '/api/haha-deepseek-oauth/start', {})
29+
expect(postMock).toHaveBeenNthCalledWith(2, '/api/haha-deepseek-oauth/start', { locale: 'zh-TW' })
30+
})
31+
32+
it('reads the status and logs out on the same base path', async () => {
33+
getMock.mockResolvedValue({ loggedIn: false })
34+
deleteMock.mockResolvedValue({ ok: true })
35+
36+
await hahaDeepSeekOAuthApi.status()
37+
await hahaDeepSeekOAuthApi.logout()
38+
39+
expect(getMock).toHaveBeenCalledWith('/api/haha-deepseek-oauth')
40+
expect(deleteMock).toHaveBeenCalledWith('/api/haha-deepseek-oauth')
41+
})
42+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { api } from './client'
2+
3+
export type HahaDeepSeekOAuthStatus =
4+
| { loggedIn: false }
5+
| {
6+
loggedIn: true
7+
name: string | null
8+
/** Masked email or mobile number of the signed-in account. */
9+
contact: string | null
10+
}
11+
12+
export const hahaDeepSeekOAuthApi = {
13+
/** `locale` only picks the language of the DeepSeek login page. */
14+
start(locale?: string) {
15+
return api.post<{ authorizeUrl: string; state: string }>(
16+
'/api/haha-deepseek-oauth/start',
17+
locale ? { locale } : {},
18+
)
19+
},
20+
21+
status() {
22+
return api.get<HahaDeepSeekOAuthStatus>('/api/haha-deepseek-oauth')
23+
},
24+
25+
logout() {
26+
return api.delete<{ ok: true }>('/api/haha-deepseek-oauth')
27+
},
28+
}

‎desktop/src/api/providers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ type TestResultResponse = { result: ProviderTestResult }
2222
type OfficialProviderModelsResponse = { models: ModelMapping }
2323
type AuthStatusResponse = {
2424
hasAuth: boolean
25-
source: 'cc-haha-provider' | 'claude-oauth' | 'openai-oauth' | 'grok-oauth' | 'original-settings' | 'env' | 'none'
25+
source: 'cc-haha-provider' | 'claude-oauth' | 'openai-oauth' | 'grok-oauth' | 'deepseek-oauth' | 'original-settings' | 'env' | 'none'
2626
activeProvider?: string
2727
}
2828

‎desktop/src/components/controls/ModelSelector.test.tsx‎

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useChatStore } from '../../stores/chatStore'
2020
import { useHahaOAuthStore } from '../../stores/hahaOAuthStore'
2121
import { useHahaOpenAIOAuthStore } from '../../stores/hahaOpenAIOAuthStore'
2222
import { useHahaGrokOAuthStore } from '../../stores/hahaGrokOAuthStore'
23+
import { useHahaDeepSeekOAuthStore } from '../../stores/hahaDeepSeekOAuthStore'
2324
import { useProviderStore } from '../../stores/providerStore'
2425
import { useSessionRuntimeStore } from '../../stores/sessionRuntimeStore'
2526
import { useSettingsStore } from '../../stores/settingsStore'
@@ -52,6 +53,7 @@ afterEach(() => {
5253
useHahaOAuthStore.setState(useHahaOAuthStore.getInitialState(), true)
5354
useHahaOpenAIOAuthStore.setState(useHahaOpenAIOAuthStore.getInitialState(), true)
5455
useHahaGrokOAuthStore.setState(useHahaGrokOAuthStore.getInitialState(), true)
56+
useHahaDeepSeekOAuthStore.setState(useHahaDeepSeekOAuthStore.getInitialState(), true)
5557
useTabStore.setState(useTabStore.getInitialState(), true)
5658
useUIStore.setState(useUIStore.getInitialState(), true)
5759
})
@@ -60,6 +62,7 @@ beforeEach(() => {
6062
useHahaOAuthStore.setState({ fetchStatus: async () => {} })
6163
useHahaOpenAIOAuthStore.setState({ fetchStatus: async () => {} })
6264
useHahaGrokOAuthStore.setState({ fetchStatus: async () => {} })
65+
useHahaDeepSeekOAuthStore.setState({ fetchStatus: async () => {} })
6366
})
6467

6568
describe('ModelSelector', () => {
@@ -356,9 +359,13 @@ describe('ModelSelector', () => {
356359
const fetchGrokStatus = vi.fn(async () => {
357360
useHahaGrokOAuthStore.setState({ status: { loggedIn: false } })
358361
})
362+
const fetchDeepSeekStatus = vi.fn(async () => {
363+
useHahaDeepSeekOAuthStore.setState({ status: { loggedIn: false } })
364+
})
359365
useHahaOAuthStore.setState({ fetchStatus: fetchClaudeStatus })
360366
useHahaOpenAIOAuthStore.setState({ fetchStatus: fetchOpenAIStatus })
361367
useHahaGrokOAuthStore.setState({ fetchStatus: fetchGrokStatus })
368+
useHahaDeepSeekOAuthStore.setState({ fetchStatus: fetchDeepSeekStatus })
362369
useSettingsStore.setState({
363370
locale: 'en',
364371
availableModels: MODELS,
@@ -391,6 +398,7 @@ describe('ModelSelector', () => {
391398
expect(fetchClaudeStatus).toHaveBeenCalledTimes(1)
392399
expect(fetchOpenAIStatus).toHaveBeenCalledTimes(1)
393400
expect(fetchGrokStatus).toHaveBeenCalledTimes(1)
401+
expect(fetchDeepSeekStatus).toHaveBeenCalledTimes(1)
394402
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
395403
expect(screen.queryByTestId('model-selector-dropdown')).not.toBeInTheDocument()
396404
})
@@ -399,9 +407,11 @@ describe('ModelSelector', () => {
399407
const fetchClaudeStatus = vi.fn(async () => {})
400408
const fetchOpenAIStatus = vi.fn(async () => {})
401409
const fetchGrokStatus = vi.fn(async () => {})
410+
const fetchDeepSeekStatus = vi.fn(async () => {})
402411
useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: fetchClaudeStatus })
403412
useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: fetchOpenAIStatus })
404413
useHahaGrokOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: fetchGrokStatus })
414+
useHahaDeepSeekOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: fetchDeepSeekStatus })
405415
useSettingsStore.setState({
406416
locale: 'en',
407417
currentModel: {
@@ -424,6 +434,7 @@ describe('ModelSelector', () => {
424434
expect(fetchClaudeStatus).not.toHaveBeenCalled()
425435
expect(fetchOpenAIStatus).not.toHaveBeenCalled()
426436
expect(fetchGrokStatus).not.toHaveBeenCalled()
437+
expect(fetchDeepSeekStatus).not.toHaveBeenCalled()
427438
expect(useUIStore.getState().pendingSettingsTab).toBe('providers')
428439
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
429440
})
@@ -1373,6 +1384,147 @@ describe('ModelSelector', () => {
13731384
expect(screen.queryByRole('button', { name: /Effort:/i })).not.toBeInTheDocument()
13741385
})
13751386

1387+
describe('DeepSeek Account', () => {
1388+
const savedProvider = {
1389+
id: 'provider-a',
1390+
presetId: 'custom',
1391+
name: 'Provider A',
1392+
apiKey: '***',
1393+
baseUrl: 'https://api.example.com',
1394+
apiFormat: 'anthropic' as const,
1395+
models: { main: 'provider-main', haiku: '', sonnet: '', opus: '' },
1396+
}
1397+
1398+
function useSavedProviderActive() {
1399+
useSettingsStore.setState({
1400+
locale: 'en',
1401+
availableModels: MODELS,
1402+
currentModel: MODELS[0],
1403+
activeProviderName: 'Provider A',
1404+
})
1405+
useProviderStore.setState({
1406+
providers: [savedProvider],
1407+
activeId: 'provider-a',
1408+
hasLoadedProviders: true,
1409+
isLoading: false,
1410+
})
1411+
useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
1412+
useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
1413+
useHahaGrokOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
1414+
}
1415+
1416+
it('does not offer DeepSeek Account models while signed out', async () => {
1417+
useSavedProviderActive()
1418+
useHahaDeepSeekOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
1419+
1420+
render(<ModelSelector runtimeKey="session-ds-out" />)
1421+
await clickByRole(/provider-main/i)
1422+
1423+
const dropdown = screen.getByTestId('model-selector-dropdown')
1424+
expect(dropdown.textContent).toContain('Provider A')
1425+
expect(dropdown.textContent).not.toContain('DeepSeek Account')
1426+
expect(dropdown.textContent).not.toContain('deepseek-v4')
1427+
})
1428+
1429+
it('lists the bundled DeepSeek models once signed in and selects one for the session', async () => {
1430+
useSavedProviderActive()
1431+
useHahaDeepSeekOAuthStore.setState({
1432+
status: { loggedIn: true, name: 'Ada', contact: 'a***@example.com' },
1433+
fetchStatus: async () => {},
1434+
})
1435+
1436+
render(<ModelSelector runtimeKey="session-ds-in" />)
1437+
await clickByRole(/provider-main/i)
1438+
1439+
const dropdown = screen.getByTestId('model-selector-dropdown')
1440+
expect(dropdown.textContent).toContain('DeepSeek Account')
1441+
expect(within(dropdown).getByRole('button', { name: /deepseek-v4-pro\[1m\]/ })).toBeInTheDocument()
1442+
await act(async () => {
1443+
fireEvent.click(within(dropdown).getByRole('button', { name: /deepseek-v4-flash/ }))
1444+
await Promise.resolve()
1445+
})
1446+
1447+
expect(useSessionRuntimeStore.getState().selections['session-ds-in']).toMatchObject({
1448+
providerId: 'deepseek-account',
1449+
modelId: 'deepseek-v4-flash',
1450+
})
1451+
})
1452+
1453+
it('keeps a restored DeepSeek Account selection instead of falling back to the saved provider', () => {
1454+
useSavedProviderActive()
1455+
useHahaDeepSeekOAuthStore.setState({ status: null, fetchStatus: async () => {} })
1456+
useSessionRuntimeStore.getState().setSelection('session-ds-restored', {
1457+
providerId: 'deepseek-account',
1458+
modelId: 'deepseek-v4-pro[1m]',
1459+
})
1460+
1461+
render(<ModelSelector runtimeKey="session-ds-restored" />)
1462+
1463+
expect(screen.getByRole('button', { name: /deepseek-v4-pro\[1m\], DeepSeek Account/ })).toBeInTheDocument()
1464+
})
1465+
1466+
it('fetches the DeepSeek sign-in status when the selector opens', async () => {
1467+
useSavedProviderActive()
1468+
const fetchStatus = vi.fn(async () => {})
1469+
useHahaDeepSeekOAuthStore.setState({ status: null, fetchStatus })
1470+
1471+
render(<ModelSelector runtimeKey="session-ds-lazy" />)
1472+
expect(fetchStatus).not.toHaveBeenCalled()
1473+
await clickByRole(/provider-main/i)
1474+
1475+
expect(fetchStatus).toHaveBeenCalledTimes(1)
1476+
})
1477+
1478+
it('opens the selector instead of routing to settings when DeepSeek is the only login', async () => {
1479+
useSettingsStore.setState({ locale: 'en', currentModel: undefined, activeProviderName: null })
1480+
useProviderStore.setState({ providers: [], activeId: null, hasLoadedProviders: true, isLoading: false })
1481+
useHahaOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
1482+
useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
1483+
useHahaGrokOAuthStore.setState({ status: { loggedIn: false }, fetchStatus: async () => {} })
1484+
useHahaDeepSeekOAuthStore.setState({
1485+
status: { loggedIn: true, name: null, contact: null },
1486+
fetchStatus: async () => {},
1487+
})
1488+
1489+
render(<ModelSelector runtimeKey="session-ds-only" />)
1490+
await act(async () => {
1491+
fireEvent.click(screen.getByRole('button'))
1492+
await Promise.resolve()
1493+
})
1494+
1495+
expect(useUIStore.getState().pendingSettingsTab).not.toBe('providers')
1496+
expect(screen.getByTestId('model-selector-dropdown').textContent).toContain('DeepSeek Account')
1497+
})
1498+
1499+
it('opens the selector after the status fetch finds a DeepSeek login nobody had checked yet', async () => {
1500+
useSettingsStore.setState({ locale: 'en', currentModel: undefined, activeProviderName: null })
1501+
useProviderStore.setState({ providers: [], activeId: null, hasLoadedProviders: true, isLoading: false })
1502+
useHahaOAuthStore.setState({ status: null, fetchStatus: async () => {
1503+
useHahaOAuthStore.setState({ status: { loggedIn: false } })
1504+
} })
1505+
useHahaOpenAIOAuthStore.setState({ status: null, fetchStatus: async () => {
1506+
useHahaOpenAIOAuthStore.setState({ status: { loggedIn: false } })
1507+
} })
1508+
useHahaGrokOAuthStore.setState({ status: null, fetchStatus: async () => {
1509+
useHahaGrokOAuthStore.setState({ status: { loggedIn: false } })
1510+
} })
1511+
useHahaDeepSeekOAuthStore.setState({ status: null, fetchStatus: async () => {
1512+
useHahaDeepSeekOAuthStore.setState({ status: { loggedIn: true, name: null, contact: null } })
1513+
} })
1514+
1515+
render(<ModelSelector runtimeKey="session-ds-late" />)
1516+
await act(async () => {
1517+
fireEvent.click(screen.getByRole('button'))
1518+
await Promise.resolve()
1519+
})
1520+
1521+
await waitFor(() => {
1522+
expect(screen.getByTestId('model-selector-dropdown').textContent).toContain('DeepSeek Account')
1523+
})
1524+
expect(useUIStore.getState().pendingSettingsTab).not.toBe('providers')
1525+
})
1526+
})
1527+
13761528
it('replaces a stale Grok runtime model with the current official default', async () => {
13771529
const grokModels: ModelInfo[] = [{
13781530
id: 'grok-4.7',

0 commit comments

Comments
 (0)