@goodandready/dsh-subscriptions 0.6.0 → 0.6.2
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/README.md +12 -1
- package/README.ru.md +11 -0
- package/README.zh.md +6 -0
- package/lib/accounts.js +1 -1
- package/lib/adapter.js +2 -2
- package/lib/client.js +220 -189
- package/lib/crypto.js +4 -4
- package/lib/google-validation.js +1 -1
- package/lib/health.js +2 -2
- package/lib/history.js +5 -5
- package/lib/http.js +7 -0
- package/lib/images.js +21 -21
- package/lib/index.js +19 -19
- package/lib/loopback.js +10 -10
- package/lib/ratelimit.js +2 -2
- package/lib/refs.js +9 -1
- package/lib/routes/accounts.js +343 -0
- package/lib/routes/oauth.js +248 -0
- package/lib/routes/proxy.js +147 -0
- package/lib/routes/status.js +257 -0
- package/lib/routes.js +12 -916
- package/lib/subscriptions.js +1 -1
- package/lib/ui-styles.js +12 -12
- package/lib/usage.js +1 -1
- package/lib/vendor-factory.js +13 -13
- package/package.json +5 -5
- package/lib/ui-i18n.js +0 -256
package/lib/history.js
CHANGED
|
@@ -2,9 +2,9 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
|
|
5
|
-
// #65:
|
|
6
|
-
// ~/.dsh/storages/dsh-subscriptions/history.json (JSON
|
|
7
|
-
//
|
|
5
|
+
// #65: request and cost history. Stored in
|
|
6
|
+
// ~/.dsh/storages/dsh-subscriptions/history.json (JSON array, newest first).
|
|
7
|
+
// All IO is best-effort and synchronous: a write failure must never crash the harness.
|
|
8
8
|
|
|
9
9
|
export const DEFAULT_HISTORY_DIR = join(homedir(), '.dsh', 'storages', 'dsh-subscriptions')
|
|
10
10
|
|
|
@@ -42,7 +42,7 @@ export class HistoryStore {
|
|
|
42
42
|
} catch { /* best-effort */ }
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
45
|
+
/** Add one entry. Returns the history length. */
|
|
46
46
|
add(entry) {
|
|
47
47
|
if (!entry || typeof entry !== 'object') return this.rows.length
|
|
48
48
|
this.rows.unshift({ ts: Date.now(), ...entry })
|
|
@@ -51,7 +51,7 @@ export class HistoryStore {
|
|
|
51
51
|
return this.rows.length
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/** Last N entries (newest first). */
|
|
55
55
|
recent(n) {
|
|
56
56
|
return this.rows.slice(0, n)
|
|
57
57
|
}
|
package/lib/http.js
CHANGED
package/lib/images.js
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Image generation on a subscription.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// This module only implements the protocol: where to go, what to send and how to read the response. Everything
|
|
4
|
+
// else - saving the file, attaching it to the conversation, the card - is the job of the generation
|
|
5
|
+
// plugin; this plugin only lends its account.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// The token never leaves the process: the plugin exposes an in-process service, not
|
|
8
|
+
// a network route. The harness on this machine is reachable without a password, and an endpoint
|
|
9
|
+
// handing out a live subscription token would be a wider hole than the ones we already closed.
|
|
10
10
|
|
|
11
|
-
/**
|
|
11
|
+
/** Where the ChatGPT subscription request goes. */
|
|
12
12
|
export const CODEX_URL = 'https://chatgpt.com/backend-api/codex/images/generations'
|
|
13
|
-
/**
|
|
13
|
+
/** The model served by this endpoint. */
|
|
14
14
|
export const CODEX_MODEL = 'gpt-image-2'
|
|
15
|
-
/**
|
|
15
|
+
/** Where the Grok subscription request goes. */
|
|
16
16
|
export const GROK_URL = 'https://api.x.ai/v1/images/generations'
|
|
17
|
-
/**
|
|
17
|
+
/** The model served by this endpoint. */
|
|
18
18
|
export const GROK_MODEL = 'grok-imagine-image-2.0'
|
|
19
19
|
|
|
20
|
-
/**
|
|
20
|
+
/** Sizes understood by ChatGPT. */
|
|
21
21
|
export const SIZES = ['1024x1024', '1024x1536', '1536x1024', 'auto']
|
|
22
22
|
|
|
23
|
-
/** Grok
|
|
23
|
+
/** Grok thinks in aspect ratios, not sizes. */
|
|
24
24
|
const GROK_ASPECT = {
|
|
25
25
|
'1024x1024': '1:1',
|
|
26
26
|
'1024x1536': '2:3',
|
|
@@ -30,7 +30,7 @@ const GROK_ASPECT = {
|
|
|
30
30
|
|
|
31
31
|
export function codexBody({ prompt, size, quality }) {
|
|
32
32
|
const text = String(prompt || '').trim()
|
|
33
|
-
if (!text) throw new Error('
|
|
33
|
+
if (!text) throw new Error('prompt must be a non-empty string')
|
|
34
34
|
return {
|
|
35
35
|
prompt: text,
|
|
36
36
|
model: CODEX_MODEL,
|
|
@@ -41,8 +41,8 @@ export function codexBody({ prompt, size, quality }) {
|
|
|
41
41
|
|
|
42
42
|
export function grokBody({ prompt, size, quality }) {
|
|
43
43
|
const text = String(prompt || '').trim()
|
|
44
|
-
if (!text) throw new Error('
|
|
45
|
-
//
|
|
44
|
+
if (!text) throw new Error('prompt must be a non-empty string')
|
|
45
|
+
// Grok has only two quality tiers: high is composed from medium.
|
|
46
46
|
const level = quality === 'low' ? 'low'
|
|
47
47
|
: (quality === 'medium' || quality === 'high') ? 'medium'
|
|
48
48
|
: undefined
|
|
@@ -55,7 +55,7 @@ export function grokBody({ prompt, size, quality }) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/** Response parsing: both sides reply in the same shape. */
|
|
59
59
|
export function parseImages(payload) {
|
|
60
60
|
const body = payload && typeof payload === 'object' ? payload : {}
|
|
61
61
|
const rows = Array.isArray(body.data) ? body.data : []
|
|
@@ -70,15 +70,15 @@ export function parseImages(payload) {
|
|
|
70
70
|
: {}),
|
|
71
71
|
})
|
|
72
72
|
}
|
|
73
|
-
if (!images.length) throw new Error('
|
|
73
|
+
if (!images.length) throw new Error('response contains no images')
|
|
74
74
|
return images
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
/**
|
|
78
|
-
*
|
|
78
|
+
* One request to the right endpoint with this provider's headers.
|
|
79
79
|
*
|
|
80
80
|
* @param options {{provider, prompt, size, quality, session, fetchImpl, signal}}
|
|
81
|
-
* session
|
|
81
|
+
* session - already refreshed token block: accessToken and, for ChatGPT, accountId.
|
|
82
82
|
*/
|
|
83
83
|
export async function generateOnce(options) {
|
|
84
84
|
const { provider, session, fetchImpl, signal } = options
|
|
@@ -88,7 +88,7 @@ export async function generateOnce(options) {
|
|
|
88
88
|
const headers = isCodex
|
|
89
89
|
? {
|
|
90
90
|
authorization: `Bearer ${session.accessToken}`,
|
|
91
|
-
// ChatGPT
|
|
91
|
+
// ChatGPT distinguishes accounts with a separate header; without it the request is rejected.
|
|
92
92
|
'chatgpt-account-id': session.accountId || '',
|
|
93
93
|
originator: 'codex_cli_rs',
|
|
94
94
|
'content-type': 'application/json',
|
package/lib/index.js
CHANGED
|
@@ -61,8 +61,8 @@ export function apply(ctx, config) {
|
|
|
61
61
|
})
|
|
62
62
|
})
|
|
63
63
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
64
|
+
// Registering custom vendors from Config. Runs at startup and on
|
|
65
|
+
// every config change (replace) - the registry is rebuilt from scratch.
|
|
66
66
|
function syncCustomVendors() {
|
|
67
67
|
let profiles
|
|
68
68
|
try {
|
|
@@ -104,7 +104,7 @@ export function apply(ctx, config) {
|
|
|
104
104
|
onLimitNotice: (provider, ref, win, threshold) => {
|
|
105
105
|
if (!live().notifyLimits) return
|
|
106
106
|
const label = win.ru || win.en || win.id
|
|
107
|
-
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}:
|
|
107
|
+
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider} ${ref}: limit ${label} at ${threshold}%`) } catch {}
|
|
108
108
|
try { ctx.emit && ctx.emit('subscriptions.limit-notice', { provider, ref, window: win.id, usedPercent: win.usedPercent, threshold }) } catch {}
|
|
109
109
|
},
|
|
110
110
|
})
|
|
@@ -141,7 +141,7 @@ export function apply(ctx, config) {
|
|
|
141
141
|
if (!cfg.ollamaFallback || !models.length) throw err
|
|
142
142
|
const model = cfg.ollamaFallbackModel || models[0].id
|
|
143
143
|
try { ctx.emit && ctx.emit('subscriptions.ollama-fallback', { provider, model, reason: err && err.code || 'EXHAUSTED' }) } catch {}
|
|
144
|
-
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}:
|
|
144
|
+
try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: all accounts exhausted (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
|
|
145
145
|
try {
|
|
146
146
|
recordHistory({
|
|
147
147
|
provider: 'ollama',
|
|
@@ -181,15 +181,15 @@ export function apply(ctx, config) {
|
|
|
181
181
|
hideDeprecatedModels: () => !!live().hideDeprecatedModels,
|
|
182
182
|
})
|
|
183
183
|
|
|
184
|
-
//
|
|
184
|
+
// Subscription-backed image generation service.
|
|
185
185
|
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
186
|
+
// An action is exposed, not a token: a network route would hand a live
|
|
187
|
+
// access key to anyone reaching the harness, while the service lives inside
|
|
188
|
+
// the process and is visible only to sibling plugins. Token refresh stays
|
|
189
|
+
// with a single owner - this plugin.
|
|
190
190
|
ctx.effect(() => ctx.provide('subscriptions', subscriptions), 'dsh-subscriptions: subscriptions service')
|
|
191
191
|
ctx.effect(() => ctx.provide('subscriptionImages', {
|
|
192
|
-
/**
|
|
192
|
+
/** Providers with an active session right now. */
|
|
193
193
|
async available() {
|
|
194
194
|
const logged = await store.loggedInProviders()
|
|
195
195
|
return ['codex', 'grok'].filter((name) => logged && logged[name])
|
|
@@ -202,13 +202,13 @@ export function apply(ctx, config) {
|
|
|
202
202
|
async generate(request) {
|
|
203
203
|
const provider = request && request.provider
|
|
204
204
|
if (provider !== 'codex' && provider !== 'grok') {
|
|
205
|
-
throw new Error(
|
|
205
|
+
throw new Error(`unknown subscription provider: ${provider}`)
|
|
206
206
|
}
|
|
207
207
|
const accounts = await store.listAccounts(provider)
|
|
208
208
|
const slot = (accounts || []).find((row) => row && row.ref)
|
|
209
|
-
if (!slot) throw new Error(
|
|
209
|
+
if (!slot) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
|
210
210
|
const raw = await store.resolveRaw(slot.ref)
|
|
211
|
-
if (!raw) throw new Error(
|
|
211
|
+
if (!raw) throw new Error(`not logged in to ${provider}: sign in in the Subscriptions section`)
|
|
212
212
|
const session = await store.ensureFresh(provider, parseBlob(raw), slot.ref)
|
|
213
213
|
return generateOnce({
|
|
214
214
|
provider,
|
|
@@ -220,7 +220,7 @@ export function apply(ctx, config) {
|
|
|
220
220
|
signal: request.signal,
|
|
221
221
|
})
|
|
222
222
|
},
|
|
223
|
-
}), 'dsh-subscriptions:
|
|
223
|
+
}), 'dsh-subscriptions: image generation service')
|
|
224
224
|
const pending = new Map()
|
|
225
225
|
const adapter = new SubscriptionAdapter({
|
|
226
226
|
listAccounts: (provider) => store.listAccounts(provider),
|
|
@@ -466,8 +466,8 @@ export function apply(ctx, config) {
|
|
|
466
466
|
ctx.effect(() => {
|
|
467
467
|
syncAdapter().catch(() => { /* first paint */ })
|
|
468
468
|
syncOllama().catch(() => { /* first paint */ })
|
|
469
|
-
// #75: eager refresh
|
|
470
|
-
//
|
|
469
|
+
// #75: eager usage refresh at startup so the windows (5h/7d) land in the blob immediately
|
|
470
|
+
// and the active subscription chip shows 5h/7d/... right away instead of waiting for probeInterval.
|
|
471
471
|
const eager = async () => {
|
|
472
472
|
try {
|
|
473
473
|
for (const slot of normalizeSlots(live().slots)) {
|
|
@@ -516,9 +516,9 @@ export function apply(ctx, config) {
|
|
|
516
516
|
return () => clear(id)
|
|
517
517
|
}, 'dsh-subscriptions: refresh ahead')
|
|
518
518
|
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
519
|
+
// Background health-check: every N minutes runs a cheap check across all
|
|
520
|
+
// connected accounts. Dead ones are flagged via describeRef, no cooldown
|
|
521
|
+
// is set (same as /check).
|
|
522
522
|
ctx.effect(() => {
|
|
523
523
|
const tick = async () => {
|
|
524
524
|
const cfg = live()
|
package/lib/loopback.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
import http from 'node:http'
|
|
2
2
|
|
|
3
|
-
// #89:
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// code/state,
|
|
3
|
+
// #89: temporary loopback server to catch the OAuth callback.
|
|
4
|
+
// Listens on the redirect_uri registered with the provider (vendor-fixed
|
|
5
|
+
// port: codex localhost:1455, grok 127.0.0.1:56121). Accepts a GET with
|
|
6
|
+
// code/state, serves an HTML page and shuts down after the first request.
|
|
7
7
|
|
|
8
8
|
const OK_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Signed in. You can close this tab and return to Settings.</p>'
|
|
9
9
|
const ERR_HTML = '<!doctype html><meta charset="utf-8"><title>Subscriptions</title><p>Login failed. Return to Settings and paste the redirected URL.</p>'
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
12
|
+
* Start the loopback server and wait for the callback.
|
|
13
13
|
* @param {object} opts
|
|
14
|
-
* @param {string} opts.redirectUri
|
|
15
|
-
* @param {number} opts.timeoutMs
|
|
14
|
+
* @param {string} opts.redirectUri registered redirect_uri (localhost/127.0.0.1)
|
|
15
|
+
* @param {number} opts.timeoutMs server lifetime
|
|
16
16
|
* @param {(query: URLSearchParams) => Promise<string>} opts.onCode
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* called with the callback request query; returns HTML for the browser.
|
|
18
|
+
* Throwing means an authorization error (ERR_HTML is served).
|
|
19
19
|
* @returns {Promise<{url: string, close: () => void}>}
|
|
20
20
|
*/
|
|
21
21
|
export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode }) {
|
|
@@ -30,7 +30,7 @@ export function startLoopback({ redirectUri, timeoutMs = 10 * 60 * 1000, onCode
|
|
|
30
30
|
const promise = new Promise((resolve, reject) => {
|
|
31
31
|
const server = http.createServer((req, res) => {
|
|
32
32
|
const url = new URL(req.url || '/', `http://127.0.0.1:${port}`)
|
|
33
|
-
//
|
|
33
|
+
// The provider may redirect to a suffixed path (e.g. /auth/callback/extra)
|
|
34
34
|
if (!url.pathname.startsWith(path)) {
|
|
35
35
|
res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
36
36
|
res.end(ERR_HTML)
|
package/lib/ratelimit.js
CHANGED
|
@@ -212,7 +212,7 @@ export function formatQuota(q) {
|
|
|
212
212
|
if (!q) return ""
|
|
213
213
|
const parts = []
|
|
214
214
|
if (q.remaining!=null && q.limit!=null) parts.push(q.remaining+"/"+q.limit)
|
|
215
|
-
else if (q.remaining!=null) parts.push("
|
|
216
|
-
if (q.resetAt) parts.push("
|
|
215
|
+
else if (q.remaining!=null) parts.push("left "+q.remaining)
|
|
216
|
+
if (q.resetAt) parts.push("reset "+new Date(q.resetAt).toLocaleString())
|
|
217
217
|
return parts.join(" · ")
|
|
218
218
|
}
|
package/lib/refs.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Built-in providers + dynamic ones from Config.customVendors.
|
|
2
2
|
export const BUILTIN_PROVIDERS = Object.freeze([
|
|
3
3
|
'codex',
|
|
4
4
|
'claude',
|
|
@@ -8,6 +8,14 @@ export const BUILTIN_PROVIDERS = Object.freeze([
|
|
|
8
8
|
'glm',
|
|
9
9
|
'cursor',
|
|
10
10
|
'kiro',
|
|
11
|
+
'copilot',
|
|
12
|
+
'qwen',
|
|
13
|
+
'ernie',
|
|
14
|
+
'spark',
|
|
15
|
+
'jetbrains',
|
|
16
|
+
'perplexity',
|
|
17
|
+
'replit',
|
|
18
|
+
'cody',
|
|
11
19
|
])
|
|
12
20
|
|
|
13
21
|
const dynamicIds = new Set()
|