@goodandready/dsh-subscriptions 0.6.71 → 0.6.82

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 (50) hide show
  1. package/README.md +13 -0
  2. package/README.ru.md +13 -0
  3. package/README.zh.md +13 -0
  4. package/lib/accounts-view.js +101 -5
  5. package/lib/accounts.js +150 -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 +18 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +944 -828
  13. package/lib/config-schema.js +46 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/doctor.js +163 -0
  17. package/lib/error-buffer.js +2 -2
  18. package/lib/forecast.js +56 -0
  19. package/lib/headless-auth.js +379 -0
  20. package/lib/history.js +36 -0
  21. package/lib/http.js +26 -4
  22. package/lib/index.js +98 -11
  23. package/lib/instructions.js +138 -0
  24. package/lib/jwt.js +84 -17
  25. package/lib/loopback.js +3 -2
  26. package/lib/mask.js +16 -0
  27. package/lib/oauth-pending.js +12 -0
  28. package/lib/pricing.js +89 -0
  29. package/lib/refs.js +2 -0
  30. package/lib/reset-clock.js +55 -0
  31. package/lib/rotate.js +18 -4
  32. package/lib/routes/accounts.js +203 -0
  33. package/lib/routes/oauth.js +44 -2
  34. package/lib/routes/proxy.js +14 -0
  35. package/lib/routes/status.js +99 -5
  36. package/lib/scope-inspector.js +51 -0
  37. package/lib/stream-rotate.js +31 -10
  38. package/lib/updater.js +21 -0
  39. package/lib/vault-sync.js +412 -0
  40. package/lib/vault.js +1 -1
  41. package/lib/vendor-factory.js +6 -2
  42. package/lib/vendors/antigravity.js +11 -2
  43. package/lib/vendors/claude.js +56 -8
  44. package/lib/vendors/codex.js +34 -6
  45. package/lib/vendors/copilot-cli.js +8 -1
  46. package/lib/vendors/ernie.js +9 -2
  47. package/lib/vendors/grok.js +66 -6
  48. package/lib/vendors/jetbrains.js +133 -13
  49. package/lib/vendors/perplexity.js +148 -17
  50. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { fetchWithTimeout } from '../http.js'
2
2
  import { buildAuthorizeUrl } from '../oauth.js'
3
3
  import { anthropicPayload, modelCatalog } from '../messages.js'
4
4
  import { jsonTokenRequest, anthropicStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
5
- import { emailFromToken } from '../jwt.js'
5
+ import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
6
6
  import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
7
7
 
8
8
  export const id = 'claude'
@@ -81,13 +81,19 @@ export function defaults() {
81
81
  }
82
82
  }
83
83
 
