@goodandready/dsh-subscriptions 0.6.72 → 0.6.83

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 (49) hide show
  1. package/README.md +8 -0
  2. package/README.ru.md +8 -0
  3. package/README.zh.md +8 -0
  4. package/lib/accounts-view.js +97 -5
  5. package/lib/accounts.js +137 -11
  6. package/lib/adapter.js +3 -0
  7. package/lib/alerts.js +35 -0
  8. package/lib/analytics.js +31 -2
  9. package/lib/billing.js +99 -0
  10. package/lib/blob.js +22 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +344 -17
  13. package/lib/config-schema.js +50 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/error-buffer.js +2 -2
  17. package/lib/forecast.js +56 -0
  18. package/lib/headless-auth.js +392 -0
  19. package/lib/history.js +36 -0
  20. package/lib/http.js +67 -4
  21. package/lib/import-auth.js +3 -6
  22. package/lib/index.js +99 -11
  23. package/lib/jwt.js +84 -17
  24. package/lib/loopback.js +3 -2
  25. package/lib/mask.js +16 -0
  26. package/lib/oauth-pending.js +12 -0
  27. package/lib/pricing.js +89 -0
  28. package/lib/refs.js +2 -0
  29. package/lib/reset-clock.js +55 -0
  30. package/lib/rotate.js +18 -4
  31. package/lib/routes/accounts.js +142 -7
  32. package/lib/routes/oauth.js +51 -2
  33. package/lib/routes/proxy.js +14 -0
  34. package/lib/scope-inspector.js +51 -0
  35. package/lib/stream-rotate.js +31 -10
  36. package/lib/subscriptions.js +24 -1
  37. package/lib/updater.js +21 -0
  38. package/lib/vault-sync.js +414 -0
  39. package/lib/vault.js +1 -1
  40. package/lib/vendor-factory.js +6 -2
  41. package/lib/vendors/antigravity.js +13 -4
  42. package/lib/vendors/claude.js +56 -8
  43. package/lib/vendors/codex.js +34 -6
  44. package/lib/vendors/copilot-cli.js +36 -5
  45. package/lib/vendors/ernie.js +9 -2
  46. package/lib/vendors/grok.js +66 -6
  47. package/lib/vendors/jetbrains.js +136 -13
  48. package/lib/vendors/perplexity.js +151 -17
  49. package/package.json +1 -1
@@ -8,6 +8,13 @@ import { join, delimiter } from 'node:path'
8
8
  import { spawn } from 'node:child_process'
9
9
  import { LlmError } from '@deepseek-ai/dsh-llm'
10
10
 
11
+ const MAX_STDERR_CHARS = 2048
12
+ const GITHUB_TOKEN_PATTERN = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g
13
+
14
+ export function redactCopilotStderr(text) {
15
+ return String(text || '').replace(GITHUB_TOKEN_PATTERN, '[REDACTED]')
16
+ }
17
+
11
18
  /**
12
19
  * Standard candidate binary names for GitHub Copilot CLI.
13
20
  */
@@ -144,8 +151,11 @@ export function findCopilotCli(customPath, env = process.env, platform = process
144
151
  )
145
152
  }
146
153
 
