@goodandready/dsh-subscriptions 0.4.16 → 0.4.17

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
@@ -136,6 +136,22 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
136
136
 
137
137
  ---
138
138
 
139
+ ### 9. 🦛 Local Ollama Gateway & Seamless Fallback (`v0.4.17`)
140
+ * **Native Provider (`ollama`)**: When a local Ollama is reachable at `ollamaBaseUrl` (default `http://127.0.0.1:11434`), it appears in the native DSH model picker with the models discovered from `/api/tags`. No API key needed.
141
+ * **Seamless Quota Fallback (`ollamaFallback`, on by default)**: When every account of a provider is exhausted (or unreachable) and nothing has been streamed yet, the chat continues on a local model (`ollamaFallbackModel`, or the first model from `/api/tags`). The fallback is logged and recorded in request history as `kind: fallback`.
142
+ * **Free ($0) Emergency Path**: Works with no internet and no quota — ideal for offline demos.
143
+
144
+ ### 10. ⚡ Reasoning Effort, Verbosity & Fast Mode (`v0.4.17`)
145
+ * **Reasoning Effort**: Codex models advertise their supported effort levels from the live catalog; the native picker validates and the chosen effort is transmitted as `reasoning.effort` in the Codex `/responses` protocol. Grok forwards effort with its own catalog-aware filtering.
146
+ * **Verbosity (`codexVerbosity`)**: `low` / `medium` / `high` is sent as `text.verbosity` for Codex reasoning models. Empty = protocol default.
147
+ * **Fast Mode (`codexFastMode`)**: Sends `service_tier: priority` (1.5x speed billing tier) with every Codex request. The active-subscription chip shows a `⚡` prefix while enabled.
148
+
149
+ ### 11. 🚦 Family-Scoped Cooldowns & Model Filtering (`v0.4.17`)
150
+ * **Reasoning vs Standard**: A 429 on a reasoning model (claude `*thinking*`, grok `*reasoning*`, all codex models) cools down only the reasoning family of that account — standard models on the same account keep working immediately. Legacy cooldowns (from older versions) still block the whole account until expiry.
151
+ * **Hide Deprecated Models (`hideDeprecatedModels`)**: Filters `test`/`preview`/`dev`/`alpha`/`beta`/`legacy` model ids out of the native picker (applies to live catalogs and the static fallback).
152
+
153
+ ---
154
+
139
155
  ## 📦 Quick Installation
140
156
 
141
157
  ```bash
@@ -155,6 +171,12 @@ dsh-subscriptions:
155
171
  cooldownMs: 60000
156
172
  autoLoopback: true # v0.4.9: catch loopback OAuth callbacks automatically
157
173
  privacyMask: false # v0.4.9: mask emails and account identifiers in the UI
174
+ ollamaBaseUrl: http://127.0.0.1:11434 # v0.4.17: local Ollama gateway
175
+ ollamaFallback: true # v0.4.17: seamless fallback when all accounts are exhausted
176
+ ollamaFallbackModel: '' # v0.4.17: e.g. qwen2.5-coder; empty = first model from /api/tags
177
+ hideDeprecatedModels: false # v0.4.17: filter test/preview/beta/legacy model ids
178
+ codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
179
+ codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
158
180
  # Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
159
181
  accounts:
160
182
  codex:
package/README.ru.md CHANGED
@@ -136,6 +136,22 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
136
136
 
137
137
  ---
138
138
 
139
+ ### 9. 🦛 Локальный шлюз Ollama и бесшовный фолбэк (`v0.4.17`)
140
+ * **Нативный провайдер (`ollama`)**: если локальный Ollama доступен по `ollamaBaseUrl` (по умолчанию `http://127.0.0.1:11434`), он появляется в нативном селекторе моделей DSH с моделями из `/api/tags`. Ключ не нужен.
141
+ * **Бесшовный фолбэк (`ollamaFallback`, включён по умолчанию)**: когда все аккаунты провайдера исчерпаны (или недоступны) и ещё ничего не выведено, чат продолжается на локальной модели (`ollamaFallbackModel`, либо первая из `/api/tags`). Фолбэк пишется в журнал и в историю запросов как `kind: fallback`.
142
+ * **Бесплатный ($0) аварийный путь**: работает без интернета и без квот — удобно для офлайн-демо.
143
+
144
+ ### 10. ⚡ Effort, Verbosity и Fast Mode (`v0.4.17`)
145
+ * **Уровень рассуждений (Effort)**: модели Codex объявляют поддерживаемые уровни из живого каталога; нативный селектор валидирует выбор, и выбранный уровень передаётся как `reasoning.effort` в протокол `/responses`. Grok передаёт effort со своей каталог-осознанной фильтрацией.
146
+ * **Детализация (`codexVerbosity`)**: `low` / `medium` / `high` передаётся как `text.verbosity` для reasoning-моделей Codex. Пусто = дефолт протокола.
147
+ * **Fast Mode (`codexFastMode`)**: c каждым Codex-запросом передаётся `service_tier: priority` (платный скоростной тир 1.5x). В чипе активной подписки при включённом режиме отображается `⚡`.
148
+
149
+ ### 11. 🚦 Кулдауны по семействам и фильтрация моделей (`v0.4.17`)
150
+ * **Reasoning vs Standard**: 429 на reasoning-модели (claude `*thinking*`, grok `*reasoning*`, все codex-модели) охлаждает только семейство reasoning этого аккаунта — standard-модели того же аккаунта продолжают работать. Legacy-кулдауны (от старых версий) по-прежнему блокируют весь аккаунт до истечения.
151
+ * **Скрыть устаревшие модели (`hideDeprecatedModels`)**: убирает из нативного селектора id с `test`/`preview`/`dev`/`alpha`/`beta`/`legacy` (действует и на живые каталоги, и на статичный фолбэк).
152
+
153
+ ---
154
+
139
155
  ## 📦 Быстрая установка