84
- function oauthHeaders(token) {
85
- return {
84
+ function oauthHeaders(token, blob) {
85
+ const h = {
86
86
  Authorization: `Bearer ${token}`,
87
87
  Accept: 'application/json',
88
88
  'anthropic-version': '2023-06-01',
89
89
  'anthropic-beta': BETA,
90
90
  }
91
+ const org = blob && (blob.selectedOrgId || blob.organizationId)
92
+ if (org) {
93
+ h['anthropic-organization-id'] = String(org)
94
+ h['x-anthropic-organization'] = String(org)
95
+ }
96
+ return h
91
97
  }
92
98
 
93
99
  export function authorizeUrl(cfg, pkce) {
@@ -104,14 +110,56 @@ export function authorizeUrl(cfg, pkce) {
104
110
 
105
111
  async function decorate(blob, fetchImpl) {
106
112
  let email = blob.email || emailFromToken(blob.accessToken)
107
- if (!email && fetchImpl && blob.accessToken) {
113
+ let plan = blob.plan || planFromToken(blob.accessToken)
114
+ let avatarUrl = blob.avatarUrl || avatarFromToken(blob.accessToken)
115
+ let scopes = blob.scopes || scopesFromToken(blob.accessToken)
116
+ let orgs = Array.isArray(blob.organizations) ? [...blob.organizations] : []
117
+ if (fetchImpl && blob.accessToken && (!email || !plan || !orgs.length)) {
108
118
  try {
109
- const res = await fetchImpl(PROFILE, { headers: oauthHeaders(blob.accessToken) })
119
+ const res = await fetchImpl(PROFILE, { headers: oauthHeaders(blob.accessToken, blob) })
110
120
  const json = await readJson(res)
111
- email = json.email || (json.account && json.account.email) || email
121
+ email = email || json.email || (json.account && json.account.email) || ""
122
+ plan = plan || json.plan || (json.account && (json.account.plan_type || (json.account.has_team ? "Team" : (json.account.has_pro ? "Pro" : "Free")))) || ""
123
+ avatarUrl = avatarUrl || json.picture || (json.account && json.account.picture) || ""
124
+ if (json.scope && !scopes.length) scopes = String(json.scope).split(/[\s,]+/).filter(Boolean)
125
+ if (Array.isArray(json.organizations)) {
126
+ for (const o of json.organizations) {
127
+ if (o && (o.id || o.uuid)) orgs.push({ id: String(o.id || o.uuid), name: String(o.name || o.title || o.id) })
128
+ }
129
+ } else if (Array.isArray(json.workspaces)) {
130
+ for (const w of json.workspaces) {
131
+ if (w && (w.id || w.uuid)) orgs.push({ id: String(w.id || w.uuid), name: String(w.name || w.title || w.id) })
132
+ }
133
+ } else if (json.account && Array.isArray(json.account.memberships)) {
134
+ for (const m of json.account.memberships) {
135
+ const o = m.organization || m
136
+ if (o && (o.id || o.uuid)) orgs.push({ id: String(o.id || o.uuid), name: String(o.name || o.title || o.id), role: m.role ? String(m.role) : undefined })
137
+ }
138
+ }
112
139
  } catch { /* profile is optional */ }
113
140
  }
114
- return { ...blob, email, label: blob.label || email || 'Claude' }
141
+ const dedupedOrgs = []
142
+ const seen = new Set()
143
+ for (const o of orgs) {
144
+ if (!seen.has(o.id)) {
145
+ seen.add(o.id)
146
+ dedupedOrgs.push(o)
147
+ }
148
+ }
149
+ const selectedOrgId = blob.selectedOrgId || blob.organizationId || (dedupedOrgs[0] ? dedupedOrgs[0].id : '')
150
+ const selectedOrgName = blob.selectedOrgName || (dedupedOrgs.find(o => o.id === selectedOrgId)?.name || selectedOrgId)
151
+ return {
152
+ ...blob,
153
+ email: email || "",
154
+ plan: plan || "Claude Pro",
155
+ avatarUrl: avatarUrl || "",
156
+ scopes,
157
+ selectedOrgId,
158
+ selectedOrgName,
159
+ organizationId: selectedOrgId,
160
+ organizations: dedupedOrgs,
161
+ label: blob.label || email || "Claude",
162
+ }
115
163
  }
116
164
 
117
165
  export async function exchangeCode(cfg, pkce, code, fetchImpl) {
@@ -302,7 +350,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
302
350
  method: 'POST',
303
351
  headers: {
304
352
  ...headers,
305
- ...oauthHeaders(blob.accessToken),
353
+ ...oauthHeaders(blob.accessToken, blob),
306
354
  'Content-Type': 'application/json',
307
355
  Accept: 'text/event-stream',
308
356
  },
@@ -2,7 +2,7 @@ import { isTimeoutError, fetchWithTimeout } from "../http.js"
2
2
  import { randomUUID, createHash } from 'node:crypto'
3
3
  import { buildAuthorizeUrl } from '../oauth.js'
4
4
  import { codexResponsesBody, modelCatalog } from '../messages.js'
5
- import { chatgptAccountId, emailFromToken } from '../jwt.js'
5
+ import { chatgptAccountId, emailFromToken, planFromToken, avatarFromToken, scopesFromToken, organizationsFromToken } from '../jwt.js'
6
6
  import { formTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError } from '../wire.js'
7
7
  import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
8
8
 
@@ -62,11 +62,13 @@ export function authorizeUrl(cfg, pkce) {
62
62
 
63
63
  function identityHeaders(blob, config, extra) {
64
64
  const originator = config.originator || 'codex_cli_rs'
65
+ const activeOrgId = blob.selectedOrgId || blob.accountId || ''
65
66
  return {
66
67
  Authorization: `Bearer ${blob.accessToken}`,
67
68
  originator,
68
- 'chatgpt-account-id': blob.accountId || '',
69
- 'ChatGPT-Account-ID': blob.accountId || '',
69
+ 'chatgpt-account-id': activeOrgId,
70
+ 'ChatGPT-Account-ID': activeOrgId,
71
+ ...(activeOrgId ? { 'OpenAI-Organization': activeOrgId } : {}),
70
72
  'User-Agent': `${originator}/0.0.1`,
71
73
  'OpenAI-Beta': 'responses=experimental',
72
74
  ...(extra || {}),
@@ -75,11 +77,37 @@ function identityHeaders(blob, config, extra) {
75
77
 
76
78
  function decorate(blob, json) {
77
79
  const idToken = (json && json.id_token) || blob.idToken || ''
80
+ const email = blob.email || emailFromToken(idToken) || emailFromToken(blob.accessToken)
81
+ const plan = blob.plan || planFromToken(idToken) || planFromToken(blob.accessToken)
82
+ const avatarUrl = blob.avatarUrl || avatarFromToken(idToken) || avatarFromToken(blob.accessToken)
83
+ const scopes = blob.scopes?.length ? blob.scopes : (json?.scope ? String(json.scope).split(/[\s,]+/).filter(Boolean) : scopesFromToken(idToken || blob.accessToken))
84
+ const parsedOrgs = [
85
+ ...organizationsFromToken(idToken),
86
+ ...organizationsFromToken(blob.accessToken),
87
+ ]
88
+ const dedupedOrgs = Array.isArray(blob.organizations) && blob.organizations.length ? [...blob.organizations] : []
89
+ const seen = new Set(dedupedOrgs.map(o => o.id))
90
+ for (const o of parsedOrgs) {
91
+ if (!seen.has(o.id)) {
92
+ seen.add(o.id)
93
+ dedupedOrgs.push(o)
94
+ }
95
+ }
96
+ const accountId = blob.accountId || chatgptAccountId(idToken) || chatgptAccountId(blob.accessToken)
97
+ const selectedOrgId = blob.selectedOrgId || accountId || (dedupedOrgs[0] ? dedupedOrgs[0].id : '')
98
+ const selectedOrgName = blob.selectedOrgName || (dedupedOrgs.find(o => o.id === selectedOrgId)?.name || selectedOrgId)
99
+
78
100
  return {
79
101
  ...blob,
80
- accountId: blob.accountId || chatgptAccountId(idToken) || chatgptAccountId(blob.accessToken),
81
- email: blob.email || emailFromToken(idToken) || emailFromToken(blob.accessToken),
82
- label: blob.label || emailFromToken(idToken) || emailFromToken(blob.accessToken) || 'ChatGPT',
102
+ accountId,
103
+ selectedOrgId,
104
+ selectedOrgName,
105
+ organizations: dedupedOrgs,
106
+ email,
107
+ plan: plan || 'ChatGPT',
108
+ avatarUrl,
109
+ scopes,
110
+ label: blob.label || email || 'ChatGPT',
83
111
  }
84
112
  }
85
113
 
@@ -187,8 +187,13 @@ export function isCopilotCliAvailable(options = {}) {
187
187
  * @param {string} [_binaryPath]
188
188
  * @returns {{ args: string[], env: NodeJS.ProcessEnv, inputPayload: string }}
189
189
  */
190
+ export const COPILOT_MODEL_PATTERN = /^[A-Za-z0-9._:/][A-Za-z0-9._:/-]{0,119}$/
191
+
190
192
  export function prepareCopilotCliInvocation(options, blob = {}, _binaryPath = 'copilot') {
191
- const model = options.model || 'claude-3.7-sonnet'
193
+ const model = String(options.model || 'claude-3.7-sonnet')
194
+ if (!COPILOT_MODEL_PATTERN.test(model)) {
195
+ throw new LlmError(`Invalid Copilot model id: ${model.slice(0, 80)}`, 'INVALID_MODEL')
196
+ }
192
197
  const token = blob.accessToken || blob.githubToken || blob.refreshToken || ''
193
198
 
194
199
  const args = [
@@ -265,6 +270,8 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
265
270
  }
266
271
 
267
272
  if (child.stdin) {
273
+ // EPIPE is expected when the CLI exits before reading stdin; never let it crash the host.
274
+ child.stdin.on('error', () => {})
268
275
  child.stdin.write(inputPayload)
269
276
  child.stdin.end()
270
277
  }
@@ -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,92 @@ 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),
33
80
  }
34
81
  }
35
82
 
36
- export function authorizeUrl() {
37
- return 'https://account.jetbrains.com/'
83
+ export function authorizeUrl(cfg = {}, pkce = {}) {
84
+ const c = cfg || {}
85
+ const p = pkce || {}
86
+ return buildAuthorizeUrl({
87
+ authUrl: AUTH,
88
+ clientId: c.clientId || defaults().clientId,
89
+ redirectUri: c.redirectUri || defaults().redirectUri,
90
+ challenge: p.challenge || 'challenge',
91
+ state: p.state || 'state',
92
+ scope: SCOPE,
93
+ })
38
94
  }
39
95
 
40
- export async function listModels() {
41
- return JETBRAINS_MODELS
96
+ async function decorate(blob, fetchImpl) {
97
+ let email = blob.email || emailFromToken(blob.accessToken) || emailFromToken(blob.idToken)
98
+ let plan = blob.plan || planFromToken(blob.accessToken)
99
+ let avatarUrl = blob.avatarUrl || avatarFromToken(blob.accessToken)
100
+ let scopes = blob.scopes || scopesFromToken(blob.accessToken)
101
+
102
+ if (fetchImpl && blob.accessToken && (!email || !plan)) {
103
+ try {
104
+ const res = await fetchImpl(USER_INFO, {
105
+ headers: {
106
+ Authorization: `Bearer ${blob.accessToken}`,
107
+ Accept: 'application/json',
108
+ },
109
+ })
110
+ if (res.ok) {
111
+ const json = await readJson(res)
112
+ email = email || json.email || json.username || ''
113
+ plan = plan || json.plan || (json.has_ai_assistant ? 'JetBrains AI Pro' : 'JetBrains Account')
114
+ avatarUrl = avatarUrl || json.picture || json.avatar_url || ''
115
+ if (json.scope && !scopes.length) scopes = String(json.scope).split(/[\s,]+/).filter(Boolean)
116
+ }
117
+ } catch { /* user info is optional */ }
118
+ }
119
+
120
+ return {
121
+ ...blob,
122
+ email: email || '',
123
+ plan: plan || 'JetBrains AI Pro',
124
+ avatarUrl: avatarUrl || '',
125
+ scopes,
126
+ label: blob.label || email || 'JetBrains AI',
127
+ }
128
+ }
129
+
130
+ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
131
+ const json = await jsonTokenRequest(TOKEN, {
132
+ grant_type: 'authorization_code',
133
+ client_id: cfg.clientId || defaults().clientId,
134
+ code,
135
+ redirect_uri: cfg.redirectUri || defaults().redirectUri,
136
+ code_verifier: pkce.verifier,
137
+ state: pkce.state,
138
+ }, fetchImpl)
139
+ return decorate(tokenBlobFromOAuth(json), fetchImpl)
140
+ }
141
+
142
+ export async function refresh(cfg, blob, fetchImpl) {
143
+ const json = await jsonTokenRequest(TOKEN, {
144
+ grant_type: 'refresh_token',
145
+ client_id: cfg.clientId || defaults().clientId,
146
+ refresh_token: blob.refreshToken,
147
+ }, fetchImpl)
148
+ return decorate(tokenBlobFromOAuth(json, { label: blob.label, email: blob.email }), fetchImpl)
149
+ }
150
+
151
+ export async function listModels(_blob, cfg) {
152
+ const customModels = (cfg && Array.isArray(cfg.models) && cfg.models.length) ? cfg.models : null
153
+ if (customModels) {
154
+ const enriched = customModels.map((m) => {
155
+ const mid = typeof m === 'string' ? m : m.id
156
+ const found = JETBRAINS_MODELS.find((p) => p.id === mid)
157
+ return found ? { ...found, ...(typeof m === 'object' ? m : {}) } : (typeof m === 'string' ? { id: m, name: m } : m)
158
+ })
159
+ return modelCatalog(id, enriched)
160
+ }
161
+ return modelCatalog(id, JETBRAINS_MODELS)
42
162
  }
43
163
 
44
164
  export async function usage() {
@@ -55,7 +175,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
55
175
  const body = {
56
176
  model,
57
177
  messages: openaiMessages(options),
58
- stream: true
178
+ stream: true,
59
179
  }
60
180
 
61
181
  const res = await impl(url, {
@@ -64,10 +184,10 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
64
184
  ...headers,
65
185
  'Authorization': `Bearer ${token}`,
66
186
  'Content-Type': 'application/json',
67
- 'User-Agent': 'IntelliJ-IDEA/2026.1'
187
+ 'User-Agent': 'IntelliJ-IDEA/2026.1',
68
188
  },
69
189
  body: JSON.stringify(body),
70
- signal
190
+ signal,
71
191
  })
72
192
 
73
193
  if (!res.ok) throw httpError(res.status, await res.text())