154
+ const candidates = platform === 'win32'
155
+ ? COPILOT_CLI_BINARIES.filter((bin) => /\.(cmd|exe|bat)$/i.test(bin))
156
+ : COPILOT_CLI_BINARIES.filter((bin) => !/\.(cmd|exe|bat)$/i.test(bin))
147
157
  for (const dir of pathDirs) {
148
- for (const bin of COPILOT_CLI_BINARIES) {
158
+ for (const bin of candidates) {
149
159
  const fullPath = join(dir, bin)
150
160
  try {
151
161
  if (existsSync(fullPath)) {
@@ -187,8 +197,13 @@ export function isCopilotCliAvailable(options = {}) {
187
197
  * @param {string} [_binaryPath]
188
198
  * @returns {{ args: string[], env: NodeJS.ProcessEnv, inputPayload: string }}
189
199
  */
200
+ export const COPILOT_MODEL_PATTERN = /^[A-Za-z0-9._:/][A-Za-z0-9._:/-]{0,119}$/
201
+
190
202
  export function prepareCopilotCliInvocation(options, blob = {}, _binaryPath = 'copilot') {
191
- const model = options.model || 'claude-3.7-sonnet'
203
+ const model = String(options.model || 'claude-3.7-sonnet')
204
+ if (!COPILOT_MODEL_PATTERN.test(model)) {
205
+ throw new LlmError(`Invalid Copilot model id: ${model.slice(0, 80)}`, 'INVALID_MODEL')
206
+ }
192
207
  const token = blob.accessToken || blob.githubToken || blob.refreshToken || ''
193
208
 
194
209
  const args = [
@@ -227,6 +242,16 @@ export function prepareCopilotCliInvocation(options, blob = {}, _binaryPath = 'c
227
242
  return { args, env, inputPayload }
228
243
  }
229
244
 
245
+ export function resolveSpawnCommand(binaryPath, args, platform = process.platform) {
246
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(String(binaryPath))) {
247
+ return {
248
+ command: process.env.ComSpec || 'cmd.exe',
249
+ args: ['/d', '/s', '/c', binaryPath, ...args],
250
+ }
251
+ }
252
+ return { command: binaryPath, args }
253
+ }
254
+
230
255
  /**
231
256
  * Stream execution from local Copilot CLI process.
232
257
  * Yields canonical DSH events: block-start, text-delta, tool-call-delta, finish, usage.
@@ -246,10 +271,11 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
246
271
 
247
272
  const { args, env, inputPayload } = prepareCopilotCliInvocation(options, blob, binaryPath)
248
273
  const spawner = spawnImpl || spawn
274
+ const invocation = resolveSpawnCommand(binaryPath, args)
249
275
 
250
276
  let child
251
277
  try {
252
- child = spawner(binaryPath, args, {
278
+ child = spawner(invocation.command, invocation.args, {
253
279
  env,
254
280
  stdio: ['pipe', 'pipe', 'pipe'],
255
281
  })
@@ -265,6 +291,8 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
265
291
  }
266
292
 
267
293
  if (child.stdin) {
294
+ // EPIPE is expected when the CLI exits before reading stdin; never let it crash the host.
295
+ child.stdin.on('error', () => {})
268
296
  child.stdin.write(inputPayload)
269
297
  child.stdin.end()
270
298
  }
@@ -278,7 +306,10 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
278
306
  let stderrBuffer = ''
279
307
 
280
308
  if (child.stderr) {
281
- child.stderr.on('data', (d) => { stderrBuffer += d.toString() })
309
+ child.stderr.on('data', (d) => {
310
+ if (stderrBuffer.length >= MAX_STDERR_CHARS) return
311
+ stderrBuffer += String(d).slice(0, MAX_STDERR_CHARS - stderrBuffer.length)
312
+ })
282
313
  }
283
314
 
284
315
  const chunkQueue = []
@@ -319,7 +350,7 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
319
350
  stdoutBuffer = ''
320
351
  }
321
352
  if (code !== 0 && !chunkQueue.length) {
322
- processError = new LlmError(`Copilot CLI exited with code ${code}: ${stderrBuffer.trim() || 'unknown error'}`, 'VENDOR')
353
+ processError = new LlmError(`Copilot CLI exited with code ${code}: ${redactCopilotStderr(stderrBuffer.trim()) || 'unknown error'}`, 'VENDOR')
323
354
  }
324
355
  if (resolveNext) {
325
356
  const r = resolveNext
@@ -44,8 +44,15 @@ export async function listModels() {
44
44
 
45
45
  async function refreshErnieToken(apiKey, secretKey, fetchImpl) {
46
46
  const impl = fetchImpl || fetch
47
- const url = `${ERNIE_OAUTH_URL}?grant_type=client_credentials&client_id=${encodeURIComponent(apiKey)}&client_secret=${encodeURIComponent(secretKey)}`
48
- const res = await impl(url, { method: 'POST' })
47
+ const res = await impl(ERNIE_OAUTH_URL, {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
50
+ body: new URLSearchParams({
51
+ grant_type: 'client_credentials',
52
+ client_id: apiKey,
53
+ client_secret: secretKey,
54
+ }),
55
+ })
49
56
  if (!res.ok) throw httpError(res.status, await res.text())
50
57
  return readJson(res)
51
58
  }
@@ -1,7 +1,8 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { buildAuthorizeUrl } from '../oauth.js'
2
3
  import { modelCatalog, codexResponsesBody } from '../messages.js'
3
4
  import { formTokenRequest, codexResponsesStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
4
- import { emailFromToken } from '../jwt.js'
5
+ import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
5
6
  import { asUsageSnapshot, usageWindows, grokBillingPercent } from '../usage.js'
6
7
 
7
8
  export const id = 'grok'
@@ -13,14 +14,41 @@ const BILLING = 'https://cli-chat-proxy.grok.com/v1/billing?format=credits'
13
14
  const MODELS = 'https://api.x.ai/v1/models'
14
15
  const CLI_MODELS = 'https://cli-chat-proxy.grok.com/v1/models'
15
16
  const CATALOG_TTL_MS = 5 * 60 * 1000
17
+ const CATALOG_CACHE_MAX = 32
16
18
  const catalogCache = new Map()
17
19
 
20
+ function catalogKey(token) {
21
+ return createHash('sha256').update(String(token || '')).digest('hex').slice(0, 16)
22
+ }
23
+
24
+ function pruneCatalogCache(now = Date.now()) {
25
+ for (const [key, entry] of catalogCache) {
26
+ if (now - entry.at >= CATALOG_TTL_MS) catalogCache.delete(key)
27
+ }
28
+ while (catalogCache.size > CATALOG_CACHE_MAX) {
29
+ catalogCache.delete(catalogCache.keys().next().value)
30
+ }
31
+ }
32
+
33
+ export function grokCatalogCacheSize() {
34
+ return catalogCache.size
35
+ }
36
+
37
+ export function clearGrokCatalogCache() {
38
+ catalogCache.clear()
39
+ }
40
+
18
41
  async function cliCatalogCached(blob, cfg, fetchImpl) {
19
- const key = String(blob.accessToken || '').slice(-24)
42
+ const key = catalogKey(blob.accessToken)
43
+ const now = Date.now()
20
44
  const hit = catalogCache.get(key)
21
- if (hit && Date.now() - hit.at < CATALOG_TTL_MS) return hit.map
45
+ if (hit && now - hit.at < CATALOG_TTL_MS) return hit.map
46
+ if (hit) catalogCache.delete(key)
22
47
  const map = await cliCatalog(blob, cfg, fetchImpl)
23
- if (map.size > 0) catalogCache.set(key, { at: Date.now(), map })
48
+ if (map.size > 0) {
49
+ catalogCache.set(key, { at: now, map })
50
+ pruneCatalogCache(now)
51
+ }
24
52
  return map
25
53
  }
26
54
 
@@ -78,13 +106,41 @@ function identityHeaders(blob, config) {
78
106
  }
79
107
  }
80
108
 
109
+ const TRUSTED_AUTH_HOSTS = new Set(['api.x.ai', 'cli-chat-proxy.grok.com'])
110
+
111
+ function isTrustedAuthHost(hostname) {
112
+ const host = String(hostname || '').toLowerCase()
113
+ return TRUSTED_AUTH_HOSTS.has(host)
114
+ || host === 'grok.com'
115
+ || host.endsWith('.grok.com')
116
+ || host === 'localhost'
117
+ || host === '127.0.0.1'
118
+ || host === '::1'
119
+ }
120
+
121
+ function isGrokHost(hostname) {
122
+ const host = String(hostname || '').toLowerCase()
123
+ return host === 'grok.com' || host.endsWith('.grok.com')
124
+ }
125
+
81
126
  function streamHeaders(blob, config, extra) {
82
127
  const base = String(config.baseUrl || defaults().baseUrl)
128
+ let hostname = ''
129
+ try {
130
+ hostname = new URL(base).hostname
131
+ } catch {
132
+ hostname = ''
133
+ }
134
+ if (!isTrustedAuthHost(hostname)) {
135
+ const err = new Error(`Untrusted Grok baseUrl host: ${hostname || base}`)
136
+ err.code = 'GROK_UNTRUSTED_HOST'
137
+ throw err
138
+ }
83
139
  const headers = {
84
140
  Authorization: `Bearer ${blob.accessToken}`,
85
141
  ...(extra || {}),
86
142
  }
87
- if (/grok\.com/i.test(base)) Object.assign(headers, identityHeaders(blob, config))
143
+ if (isGrokHost(hostname)) Object.assign(headers, identityHeaders(blob, config))
88
144
  return headers
89
145
  }
90
146
 
@@ -108,7 +164,11 @@ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
108
164
  code_verifier: pkce.verifier,
109
165
  }, fetchImpl)
110
166
  const blob = tokenBlobFromOAuth(json)
111
- return { ...blob, email: blob.email || emailFromToken(blob.accessToken), label: blob.label || 'Grok' }
167
+ const email = blob.email || emailFromToken(blob.idToken || blob.accessToken)
168
+ const plan = blob.plan || planFromToken(blob.idToken || blob.accessToken)
169
+ const avatarUrl = blob.avatarUrl || avatarFromToken(blob.idToken || blob.accessToken)
170
+ const scopes = blob.scopes?.length ? blob.scopes : (json?.scope ? String(json.scope).split(/[\s,]+/).filter(Boolean) : scopesFromToken(blob.idToken || blob.accessToken))
171
+ return { ...blob, email, plan, avatarUrl, scopes, label: blob.label || email || "Grok" }
112
172
  }
113
173
 
114
174
  export async function refresh(cfg, blob, fetchImpl) {
@@ -1,9 +1,19 @@
1
+ // #563: JetBrains AI Assistant Subscription OAuth Vendor
2
+ // Supports OAuth PKCE authentication, token refresh, and JetBrains AI models.
3
+
1
4
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
- import { openaiMessages } from '../messages.js'
3
- import { openaiChatStream, httpError } from '../wire.js'
5
+ import { buildAuthorizeUrl } from '../oauth.js'
6
+ import { openaiMessages, modelCatalog } from '../messages.js'
7
+ import { jsonTokenRequest, tokenBlobFromOAuth, openaiChatStream, httpError, readJson } from '../wire.js'
8
+ import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
4
9
 
5
10
  export const id = 'jetbrains'
11
+
12
+ const AUTH = 'https://account.jetbrains.com/oauth2/auth'
13
+ const TOKEN = 'https://oauth.jetbrains.com/oauth2/token'
6
14
  const JETBRAINS_API_BASE = 'https://api.jetbrains.ai'
15
+ const USER_INFO = 'https://account.jetbrains.com/api/user'
16
+ const SCOPE = 'openid profile email ai.assistant offline_access'
7
17
 
8
18
  const JETBRAINS_MODELS = [
9
19
  {
@@ -11,15 +21,50 @@ const JETBRAINS_MODELS = [
11
21
  name: 'Claude 3.5 Sonnet (JetBrains AI)',
12
22
  contextWindow: 200000,
13
23
  maxTokens: 8192,
14
- inputModalities: ['text']
24
+ inputModalities: ['text'],
25
+ },
26
+ {
27
+ id: 'jb-claude-3.7-sonnet',
28
+ name: 'Claude 3.7 Sonnet (JetBrains AI)',
29
+ contextWindow: 200000,
30
+ maxTokens: 8192,
31
+ reasoning: {
32
+ efforts: [
33
+ { id: 'low', name: 'low' },
34
+ { id: 'medium', name: 'medium' },
35
+ { id: 'high', name: 'high' },
36
+ ],
37
+ },
38
+ inputModalities: ['text'],
15
39
  },
16
40
  {
17
41
  id: 'jb-gpt-4o',
18
42
  name: 'GPT-4o (JetBrains AI)',
19
43
  contextWindow: 128000,
20
44
  maxTokens: 8192,
21
- inputModalities: ['text']
22
- }
45
+ inputModalities: ['text'],
46
+ },
47
+ {
48
+ id: 'jb-o3-mini',
49
+ name: 'o3-mini (JetBrains AI)',
50
+ contextWindow: 128000,
51
+ maxTokens: 8192,
52
+ reasoning: {
53
+ efforts: [
54
+ { id: 'low', name: 'low' },
55
+ { id: 'medium', name: 'medium' },
56
+ { id: 'high', name: 'high' },
57
+ ],
58
+ },
59
+ inputModalities: ['text'],
60
+ },
61
+ {
62
+ id: 'jb-deepseek-r1',
63
+ name: 'DeepSeek R1 (JetBrains AI)',
64
+ contextWindow: 128000,
65
+ maxTokens: 8192,
66
+ inputModalities: ['text'],
67
+ },
23
68
  ]
24
69
 
25
70
  export function providerInfo() {
@@ -28,17 +73,95 @@ export function providerInfo() {
28
73
 
29
74
  export function defaults() {
30
75
  return {
76
+ clientId: 'jetbrains-ai-assistant',
77
+ redirectUri: 'http://localhost:1455/auth/callback',
31
78
  apiBase: JETBRAINS_API_BASE,
32
- models: JETBRAINS_MODELS.map((m) => m.id)
79
+ models: JETBRAINS_MODELS.map((m) => m.id),
80
+ }
81
+ }
82
+
83
+ export function authorizeUrl(cfg = {}, pkce = {}) {
84
+ const c = cfg || {}
85
+ const p = pkce || {}
86
+ if (!p.challenge || !p.state) {
87
+ throw new Error('JetBrains OAuth requires PKCE challenge and state')
88
+ }
89
+ return buildAuthorizeUrl({
90
+ authUrl: AUTH,
91
+ clientId: c.clientId || defaults().clientId,
92
+ redirectUri: c.redirectUri || defaults().redirectUri,
93
+ challenge: p.challenge,
94
+ state: p.state,
95
+ scope: SCOPE,
96
+ })
97
+ }
98
+
99
+ async function decorate(blob, fetchImpl) {
100
+ let email = blob.email || emailFromToken(blob.accessToken) || emailFromToken(blob.idToken)
101
+ let plan = blob.plan || planFromToken(blob.accessToken)
102
+ let avatarUrl = blob.avatarUrl || avatarFromToken(blob.accessToken)
103
+ let scopes = blob.scopes || scopesFromToken(blob.accessToken)
104
+
105
+ if (fetchImpl && blob.accessToken && (!email || !plan)) {
106
+ try {
107
+ const res = await fetchImpl(USER_INFO, {
108
+ headers: {
109
+ Authorization: `Bearer ${blob.accessToken}`,
110
+ Accept: 'application/json',
111
+ },
112
+ })
113
+ if (res.ok) {
114
+ const json = await readJson(res)
115
+ email = email || json.email || json.username || ''
116
+ plan = plan || json.plan || (json.has_ai_assistant ? 'JetBrains AI Pro' : 'JetBrains Account')
117
+ avatarUrl = avatarUrl || json.picture || json.avatar_url || ''
118
+ if (json.scope && !scopes.length) scopes = String(json.scope).split(/[\s,]+/).filter(Boolean)
119
+ }
120
+ } catch { /* user info is optional */ }
121
+ }
122
+
123
+ return {
124
+ ...blob,
125
+ email: email || '',
126
+ plan: plan || 'JetBrains AI Pro',
127
+ avatarUrl: avatarUrl || '',
128
+ scopes,
129
+ label: blob.label || email || 'JetBrains AI',
33
130
  }
34
131
  }
35
132
 
36
- export function authorizeUrl() {
37
- return 'https://account.jetbrains.com/'
133
+ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
134
+ const json = await jsonTokenRequest(TOKEN, {
135
+ grant_type: 'authorization_code',
136
+ client_id: cfg.clientId || defaults().clientId,
137
+ code,
138
+ redirect_uri: cfg.redirectUri || defaults().redirectUri,
139
+ code_verifier: pkce.verifier,
140
+ state: pkce.state,
141
+ }, fetchImpl)
142
+ return decorate(tokenBlobFromOAuth(json), fetchImpl)
143
+ }
144
+
145
+ export async function refresh(cfg, blob, fetchImpl) {
146
+ const json = await jsonTokenRequest(TOKEN, {
147
+ grant_type: 'refresh_token',
148
+ client_id: cfg.clientId || defaults().clientId,
149
+ refresh_token: blob.refreshToken,
150
+ }, fetchImpl)
151
+ return decorate(tokenBlobFromOAuth(json, { label: blob.label, email: blob.email }), fetchImpl)
38
152
  }
39
153
 
40
- export async function listModels() {
41
- return JETBRAINS_MODELS
154
+ export async function listModels(_blob, cfg) {
155
+ const customModels = (cfg && Array.isArray(cfg.models) && cfg.models.length) ? cfg.models : null
156
+ if (customModels) {
157
+ const enriched = customModels.map((m) => {
158
+ const mid = typeof m === 'string' ? m : m.id
159
+ const found = JETBRAINS_MODELS.find((p) => p.id === mid)
160
+ return found ? { ...found, ...(typeof m === 'object' ? m : {}) } : (typeof m === 'string' ? { id: m, name: m } : m)
161
+ })
162
+ return modelCatalog(id, enriched)
163
+ }
164
+ return modelCatalog(id, JETBRAINS_MODELS)
42
165
  }
43
166
 
44
167
  export async function usage() {
@@ -55,7 +178,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
55
178
  const body = {
56
179
  model,
57
180
  messages: openaiMessages(options),
58
- stream: true
181
+ stream: true,
59
182
  }
60
183
 
61
184
  const res = await impl(url, {
@@ -64,10 +187,10 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
64
187
  ...headers,
65
188
  'Authorization': `Bearer ${token}`,
66
189
  'Content-Type': 'application/json',
67
- 'User-Agent': 'IntelliJ-IDEA/2026.1'
190
+ 'User-Agent': 'IntelliJ-IDEA/2026.1',
68
191
  },
69
192
  body: JSON.stringify(body),
70
- signal
193
+ signal,
71
194
  })
72
195
 
73
196
  if (!res.ok) throw httpError(res.status, await res.text())
@@ -1,9 +1,20 @@
1
+ // #563: Perplexity Pro Subscription OAuth Vendor
2
+ // Supports OAuth PKCE authentication, token refresh, and Pro subscription models.
3
+
1
4
  import { LlmError } from '@deepseek-ai/dsh-llm'
2
- import { openaiMessages } from '../messages.js'
3
- import { openaiChatStream, httpError } from '../wire.js'
5
+ import { buildAuthorizeUrl } from '../oauth.js'
6
+ import { openaiMessages, modelCatalog } from '../messages.js'
7
+ import { jsonTokenRequest, tokenBlobFromOAuth, openaiChatStream, httpError, readJson } from '../wire.js'
8
+ import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
4
9
 
5
10
  export const id = 'perplexity'
11
+
12
+ const AUTH = 'https://www.perplexity.ai/oauth/authorize'
13
+ const TOKEN = 'https://api.perplexity.ai/oauth/token'
6
14
  const PERPLEXITY_API_BASE = 'https://api.perplexity.ai'
15
+ const PROFILE = 'https://api.perplexity.ai/api/v1/user/profile'
16
+ const USAGE = 'https://api.perplexity.ai/api/v1/user/usage'
17
+ const SCOPE = 'openid profile email pro:search offline_access'
7
18
 
8
19
  const PERPLEXITY_MODELS = [
9
20
  {
@@ -11,15 +22,43 @@ const PERPLEXITY_MODELS = [
11
22
  name: 'Sonar Reasoning Pro',
12
23
  contextWindow: 128000,
13
24
  maxTokens: 8192,
14
- inputModalities: ['text']
25
+ reasoning: {
26
+ efforts: [
27
+ { id: 'low', name: 'low' },
28
+ { id: 'medium', name: 'medium' },
29
+ { id: 'high', name: 'high' },
30
+ ],
31
+ },
32
+ inputModalities: ['text'],
15
33
  },
16
34
  {
17
35
  id: 'sonar-pro',
18
36
  name: 'Sonar Pro',
19
37
  contextWindow: 200000,
20
38
  maxTokens: 8192,
21
- inputModalities: ['text']
22
- }
39
+ inputModalities: ['text'],
40
+ },
41
+ {
42
+ id: 'sonar-reasoning',
43
+ name: 'Sonar Reasoning',
44
+ contextWindow: 128000,
45
+ maxTokens: 8192,
46
+ inputModalities: ['text'],
47
+ },
48
+ {
49
+ id: 'sonar',
50
+ name: 'Sonar',
51
+ contextWindow: 128000,
52
+ maxTokens: 8192,
53
+ inputModalities: ['text'],
54
+ },
55
+ {
56
+ id: 'r1-1776',
57
+ name: 'DeepSeek R1 (Perplexity)',
58
+ contextWindow: 128000,
59
+ maxTokens: 8192,
60
+ inputModalities: ['text'],
61
+ },
23
62
  ]
24
63
 
25
64
  export function providerInfo() {
@@ -28,24 +67,119 @@ export function providerInfo() {
28
67
 
29
68
  export function defaults() {
30
69
  return {
70
+ clientId: 'perplexity-cli-oauth',
71
+ redirectUri: 'http://localhost:1455/auth/callback',
31
72
  apiBase: PERPLEXITY_API_BASE,
32
- models: PERPLEXITY_MODELS.map((m) => m.id)
73
+ models: PERPLEXITY_MODELS.map((m) => m.id),
33
74
  }
34
75
  }
35
76
 
36
- export function authorizeUrl() {
37
- return 'https://www.perplexity.ai/settings/api'
77
+ export function authorizeUrl(cfg = {}, pkce = {}) {
78
+ const c = cfg || {}
79
+ const p = pkce || {}
80
+ if (!p.challenge || !p.state) {
81
+ throw new Error('Perplexity OAuth requires PKCE challenge and state')
82
+ }
83
+ return buildAuthorizeUrl({
84
+ authUrl: AUTH,
85
+ clientId: c.clientId || defaults().clientId,
86
+ redirectUri: c.redirectUri || defaults().redirectUri,
87
+ challenge: p.challenge,
88
+ state: p.state,
89
+ scope: SCOPE,
90
+ })
38
91
  }
39
92
 
40
- export async function listModels() {
41
- return PERPLEXITY_MODELS
93
+ async function decorate(blob, fetchImpl) {
94
+ let email = blob.email || emailFromToken(blob.accessToken) || emailFromToken(blob.idToken)
95
+ let plan = blob.plan || planFromToken(blob.accessToken)
96
+ let avatarUrl = blob.avatarUrl || avatarFromToken(blob.accessToken)
97
+ let scopes = blob.scopes || scopesFromToken(blob.accessToken)
98
+
99
+ if (fetchImpl && blob.accessToken && (!email || !plan)) {
100
+ try {
101
+ const res = await fetchImpl(PROFILE, {
102
+ headers: {
103
+ Authorization: `Bearer ${blob.accessToken}`,
104
+ Accept: 'application/json',
105
+ },
106
+ })
107
+ if (res.ok) {
108
+ const json = await readJson(res)
109
+ email = email || json.email || ''
110
+ plan = plan || json.plan || (json.has_pro ? 'Perplexity Pro' : 'Free')
111
+ avatarUrl = avatarUrl || json.picture || json.avatar_url || ''
112
+ if (json.scope && !scopes.length) scopes = String(json.scope).split(/[\s,]+/).filter(Boolean)
113
+ }
114
+ } catch { /* profile is optional */ }
115
+ }
116
+
117
+ return {
118
+ ...blob,
119
+ email: email || '',
120
+ plan: plan || 'Perplexity Pro',
121
+ avatarUrl: avatarUrl || '',
122
+ scopes,
123
+ label: blob.label || email || 'Perplexity Pro',
124
+ }
42
125
  }
43
126
 
44
- export async function usage(blob) {
45
- if (blob && (blob.accessToken || blob.apiKey)) {
46
- return { usedPercent: null, plan: 'Perplexity Pro', timestamp: Date.now() }
127
+ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
128
+ const json = await jsonTokenRequest(TOKEN, {
129
+ grant_type: 'authorization_code',
130
+ client_id: cfg.clientId || defaults().clientId,
131
+ code,
132
+ redirect_uri: cfg.redirectUri || defaults().redirectUri,
133
+ code_verifier: pkce.verifier,
134
+ state: pkce.state,
135
+ }, fetchImpl)
136
+ return decorate(tokenBlobFromOAuth(json), fetchImpl)
137
+ }
138
+
139
+ export async function refresh(cfg, blob, fetchImpl) {
140
+ const json = await jsonTokenRequest(TOKEN, {
141
+ grant_type: 'refresh_token',
142
+ client_id: cfg.clientId || defaults().clientId,
143
+ refresh_token: blob.refreshToken,
144
+ }, fetchImpl)
145
+ return decorate(tokenBlobFromOAuth(json, { label: blob.label, email: blob.email }), fetchImpl)
146
+ }
147
+
148
+ export async function listModels(_blob, cfg) {
149
+ const customModels = (cfg && Array.isArray(cfg.models) && cfg.models.length) ? cfg.models : null
150
+ if (customModels) {
151
+ const enriched = customModels.map((m) => {
152
+ const mid = typeof m === 'string' ? m : m.id
153
+ const found = PERPLEXITY_MODELS.find((p) => p.id === mid)
154
+ return found ? { ...found, ...(typeof m === 'object' ? m : {}) } : (typeof m === 'string' ? { id: m, name: m } : m)
155
+ })
156
+ return modelCatalog(id, enriched)
47
157
  }
48
- return null
158
+ return modelCatalog(id, PERPLEXITY_MODELS)
159
+ }
160
+
161
+ export async function usage(blob, _cfg, fetchImpl) {
162
+ if (!blob || (!blob.accessToken && !blob.apiKey)) return null
163
+ const key = blob.accessToken || blob.apiKey
164
+ const impl = fetchImpl || fetch
165
+ try {
166
+ const res = await impl(USAGE, {
167
+ headers: {
168
+ Authorization: `Bearer ${key}`,
169
+ Accept: 'application/json',
170
+ },
171
+ })
172
+ if (res.ok) {
173
+ const d = await readJson(res)
174
+ return {
175
+ usedPercent: typeof d.used_percent === 'number' ? d.used_percent : null,
176
+ plan: d.plan || blob.plan || 'Perplexity Pro',
177
+ timestamp: Date.now(),
178
+ quota: d.quota ? { used: d.quota.used, limit: d.quota.limit, resetAt: d.quota.reset_at } : undefined,
179
+ }
180
+ }
181
+ } catch { /* usage endpoint is optional */ }
182
+ return { usedPercent: null, plan: blob.plan || 'Perplexity Pro', timestamp: Date.now() }
49
183
  }
50
184
 
51
185
  export async function* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
@@ -57,7 +191,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
57
191
  const body = {
58
192
  model: options.model || 'sonar-pro',
59
193
  messages: openaiMessages(options),
60
- stream: true
194
+ stream: true,
61
195
  }
62
196
 
63
197
  const res = await impl(url, {
@@ -65,10 +199,10 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
65
199
  headers: {
66
200
  ...headers,
67
201
  'Authorization': `Bearer ${key}`,
68
- 'Content-Type': 'application/json'
202
+ 'Content-Type': 'application/json',
69
203
  },
70
204
  body: JSON.stringify(body),
71
- signal
205
+ signal,
72
206
  })
73
207
 
74
208
  if (!res.ok) throw httpError(res.status, await res.text())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.72",
3
+ "version": "0.6.83",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",