140
156
 
141
157
  ```bash
@@ -152,6 +168,12 @@ dsh-subscriptions:
152
168
  cooldownMs: 60000
153
169
  autoLoopback: true # v0.4.9: автоматически ловить loopback-callback
154
170
  privacyMask: false # v0.4.9: маскировать email и учётные записи в UI
171
+ ollamaBaseUrl: http://127.0.0.1:11434 # v0.4.17: локальный шлюз Ollama
172
+ ollamaFallback: true # v0.4.17: бесшовный фолбэк при исчерпании всех аккаунтов
173
+ ollamaFallbackModel: '' # v0.4.17: например qwen2.5-coder; пусто = первая модель из /api/tags
174
+ hideDeprecatedModels: false # v0.4.17: фильтровать test/preview/beta/legacy id моделей
175
+ codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
176
+ codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
155
177
  # Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
156
178
  accounts:
157
179
  codex:
package/README.zh.md CHANGED
@@ -89,6 +89,20 @@ graph LR
89
89
  * 每个账号可配置独立代理(`http://`、`https://`、`socks5://`);令牌刷新、供应商检查、模型请求均走该代理。
90
90
  * 账号卡片内的 **Check proxy** 按钮一键检测代理延迟。
91
91
 
92
+ ### 本地 Ollama 网关与无缝回退(`v0.4.17`)
93
+ * **原生 provider (`ollama`)**:本地 Ollama 可达时(`ollamaBaseUrl`,默认 `http://127.0.0.1:11434`),自动出现在 DSH 原生模型选择器中(模型来自 `/api/tags`),无需密钥。
94
+ * **无缝配额回退 (`ollamaFallback`,默认开启)**:某供应商的所有账号耗尽或不可达且尚未输出任何内容时,对话自动切换到本地模型(`ollamaFallbackModel` 或 `/api/tags` 第一个模型),并记录到请求历史(`kind: fallback`)。
95
+ * 免费($0)应急通道:无网络、无配额也能用。
96
+
97
+ ### Effort、Verbosity 与 Fast Mode(`v0.4.17`)
98
+ * **推理力度 (Effort)**:Codex 模型从实时目录上报支持的力度等级,原生选择器校验后以 `reasoning.effort` 传入 `/responses` 协议;Grok 按自身目录过滤转发。
99
+ * **详略程度 (`codexVerbosity`)**:`low`/`medium`/`high` 以 `text.verbosity` 传给 Codex 推理模型;留空为协议默认。
100
+ * **Fast Mode (`codexFastMode`)**:每次 Codex 请求携带 `service_tier: priority`(1.5x 速度计费档);启用时活动订阅芯片显示 `⚡`。
101
+
102
+ ### 按模型家族的冷却与模型过滤(`v0.4.17`)
103
+ * **Reasoning 与 Standard 分离**:推理模型(claude `*thinking*`、grok `*reasoning*`、全部 codex 模型)触发 429 时,只冷却该账号的 reasoning 家族——同账号的 standard 模型立即可用;旧版冷却仍按整账号生效。
104
+ * **隐藏过时模型 (`hideDeprecatedModels`)**:从原生选择器中过滤 `test`/`preview`/`dev`/`alpha`/`beta`/`legacy` 模型 id(同时作用于实时目录与静态回退)。
105
+
92
106
  ### 隐私模式与诊断报告(`v0.4.9`)
93
107
  * **`privacyMask`**:一个开关即可在整个界面隐藏个人数据(邮箱显示为 `j***n@example.com`),服务端遮蔽,适合屏幕共享。
