@goodandready/dsh-subscriptions 0.6.91 → 0.7.3
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/bin/dsh-subscriptions.mjs +65 -0
- package/lib/adapter-manager.js +3 -0
- package/lib/adapter.js +10 -2
- package/lib/alerts.js +54 -22
- package/lib/client.js +283 -9
- package/lib/coexistence.js +45 -0
- package/lib/config-schema.js +22 -0
- package/lib/index.js +24 -1
- package/lib/messages.js +1 -1
- package/lib/model-settings.js +49 -0
- package/lib/native-tools.js +220 -0
- package/lib/reset-clock.js +16 -6
- package/lib/route-report.js +28 -0
- package/lib/routes/status.js +88 -2
- package/lib/speed-mode.js +17 -0
- package/lib/stream-rotate.js +10 -2
- package/lib/subscriptions.js +1 -1
- package/lib/usage-export.js +52 -0
- package/lib/vault-sync.js +53 -15
- package/lib/vendor-pack.js +36 -0
- package/overlay.example.yml +8 -0
- package/package.json +33 -2
package/lib/index.js
CHANGED
|
@@ -12,8 +12,11 @@ import { getVendor, registerCustomVendor, clearCustomVendors } from './vendors/i
|
|
|
12
12
|
import { generateOnce, SIZES as IMAGE_SIZES } from './images.js'
|
|
13
13
|
import { parseBlob } from './blob.js'
|
|
14
14
|
import { createVendorFromProfile, validateProfiles } from './vendor-factory.js'
|
|
15
|
+
import { registerVendorPack } from './vendor-pack.js'
|
|
15
16
|
import { registerCustomProviderIds, registerDisplayName } from './refs.js'
|
|
16
17
|
import { createSubscriptionsService } from './subscriptions.js'
|
|
18
|
+
import { registerSubscriptionTools } from './native-tools.js'
|
|
19
|
+
import { detectConflicts, readProfilePluginNames, setActiveConflicts } from './coexistence.js'
|
|
17
20
|
import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
|
|
18
21
|
import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
|
|
19
22
|
import { createResetCreditService } from './reset-credits.js'
|
|
@@ -39,7 +42,7 @@ export { pushToWebdav, pullFromWebdav, pushToGist, pullFromGist, buildS3SigV4Hea
|
|
|
39
42
|
import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from './vault-sync.js'
|
|
40
43
|
|
|
41
44
|
export const name = '@goodandready/dsh-subscriptions'
|
|
42
|
-
export const inject = ['llm', 'credentials', 'webServer', 'settings']
|
|
45
|
+
export const inject = ['llm', 'credentials', 'webServer', 'settings', 'tools']
|
|
43
46
|
|
|
44
47
|
const NS = 'dsh-subscriptions'
|
|
45
48
|
|
|
@@ -75,6 +78,11 @@ const OK_HTML = `<!doctype html>
|
|
|
75
78
|
|
|
76
79
|
export function apply(ctx, rawConfig) {
|
|
77
80
|
const logger = typeof ctx.logger === 'function' ? ctx.logger('subscriptions') : (ctx.logger || undefined)
|
|
81
|
+
const conflicts = detectConflicts({ dependencies: readProfilePluginNames() })
|
|
82
|
+
setActiveConflicts(conflicts)
|
|
83
|
+
if (conflicts.length && logger && logger.warn) {
|
|
84
|
+
logger.warn('[dsh-subscriptions] conflicting subscription plugins detected: ' + conflicts.join(', ') + '; keep one provider owner')
|
|
85
|
+
}
|
|
78
86
|
|
|
79
87
|
// rawConfig still holds Volatile boxes, so unwrap before validating.
|
|
80
88
|
const resolveEntry = () => {
|
|
@@ -98,6 +106,17 @@ export function apply(ctx, rawConfig) {
|
|
|
98
106
|
if (logger && logger.warn) logger.warn('[dsh-subscriptions] customVendors[' + profile.id + ']: ' + String(e && e.message || e))
|
|
99
107
|
}
|
|
100
108
|
}
|
|
109
|
+
for (const pack of (live().providerPacks || [])) {
|
|
110
|
+
try {
|
|
111
|
+
registerVendorPack(pack, {
|
|
112
|
+
registerVendor: registerCustomVendor,
|
|
113
|
+
registerProviderId: registerCustomProviderIds,
|
|
114
|
+
registerDisplayName,
|
|
115
|
+
})
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (logger && logger.warn) logger.warn('[dsh-subscriptions] providerPacks[' + (pack && pack.id) + ']: ' + String(e && e.message || e))
|
|
118
|
+
}
|
|
119
|
+
}
|
|
101
120
|
}
|
|
102
121
|
syncCustomVendors()
|
|
103
122
|
|
|
@@ -377,6 +396,10 @@ export function apply(ctx, rawConfig) {
|
|
|
377
396
|
})
|
|
378
397
|
},
|
|
379
398
|
}), 'dsh-subscriptions: image generation service')
|
|
399
|
+
ctx.effect(() => registerSubscriptionTools(ctx, {
|
|
400
|
+
subscriptions,
|
|
401
|
+
images: () => ctx.subscriptionImages,
|
|
402
|
+
}), 'dsh-subscriptions: native tools')
|
|
380
403
|
|
|
381
404
|
const pending = new Map()
|
|
382
405
|
|
package/lib/messages.js
CHANGED
|
@@ -301,7 +301,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
|
|
|
301
301
|
...(vendorCfg && /^(low|medium|high)$/.test(String(vendorCfg.verbosity || ''))
|
|
302
302
|
? { text: { verbosity: String(vendorCfg.verbosity) } } : {}),
|
|
303
303
|
// #92: Fast Mode = 1.5x speed billing tier on the Codex backend.
|
|
304
|
-
...(vendorCfg && vendorCfg.fastMode ? { service_tier: 'priority' } : {}),
|
|
304
|
+
...((vendorCfg && vendorCfg.fastMode) || options.service_tier === 'priority' ? { service_tier: 'priority' } : {}),
|
|
305
305
|
...(options.maxTokens != null ? { max_output_tokens: options.maxTokens } : {}),
|
|
306
306
|
...(options.temperature != null ? { temperature: options.temperature } : {}),
|
|
307
307
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
function rowsFor(rows, provider) {
|
|
2
|
+
return (Array.isArray(rows) ? rows : []).filter((row) => row && row.provider === provider)
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
const PLAN_RANK = { free: 0, plus: 1, pro: 2, max: 3, team: 3, enterprise: 4 }
|
|
6
|
+
|
|
7
|
+
export function planRank(name) {
|
|
8
|
+
return PLAN_RANK[String(name || '').toLowerCase()] ?? 0
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function bestAccountPlan(accounts) {
|
|
12
|
+
let best = null
|
|
13
|
+
for (const account of accounts || []) {
|
|
14
|
+
const plan = account && (account.plan || account.paidTierName)
|
|
15
|
+
if (!plan) continue
|
|
16
|
+
if (!best || planRank(plan) > planRank(best)) best = String(plan)
|
|
17
|
+
}
|
|
18
|
+
return best
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function applyModelSettings(provider, models, cfg = {}, accountPlan = null) {
|
|
22
|
+
const list = Array.isArray(models) ? models : []
|
|
23
|
+
const hidden = new Set(rowsFor(cfg.hiddenModels, provider).map((row) => String(row.id)))
|
|
24
|
+
const plans = new Map(rowsFor(cfg.modelPlans, provider).map((row) => [String(row.id), String(row.minPlan || '')]))
|
|
25
|
+
const accountRank = accountPlan ? planRank(accountPlan) : null
|
|
26
|
+
const names = new Map(rowsFor(cfg.modelDisplayNames, provider).map((row) => [String(row.id), String(row.name)]))
|
|
27
|
+
const contexts = new Map(rowsFor(cfg.modelContextWindows, provider).map((row) => [String(row.id), Number(row.contextWindow)]))
|
|
28
|
+
const efforts = new Map(rowsFor(cfg.modelReasoningDefaults, provider).map((row) => [String(row.id), String(row.effort).toLowerCase()]))
|
|
29
|
+
return list
|
|
30
|
+
.filter((model) => {
|
|
31
|
+
if (!model || hidden.has(String(model.id))) return false
|
|
32
|
+
const minPlan = plans.get(String(model.id))
|
|
33
|
+
if (minPlan && accountRank != null && planRank(minPlan) > accountRank) return false
|
|
34
|
+
return true
|
|
35
|
+
})
|
|
36
|
+
.map((model) => {
|
|
37
|
+
const id = String(model.id)
|
|
38
|
+
const minPlan = plans.get(id)
|
|
39
|
+
const contextWindow = contexts.get(id)
|
|
40
|
+
const effort = efforts.get(id)
|
|
41
|
+
return {
|
|
42
|
+
...model,
|
|
43
|
+
name: names.get(id) || model.name || model.id,
|
|
44
|
+
...(Number.isFinite(contextWindow) && contextWindow > 0 ? { contextWindow } : {}),
|
|
45
|
+
...(effort ? { reasoning: { ...(model.reasoning || {}), defaultEffort: effort } } : {}),
|
|
46
|
+
...(minPlan ? { plan: minPlan, minPlan } : {}),
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
const TOOL_OUTPUT = {
|
|
5
|
+
schema: {
|
|
6
|
+
type: 'object',
|
|
7
|
+
additionalProperties: true,
|
|
8
|
+
properties: { text: { type: 'string' } },
|
|
9
|
+
required: ['text'],
|
|
10
|
+
},
|
|
11
|
+
render(_args, value) {
|
|
12
|
+
return [{ type: 'text', text: String((value && value.text) || '') }]
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function textResult(text) {
|
|
17
|
+
return { text: String(text) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function extractResponseText(payload) {
|
|
21
|
+
const body = payload && typeof payload === 'object' ? payload : {}
|
|
22
|
+
if (typeof body.output_text === 'string' && body.output_text) return body.output_text
|
|
23
|
+
if (typeof body.answer === 'string' && body.answer) return body.answer
|
|
24
|
+
if (Array.isArray(body.output)) {
|
|
25
|
+
const parts = []
|
|
26
|
+
for (const item of body.output) {
|
|
27
|
+
for (const content of (item && item.content) || []) {
|
|
28
|
+
if (content && typeof content.text === 'string') parts.push(content.text)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (parts.length) return parts.join('\n')
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(body.choices)) {
|
|
34
|
+
return body.choices.map((choice) => choice && choice.message && choice.message.content)
|
|
35
|
+
.filter((value) => typeof value === 'string').join('\n')
|
|
36
|
+
}
|
|
37
|
+
return JSON.stringify(body).slice(0, 4000)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function outputDir(exec) {
|
|
41
|
+
const cwd = (exec && exec.agent && exec.agent.session && exec.agent.session.header
|
|
42
|
+
&& exec.agent.session.header.cwd) || process.cwd()
|
|
43
|
+
return path.join(cwd, 'generated', 'subscriptions')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function saveImages(images, exec) {
|
|
47
|
+
const dir = outputDir(exec)
|
|
48
|
+
await mkdir(dir, { recursive: true })
|
|
49
|
+
const out = []
|
|
50
|
+
for (let i = 0; i < images.length; i += 1) {
|
|
51
|
+
const image = images[i]
|
|
52
|
+
if (!image || typeof image.b64_json !== 'string') continue
|
|
53
|
+
const file = path.join(dir, `image-${Date.now()}-${i}.png`)
|
|
54
|
+
await writeFile(file, Buffer.from(image.b64_json, 'base64'))
|
|
55
|
+
out.push(file)
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function registerSubscriptionTools(ctx, deps) {
|
|
61
|
+
const tools = ctx && ctx.tools
|
|
62
|
+
if (!tools || typeof tools.register !== 'function') return () => {}
|
|
63
|
+
const disposers = []
|
|
64
|
+
const register = (definition) => {
|
|
65
|
+
try {
|
|
66
|
+
const off = tools.register(definition)
|
|
67
|
+
if (typeof off === 'function') disposers.push(off)
|
|
68
|
+
} catch (err) {
|
|
69
|
+
// Another plugin may already own this tool name (for example x_search
|
|
70
|
+
// from a Grok search plugin or image_generate from an image plugin).
|
|
71
|
+
// Keep our remaining tools registered instead of failing the whole plugin.
|
|
72
|
+
if (!/already registered/i.test(String(err && err.message || err))) throw err
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
register({
|
|
77
|
+
name: 'image_generate',
|
|
78
|
+
description: 'Generate an image with the active ChatGPT/Codex or Grok subscription. Returns saved file paths.',
|
|
79
|
+
output: TOOL_OUTPUT,
|
|
80
|
+
parameters: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: {
|
|
83
|
+
prompt: { type: 'string', description: 'Image prompt' },
|
|
84
|
+
provider: { type: 'string', enum: ['gpt', 'codex', 'grok'], description: 'Preferred provider' },
|
|
85
|
+
size: { type: 'string', description: 'Image size, e.g. 1024x1024' },
|
|
86
|
+
quality: { type: 'string', description: 'low, medium or high' },
|
|
87
|
+
},
|
|
88
|
+
required: ['prompt'],
|
|
89
|
+
},
|
|
90
|
+
async execute(args, exec) {
|
|
91
|
+
const images = deps.images && deps.images()
|
|
92
|
+
if (!images || typeof images.generate !== 'function') throw new Error('subscription image service is unavailable')
|
|
93
|
+
const preferred = String((args && args.provider) || 'gpt').toLowerCase()
|
|
94
|
+
const order = preferred === 'grok' ? ['grok', 'codex'] : ['codex', 'grok']
|
|
95
|
+
let lastError = null
|
|
96
|
+
for (const provider of order) {
|
|
97
|
+
try {
|
|
98
|
+
const rows = await images.generate({
|
|
99
|
+
provider,
|
|
100
|
+
prompt: args && args.prompt,
|
|
101
|
+
size: args && args.size,
|
|
102
|
+
quality: args && args.quality,
|
|
103
|
+
signal: exec && exec.signal,
|
|
104
|
+
})
|
|
105
|
+
const files = await saveImages(rows, exec)
|
|
106
|
+
if (!files.length) throw new Error('image response contained no images')
|
|
107
|
+
return textResult(files.map((file) => `Generated image: ${file}`).join('\n'))
|
|
108
|
+
} catch (err) {
|
|
109
|
+
lastError = err
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
throw lastError || new Error('no subscription image provider is available')
|
|
113
|
+
},
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
register({
|
|
117
|
+
name: 'x_search',
|
|
118
|
+
description: 'Search X posts through the active Grok subscription and return an answer with citations.',
|
|
119
|
+
output: TOOL_OUTPUT,
|
|
120
|
+
parameters: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
properties: {
|
|
123
|
+
query: { type: 'string', description: 'Search query' },
|
|
124
|
+
model: { type: 'string', description: 'Optional Grok model id' },
|
|
125
|
+
},
|
|
126
|
+
required: ['query'],
|
|
127
|
+
},
|
|
128
|
+
async execute(args, exec) {
|
|
129
|
+
const res = await deps.subscriptions.request({
|
|
130
|
+
provider: 'grok',
|
|
131
|
+
path: '/v1/responses',
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
134
|
+
body: {
|
|
135
|
+
model: (args && args.model) || 'grok-4.6',
|
|
136
|
+
input: args && args.query,
|
|
137
|
+
tools: [{ type: 'x_search' }],
|
|
138
|
+
stream: false,
|
|
139
|
+
},
|
|
140
|
+
signal: exec && exec.signal,
|
|
141
|
+
})
|
|
142
|
+
const data = await res.json().catch(() => ({}))
|
|
143
|
+
if (!res.ok) throw new Error(`x_search HTTP ${res.status}: ${extractResponseText(data)}`)
|
|
144
|
+
return textResult(extractResponseText(data))
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
register({
|
|
149
|
+
name: 'web_search',
|
|
150
|
+
description: 'Search the web through the active ChatGPT/Codex subscription and return an answer with citations.',
|
|
151
|
+
output: TOOL_OUTPUT,
|
|
152
|
+
parameters: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
properties: {
|
|
155
|
+
query: { type: 'string', description: 'Search query' },
|
|
156
|
+
model: { type: 'string', description: 'Optional Codex model id' },
|
|
157
|
+
},
|
|
158
|
+
required: ['query'],
|
|
159
|
+
},
|
|
160
|
+
async execute(args, exec) {
|
|
161
|
+
const res = await deps.subscriptions.request({
|
|
162
|
+
provider: 'codex',
|
|
163
|
+
path: '/responses',
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
166
|
+
body: {
|
|
167
|
+
model: (args && args.model) || 'gpt-5.6',
|
|
168
|
+
input: args && args.query,
|
|
169
|
+
tools: [{ type: 'web_search_preview' }],
|
|
170
|
+
stream: false,
|
|
171
|
+
},
|
|
172
|
+
signal: exec && exec.signal,
|
|
173
|
+
})
|
|
174
|
+
const data = await res.json().catch(() => ({}))
|
|
175
|
+
if (!res.ok) throw new Error(`web_search HTTP ${res.status}: ${extractResponseText(data)}`)
|
|
176
|
+
return textResult(extractResponseText(data))
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
register({
|
|
181
|
+
name: 'video_generate',
|
|
182
|
+
description: 'Submit a short video generation request through the active Grok subscription.',
|
|
183
|
+
output: TOOL_OUTPUT,
|
|
184
|
+
parameters: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
properties: {
|
|
187
|
+
prompt: { type: 'string', description: 'Video prompt' },
|
|
188
|
+
duration: { type: 'number', description: 'Duration in seconds (1-15)' },
|
|
189
|
+
aspect_ratio: { type: 'string', description: 'e.g. 16:9, 1:1, 9:16' },
|
|
190
|
+
image_url: { type: 'string', description: 'Optional source image URL' },
|
|
191
|
+
},
|
|
192
|
+
required: ['prompt'],
|
|
193
|
+
},
|
|
194
|
+
async execute(args, exec) {
|
|
195
|
+
const res = await deps.subscriptions.request({
|
|
196
|
+
provider: 'grok',
|
|
197
|
+
path: '/v1/videos',
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
200
|
+
body: {
|
|
201
|
+
model: 'grok-imagine-video-1.5',
|
|
202
|
+
prompt: args && args.prompt,
|
|
203
|
+
duration: args && args.duration,
|
|
204
|
+
aspect_ratio: args && args.aspect_ratio,
|
|
205
|
+
image_url: args && args.image_url,
|
|
206
|
+
},
|
|
207
|
+
signal: exec && exec.signal,
|
|
208
|
+
})
|
|
209
|
+
const data = await res.json().catch(() => ({}))
|
|
210
|
+
if (!res.ok) throw new Error(`video_generate HTTP ${res.status}: ${extractResponseText(data)}`)
|
|
211
|
+
return textResult(`Video request submitted: ${JSON.stringify(data).slice(0, 1000)}`)
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
return () => {
|
|
216
|
+
for (const off of disposers) {
|
|
217
|
+
try { off() } catch { /* best-effort cleanup */ }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
package/lib/reset-clock.js
CHANGED
|
@@ -80,15 +80,25 @@ export function resolveAccurateReset(err, account, provider, nowMs = Date.now(),
|
|
|
80
80
|
return targetReset + (Number(safetyBufferMs) || 0)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (windowDefault && err && (err.status === 429 || err.code === 'RATE_LIMIT' || err.code === 'QUOTA')) {
|
|
86
|
-
return now + windowDefault + (Number(safetyBufferMs) || 0)
|
|
87
|
-
}
|
|
88
|
-
|
|
83
|
+
// #684: a 429 without a disclosed reset is a local backoff, not a whole
|
|
84
|
+
// vendor window. Parking on a guessed 2-24h window burns a healthy account.
|
|
89
85
|
return null
|
|
90
86
|
}
|
|
91
87
|
|
|
88
|
+
export function nextCooldownMs(accounts, now = Date.now()) {
|
|
89
|
+
const current = Number(now) || Date.now()
|
|
90
|
+
let next = null
|
|
91
|
+
for (const account of accounts || []) {
|
|
92
|
+
if (!account) continue
|
|
93
|
+
const candidates = [account.cooldownUntil, account.quarantineUntil, account.quota && account.quota.resetAt]
|
|
94
|
+
for (const value of candidates) {
|
|
95
|
+
const ts = Number(value)
|
|
96
|
+
if (Number.isFinite(ts) && ts > current) next = next === null ? ts : Math.min(next, ts)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return next === null ? null : next - current
|
|
100
|
+
}
|
|
101
|
+
|
|
92
102
|
export function formatRollingCountdown(resetAt, lang = 'en', now = Date.now()) {
|
|
93
103
|
const ts = Number(resetAt)
|
|
94
104
|
if (!Number.isFinite(ts) || ts <= 0) return ''
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function buildRouteReport(last, accounts, now = Date.now()) {
|
|
2
|
+
if (!last || !last.provider) return null
|
|
3
|
+
const current = Number(now) || Date.now()
|
|
4
|
+
const account = (Array.isArray(accounts) ? accounts : []).find((row) => row && row.ref === last.ref)
|
|
5
|
+
const source = (account && account.quota && account.quota.source) || account && account.quotaSource || 'unknown'
|
|
6
|
+
return {
|
|
7
|
+
at: last.at || last.timestamp || null,
|
|
8
|
+
route: {
|
|
9
|
+
provider: last.provider,
|
|
10
|
+
model: last.model || null,
|
|
11
|
+
path: last.path || null,
|
|
12
|
+
account: last.ref || null,
|
|
13
|
+
},
|
|
14
|
+
billing: {
|
|
15
|
+
provider: last.billingProvider || last.provider,
|
|
16
|
+
route: last.billingRoute || last.path || null,
|
|
17
|
+
},
|
|
18
|
+
quota: {
|
|
19
|
+
usedPercent: account && account.usagePercent != null ? account.usagePercent : null,
|
|
20
|
+
source,
|
|
21
|
+
ageMs: account && account.usageAt ? Math.max(0, current - Number(account.usageAt)) : null,
|
|
22
|
+
resetAt: (account && account.quota && account.quota.resetAt) || null,
|
|
23
|
+
confidence: source === 'unknown' ? 'unknown' : 'measured',
|
|
24
|
+
},
|
|
25
|
+
healthScore: account && account.healthScore != null ? account.healthScore : null,
|
|
26
|
+
stale: Boolean(account && account.usageAt && current - Number(account.usageAt) > 5 * 60 * 1000),
|
|
27
|
+
}
|
|
28
|
+
}
|
package/lib/routes/status.js
CHANGED
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
} from '../reasoning-effort.js'
|
|
11
11
|
import { defaultConcurrencyQueue } from '../concurrency-queue.js'
|
|
12
12
|
import { calculateAnalytics } from '../analytics.js'
|
|
13
|
+
import { setSpeedMode, getSpeedMode, clearSpeedMode } from '../speed-mode.js'
|
|
14
|
+
import { getActiveConflicts } from '../coexistence.js'
|
|
15
|
+
import { buildUsageRows, buildCallRows, toCsv } from '../usage-export.js'
|
|
16
|
+
import { buildRouteReport } from '../route-report.js'
|
|
13
17
|
import { runDiagnosticWizard } from '../diagnostic-wizard.js'
|
|
14
18
|
import { buildEnrichedCatalog } from '../models-catalog.js'
|
|
15
19
|
// #374: Sensitive GET routes protected by same-origin check
|
|
@@ -254,7 +258,8 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
254
258
|
labels,
|
|
255
259
|
expiryNotifyDays: live().expiryNotifyDays,
|
|
256
260
|
fastMode: !!live().codexFastMode,
|
|
257
|
-
composerQuota: String(live().composerQuota || 'off'),
|
|
261
|
+
composerQuota: String(live().composerQuota || 'off'), composerQuotaDisplay: String(live().composerQuotaDisplay || 'current'),
|
|
262
|
+
quotaWidget: String(live().quotaWidget || 'docked'),
|
|
258
263
|
active,
|
|
259
264
|
})
|
|
260
265
|
}),
|
|
@@ -297,7 +302,14 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
297
302
|
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
298
303
|
return
|
|
299
304
|
}
|
|
300
|
-
|
|
305
|
+
const accounts = await accountsView()
|
|
306
|
+
const last = history.recent(1)[0]
|
|
307
|
+
writeJson(res, 200, {
|
|
308
|
+
ok: true,
|
|
309
|
+
report: await diagnosticsReport(),
|
|
310
|
+
conflicts: getActiveConflicts(),
|
|
311
|
+
route: buildRouteReport(last, accounts),
|
|
312
|
+
})
|
|
301
313
|
}),
|
|
302
314
|
}), 'dsh-subscriptions: /diagnostics')
|
|
303
315
|
|
|
@@ -436,6 +448,45 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
436
448
|
}),
|
|
437
449
|
}), 'dsh-subscriptions: /history')
|
|
438
450
|
|
|
451
|
+
// #687: quota snapshot and per-call ledger export for DSH quota plugins.
|
|
452
|
+
ctx.effect(() => ctx.webServer.register({
|
|
453
|
+
kind: 'exact',
|
|
454
|
+
path: '/dsh-subscriptions/usage',
|
|
455
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
456
|
+
if (req.method !== 'GET') {
|
|
457
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET only' } })
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
461
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
const q = queryOf(req)
|
|
465
|
+
const type = q.get('type') === 'calls' ? 'calls' : 'accounts'
|
|
466
|
+
const format = String(q.get('format') || 'json').toLowerCase()
|
|
467
|
+
const now = Date.now()
|
|
468
|
+
const accounts = await accountsView()
|
|
469
|
+
const calls = history.recent(500)
|
|
470
|
+
const rows = type === 'calls' ? buildCallRows(calls) : buildUsageRows(accounts, now)
|
|
471
|
+
if (format === 'csv') {
|
|
472
|
+
res.writeHead(200, {
|
|
473
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
474
|
+
'Content-Disposition': 'attachment; filename="dsh-subscriptions-' + type + '.csv"',
|
|
475
|
+
'Cache-Control': 'no-store',
|
|
476
|
+
})
|
|
477
|
+
res.end(toCsv(rows))
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
writeJson(res, 200, {
|
|
481
|
+
ok: true,
|
|
482
|
+
generatedAt: now,
|
|
483
|
+
type,
|
|
484
|
+
rows,
|
|
485
|
+
summary: { accounts: Array.isArray(accounts) ? accounts.length : 0, calls: Array.isArray(calls) ? calls.length : 0 },
|
|
486
|
+
})
|
|
487
|
+
}),
|
|
488
|
+
}), 'dsh-subscriptions: /usage')
|
|
489
|
+
|
|
439
490
|
// Telemetry summary route for UI stat cards (requests, latency, success rate)
|
|
440
491
|
ctx.effect(() => ctx.webServer.register({
|
|
441
492
|
kind: 'exact',
|
|
@@ -716,6 +767,41 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
716
767
|
}),
|
|
717
768
|
}), 'dsh-subscriptions: /queue')
|
|
718
769
|
|
|
770
|
+
// #681: per-session Codex Speed/Fast toggle.
|
|
771
|
+
ctx.effect(() => ctx.webServer.register({
|
|
772
|
+
kind: 'exact',
|
|
773
|
+
path: '/dsh-subscriptions/speed',
|
|
774
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
775
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
776
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
if (req.method === 'GET') {
|
|
780
|
+
const sid = queryOf(req).get('sessionId') || 'default'
|
|
781
|
+
writeJson(res, 200, { ok: true, sessionId: sid, mode: getSpeedMode(sid) || 'standard' })
|
|
782
|
+
return
|
|
783
|
+
}
|
|
784
|
+
if (req.method !== 'POST') {
|
|
785
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET or POST only' } })
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
let body = {}
|
|
789
|
+
try { body = JSON.parse((await readBody(req, 16 * 1024)).toString('utf8') || '{}') } catch {
|
|
790
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
791
|
+
return
|
|
792
|
+
}
|
|
793
|
+
const sid = String(body.sessionId || 'default').slice(0, 200)
|
|
794
|
+
const mode = String(body.mode || '').toLowerCase()
|
|
795
|
+
if (mode !== 'fast' && mode !== 'standard' && mode !== 'off') {
|
|
796
|
+
writeJson(res, 400, { ok: false, error: { code: 'mode', message: 'mode must be fast or standard' } })
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
if (mode === 'fast') setSpeedMode(sid, 'fast')
|
|
800
|
+
else clearSpeedMode(sid)
|
|
801
|
+
writeJson(res, 200, { ok: true, sessionId: sid, mode: mode === 'fast' ? 'fast' : 'standard' })
|
|
802
|
+
}),
|
|
803
|
+
}), 'dsh-subscriptions: /speed')
|
|
804
|
+
|
|
719
805
|
// #519: Interactive Diagnostic Wizard for accounts and connectivity
|
|
720
806
|
ctx.effect(() => ctx.webServer.register({
|
|
721
807
|
kind: 'exact',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const modes = new Map()
|
|
2
|
+
|
|
3
|
+
export function setSpeedMode(sessionId, mode) {
|
|
4
|
+
const id = String(sessionId || '').trim()
|
|
5
|
+
if (!id) return
|
|
6
|
+
const value = String(mode || '').toLowerCase()
|
|
7
|
+
if (value === 'fast') modes.set(id, 'fast')
|
|
8
|
+
else modes.delete(id)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getSpeedMode(sessionId) {
|
|
12
|
+
return modes.get(String(sessionId || '').trim()) || null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clearSpeedMode(sessionId) {
|
|
16
|
+
modes.delete(String(sessionId || '').trim())
|
|
17
|
+
}
|
package/lib/stream-rotate.js
CHANGED
|
@@ -7,7 +7,7 @@ import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
|
7
7
|
import { withRunawayGuard } from './runaway-guard.js'
|
|
8
8
|
import { defaultErrorBuffer } from './error-buffer.js'
|
|
9
9
|
import { withSoftFailureSentinel } from './sentinel.js'
|
|
10
|
-
import { resolveAccurateReset } from './reset-clock.js'
|
|
10
|
+
import { resolveAccurateReset, nextCooldownMs } from './reset-clock.js'
|
|
11
11
|
|
|
12
12
|
export async function* streamWithRotation({
|
|
13
13
|
accounts,
|
|
@@ -88,7 +88,15 @@ export async function* streamWithRotation({
|
|
|
88
88
|
return
|
|
89
89
|
}
|
|
90
90
|
syncTrace('exhausted')
|
|
91
|
-
if (lastError)
|
|
91
|
+
if (lastError) {
|
|
92
|
+
const wait = nextCooldownMs(pool, nowMs())
|
|
93
|
+
if (wait != null && options && options.rateLimitWait !== false) {
|
|
94
|
+
const maxWait = Number(options.rateLimitMaxWaitMs) || 6 * 3600 * 1000
|
|
95
|
+
lastError.retryAfterMs = Math.min(wait, maxWait)
|
|
96
|
+
lastError.resetAt = nowMs() + lastError.retryAfterMs
|
|
97
|
+
}
|
|
98
|
+
throw lastError
|
|
99
|
+
}
|
|
92
100
|
const err = /** @type {Error & { code?: string }} */ (new Error('no usable subscription account for this provider'))
|
|
93
101
|
err.code = 'RATE_LIMIT'
|
|
94
102
|
throw err
|
package/lib/subscriptions.js
CHANGED
|
@@ -20,7 +20,7 @@ const OPENAI_COMPAT = [
|
|
|
20
20
|
export const ALLOWLIST = {
|
|
21
21
|
codex: ["/responses", "/models", "/images/generations", "/backend-api/codex/responses", "/backend-api/codex/models", "/backend-api/codex/images/generations"],
|
|
22
22
|
claude: ["/v1/messages", "/api/oauth/profile", "/api/oauth/usage", "/v1/models"],
|
|
23
|
-
grok: ["/v1/models", "/responses", "/v1/responses", "/v1/billing", "/v1/chat/completions", "/images/generations", "/v1/images/generations"],
|
|
23
|
+
grok: ["/v1/models", "/responses", "/v1/responses", "/v1/billing", "/v1/chat/completions", "/images/generations", "/v1/images/generations", "/v1/videos"],
|
|
24
24
|
antigravity: ["/v1/models", "/v1/generateContent", "/v1/streamGenerateContent", "/v1/loadCodeAssist", "/v1/fetchAvailableModels", "/v1/countTokens", "/v1/internal:loadCodeAssist"],
|
|
25
25
|
kimi: OPENAI_COMPAT,
|
|
26
26
|
glm: [...OPENAI_COMPAT, "/api/paas/v4/chat/completions", "/api/paas/v4/models"],
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function csvCell(value) {
|
|
2
|
+
const text = value == null ? '' : String(value)
|
|
3
|
+
return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function toCsv(rows) {
|
|
7
|
+
const list = Array.isArray(rows) ? rows : []
|
|
8
|
+
if (!list.length) return ''
|
|
9
|
+
const headers = Object.keys(list[0])
|
|
10
|
+
const lines = [headers.map(csvCell).join(',')]
|
|
11
|
+
for (const row of list) lines.push(headers.map((key) => csvCell(row[key])).join(','))
|
|
12
|
+
return lines.join('\n') + '\n'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildUsageRows(accounts, now = Date.now()) {
|
|
16
|
+
const current = Number(now) || Date.now()
|
|
17
|
+
return (Array.isArray(accounts) ? accounts : []).map((account) => ({
|
|
18
|
+
ref: account.ref || '',
|
|
19
|
+
provider: account.provider || '',
|
|
20
|
+
index: account.index ?? '',
|
|
21
|
+
label: account.label || '',
|
|
22
|
+
plan: account.plan || account.paidTierName || '',
|
|
23
|
+
configured: Boolean(account.hasToken || account.configured),
|
|
24
|
+
usagePercent: account.usagePercent ?? '',
|
|
25
|
+
remainingPercent: account.usagePercent != null ? Math.max(0, 100 - Number(account.usagePercent)) : '',
|
|
26
|
+
quotaSource: (account.quota && account.quota.source) || 'unknown',
|
|
27
|
+
usageAgeMs: account.usageAt ? Math.max(0, current - Number(account.usageAt)) : '',
|
|
28
|
+
resetAt: (account.quota && account.quota.resetAt) || '',
|
|
29
|
+
cooldownUntil: account.cooldownUntil || '',
|
|
30
|
+
quarantineUntil: account.quarantineUntil || '',
|
|
31
|
+
quarantineReason: account.quarantineReason || '',
|
|
32
|
+
healthScore: account.healthScore ?? '',
|
|
33
|
+
monthlyCost: account.monthlyCost ?? '',
|
|
34
|
+
}))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function buildCallRows(calls) {
|
|
38
|
+
return (Array.isArray(calls) ? calls : []).map((call) => ({
|
|
39
|
+
at: call.at || call.timestamp || '',
|
|
40
|
+
provider: call.provider || '',
|
|
41
|
+
ref: call.ref || '',
|
|
42
|
+
model: call.model || '',
|
|
43
|
+
path: call.path || '',
|
|
44
|
+
ok: call.ok !== false,
|
|
45
|
+
status: call.status ?? '',
|
|
46
|
+
inputTokens: call.inputTokens ?? '',
|
|
47
|
+
outputTokens: call.outputTokens ?? '',
|
|
48
|
+
cacheReadTokens: call.cacheReadTokens ?? '',
|
|
49
|
+
durationMs: call.durationMs ?? '',
|
|
50
|
+
error: call.error || '',
|
|
51
|
+
}))
|
|
52
|
+
}
|