@goodandready/dsh-subscriptions 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GooDAnDReaDY
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # dsh-subscriptions
2
+
3
+ Use **ChatGPT Codex**, **Claude**, **Grok**, **Antigravity**
4
+ subscriptions as DeepSeek Harness LLM providers. Log in from
5
+ **Settings → Subscriptions**. Several accounts per provider rotate on quota
6
+ inside this plugin.
7
+
8
+ This package is original software. It speaks vendor-public OAuth and LLM HTTP
9
+ contracts. It does not ship tools (`x_search`, image, or video).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ dsh plugin --profile web add @goodandready/dsh-subscriptions
15
+ ```
16
+
17
+ Local checkout (development):
18
+
19
+ ```bash
20
+ dsh plugin --profile web add file:/path/to/dsh-subscriptions
21
+ ```
22
+
23
+ After a `file:` install, remove then add again if you added new files under
24
+ `lib/` — pnpm reuses the previous copy otherwise.
25
+
26
+ ## Settings
27
+
28
+ 1. Open **Settings → Subscriptions**.
29
+ 2. Pick a provider card and click **Connect**. Complete sign-in in the browser.
30
+ 3. **Disconnect** removes that account's token so you can connect again. **Reconnect** repeats OAuth on the same slot. The × on the card also disconnects, then drops the slot.
31
+ 4. If the provider redirects to a localhost or vendor URL that this host cannot
32
+ receive, paste the full redirected URL (or the `code` value) into the account
33
+ row and click **Submit code**.
34
+ 5. Logged-in providers appear in the session model picker.
35
+
36
+ Leave **Use this Web UI origin as OAuth redirect_uri** off unless you registered
37
+ your own OAuth client for this origin. Vendor CLI clients typically require
38
+ their published redirect URI plus the paste step.
39
+
40
+ Disconnect removes that account's blob from the host credentials store.
41
+
42
+ ## Credential names
43
+
44
+ Tokens are JSON blobs in the DSH credentials store. Names look like:
45
+
46
+ ```text
47
+ CODEX_OAUTH_1
48
+ CLAUDE_OAUTH_2
49
+ ```
50
+
51
+ `<PROVIDER>` is `CODEX`, `CLAUDE`, `GROK`, or `ANTIGRAVITY`.
52
+ Settings GET never returns access or refresh tokens — only
53
+ `{ configured, label, usagePercent, cooldownUntil, ref }`.
54
+
55
+ ## Providers
56
+
57
+ | Key | Subscription | Default OAuth client |
58
+ |---|---|---|
59
+ | `codex` | ChatGPT / Codex | Vendor-public Codex CLI client id |
60
+ | `claude` | Claude Pro/Max | Vendor-public Claude Code client id |
61
+ | `grok` | xAI / SuperGrok | Vendor-public Grok CLI client id |
62
+ | `antigravity` | Google Antigravity | Vendor-public Antigravity client id |
63
+
64
+ Override `codexClientId`, `claudeClientId`, and the other empty Config fields
65
+ if you register your own OAuth app.
66
+
67
+ Live requests use the vendor subscription surfaces, not API-key hosts:
68
+ Codex `chatgpt.com/backend-api/codex/responses`, Claude Messages with the
69
+ OAuth beta header, Grok `cli-chat-proxy.grok.com` with CLI identity headers,
70
+ and Gemini/Antigravity Cloud Code Assist (`loadCodeAssist` then
71
+ `streamGenerateContent`). Usage endpoints, when they answer, feed the 100%
72
+ skip. If a live model list fails, the built-in catalog is used.
73
+ Installed-app Google client secrets are
74
+ vendor-published (not user secrets); override `geminiClientSecret` /
75
+ `antigravityClientSecret` when you use your own client.
76
+
77
+ Default model catalogs are built-in lists you can replace with
78
+ `codexModels`, `claudeModels`, `grokModels`, `geminiModels`,
79
+ `antigravityModels` in the plugin Config.
80
+
81
+ ## Rotation
82
+
83
+ On `RATE_LIMIT`, `QUOTA`, or HTTP 429 the plugin cools that account down
84
+ (default 30 minutes) and retries the **same provider** on the next account.
85
+ It never switches to a different provider. Accounts at 100% usage (when the
86
+ vendor reports usage) are skipped.
87
+
88
+ This plugin does not call `dsh-key-rotation`.
89
+
90
+ ## Identity
91
+
92
+ These three names must match:
93
+
94
+ | Place | Value |
95
+ |---|---|
96
+ | `package.json` `name` | `@goodandready/dsh-subscriptions` |
97
+ | `cordis.patch.yml` `name:` | `@goodandready/dsh-subscriptions` |
98
+ | `lib/client.js` loader `id` | `@goodandready/dsh-subscriptions` |
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,8 @@
1
+ # dsh-subscriptions bundle layer.
2
+ #
3
+ # `name` must stay the full npm package name: the client-modules registry
4
+ # resolves the browser bundle by the loader entry name.
5
+ - insert:
6
+ - id: dsh-subscriptions
7
+ name: '@goodandready/dsh-subscriptions'
8
+ config: {}
@@ -0,0 +1,198 @@
1
+ import { credentialRef } from '@deepseek-ai/dsh-credentials'
2
+ import { oauthRef, isProvider } from './refs.js'
3
+ import { parseBlob, serializeBlob } from './blob.js'
4
+ import { getVendor } from './vendors/index.js'
5
+
6
+ const SKEW_MS = 60 * 1000
7
+ const USAGE_TTL_MS = 2 * 60 * 1000
8
+
9
+ export function normalizeSlots(slots) {
10
+ const out = []
11
+ const seen = new Set()
12
+ for (const slot of Array.isArray(slots) ? slots : []) {
13
+ if (!isProvider(slot.provider)) continue
14
+ const index = Number(slot.index)
15
+ if (!Number.isInteger(index) || index < 1) continue
16
+ const ref = oauthRef(slot.provider, index)
17
+ if (seen.has(ref)) continue
18
+ seen.add(ref)
19
+ out.push({
20
+ provider: slot.provider,
21
+ index,
22
+ label: String(slot.label || ''),
23
+ ref,
24
+ })
25
+ }
26
+ return out
27
+ }
28
+
29
+ export function vendorConfig(provider, cfg) {
30
+ const d = getVendor(provider).defaults()
31
+ const pick = (suffix, fallback) => {
32
+ const value = cfg && cfg[`${provider}${suffix}`]
33
+ if (value == null || String(value).trim() === '') return fallback
34
+ return String(value).trim()
35
+ }
36
+ const modelsKey = `${provider}Models`
37
+ return {
38
+ clientId: pick('ClientId', d.clientId),
39
+ clientSecret: pick('ClientSecret', d.clientSecret || ''),
40
+ redirectUri: pick('RedirectUri', d.redirectUri),
41
+ baseUrl: pick('BaseUrl', d.baseUrl || ''),
42
+ originator: pick('Originator', d.originator || ''),
43
+ systemPrefix: pick('SystemPrefix', d.systemPrefix || ''),
44
+ clientVersion: pick('ClientVersion', d.clientVersion || ''),
45
+ models: Array.isArray(cfg && cfg[modelsKey]) && cfg[modelsKey].length
46
+ ? cfg[modelsKey]
47
+ : (d.models || []),
48
+ }
49
+ }
50
+
51
+ export function createAccountStore({ credentials, getConfig, fetchImpl }) {
52
+ const cooldowns = new Map()
53
+ const usage = new Map()
54
+ const usageFetched = new Map()
55
+ const doFetch = fetchImpl || fetch
56
+
57
+ async function resolveRaw(ref) {
58
+ try {
59
+ const resolved = await credentials.resolve(credentialRef(ref))
60
+ return resolved && resolved.value ? String(resolved.value) : ''
61
+ } catch {
62
+ return ''
63
+ }
64
+ }
65
+
66
+ async function loadBlob(ref) {
67
+ const raw = await resolveRaw(ref)
68
+ if (!raw) {
69
+ const err = new Error(`no token for ${ref}`)
70
+ err.code = 'AUTH'
71
+ throw err
72
+ }
73
+ return parseBlob(raw)
74
+ }
75
+
76
+ async function saveBlob(ref, blob) {
77
+ await credentials.set(credentialRef(ref), serializeBlob(blob))
78
+ }
79
+
80
+ async function clearRef(ref) {
81
+ await credentials.unset(credentialRef(ref))
82
+ cooldowns.delete(ref)
83
+ usage.delete(ref)
84
+ usageFetched.delete(ref)
85
+ }
86
+
87
+ async function describeRef(ref) {
88
+ const base = { ref, configured: false, writable: true, label: '', email: '' }
89
+ try {
90
+ if (typeof credentials.describe === 'function') {
91
+ const d = await credentials.describe(credentialRef(ref))
92
+ base.configured = !!(d && d.configured)
93
+ base.writable = d && d.writable === false ? false : true
94
+ } else {
95
+ base.configured = !!(await resolveRaw(ref))
96
+ }
97
+ } catch {
98
+ return base
99
+ }
100
+ if (base.configured) {
101
+ try {
102
+ const blob = parseBlob(await resolveRaw(ref))
103
+ base.label = blob.label || blob.email || ''
104
+ base.email = blob.email || ''
105
+ if (blob.validationUrl) base.validationUrl = blob.validationUrl
106
+ if (blob.validationMessage) base.validationMessage = blob.validationMessage
107
+ if (blob.accountNotice) base.accountNotice = blob.accountNotice
108
+ if (blob.paidTierName) base.paidTierName = blob.paidTierName
109
+ } catch { /* ignore parse */ }
110
+ }
111
+ return {
112
+ ...base,
113
+ cooldownUntil: cooldowns.get(ref) || 0,
114
+ usagePercent: usage.has(ref) ? usage.get(ref) : null,
115
+ validationUrl: base.validationUrl || '',
116
+ validationMessage: base.validationMessage || '',
117
+ accountNotice: base.accountNotice || '',
118
+ paidTierName: base.paidTierName || '',
119
+ }
120
+ }
121
+
122
+ async function listAccounts(provider) {
123
+ const slots = normalizeSlots(getConfig().slots).filter((s) => s.provider === provider)
124
+ const out = []
125
+ for (const slot of slots) {
126
+ const info = await describeRef(slot.ref)
127
+ out.push({
128
+ ref: slot.ref,
129
+ hasToken: !!info.configured,
130
+ usagePercent: info.usagePercent,
131
+ cooldownUntil: info.cooldownUntil,
132
+ label: slot.label || info.label,
133
+ })
134
+ }
135
+ return out
136
+ }
137
+
138
+ async function loggedInProviders() {
139
+ const found = new Set()
140
+ for (const slot of normalizeSlots(getConfig().slots)) {
141
+ const info = await describeRef(slot.ref)
142
+ if (info.configured) found.add(slot.provider)
143
+ }
144
+ return [...found]
145
+ }
146
+
147
+ async function ensureFresh(provider, blob, ref) {
148
+ if (!blob.refreshToken) return blob
149
+ if (blob.expiresAt && blob.expiresAt - SKEW_MS > Date.now()) return blob
150
+ const cfg = vendorConfig(provider, getConfig())
151
+ const next = await getVendor(provider).refresh(cfg, blob, doFetch)
152
+ const merged = {
153
+ ...blob,
154
+ ...next,
155
+ refreshToken: next.refreshToken || blob.refreshToken,
156
+ projectId: next.projectId || blob.projectId,
157
+ accountId: next.accountId || blob.accountId,
158
+ }
159
+ if (ref) await saveBlob(ref, merged)
160
+ return merged
161
+ }
162
+
163
+ async function refreshUsage(provider) {
164
+ const cfg = vendorConfig(provider, getConfig())
165
+ const vendor = getVendor(provider)
166
+ for (const slot of normalizeSlots(getConfig().slots).filter((s) => s.provider === provider)) {
167
+ const last = usageFetched.get(slot.ref) || 0
168
+ if (Date.now() - last < USAGE_TTL_MS) continue
169
+ let raw = ''
170
+ try { raw = await resolveRaw(slot.ref) } catch { continue }
171
+ if (!raw) continue
172
+ try {
173
+ const blob = await ensureFresh(provider, parseBlob(raw), slot.ref)
174
+ const snap = await vendor.usage(blob, cfg, doFetch)
175
+ usageFetched.set(slot.ref, Date.now())
176
+ if (snap && Number.isFinite(Number(snap.usedPercent))) {
177
+ usage.set(slot.ref, Number(snap.usedPercent))
178
+ }
179
+ } catch {
180
+ usageFetched.set(slot.ref, Date.now())
181
+ }
182
+ }
183
+ }
184
+
185
+ return {
186
+ loadBlob,
187
+ saveBlob,
188
+ clearRef,
189
+ describeRef,
190
+ listAccounts,
191
+ loggedInProviders,
192
+ resolveRaw,
193
+ ensureFresh,
194
+ refreshUsage,
195
+ rememberCooldown(ref, until) { cooldowns.set(ref, until) },
196
+ rememberUsage(ref, percent) { usage.set(ref, percent) },
197
+ }
198
+ }
package/lib/adapter.js ADDED
@@ -0,0 +1,110 @@
1
+ import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
2
+ import { displayName } from './refs.js'
3
+ import { getVendor } from './vendors/index.js'
4
+ import { modelCatalog } from './messages.js'
5
+ import { streamWithRotation } from './stream-rotate.js'
6
+
7
+ function asLlmError(err) {
8
+ if (err instanceof LlmError) return err
9
+ const code = (err && err.code) || 'VENDOR'
10
+ const message = String((err && err.message) || err || 'vendor error')
11
+ try {
12
+ const out = new LlmError(message, code)
13
+ if (err && err.validationUrl) out.validationUrl = err.validationUrl
14
+ return out
15
+ } catch {
16
+ return err
17
+ }
18
+ }
19
+
20
+ export class SubscriptionAdapter extends LlmAdapter {
21
+ constructor(deps) {
22
+ super()
23
+ this.deps = deps
24
+ }
25
+
26
+ providerInfo(provider) {
27
+ return { id: provider, name: displayName(provider) }
28
+ }
29
+
30
+ providerRetryPolicy(_provider) {
31
+ return undefined
32
+ }
33
+
34
+ async listModels(provider) {
35
+ const accounts = await this.deps.listAccounts(provider)
36
+ if (!accounts.some((a) => a.hasToken)) return []
37
+ const cfg = this.deps.vendorConfig(provider)
38
+ const fetchImpl = this.deps.fetchImpl || fetch
39
+ try {
40
+ const blob = await this.deps.ensureFresh(
41
+ provider,
42
+ await this.deps.loadBlob(accounts.find((a) => a.hasToken).ref),
43
+ accounts.find((a) => a.hasToken).ref,
44
+ )
45
+ const models = await getVendor(provider).listModels(blob, cfg, fetchImpl)
46
+ if (Array.isArray(models) && models.length) return models
47
+ } catch { /* use built-in catalog */ }
48
+ return modelCatalog(provider, cfg.models)
49
+ }
50
+
51
+ async resolveModel(provider, model, _signal) {
52
+ const rows = await this.listModels(provider)
53
+ const found = rows.find((row) => row && row.id === model)
54
+ if (found) {
55
+ return {
56
+ provider,
57
+ id: model,
58
+ name: found.name || model,
59
+ ...(found.description ? { description: found.description } : {}),
60
+ ...(found.inputModalities ? { inputModalities: found.inputModalities } : {}),
61
+ ...(found.contextWindow ? { context: { contextWindow: found.contextWindow } } : {}),
62
+ ...(found.reasoning ? { reasoning: found.reasoning } : {}),
63
+ }
64
+ }
65
+ return { provider, id: model, name: model }
66
+ }
67
+
68
+ async *stream(options) {
69
+ const provider = options.provider
70
+ const deps = this.deps
71
+ try {
72
+ if (typeof deps.refreshUsage === 'function') {
73
+ await deps.refreshUsage(provider)
74
+ }
75
+ yield* streamWithRotation({
76
+ accounts: await deps.listAccounts(provider),
77
+ nowMs: () => Date.now(),
78
+ cooldownMs: deps.cooldownMs(),
79
+ options,
80
+ onCooldown: (account) => deps.rememberCooldown(account.ref, account.cooldownUntil),
81
+ streamOnce: async function* (account, opts) {
82
+ const blob = await deps.ensureFresh(provider, await deps.loadBlob(account.ref), account.ref)
83
+ const vendor = getVendor(provider)
84
+ try {
85
+ yield* vendor.streamOnce({
86
+ blob,
87
+ options: opts,
88
+ fetchImpl: deps.fetchImpl || fetch,
89
+ headers: attributionHeaders(),
90
+ config: deps.vendorConfig(provider),
91
+ signal: opts.signal,
92
+ saveBlob: (next) => deps.saveBlob(account.ref, next),
93
+ })
94
+ } catch (err) {
95
+ if (err && err.code === 'VALIDATION_REQUIRED' && err.validationUrl) {
96
+ await deps.saveBlob(account.ref, {
97
+ ...blob,
98
+ validationUrl: err.validationUrl,
99
+ validationMessage: String(err.message || ''),
100
+ })
101
+ }
102
+ throw err
103
+ }
104
+ },
105
+ })
106
+ } catch (err) {
107
+ throw asLlmError(err)
108
+ }
109
+ }
110
+ }
package/lib/blob.js ADDED
@@ -0,0 +1,43 @@
1
+ function asString(value) {
2
+ return value == null ? '' : String(value)
3
+ }
4
+
5
+ export function serializeBlob(obj) {
6
+ const accessToken = asString(obj && obj.accessToken)
7
+ const refreshToken = asString(obj && obj.refreshToken)
8
+ if (!accessToken && !refreshToken) {
9
+ throw new Error('oauth blob needs accessToken or refreshToken')
10
+ }
11
+ return JSON.stringify({
12
+ accessToken,
13
+ refreshToken,
14
+ expiresAt: Number(obj && obj.expiresAt) || 0,
15
+ label: asString(obj && obj.label),
16
+ email: asString(obj && obj.email),
17
+ accountId: asString(obj && obj.accountId),
18
+ projectId: asString(obj && obj.projectId),
19
+ })
20
+ }
21
+
22
+ export function parseBlob(text) {
23
+ const obj = typeof text === 'string' ? JSON.parse(text) : text
24
+ if (!obj || typeof obj !== 'object') throw new Error('invalid oauth blob')
25
+ return {
26
+ accessToken: asString(obj.accessToken),
27
+ refreshToken: asString(obj.refreshToken),
28
+ expiresAt: Number(obj.expiresAt) || 0,
29
+ label: asString(obj.label),
30
+ email: asString(obj.email),
31
+ accountId: asString(obj.accountId),
32
+ projectId: asString(obj.projectId),
33
+ }
34
+ }
35
+
36
+ export function publicAccountView(blob, extra) {
37
+ const label = (blob && (blob.label || blob.email)) || ''
38
+ return {
39
+ label,
40
+ email: (blob && blob.email) || '',
41
+ ...(extra || {}),
42
+ }
43
+ }