@goodandready/dsh-subscriptions 0.6.27 → 0.6.29

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.
@@ -46,6 +46,8 @@ export function createAdapterManager({
46
46
  getQuota: (ref) => store.getQuota(ref),
47
47
  refreshUsage: (provider) => store.refreshUsage(provider),
48
48
  saveBlob: (ref, blob) => store.saveBlob(ref, blob),
49
+ alertThresholds: () => live().alertThresholds,
50
+ webhookUrl: () => live().webhookUrl,
49
51
  recordHistory,
50
52
  fetchImpl: fetch,
51
53
  fetchForRef,
package/lib/adapter.js CHANGED
@@ -4,6 +4,7 @@ import { displayName } from './refs.js'
4
4
  import { getVendor } from './vendors/index.js'
5
5
  import { modelCatalog } from './messages.js'
6
6
  import { streamWithRotation } from './stream-rotate.js'
7
+ import { checkQuotaThresholds } from './alerts.js'
7
8
  import { pickFetch } from './proxy.js'
8
9
  import { quotaSnapshot, parseBody } from './ratelimit.js'
9
10
  import { toTokenUsage } from './wire.js'
@@ -112,6 +113,8 @@ export class SubscriptionAdapter extends LlmAdapter {
112
113
  cooldownMs: deps.cooldownMs(),
113
114
  switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
114
115
  options,
116
+ webhookUrl: typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl,
117
+ fetchImpl: deps.fetchImpl || fetch,
115
118
  onCooldown: (account) => {
116
119
  if (typeof deps.rememberCooldown === 'function') deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
117
120
  if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
@@ -129,7 +132,21 @@ export class SubscriptionAdapter extends LlmAdapter {
129
132
  const res = await fetchWithTimeout(baseFetch, url, init, { timeoutMs: 120000 })
130
133
  try {
131
134
  const snap = quotaSnapshot(provider, res.headers, null, Date.now())
132
- if (snap && typeof deps.rememberQuota === "function") deps.rememberQuota(account.ref, snap)
135
+ if (snap && typeof deps.rememberQuota === "function") {
136
+ deps.rememberQuota(account.ref, snap)
137
+ if (snap.usedPercent != null) {
138
+ const th = typeof deps.alertThresholds === 'function' ? deps.alertThresholds() : deps.alertThresholds
139
+ const wh = typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl
140
+ checkQuotaThresholds({
141
+ ref: account.ref,
142
+ provider,
143
+ usedPercent: snap.usedPercent,
144
+ thresholds: th,
145
+ webhookUrl: wh,
146
+ fetchImpl: deps.fetchImpl || fetch,
147
+ })
148
+ }
149
+ }
133
150
  } catch { /* ignore quota capture failure */ }
134
151
  if (!res.ok && res.clone) {
135
152
  try {
@@ -139,7 +156,21 @@ export class SubscriptionAdapter extends LlmAdapter {
139
156
  const b = parseBody(provider, j, Date.now())
140
157
  if (b) {
141
158
  const snap2 = quotaSnapshot(provider, res.headers, j, Date.now())
142
- if (snap2) deps.rememberQuota(account.ref, snap2)
159
+ if (snap2) {
160
+ deps.rememberQuota(account.ref, snap2)
161
+ if (snap2.usedPercent != null) {
162
+ const th = typeof deps.alertThresholds === 'function' ? deps.alertThresholds() : deps.alertThresholds
163
+ const wh = typeof deps.webhookUrl === 'function' ? deps.webhookUrl() : deps.webhookUrl
164
+ checkQuotaThresholds({
165
+ ref: account.ref,
166
+ provider,
167
+ usedPercent: snap2.usedPercent,
168
+ thresholds: th,
169
+ webhookUrl: wh,
170
+ fetchImpl: deps.fetchImpl || fetch,
171
+ })
172
+ }
173
+ }
143
174
  }
144
175
  }
145
176
  } catch { /* ignore quota capture failure */ }
package/lib/alerts.js CHANGED
@@ -1,3 +1,12 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ let pkgVersion = ''
4
+ try {
5
+ pkgVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || ''
6
+ } catch {
7
+ // ignore package read errors
8
+ }
9
+
1
10
  // #351: Proactive Quota Alerts & Webhook Dispatcher
2
11
  // Manages quota threshold warnings, session expiry notifications,
3
12
  // and webhook dispatch (e.g. Telegram/Discord or external monitoring).
@@ -70,7 +79,7 @@ export async function dispatchWebhookAlert(alert, webhookUrl, fetchImpl = fetch)
70
79
  method: 'POST',
71
80
  headers: {
72
81
  'content-type': 'application/json',
73
- 'user-agent': 'dsh-subscriptions/0.6.17',
82
+ 'user-agent': pkgVersion ? `dsh-subscriptions/${pkgVersion}` : 'dsh-subscriptions',
74
83
  },
75
84
  body: JSON.stringify(payload),
76
85
  })
package/lib/client.js CHANGED
@@ -149,7 +149,7 @@ window.__ModuleLoader__.load({
149
149
  '.dsub-slot-meta-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px}' +
150
150
  '.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
151
151
  '.dsub-ref-tag code{padding:2px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,monospace;color:var(--dsw-alias-label-primary);font-size:11px}' +
152
- '.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-default,#3b82f6);background:color-mix(in srgb,var(--dsw-alias-brand-default,#3b82f6) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-default,#3b82f6) 20%,transparent)}' +
152
+ '.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-default);background:color-mix(in srgb,var(--dsw-alias-brand-default) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-default) 20%,transparent)}' +
153
153
  '.dsub-btn-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)!important;color:var(--dsw-alias-state-error-primary)!important}' +
154
154
  '.dsub-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)!important}' +
155
155
  '.dsub-poolTitle{margin-top:16px;margin-bottom:8px;font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em}' +
@@ -477,6 +477,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
477
477
  "save": "Save",
478
478
  "saved": "Saved",
479
479
  "loading": "Loading…",
480
+ "unavailable": "Settings are unavailable",
480
481
  "accountLabel": "Account",
481
482
  "plan": "Plan",
482
483
  "storedAs": "Stored as",
@@ -674,6 +675,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
674
675
  "save": "保存",
675
676
  "saved": "已保存",
676
677
  "loading": "加载中…",
678
+ "unavailable": "设置不可用",
677
679
  "accountLabel": "账户",
678
680
  "plan": "套餐方案",
679
681
  "storedAs": "存储标识",
@@ -2074,6 +2076,33 @@ const cssId = 'dsh-subscriptions/settings.module.css'
2074
2076
  if (props && props.view === 'summary') {
2075
2077
  return React.createElement('div', { className: 'dsub-description' }, t('cardIntro'))
2076
2078
  }
2079
+ // Check host snapshot status (#408): loading or unavailable
2080
+ if (props && props.status === 'loading') {
2081
+ return React.createElement(page ? 'div' : 'li', {
2082
+ className: page ? 'dsub-page' : 'dsub-card',
2083
+ },
2084
+ React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
2085
+ React.createElement('div', { className: 'dsub-headText' },
2086
+ React.createElement('div', { className: 'dsub-name' }, t('title')),
2087
+ React.createElement('div', { className: 'dsub-description' }, t('loading') || t('settingsLoading') || 'Loading…'),
2088
+ ),
2089
+ )
2090
+ )
2091
+ }
2092
+ if (props && props.status === 'unavailable') {
2093
+ return React.createElement(page ? 'div' : 'li', {
2094
+ className: page ? 'dsub-page' : 'dsub-card',
2095
+ },
2096
+ React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
2097
+ React.createElement('div', { className: 'dsub-headText' },
2098
+ React.createElement('div', { className: 'dsub-name' }, t('title')),
2099
+ React.createElement('div', { className: 'dsub-description', style: { color: 'var(--dsw-alias-state-warning-primary)' } },
2100
+ t('unavailable') || t('settingsUnavailable') || 'Settings are unavailable',
2101
+ ),
2102
+ ),
2103
+ )
2104
+ )
2105
+ }
2077
2106
  return React.createElement(page ? 'div' : 'li', {
2078
2107
  className: page ? 'dsub-page' : 'dsub-card' + (open ? ' dsub-cardOpen' : ''),
2079
2108
  },
@@ -1,3 +1,4 @@
1
+ import { normalizeTools } from './tools-normalizer.js'
1
2
  const TYPE_MAP = {
2
3
  string: 'string',
3
4
  number: 'number',
@@ -33,7 +34,7 @@ export function toGeminiSchema(schema, depth) {
33
34
  else type = 'string'
34
35
  }
35
36
  const out = { type }
36
- if (typed && typed.nullable) out.nullable = true
37
+ if ((typed && typed.nullable) || schema.nullable === true) out.nullable = true
37
38
  if (schema.description) out.description = String(schema.description)
38
39
  if (type === 'object') {
39
40
  const props = schema.properties
@@ -65,10 +66,11 @@ export function toGeminiSchema(schema, depth) {
65
66
  }
66
67
 
67
68
  export function geminiFunctionDeclarations(tools) {
68
- if (!Array.isArray(tools) || !tools.length) return undefined
69
- return tools.map((tool) => ({
69
+ const norm = normalizeTools(tools)
70
+ if (!norm) return undefined
71
+ return norm.map((tool) => ({
70
72
  name: tool.name,
71
- description: tool.description || '',
72
- parameters: toGeminiSchema(tool.parameters || { type: 'object' }),
73
+ description: tool.description,
74
+ parameters: toGeminiSchema(tool.parameters),
73
75
  }))
74
76
  }
package/lib/images.js CHANGED
@@ -109,3 +109,128 @@ export async function generateOnce(options) {
109
109
  }
110
110
  return parseImages(payload)
111
111
  }
112
+
113
+ // Multimodal Payload Normalization for Vision Models
114
+ export function parseDataUri(uri) {
115
+ if (typeof uri !== 'string') return null
116
+ const m = /^data:([a-zA-Z0-9.+/-]+);base64,(.+)$/.exec(uri.trim())
117
+ if (!m) return null
118
+ return { mimeType: m[1], base64: m[2] }
119
+ }
120
+
121
+ export function normalizeImage(item) {
122
+ if (!item) return null
123
+
124
+ // 1. Direct string (data URI or URL)
125
+ if (typeof item === 'string') {
126
+ const dataUri = parseDataUri(item)
127
+ if (dataUri) {
128
+ return { mimeType: dataUri.mimeType, base64: dataUri.base64, url: item.trim() }
129
+ }
130
+ if (/^https?:\/\//i.test(item.trim())) {
131
+ return { mimeType: 'image/jpeg', base64: null, url: item.trim() }
132
+ }
133
+ return null
134
+ }
135
+
136
+ if (typeof item !== 'object') return null
137
+
138
+ // 2. Anthropic style: { type: 'image', source: { type: 'base64', media_type, data } }
139
+ if (item.source && item.source.type === 'base64' && item.source.data) {
140
+ const mimeType = item.source.media_type || 'image/jpeg'
141
+ const base64 = item.source.data
142
+ return {
143
+ mimeType,
144
+ base64,
145
+ url: `data:${mimeType};base64,${base64}`,
146
+ }
147
+ }
148
+
149
+ // 3. Gemini style: { inlineData: { mimeType, data } }
150
+ if (item.inlineData && item.inlineData.data) {
151
+ const mimeType = item.inlineData.mimeType || 'image/jpeg'
152
+ const base64 = item.inlineData.data
153
+ return {
154
+ mimeType,
155
+ base64,
156
+ url: `data:${mimeType};base64,${base64}`,
157
+ }
158
+ }
159
+
160
+ // 4. OpenAI style: { type: 'image_url', image_url: { url } } or { type: 'image', image: '...' }
161
+ const rawUrl = (item.image_url && (item.image_url.url || item.image_url)) ||
162
+ item.image || item.url
163
+
164
+ if (typeof rawUrl === 'string') {
165
+ const dataUri = parseDataUri(rawUrl)
166
+ if (dataUri) {
167
+ return { mimeType: dataUri.mimeType, base64: dataUri.base64, url: rawUrl.trim() }
168
+ }
169
+ if (/^https?:\/\//i.test(rawUrl.trim())) {
170
+ return { mimeType: 'image/jpeg', base64: null, url: rawUrl.trim() }
171
+ }
172
+ }
173
+
174
+ return null
175
+ }
176
+
177
+ export function extractImages(content) {
178
+ if (!Array.isArray(content)) {
179
+ const norm = normalizeImage(content)
180
+ return norm ? [norm] : []
181
+ }
182
+ const images = []
183
+ for (const block of content) {
184
+ if (!block) continue
185
+ if (typeof block === 'string') {
186
+ const norm = normalizeImage(block)
187
+ if (norm) images.push(norm)
188
+ continue
189
+ }
190
+ if (block.type === 'image' || block.type === 'image_url' || block.inlineData || block.image_url || block.image) {
191
+ const norm = normalizeImage(block)
192
+ if (norm) images.push(norm)
193
+ }
194
+ }
195
+ return images
196
+ }
197
+
198
+ export function toOpenAiImage(norm) {
199
+ const url = norm.url || (norm.base64 ? `data:${norm.mimeType};base64,${norm.base64}` : null)
200
+ if (!url) return null
201
+ return {
202
+ type: 'image_url',
203
+ image_url: { url },
204
+ }
205
+ }
206
+
207
+ export function toAnthropicImage(norm) {
208
+ if (!norm.base64) return null
209
+ return {
210
+ type: 'image',
211
+ source: {
212
+ type: 'base64',
213
+ media_type: norm.mimeType || 'image/jpeg',
214
+ data: norm.base64,
215
+ },
216
+ }
217
+ }
218
+
219
+ export function toGeminiImage(norm) {
220
+ if (!norm.base64) return null
221
+ return {
222
+ inlineData: {
223
+ mimeType: norm.mimeType || 'image/jpeg',
224
+ data: norm.base64,
225
+ },
226
+ }
227
+ }
228
+
229
+ export function toCodexImage(norm) {
230
+ const url = norm.url || (norm.base64 ? `data:${norm.mimeType};base64,${norm.base64}` : null)
231
+ if (!url) return null
232
+ return {
233
+ type: 'input_image',
234
+ image_url: url,
235
+ }
236
+ }
package/lib/messages.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { extractImages, toOpenAiImage, toAnthropicImage, toGeminiImage, toCodexImage } from './images.js'
2
+ import { normalizeTools } from './tools-normalizer.js'
1
3
  import { geminiFunctionDeclarations } from './gemini-schema.js'
2
4
 
3
5
  function flattenText(content) {
@@ -64,7 +66,18 @@ export function openaiMessages(options) {
64
66
  }
65
67
  const results = toolResults(message.content)
66
68
  const text = flattenText(message.content)
67
- if (text || !results.length) out.push({ role: 'user', content: text })
69
+ const images = extractImages(message.content)
70
+ if (images.length) {
71
+ const parts = []
72
+ if (text) parts.push({ type: 'text', text })
73
+ for (const img of images) {
74
+ const blk = toOpenAiImage(img)
75
+ if (blk) parts.push(blk)
76
+ }
77
+ out.push({ role: 'user', content: parts })
78
+ } else if (text || !results.length) {
79
+ out.push({ role: 'user', content: text })
80
+ }
68
81
  for (const result of results) {
69
82
  out.push({
70
83
  role: 'tool',
@@ -77,14 +90,14 @@ export function openaiMessages(options) {
77
90
  }
78
91
 
79
92
  export function openaiTools(options) {
80
- const tools = options.tools
81
- if (!Array.isArray(tools) || !tools.length) return undefined
93
+ const tools = normalizeTools(options && options.tools)
94
+ if (!tools) return undefined
82
95
  return tools.map((tool) => ({
83
96
  type: 'function',
84
97
  function: {
85
98
  name: tool.name,
86
- description: tool.description || '',
87
- parameters: tool.parameters || { type: 'object' },
99
+ description: tool.description,
100
+ parameters: tool.parameters,
88
101
  },
89
102
  }))
90
103
  }
@@ -147,6 +160,18 @@ export function reconcileResponsesInput(items) {
147
160
  return result
148
161
  }
149
162
 
163
+ export function normalizeSystemPrefix(parts) {
164
+ if (!Array.isArray(parts)) return ""
165
+ return parts
166
+ .map((p) => {
167
+ if (!p) return ""
168
+ const s = typeof p === "string" ? p : (p.text || String(p))
169
+ return s.replace(/\r\n/g, "\n").trim()
170
+ })
171
+ .filter(Boolean)
172
+ .join("\n\n")
173
+ }
174
+
150
175
  export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
151
176
  const systemParts = []
152
177
  if (options.system) systemParts.push(options.system)
@@ -215,6 +240,15 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
215
240
  const results = toolResults(message.content)
216
241
  const text = flattenText(message.content)
217
242
  if (text) addText('user', text)
243
+ const images = extractImages(message.content)
244
+ for (const img of images) {
245
+ const blk = toCodexImage(img)
246
+ if (blk) {
247
+ if (pendingRole && pendingRole !== 'user') flush()
248
+ pendingRole = 'user'
249
+ pendingContent.push(blk)
250
+ }
251
+ }
218
252
  for (const result of results) {
219
253
  flush()
220
254
  const callId = String(result.toolCallId || result.tool_call_id || result.call_id || result.id || '')
@@ -239,7 +273,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
239
273
  description: tool.function.description,
240
274
  parameters: tool.function.parameters,
241
275
  }))
242
- const instructions = systemParts.filter(Boolean).join('\n\n') || fallbackInstructions
276
+ const instructions = normalizeSystemPrefix(systemParts) || (fallbackInstructions ? fallbackInstructions.replace(/\r\n/g, "\n").trim() : undefined)
243
277
  return {
244
278
  model: options.model,
245
279
  stream: true,
@@ -265,77 +299,123 @@ export function anthropicPayload(options, extraSystem) {
265
299
  if (options.system) systemParts.push(options.system)
266
300
  const messages = []
267
301
  for (const message of options.messages || []) {
268
- if (message.role === 'system') {
302
+ if (message.role === "system") {
269
303
  systemParts.push(flattenText(message.content))
270
304
  continue
271
305
  }
272
- if (message.role === 'assistant') {
306
+ if (message.role === "assistant") {
273
307
  const blocks = []
274
308
  const text = flattenText(message.content)
275
- if (text) blocks.push({ type: 'text', text })
309
+ if (text) blocks.push({ type: "text", text })
276
310
  const calls = [
277
311
  ...toolCalls(message.content),
278
312
  ...((message.tool_calls || message.toolCalls || []).map((c) => ({
279
313
  id: c.id || c.call_id,
280
314
  name: c.name || (c.function && c.function.name),
281
- arguments: c.arguments || (c.function && c.function.arguments) || '{}',
315
+ arguments: c.arguments || (c.function && c.function.arguments) || "{}",
282
316
  }))),
283
317
  ]
284
318
  for (const call of calls) {
285
319
  if (!call.id) continue
286
320
  blocks.push({
287
- type: 'tool_use',
321
+ type: "tool_use",
288
322
  id: call.id,
289
323
  name: call.name,
290
324
  input: safeJson(call.arguments),
291
325
  })
292
326
  }
293
- messages.push({ role: 'assistant', content: blocks.length ? blocks : [{ type: 'text', text: '' }] })
327
+ messages.push({ role: "assistant", content: blocks.length ? blocks : [{ type: "text", text: "" }] })
294
328
  continue
295
329
  }
296
- if (message.role === 'tool') {
297
- const toolUseId = String(message.toolCallId || message.tool_call_id || message.tool_use_id || message.id || '')
298
- const content = typeof message.content === 'string'
330
+ if (message.role === "tool") {
331
+ const toolUseId = String(message.toolCallId || message.tool_call_id || message.tool_use_id || message.id || "")
332
+ const content = typeof message.content === "string"
299
333
  ? message.content
300
- : (flattenText(message.content) || JSON.stringify(message.content || ''))
334
+ : (flattenText(message.content) || JSON.stringify(message.content || ""))
301
335
  messages.push({
302
- role: 'user',
336
+ role: "user",
303
337
  content: [{
304
- type: 'tool_result',
338
+ type: "tool_result",
305
339
  tool_use_id: toolUseId,
306
- content: content || '',
340
+ content: content || "",
307
341
  }],
308
342
  })
309
343
  continue
310
344
  }
311
345
  const blocks = []
312
346
  const text = flattenText(message.content)
313
- if (text) blocks.push({ type: 'text', text })
347
+ if (text) blocks.push({ type: "text", text })
348
+ const images = extractImages(message.content)
349
+ for (const img of images) {
350
+ const blk = toAnthropicImage(img)
351
+ if (blk) blocks.push(blk)
352
+ }
314
353
  for (const result of toolResults(message.content)) {
315
- const toolUseId = String(result.toolCallId || result.tool_call_id || result.tool_use_id || result.id || '')
316
- const content = typeof result.content === 'string'
354
+ const toolUseId = String(result.toolCallId || result.tool_call_id || result.tool_use_id || result.id || "")
355
+ const content = typeof result.content === "string"
317
356
  ? result.content
318
- : (flattenText(result.content) || (result.output != null ? (typeof result.output === 'string' ? result.output : JSON.stringify(result.output)) : ''))
357
+ : (flattenText(result.content) || (result.output != null ? (typeof result.output === "string" ? result.output : JSON.stringify(result.output)) : ""))
319
358
  blocks.push({
320
- type: 'tool_result',
359
+ type: "tool_result",
321
360
  tool_use_id: toolUseId,
322
- content: content || '',
361
+ content: content || "",
323
362
  })
324
363
  }
325
- messages.push({ role: 'user', content: blocks.length ? blocks : [{ type: 'text', text: '' }] })
364
+ messages.push({ role: "user", content: blocks.length ? blocks : [{ type: "text", text: "" }] })
326
365
  }
327
- const tools = Array.isArray(options.tools)
328
- ? options.tools.map((tool) => ({
329
- name: tool.name,
330
- description: tool.description || '',
331
- input_schema: tool.parameters || { type: 'object' },
332
- }))
366
+
367
+ const useCacheControl = options.cacheControl !== false
368
+ const systemText = normalizeSystemPrefix(systemParts)
369
+ let system
370
+ if (systemText) {
371
+ if (useCacheControl) {
372
+ system = [{ type: "text", text: systemText, cache_control: { type: "ephemeral" } }]
373
+ } else {
374
+ system = systemText
375
+ }
376
+ }
377
+
378
+ const normTools = normalizeTools(options.tools)
379
+ const tools = normTools
380
+ ? normTools.map((tool, idx) => {
381
+ const item = {
382
+ name: tool.name,
383
+ description: tool.description,
384
+ input_schema: tool.parameters,
385
+ }
386
+ if (useCacheControl && idx === normTools.length - 1) {
387
+ item.cache_control = { type: "ephemeral" }
388
+ }
389
+ return item
390
+ })
333
391
  : undefined
392
+
393
+ if (useCacheControl) {
394
+ const userTurnIndices = []
395
+ for (let i = 0; i < messages.length; i++) {
396
+ if (messages[i].role === "user") {
397
+ userTurnIndices.push(i)
398
+ }
399
+ }
400
+ if (userTurnIndices.length >= 2) {
401
+ const targetIdx = userTurnIndices[userTurnIndices.length - 2]
402
+ const targetMsg = messages[targetIdx]
403
+ if (Array.isArray(targetMsg.content) && targetMsg.content.length > 0) {
404
+ const lastBlockIdx = targetMsg.content.length - 1
405
+ const lastBlock = targetMsg.content[lastBlockIdx]
406
+ targetMsg.content[lastBlockIdx] = {
407
+ ...lastBlock,
408
+ cache_control: { type: "ephemeral" },
409
+ }
410
+ }
411
+ }
412
+ }
413
+
334
414
  return {
335
415
  model: options.model,
336
416
  max_tokens: options.maxTokens || 8192,
337
417
  stream: true,
338
- ...(systemParts.length ? { system: systemParts.join('\n\n') } : {}),
418
+ ...(system ? { system } : {}),
339
419
  messages,
340
420
  ...(tools && tools.length ? { tools } : {}),
341
421
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
@@ -371,6 +451,11 @@ export function googleContents(options) {
371
451
  const parts = []
372
452
  const text = flattenText(message.content)
373
453
  if (text) parts.push({ text })
454
+ const images = extractImages(message.content)
455
+ for (const img of images) {
456
+ const blk = toGeminiImage(img)
457
+ if (blk) parts.push(blk)
458
+ }
374
459
  const calls = [
375
460
  ...toolCalls(message.content),
376
461
  ...((message.tool_calls || message.toolCalls || []).map((c) => ({
@@ -400,7 +485,10 @@ export function googleContents(options) {
400
485
  const tools = declarations ? [{ functionDeclarations: declarations }] : undefined
401
486
  return {
402
487
  contents,
403
- ...(systemParts.length ? { systemInstruction: { parts: systemParts } } : {}),
488
+ ...(() => {
489
+ const systemText = normalizeSystemPrefix(systemParts)
490
+ return systemText ? { systemInstruction: { parts: [{ text: systemText }] } } : {}
491
+ })(),
404
492
  ...(tools ? { tools } : {}),
405
493
  generationConfig: {
406
494
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
package/lib/routes.js CHANGED
@@ -24,6 +24,10 @@ export function registerRoutes(ctx, state) {
24
24
 
25
25
  ctx.effect(() => registerPluginUpdater(ctx, {
26
26
  endpoint: '/dsh-subscriptions/update',
27
+ aliases: [
28
+ '/api/@goodandready/dsh-subscriptions/update',
29
+ '/api/dsh-subscriptions/update',
30
+ ],
27
31
  packageName: '@goodandready/dsh-subscriptions',
28
32
  manifestUrl: new URL('../package.json', import.meta.url),
29
33
  }), 'dsh-subscriptions: /update')
@@ -0,0 +1,105 @@
1
+ export class RunawayDetector {
2
+ constructor(options = {}) {
3
+ this.maxIdenticalConsecutive = options.maxIdenticalConsecutive || 30
4
+ this.maxPatternRepetitions = options.maxPatternRepetitions || 6
5
+ this.minPatternLength = options.minPatternLength || 4
6
+ this.maxPatternLength = options.maxPatternLength || 80
7
+ this.history = ''
8
+ this.lastChunk = null
9
+ this.identicalCount = 0
10
+ this.tripped = false
11
+ this.reason = null
12
+ }
13
+
14
+ feed(text) {
15
+ if (this.tripped || !text || typeof text !== 'string') return false
16
+
17
+ // 1. Check identical consecutive text chunks
18
+ if (this.lastChunk === text) {
19
+ this.identicalCount++
20
+ if (this.identicalCount >= this.maxIdenticalConsecutive) {
21
+ this.tripped = true
22
+ this.reason = `identical_chunk_loop (${this.identicalCount} repetitions of "${text.slice(0, 16)}")`
23
+ return true
24
+ }
25
+ } else {
26
+ this.lastChunk = text
27
+ this.identicalCount = 1
28
+ }
29
+
30
+ // 2. Sliding window n-gram repetition detection
31
+ const maxHistLen = this.maxPatternLength * this.maxPatternRepetitions + 128
32
+ this.history += text
33
+ if (this.history.length > maxHistLen * 2) {
34
+ this.history = this.history.slice(-maxHistLen)
35
+ }
36
+
37
+ const hLen = this.history.length
38
+ for (let pLen = this.minPatternLength; pLen <= this.maxPatternLength; pLen++) {
39
+ const requiredLen = pLen * this.maxPatternRepetitions
40
+ if (hLen < requiredLen) break
41
+
42
+ const pattern = this.history.slice(hLen - pLen)
43
+ let matches = 0
44
+ for (let i = 1; i < this.maxPatternRepetitions; i++) {
45
+ const seg = this.history.slice(hLen - (i + 1) * pLen, hLen - i * pLen)
46
+ if (seg === pattern) {
47
+ matches++
48
+ } else {
49
+ break
50
+ }
51
+ }
52
+ if (matches >= this.maxPatternRepetitions - 1) {
53
+ this.tripped = true
54
+ this.reason = `pattern_repetition_loop (${pLen} chars repeated ${this.maxPatternRepetitions} times: "${pattern.slice(0, 20)}...")`
55
+ return true
56
+ }
57
+ }
58
+
59
+ return false
60
+ }
61
+
62
+ isTripped() {
63
+ return this.tripped
64
+ }
65
+
66
+ getReason() {
67
+ return this.reason
68
+ }
69
+ }
70
+
71
+ export async function* withRunawayGuard(stream, options = {}) {
72
+ if (options && options.enabled === false) {
73
+ yield* stream
74
+ return
75
+ }
76
+
77
+ const detector = new RunawayDetector(options)
78
+
79
+ for await (const chunk of stream) {
80
+ if (chunk && chunk.type === 'finish') {
81
+ yield chunk
82
+ return
83
+ }
84
+
85
+ const text = (chunk && typeof chunk === 'object')
86
+ ? (typeof chunk.text === 'string' ? chunk.text : null)
87
+ : null
88
+
89
+ if (text && (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta' || !chunk.type)) {
90
+ if (detector.feed(text)) {
91
+ yield {
92
+ type: 'finish',
93
+ reason: {
94
+ kind: 'stop',
95
+ message: 'runaway_loop_detected',
96
+ detail: detector.getReason(),
97
+ },
98
+ }
99
+ return
100
+ }
101
+ }
102
+
103
+ yield chunk
104
+ }
105
+ }
@@ -1,5 +1,7 @@
1
1
  import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
2
2
  import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
3
+ import { notifySessionExpired } from './alerts.js'
4
+ import { withRunawayGuard } from './runaway-guard.js'
3
5
 
4
6
  export async function* streamWithRotation({
5
7
  accounts,
@@ -11,6 +13,8 @@ export async function* streamWithRotation({
11
13
  onCooldown,
12
14
  cascadeFallback, // #349: optional cross-vendor cascading fallback generator
13
15
  offlineFallback, // #174: optional fallback generator if all accounts exhausted
16
+ webhookUrl,
17
+ fetchImpl = fetch,
14
18
  }) {
15
19
  const pool = (accounts || []).map((account) => ({ ...account }))
16
20
  let lastError = null
@@ -49,7 +53,8 @@ export async function* streamWithRotation({
49
53
 
50
54
  while (true) {
51
55
  try {
52
- for await (const chunk of streamOnce(account, options)) {
56
+ const guardedStream = withRunawayGuard(streamOnce(account, options), options && options.runawayGuard)
57
+ for await (const chunk of guardedStream) {
53
58
  firstChunkDelivered = true
54
59
  yield chunk
55
60
  }
@@ -87,6 +92,19 @@ export async function* streamWithRotation({
87
92
  account.quarantineUntil = quarantined.quarantineUntil
88
93
  account.quarantineReason = quarantined.quarantineReason
89
94
 
95
+ if (reason === REASON_REVOKED) {
96
+ try {
97
+ notifySessionExpired({
98
+ ref: account.ref,
99
+ provider: options && options.provider,
100
+ message: err && err.message,
101
+ webhookUrl: webhookUrl || (options && options.webhookUrl),
102
+ fetchImpl,
103
+ nowMs: nowMs(),
104
+ })
105
+ } catch { /* fire-and-forget alert */ }
106
+ }
107
+
90
108
  if (onCooldown) onCooldown(account)
91
109
  // Break inner retry loop to continue to next account in outer while (true) loop
92
110
  break
@@ -0,0 +1,118 @@
1
+ export function sanitizeToolName(name) {
2
+ if (typeof name !== 'string' || !name.trim()) return 'tool_fn'
3
+ const sanitized = name.trim().replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64)
4
+ return sanitized || 'tool_fn'
5
+ }
6
+
7
+ export function sanitizeDescription(desc) {
8
+ if (typeof desc === 'string') return desc.trim()
9
+ if (desc == null) return ''
10
+ return String(desc).trim()
11
+ }
12
+
13
+ export function normalizeJsonSchema(schema, depth = 0) {
14
+ if (depth > 12) return { type: 'string' }
15
+ if (!schema || typeof schema !== 'object') {
16
+ return { type: 'object' }
17
+ }
18
+ if (Array.isArray(schema)) {
19
+ return normalizeJsonSchema(schema[0] || { type: 'object' }, depth + 1)
20
+ }
21
+
22
+ const out = {}
23
+
24
+ let type = schema.type
25
+ let nullable = false
26
+ if (Array.isArray(type)) {
27
+ nullable = type.includes('null')
28
+ type = type.find((t) => t && t !== 'null') || 'string'
29
+ }
30
+ if (typeof type === 'string') {
31
+ out.type = type.toLowerCase()
32
+ } else if (schema.properties) {
33
+ out.type = 'object'
34
+ } else if (schema.items) {
35
+ out.type = 'array'
36
+ } else {
37
+ out.type = depth === 0 ? 'object' : 'string'
38
+ }
39
+
40
+ if (nullable) {
41
+ out.nullable = true
42
+ }
43
+
44
+ if (schema.description) {
45
+ out.description = String(schema.description)
46
+ }
47
+
48
+ if (out.type === 'object') {
49
+ if (schema.properties && typeof schema.properties === 'object' && !Array.isArray(schema.properties)) {
50
+ out.properties = {}
51
+ for (const [key, val] of Object.entries(schema.properties)) {
52
+ if (typeof key === 'string' && key) {
53
+ out.properties[key] = normalizeJsonSchema(val, depth + 1)
54
+ }
55
+ }
56
+ }
57
+
58
+ if (Array.isArray(schema.required)) {
59
+ const propKeys = new Set(Object.keys(out.properties))
60
+ const reqs = schema.required
61
+ .filter((k) => typeof k === 'string' && k.length > 0)
62
+ .filter((k) => propKeys.size === 0 || propKeys.has(k))
63
+ if (reqs.length > 0) {
64
+ out.required = [...new Set(reqs)]
65
+ }
66
+ }
67
+
68
+ if (typeof schema.additionalProperties === 'boolean') {
69
+ out.additionalProperties = schema.additionalProperties
70
+ }
71
+ } else if (out.type === 'array') {
72
+ if (schema.items) {
73
+ out.items = normalizeJsonSchema(
74
+ Array.isArray(schema.items) ? schema.items[0] : schema.items,
75
+ depth + 1,
76
+ )
77
+ } else {
78
+ out.items = { type: 'string' }
79
+ }
80
+ }
81
+
82
+ if (Array.isArray(schema.enum) && schema.enum.length > 0) {
83
+ const cleaned = [...new Set(
84
+ schema.enum
85
+ .map((item) => (item != null ? String(item).trim() : ''))
86
+ .filter((item) => item.length > 0),
87
+ )]
88
+ if (cleaned.length > 0) {
89
+ out.enum = cleaned
90
+ }
91
+ }
92
+
93
+ return out
94
+ }
95
+
96
+ export function normalizeTools(tools) {
97
+ if (!Array.isArray(tools) || !tools.length) return undefined
98
+ const validTools = []
99
+
100
+ for (const tool of tools) {
101
+ if (!tool || typeof tool !== 'object') continue
102
+ const rawName = tool.name || (tool.function && tool.function.name)
103
+ if (!rawName) continue
104
+
105
+ const name = sanitizeToolName(rawName)
106
+ const description = sanitizeDescription(tool.description || (tool.function && tool.function.description))
107
+ const rawParams = tool.parameters || tool.input_schema || (tool.function && tool.function.parameters)
108
+ const parameters = normalizeJsonSchema(rawParams)
109
+
110
+ validTools.push({
111
+ name,
112
+ description,
113
+ parameters,
114
+ })
115
+ }
116
+
117
+ return validTools.length ? validTools : undefined
118
+ }
package/lib/updater.js CHANGED
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises'
4
4
  import { homedir } from 'node:os'
5
5
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
7
- import { writeJson } from './http.js'
7
+ import { isTrustedSettingsRequest, writeJson } from './http.js'
8
8
 
9
9
  const UPDATE_HEADER = 'x-dsh-plugin-update'
10
10
  const UPDATE_TIMEOUT_MS = 10 * 60_000
@@ -25,24 +25,11 @@ export function isLoopback(value) {
25
25
 
26
26
  export function isTrustedUpdateRequest(request) {
27
27
  const customHeader = header(request, UPDATE_HEADER)
28
- if (customHeader !== '1') {
29
- // In browser fetch, allow if sec-fetch-site is same-origin or same-site
30
- const site = header(request, 'sec-fetch-site')
31
- if (site === 'cross-site') return false
28
+ if (customHeader === '1') {
29
+ const remote = request.socket?.remoteAddress
30
+ if (remote && isLoopback(remote)) return true
32
31
  }
33
- const remote = request.socket?.remoteAddress
34
- if (remote && !isLoopback(remote)) return false
35
- const origin = header(request, 'origin')
36
- const host = header(request, 'host')
37
- if (origin && host) {
38
- try {
39
- const url = new URL(origin)
40
- if (url.host !== host && !isLoopback(url.hostname)) return false
41
- } catch {
42
- return false
43
- }
44
- }
45
- return true
32
+ return isTrustedSettingsRequest(request)
46
33
  }
47
34
 
48
35
  function validProfileName(value) {
@@ -206,10 +193,11 @@ export async function installExactPackage(target, packageSpec, options) {
206
193
 
207
194
  export function registerPluginUpdater(ctx, options) {
208
195
  let installing = false
209
- return ctx.webServer.register({
210
- kind: 'exact',
211
- path: options.endpoint,
212
- handler: async (request, response) => {
196
+ const endpoints = Array.isArray(options.endpoint)
197
+ ? options.endpoint
198
+ : [options.endpoint, ...(Array.isArray(options.aliases) ? options.aliases : [])]
199
+
200
+ const handler = async (request, response) => {
213
201
  try {
214
202
  const target = getRuntime()
215
203
  if (request.method === 'GET' || request.method === 'HEAD') {
@@ -255,6 +243,17 @@ export function registerPluginUpdater(ctx, options) {
255
243
  ctx.logger?.warn?.(`plugin updater failed: ${String(error)}`)
256
244
  writeJson(response, 503, { ok: false, error: { code: 'update_failed', message: String(error?.message || error) } })
257
245
  }
258
- },
259
- })
246
+ }
247
+
248
+ const unregisters = endpoints.map((ep) => ctx.webServer.register({
249
+ kind: 'exact',
250
+ path: ep,
251
+ handler,
252
+ }))
253
+
254
+ return () => {
255
+ for (const un of unregisters) {
256
+ if (typeof un === 'function') un()
257
+ }
258
+ }
260
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.27",
3
+ "version": "0.6.29",
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",