dsh-oauth-login 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/INSTALL.md +59 -0
  2. package/INSTALL.zh.md +45 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +104 -0
  5. package/README.md +104 -0
  6. package/README.zh.md +3 -0
  7. package/SECURITY.md +33 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/network-proxy.md +45 -0
  10. package/docs/openrouter-sync.md +84 -0
  11. package/docs/screenshots/01-providers-signed-out.png +0 -0
  12. package/docs/screenshots/03-copilot-signing-in.png +0 -0
  13. package/docs/screenshots/04-mixed-live-states.png +0 -0
  14. package/docs/screenshots/05-copilot-sign-in.gif +0 -0
  15. package/docs/screenshots/06-cli-status-store.png +0 -0
  16. package/lib/bin.d.ts +5 -0
  17. package/lib/bin.js +2132 -0
  18. package/lib/client.js +1915 -0
  19. package/lib/client.js.map +1 -0
  20. package/lib/index.d.ts +482 -0
  21. package/lib/index.js +3105 -0
  22. package/lib/invariant.d.ts +7 -0
  23. package/lib/invariant.js +8 -0
  24. package/package.json +180 -0
  25. package/src/adapter.ts +207 -0
  26. package/src/auth-routes.ts +654 -0
  27. package/src/auth.ts +76 -0
  28. package/src/bin.ts +185 -0
  29. package/src/catalog.ts +129 -0
  30. package/src/client/OpenRouterSyncStatus.tsx +66 -0
  31. package/src/client/PiLoginSettings.tsx +470 -0
  32. package/src/client/ProxySettings.tsx +123 -0
  33. package/src/client/SearchableModelSelect.tsx +595 -0
  34. package/src/client/draft-input.ts +12 -0
  35. package/src/client/index.tsx +71 -0
  36. package/src/client/locales.ts +176 -0
  37. package/src/client/login-input-copy.ts +34 -0
  38. package/src/client/login-window.ts +24 -0
  39. package/src/client/model-filter.ts +45 -0
  40. package/src/client/openrouter-store.ts +122 -0
  41. package/src/client/proxy-draft.ts +57 -0
  42. package/src/extra-models.ts +201 -0
  43. package/src/hosted-capture.ts +41 -0
  44. package/src/hosted-images.ts +154 -0
  45. package/src/http.ts +241 -0
  46. package/src/ids.ts +17 -0
  47. package/src/index.ts +183 -0
  48. package/src/invariant.ts +12 -0
  49. package/src/model-error-hint.ts +98 -0
  50. package/src/native-tools.ts +442 -0
  51. package/src/oauth-refresh.ts +49 -0
  52. package/src/openrouter-catalog.ts +245 -0
  53. package/src/openrouter-free.ts +76 -0
  54. package/src/openrouter-models.ts +155 -0
  55. package/src/openrouter-types.ts +40 -0
  56. package/src/plugin-config.ts +30 -0
  57. package/src/provider.ts +137 -0
  58. package/src/proxy-config.ts +61 -0
  59. package/src/proxy-store.ts +45 -0
  60. package/src/proxy-transport.ts +220 -0
  61. package/src/redact.ts +22 -0
  62. package/src/responses-tap.ts +97 -0
  63. package/src/session.ts +104 -0
  64. package/src/store.ts +234 -0
