@goodandready/dsh-subscriptions 0.6.64 → 0.6.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -207,6 +207,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
207
207
  * **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
208
208
  * **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
209
209
 
210
+ ### 19. 🧠 Session Reasoning Effort Management & Contract Matrix (v0.6.65)
211
+ * **Session-Scoped Reasoning Effort Control (#505)**: Dynamically adjust reasoning effort (`low`, `medium`, `high`, `max`) or disable reasoning (`off`) per conversation session via REST endpoints (`GET`, `POST`, `DELETE /dsh-subscriptions/session-reasoning`).
212
+ * **Model-Switch Invariant & Safety Guard**: Automatic suppression of reasoning parameters when switching from a reasoning model (e.g. `o1`) to a standard model (e.g. `gpt-4o`), avoiding upstream HTTP 400 parameter errors.
213
+ * **Extended Multi-Vendor Capability**: Native reasoning support across Codex (`o1`, `o3`), Claude (`claude-3-7-sonnet*`, `claude-4.6+` thinking tokens & budgets), Antigravity/Gemini (`thinkingConfig`), Grok (`grok-3-mini`), and Kimi (`k1.5`).
214
+ * **Comprehensive Adapter Contract Matrix (#521)**: Automated offline wire contract verification (`test/contract-matrix.test.mjs`) testing 8 providers across 7 wire protocol capabilities (`stream`, `tools`, `reasoning`, `usage`, `caching`, `auth_errors`, `rate_limit`).
215
+
210
216
  ### 18. 🔑 Antigravity Client Secret & Resilient CLI Refresh (v0.6.60)
211
217
  * **Client Secret in Schema (`antigravityClientSecret`)**: Configure custom Google Cloud OAuth client secrets with automatic mask redaction (`••••••`), resolving token refresh rejections for confidential OAuth clients.
212
218
  * **Zero-Config CLI Token Refresh**: Bundled public application credentials for the standard Gemini / Antigravity CLI client (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`), ensuring imported CLI sessions keep refreshing indefinitely without dying after 1 hour.
package/README.ru.md CHANGED
@@ -183,6 +183,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
183
183
  * **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
184
184
  * **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
185
185
 
186
+ ### 15. 🧠 Управление Reasoning Effort в сессии и матрица контрактов (v0.6.65)
187
+ * **Управление глубиной рассуждений в сессии (#505)**: Динамическая настройка усилия рассуждений (`low`, `medium`, `high`, `max`) или отключение (`off`) в рамках диалога через REST API (`GET`, `POST`, `DELETE /dsh-subscriptions/session-reasoning`).
188
+ * **Безопасность при смене модели**: Автоматическое подавление параметров рассуждений при переключении с reasoning-модели (напр. `o1`) на обычную (напр. `gpt-4o`), предотвращающее ошибки HTTP 400 от upstream API.
189
+ * **Поддержка ведущих вендоров**: Codex (`o1`, `o3`), Claude (`claude-3-7-sonnet*`, `claude-4.6+` thinking budget), Antigravity/Gemini (`thinkingConfig`), Grok (`grok-3-mini`) и Kimi (`k1.5`).
190
+ * **Матрица контрактных проверок адаптеров (#521)**: Тестовый набор (`test/contract-matrix.test.mjs`) для 8 провайдеров по 7 ключевым возможностям протокола (`stream`, `tools`, `reasoning`, `usage`, `caching`, `auth_errors`, `rate_limit`).
191
+
186
192
  ### 14. 🔑 Antigravity Client Secret и надежный refresh CLI-токенов (v0.6.60)
187
193
  * **Client Secret в схеме настроек (`antigravityClientSecret`)**: Поддержка ввода пользовательского Google Cloud OAuth Client Secret с автоматическим маскированием (`••••••`), устраняющая ошибки обновления токенов для конфиденциальных OAuth-клиентов.
188
194
  * **Бесшовный refresh сессий из CLI**: Встроенные официальные публичные учетные данные Gemini / Antigravity CLI (`Google Gemini CLI Client ID` / `Google Gemini CLI Client Secret`), предотвращающие отказ Google OAuth через 1 час после импорта через `From CLI`.
package/lib/adapter.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { resolveEffectiveReasoning, getModelReasoningCapabilities } from './reasoning-effort.js'
1
2
  import { inferModelCapabilities, isDeprecatedId } from './models-catalog.js'
2
3
  import { fetchWithTimeout } from "./http.js"
3
4
  import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
@@ -95,6 +96,11 @@ export class SubscriptionAdapter extends LlmAdapter {
95
96
  async resolveModel(provider, model) {
96
97
  const rows = await this.listModels(provider)
97
98
  const found = rows.find((row) => row && row.id === model)
99
+ const caps = getModelReasoningCapabilities(provider, model, rows)
100
+ const reasoningMeta = found && found.reasoning ? found.reasoning : (caps.supported ? {
101
+ efforts: caps.levels.map((lvl) => ({ id: lvl, name: lvl.charAt(0).toUpperCase() + lvl.slice(1) })),
102
+ ...(caps.defaultEffort ? { defaultEffort: caps.defaultEffort } : {}),
103
+ } : undefined)
98
104
  if (found) {
99
105
  return {
100
106
  provider,
@@ -103,10 +109,10 @@ export class SubscriptionAdapter extends LlmAdapter {
103
109
  ...(found.description ? { description: found.description } : {}),
104
110
  ...(found.inputModalities ? { inputModalities: found.inputModalities } : {}),
105
111
  ...(found.contextWindow ? { context: { contextWindow: found.contextWindow } } : {}),
106
- ...(found.reasoning ? { reasoning: found.reasoning } : {}),
112
+ ...(reasoningMeta ? { reasoning: reasoningMeta } : {}),
107
113
  }
108
114
  }
109
- return { provider, id: model, name: model }
115
+ return { provider, id: model, name: model, ...(reasoningMeta ? { reasoning: reasoningMeta } : {}) }
110
116
  }
111
117
 
112
118
  async prepareCall(provider, model, signal) {
@@ -136,9 +142,13 @@ export class SubscriptionAdapter extends LlmAdapter {
136
142
  const effectiveDrainSafetyPercent = options.drainSafetyPercent !== undefined
137
143
  ? options.drainSafetyPercent
138
144
  : (typeof deps.drainSafetyPercent === 'function' ? deps.drainSafetyPercent() : (deps.drainSafetyPercent ?? 5))
145
+ const sessionId = options.sessionId || options.session_id || options.conversationId
146
+ const effectiveReasoning = resolveEffectiveReasoning(sessionId, options)
139
147
  const decisionTrace = {}
140
148
  const streamOptions = {
141
149
  ...options,
150
+ ...(effectiveReasoning.effort ? { reasoningEffort: effectiveReasoning.effort } : {}),
151
+ ...(effectiveReasoning.budgetTokens ? { thinkingBudget: effectiveReasoning.budgetTokens } : {}),
142
152
  _decisionTrace: decisionTrace,
143
153
  autoPacing: effectiveAutoPacing,
144
154
  drainBeforeReset: effectiveDrainBeforeReset,
package/lib/client.js CHANGED
@@ -609,7 +609,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
609
609
  "wizardRecommendations": "Recommendations & Next Steps",
610
610
  "wizardCopy": "Copy Masked Diagnostic Report",
611
611
  "cachePinnedBadge": "Cache Pinned",
612
- "field.enableToasts": "Show popup toast notifications on quarantine or quota events"
612
+ "field.enableToasts": "Show popup toast notifications on quarantine or quota events",
613
+ "reasoningEffortTitle": "Reasoning Effort Control",
614
+ "reasoningEffortDesc": "Configure reasoning depth for models with extended thinking capabilities.",
615
+ "reasoningLevelLow": "Low",
616
+ "reasoningLevelMed": "Medium",
617
+ "reasoningLevelHigh": "High",
618
+ "reasoningLevelMax": "Max",
619
+ "reasoningLevelOff": "Off"
613
620
  };
614
621
 
615
622
  const zh = {
@@ -867,7 +874,14 @@ const cssId = 'dsh-subscriptions/settings.module.css'
867
874
  "wizardRecommendations": "排查建议与修复步骤",
868
875
  "wizardCopy": "复制脱敏诊断报告",
869
876
  "cachePinnedBadge": "缓存粘性",
870
- "field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒"
877
+ "field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒",
878
+ "reasoningEffortTitle": "推理强度控制",
879
+ "reasoningEffortDesc": "为具备扩展思考能力的模型配置推理深度与思考预算。",
880
+ "reasoningLevelLow": "低",
881
+ "reasoningLevelMed": "中",
882
+ "reasoningLevelHigh": "高",
883
+ "reasoningLevelMax": "最高",
884
+ "reasoningLevelOff": "关闭"
871
885
  };
872
886
  // #51: live countdown to the quota window reset (account.quota.resetAt).
873
887
  function cleanErrorMessage(raw) {
@@ -1971,6 +1985,20 @@ const cssId = 'dsh-subscriptions/settings.module.css'
1971
1985
  })
1972
1986
  ),
1973
1987
  ),
1988
+ React.createElement('details', { className: 'dsub-group' },
1989
+ React.createElement('summary', { className: 'dsub-groupHead' }, '🧠 ' + t('reasoningEffortTitle')),
1990
+ React.createElement('div', { className: 'dsub-sub', style: { marginTop: '4px' } }, t('reasoningEffortDesc')),
1991
+ React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '10px' } },
1992
+ ['off', 'low', 'medium', 'high', 'max'].map((lvl) => {
1993
+ const label = lvl === 'off' ? t('reasoningLevelOff') : (lvl.charAt(0).toUpperCase() + lvl.slice(1))
1994
+ return React.createElement('span', {
1995
+ key: lvl,
1996
+ className: 'dsub-model-tag',
1997
+ style: { padding: '4px 10px', fontSize: '12px', cursor: 'default' }
1998
+ }, '⚙️ ' + label)
1999
+ })
2000
+ )
2001
+ ),
1974
2002
  React.createElement('details', { className: 'dsub-details' },
1975
2003
  React.createElement('summary', { className: 'dsub-summary' }, t('advancedSettings')),
1976
2004
  React.createElement('div', { className: 'dsub-block' },
@@ -1,3 +1,4 @@
1
+ import { getSessionReasoningStats } from './reasoning-effort.js'
1
2
  import { defaultConcurrencyQueue } from './concurrency-queue.js'
2
3
  import { calculateCacheMetrics } from './analytics.js'
3
4
  import { getSessionPinStats } from './session-pin.js'
@@ -52,6 +53,7 @@ export async function createDiagnosticsReport({ live, store, history, NS, pkgVer
52
53
  cache: calculateCacheMetrics(rows),
53
54
  queue: defaultConcurrencyQueue.stats(),
54
55
  pins: getSessionPinStats(),
56
+ reasoning: getSessionReasoningStats(),
55
57
  decisions: {
56
58
  recent: rows.filter((r) => r && r.decisionTrace).slice(0, 10).map((r) => ({
57
59
  ts: r.ts,
package/lib/messages.js CHANGED
@@ -289,8 +289,14 @@ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
289
289
  instructions,
290
290
  input,
291
291
  ...(responsesTools && responsesTools.length ? { tools: responsesTools } : {}),
292
- // #93: reasoning effort chosen in the native picker flows to the protocol.
293
- ...(options.reasoningEffort ? { reasoning: { effort: String(options.reasoningEffort) } } : {}),
292
+ // #93, #505: reasoning effort chosen flows to the protocol only when supported and not 'off'
293
+ ...((() => {
294
+ const eff = options && String(options.reasoningEffort || '').toLowerCase().trim()
295
+ if (eff && eff !== 'off' && /^(low|medium|high)$/.test(eff)) {
296
+ return { reasoning: { effort: eff } }
297
+ }
298
+ return {}
299
+ })()),
294
300
  // #93: verbosity comes from the codexVerbosity setting (low/medium/high).
295
301
  ...(vendorCfg && /^(low|medium|high)$/.test(String(vendorCfg.verbosity || ''))
296
302
  ? { text: { verbosity: String(vendorCfg.verbosity) } } : {}),
@@ -501,6 +507,18 @@ export function googleContents(options) {
501
507
  generationConfig: {
502
508
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
503
509
  ...(options.maxTokens != null ? { maxOutputTokens: options.maxTokens } : {}),
510
+ ...((() => {
511
+ const budget = options && (options.thinkingBudget || options.budgetTokens || (options.thinking && options.thinking.budget_tokens))
512
+ if (budget != null && Number(budget) >= 0) {
513
+ return { thinkingConfig: { thinkingBudget: Number(budget) } }
514
+ }
515
+ const eff = options && String(options.reasoningEffort || '').toLowerCase().trim()
516
+ if (eff === 'off') return { thinkingConfig: { thinkingBudget: 0 } }
517
+ if (eff === 'low') return { thinkingConfig: { thinkingBudget: 1024 } }
518
+ if (eff === 'medium') return { thinkingConfig: { thinkingBudget: 8192 } }
519
+ if (eff === 'high') return { thinkingConfig: { thinkingBudget: 24576 } }
520
+ return {}
521
+ })()),
504
522
  },
505
523
  }
506
524
  }
@@ -0,0 +1,323 @@
1
+ // Reasoning effort management and model capability mapping (#505).
2
+ // Enforces 0 Cyrillic characters.
3
+
4
+ /**
5
+ * Known default effort levels per vendor.
6
+ * @type {Record<string, string[]>}
7
+ */
8
+ export const DEFAULT_VENDOR_EFFORTS = {
9
+ codex: ['low', 'medium', 'high'],
10
+ claude: ['low', 'medium', 'high'],
11
+ antigravity: ['low', 'medium', 'high'],
12
+ grok: ['low', 'medium', 'high'],
13
+ kimi: ['low', 'medium', 'high', 'max'],
14
+ cursor: ['low', 'medium', 'high', 'max'],
15
+ }
16
+
17
+ /**
18
+ * Inspect model and catalog to determine supported reasoning levels.
19
+ * @param {string} provider
20
+ * @param {string} modelId
21
+ * @param {Array<any>} [catalog]
22
+ * @returns {{ supported: boolean, levels: string[], defaultEffort?: string }}
23
+ */
24
+ export function getModelReasoningCapabilities(provider, modelId, catalog = []) {
25
+ const p = String(provider || '').toLowerCase().trim()
26
+ const id = String(modelId || '').toLowerCase().trim()
27
+
28
+ // 1. Check catalog entry if provided
29
+ if (Array.isArray(catalog) && catalog.length) {
30
+ const entry = catalog.find((m) => m && (m.id === id || m.slug === id || m.name === id))
31
+ if (entry && entry.reasoning && Array.isArray(entry.reasoning.efforts) && entry.reasoning.efforts.length) {
32
+ const levels = entry.reasoning.efforts.map((row) => (typeof row === 'string' ? row : row.id)).filter(Boolean)
33
+ if (levels.length) {
34
+ return {
35
+ supported: true,
36
+ levels,
37
+ defaultEffort: entry.reasoning.defaultEffort || 'medium',
38
+ }
39
+ }
40
+ }
41
+ }
42
+
43
+ // 2. Vendor-specific heuristics
44
+ if (p === 'claude') {
45
+ // Claude 3.7+ Sonnet (both hybrid thinking and adaptive)
46
+ if (/claude-(?:3[.-]7|37)-sonnet/.test(id)) {
47
+ return { supported: true, levels: ['low', 'medium', 'high'], defaultEffort: 'medium' }
48
+ }
49
+ // Claude 4.6+ Sonnet / Opus
50
+ const m = /^claude-(opus|sonnet|haiku|fable)(?:-(\d+))?(?:-(\d+))?/.exec(id)
51
+ if (m) {
52
+ const [, family, maj, min] = m
53
+ const major = maj ? Number(maj) : null
54
+ const minor = min ? Number(min) : null
55
+ const atLeast = (wantMaj, wantMin) => (
56
+ major === null || major > wantMaj || (major === wantMaj && (minor === null || minor >= wantMin))
57
+ )
58
+ if ((family === 'sonnet' || family === 'opus') && atLeast(4, 6)) {
59
+ const levels = ['low', 'medium', 'high']
60
+ if (family === 'opus') {
61
+ if (atLeast(4, 7)) levels.push('xhigh')
62
+ levels.push('max')
63
+ }
64
+ return { supported: true, levels, defaultEffort: 'medium' }
65
+ }
66
+ }
67
+ return { supported: false, levels: [] }
68
+ }
69
+
70
+ if (p === 'codex') {
71
+ // OpenAI reasoning models: o1, o3, gpt-5.1-codex, or models containing 'reasoning'
72
+ if (
73
+ id.startsWith('o1') ||
74
+ id.startsWith('o3') ||
75
+ id.includes('reasoning') ||
76
+ id.includes('codex')
77
+ ) {
78
+ return { supported: true, levels: ['low', 'medium', 'high'], defaultEffort: 'medium' }
79
+ }
80
+ return { supported: false, levels: [] }
81
+ }
82
+
83
+ if (p === 'antigravity') {
84
+ if (
85
+ id.includes('thinking') ||
86
+ id.includes('gemini-3.1-pro') ||
87
+ id.includes('gemini-3.6-flash') ||
88
+ id.includes('gemini-3.7-flash') ||
89
+ id.includes('gemini-3.8-flash')
90
+ ) {
91
+ return { supported: true, levels: ['low', 'medium', 'high'], defaultEffort: 'medium' }
92
+ }
93
+ return { supported: false, levels: [] }
94
+ }
95
+
96
+ if (p === 'grok') {
97
+ if (id.includes('reasoning') || id === 'grok-3-mini') {
98
+ return { supported: true, levels: ['low', 'medium', 'high'], defaultEffort: 'medium' }
99
+ }
100
+ return { supported: false, levels: [] }
101
+ }
102
+
103
+ if (p === 'kimi') {
104
+ if (id.includes('k1.5') || id.includes('reasoning')) {
105
+ return { supported: true, levels: ['low', 'medium', 'high', 'max'], defaultEffort: 'medium' }
106
+ }
107
+ return { supported: false, levels: [] }
108
+ }
109
+
110
+ return { supported: false, levels: [] }
111
+ }
112
+
113
+ /**
114
+ * Extract raw effort and budget from incoming request options.
115
+ * @param {any} options
116
+ * @returns {{ effort?: string, budgetTokens?: number }}
117
+ */
118
+ export function extractReasoningOptions(options) {
119
+ if (!options || typeof options !== 'object') return {}
120
+ let effort = options.reasoningEffort
121
+ if (!effort && options.reasoning && typeof options.reasoning.effort === 'string') {
122
+ effort = options.reasoning.effort
123
+ }
124
+ if (!effort && options.thinking && typeof options.thinking.effort === 'string') {
125
+ effort = options.thinking.effort
126
+ }
127
+ if (!effort && typeof options.effort === 'string') {
128
+ effort = options.effort
129
+ }
130
+
131
+ let budgetTokens = options.thinkingBudget ?? options.budgetTokens
132
+ if (budgetTokens == null && options.thinking && options.thinking.budget_tokens != null) {
133
+ budgetTokens = options.thinking.budget_tokens
134
+ }
135
+
136
+ const normalizedEffort = typeof effort === 'string' ? effort.toLowerCase().trim() : undefined
137
+ const normalizedBudget = typeof budgetTokens === 'number' && !Number.isNaN(budgetTokens) ? budgetTokens : undefined
138
+
139
+ return {
140
+ ...(normalizedEffort ? { effort: normalizedEffort } : {}),
141
+ ...(normalizedBudget != null ? { budgetTokens: normalizedBudget } : {}),
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Validate effort level against model capabilities.
147
+ * @param {string} provider
148
+ * @param {string} modelId
149
+ * @param {string|undefined} effort
150
+ * @param {Array<any>} [catalog]
151
+ * @returns {{ valid: boolean, effort?: string, reason?: string }}
152
+ */
153
+ export function validateReasoningEffort(provider, modelId, effort, catalog = []) {
154
+ if (!effort) return { valid: true, effort: undefined }
155
+ const eff = String(effort).toLowerCase().trim()
156
+ if (eff === 'off' || eff === 'none') {
157
+ return { valid: true, effort: 'off' }
158
+ }
159
+
160
+ const caps = getModelReasoningCapabilities(provider, modelId, catalog)
161
+ if (!caps.supported) {
162
+ return { valid: false, reason: 'model_not_reasoning' }
163
+ }
164
+
165
+ if (!caps.levels.includes(eff)) {
166
+ return { valid: false, reason: 'unsupported_effort_level' }
167
+ }
168
+
169
+ return { valid: true, effort: eff }
170
+ }
171
+
172
+ const DEFAULT_TTL_MS = 60 * 60 * 1000 // 1 hour
173
+ const MAX_STORED_SESSIONS = 500
174
+
175
+ /**
176
+ * @typedef {Object} SessionReasoningRecord
177
+ * @property {string} provider
178
+ * @property {string} model
179
+ * @property {string} effort
180
+ * @property {number} [budgetTokens]
181
+ * @property {number} updatedAt
182
+ * @property {number} expiresAt
183
+ */
184
+
185
+ /** @type {Map<string, SessionReasoningRecord>} */
186
+ const sessionReasoningStore = new Map()
187
+
188
+ /**
189
+ * Store session-scoped reasoning configuration.
190
+ * @param {string} sessionId
191
+ * @param {object} params
192
+ * @param {string} params.provider
193
+ * @param {string} params.model
194
+ * @param {string} params.effort
195
+ * @param {number} [params.budgetTokens]
196
+ * @param {number} [params.ttlMs]
197
+ * @param {Array<any>} [catalog]
198
+ * @returns {{ ok: boolean, error?: string }}
199
+ */
200
+ export function setSessionReasoning(sessionId, { provider, model, effort, budgetTokens, ttlMs = DEFAULT_TTL_MS }, catalog = []) {
201
+ if (!sessionId || typeof sessionId !== 'string') {
202
+ return { ok: false, error: 'invalid_session_id' }
203
+ }
204
+
205
+ const validation = validateReasoningEffort(provider, model, effort, catalog)
206
+ if (!validation.valid) {
207
+ return { ok: false, error: validation.reason }
208
+ }
209
+
210
+ if (sessionReasoningStore.size >= MAX_STORED_SESSIONS) {
211
+ pruneSessionReasoning()
212
+ }
213
+
214
+ const now = Date.now()
215
+ sessionReasoningStore.set(sessionId, {
216
+ provider: String(provider || ''),
217
+ model: String(model || ''),
218
+ effort: validation.effort || 'off',
219
+ budgetTokens: typeof budgetTokens === 'number' ? budgetTokens : undefined,
220
+ updatedAt: now,
221
+ expiresAt: now + ttlMs,
222
+ })
223
+
224
+ return { ok: true }
225
+ }
226
+
227
+ /**
228
+ * Retrieve session-scoped reasoning configuration.
229
+ * @param {string} sessionId
230
+ * @param {number} [now]
231
+ * @returns {SessionReasoningRecord|null}
232
+ */
233
+ export function getSessionReasoning(sessionId, now = Date.now()) {
234
+ if (!sessionId) return null
235
+ const record = sessionReasoningStore.get(sessionId)
236
+ if (!record) return null
237
+ if (record.expiresAt <= now) {
238
+ sessionReasoningStore.delete(sessionId)
239
+ return null
240
+ }
241
+ // Sliding TTL extension
242
+ record.expiresAt = now + DEFAULT_TTL_MS
243
+ return record
244
+ }
245
+
246
+ /**
247
+ * Clear session-scoped reasoning configuration.
248
+ * @param {string} sessionId
249
+ * @returns {boolean}
250
+ */
251
+ export function clearSessionReasoning(sessionId) {
252
+ if (!sessionId) return false
253
+ return sessionReasoningStore.delete(sessionId)
254
+ }
255
+
256
+ /**
257
+ * Prune expired session reasoning entries.
258
+ * @param {number} [now]
259
+ * @returns {number} count of pruned sessions
260
+ */
261
+ export function pruneSessionReasoning(now = Date.now()) {
262
+ let pruned = 0
263
+ for (const [id, record] of sessionReasoningStore.entries()) {
264
+ if (record.expiresAt <= now) {
265
+ sessionReasoningStore.delete(id)
266
+ pruned++
267
+ }
268
+ }
269
+ return pruned
270
+ }
271
+
272
+ /**
273
+ * Get internal store statistics.
274
+ * @returns {{ activeSessions: number }}
275
+ */
276
+ export function getSessionReasoningStats() {
277
+ return { activeSessions: sessionReasoningStore.size }
278
+ }
279
+
280
+ /**
281
+ * Resolve effective reasoning effort for a request turn.
282
+ * Automatically guards against model switching: if the session has a stored
283
+ * reasoning effort but the target model is non-reasoning, the parameter is suppressed.
284
+ * @param {string|undefined} sessionId
285
+ * @param {any} options
286
+ * @param {Array<any>} [catalog]
287
+ * @returns {{ effort?: string, budgetTokens?: number }}
288
+ */
289
+ export function resolveEffectiveReasoning(sessionId, options, catalog = []) {
290
+ const extracted = extractReasoningOptions(options)
291
+ const provider = options && options.provider
292
+ const targetModel = options && options.model
293
+
294
+ // 1. Explicit request-level option takes precedence
295
+ if (extracted.effort || extracted.budgetTokens) {
296
+ const val = validateReasoningEffort(provider, targetModel, extracted.effort, catalog)
297
+ if (val.valid) {
298
+ return {
299
+ effort: val.effort === 'off' ? 'off' : val.effort,
300
+ budgetTokens: extracted.budgetTokens,
301
+ }
302
+ }
303
+ // If invalid (e.g. non-reasoning model), suppress so request does not fail
304
+ return { effort: undefined, budgetTokens: undefined }
305
+ }
306
+
307
+ // 2. Fall back to session-scoped configuration
308
+ if (sessionId) {
309
+ const record = getSessionReasoning(sessionId)
310
+ if (record && record.effort) {
311
+ // Validate that the target model currently being invoked supports reasoning
312
+ const val = validateReasoningEffort(provider, targetModel, record.effort, catalog)
313
+ if (val.valid && val.effort !== 'off') {
314
+ return {
315
+ effort: val.effort,
316
+ budgetTokens: record.budgetTokens,
317
+ }
318
+ }
319
+ }
320
+ }
321
+
322
+ return { effort: undefined, budgetTokens: undefined }
323
+ }
@@ -1,3 +1,11 @@
1
+ import {
2
+ getModelReasoningCapabilities,
3
+ setSessionReasoning,
4
+ getSessionReasoning,
5
+ clearSessionReasoning,
6
+ DEFAULT_VENDOR_EFFORTS,
7
+ validateReasoningEffort,
8
+ } from '../reasoning-effort.js'
1
9
  import { defaultConcurrencyQueue } from '../concurrency-queue.js'
2
10
  import { calculateAnalytics } from '../analytics.js'
3
11
  import { runDiagnosticWizard } from '../diagnostic-wizard.js'
@@ -614,4 +622,72 @@ export function registerStatusRoutes(ctx, state) {
614
622
  writeJson(res, 200, { ok: true, report })
615
623
  }),
616
624
  }), 'dsh-subscriptions: /diagnostic-wizard')
625
+
626
+ // #505: Session-scoped reasoning effort management
627
+ ctx.effect(() => ctx.webServer.register({
628
+ kind: 'exact',
629
+ path: '/dsh-subscriptions/session-reasoning',
630
+ handler: safeJsonHandler(async (req, res) => {
631
+ if (!isTrustedSettingsRequest(req, ctx)) {
632
+ writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
633
+ return
634
+ }
635
+
636
+ const q = queryOf(req)
637
+ if (req.method === 'GET') {
638
+ const sessionId = q.get('sessionId') || ''
639
+ const provider = q.get('provider') || ''
640
+ const model = q.get('model') || ''
641
+ const current = sessionId ? getSessionReasoning(sessionId) : null
642
+ const caps = model ? getModelReasoningCapabilities(provider, model) : { supported: false, levels: [] }
643
+ writeJson(res, 200, {
644
+ ok: true,
645
+ sessionId: sessionId || null,
646
+ effort: (current && current.effort) || (caps.supported ? caps.defaultEffort : null),
647
+ budgetTokens: (current && current.budgetTokens) || null,
648
+ capabilities: caps,
649
+ defaults: DEFAULT_VENDOR_EFFORTS,
650
+ validateReasoningEffort,
651
+ })
652
+ return
653
+ }
654
+
655
+ if (req.method === 'POST') {
656
+ let body = {}
657
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore */ }
658
+ const { sessionId, provider, model, effort, budgetTokens } = body
659
+ if (!sessionId) {
660
+ writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'sessionId is required' } })
661
+ return
662
+ }
663
+ const val = validateReasoningEffort(provider, model, effort)
664
+ if (!val.valid) {
665
+ writeJson(res, 400, { ok: false, error: { code: val.reason || 'invalid_effort', message: 'invalid reasoning effort for model' } })
666
+ return
667
+ }
668
+ const result = setSessionReasoning(sessionId, { provider, model, effort, budgetTokens })
669
+ if (!result.ok) {
670
+ writeJson(res, 400, { ok: false, error: { code: result.error || 'validation_failed', message: 'invalid reasoning effort for model' } })
671
+ return
672
+ }
673
+ writeJson(res, 200, { ok: true, sessionId, effort: effort || 'off', model: model || null })
674
+ return
675
+ }
676
+
677
+ if (req.method === 'DELETE') {
678
+ let body = {}
679
+ try { body = JSON.parse((await readBody(req, 64 * 1024)).toString('utf8') || '{}') } catch { /* ignore */ }
680
+ const sessionId = q.get('sessionId') || body.sessionId
681
+ if (!sessionId) {
682
+ writeJson(res, 400, { ok: false, error: { code: 'bad_request', message: 'sessionId is required' } })
683
+ return
684
+ }
685
+ const cleared = clearSessionReasoning(sessionId)
686
+ writeJson(res, 200, { ok: true, sessionId, cleared })
687
+ return
688
+ }
689
+
690
+ writeJson(res, 405, { ok: false, error: { code: 'method', message: 'GET, POST, DELETE only' } })
691
+ }),
692
+ }), 'dsh-subscriptions: /session-reasoning')
617
693
  }
@@ -33,15 +33,29 @@ export function providerInfo() {
33
33
  const BASE_EFFORT_LEVELS = ['low', 'medium', 'high']
34
34
 
35
35
  export function effortLevelsForModel(modelId) {
36
- const m = /^claude-(opus|sonnet|haiku|fable)(?:-(\d+))?(?:-(\d+))?/.exec(String(modelId || ''))
37
- if (!m) return null
38
- const [, family, majorRaw, minorRaw] = m
39
- if (family === 'haiku' || family === 'fable') return null
40
- const major = majorRaw ? Number(majorRaw) : null
41
- const minor = minorRaw ? Number(minorRaw) : null
36
+ const s = String(modelId || '').toLowerCase()
37
+ let m = /^claude-(opus|sonnet|haiku|fable)(?:-(\d+))?(?:-(\d+))?/.exec(s)
38
+ let family, major, minor
39
+ if (m) {
40
+ family = m[1]
41
+ major = m[2] ? Number(m[2]) : null
42
+ minor = m[3] ? Number(m[3]) : null
43
+ } else {
44
+ m = /^claude-(\d+)[-.](\d+)-(opus|sonnet|haiku|fable)/.exec(s)
45
+ if (m) {
46
+ major = Number(m[1])
47
+ minor = Number(m[2])
48
+ family = m[3]
49
+ }
50
+ }
51
+ if (!family || family === 'haiku' || family === 'fable') return null
42
52
  const atLeast = (wantMajor, wantMinor) => (
43
53
  major === null || major > wantMajor || (major === wantMajor && (minor === null || minor >= wantMinor))
44
54
  )
55
+ // Claude 3.7+ Sonnet supports extended thinking
56
+ if (family === 'sonnet' && major === 3 && minor !== null && minor >= 7) {
57
+ return [...BASE_EFFORT_LEVELS]
58
+ }
45
59
  // Adaptive thinking / effort exist from Opus 4.6 and Sonnet 4.6 onward.
46
60
  if (!atLeast(4, 6)) return null
47
61
  const levels = [...BASE_EFFORT_LEVELS]
@@ -174,6 +188,11 @@ export async function listModels(blob, cfg, fetchImpl) {
174
188
  // selection) leaves the request untouched so behavior stays opt-in.
175
189
  function applyClaudeThinking(payload, options) {
176
190
  const effort = options && String(options.reasoningEffort || '')
191
+ const budget = options && (options.thinkingBudget || options.budgetTokens || (options.thinking && options.thinking.budget_tokens))
192
+ if (budget && Number(budget) > 0) {
193
+ payload.thinking = { type: 'enabled', budget_tokens: Math.max(1024, Number(budget)) }
194
+ return payload
195
+ }
177
196
  if (!effort || effort === 'off') return payload
178
197
  const levels = effortLevelsForModel(payload && payload.model)
179
198
  if (!levels || !levels.includes(effort)) return payload
package/lib/wire.js CHANGED
@@ -275,6 +275,8 @@ export async function* anthropicStream(body) {
275
275
  id: toolId,
276
276
  argumentsDelta: delta.partial_json,
277
277
  }
278
+ } else if (delta.type === 'thinking_delta' && delta.thinking) {
279
+ yield { type: 'reasoning-delta', index: json.index ?? 0, text: delta.thinking }
278
280
  }
279
281
  } else if (json.type === 'message_delta') {
280
282
  sawTerminal = true
@@ -354,7 +356,9 @@ export async function* googleStream(body) {
354
356
  }
355
357
  const parts = cand && cand.content && Array.isArray(cand.content.parts) ? cand.content.parts : []
356
358
  for (const part of parts) {
357
- if (part.text) {
359
+ if (part.thought && part.text) {
360
+ yield { type: 'reasoning-delta', index, text: part.text }
361
+ } else if (part.text) {
358
362
  if (!started) {
359
363
  yield { type: 'block-start', index, blockType: 'text' }
360
364
  started = true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.6.64",
3
+ "version": "0.6.65",
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",