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.
- package/README.md +53 -51
- package/bin/free-coding-models.js +429 -4276
- package/package.json +2 -2
- package/sources.js +3 -2
- package/src/account-manager.js +600 -0
- package/src/analysis.js +197 -0
- package/{lib → src}/config.js +122 -0
- package/src/constants.js +116 -0
- package/src/error-classifier.js +154 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/log-reader.js +174 -0
- package/src/model-merger.js +78 -0
- package/src/openclaw.js +131 -0
- package/src/opencode-sync.js +159 -0
- package/src/opencode.js +952 -0
- package/src/overlays.js +840 -0
- package/src/ping.js +186 -0
- package/src/provider-metadata.js +218 -0
- package/src/provider-quota-fetchers.js +319 -0
- package/src/proxy-server.js +543 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/request-transformer.js +180 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/src/token-stats.js +310 -0
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/src/usage-reader.js +245 -0
- package/{lib → src}/utils.js +55 -0
|
@@ -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
|
+
}
|