@goodandready/dsh-subscriptions 0.4.15 → 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/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/proxy.js ADDED
@@ -0,0 +1,79 @@
1
+ // #88: per-account proxy support.
2
+ // http/https proxies -> undici ProxyAgent; socks5 -> undici Agent with a socks connector.
3
+ import { ProxyAgent, Agent, fetch as undiciFetch } from 'undici'
4
+ import tls from 'node:tls'
5
+ import { SocksClient } from 'socks'
6
+
7
+ const cache = new Map()
8
+
9
+ export function parseProxyUrl(raw) {
10
+ const s = String(raw || '').trim()
11
+ if (!s) return null
12
+ let u
13
+ try { u = new URL(s) } catch { return null }
14
+ const scheme = u.protocol.replace(':', '')
15
+ if (scheme !== 'http' && scheme !== 'https' && scheme !== 'socks5') return null
16
+ if (!u.hostname) return null
17
+ const port = Number(u.port) || (scheme === 'socks5' ? 1080 : (scheme === 'https' ? 443 : 80))
18
+ const auth = u.username
19
+ ? { username: decodeURIComponent(u.username), password: decodeURIComponent(u.password || '') }
20
+ : null
21
+ return { href: s, scheme, host: u.hostname, port, auth }
22
+ }
23
+
24
+ function socksDispatcher(p) {
25
+ return new Agent({
26
+ connect: (opts, callback) => {
27
+ SocksClient.createConnection({
28
+ proxy: {
29
+ host: p.host,
30
+ port: p.port,
31
+ type: 5,
32
+ ...(p.auth ? { userId: p.auth.username, password: p.auth.password } : {}),
33
+ },
34
+ command: 'connect',
35
+ destination: { host: opts.hostname || opts.host, port: Number(opts.port) || 443 },
36
+ }).then(({ socket }) => {
37
+ if (String(opts.protocol) !== 'https:') {
38
+ callback(null, socket)
39
+ return
40
+ }
41
+ const tlsSocket = tls.connect({
42
+ socket,
43
+ servername: opts.servername || opts.hostname || opts.host,
44
+ })
45
+ tlsSocket.once('secureConnect', () => callback(null, tlsSocket))
46
+ tlsSocket.once('error', (e) => callback(e))
47
+ }).catch(callback)
48
+ },
49
+ })
50
+ }
51
+
52
+ function httpDispatcher(p) {
53
+ return new ProxyAgent({
54
+ uri: p.href,
55
+ ...(p.auth ? { token: 'Basic ' + Buffer.from(p.auth.username + ':' + p.auth.password).toString('base64') } : {}),
56
+ })
57
+ }
58
+
59
+ /** Returns a fetch bound to the proxy dispatcher, or null for empty/invalid proxy. Dispatchers are cached per URL. */
60
+ export function proxyFetch(raw) {
61
+ const p = parseProxyUrl(raw)
62
+ if (!p) return null
63
+ let f = cache.get(p.href)
64
+ if (!f) {
65
+ const d = p.scheme === 'socks5' ? socksDispatcher(p) : httpDispatcher(p)
66
+ f = (url, init = {}) => undiciFetch(url, { ...init, dispatcher: d })
67
+ cache.set(p.href, f)
68
+ }
69
+ return f
70
+ }
71
+
72
+ /** Pick the fetch for an account ref: per-slot proxy -> deps.fetchImpl -> global fetch. */
73
+ export function pickFetch(deps, ref) {
74
+ if (typeof deps?.fetchForRef === 'function') {
75
+ const f = deps.fetchForRef(ref)
76
+ if (f) return f
77
+ }
78
+ return deps?.fetchImpl || fetch
79
+ }
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,7 +1,8 @@
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
+ import { pickFetch } from "./proxy.js"
5
6
 
6
7
  // ponytail: allowlist per provider — add path to extend without touching request logic
