@goodandready/dsh-subscriptions 0.7.41 → 0.7.42

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/lib/accounts.js CHANGED
@@ -50,6 +50,7 @@ import { accountIdentityKey, credentialState } from './account-reliability.js'
50
50
  import { recordSwitch, recordExhaust, recordBroken, computeHealthScore, healthBadge } from './health.js'
51
51
  import { executeWithRetry } from './backoff.js'
52
52
  import { getVendor } from './vendors/index.js'
53
+ import { versionForProvider } from './client-version-service.js'
53
54
 
54
55
  const SKEW_MS = 60 * 1000
55
56
  const USAGE_TTL_MS = 2 * 60 * 1000
@@ -99,6 +100,8 @@ export function vendorConfig(provider, cfg) {
99
100
  return String(value).trim()
100
101
  }
101
102
  const modelsKey = `${provider}Models`
103
+ const manualVersion = provider === 'claude' ? pick('CcVersion', '') : pick('ClientVersion', '')
104
+ const resolvedVersion = versionForProvider(provider, manualVersion, d.clientVersion || d.claudeCcVersion || '')
102
105
  return {
103
106
  clientId: pick('ClientId', d.clientId),
104
107
  clientSecret: pick('ClientSecret', d.clientSecret || ''),
@@ -106,7 +109,8 @@ export function vendorConfig(provider, cfg) {
106
109
  baseUrl: pick('BaseUrl', d.baseUrl || ''),
107
110
  originator: pick('Originator', d.originator || ''),
108
111
  systemPrefix: pick('SystemPrefix', d.systemPrefix || ''),
109
- clientVersion: pick('ClientVersion', d.clientVersion || ''),
112
+ clientVersion: resolvedVersion.version,
113
+ ...(provider === 'claude' ? { claudeCcVersion: resolvedVersion.version } : {}),
110
114
  // #93/#92: per-vendor request shaping (codex only consumes these today).
111
115
  verbosity: pick('Verbosity', ''),
112
116
  fastMode: !!(cfg && cfg[`${provider}FastMode`]),
@@ -0,0 +1,73 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { resolveClientVersion } from './client-version.js'
3
+
4
+ const PACKAGES = { codex: '@openai/codex', claude: '@anthropic-ai/claude-code' }
5
+ const BINARIES = { codex: 'codex', claude: 'claude' }
6
+ const TTL_MS = 6 * 60 * 60 * 1000
7
+ const state = {
8
+ codex: { npm: '', local: '', at: 0 },
9
+ claude: { npm: '', local: '', at: 0 },
10
+ }
11
+ let inflight = null
12
+
13
+ function npmMetaUrl(name) {
14
+ return 'https://registry.npmjs.org/' + encodeURIComponent(name) + '/latest'
15
+ }
16
+
17
+ function localVersion(binary) {
18
+ try {
19
+ const out = execFileSync(binary, ['--version'], { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] })
20
+ const m = /\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/.exec(String(out))
21
+ return m ? m[0] : ''
22
+ } catch { return '' }
23
+ }
24
+
25
+ async function npmVersion(name, fetchImpl, signal) {
26
+ try {
27
+ const res = await fetchImpl(npmMetaUrl(name), { headers: { Accept: 'application/json' }, signal })
28
+ if (!res.ok) return ''
29
+ const json = await res.json()
30
+ return typeof json?.version === 'string' ? json.version : ''
31
+ } catch { return '' }
32
+ }
33
+
34
+ export function clientVersionInfo(fallbacks = {}, configs = {}) {
35
+ const out = {}
36
+ for (const provider of Object.keys(PACKAGES)) {
37
+ const npm = state[provider].npm
38
+ const local = state[provider].local
39
+ out[provider] = resolveClientVersion({
40
+ configValue: configs[provider] || '',
41
+ npmVersion: npm,
42
+ localVersion: local,
43
+ fallback: fallbacks[provider] || '',
44
+ })
45
+ }
46
+ return out
47
+ }
48
+
49
+ /** @param {{ fetchImpl?: typeof fetch, force?: boolean, signal?: AbortSignal, localVersionFn?: (binary: string) => string }} [options] */
50
+ export async function refreshClientVersions({ fetchImpl = fetch, force = false, signal, localVersionFn = localVersion } = {}) {
51
+ const now = Date.now()
52
+ if (!force && Object.values(state).every((row) => row.at && now - row.at < TTL_MS)) return clientVersionInfo()
53
+ if (inflight) return inflight
54
+ inflight = (async () => {
55
+ for (const [provider, name] of Object.entries(PACKAGES)) {
56
+ const npm = await npmVersion(name, fetchImpl, signal)
57
+ const local = localVersionFn(BINARIES[provider])
58
+ state[provider] = { npm, local, at: now }
59
+ }
60
+ return clientVersionInfo()
61
+ })().finally(() => { inflight = null })
62
+ return inflight
63
+ }
64
+
65
+ export function resetClientVersionCache() {
66
+ for (const key of Object.keys(state)) state[key] = { npm: '', local: '', at: 0 }
67
+ inflight = null
68
+ }
69
+
70
+ export function versionForProvider(provider, configValue, fallback) {
71
+ const cached = state[provider] || {}
72
+ return resolveClientVersion({ configValue, npmVersion: cached.npm, localVersion: cached.local, fallback })
73
+ }
@@ -1,4 +1,6 @@
1
- /** #798: choose a client version with deterministic source precedence. */
1
+ /** #798: choose a client version with deterministic source precedence.
2
+ * @param {{ configValue?: string, npmVersion?: string, localVersion?: string, fallback?: string }} [options]
3
+ */
2
4
  export function resolveClientVersion({ configValue, npmVersion, localVersion, fallback } = {}) {
3
5
  const values = [
4
6
  ['config', configValue],
package/lib/client.js CHANGED
@@ -249,6 +249,7 @@ window.__ModuleLoader__.load({
249
249
  "subsLogged": "connected",
250
250
  "subsNotLogged": "not connected",
251
251
  "subsCooldown": "cooldown",
252
+ "clientVersions": "Client versions",
252
253
  "settingsLoading": "Loading settings…",
253
254
  "settingsUnavailable": "Settings are unavailable",
254
255
  "settingsRetry": "Retry",
@@ -656,6 +657,7 @@ window.__ModuleLoader__.load({
656
657
  "subsLogged": "已连接",
657
658
  "subsNotLogged": "未连接",
658
659
  "subsCooldown": "冷却中",
660
+ "clientVersions": "客户端版本",
659
661
  "settingsLoading": "正在加载设置…",
660
662
  "settingsUnavailable": "设置加载失败",
661
663
  "settingsRetry": "重试",
@@ -1103,6 +1105,7 @@ window.__ModuleLoader__.load({
1103
1105
  const [accounts, setAccounts] = React.useState([])
1104
1106
  const [providers, setProviders] = React.useState([])
1105
1107
  const [modelsCatalog, setModelsCatalog] = React.useState([])
1108
+ const [clientVersions, setClientVersions] = React.useState(null)
1106
1109
  const [modelQuery, setModelQuery] = React.useState('')
1107
1110
  const [modelSort, setModelSort] = React.useState('name')
1108
1111
  const [modelLimit, setModelLimit] = React.useState(40)
@@ -1490,6 +1493,7 @@ window.__ModuleLoader__.load({
1490
1493
  setAccounts((data && data.accounts) || [])
1491
1494
  setProviders((data && data.providers) || [])
1492
1495
  setModelsCatalog((data && data.modelsCatalog) || [])
1496
+ setClientVersions((data && data.clientVersions) || null)
1493
1497
  }
1494
1498
 
1495
1499
  let reloadSeq = 0
@@ -1969,6 +1973,14 @@ window.__ModuleLoader__.load({
1969
1973
  className: updater.message.includes(t('updateSuccessRestart')) ? 'dsub-alert-ok' : 'dsub-alert-bad',
1970
1974
  style: { marginBottom: '12px' }
1971
1975
  }, updater.message) : null,
1976
+ clientVersions ? h('div', {
1977
+ 'data-pane': 'overview',
1978
+ className: 'dsub-sub',
1979
+ style: { marginBottom: '10px' },
1980
+ }, t('clientVersions') + ': ' + ['codex', 'claude'].map((provider) => {
1981
+ const row = clientVersions[provider]
1982
+ return provider + ' ' + (row && row.version ? row.version : '?') + ' (' + (row && row.source || 'none') + ')'
1983
+ }).join(' · ')) : null,
1972
1984
  smoke.result ? h('div', {
1973
1985
  'data-pane': 'overview',
1974
1986
  className: smoke.result.ok ? 'dsub-alert-ok' : 'dsub-alert-bad',
package/lib/compat.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** #816: normalize host API shape differences in one place. */
2
2
  export function toolCallId(value) {
3
3
  if (!value || typeof value !== 'object') return ''
4
- return String(value.toolCallId || value.tool_call_id || value.callId || value.call_id || value.id || '')
4
+ return String(value.toolCallId || value.tool_call_id || value.toolUseId || value.tool_use_id || value.callId || value.call_id || value.id || '')
5
5
  }
6
6
 
7
7
  export function normalizeRpcResult(value) {
@@ -14,6 +14,7 @@ export function normalizeRpcResult(value) {
14
14
 
15
15
  export function hostCompatibilityError(detail) {
16
16
  const suffix = detail ? `: ${String(detail)}` : ''
17
+ /** @type {Error & { code?: string }} */
17
18
  const err = new Error(`unsupported DSH host API${suffix}`)
18
19
  err.code = 'DSH_HOST_INCOMPATIBLE'
19
20
  return err
@@ -117,7 +117,11 @@ export const Config = z.object({
117
117
  codexRedirectUri: z.string().volatile().default(''),
118
118
  codexBaseUrl: z.string().volatile().default(''),
119
119
  codexClientVersion: z.string().volatile().default('')
120
- .description('Codex CLI identity version for /models catalog gating (e.g. 0.160.0). Empty uses the built-in default.'),
120
+ .description('Codex CLI identity version for /models catalog gating (e.g. 0.160.0). Empty uses npm/local/built-in resolution.'),
121
+ codexWebSearch: z.boolean().volatile().default(true)
122
+ .description('#815 Expose Codex subscription search as the host web_search provider. Off releases the seam.'),
123
+ codexWebSearchModel: z.string().volatile().default('gpt-5.6-luna')
124
+ .description('#815 Model used for Codex web-search turns.'),
121
125
  claudeClientId: z.string().volatile().default(''),
122
126
  claudeRedirectUri: z.string().volatile().default(''),
123
127
  grokClientId: z.string().volatile().default(''),
package/lib/index.js CHANGED
@@ -24,6 +24,11 @@ import { createResetCreditService } from './reset-credits.js'
24
24
  import { maskEmail, maskLabel } from './mask.js'
25
25
  import { proxyFetch } from './proxy.js'
26
26
  import { shouldBypassProxy } from './feature-helpers.js'
27
+ import { refreshClientVersions } from './client-version-service.js'
28
+ import { normalizeRpcResult, hostCompatibilityError } from './compat.js'
29
+ import { CodexWebSearchProvider } from './web-search-provider.js'
30
+ import { identityHeaders } from './vendors/codex.js'
31
+ import { webSearchRetryDelay } from './feature-helpers.js'
27
32
  import { HistoryStore, resolveHistoryDir } from './history.js'
28
33
  import { createDiagnosticsReport } from './diagnostics.js'
29
34
  import { createAccountsView } from './accounts-view.js'
@@ -49,7 +54,7 @@ export { pushToWebdav, pullFromWebdav, pushToGist, pullFromGist, buildS3SigV4Hea
49
54
  import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from './vault-sync.js'
50
55
 
51
56
  export const name = '@goodandready/dsh-subscriptions'
52
- export const inject = ['llm', 'credentials', 'webServer', 'settings', 'tools']
57
+ export const inject = ['llm', 'credentials', 'webServer', 'settings', 'tools', 'web']
53
58
 
54
59
  const NS = 'dsh-subscriptions'
55
60
 
@@ -168,11 +173,16 @@ export function apply(ctx, rawConfig) {
168
173
  const parsed = plainConfig(next)
169
174
  const prevConfig = cachedConfig
170
175
  try {
176
+ let rpc
171
177
  if (typeof settingsSvc?.update === 'function') {
172
- await settingsSvc.update(NS, structuredClone(parsed), expectedRevision !== undefined ? expectedRevision : describeRow()?.revision)
178
+ rpc = await settingsSvc.update(NS, structuredClone(parsed), expectedRevision !== undefined ? expectedRevision : describeRow()?.revision)
173
179
  } else if (typeof settingsSvc?.replace === 'function') {
174
- await settingsSvc.replace(NS, structuredClone(parsed), expectedRevision !== undefined ? expectedRevision : describeRow()?.revision)
180
+ rpc = await settingsSvc.replace(NS, structuredClone(parsed), expectedRevision !== undefined ? expectedRevision : describeRow()?.revision)
181
+ } else {
182
+ throw hostCompatibilityError('settings.update/replace is missing')
175
183
  }
184
+ const result = rpc === undefined ? { ok: true } : normalizeRpcResult(rpc)
185
+ if (!result.ok) throw new Error('settings update was not acknowledged by the host')
176
186
  cachedConfig = parsed
177
187
  clearKeyRotationCache()
178
188
  clearOllamaHealthCache()
@@ -214,6 +224,9 @@ export function apply(ctx, rawConfig) {
214
224
  const row = describeRow()
215
225
  if (row && row.value) syncSnapshot(plainConfig(Config(plainConfig(row.value))))
216
226
  else syncSnapshot()
227
+ if ((live().slots || []).some((slot) => slot && (slot.provider === 'codex' || slot.provider === 'claude'))) {
228
+ refreshClientVersions({ fetchImpl: fetch }).catch(() => {})
229
+ }
217
230
  reconcileSlots().catch(() => {})
218
231
  } catch { /* keep existing snapshot on error */ }
219
232
  }
@@ -228,10 +241,14 @@ export function apply(ctx, rawConfig) {
228
241
  }, 'dsh-subscriptions: settings change listener')
229
242
  }
230
243
 
231
- Promise.resolve().then(() => {
244
+ Promise.resolve().then(async () => {
232
245
  reconcileSlots().catch((err) => {
233
246
  if (logger && logger.warn) logger.warn('[dsh-subscriptions] reconcileSlots initial error: ' + (err && err.message || err))
234
247
  })
248
+ const logged = await store.loggedInProviders().catch(() => [])
249
+ if (logged.some((provider) => provider === 'codex' || provider === 'claude')) {
250
+ refreshClientVersions({ fetchImpl: fetch }).catch(() => {})
251
+ }
235
252
  })
236
253
 
237
254
  const fetchForRef = (ref) => {
@@ -280,6 +297,47 @@ export function apply(ctx, rawConfig) {
280
297
  }
281
298
  const recordHistory = (entry) => history.add(entry)
282
299
 
300
+ // #815: register Codex subscription search behind the host web seam.
301
+ if (ctx.web && typeof ctx.web.registerSearchProvider === 'function') {
302
+ const provider = new CodexWebSearchProvider({
303
+ available: () => !!live().codexWebSearch,
304
+ search: async (request, signal) => {
305
+ const rows = await store.listAccounts('codex')
306
+ const row = (rows || []).find((account) => account && account.hasToken && account.ref)
307
+ if (!row) throw new Error('Codex web search requires a connected Codex account')
308
+ const raw = await store.resolveRaw(row.ref)
309
+ if (!raw) throw new Error('Codex web search requires a connected Codex account')
310
+ const blob = await store.ensureFresh('codex', parseBlob(raw), row.ref)
311
+ const cfg = vendorConfig('codex', live())
312
+ const base = (cfg.baseUrl || 'https://chatgpt.com/backend-api/codex').replace(/\/$/, '')
313
+ const fetchFn = fetchForRef(row.ref) || fetch
314
+ let attempt = 0
315
+ for (;;) {
316
+ const res = await fetchFn(base + '/responses', {
317
+ method: 'POST',
318
+ headers: identityHeaders(blob, cfg, { 'Content-Type': 'application/json', Accept: 'application/json' }),
319
+ body: JSON.stringify({
320
+ model: live().codexWebSearchModel || 'gpt-5.6-luna',
321
+ input: [{ role: 'user', content: [{ type: 'input_text', text: String(request.query || '') }] }],
322
+ tools: [{ type: 'web_search' }],
323
+ stream: false,
324
+ }),
325
+ signal,
326
+ })
327
+ if (res.ok) return res.json()
328
+ const delay = webSearchRetryDelay(attempt, res.status)
329
+ if (delay == null) throw new Error('Codex web search failed: HTTP ' + res.status)
330
+ await new Promise((resolve) => setTimeout(resolve, delay))
331
+ attempt += 1
332
+ }
333
+ },
334
+ })
335
+ ctx.effect(() => {
336
+ const dispose = ctx.web.registerSearchProvider(provider)
337
+ return () => { if (typeof dispose === 'function') dispose() }
338
+ }, 'dsh-subscriptions: codex web search provider')
339
+ }
340
+
283
341
  // #85: host-only reset credit service for Codex accounts.
284
342
  const resetCredits = createResetCreditService({ loadBlob: (ref) => store.loadBlob(ref) })
285
343
  function refForSlot(provider, indexOrAlias) {
package/lib/messages.js CHANGED
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'
2
2
  import { extractImages, toOpenAiImage, toAnthropicImage, toGeminiImage, toCodexImage } from './images.js'
3
3
  import { normalizeTools } from './tools-normalizer.js'
4
4
  import { geminiFunctionDeclarations } from './gemini-schema.js'
5
+ import { toolCallId as callIdOf } from './compat.js'
5
6
 
6
7
  function flattenText(content) {
7
8
  if (typeof content === 'string') return content
@@ -45,7 +46,7 @@ export function openaiMessages(options) {
45
46
  function: { name: block.name, arguments: block.arguments || '{}' },
46
47
  })),
47
48
  ...((message.tool_calls || message.toolCalls || []).map((c) => ({
48
- id: c.id || c.call_id,
49
+ id: callIdOf(c),
49
50
  type: 'function',
50
51
  function: {
51
52
  name: c.name || (c.function && c.function.name),
@@ -61,7 +62,7 @@ export function openaiMessages(options) {
61
62
  continue
62
63
  }
63
64
  if (message.role === 'tool') {
64
- const toolCallId = String(message.toolCallId || message.tool_call_id || message.call_id || message.id || '')
65
+ const toolCallId = callIdOf(message)
65
66
  const content = typeof message.content === 'string'
66
67
  ? message.content
67
68
  : (flattenText(message.content) || JSON.stringify(message.content || ''))
@@ -89,7 +90,7 @@ export function openaiMessages(options) {
89
90
  for (const result of results) {
90
91
  out.push({
91
92
  role: 'tool',
92
- tool_call_id: String(result.toolCallId || result.tool_call_id || result.call_id || result.id || ''),
93
+ tool_call_id: callIdOf(result),
93
94
  content: typeof result.content === 'string' ? result.content : (flattenText(result.content) || '(no output)'),
94
95
  })
95
96
  }
@@ -213,7 +214,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
213
214
  const calls = [
214
215
  ...toolCalls(message.content),
215
216
  ...((message.tool_calls || message.toolCalls || []).map((c) => ({
216
- id: c.id || c.call_id,
217
+ id: callIdOf(c),
217
218
  name: c.name || (c.function && c.function.name),
218
219
  arguments: typeof c.arguments === 'string' ? c.arguments : (c.function && c.function.arguments) || c.arguments || '{}',
219
220
  }))),
@@ -232,7 +233,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
232
233
  }
233
234
  if (message.role === 'tool') {
234
235
  flush()
235
- const callId = String(message.toolCallId || message.tool_call_id || message.call_id || message.id || '')
236
+ const callId = callIdOf(message)
236
237
  const outText = typeof message.content === 'string'
237
238
  ? message.content
238
239
  : (flattenText(message.content) || JSON.stringify(message.content || ''))
@@ -259,7 +260,7 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
259
260
  }
260
261
  for (const result of results) {
261
262
  flush()
262
- const callId = String(result.toolCallId || result.tool_call_id || result.call_id || result.id || '')
263
+ const callId = callIdOf(result)
263
264
  const outText = typeof result.content === 'string'
264
265
  ? result.content
265
266
  : (flattenText(result.content) || (result.output != null ? (typeof result.output === 'string' ? result.output : JSON.stringify(result.output)) : ''))
@@ -324,7 +325,7 @@ export function anthropicPayload(options, extraSystem) {
324
325
  const calls = [
325
326
  ...toolCalls(message.content),
326
327
  ...((message.tool_calls || message.toolCalls || []).map((c) => ({
327
- id: c.id || c.call_id,
328
+ id: callIdOf(c),
328
329
  name: c.name || (c.function && c.function.name),
329
330
  arguments: c.arguments || (c.function && c.function.arguments) || "{}",
330
331
  }))),
@@ -342,7 +343,7 @@ export function anthropicPayload(options, extraSystem) {
342
343
  continue
343
344
  }
344
345
  if (message.role === "tool") {
345
- const toolUseId = String(message.toolCallId || message.tool_call_id || message.tool_use_id || message.id || "")
346
+ const toolUseId = callIdOf(message)
346
347
  const images = extractImages(message.content)
347
348
  let content = typeof message.content === "string"
348
349
  ? message.content
@@ -375,7 +376,7 @@ export function anthropicPayload(options, extraSystem) {
375
376
  if (blk) blocks.push(blk)
376
377
  }
377
378
  for (const result of toolResults(message.content)) {
378
- const toolUseId = String(result.toolCallId || result.tool_call_id || result.tool_use_id || result.id || "")
379
+ const toolUseId = callIdOf(result)
379
380
  const content = typeof result.content === "string"
380
381
  ? result.content
381
382
  : (flattenText(result.content) || (result.output != null ? (typeof result.output === "string" ? result.output : JSON.stringify(result.output)) : ""))
@@ -30,6 +30,7 @@ import { assessHealth } from '../health-report.js'
30
30
  import { probeOllama } from '../ollama-health.js'
31
31
  import { defaultSnapshotStore } from '../vault-snapshots.js'
32
32
  import { collectSourceChain } from './sources.js'
33
+ import { clientVersionInfo } from '../client-version-service.js'
33
34
 
34
35
  export function registerStatusRoutes(ctx, state) {
35
36
  const {
@@ -71,6 +72,13 @@ export function registerStatusRoutes(ctx, state) {
71
72
  liveConfig: live(),
72
73
  hideDeprecated: !!live().hideDeprecatedModels,
73
74
  }),
75
+ clientVersions: clientVersionInfo({
76
+ codex: getVendor('codex').defaults().clientVersion,
77
+ claude: getVendor('claude').defaults().clientVersion,
78
+ }, {
79
+ codex: live().codexClientVersion,
80
+ claude: live().claudeCcVersion,
81
+ }),
74
82
  queue: defaultConcurrencyQueue.stats(),
75
83
  }
76
84
  }
@@ -60,7 +60,7 @@ export function authorizeUrl(cfg, pkce) {
60
60
  })
61
61
  }
62
62
 
63
- function identityHeaders(blob, config, extra) {
63
+ export function identityHeaders(blob, config, extra) {
64
64
  const originator = headerSafeValue(config.originator || 'codex_cli_rs')
65
65
  const activeOrgId = headerSafeValue(blob.selectedOrgId || blob.accountId || '')
66
66
  return {
@@ -0,0 +1,64 @@
1
+ const MAX_BYTES = 64 * 1024
2
+
3
+ function outputText(payload) {
4
+ if (typeof payload?.output_text === 'string') return payload.output_text
5
+ if (!Array.isArray(payload?.output)) return ''
6
+ return payload.output
7
+ .flatMap((item) => Array.isArray(item?.content) ? item.content : [])
8
+ .filter((block) => block && (block.type === 'output_text' || block.type === 'text'))
9
+ .map((block) => String(block.text || ''))
10
+ .join('')
11
+ }
12
+
13
+ function citationSources(payload) {
14
+ const sources = []
15
+ const seen = new Set()
16
+ const visit = (value) => {
17
+ if (!value || typeof value !== 'object') return
18
+ if (Array.isArray(value)) { value.forEach(visit); return }
19
+ for (const key of Object.keys(value)) {
20
+ if (key !== 'content' && key !== 'annotations' && key !== 'citations') continue
21
+ const nested = value[key]
22
+ if (!Array.isArray(nested)) continue
23
+ for (const item of nested) {
24
+ if (item && typeof item === 'object') visit(item)
25
+ }
26
+ }
27
+ const annotations = Array.isArray(value.annotations) ? value.annotations : []
28
+ for (const annotation of annotations) {
29
+ const url = String(annotation?.url || annotation?.link || '')
30
+ if (!url || seen.has(url)) continue
31
+ seen.add(url)
32
+ sources.push({ url, ...(annotation.title ? { title: String(annotation.title) } : {}) })
33
+ }
34
+ }
35
+ visit(payload?.output)
36
+ visit(payload?.citations)
37
+ return sources
38
+ }
39
+
40
+ /** #815: map a Codex responses payload into the host WebSearchResult contract. */
41
+ export function normalizeCodexSearch(payload, maxBytes = MAX_BYTES) {
42
+ return {
43
+ content: String(outputText(payload) || '').slice(0, maxBytes),
44
+ sources: citationSources(payload).slice(0, 50),
45
+ truncated: false,
46
+ }
47
+ }
48
+
49
+ export class CodexWebSearchProvider {
50
+ constructor({ available, search }) {
51
+ this.id = 'subscriptions-codex'
52
+ this._available = available
53
+ this._search = search
54
+ }
55
+
56
+ available() {
57
+ try { return !!this._available() } catch { return false }
58
+ }
59
+
60
+ async search(request, signal) {
61
+ if (!this.available()) throw new Error('Codex web search is not available')
62
+ return normalizeCodexSearch(await this._search(request, signal))
63
+ }
64
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.7.41",
3
+ "version": "0.7.42",
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",