free-coding-models 0.1.82 → 0.1.84

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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * @file lib/provider-quota-fetchers.js
3
+ * @description Provider endpoint quota pollers for publicly available endpoints.
4
+ *
5
+ * Supported providers:
6
+ * - openrouter: GET https://openrouter.ai/api/v1/key
7
+ * derives percent from limit_remaining/limit (with fallback field names)
8
+ * - siliconflow: GET https://api.siliconflow.cn/v1/user/info
9
+ * returns balance info; percent is null (no limit field to derive from)
10
+ *
11
+ * Features:
12
+ * - TTL cache (default 60s) prevents hammering endpoints
13
+ * - Error backoff (default 15s) after failures
14
+ * - Injectable fetch + time for testing
15
+ * - API keys are never logged
16
+ *
17
+ * @exports parseOpenRouterResponse(data) → number|null
18
+ * @exports parseSiliconFlowResponse(data) → { balance, chargeBalance, totalBalance }|null
19
+ * @exports createProviderQuotaFetcher(options) → fetcher(providerKey, apiKey) → Promise<number|null>
20
+ * @exports fetchProviderQuota(providerKey, apiKey, options) → Promise<number|null>
21
+ */
22
+
23
+ // ─── Response parsers (pure, no I/O) ─────────────────────────────────────────
24
+
25
+ import { createHash } from 'node:crypto'
26
+
27
+ /**
28
+ * Parse an OpenRouter /api/v1/key response into a quota percent [0,100] or null.
29
+ *
30
+ * The endpoint may wrap fields in a `data` object or return them at root.
31
+ * Field precedence:
32
+ * 1. limit_remaining / limit
33
+ * 2. remaining / total_limit
34
+ * 3. remaining_credits / credits
35
+ *
36
+ * @param {unknown} responseData - Parsed JSON from the endpoint
37
+ * @returns {number|null} Integer percent 0–100, or null when not derivable
38
+ */
39
+ export function parseOpenRouterResponse(responseData) {
40
+ if (responseData == null || typeof responseData !== 'object') return null
41
+
42
+ // Unwrap .data if present, fall back to root
43
+ const root = responseData.data != null && typeof responseData.data === 'object'
44
+ ? responseData.data
45
+ : responseData
46
+
47
+ // Try field pairs in priority order
48
+ const pairs = [
49
+ ['limit_remaining', 'limit'],
50
+ ['remaining', 'total_limit'],
51
+ ['remaining_credits', 'credits'],
52
+ ]
53
+
54
+ for (const [remainingKey, limitKey] of pairs) {
55
+ const remaining = parseFloat(root[remainingKey])
56
+ const limit = parseFloat(root[limitKey])
57
+ if (Number.isFinite(remaining) && Number.isFinite(limit) && limit > 0) {
58
+ const pct = Math.round((remaining / limit) * 100)
59
+ return Math.max(0, Math.min(100, pct))
60
+ }
61
+ }
62
+
63
+ return null
64
+ }
65
+
66
+ /**
67
+ * Parse a SiliconFlow /v1/user/info response.
68
+ *
69
+ * SiliconFlow does not expose a credit/quota limit, only the current balance.
70
+ * A percentage cannot be reliably derived without knowing the original limit.
71
+ *
72
+ * Returns an object with raw balance fields when the response is well-formed,
73
+ * or null when the response is missing/malformed/error.
74
+ *
75
+ * Callers may use { percent: null } as a signal that the provider responded
76
+ * successfully but quota percentage is not available.
77
+ *
78
+ * @param {unknown} responseData - Parsed JSON from the endpoint
79
+ * @returns {{ balance: number, chargeBalance: number, totalBalance: number, percent: null }|null}
80
+ */
81
+ export function parseSiliconFlowResponse(responseData) {
82
+ if (responseData == null || typeof responseData !== 'object') return null
83
+
84
+ // SiliconFlow wraps payload in .data; code 20000 = success
85
+ const data = responseData.data
86
+ if (data == null || typeof data !== 'object') return null
87
+
88
+ // Require a success indicator
89
+ const code = responseData.code
90
+ const status = responseData.status
91
+ if (code !== 20000 && status !== true) return null
92
+
93
+ const balance = parseFloat(data.balance)
94
+ const chargeBalance = parseFloat(data.chargeBalance)
95
+ const totalBalance = parseFloat(data.totalBalance)
96
+
97
+ // All three fields must be numeric to be valid
98
+ if (!Number.isFinite(balance) || !Number.isFinite(chargeBalance) || !Number.isFinite(totalBalance)) {
99
+ return null
100
+ }
101
+
102
+ // We cannot derive a reliable percent without a "limit" (initial balance) field.
103
+ // Return structured balance info with percent: null.
104
+ return {
105
+ balance,
106
+ chargeBalance,
107
+ totalBalance,
108
+ percent: null,
109
+ }
110
+ }
111
+
112
+ // ─── TTL cache + backoff ──────────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Create an in-memory cache entry.
116
+ * @param {number|null} value
117
+ * @param {number} expiresAt - Date.now() timestamp
118
+ * @returns {{ value: number|null, expiresAt: number }}
119
+ */
120
+ function makeCacheEntry(value, expiresAt) {
121
+ return { value, expiresAt }
122
+ }
123
+
124
+ // ─── Endpoint definitions ─────────────────────────────────────────────────────
125
+
126
+ const OPENROUTER_KEY_ENDPOINT = 'https://openrouter.ai/api/v1/key'
127
+ const SILICONFLOW_USER_ENDPOINT = 'https://api.siliconflow.cn/v1/user/info'
128
+
129
+ /**
130
+ * @param {string} apiKey
131
+ * @param {Function} fetchFn - injectable fetch
132
+ * @returns {Promise<number|null>} quota percent or null
133
+ */
134
+ async function fetchOpenRouterRaw(apiKey, fetchFn) {
135
+ const resp = await fetchFn(OPENROUTER_KEY_ENDPOINT, {
136
+ method: 'GET',
137
+ headers: {
138
+ Authorization: `Bearer ${apiKey}`,
139
+ 'HTTP-Referer': 'https://github.com/vava-nessa/free-coding-models',
140
+ 'X-Title': 'free-coding-models',
141
+ },
142
+ signal: AbortSignal.timeout(5000),
143
+ })
144
+ if (!resp.ok) return null
145
+ const data = await resp.json()
146
+ return parseOpenRouterResponse(data)
147
+ }
148
+
149
+ /**
150
+ * @param {string} apiKey
151
+ * @param {Function} fetchFn - injectable fetch
152
+ * @returns {Promise<number|null>} quota percent (always null for SiliconFlow) or null on error
153
+ */
154
+ async function fetchSiliconFlowRaw(apiKey, fetchFn) {
155
+ const resp = await fetchFn(SILICONFLOW_USER_ENDPOINT, {
156
+ method: 'GET',
157
+ headers: {
158
+ Authorization: `Bearer ${apiKey}`,
159
+ },
160
+ signal: AbortSignal.timeout(5000),
161
+ })
162
+ if (!resp.ok) return null
163
+ const data = await resp.json()
164
+ const parsed = parseSiliconFlowResponse(data)
165
+ // percent is always null for SiliconFlow (no limit field)
166
+ return parsed !== null ? parsed.percent : null
167
+ }
168
+
169
+ // ─── Module-level default cache (used by fetchProviderQuota) ──────────────────
170
+
171
+ const DEFAULT_CACHE_TTL_MS = 60_000
172
+ const DEFAULT_ERROR_BACKOFF_MS = 15_000
173
+ /** @type {Map<string, { value: number|null, expiresAt: number, pendingPromise?: Promise<number|null> }>} */
174
+ const _defaultCache = new Map()
175
+
176
+ /**
177
+ * Build a collision-resistant cache key from providerKey + apiKey.
178
+ * Uses SHA-256 of the full apiKey so that keys sharing the same suffix
179
+ * (e.g. 'account-A-SHARED12' vs 'account-B-SHARED12') do not collide.
180
+ * The raw API key is never stored or logged.
181
+ *
182
+ * @param {string} providerKey
183
+ * @param {string} apiKey
184
+ * @returns {string}
185
+ */
186
+ function makeCacheKey(providerKey, apiKey) {
187
+ const hash = createHash('sha256').update(apiKey).digest('hex').slice(0, 16)
188
+ return `${providerKey}:${hash}`
189
+ }
190
+
191
+ // ─── createProviderQuotaFetcher ───────────────────────────────────────────────
192
+
193
+ /**
194
+ * Create a stateful fetcher with its own TTL cache and error backoff.
195
+ *
196
+ * @param {object} [options]
197
+ * @param {Function} [options.fetchFn=fetch] - injectable fetch (defaults to global fetch)
198
+ * @param {number} [options.cacheTtlMs=60000] - TTL for successful results
199
+ * @param {number} [options.errorBackoffMs=15000] - TTL after errors (prevents spam)
200
+ * @returns {(providerKey: string, apiKey: string) => Promise<number|null>}
201
+ */
202
+ export function createProviderQuotaFetcher({ fetchFn = fetch, cacheTtlMs = DEFAULT_CACHE_TTL_MS, errorBackoffMs = DEFAULT_ERROR_BACKOFF_MS } = {}) {
203
+ /** @type {Map<string, { value: number|null, expiresAt: number, pendingPromise?: Promise<number|null> }>} */
204
+ const cache = new Map()
205
+
206
+ return async function fetcherInstance(providerKey, apiKey) {
207
+ if (!apiKey) return null
208
+
209
+ // Cache key uses a hash of the full key to avoid suffix-collision bugs
210
+ const cacheKey = makeCacheKey(providerKey, apiKey)
211
+ const now = Date.now()
212
+ const cached = cache.get(cacheKey)
213
+
214
+ // Reuse in-flight promise to prevent duplicate concurrent requests
215
+ if (cached?.pendingPromise) {
216
+ return cached.pendingPromise
217
+ }
218
+
219
+ // Return cached value if still fresh
220
+ if (cached && cached.expiresAt > now) {
221
+ return cached.value
222
+ }
223
+
224
+ // Dispatch to provider-specific fetcher
225
+ const doFetch = providerKey === 'openrouter'
226
+ ? () => fetchOpenRouterRaw(apiKey, fetchFn)
227
+ : providerKey === 'siliconflow'
228
+ ? () => fetchSiliconFlowRaw(apiKey, fetchFn)
229
+ : null
230
+
231
+ if (!doFetch) return null
232
+
233
+ const pendingPromise = doFetch()
234
+ .then((value) => {
235
+ const finalValue = (typeof value === 'number' && Number.isFinite(value)) ? value : null
236
+ cache.set(cacheKey, makeCacheEntry(finalValue, Date.now() + cacheTtlMs))
237
+ return finalValue
238
+ })
239
+ .catch(() => {
240
+ cache.set(cacheKey, makeCacheEntry(null, Date.now() + errorBackoffMs))
241
+ return null
242
+ })
243
+
244
+ // Store pending promise to coalesce concurrent calls
245
+ cache.set(cacheKey, {
246
+ value: cached?.value ?? null,
247
+ expiresAt: cached?.expiresAt ?? 0,
248
+ pendingPromise,
249
+ })
250
+
251
+ return pendingPromise
252
+ }
253
+ }
254
+
255
+ // ─── fetchProviderQuota (top-level convenience, uses module-level default cache) ──
256
+
257
+ /**
258
+ * Fetch provider quota percent for a given provider + API key.
259
+ *
260
+ * Supported providers: 'openrouter', 'siliconflow'.
261
+ * All other providers return null immediately.
262
+ *
263
+ * Options:
264
+ * - fetchFn: injectable fetch for testing (bypasses module-level cache when provided)
265
+ * - cacheTtlMs / errorBackoffMs: only used when fetchFn is provided (creates isolated fetcher)
266
+ *
267
+ * When called WITHOUT fetchFn, uses the module-level cache shared across all calls.
268
+ *
269
+ * @param {string} providerKey
270
+ * @param {string} apiKey
271
+ * @param {object} [options]
272
+ * @param {Function} [options.fetchFn] - injectable fetch; when provided, creates a per-call fetcher
273
+ * @param {number} [options.cacheTtlMs]
274
+ * @param {number} [options.errorBackoffMs]
275
+ * @returns {Promise<number|null>}
276
+ */
277
+ export async function fetchProviderQuota(providerKey, apiKey, options = {}) {
278
+ if (!apiKey) return null
279
+ if (providerKey !== 'openrouter' && providerKey !== 'siliconflow') return null
280
+
281
+ const { fetchFn, cacheTtlMs = DEFAULT_CACHE_TTL_MS, errorBackoffMs = DEFAULT_ERROR_BACKOFF_MS } = options
282
+
283
+ // When a custom fetchFn is provided, create an isolated fetcher (for testing)
284
+ if (fetchFn) {
285
+ const fetcher = createProviderQuotaFetcher({ fetchFn, cacheTtlMs, errorBackoffMs })
286
+ return fetcher(providerKey, apiKey)
287
+ }
288
+
289
+ // Default path: use module-level cache
290
+ const cacheKey = makeCacheKey(providerKey, apiKey)
291
+ const now = Date.now()
292
+ const cached = _defaultCache.get(cacheKey)
293
+
294
+ if (cached?.pendingPromise) return cached.pendingPromise
295
+ if (cached && cached.expiresAt > now) return cached.value
296
+
297
+ const doFetch = providerKey === 'openrouter'
298
+ ? () => fetchOpenRouterRaw(apiKey, fetch)
299
+ : () => fetchSiliconFlowRaw(apiKey, fetch)
300
+
301
+ const pendingPromise = doFetch()
302
+ .then((value) => {
303
+ const finalValue = (typeof value === 'number' && Number.isFinite(value)) ? value : null
304
+ _defaultCache.set(cacheKey, makeCacheEntry(finalValue, Date.now() + cacheTtlMs))
305
+ return finalValue
306
+ })
307
+ .catch(() => {
308
+ _defaultCache.set(cacheKey, makeCacheEntry(null, Date.now() + errorBackoffMs))
309
+ return null
310
+ })
311
+
312
+ _defaultCache.set(cacheKey, {
313
+ value: cached?.value ?? null,
314
+ expiresAt: cached?.expiresAt ?? 0,
315
+ pendingPromise,
316
+ })
317
+
318
+ return pendingPromise
319
+ }