package/src/auth.ts ADDED
@@ -0,0 +1,76 @@
1
+ /** DSH-owned models.login() for every subscribed provider in the catalog. */
2
+
3
+ import { createModels } from '@earendil-works/pi-ai'
4
+ import { dirname, join } from 'node:path'
5
+ import type { AuthInteraction } from '@earendil-works/pi-ai'
6
+ import { requirePiLoginProvider } from './catalog.ts'
7
+ import { OAuthProxyTransport } from './proxy-transport.ts'
8
+ import { PROXY_SETTINGS_FILENAME } from './proxy-config.ts'
9
+ import { catalogProvider } from './provider.ts'
10
+ import type { PiLoginSession } from './session.ts'
11
+ import { PiLoginCredentialStore } from './store.ts'
12
+
13
+ export interface PiLoginAuthStatus {
14
+ providerId: string
15
+ authenticated: boolean
16
+ credentialType?: 'oauth' | 'api_key'
17
+ expiresAt?: Date
18
+ }
19
+
20
+ export async function loginPiProvider(
21
+ providerId: string,
22
+ interaction: AuthInteraction,
23
+ store: PiLoginCredentialStore = new PiLoginCredentialStore(),
24
+ ): Promise<void> {
25
+ const spec = requirePiLoginProvider(providerId)
26
+ const models = createModels({ credentials: store })
27
+ models.setProvider(catalogProvider(providerId))
28
+ const proxy = new OAuthProxyTransport(join(dirname(store.filename), PROXY_SETTINGS_FILENAME))
29
+ try { await proxy.run(() => models.login(providerId, spec.authType, interaction)) } finally { await proxy.dispose() }
30
+ }
31
+
32
+ export async function logoutPiProvider(
33
+ providerId: string,
34
+ store: PiLoginCredentialStore = new PiLoginCredentialStore(),
35
+ ): Promise<void> {
36
+ requirePiLoginProvider(providerId)
37
+ await store.delete(providerId)
38
+ }
39
+
40
+ export async function piLoginStatus(
41
+ store: PiLoginCredentialStore = new PiLoginCredentialStore(),
42
+ providerId?: string,
43
+ ): Promise<PiLoginAuthStatus[]> {
44
+ const ids = providerId === undefined ? (await store.list()).map(item => item.providerId) : [providerId]
45
+ const out: PiLoginAuthStatus[] = []
46
+ for (const id of ids) {
47
+ const credential = await store.read(id)
48
+ if (credential?.type === 'oauth') {
49
+ out.push({
50
+ providerId: id,
51
+ authenticated: true,
52
+ credentialType: 'oauth',
53
+ expiresAt: new Date(credential.expires),
54
+ })
55
+ continue
56
+ }
57
+ if (credential?.type === 'api_key' && typeof credential.key === 'string' && credential.key.length > 0) {
58
+ out.push({ providerId: id, authenticated: true, credentialType: 'api_key' })
59
+ continue
60
+ }
61
+ out.push({ providerId: id, authenticated: false })
62
+ }
63
+ return out
64
+ }
65
+
66
+ export async function loginPiProviderSession(
67
+ providerId: string,
68
+ interaction: AuthInteraction,
69
+ session: PiLoginSession,
70
+ ): Promise<void> {
71
+ const spec = requirePiLoginProvider(providerId)
72
+ await session.ensureTransport()
73
+ session.models.setProvider(catalogProvider(providerId))
74
+ await session.proxy.run(() => session.models.login(providerId, spec.authType, interaction))
75
+ if (providerId === 'openrouter') await session.openRouter.syncAuthentication('login')
76
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ /** Standalone credential CLI. Never reads or writes official CLI auth files. */
3
+
4
+ import { spawn } from 'node:child_process'
5
+ import { realpathSync } from 'node:fs'
6
+ import { createInterface } from 'node:readline/promises'
7
+ import { fileURLToPath } from 'node:url'
8
+ import type { AuthEvent, AuthPrompt } from '@earendil-works/pi-ai'
9
+ import { PI_LOGIN_PROVIDERS, requirePiLoginProvider } from './catalog.ts'
10
+ import {
11
+ loginPiProviderSession,
12
+ piLoginAuthPath,
13
+ piLoginStatus,
14
+ PiLoginSession,
15
+ } from './index.ts'
16
+ import { isSafeAuthUrl, safeMessage } from './redact.ts'
17
+
18
+ type Action = 'login' | 'logout' | 'status'
19
+
20
+ function openBrowser(rawUrl: string, providerId: string): void {
21
+ const spec = requirePiLoginProvider(providerId)
22
+ if (!isSafeAuthUrl(rawUrl, spec)) {
23
+ throw new Error(`refusing to open authorization URL outside ${spec.displayName} official hosts`)
24
+ }
25
+ const url = new URL(rawUrl)
26
+ const command = process.platform === 'win32'
27
+ ? { file: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url.href] }
28
+ : process.platform === 'darwin'
29
+ ? { file: 'open', args: [url.href] }
30
+ : { file: 'xdg-open', args: [url.href] }
31
+ try {
32
+ const child = spawn(command.file, command.args, {
33
+ detached: true,
34
+ stdio: 'ignore',
35
+ windowsHide: true,
36
+ })
37
+ child.on('error', () => {})
38
+ child.unref()
39
+ } catch {
40
+ // printed URL remains the fallback
41
+ }
42
+ }
43
+
44
+ function notify(event: AuthEvent, providerId: string, useBrowser: boolean): void {
45
+ switch (event.type) {
46
+ case 'auth_url':
47
+ process.stdout.write(`Open this URL to sign in:\n${event.url}\n`)
48
+ if (event.instructions !== undefined) process.stdout.write(`${event.instructions}\n`)
49
+ if (useBrowser) openBrowser(event.url, providerId)
50
+ break
51
+ case 'device_code':
52
+ process.stdout.write(`Open this URL to sign in:\n${event.verificationUri}\n`)
53
+ if (event.userCode.length > 0) process.stdout.write(`Enter code: ${event.userCode}\n`)
54
+ if (useBrowser) openBrowser(event.verificationUri, providerId)
55
+ break
56
+ case 'info':
57
+ case 'progress':
58
+ process.stdout.write(`${event.message}\n`)
59
+ break
60
+ default:
61
+ event satisfies never
62
+ }
63
+ }
64
+
65
+ async function answerPrompt(
66
+ prompt: AuthPrompt,
67
+ question: (text: string, options: { signal?: AbortSignal }) => Promise<string>,
68
+ ): Promise<string> {
69
+ if (prompt.type === 'select') {
70
+ const oauth = prompt.options.find(option => option.id === 'oauth' || option.id.includes('oauth'))
71
+ const browser = prompt.options.find(option => option.id.includes('browser'))
72
+ return oauth?.id ?? browser?.id ?? prompt.options[0]?.id ?? 'oauth'
73
+ }
74
+ const suffix = prompt.placeholder === undefined ? '' : ` (${prompt.placeholder})`
75
+ return question(`${prompt.message}${suffix}: `, {
76
+ ...prompt.signal === undefined ? {} : { signal: prompt.signal },
77
+ })
78
+ }
79
+
80
+ function printHelp(): void {
81
+ const ids = PI_LOGIN_PROVIDERS.map(provider => ` ${provider.id.padEnd(18)} ${provider.displayName}`).join('\n')
82
+ process.stdout.write([
83
+ 'Usage: dsh-oauth-login <login|logout|status> [provider]',
84
+ '',
85
+ ' login [provider] Subscription OAuth or official Plan API key',
86
+ ' logout [provider] remove a dsh credential (or all if omitted)',
87
+ ' status [provider] report non-secret credential state',
88
+ '',
89
+ 'Providers:',
90
+ ids,
91
+ '',
92
+ ].join('\n'))
93
+ }
94
+
95
+ function resolveIds(raw: string | undefined): string[] {
96
+ if (raw === undefined) return PI_LOGIN_PROVIDERS.map(provider => provider.id)
97
+ return [requirePiLoginProvider(raw).id]
98
+ }
99
+
100
+ export async function run(argv: readonly string[]): Promise<number> {
101
+ if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
102
+ printHelp()
103
+ return 0
104
+ }
105
+ const [rawAction, rawProvider, ...rest] = argv
106
+ if (rawAction !== 'login' && rawAction !== 'logout' && rawAction !== 'status') {
107
+ process.stderr.write(`dsh-oauth-login: expected login, logout, or status; got ${JSON.stringify(rawAction)}\n`)
108
+ return 1
109
+ }
110
+ if (rest.length > 0) {
111
+ process.stderr.write(`dsh-oauth-login: unexpected extra arguments: ${rest.join(' ')}\n`)
112
+ return 1
113
+ }
114
+ const action: Action = rawAction
115
+ try {
116
+ const session = new PiLoginSession()
117
+ switch (action) {
118
+ case 'status': {
119
+ const ids = resolveIds(rawProvider)
120
+ let failed = false
121
+ process.stdout.write(`store: ${piLoginAuthPath()}\n`)
122
+ await session.refreshStoredGrants()
123
+ for (const id of ids) {
124
+ const [status] = await piLoginStatus(session.store, id)
125
+ const spec = requirePiLoginProvider(id)
126
+ if (status === undefined || !status.authenticated) {
127
+ process.stdout.write(`${spec.displayName}: signed out\n`)
128
+ failed = true
129
+ continue
130
+ }
131
+ const expires = status.expiresAt
132
+ const suffix = expires === undefined || Number.isNaN(expires.valueOf())
133
+ ? ''
134
+ : `; access expires ${expires.toISOString()}`
135
+ const models = session.visibleModels(id).map(model => model.id).slice(0, 8).join(', ')
136
+ process.stdout.write(`${spec.displayName}: signed in${suffix}\n`)
137
+ process.stdout.write(` route: ${spec.route}\n`)
138
+ process.stdout.write(` models: ${models}\n`)
139
+ }
140
+ return failed && rawProvider !== undefined ? 1 : 0
141
+ }
142
+ case 'logout': {
143
+ const ids = resolveIds(rawProvider)
144
+ for (const id of ids) {
145
+ await session.logout(id)
146
+ process.stdout.write(`${requirePiLoginProvider(id).displayName}: signed out\n`)
147
+ }
148
+ process.stdout.write('Official CLI auth files were not touched.\n')
149
+ return 0
150
+ }
151
+ case 'login': {
152
+ if (rawProvider === undefined) {
153
+ process.stderr.write('dsh-oauth-login: login requires a provider id (see --help)\n')
154
+ return 1
155
+ }
156
+ const id = requirePiLoginProvider(rawProvider).id
157
+ const spec = requirePiLoginProvider(id)
158
+ if (spec.authType === 'api_key' && spec.loginUrl !== undefined) {
159
+ process.stdout.write(`Create or copy the Plan API key here:\n${spec.loginUrl}\n`)
160
+ openBrowser(spec.loginUrl, id)
161
+ }
162
+ const readline = createInterface({ input: process.stdin, output: process.stdout })
163
+ try {
164
+ await loginPiProviderSession(id, {
165
+ prompt: prompt => answerPrompt(prompt, (text, options) => readline.question(text, options)),
166
+ notify: event => notify(event, id, true),
167
+ }, session)
168
+ } finally {
169
+ readline.close()
170
+ }
171
+ process.stdout.write(`${requirePiLoginProvider(id).displayName}: signed in\n`)
172
+ process.stdout.write(`store: ${piLoginAuthPath()}\n`)
173
+ process.stdout.write('Official CLI auth files were not read or written.\n')
174
+ return 0
175
+ }
176
+ }
177
+ } catch (error: unknown) {
178
+ process.stderr.write(`dsh-oauth-login: ${action} failed: ${safeMessage(error)}\n`)
179
+ return 1
180
+ }
181
+ }
182
+
183
+ if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
184
+ process.exitCode = await run(process.argv.slice(2))
185
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,129 @@
1
+ /** OAuth subscriptions Pi Agent exposes via /login. Radius is omitted (needs a gateway). */
2
+
3
+ export interface PiLoginProvider {
4
+ /** pi-ai provider id used by models.login(). */
5
+ readonly id: string
6
+ /** Harness LLM route. Distinct from catalog routes and from other plugins. */
7
+ readonly route: string
8
+ /** Credential method this plugin deliberately exposes for this provider. */
9
+ readonly authType: 'oauth' | 'api_key'
10
+ /** Official account page opened before an interactive API-key prompt. */
11
+ readonly loginUrl?: string
12
+ readonly displayName: string
13
+ readonly shortName: string
14
+ readonly blurb: string
15
+ readonly blurbZh: string
16
+ /** HTTPS hosts this provider is allowed to open during login. */
17
+ readonly allowedHosts: readonly string[]
18
+ /** Extra allowed host suffixes (leading-dot match). */
19
+ readonly allowedSuffixes: readonly string[]
20
+ readonly preferredModels: readonly string[]
21
+ }
22
+
23
+ export const PI_LOGIN_PROVIDERS: readonly PiLoginProvider[] = [
24
+ {
25
+ id: 'openai-codex',
26
+ route: 'pi-openai-codex',
27
+ authType: 'oauth',
28
+ displayName: 'ChatGPT Codex',
29
+ shortName: 'Codex',
30
+ blurb: 'ChatGPT Plus/Pro Codex. Independent of official `codex login`.',
31
+ blurbZh: 'ChatGPT Plus/Pro 的 Codex。和官方 `codex login` 互不影响。',
32
+ allowedHosts: ['auth.openai.com', 'chatgpt.com', 'www.chatgpt.com'],
33
+ allowedSuffixes: ['.openai.com', '.chatgpt.com'],
34
+ preferredModels: ['gpt-5.4', 'gpt-5.3-codex', 'gpt-5.3-codex-spark'],
35
+ },
36
+ {
37
+ id: 'anthropic',
38
+ route: 'pi-anthropic',
39
+ authType: 'oauth',
40
+ displayName: 'Claude Pro/Max',
41
+ shortName: 'Claude',
42
+ blurb: 'Claude subscription. Independent of official Claude Code login.',
43
+ blurbZh: 'Claude 订阅。和官方 Claude Code 登录互不影响。',
44
+ allowedHosts: ['claude.ai', 'www.claude.ai', 'platform.claude.com'],
45
+ allowedSuffixes: ['.anthropic.com', '.claude.ai'],
46
+ preferredModels: ['claude-opus-4-6', 'claude-sonnet-4-6', 'claude-opus-4-5'],
47
+ },
48
+ {
49
+ id: 'xai',
50
+ route: 'pi-xai',
51
+ authType: 'oauth',
52
+ displayName: 'xAI Grok',
53
+ shortName: 'Grok',
54
+ blurb: 'SuperGrok / X Premium. Independent of official `grok` CLI.',
55
+ blurbZh: 'SuperGrok / X Premium。和官方 `grok` CLI 互不影响。',
56
+ allowedHosts: ['auth.x.ai', 'accounts.x.ai', 'x.ai', 'www.x.ai'],
57
+ allowedSuffixes: ['.x.ai'],
58
+ preferredModels: ['grok-4.6', 'grok-4.5', 'grok-4.3'],
59
+ },
60
+ {
61
+ id: 'github-copilot',
62
+ route: 'pi-github-copilot',
63
+ authType: 'oauth',
64
+ displayName: 'GitHub Copilot',
65
+ shortName: 'Copilot',
66
+ blurb: 'GitHub Copilot subscription via device code.',
67
+ blurbZh: 'GitHub Copilot 订阅,走 device code。',
68
+ allowedHosts: ['github.com', 'www.github.com', 'api.github.com'],
69
+ allowedSuffixes: ['.github.com', '.githubcopilot.com'],
70
+ preferredModels: ['gpt-5.4', 'claude-sonnet-4.6', 'claude-opus-4.6'],
71
+ },
72
+ {
73
+ id: 'openrouter',
74
+ route: 'pi-openrouter',
75
+ authType: 'oauth',
76
+ displayName: 'OpenRouter',
77
+ shortName: 'OpenRouter',
78
+ blurb: 'OpenRouter OAuth mints a key billed from your OpenRouter credits.',
79
+ blurbZh: 'OpenRouter OAuth 会签发一把钥匙,从你的 OpenRouter 余额扣费。',
80
+ allowedHosts: ['openrouter.ai', 'www.openrouter.ai'],
81
+ allowedSuffixes: ['.openrouter.ai'],
82
+ preferredModels: [],
83
+ },
84
+ {
85
+ id: 'kimi-coding',
86
+ route: 'pi-kimi-coding',
87
+ authType: 'oauth',
88
+ displayName: 'Kimi For Coding',
89
+ shortName: 'Kimi',
90
+ blurb: 'Kimi Code subscription.',
91
+ blurbZh: 'Kimi Code 订阅。',
92
+ allowedHosts: ['auth.kimi.com', 'kimi.com', 'www.kimi.com', 'api.kimi.com'],
93
+ allowedSuffixes: ['.kimi.com'],
94
+ preferredModels: ['kimi-for-coding', 'k3'],
95
+ },
96
+ {
97
+ id: 'zai-coding-cn',
98
+ route: 'pi-zai-coding-cn',
99
+ authType: 'api_key',
100
+ loginUrl: 'https://bigmodel.cn/coding-plan/personal/overview',
101
+ displayName: '智谱 GLM Coding Plan',
102
+ shortName: 'GLM',
103
+ blurb: 'Zhipu GLM Coding Plan (China) via its official Plan API key.',
104
+ blurbZh: '智谱 GLM Coding Plan 中国区套餐,使用套餐页签发的专用 API Key。',
105
+ allowedHosts: ['bigmodel.cn', 'www.bigmodel.cn', 'open.bigmodel.cn'],
106
+ allowedSuffixes: ['.bigmodel.cn'],
107
+ preferredModels: ['glm-5.3-flash', 'glm-5.3', 'glm-5.2', 'glm-5-turbo', 'glm-5.1', 'glm-4.7'],
108
+ },
109
+ ]
110
+
111
+ export function piLoginProvider(id: string): PiLoginProvider | undefined {
112
+ return PI_LOGIN_PROVIDERS.find(provider => provider.id === id)
113
+ }
114
+
115
+ export function piLoginProviderByRoute(route: string): PiLoginProvider | undefined {
116
+ return PI_LOGIN_PROVIDERS.find(provider => provider.route === route)
117
+ }
118
+
119
+ export function requirePiLoginProvider(id: string): PiLoginProvider {
120
+ const provider = piLoginProvider(id)
121
+ if (provider === undefined) {
122
+ throw new Error(`dsh-oauth-login: unknown provider "${id}"`)
123
+ }
124
+ return provider
125
+ }
126
+
127
+ export function piLoginRoutes(): string[] {
128
+ return PI_LOGIN_PROVIDERS.map(provider => provider.route)
129
+ }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useState, useSyncExternalStore } from 'react'
2
+ import type { OpenRouterCatalogClient } from './openrouter-store.ts'
3
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
4
+
5
+ const CSS = [
6
+ '.dsh-or-sync{display:flex;flex-direction:column;gap:4px;padding:6px 8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);min-width:0}',
7
+ '.dsh-or-sync-row{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:4px 8px}',
8
+ '.dsh-or-sync-copy{min-width:0;overflow-wrap:anywhere}',
9
+ '.dsh-or-sync time{font-variant-numeric:tabular-nums}',
10
+ '.dsh-or-refresh{min-height:28px;padding:3px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer}',
11
+ '.dsh-or-refresh:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}',
12
+ '.dsh-or-refresh:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px}',
13
+ '.dsh-or-refresh:disabled{opacity:.55;cursor:default}',
14
+ '.dsh-or-sync-error{color:color-mix(in srgb,var(--dsw-alias-state-warn-label) 50%,var(--dsw-alias-label-primary))}',
15
+ ].join('\n')
16
+
17
+ export interface OpenRouterSyncStatusProps {
18
+ catalog: OpenRouterCatalogClient
19
+ ts: TranslateNS<'model-search'>
20
+ details?: boolean
21
+ }
22
+
23
+ export function OpenRouterSyncStatus({ catalog, ts, details = false }: OpenRouterSyncStatusProps) {
24
+ const state = useSyncExternalStore(catalog.subscribe, catalog.getSnapshot)
25
+ const [now, setNow] = useState(Date.now)
26
+ const data = state.data
27
+ const retryAt = data?.retryAt ?? 0
28
+ useEffect(() => {
29
+ setNow(Date.now())
30
+ if (retryAt <= Date.now()) return
31
+ const timer = setTimeout(() => { setNow(Date.now()) }, retryAt - Date.now() + 20)
32
+ return () => { clearTimeout(timer) }
33
+ }, [retryAt])
34
+ const cooling = retryAt > now
35
+ const working = state.loading || data?.refreshing === true
36
+ const unavailable = state.error !== null || data?.error !== null && data?.error !== undefined
37
+ const timestamp = data?.lastUpdatedAt
38
+ return (
39
+ <div className="dsh-or-sync" data-ud-check={details ? 'openrouter-settings-sync' : 'openrouter-menu-sync'} data-ud-min-gap="4">
40
+ <style>{CSS}</style>
41
+ <div className="dsh-or-sync-row">
42
+ <span className="dsh-or-sync-copy" role="status" aria-live="polite">
43
+ {working ? ts('syncing') : data?.connected ? ts('syncSchedule') : ts('syncSignIn')}
44
+ </span>
45
+ <button type="button" className="dsh-or-refresh"
46
+ disabled={working || cooling || data?.connected !== true}
47
+ onClick={() => { void catalog.load(true) }}
48
+ >{working ? ts('syncing') : cooling ? ts('syncCooldown') : ts('syncRefresh')}</button>
49
+ </div>
50
+ {timestamp != null && (
51
+ <span className="dsh-or-sync-copy">{ts('syncUpdated')}{' '}
52
+ <time dateTime={new Date(timestamp).toISOString()}>{new Date(timestamp).toLocaleString()}</time>
53
+ {data?.stale ? ' · ' + ts('syncStale') : ''}
54
+ </span>
55
+ )}
56
+ {timestamp == null && data?.connected && <span>{ts('syncBuiltin')}</span>}
57
+ {unavailable && <span role="status" className="dsh-or-sync-copy dsh-or-sync-error">
58
+ {ts(state.error === 'restart' ? 'syncRestart' : data?.error === 'save' ? 'syncSaveError' : 'syncError')}
59
+ </span>}
60
+ {details && <>
61
+ <span>{ts('freeHelp')}</span>
62
+ <span>{ts('forcedPluginWarning')}</span>
63
+ </>}
64
+ </div>
65
+ )
66
+ }