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,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
|
+
}
|
package/src/invariant.ts
ADDED
|
@@ -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))
|