94
108
  * **匿名诊断报告**:设置卡片内一键生成(插件/运行时版本、系统、各供应商健康状态、HTTP 状态聚合、最近错误与耗时、非敏感配置)并自动复制到剪贴板;令牌、邮箱、凭据名与代理地址严格排除。
package/lib/accounts.js CHANGED
@@ -42,6 +42,9 @@ export function vendorConfig(provider, cfg) {
42
42
  originator: pick('Originator', d.originator || ''),
43
43
  systemPrefix: pick('SystemPrefix', d.systemPrefix || ''),
44
44
  clientVersion: pick('ClientVersion', d.clientVersion || ''),
45
+ // #93/#92: per-vendor request shaping (codex only consumes these today).
46
+ verbosity: pick('Verbosity', ''),
47
+ fastMode: !!(cfg && cfg[`${provider}FastMode`]),
45
48
  models: Array.isArray(cfg && cfg[modelsKey]) && cfg[modelsKey].length
46
49
  ? cfg[modelsKey]
47
50
  : (d.models || []),
@@ -124,7 +127,8 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
124
127
  }
125
128
  return {
126
129
  ...base,
127
- cooldownUntil: cooldowns.get(ref) || 0,
130
+ cooldownUntil: cooldowns.get(ref) ? cooldowns.get(ref).until : 0,
131
+ cooldownFamilies: cooldowns.get(ref) && cooldowns.get(ref).families ? cooldowns.get(ref).families : null,
128
132
  quota: quotas.get(ref) || null,
129
133
  usage: windows.get(ref) || base.usage || null,
130
134
  health: health.get(ref) || null,
@@ -272,7 +276,15 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
272
276
  resolveRaw,
273
277
  ensureFresh,
274
278
  refreshUsage,
275
- rememberCooldown(ref, until) { cooldowns.set(ref, until) },
279
+ // #87: family-scoped cooldowns. families=null blocks the whole account
280
+ // (legacy behavior); a list blocks only those model families.
281
+ rememberCooldown(ref, until, families) {
282
+ if (!families || !families.length) { cooldowns.set(ref, { until: Number(until) || 0, families: null }); return }
283
+ const prev = cooldowns.get(ref)
284
+ const merged = new Set(families)
285
+ if (prev && Array.isArray(prev.families)) for (const f of prev.families) merged.add(f)
286
+ cooldowns.set(ref, { until: Math.max(Number(until) || 0, prev ? prev.until : 0), families: Array.from(merged) })
287
+ },
276
288
  rememberQuota(ref, snap) { if (snap) quotas.set(ref, snap); else quotas.delete(ref) },
277
289
  shouldSkipRefresh(ref, now, retryMs) { const f = refreshFailures.get(ref); if (!f) return false; return (Number(f.at) + Number(retryMs)) > Number(now) },
278
290
  getQuota(ref) { return quotas.get(ref) || null },
package/lib/adapter.js CHANGED
@@ -5,6 +5,11 @@ import { modelCatalog } from './messages.js'
5
5
  import { streamWithRotation } from './stream-rotate.js'
6
6
  import { pickFetch } from './proxy.js'
7
7
 
8
+ // #94: heuristic for test/preview/beta/legacy model ids.
9
+ function isDeprecatedId(id) {
10
+ return /(^|[-_:])(test|preview|dev|alpha|beta|snapshot|experimental|legacy|deprecated)([-_:]|$)/i.test(id)
11
+ }
12
+
8
13
  function asLlmError(err) {
9
14
  if (err instanceof LlmError) return err
10
15
  const code = (err && err.code) || 'VENDOR'
@@ -37,16 +42,22 @@ export class SubscriptionAdapter extends LlmAdapter {
37
42
  if (!accounts.some((a) => a.hasToken)) return []
38
43
  const cfg = this.deps.vendorConfig(provider)
39
44
  const fetchImpl = this.deps.fetchImpl || fetch
45
+ let models = null
40
46
  try {
41
47
  const blob = await this.deps.ensureFresh(
42
48
  provider,
43
49
  await this.deps.loadBlob(accounts.find((a) => a.hasToken).ref),
44
50
  accounts.find((a) => a.hasToken).ref,
45
51
  )
46
- const models = await getVendor(provider).listModels(blob, cfg, fetchImpl)
47
- if (Array.isArray(models) && models.length) return models
52
+ const live = await getVendor(provider).listModels(blob, cfg, fetchImpl)
53
+ if (Array.isArray(live) && live.length) models = live
48
54
  } catch { /* use built-in catalog */ }
49
- return modelCatalog(provider, cfg.models)
55
+ if (!models) models = modelCatalog(provider, cfg.models)
56
+ // #94: optionally hide test/preview/beta/legacy ids from the picker.
57
+ if (typeof this.deps.hideDeprecatedModels === 'function' && this.deps.hideDeprecatedModels()) {
58
+ models = models.filter((m) => !isDeprecatedId(String((m && m.id) || '')))
59
+ }
60
+ return models
50
61
  }
51
62
 
52
63
  async resolveModel(provider, model, _signal) {
@@ -69,18 +80,19 @@ export class SubscriptionAdapter extends LlmAdapter {
69
80
  async *stream(options) {
70
81
  const provider = options.provider
71
82
  const deps = this.deps
83
+ let yieldedAny = false
72
84
  try {
73
85
  if (typeof deps.refreshUsage === 'function') {
74
86
  await deps.refreshUsage(provider)
75
87
  }
76
- yield* streamWithRotation({
88
+ for await (const event of streamWithRotation({
77
89
  accounts: await deps.listAccounts(provider),
78
90
  nowMs: () => Date.now(),
79
91
  cooldownMs: deps.cooldownMs(),
80
92
  switchAtRemaining: typeof deps.switchAtRemaining === 'function' ? deps.switchAtRemaining() : (deps.switchAtRemaining ?? 0),
81
93
  options,
82
94
  onCooldown: (account) => {
83
- deps.rememberCooldown(account.ref, account.cooldownUntil)
95
+ deps.rememberCooldown(account.ref, account.cooldownUntil, account.cooldownFamilies || null)
84
96
  if (typeof deps.recordSwitch === 'function') deps.recordSwitch(account.ref)
85
97
  },
86
98
  streamOnce: async function* (account, opts) {
@@ -148,8 +160,15 @@ export class SubscriptionAdapter extends LlmAdapter {
148
160
  throw err
149
161
  }
150
162
  },
151
- })
163
+ })) { yieldedAny = true; yield event }
164
+ return
152
165
  } catch (err) {
166
+ // #91: when the whole pool is exhausted and nothing has been streamed
167
+ // yet, continue seamlessly on local Ollama instead of failing the chat.
168
+ if (!yieldedAny && typeof deps.ollamaFallback === 'function') {
169
+ yield* deps.ollamaFallback({ options, provider, err })
170
+ return
171
+ }
153
172
  throw asLlmError(err)
154
173
  }
155
174
  }
package/lib/client.js CHANGED
@@ -788,7 +788,7 @@ function PluginCard(props) {
788
788
  expiryNotifyDays: (data && data.expiryNotifyDays) || 7,
789
789
  active: (data && data.active) || null,
790
790
  }
791
- } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, active: null } }
791
+ } catch { return { logged: [], usage: 0, expiresAt: {}, labels: {}, expiryNotifyDays: 7, fastMode: false, active: null } }
792
792
  }