7
8
  export const ALLOWLIST = {
@@ -91,7 +92,7 @@ export function createSubscriptionsService(deps) {
91
92
  const tried = new Set()
92
93
  let lastError = null
93
94
  while (true) {
94
- const account = pickAccount(pool, Date.now(), { switchAtRemaining: thr })
95
+ const account = pickAccount(pool, Date.now(), { switchAtRemaining: thr, family: modelFamily(provider, body && body.model) })
95
96
  if (!account) {
96
97
  if (lastError) throw lastError
97
98
  const err = new Error("no usable subscription account for this provider")
@@ -121,7 +122,8 @@ export function createSubscriptionsService(deps) {
121
122
  }
122
123
  const url = p
123
124
  const extraHeaders = headersFor(provider, blob, cfg)
124
- const fetchImpl = deps.fetchImpl || fetch
125
+ const fetchImpl = pickFetch(deps, account.ref)
126
+ const t0 = Date.now()
125
127
  const res = await fetchImpl(url, {
126
128
  method,
127
129
  headers: {
@@ -169,6 +171,7 @@ export function createSubscriptionsService(deps) {
169
171
  path,
170
172
  method,
171
173
  status: res.status || 200,
174
+ ms: Date.now() - t0,
172
175
  kind: "request",
173
176
  })
174
177
  } catch {}
@@ -177,9 +180,9 @@ export function createSubscriptionsService(deps) {
177
180
  } catch (err) {
178
181
  lastError = err
179
182
  if (!isSwitchableError(err)) throw err
180
- const cooled = markCooldown(account, Date.now(), cooldownMs)
183
+ const cooled = markCooldown(account, Date.now(), cooldownMs, modelFamily(provider, body && body.model))
181
184
  account.cooldownUntil = cooled.cooldownUntil
182
- 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)
183
186
  // try next account
184
187
  }
185
188
  }
@@ -10,6 +10,12 @@ export const id = 'codex'
10
10
  const AUTH = 'https://auth.openai.com/oauth/authorize'
11
11
  const TOKEN = 'https://auth.openai.com/oauth/token'
12
12
  const USAGE = 'https://chatgpt.com/backend-api/wham/usage'
13
+ // #90: device-code login (headless). OpenAI mints an authorization_code +
14
+ // code_verifier server-side; we finish with the normal PKCE exchange.
15
+ const DEVICE_USERCODE_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode'
16
+ const DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token'
17
+ export const DEVICE_AUTH_URL = 'https://auth.openai.com/codex/device'
18
+ const DEVICE_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback'
13
19
  const SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke'
14
20
  const INSTRUCTIONS = 'You are a coding assistant using a ChatGPT Codex subscription.'
15
21
 
@@ -82,6 +88,45 @@ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
82
88
  return decorate(tokenBlobFromOAuth(json), json)
83
89
  }
84
90
 
91
+ export async function deviceStart(cfg, fetchImpl) {
92
+ const impl = fetchImpl || fetch
93
+ const res = await impl(DEVICE_USERCODE_URL, {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
96
+ body: JSON.stringify({ client_id: cfg.clientId }),
97
+ })
98
+ if (!res.ok) throw httpError(res.status, await res.text().catch(() => ''), 'DEVICE')
99
+ const json = await readJson(res)
100
+ if (!json || typeof json.user_code !== 'string' || typeof json.device_auth_id !== 'string') {
101
+ throw httpError(502, 'device auth response is incomplete', 'DEVICE')
102
+ }
103
+ return {
104
+ userCode: json.user_code,
105
+ deviceAuthId: json.device_auth_id,
106
+ intervalMs: Math.max(parseInt(json.interval, 10) || 5, 1) * 1000,
107
+ authUrl: DEVICE_AUTH_URL,
108
+ }
109
+ }
110
+
111
+ export async function devicePoll(cfg, session, fetchImpl) {
112
+ const impl = fetchImpl || fetch
113
+ const res = await impl(DEVICE_TOKEN_URL, {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
116
+ body: JSON.stringify({ device_auth_id: session.deviceAuthId, user_code: session.userCode }),
117
+ })
118
+ // 403/404 = not confirmed yet (matches reference implementations)
119
+ if (res.status === 403 || res.status === 404) return { status: 'pending' }
120
+ if (!res.ok) throw httpError(res.status, await res.text().catch(() => ''), 'DEVICE')
121
+ const json = await readJson(res)
122
+ if (!json || typeof json.authorization_code !== 'string' || typeof json.code_verifier !== 'string') {
123
+ throw httpError(502, 'device auth token response is incomplete', 'DEVICE')
124
+ }
125
+ const deviceCfg = { ...cfg, redirectUri: DEVICE_REDIRECT_URI }
126
+ const blob = await exchangeCode(deviceCfg, { verifier: json.code_verifier }, json.authorization_code, impl)
127
+ return { status: 'authorized', blob }
128
+ }
129
+
85
130
  export async function refresh(cfg, blob, fetchImpl) {
86
131
  const json = await formTokenRequest(TOKEN, {
87
132
  grant_type: 'refresh_token',
@@ -160,7 +205,7 @@ export async function usage(blob, cfg, fetchImpl) {
160
205
 
161
206
  export async function* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
162
207
  const base = (config.baseUrl || defaults().baseUrl).replace(/\/$/, '')
163
- const body = codexResponsesBody(options, INSTRUCTIONS)
208
+ const body = codexResponsesBody(options, INSTRUCTIONS, config)
164
209
  const res = await fetchImpl(`${base}/responses`, {
165
210
  method: 'POST',
166
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.15",
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",
@@ -51,13 +51,20 @@
51
51
  },
52
52
  "peerDependencies": {
53
53
  "@deepseek-ai/cordis": "^4.0.1",
54
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
55
54
  "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
56
55
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
56
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6",
57
57
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
58
58
  "@deepseek-ai/schemastery": "^3.18.1"
59
59
  },
60
60
  "publishConfig": {
61
61
  "access": "public"
62
+ },
63
+ "dependencies": {
64
+ "socks": "^2.8.9",
65
+ "undici": "^8.10.1"
66
+ },
67
+ "devDependencies": {
68
+ "@deepseek-ai/dsh-llm": "0.1.0-rc.8"
62
69
  }
63
70
  }