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
@@ -0,0 +1,61 @@
1
+ /** Shared, credential-free settings for this plugin's two outbound transports. */
2
+
3
+ export const PROXY_SETTINGS_PATH = '/plugins/dsh-oauth-login/network/settings'
4
+ export const PROXY_SETTINGS_FILENAME = '.dsh-oauth-proxy.json'
5
+
6
+ export interface ProxyChannelSettings {
7
+ /** False means direct, even when proxy environment variables are set. */
8
+ enabled: boolean
9
+ /** Empty keeps environment/system discovery; otherwise this URL wins. */
10
+ url: string
11
+ }
12
+
13
+ export interface ProxySettings {
14
+ http: ProxyChannelSettings
15
+ websocket: ProxyChannelSettings
16
+ }
17
+
18
+ export interface ProxySettingsSnapshot extends ProxySettings {
19
+ revision: number
20
+ }
21
+
22
+ export function defaultProxySettings(): ProxySettingsSnapshot {
23
+ return { revision: 0, http: { enabled: true, url: '' }, websocket: { enabled: true, url: '' } }
24
+ }
25
+
26
+ function object(value: unknown, keys: readonly string[], label: string): Record<string, unknown> {
27
+ if (typeof value !== 'object' || value === null || Array.isArray(value)
28
+ || Object.keys(value).some(key => !keys.includes(key))) {
29
+ throw new Error(`${label}: invalid settings object`)
30
+ }
31
+ return value as Record<string, unknown>
32
+ }
33
+
34
+ function channel(value: unknown, label: string): ProxyChannelSettings {
35
+ const raw = object(value, ['enabled', 'url'], label)
36
+ if (typeof raw.enabled !== 'boolean' || typeof raw.url !== 'string' || raw.url.length > 1024) {
37
+ throw new Error(`${label}: expected enabled and proxy URL`)
38
+ }
39
+ const text = raw.url.trim()
40
+ if (text === '') return { enabled: raw.enabled, url: '' }
41
+ let parsed: URL
42
+ try { parsed = new URL(text) } catch { throw new Error(`${label}: enter a valid HTTP proxy address and port`) }
43
+ if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname
44
+ || parsed.username || parsed.password || parsed.search || parsed.hash
45
+ || parsed.pathname !== '/' || parsed.port === '0') {
46
+ throw new Error(`${label}: use an HTTP(S) proxy without credentials, path, query or fragment`)
47
+ }
48
+ return { enabled: raw.enabled, url: parsed.origin }
49
+ }
50
+
51
+ export function parseProxySettings(value: unknown): ProxySettingsSnapshot {
52
+ const raw = object(value, ['revision', 'http', 'websocket'], 'Network')
53
+ if (typeof raw.revision !== 'number' || !Number.isSafeInteger(raw.revision) || raw.revision < 0) {
54
+ throw new Error('Network: invalid revision')
55
+ }
56
+ return {
57
+ revision: raw.revision,
58
+ http: channel(raw.http, 'HTTP'),
59
+ websocket: channel(raw.websocket, 'WebSocket'),
60
+ }
61
+ }
@@ -0,0 +1,45 @@
1
+ /** Independent, atomic settings file; never writes the OAuth credential document. */
2
+
3
+ import { mkdir, readFile } from 'node:fs/promises'
4
+ import { dirname } from 'node:path'
5
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
6
+ import { defaultProxySettings, parseProxySettings } from './proxy-config.ts'
7
+ import type { ProxySettingsSnapshot } from './proxy-config.ts'
8
+
9
+ export class ProxySettingsConflict extends Error {}
10
+
11
+ export class ProxySettingsStore {
12
+ constructor(readonly filename: string) {}
13
+
14
+ async read(): Promise<ProxySettingsSnapshot> {
15
+ let text: string
16
+ try { text = await readFile(this.filename, 'utf8') } catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultProxySettings()
18
+ throw new Error('Network settings could not be read', { cause: error })
19
+ }
20
+ let document: unknown
21
+ try { document = JSON.parse(text) } catch { throw new Error('Network settings file is not valid JSON') }
22
+ if (typeof document !== 'object' || document === null || !('version' in document)
23
+ || document.version !== 1 || !('settings' in document)
24
+ || Object.keys(document).some(key => key !== 'version' && key !== 'settings')) {
25
+ throw new Error('Unsupported network settings file')
26
+ }
27
+ return parseProxySettings(document.settings)
28
+ }
29
+
30
+ async save(value: unknown): Promise<ProxySettingsSnapshot> {
31
+ const input = parseProxySettings(value)
32
+ await mkdir(dirname(this.filename), { recursive: true, mode: 0o700 })
33
+ return withFileLock(this.filename, async () => {
34
+ const current = await this.read()
35
+ if (current.revision !== input.revision) {
36
+ throw new ProxySettingsConflict('Network settings changed. Reload them before saving.')
37
+ }
38
+ const settings = { ...input, revision: current.revision + 1 }
39
+ await writeFileAtomic(this.filename, `${JSON.stringify({ version: 1, settings }, null, 2)}\n`, {
40
+ mode: 0o600, dirMode: 0o700,
41
+ })
42
+ return settings
43
+ })
44
+ }
45
+ }
@@ -0,0 +1,220 @@
1
+ /** Plugin-scoped HTTP/WS routing without changing process.env or provider SDKs. */
2
+
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
4
+ import { isIP } from 'node:net'
5
+ import { Agent, ProxyAgent, getGlobalDispatcher, setGlobalDispatcher } from 'undici'
6
+ import type { Dispatcher } from 'undici'
7
+ import { closeOpenAICodexWebSocketSessions } from '@earendil-works/pi-ai/api/openai-codex-responses'
8
+ import { createOAuthHttpTransport, installOAuthHttpGlobals } from './http.ts'
9
+ import type { OAuthProxyDiscoveryOptions, OAuthProxyResolution } from './http.ts'
10
+ import type { ProxyChannelSettings, ProxySettingsSnapshot } from './proxy-config.ts'
11
+ import { ProxySettingsStore } from './proxy-store.ts'
12
+
13
+ interface RoutingScope {
14
+ http: Dispatcher
15
+ websocket: Dispatcher
16
+ direct: Dispatcher
17
+ }
18
+
19
+ interface RoutingState {
20
+ context: AsyncLocalStorage<RoutingScope>
21
+ dispatcher?: Dispatcher
22
+ }
23
+
24
+ const ROUTING_KEY = Symbol.for('dsh-oauth-login.scoped-proxy.v1')
25
+ const globals = globalThis as typeof globalThis & { [ROUTING_KEY]?: RoutingState }
26
+ const routing = globals[ROUTING_KEY] ??= { context: new AsyncLocalStorage<RoutingScope>() }
27
+
28
+ function isLoopback(origin: Dispatcher.DispatchOptions['origin']): boolean {
29
+ if (origin === undefined) return false
30
+ const hostname = new URL(origin).hostname.toLowerCase().replace(/^\[|\]$/g, '')
31
+ return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '::1'
32
+ || (isIP(hostname) === 4 && hostname.startsWith('127.'))
33
+ }
34
+
35
+ function installRouting(): void {
36
+ const fallback = getGlobalDispatcher()
37
+ if (fallback !== routing.dispatcher) {
38
+ routing.dispatcher = fallback.compose(dispatch => (options, handler) => {
39
+ const scope = routing.context.getStore()
40
+ if (scope === undefined) return dispatch(options, handler)
41
+ const websocket = typeof options.upgrade === 'string' && options.upgrade.toLowerCase() === 'websocket'
42
+ const selected = isLoopback(options.origin) ? scope.direct : websocket ? scope.websocket : scope.http
43
+ return selected.dispatch(options, handler)
44
+ })
45
+ setGlobalDispatcher(routing.dispatcher)
46
+ }
47
+ installOAuthHttpGlobals()
48
+ }
49
+
50
+ interface ReadyTransport {
51
+ scope: RoutingScope
52
+ resolution: OAuthProxyResolution
53
+ agents: Set<Dispatcher>
54
+ wsKey: string
55
+ active: number
56
+ retired: boolean
57
+ closing: boolean
58
+ }
59
+
60
+ interface SessionConnection {
61
+ key: string
62
+ active: number
63
+ resetPending: boolean
64
+ }
65
+
66
+ export class OAuthProxyTransport {
67
+ readonly settings: ProxySettingsStore
68
+ private readyPromise?: Promise<ReadyTransport>
69
+ private readonly generations = new Set<ReadyTransport>()
70
+ private readonly sessions = new Map<string, SessionConnection>()
71
+ private disposed = false
72
+
73
+ constructor(filename: string, private readonly discovery: OAuthProxyDiscoveryOptions = {}) {
74
+ this.settings = new ProxySettingsStore(filename)
75
+ }
76
+
77
+ private async build(): Promise<ReadyTransport> {
78
+ const settings = await this.settings.read()
79
+ const channels = [settings.http, settings.websocket]
80
+ const auto = channels.some(channel => channel.enabled && channel.url === '')
81
+ ? await createOAuthHttpTransport(this.discovery)
82
+ : undefined
83
+ const direct = new Agent({ allowH2: false })
84
+ const agents = new Set<Dispatcher>([direct, ...(auto === undefined ? [] : [auto.dispatcher])])
85
+ const select = (channel: ProxyChannelSettings): Dispatcher => {
86
+ if (!channel.enabled) return direct
87
+ if (channel.url === '') {
88
+ if (auto === undefined) throw new Error('Automatic proxy transport is unavailable')
89
+ return auto.dispatcher
90
+ }
91
+ const proxy = new ProxyAgent({ uri: channel.url, allowH2: false })
92
+ agents.add(proxy)
93
+ return proxy
94
+ }
95
+ const ready: ReadyTransport = {
96
+ scope: { direct, http: select(settings.http), websocket: select(settings.websocket) },
97
+ // Preserve the legacy initialize() result as HTTP-route metadata. The
98
+ // separately configured WS route is never reported as an HTTP proxy.
99
+ resolution: !settings.http.enabled ? { source: 'direct' }
100
+ : settings.http.url !== '' ? { source: 'explicit', proxyUrl: settings.http.url }
101
+ : auto?.resolution ?? { source: 'direct' },
102
+ agents,
103
+ // Refresh auto-discovered connections after a settings save as well.
104
+ wsKey: JSON.stringify(settings.websocket) + (settings.websocket.enabled && !settings.websocket.url ? `:${settings.revision}` : ''),
105
+ active: 0, retired: false, closing: false,
106
+ }
107
+ if (this.disposed) {
108
+ await Promise.allSettled([...agents].map(agent => agent.destroy()))
109
+ throw new Error('OAuth proxy transport disposed')
110
+ }
111
+ this.generations.add(ready)
112
+ installRouting()
113
+ return ready
114
+ }
115
+
116
+ private ready(): Promise<ReadyTransport> {
117
+ if (this.disposed) return Promise.reject(new Error('OAuth proxy transport disposed'))
118
+ if (this.readyPromise === undefined) {
119
+ const pending = this.build()
120
+ this.readyPromise = pending
121
+ void pending.catch(() => { if (this.readyPromise === pending) this.readyPromise = undefined })
122
+ }
123
+ return this.readyPromise
124
+ }
125
+
126
+ async initialize(): Promise<OAuthProxyResolution> {
127
+ return (await this.ready()).resolution
128
+ }
129
+
130
+ private async acquire(): Promise<ReadyTransport> {
131
+ while (true) {
132
+ const ready = await this.ready()
133
+ if (ready.retired) continue
134
+ ready.active++
135
+ return ready
136
+ }
137
+ }
138
+
139
+ async save(value: unknown): Promise<ProxySettingsSnapshot> {
140
+ if (this.disposed) throw new Error('OAuth proxy transport disposed')
141
+ const saved = await this.settings.save(value)
142
+ const previous = this.readyPromise
143
+ this.readyPromise = undefined
144
+ void previous?.then(ready => {
145
+ ready.retired = true
146
+ this.release(ready, false)
147
+ }, () => undefined)
148
+ return saved
149
+ }
150
+
151
+ private release(ready: ReadyTransport, decrement = true): void {
152
+ if (decrement) ready.active--
153
+ if (ready.retired && ready.active === 0 && !ready.closing) {
154
+ ready.closing = true
155
+ void Promise.allSettled([...ready.agents].map(agent => agent.close())).then(() => {
156
+ this.generations.delete(ready)
157
+ })
158
+ }
159
+ }
160
+
161
+ async run<T>(operation: () => Promise<T>): Promise<T> {
162
+ const ready = await this.acquire()
163
+ try {
164
+ installRouting()
165
+ return await routing.context.run(ready.scope, operation)
166
+ } finally { this.release(ready) }
167
+ }
168
+
169
+ private beginSession(sessionId: string | undefined, ready: ReadyTransport): (() => void) | undefined {
170
+ if (sessionId === undefined) return undefined
171
+ let record = this.sessions.get(sessionId)
172
+ if (record === undefined) {
173
+ record = { key: ready.wsKey, active: 0, resetPending: false }
174
+ this.sessions.set(sessionId, record)
175
+ }
176
+ if (record.key !== ready.wsKey) record.resetPending = true
177
+ if (record.resetPending && record.active === 0) {
178
+ // Public Pi API: retire the old connection, without clearing its SSE
179
+ // fallback latch or changing any retry budget. Never interrupt active work.
180
+ closeOpenAICodexWebSocketSessions(sessionId)
181
+ record.key = ready.wsKey
182
+ record.resetPending = false
183
+ }
184
+ record.active++
185
+ return () => { record.active-- }
186
+ }
187
+
188
+ async * iterate<T>(source: AsyncIterable<T>, codexSessionId?: string): AsyncIterable<T> {
189
+ const ready = await this.acquire()
190
+ let endSession: (() => void) | undefined
191
+ let iterator: AsyncIterator<T> | undefined
192
+ try {
193
+ installRouting()
194
+ endSession = this.beginSession(codexSessionId, ready)
195
+ iterator = routing.context.run(ready.scope, () => source[Symbol.asyncIterator]())
196
+ while (true) {
197
+ const current = iterator
198
+ const item = await routing.context.run(ready.scope, () => current.next())
199
+ if (item.done) return
200
+ yield item.value
201
+ }
202
+ } finally {
203
+ try {
204
+ await routing.context.run(ready.scope, () => iterator?.return?.())
205
+ } finally {
206
+ endSession?.()
207
+ this.release(ready)
208
+ }
209
+ }
210
+ }
211
+
212
+ async dispose(): Promise<void> {
213
+ this.disposed = true
214
+ await this.readyPromise?.catch(() => undefined)
215
+ for (const sessionId of this.sessions.keys()) closeOpenAICodexWebSocketSessions(sessionId)
216
+ this.sessions.clear()
217
+ await Promise.allSettled([...this.generations].flatMap(ready => [...ready.agents].map(agent => agent.destroy())))
218
+ this.generations.clear()
219
+ }
220
+ }
package/src/redact.ts ADDED
@@ -0,0 +1,22 @@
1
+ import type { PiLoginProvider } from './catalog.ts'
2
+
3
+ /** Remove token-like strings from an external OAuth diagnostic. */
4
+ export function safeMessage(error: unknown): string {
5
+ return (error instanceof Error ? error.message : String(error))
6
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, '[redacted token]')
7
+ .replace(/(\b(?:code|token|refresh_token|access_token|api[_-]?key|key)=)[^&\s]+/giu, '$1[redacted]')
8
+ .slice(0, 1000)
9
+ }
10
+
11
+ /** Only this provider's official HTTPS hosts may be opened for login. */
12
+ export function isSafeAuthUrl(raw: string, provider: PiLoginProvider): boolean {
13
+ try {
14
+ const url = new URL(raw)
15
+ if (url.protocol !== 'https:') return false
16
+ const host = url.hostname.toLowerCase()
17
+ if (provider.allowedHosts.includes(host)) return true
18
+ return provider.allowedSuffixes.some(suffix => host.endsWith(suffix))
19
+ } catch {
20
+ return false
21
+ }
22
+ }
@@ -0,0 +1,97 @@
1
+ /** Peek Responses SSE for hosted image_generation_call items pi-ai ignores. */
2
+
3
+ import { currentHostedCapture } from './hosted-capture.ts'
4
+ import { collectHostedImagesFromEvent } from './hosted-images.ts'
5
+
6
+ const TAPPED = Symbol('dsh-oauth-hosted-tap')
7
+
8
+ type TappableFetch = typeof fetch & { [TAPPED]?: true }
9
+ type FetchInput = Parameters<typeof fetch>[0]
10
+ type FetchInit = Parameters<typeof fetch>[1]
11
+
12
+ function requestUrl(input: FetchInput): string {
13
+ if (typeof input === 'string') return input
14
+ if (input instanceof URL) return input.href
15
+ return input.url
16
+ }
17
+
18
+ function requestMethod(input: FetchInput, init?: FetchInit): string {
19
+ if (init?.method !== undefined) return init.method.toUpperCase()
20
+ if (typeof input !== 'string' && !(input instanceof URL) && input.method.length > 0) {
21
+ return input.method.toUpperCase()
22
+ }
23
+ return 'GET'
24
+ }
25
+
26
+ export function isResponsesRequest(input: FetchInput, init?: FetchInit): boolean {
27
+ if (requestMethod(input, init) !== 'POST') return false
28
+ try {
29
+ const path = new URL(requestUrl(input)).pathname
30
+ return path === '/responses' || path.endsWith('/responses')
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
36
+ function parseSseBlock(block: string): unknown {
37
+ const data = block
38
+ .split(/\r?\n/)
39
+ .filter(line => line.startsWith('data:'))
40
+ .map(line => line.slice(5).trimStart())
41
+ .join('\n')
42
+ .trim()
43
+ if (data.length === 0 || data === '[DONE]') return undefined
44
+ try {
45
+ return JSON.parse(data) as unknown
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
51
+ export function consumeResponsesSseText(text: string, onEvent: (event: unknown) => void): string {
52
+ const parts = text.split(/\r?\n\r?\n/)
53
+ const rest = parts.pop() ?? ''
54
+ for (const block of parts) {
55
+ const event = parseSseBlock(block)
56
+ if (event !== undefined) onEvent(event)
57
+ }
58
+ return rest
59
+ }
60
+
61
+ function peekResponsesBody(body: ReadableStream<Uint8Array>, onEvent: (event: unknown) => void): ReadableStream<Uint8Array> {
62
+ const decoder = new TextDecoder()
63
+ let buffer = ''
64
+ return body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
65
+ transform(chunk, controller) {
66
+ controller.enqueue(chunk)
67
+ buffer = consumeResponsesSseText(buffer + decoder.decode(chunk, { stream: true }), onEvent)
68
+ },
69
+ flush(controller) {
70
+ if (buffer.length === 0) return
71
+ const event = parseSseBlock(buffer)
72
+ if (event !== undefined) onEvent(event)
73
+ void controller
74
+ },
75
+ }))
76
+ }
77
+
78
+ export function tapResponsesResponse(response: Response, onEvent: (event: unknown) => void): Response {
79
+ if (response.body === null) return response
80
+ return new Response(peekResponsesBody(response.body, onEvent), response)
81
+ }
82
+
83
+ export function wrapFetchForHostedOutput(fetchImpl: typeof fetch): typeof fetch {
84
+ return async (input, init) => {
85
+ const capture = currentHostedCapture()
86
+ if (capture === undefined || !isResponsesRequest(input, init)) return await fetchImpl(input, init)
87
+ const response = await fetchImpl(input, init)
88
+ return tapResponsesResponse(response, event => collectHostedImagesFromEvent(event, capture.images))
89
+ }
90
+ }
91
+
92
+ export function installHostedOutputFetch(): void {
93
+ const current = globalThis.fetch as TappableFetch
94
+ if (current[TAPPED] === true) return
95
+ const next = Object.assign(wrapFetchForHostedOutput(current), { [TAPPED]: true as const })
96
+ globalThis.fetch = next
97
+ }
package/src/session.ts ADDED
@@ -0,0 +1,104 @@
1
+ /** Shared OAuth store + catalog for the host plugin and CLI. */
2
+
3
+ import { dirname, join } from 'node:path'
4
+ import { createModels } from '@earendil-works/pi-ai'
5
+ import type { MutableModels } from '@earendil-works/pi-ai'
6
+ import { PI_LOGIN_PROVIDERS, piLoginProvider } from './catalog.ts'
7
+ import type { PiLoginProvider } from './catalog.ts'
8
+ import type { OAuthProxyDiscoveryOptions, OAuthProxyResolution } from './http.ts'
9
+ import { PROXY_SETTINGS_FILENAME } from './proxy-config.ts'
10
+ import { OAuthProxyTransport } from './proxy-transport.ts'
11
+ import {
12
+ grantNeedsRefresh,
13
+ markRefreshAttempt,
14
+ refreshAttemptKey,
15
+ refreshGrant,
16
+ refreshOnCooldown,
17
+ } from './oauth-refresh.ts'
18
+ import { DEFAULT_NATIVE_TOOL_POLICY } from './native-tools.ts'
19
+ import type { NativeToolPolicy } from './native-tools.ts'
20
+ import { allCatalogProviders, harnessModels, harnessProvider } from './provider.ts'
21
+ import { PiLoginCredentialStore } from './store.ts'
22
+ import { OPENROUTER_CACHE_FILENAME, OpenRouterCatalog } from './openrouter-catalog.ts'
23
+ import type { OpenRouterCatalogOptions } from './openrouter-catalog.ts'
24
+
25
+ export class PiLoginSession {
26
+ readonly store: PiLoginCredentialStore
27
+ readonly models: MutableModels
28
+ readonly native: NativeToolPolicy
29
+ readonly openRouter: OpenRouterCatalog
30
+ readonly proxy: OAuthProxyTransport
31
+
32
+ constructor(
33
+ store: PiLoginCredentialStore = new PiLoginCredentialStore(),
34
+ native: NativeToolPolicy = DEFAULT_NATIVE_TOOL_POLICY,
35
+ catalogOptions: Partial<Pick<OpenRouterCatalogOptions, 'fetch' | 'now' | 'filename'>> = {},
36
+ proxyDiscovery: OAuthProxyDiscoveryOptions = {},
37
+ ) {
38
+ this.store = store
39
+ this.native = native
40
+ this.proxy = new OAuthProxyTransport(join(dirname(store.filename), PROXY_SETTINGS_FILENAME), proxyDiscovery)
41
+ this.models = createModels({ credentials: store })
42
+ for (const provider of allCatalogProviders()) this.models.setProvider(provider)
43
+ this.openRouter = new OpenRouterCatalog({
44
+ filename: join(dirname(store.filename), OPENROUTER_CACHE_FILENAME),
45
+ isAuthenticated: async () => (await store.list()).some(item => item.providerId === 'openrouter'),
46
+ beforeFetch: () => this.ensureTransport(),
47
+ fetch: (input, init) => this.proxy.run(() => fetch(input, init)),
48
+ initiallyProtectedIds: harnessModels(this.spec('openrouter'))
49
+ .filter(model => model.cost.input === 0 && model.cost.output === 0)
50
+ .map(model => model.id),
51
+ ...catalogOptions,
52
+ })
53
+ }
54
+
55
+ ensureTransport(): Promise<OAuthProxyResolution> {
56
+ return this.proxy.initialize()
57
+ }
58
+
59
+ spec(id: string): PiLoginProvider {
60
+ const spec = piLoginProvider(id)
61
+ if (spec === undefined) throw new Error(`dsh-oauth-login: unknown provider "${id}"`)
62
+ return spec
63
+ }
64
+
65
+ provider(id: string) {
66
+ return harnessProvider(this.spec(id), this.native, this.openRouter)
67
+ }
68
+
69
+ visibleModels(id: string) {
70
+ return this.provider(id).getModels()
71
+ }
72
+
73
+ /**
74
+ * Harness routes that currently hold a stored OAuth grant.
75
+ * Model pickers should only advertise these — logging out must drop the route.
76
+ */
77
+ async authenticatedRoutes(): Promise<string[]> {
78
+ const signedIn = new Set((await this.store.list()).map(item => item.providerId))
79
+ return PI_LOGIN_PROVIDERS
80
+ .filter(provider => signedIn.has(provider.id))
81
+ .map(provider => provider.route)
82
+ }
83
+
84
+ async logout(id: string): Promise<void> {
85
+ await this.store.delete(id)
86
+ if (id === 'openrouter') this.openRouter.disconnect()
87
+ }
88
+
89
+ /**
90
+ * Renew access tokens that are expired or close to expiry.
91
+ * Failures stay in the store; the next poll or chat retries.
92
+ */
93
+ async refreshStoredGrants(now = Date.now()): Promise<void> {
94
+ await this.ensureTransport()
95
+ for (const { providerId } of await this.store.list()) {
96
+ const credential = await this.store.read(providerId)
97
+ if (credential?.type !== 'oauth' || !grantNeedsRefresh(credential.expires, now)) continue
98
+ const key = refreshAttemptKey(this.store.filename, providerId)
99
+ if (refreshOnCooldown(key, now)) continue
100
+ markRefreshAttempt(key, now)
101
+ await this.proxy.run(() => refreshGrant(id => this.models.getAuth(id), providerId))
102
+ }
103
+ }
104
+ }