793
793
 
794
794
  // #52: компактный чип в шапке сессии со статусом подключённых подписок.
@@ -833,7 +833,7 @@ function PluginCard(props) {
833
833
  const idx = a.index != null ? ('#' + a.index) : ''
834
834
  const plan = a.plan ? (' ' + a.plan) : ''
835
835
  // #77: без provider-префикса
836
- const label = idx + plan
836
+ const label = (a.provider === 'codex' && state.fastMode ? '\u26a1' : '') + idx + plan
837
837
  // windows: "5h 12% 7d 8%"
838
838
  const winLabels = wins.map((w) => (w.label || w.id) + ' ' + Math.round(w.usedPercent) + '%').join(' ')
839
839
  return React.createElement('span', {
package/lib/index.js CHANGED
@@ -15,6 +15,7 @@ import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
15
15
  import { quotaSnapshot } from './ratelimit.js'
16
16
  import { createAccountStore, normalizeSlots, vendorConfig } from './accounts.js'
17
17
  import { startLoopback } from './loopback.js'
18
+ import { OllamaAdapter, ollamaAlive, ollamaModels, ollamaBase } from './ollama.js'
18
19
  import { maskEmail, maskLabel, maskText } from './mask.js'
19
20
  import { proxyFetch, pickFetch } from './proxy.js'
20
21
  import { HistoryStore } from './history.js'
@@ -71,7 +72,19 @@ export const Config = z.object({
71
72
  .description('When on, redirect_uri is this Web UI origin + /dsh-subscriptions/oauth/callback. When off, the vendor CLI registered redirect is used and you paste the redirected URL.'),
72
73
  autoLoopback: z.boolean().default(true)
73
74
  .description('#89 When on and the vendor redirect_uri is a loopback address (codex :1455, grok :56121), a temporary local server catches the OAuth callback automatically - no paste needed. Paste fallback stays available.'),
75
+ ollamaBaseUrl: z.string().default('http://127.0.0.1:11434')
76
+ .description('#91 Local Ollama base URL. Served as the ollama provider in the native model picker when reachable.'),
77
+ ollamaFallback: z.boolean().default(true)
78
+ .description('#91 When every account of a provider is exhausted, continue the chat on local Ollama instead of failing.'),
79
+ ollamaFallbackModel: z.string().default('')
80
+ .description('#91 Ollama model used for the fallback (for example qwen2.5-coder). Empty = first model from /api/tags.'),
81
+ hideDeprecatedModels: z.boolean().default(false)
82
+ .description('#94 Hide test/preview/beta/legacy model ids from the native model picker.'),
74
83
  codexClientId: z.string().default(''),
84
+ codexVerbosity: z.string().default('')
85
+ .description('#93 Response verbosity for Codex reasoning models: low, medium or high. Empty = protocol default.'),
86
+ codexFastMode: z.boolean().default(false)
87
+ .description('#92 Fast Mode for Codex: sends service_tier priority (1.5x speed billing tier) with every request.'),
75
88
  codexRedirectUri: z.string().default(''),
76
89
  codexBaseUrl: z.string().default(''),
77
90
  claudeClientId: z.string().default(''),
@@ -165,6 +178,44 @@ export function apply(ctx, config) {
165
178
  const history = new HistoryStore()
166
179
  const recordHistory = (entry) => history.add(entry)
167
180
 
181
+ // #91: local Ollama - native provider + seamless fallback when the whole
182
+ // pool is exhausted and nothing has been streamed yet.
183
+ const ollamaAdapter = new OllamaAdapter({
184
+ baseUrl: () => ollamaBase(live()),
185
+ fallbackModel: () => live().ollamaFallbackModel || '',
186
+ })
187
+ let ollamaHandle
188
+ async function syncOllama() {
189
+ const cfg = live()
190
+ const alive = !!cfg.ollamaFallback && await ollamaAlive(ollamaBase(cfg), fetch)
191
+ if (alive && !ollamaHandle) {
192
+ try { ollamaHandle = ctx.llm.registerAdapter(['ollama'], ollamaAdapter) } catch { /* already registered elsewhere */ }
193
+ } else if (!alive && ollamaHandle) {
194
+ try { ollamaHandle() } catch { /* already gone */ }
195
+ ollamaHandle = undefined
196
+ }
197
+ }
198
+ async function* ollamaFallbackStream({ options, provider, err }) {
199
+ const cfg = live()
200
+ const models = await ollamaModels(ollamaBase(cfg), fetch).catch(() => [])
201
+ if (!cfg.ollamaFallback || !models.length) throw err
202
+ const model = cfg.ollamaFallbackModel || models[0].id
203
+ try { ctx.emit && ctx.emit('subscriptions.ollama-fallback', { provider, model, reason: err && err.code || 'EXHAUSTED' }) } catch {}
204
+ try { ctx.log && ctx.log.warn && ctx.log.warn(`[dsh-subscriptions] ${provider}: все аккаунты исчерпаны (${err && err.code || 'EXHAUSTED'}), откат на ollama/${model}`) } catch {}
205
+ try {
206
+ recordHistory({
207
+ provider: 'ollama',
208
+ ref: 'OLLAMA_FALLBACK',
209
+ model,
210
+ path: '/v1/chat/completions',
211
+ method: 'POST',
212
+ status: 200,
213
+ kind: 'fallback',
214
+ })
215
+ } catch {}
216
+ yield* ollamaAdapter.stream({ ...options, provider: 'ollama', model })
217
+ }
218
+
168
219
  const subscriptions = createSubscriptionsService({
169
220
  listAccounts: (provider) => store.listAccounts(provider),
170
221
  loadBlob: (ref) => store.loadBlob(ref),
@@ -172,7 +223,7 @@ export function apply(ctx, config) {
172
223
  vendorConfig: (provider) => vendorConfig(provider, live()),
173
224
  cooldownMs: () => live().cooldownMs,
174
225
  switchAtRemaining: () => live().switchAtRemaining,
175
- rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
226
+ rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
176
227
  recordSuccess: (ref) => store.recordSuccess(ref),
177
228
  getHealth: (ref) => store.getHealth(ref),
178
229
  recordSwitch: (ref) => store.recordSwitch(ref),
@@ -185,6 +236,8 @@ export function apply(ctx, config) {
185
236
  recordHistory,
186
237
  fetchImpl: fetch,
187
238
  fetchForRef,
239
+ ollamaFallback: ollamaFallbackStream,
240
+ hideDeprecatedModels: () => !!live().hideDeprecatedModels,
188
241
  })
189
242
 
190
243
  // Служба генерации картинок на подписке.
@@ -235,7 +288,7 @@ export function apply(ctx, config) {
235
288
  vendorConfig: (provider) => vendorConfig(provider, live()),
236
289
  cooldownMs: () => live().cooldownMs,
237
290
  switchAtRemaining: () => live().switchAtRemaining,
238
- rememberCooldown: (ref, until) => store.rememberCooldown(ref, until),
291
+ rememberCooldown: (ref, until, families) => store.rememberCooldown(ref, until, families),
239
292
  rememberQuota: (ref, snap) => store.rememberQuota(ref, snap),
240
293
  getQuota: (ref) => store.getQuota(ref),
241
294
  refreshUsage: (provider) => store.refreshUsage(provider),
@@ -470,6 +523,7 @@ export function apply(ctx, config) {
470
523
 
471
524
  ctx.effect(() => {
472
525
  syncAdapter().catch(() => { /* first paint */ })
526
+ syncOllama().catch(() => { /* first paint */ })
473
527
  // #75: eager refresh usage на старте, чтобы windows (5h/7d) появились в blob сразу
474
528
  // и активная подписка в чипе сразу показывала 5h/7d/..., а не ждала probeInterval.
475
529
  const eager = async () => {
@@ -560,7 +614,7 @@ export function apply(ctx, config) {
560
614
  await tick()
561
615
  }
562
616
  wrapped().catch(() => {})
563
- const timer = setInterval(() => { wrapped().catch(() => {}) }, 60 * 1000)
617
+ const timer = setInterval(() => { wrapped().catch(() => {}); syncOllama().catch(() => {}) }, 60 * 1000)
564
618
  return () => clearInterval(timer)
565
619
  }, 'dsh-subscriptions: probe loop')
566
620
 
@@ -676,6 +730,7 @@ export function apply(ctx, config) {
676
730
  expiresAt: expires,
677
731
  labels,
678
732
  expiryNotifyDays: live().expiryNotifyDays,
733
+ fastMode: !!live().codexFastMode,
679
734
  active,
680
735
  })
681
736
  },
package/lib/messages.js CHANGED
@@ -65,7 +65,7 @@ export function openaiTools(options) {
65
65
  }))
66
66
  }
67
67
 
68
- export function codexResponsesBody(options, fallbackInstructions) {
68
+ export function codexResponsesBody(options, fallbackInstructions, vendorCfg) {
69
69
  const systemParts = []
70
70
  if (options.system) systemParts.push(options.system)
71
71
  const input = []
@@ -134,6 +134,13 @@ export function codexResponsesBody(options, fallbackInstructions) {
134
134
  instructions,
135
135
  input,
136
136
  ...(responsesTools && responsesTools.length ? { tools: responsesTools } : {}),
137
+ // #93: reasoning effort chosen in the native picker flows to the protocol.
138
+ ...(options.reasoningEffort ? { reasoning: { effort: String(options.reasoningEffort) } } : {}),
139
+ // #93: verbosity comes from the codexVerbosity setting (low/medium/high).
140
+ ...(vendorCfg && /^(low|medium|high)$/.test(String(vendorCfg.verbosity || ''))
141
+ ? { text: { verbosity: String(vendorCfg.verbosity) } } : {}),
142
+ // #92: Fast Mode = 1.5x speed billing tier on the Codex backend.
143
+ ...(vendorCfg && vendorCfg.fastMode ? { service_tier: 'priority' } : {}),
137
144
  ...(options.maxTokens != null ? { max_output_tokens: options.maxTokens } : {}),
138
145
  ...(options.temperature != null ? { temperature: options.temperature } : {}),
139
146
  }
package/lib/ollama.js ADDED
@@ -0,0 +1,70 @@
1
+ import { LlmAdapter, LlmError, attributionHeaders } from '@deepseek-ai/dsh-llm'
2
+ import { openaiMessages, openaiTools } from './messages.js'
3
+ import { openaiChatStream } from './wire.js'
4
+
5
+ // #91: local Ollama gateway - a $0 emergency provider and seamless quota
6
+ // fallback. Speaks the OpenAI-compatible subset Ollama serves at /v1.
7
+
8
+ export function ollamaBase(cfg) {
9
+ return String(cfg.ollamaBaseUrl || 'http://127.0.0.1:11434').replace(/\/$/, '')
10
+ }
11
+
12
+ export async function ollamaAlive(baseUrl, fetchImpl, timeoutMs = 2000) {
13
+ try {
14
+ const res = await fetchImpl(baseUrl + '/api/tags', { signal: AbortSignal.timeout(timeoutMs) })
15
+ return !!res && res.ok
16
+ } catch { return false }
17
+ }
18
+
19
+ export async function ollamaModels(baseUrl, fetchImpl) {
20
+ const res = await fetchImpl(baseUrl + '/api/tags', { signal: AbortSignal.timeout(3000) })
21
+ if (!res || !res.ok) throw new LlmError('ollama /api/tags http ' + (res && res.status), 'VENDOR', { status: res && res.status })
22
+ const j = await res.json()
23
+ return (Array.isArray(j.models) ? j.models : []).map((m) => ({
24
+ id: m.name,
25
+ name: m.name,
26
+ ...(m.details && m.details.parameter_size ? { description: m.details.parameter_size } : {}),
27
+ }))
28
+ }
29
+
30
+ export class OllamaAdapter extends LlmAdapter {
31
+ constructor(deps) {
32
+ super()
33
+ this.deps = deps
34
+ }
35
+
36
+ providerInfo(provider) {
37
+ return { id: provider, name: 'Ollama (local)' }
38
+ }
39
+
40
+ providerRetryPolicy(_provider) {
41
+ return undefined
42
+ }
43
+
44
+ async listModels(provider) {
45
+ return ollamaModels(this.deps.baseUrl(), this.deps.fetchImpl || fetch)
46
+ }
47
+
48
+ async resolveModel(provider, model, _signal) {
49
+ return { provider, id: model, name: model }
50
+ }
51
+
52
+ async *stream(options) {
53
+ const base = this.deps.baseUrl()
54
+ const model = options.model || this.deps.fallbackModel() || ''
55
+ const body = { model, messages: openaiMessages(options), stream: true }
56
+ const tools = openaiTools(options)
57
+ if (tools) body.tools = tools
58
+ const res = await (this.deps.fetchImpl || fetch)(base + '/v1/chat/completions', {
59
+ method: 'POST',
60
+ headers: { 'Content-Type': 'application/json', ...attributionHeaders() },
61
+ body: JSON.stringify(body),
62
+ signal: options.signal,
63
+ })
64
+ if (!res || !res.ok) {
65
+ const txt = res && res.text ? await res.text().catch(() => '') : ''
66
+ throw new LlmError('ollama http ' + (res && res.status) + (txt ? ': ' + txt.slice(0, 200) : ''), 'VENDOR', { status: res && res.status })
67
+ }
68
+ yield* openaiChatStream(res.body)
69
+ }
70
+ }
package/lib/rotate.js CHANGED
@@ -12,6 +12,25 @@ export function isSwitchableError(err) {
12
12
  return status === 429
13
13
  }
14
14
 
15
+ // #87: family classification. Cooldowns are scoped to the model family that
16
+ // hit the limit, so a reasoning 429 does not block standard models.
17
+ export function modelFamily(provider, model) {
18
+ const id = String(model || '')
19
+ if (provider === 'claude') return /thinking/i.test(id) ? 'reasoning' : 'standard'
20
+ if (provider === 'grok') return /reasoning/i.test(id) ? 'reasoning' : 'standard'
21
+ if (provider === 'codex') return 'reasoning'
22
+ return 'standard'
23
+ }
24
+
25
+ // Legacy cooldowns (no family list) block everything; scoped ones block only
26
+ // their own family. An unknown family never blocks a differently-scoped cooldown.
27
+ export function cooldownBlocks(acc, family) {
28
+ const fams = acc && acc.cooldownFamilies
29
+ if (!Array.isArray(fams) || !fams.length) return true
30
+ if (!family) return true
31
+ return fams.includes(family)
32
+ }
33
+
15
34
  export function pickAccount(accounts, nowMs, opts) {
16
35
  const list = Array.isArray(accounts) ? accounts : []
17
36
  const now = Number(nowMs) || 0
@@ -47,7 +66,7 @@ export function pickAccount(accounts, nowMs, opts) {
47
66
  const fallback = []
48
67
  for (const acc of list) {
49
68
  if (!acc || !acc.hasToken) continue
50
- const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now
69
+ const isCooldown = acc.cooldownUntil && Number(acc.cooldownUntil) > now && cooldownBlocks(acc, opts && opts.family)
51
70
  const exhausted = isQuotaExhausted(acc) || isUsageExhausted(acc)
52
71
  if (isCooldown || exhausted) {
53
72
  tiers[2].push(acc)
@@ -68,8 +87,14 @@ export function pickAccount(accounts, nowMs, opts) {
68
87
  return null
69
88
  }
70
89
 
71
- export function markCooldown(account, nowMs, cooldownMs) {
90
+ export function markCooldown(account, nowMs, cooldownMs, family) {
72
91
  const wait = Number(cooldownMs)
73
92
  const ms = Number.isFinite(wait) && wait > 0 ? wait : 30 * 60 * 1000
74
- return { ...account, cooldownUntil: (Number(nowMs) || 0) + ms }
93
+ const prev = Array.isArray(account.cooldownFamilies) ? account.cooldownFamilies.slice() : []
94
+ const fams = family ? (prev.includes(family) ? prev : prev.concat(family)) : prev
95
+ return {
96
+ ...account,
97
+ cooldownUntil: (Number(nowMs) || 0) + ms,
98
+ ...(fams.length ? { cooldownFamilies: fams } : {}),
99
+ }
75
100
  }
@@ -1,4 +1,4 @@
1
- import { pickAccount, markCooldown, isSwitchableError } from './rotate.js'
1
+ import { pickAccount, markCooldown, isSwitchableError, modelFamily } from './rotate.js'
2
2
 
3
3
  export async function* streamWithRotation({
4
4
  accounts,
@@ -13,7 +13,7 @@ export async function* streamWithRotation({
13
13
  let lastError = null
14
14
  const tried = new Set()
15
15
  while (true) {
16
- const account = pickAccount(pool, nowMs(), { switchAtRemaining })
16
+ const account = pickAccount(pool, nowMs(), { switchAtRemaining, family: modelFamily(options && options.provider, options && options.model) })
17
17
  if (!account) {
18
18
  if (lastError) throw lastError
19
19
  const err = new Error('no usable subscription account for this provider')
@@ -33,8 +33,9 @@ export async function* streamWithRotation({
33
33
  } catch (err) {
34
34
  lastError = err
35
35
  if (!isSwitchableError(err)) throw err
36
- const cooled = markCooldown(account, nowMs(), cooldownMs)
36
+ const cooled = markCooldown(account, nowMs(), cooldownMs, modelFamily(options && options.provider, options && options.model))
37
37
  account.cooldownUntil = cooled.cooldownUntil
38
+ if (cooled.cooldownFamilies) account.cooldownFamilies = cooled.cooldownFamilies
38
39
  if (onCooldown) onCooldown(account)
39
40
  }
40
41
  }
@@ -1,5 +1,5 @@
1
1
  import { isProvider } from "./refs.js"
2
- import { pickAccount, markCooldown, isSwitchableError } from "./rotate.js"
2
+ import { pickAccount, markCooldown, isSwitchableError, modelFamily } from "./rotate.js"
3
3
  import { quotaSnapshot } from "./ratelimit.js"
4
4
  import { getVendor } from "./vendors/index.js"
5
5
  import { pickFetch } from "./proxy.js"
@@ -92,7 +92,7 @@ export function createSubscriptionsService(deps) {
92
92
  const tried = new Set()
93
93
  let lastError = null
94
94
  while (true) {
95
- const account = pickAccount(pool, Date.now(), { switchAtRemaining: thr })
95
+ const account = pickAccount(pool, Date.now(), { switchAtRemaining: thr, family: modelFamily(provider, body && body.model) })
96
96
  if (!account) {
97
97
  if (lastError) throw lastError
98
98
  const err = new Error("no usable subscription account for this provider")
@@ -180,9 +180,9 @@ export function createSubscriptionsService(deps) {
180
180
  } catch (err) {
181
181
  lastError = err
182
182
  if (!isSwitchableError(err)) throw err
183
- const cooled = markCooldown(account, Date.now(), cooldownMs)
183
+ const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model))
184
184
  account.cooldownUntil = cooled.cooldownUntil
185
- if (typeof deps.rememberCooldown === "function") deps.rememberCooldown(account.ref, account.cooldownUntil)
185
+ if (typeof deps.rememberCooldown === "function") deps.rememberCooldown(account.ref, account.cooldownUntil, cooled.cooldownFamilies || null)
186
186
  // try next account
187
187
  }
188
188
  }
@@ -205,7 +205,7 @@ export async function usage(blob, cfg, fetchImpl) {
205
205
 
206
206
  export async function* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
207
207
  const base = (config.baseUrl || defaults().baseUrl).replace(/\/$/, '')
208
- const body = codexResponsesBody(options, INSTRUCTIONS)
208
+ const body = codexResponsesBody(options, INSTRUCTIONS, config)
209
209
  const res = await fetchImpl(`${base}/responses`, {
210
210
  method: 'POST',
211
211
  headers: {
@@ -213,6 +213,8 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
213
213
  const base = (config.baseUrl || defaults().baseUrl).replace(/\/$/, '')
214
214
  let body = codexResponsesBody(options, '')
215
215
  if (!body.instructions) delete body.instructions
216
+ // grok manages reasoning itself (cli catalog aware); drop the codex-level field
217
+ delete body.reasoning
216
218
  const catalog = await cliCatalogCached(blob, config, fetchImpl).catch(() => new Map())
217
219
  const reasoning = grokReasoningBody(options.model, options.reasoningEffort, catalog)
218
220
  if (reasoning) body.reasoning = reasoning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-subscriptions",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "description": "Use ChatGPT Codex, Claude, Grok, and Antigravity subscriptions as DeepSeek Harness LLM providers via OAuth.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -63,5 +63,8 @@
63
63
  "dependencies": {
64
64
  "socks": "^2.8.9",
65
65
  "undici": "^8.10.1"
66
+ },
67
+ "devDependencies": {
68
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.8"
66
69
  }
67
70
  }