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,41 @@
1
+ /** Per-request bag for hosted Responses output that pi-ai drops. */
2
+
3
+ import { AsyncLocalStorage } from 'node:async_hooks'
4
+
5
+ export interface HostedImage {
6
+ id?: string
7
+ base64: string
8
+ }
9
+
10
+ export interface HostedCapture {
11
+ images: HostedImage[]
12
+ }
13
+
14
+ export const hostedCapture = new AsyncLocalStorage<HostedCapture>()
15
+
16
+ export function currentHostedCapture(): HostedCapture | undefined {
17
+ return hostedCapture.getStore()
18
+ }
19
+
20
+ /** Keep ALS alive across each iterator step so the OpenAI SDK fetch inherits it. */
21
+ export function iterateInCapture<T>(
22
+ capture: HostedCapture,
23
+ source: AsyncIterable<T>,
24
+ ): AsyncIterable<T> {
25
+ return {
26
+ [Symbol.asyncIterator]() {
27
+ const iterator = source[Symbol.asyncIterator]()
28
+ return {
29
+ next: () => hostedCapture.run(capture, () => iterator.next()),
30
+ return: (value?: unknown) => hostedCapture.run(
31
+ capture,
32
+ () => iterator.return?.(value) ?? Promise.resolve({ done: true as const, value: undefined }),
33
+ ),
34
+ throw: (error?: unknown) => hostedCapture.run(
35
+ capture,
36
+ () => iterator.throw?.(error) ?? Promise.reject(error),
37
+ ),
38
+ }
39
+ },
40
+ }
41
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Receive hosted image_generation_call results as durable DSH ImageBlocks.
3
+ * pi-ai 0.82.1 drops those items; the UI already renders assistant images.
4
+ */
5
+
6
+ import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment'
7
+ import type { Message, StreamChunk } from '@deepseek-ai/dsh-llm'
8
+ import type { HostedCapture, HostedImage } from './hosted-capture.ts'
9
+
10
+ function isRecord(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
12
+ }
13
+
14
+ export function sniffImageMediaType(bytes: Uint8Array): ImageMediaType | undefined {
15
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
16
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
17
+ return 'image/png'
18
+ }
19
+ if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif'
20
+ if (
21
+ bytes.length >= 12
22
+ && bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
23
+ ) {
24
+ return 'image/webp'
25
+ }
26
+ return undefined
27
+ }
28
+
29
+ function stripDataUrl(value: string): string {
30
+ const match = /^data:image\/[a-zA-Z0-9.+-]+;base64,(.+)$/s.exec(value.trim())
31
+ return match?.[1] ?? value.trim()
32
+ }
33
+
34
+ function base64FromUnknown(value: unknown): string | undefined {
35
+ if (typeof value === 'string' && value.length > 0) return stripDataUrl(value)
36
+ if (!isRecord(value)) return undefined
37
+ if (typeof value.b64_json === 'string' && value.b64_json.length > 0) return stripDataUrl(value.b64_json)
38
+ if (typeof value.base64 === 'string' && value.base64.length > 0) return stripDataUrl(value.base64)
39
+ if (typeof value.result === 'string' && value.result.length > 0) return stripDataUrl(value.result)
40
+ return undefined
41
+ }
42
+
43
+ function pushImage(into: HostedImage[], id: string | undefined, base64: string): void {
44
+ if (id !== undefined && into.some(existing => existing.id === id)) return
45
+ if (id === undefined && into.some(existing => existing.base64 === base64)) return
46
+ into.push(id === undefined ? { base64 } : { id, base64 })
47
+ }
48
+
49
+ export function collectHostedImageFromItem(item: unknown, into: HostedImage[]): void {
50
+ if (!isRecord(item) || item.type !== 'image_generation_call') return
51
+ if (item.status !== undefined && item.status !== 'completed') return
52
+ const base64 = base64FromUnknown(item.result)
53
+ if (base64 === undefined) return
54
+ pushImage(into, typeof item.id === 'string' ? item.id : undefined, base64)
55
+ }
56
+
57
+ export function collectHostedImagesFromEvent(event: unknown, into: HostedImage[]): void {
58
+ if (!isRecord(event) || typeof event.type !== 'string') return
59
+
60
+ if (event.type === 'response.output_item.done') {
61
+ collectHostedImageFromItem(event.item, into)
62
+ return
63
+ }
64
+
65
+ if (event.type.startsWith('response.image_generation_call.')) {
66
+ collectHostedImageFromItem(event.item, into)
67
+ if (event.type.endsWith('.completed')) {
68
+ const base64 = base64FromUnknown(event.result)
69
+ if (base64 !== undefined) {
70
+ const id = typeof event.item_id === 'string'
71
+ ? event.item_id
72
+ : isRecord(event.item) && typeof event.item.id === 'string' ? event.item.id : undefined
73
+ pushImage(into, id, base64)
74
+ }
75
+ }
76
+ return
77
+ }
78
+
79
+ if (event.type !== 'response.completed' && event.type !== 'response.incomplete') return
80
+ const output = isRecord(event.response) ? event.response.output : undefined
81
+ if (!Array.isArray(output)) return
82
+ for (const item of output) collectHostedImageFromItem(item, into)
83
+ }
84
+
85
+ export function decodeHostedImage(base64: string): { data: Uint8Array, mediaType: ImageMediaType } | undefined {
86
+ let data: Uint8Array
87
+ try {
88
+ data = Uint8Array.from(Buffer.from(base64, 'base64'))
89
+ } catch {
90
+ return undefined
91
+ }
92
+ if (data.length === 0) return undefined
93
+ const mediaType = sniffImageMediaType(data)
94
+ return mediaType === undefined ? undefined : { data, mediaType }
95
+ }
96
+
97
+ function extensionFor(mediaType: ImageMediaType): string {
98
+ switch (mediaType) {
99
+ case 'image/jpeg': return 'jpg'
100
+ case 'image/png': return 'png'
101
+ case 'image/webp': return 'webp'
102
+ case 'image/gif': return 'gif'
103
+ }
104
+ }
105
+
106
+ /**
107
+ * pi-ai replay cannot represent assistant ImageBlocks. Drop them before the
108
+ * next request so replay metadata still lines up with remaining content.
109
+ */
110
+ export function stripAssistantImages(messages: readonly Message[]): Message[] {
111
+ return messages.map(message => {
112
+ if (message.role !== 'assistant') return message
113
+ const content = message.content.filter(block => block.type !== 'image')
114
+ return content.length === message.content.length ? message : { ...message, content }
115
+ })
116
+ }
117
+
118
+ export async function* injectHostedImages(
119
+ source: AsyncIterable<StreamChunk>,
120
+ capture: HostedCapture,
121
+ save: (input: SaveImageAttachment) => Promise<ImageAttachmentRef>,
122
+ ): AsyncGenerator<StreamChunk> {
123
+ let maxIndex = -1
124
+ const tail: StreamChunk[] = []
125
+
126
+ for await (const chunk of source) {
127
+ if ('index' in chunk && typeof chunk.index === 'number') maxIndex = Math.max(maxIndex, chunk.index)
128
+ if (chunk.type === 'usage' || chunk.type === 'finish') {
129
+ tail.push(chunk)
130
+ continue
131
+ }
132
+ yield chunk
133
+ }
134
+
135
+ for (const image of capture.images) {
136
+ const decoded = decodeHostedImage(image.base64)
137
+ if (decoded === undefined) continue
138
+ try {
139
+ const attachment = await save({
140
+ data: decoded.data,
141
+ mediaType: decoded.mediaType,
142
+ name: `generated.${extensionFor(decoded.mediaType)}`,
143
+ })
144
+ const index = maxIndex + 1
145
+ maxIndex = index
146
+ yield { type: 'block-start', index, blockType: 'image' }
147
+ yield { type: 'block-end', index, block: { type: 'image', attachment } }
148
+ } catch {
149
+ // Keep the text answer when the attachment store rejects the raster.
150
+ }
151
+ }
152
+
153
+ yield* tail
154
+ }
package/src/http.ts ADDED
@@ -0,0 +1,241 @@
1
+ /** Proxy-aware HTTP transport for OAuth and subscribed-provider requests. */
2
+
3
+ import { execFile } from 'node:child_process'
4
+ import { connect } from 'node:net'
5
+ import { EnvHttpProxyAgent, install, setGlobalDispatcher } from 'undici'
6
+ import { installHostedOutputFetch } from './responses-tap.ts'
7
+
8
+ const originalFetch = globalThis.fetch
9
+ let installedFetch: typeof globalThis.fetch | undefined
10
+
11
+ const DEFAULT_LOOPBACK_PROXY_CANDIDATES = [
12
+ 'http://127.0.0.1:7890',
13
+ 'http://127.0.0.1:45678',
14
+ 'http://127.0.0.1:7891',
15
+ 'http://127.0.0.1:8080',
16
+ 'http://127.0.0.1:8888',
17
+ ] as const
18
+
19
+ const PROXY_ENV_KEYS = [
20
+ 'HTTPS_PROXY',
21
+ 'https_proxy',
22
+ 'HTTP_PROXY',
23
+ 'http_proxy',
24
+ 'ALL_PROXY',
25
+ 'all_proxy',
26
+ ] as const
27
+
28
+ export type OAuthProxyResolution =
29
+ | { source: 'environment' }
30
+ | { source: 'explicit' | 'system' | 'loopback', proxyUrl: string }
31
+ | { source: 'direct' }
32
+
33
+ export interface OAuthProxyDiscoveryOptions {
34
+ env?: NodeJS.ProcessEnv
35
+ platform?: NodeJS.Platform
36
+ readSystemProxy?: () => Promise<string | undefined>
37
+ probe?: (proxyUrl: string) => Promise<boolean>
38
+ candidates?: readonly string[]
39
+ }
40
+
41
+ function normalizeProxyUrl(value: string): string {
42
+ const trimmed = value.trim()
43
+ if (trimmed.length === 0) throw new Error('dsh-oauth-login: proxy URL is empty')
44
+
45
+ const url = new URL(trimmed.includes('://') ? trimmed : `http://${trimmed}`)
46
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
47
+ throw new Error(`dsh-oauth-login: unsupported proxy protocol "${url.protocol}"`)
48
+ }
49
+ if (url.hostname.length === 0) throw new Error('dsh-oauth-login: proxy URL has no host')
50
+
51
+ url.pathname = '/'
52
+ url.search = ''
53
+ url.hash = ''
54
+ return url.toString()
55
+ }
56
+
57
+ function inheritedProxy(env: NodeJS.ProcessEnv): string | undefined {
58
+ for (const key of PROXY_ENV_KEYS) {
59
+ const value = env[key]
60
+ if (value !== undefined && value.trim().length > 0) return value
61
+ }
62
+ return undefined
63
+ }
64
+
65
+ function scutilValue(output: string, key: string): string | undefined {
66
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
67
+ const match = output.match(new RegExp(`^\\s*${escaped}\\s*:\\s*(.+?)\\s*$`, 'm'))
68
+ return match?.[1]
69
+ }
70
+
71
+ function parseMacOSSystemProxy(output: string): string | undefined {
72
+ for (const kind of ['HTTPS', 'HTTP'] as const) {
73
+ if (scutilValue(output, `${kind}Enable`) !== '1') continue
74
+ const host = scutilValue(output, `${kind}Proxy`)
75
+ const port = Number(scutilValue(output, `${kind}Port`))
76
+ if (host === undefined || host.length === 0 || !Number.isInteger(port) || port <= 0 || port > 65535) {
77
+ continue
78
+ }
79
+ const formattedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
80
+ return normalizeProxyUrl(`http://${formattedHost}:${port}`)
81
+ }
82
+ return undefined
83
+ }
84
+
85
+ async function readMacOSSystemProxy(): Promise<string | undefined> {
86
+ return await new Promise((resolve) => {
87
+ execFile(
88
+ '/usr/sbin/scutil',
89
+ ['--proxy'],
90
+ { encoding: 'utf8', timeout: 1_500, maxBuffer: 128 * 1024 },
91
+ (error, stdout) => resolve(error === null ? parseMacOSSystemProxy(stdout) : undefined),
92
+ )
93
+ })
94
+ }
95
+
96
+ function isLoopbackProxy(proxyUrl: string): boolean {
97
+ const hostname = new URL(proxyUrl).hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase()
98
+ return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1'
99
+ }
100
+
101
+ /**
102
+ * Verify that a candidate is an HTTP CONNECT proxy. The probe contains no
103
+ * OAuth code, credential, token, cookie, or geographic metadata.
104
+ */
105
+ async function probeHttpConnectProxy(proxyUrl: string): Promise<boolean> {
106
+ const url = new URL(proxyUrl)
107
+ if (url.protocol !== 'http:') return false
108
+
109
+ const port = Number(url.port || '80')
110
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return false
111
+
112
+ return await new Promise((resolve) => {
113
+ const socket = connect({ host: url.hostname, port })
114
+ let settled = false
115
+ let response = ''
116
+
117
+ const finish = (accepted: boolean) => {
118
+ if (settled) return
119
+ settled = true
120
+ socket.destroy()
121
+ resolve(accepted)
122
+ }
123
+
124
+ socket.setTimeout(800)
125
+ socket.once('connect', () => {
126
+ socket.write(
127
+ 'CONNECT auth.openai.com:443 HTTP/1.1\r\n'
128
+ + 'Host: auth.openai.com:443\r\n'
129
+ + 'Connection: close\r\n\r\n',
130
+ )
131
+ })
132
+ socket.on('data', (chunk) => {
133
+ response += chunk.toString('latin1')
134
+ const lineEnd = response.indexOf('\r\n')
135
+ if (lineEnd >= 0) finish(/^HTTP\/1\.[01] 200(?:\s|$)/.test(response.slice(0, lineEnd)))
136
+ else if (response.length > 16 * 1024) finish(false)
137
+ })
138
+ socket.once('timeout', () => finish(false))
139
+ socket.once('error', () => finish(false))
140
+ socket.once('end', () => finish(/^HTTP\/1\.[01] 200(?:\s|$)/.test(response)))
141
+ })
142
+ }
143
+
144
+ export async function resolveOAuthProxy(
145
+ options: OAuthProxyDiscoveryOptions = {},
146
+ ): Promise<OAuthProxyResolution> {
147
+ const env = options.env ?? process.env
148
+ if (inheritedProxy(env) !== undefined) return { source: 'environment' }
149
+
150
+ const explicit = env.DSH_OAUTH_PROXY
151
+ if (explicit !== undefined && explicit.trim().length > 0) {
152
+ return { source: 'explicit', proxyUrl: normalizeProxyUrl(explicit) }
153
+ }
154
+
155
+ const probe = options.probe ?? probeHttpConnectProxy
156
+ const platform = options.platform ?? process.platform
157
+ if (platform === 'darwin') {
158
+ const systemProxy = await (options.readSystemProxy ?? readMacOSSystemProxy)()
159
+ if (systemProxy !== undefined) {
160
+ const normalized = normalizeProxyUrl(systemProxy)
161
+ if (await probe(normalized)) return { source: 'system', proxyUrl: normalized }
162
+ }
163
+ }
164
+
165
+ const candidates = options.candidates ?? DEFAULT_LOOPBACK_PROXY_CANDIDATES
166
+ const normalizedCandidates = candidates.flatMap((candidate) => {
167
+ try {
168
+ const normalized = normalizeProxyUrl(candidate)
169
+ return isLoopbackProxy(normalized) ? [normalized] : []
170
+ } catch {
171
+ return []
172
+ }
173
+ })
174
+ const results = await Promise.all(normalizedCandidates.map(async candidate => {
175
+ try {
176
+ return await probe(candidate)
177
+ } catch {
178
+ return false
179
+ }
180
+ }))
181
+ const selected = results.findIndex(Boolean)
182
+ if (selected >= 0) return { source: 'loopback', proxyUrl: normalizedCandidates[selected] }
183
+
184
+ return { source: 'direct' }
185
+ }
186
+
187
+ function noProxyValue(env: NodeJS.ProcessEnv): string {
188
+ const entries = new Set(
189
+ (env.NO_PROXY ?? env.no_proxy ?? '')
190
+ .split(/[\s,]+/)
191
+ .filter(Boolean),
192
+ )
193
+ entries.add('localhost')
194
+ entries.add('127.0.0.1')
195
+ entries.add('::1')
196
+ return [...entries].join(',')
197
+ }
198
+
199
+ function inheritedProxyOptions(env: NodeJS.ProcessEnv): { httpProxy: string, httpsProxy: string } {
200
+ const allProxy = env.ALL_PROXY ?? env.all_proxy ?? ''
201
+ return {
202
+ httpProxy: env.HTTP_PROXY ?? env.http_proxy ?? allProxy,
203
+ httpsProxy: env.HTTPS_PROXY ?? env.https_proxy ?? allProxy,
204
+ }
205
+ }
206
+
207
+ export async function createOAuthHttpTransport(
208
+ options: OAuthProxyDiscoveryOptions = {},
209
+ ): Promise<{ dispatcher: EnvHttpProxyAgent; resolution: OAuthProxyResolution }> {
210
+ const env = options.env ?? process.env
211
+ const resolution = await resolveOAuthProxy({ ...options, env })
212
+ const selectedProxy = 'proxyUrl' in resolution ? resolution.proxyUrl : undefined
213
+ const inherited = inheritedProxyOptions(env)
214
+ const dispatcher = new EnvHttpProxyAgent({
215
+ allowH2: false,
216
+ httpProxy: selectedProxy ?? inherited.httpProxy,
217
+ httpsProxy: selectedProxy ?? inherited.httpsProxy,
218
+ noProxy: noProxyValue(env),
219
+ })
220
+ return { dispatcher, resolution }
221
+ }
222
+
223
+ export function installOAuthHttpGlobals(): void {
224
+ const shouldInstallFetch = installedFetch === undefined
225
+ ? globalThis.fetch === originalFetch
226
+ : globalThis.fetch === installedFetch
227
+ if (shouldInstallFetch) {
228
+ install()
229
+ installedFetch = globalThis.fetch
230
+ }
231
+ installHostedOutputFetch()
232
+ }
233
+
234
+ export async function configureOAuthHttpTransport(
235
+ options: OAuthProxyDiscoveryOptions = {},
236
+ ): Promise<OAuthProxyResolution> {
237
+ const { dispatcher, resolution } = await createOAuthHttpTransport(options)
238
+ setGlobalDispatcher(dispatcher)
239
+ installOAuthHttpGlobals()
240
+ return resolution
241
+ }
package/src/ids.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** Basename of the DSH-owned multi-provider OAuth document. */
2
+ export const PI_LOGIN_AUTH_FILENAME = '.dsh-oauth-auth.json'
3
+
4
+ /** Legacy DSH filename accepted during the one-time storage migration. */
5
+ export const LEGACY_PI_LOGIN_AUTH_FILENAME = '.pi-login-auth.json'
6
+
7
+ /** Prefix for harness routes so they never collide with catalog / other plugins. */
8
+ export const PI_LOGIN_ROUTE_PREFIX = 'pi-'
9
+
10
+ /** Provider idle ceiling used by every composite route. */
11
+ export const PI_LOGIN_STREAM_IDLE_TIMEOUT_MS = 300_000
12
+
13
+ /** RC8 request-level image payload ceiling; matches the official pi-ai default. */
14
+ export const PI_LOGIN_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
15
+
16
+ /** Startup line `dshx verify` looks for. */
17
+ export const PI_LOGIN_BOOT_MARKER = '[my-plugins/dsh-oauth-login] loaded'
package/src/index.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * DSH-owned multi-provider subscription login for DeepSeek Harness.
3
+ * Independent store. Never touches Pi Agent or official CLI auth files.
4
+ */
5
+
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import type {} from '@deepseek-ai/dsh-attachment'
8
+ import type {} from '@deepseek-ai/dsh-host-webserver'
9
+ import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
10
+ import { createPiLoginAdapter } from './adapter.ts'
11
+ import { registerPiLoginAuthRoutes } from './auth-routes.ts'
12
+ import { piLoginProviderByRoute, piLoginRoutes } from './catalog.ts'
13
+ import { maskDshWebAssembly, nativePlanForRoute } from './native-tools.ts'
14
+ import { OAUTH_REFRESH_POLL_MS } from './oauth-refresh.ts'
15
+ import type { Config } from './plugin-config.ts'
16
+ import { PiLoginSession } from './session.ts'
17
+ import { PiLoginCredentialStore } from './store.ts'
18
+
19
+ type NativeAssembly = {
20
+ tools: { name: string }[]
21
+ sections: { name: string; text: string }[]
22
+ }
23
+
24
+ type NativeAssembleContext = {
25
+ agent?: { options?: { provider?: string; model?: string } }
26
+ }
27
+
28
+ declare module '@deepseek-ai/cordis' {
29
+ interface Events {
30
+ 'system-prompt/assemble'(
31
+ assembly: NativeAssembly,
32
+ context: NativeAssembleContext,
33
+ next: () => Promise<NativeAssembly>,
34
+ ): Promise<NativeAssembly>
35
+ }
36
+ }
37
+
38
+ export { createPiLoginAdapter } from './adapter.ts'
39
+ export type { PiLoginAdapterOptions } from './adapter.ts'
40
+ export {
41
+ loginPiProvider,
42
+ loginPiProviderSession,
43
+ logoutPiProvider,
44
+ piLoginStatus,
45
+ } from './auth.ts'
46
+ export type { PiLoginAuthStatus } from './auth.ts'
47
+ export {
48
+ registerPiLoginAuthRoutes,
49
+ PI_LOGIN_AUTH_CANCEL_PATH,
50
+ PI_LOGIN_AUTH_COMPLETE_PATH,
51
+ PI_LOGIN_AUTH_LOGIN_PATH,
52
+ PI_LOGIN_AUTH_LOGOUT_PATH,
53
+ PI_LOGIN_AUTH_STATUS_PATH,
54
+ } from './auth-routes.ts'
55
+ export type {
56
+ LoginChallenge,
57
+ LoginInputChallenge,
58
+ PiLoginProviderStatus,
59
+ } from './auth-routes.ts'
60
+ export { PI_LOGIN_PROVIDERS, piLoginProvider, piLoginRoutes } from './catalog.ts'
61
+ export type { PiLoginProvider } from './catalog.ts'
62
+ export {
63
+ defaultReasoningEffortFor,
64
+ extraModelsFor,
65
+ OX_ALPHA_DEFAULT_EFFORT,
66
+ OX_ALPHA_MODEL_ID,
67
+ } from './extra-models.ts'
68
+ export {
69
+ applyNativeToolsToPayload,
70
+ DEFAULT_NATIVE_TOOL_POLICY,
71
+ filterHostedServerToolTraces,
72
+ filterXaiServerToolTraces,
73
+ isHostedSearchReasoningReplay,
74
+ isHostedServerToolCall,
75
+ isXaiServerXSearchCall,
76
+ nativePlan,
77
+ nativePlanForRoute,
78
+ prepareNativeToolRequest,
79
+ } from './native-tools.ts'
80
+ export {
81
+ collectHostedImagesFromEvent,
82
+ decodeHostedImage,
83
+ injectHostedImages,
84
+ sniffImageMediaType,
85
+ stripAssistantImages,
86
+ } from './hosted-images.ts'
87
+ export type { NativeToolPlan, NativeToolPolicy } from './native-tools.ts'
88
+ export {
89
+ grantNeedsRefresh,
90
+ OAUTH_REFRESH_POLL_MS,
91
+ OAUTH_REFRESH_SOON_MS,
92
+ } from './oauth-refresh.ts'
93
+ export { Config } from './plugin-config.ts'
94
+ export type { Config as PluginConfig } from './plugin-config.ts'
95
+ export {
96
+ LEGACY_PI_LOGIN_AUTH_FILENAME,
97
+ PI_LOGIN_AUTH_FILENAME,
98
+ PI_LOGIN_BOOT_MARKER,
99
+ PI_LOGIN_ROUTE_PREFIX,
100
+ PI_LOGIN_STREAM_IDLE_TIMEOUT_MS,
101
+ } from './ids.ts'
102
+ export {
103
+ hintFailure,
104
+ hintForCode,
105
+ QUOTA_HINT,
106
+ RATE_LIMIT_HINT,
107
+ TRANSIENT_HINT,
108
+ TRANSIENT_MODEL_CODES,
109
+ withModelErrorHint,
110
+ } from './model-error-hint.ts'
111
+ export { catalogProvider, harnessModels, harnessProvider, preferredModel } from './provider.ts'
112
+ export { isSafeAuthUrl, safeMessage } from './redact.ts'
113
+ export { PiLoginSession } from './session.ts'
114
+ export { PiLoginCredentialStore, piLoginAuthPath } from './store.ts'
115
+
116
+ export const name = 'llm-oauth-login'
117
+ export const inject = ['llm']
118
+
119
+ /**
120
+ * Publish only the routes that currently hold a plugin-owned credential.
121
+ * Logging out must drop that route from the model picker immediately.
122
+ */
123
+ async function syncAuthenticatedRoutes(
124
+ session: PiLoginSession,
125
+ registration: AdapterRegistrationHandle,
126
+ ): Promise<void> {
127
+ registration.replace(await session.authenticatedRoutes())
128
+ }
129
+
130
+ export function apply(ctx: Context, config: Config): void {
131
+ console.log('[my-plugins/dsh-oauth-login] loaded')
132
+ const native = {
133
+ enabled: config.nativeTools !== false,
134
+ image: config.nativeImage !== false,
135
+ }
136
+ const session = new PiLoginSession(new PiLoginCredentialStore(), native)
137
+ // Initial registration needs ≥1 route; replace() may then empty the set.
138
+ const registration = ctx.llm.registerAdapter(
139
+ piLoginRoutes(),
140
+ createPiLoginAdapter(session, () => ctx.get('attachments'), {
141
+ streamIdleTimeoutMs: config.streamIdleTimeoutMs,
142
+ retryPolicy: config.retryPolicy,
143
+ }),
144
+ )
145
+ const refreshRoutes = (): Promise<void> => syncAuthenticatedRoutes(session, registration)
146
+ void refreshRoutes()
147
+ ctx.effect(() => {
148
+ const stop = session.openRouter.subscribe(() => {
149
+ void refreshRoutes().catch(() => { /* The next directory load retries. */ })
150
+ })
151
+ void session.openRouter.syncAuthentication().catch(() => {
152
+ console.warn('[dsh-oauth-login] OpenRouter catalog initialization failed; keeping built-in models')
153
+ })
154
+ return async () => {
155
+ stop()
156
+ session.openRouter.dispose()
157
+ await session.proxy.dispose()
158
+ }
159
+ }, 'dsh-oauth-login: OpenRouter catalog')
160
+ ctx.effect(() => {
161
+ const timer = setInterval(() => {
162
+ void session.refreshStoredGrants().catch(() => { /* Next auth poll retries. */ })
163
+ }, OAUTH_REFRESH_POLL_MS)
164
+ void session.refreshStoredGrants().catch(() => { /* Next auth poll retries. */ })
165
+ return () => clearInterval(timer)
166
+ }, 'dsh-oauth-login: refresh oauth grants')
167
+ ctx.inject(['webServer', 'connection'], webCtx => {
168
+ registerPiLoginAuthRoutes(webCtx, session, { onAuthChanged: refreshRoutes })
169
+ })
170
+ ctx.inject(['systemPrompt'], promptCtx => {
171
+ promptCtx.on('system-prompt/assemble', async (_assembly, context, next) => {
172
+ const assembled = await next()
173
+ const route = context.agent?.options?.provider
174
+ const modelId = context.agent?.options?.model
175
+ const spec = route === undefined ? undefined : piLoginProviderByRoute(route)
176
+ const api = spec === undefined || modelId === undefined
177
+ ? undefined
178
+ : session.visibleModels(spec.id).find(model => model.id === modelId)?.api
179
+ const plan = nativePlanForRoute(route, api, session.native)
180
+ return plan === undefined ? assembled : maskDshWebAssembly(assembled, plan)
181
+ })
182
+ })
183
+ }
@@ -0,0 +1,12 @@
1
+ import type { Context } from '@deepseek-ai/cordis'
2
+ import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
3
+
4
+ const PACKAGE_NAME = 'dsh-oauth-login'
5
+
6
+ export const name = 'pi-login-invariant'
7
+ export const inject = ['invariants']
8
+
9
+ const install: InvariantInstaller = () => {}
10
+
11
+ export const apply = (ctx: Context): Promise<() => void> =>
12
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))