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.
- package/INSTALL.md +59 -0
- package/INSTALL.zh.md +45 -0
- package/LICENSE +201 -0
- package/README.en.md +104 -0
- package/README.md +104 -0
- package/README.zh.md +3 -0
- package/SECURITY.md +33 -0
- package/cordis.patch.yml +5 -0
- package/docs/network-proxy.md +45 -0
- package/docs/openrouter-sync.md +84 -0
- package/docs/screenshots/01-providers-signed-out.png +0 -0
- package/docs/screenshots/03-copilot-signing-in.png +0 -0
- package/docs/screenshots/04-mixed-live-states.png +0 -0
- package/docs/screenshots/05-copilot-sign-in.gif +0 -0
- package/docs/screenshots/06-cli-status-store.png +0 -0
- package/lib/bin.d.ts +5 -0
- package/lib/bin.js +2132 -0
- package/lib/client.js +1915 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +482 -0
- package/lib/index.js +3105 -0
- package/lib/invariant.d.ts +7 -0
- package/lib/invariant.js +8 -0
- package/package.json +180 -0
- package/src/adapter.ts +207 -0
- package/src/auth-routes.ts +654 -0
- package/src/auth.ts +76 -0
- package/src/bin.ts +185 -0
- package/src/catalog.ts +129 -0
- package/src/client/OpenRouterSyncStatus.tsx +66 -0
- package/src/client/PiLoginSettings.tsx +470 -0
- package/src/client/ProxySettings.tsx +123 -0
- package/src/client/SearchableModelSelect.tsx +595 -0
- package/src/client/draft-input.ts +12 -0
- package/src/client/index.tsx +71 -0
- package/src/client/locales.ts +176 -0
- package/src/client/login-input-copy.ts +34 -0
- package/src/client/login-window.ts +24 -0
- package/src/client/model-filter.ts +45 -0
- package/src/client/openrouter-store.ts +122 -0
- package/src/client/proxy-draft.ts +57 -0
- package/src/extra-models.ts +201 -0
- package/src/hosted-capture.ts +41 -0
- package/src/hosted-images.ts +154 -0
- package/src/http.ts +241 -0
- package/src/ids.ts +17 -0
- package/src/index.ts +183 -0
- package/src/invariant.ts +12 -0
- package/src/model-error-hint.ts +98 -0
- package/src/native-tools.ts +442 -0
- package/src/oauth-refresh.ts +49 -0
- package/src/openrouter-catalog.ts +245 -0
- package/src/openrouter-free.ts +76 -0
- package/src/openrouter-models.ts +155 -0
- package/src/openrouter-types.ts +40 -0
- package/src/plugin-config.ts +30 -0
- package/src/provider.ts +137 -0
- package/src/proxy-config.ts +61 -0
- package/src/proxy-store.ts +45 -0
- package/src/proxy-transport.ts +220 -0
- package/src/redact.ts +22 -0
- package/src/responses-tap.ts +97 -0
- package/src/session.ts +104 -0
- 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
|
+
}
|