free-coding-models 0.1.81 → 0.1.83
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 -40
- package/bin/free-coding-models.js +692 -69
- package/lib/account-manager.js +600 -0
- package/lib/config.js +122 -0
- package/lib/error-classifier.js +154 -0
- package/lib/log-reader.js +174 -0
- package/lib/model-merger.js +78 -0
- package/lib/opencode-sync.js +159 -0
- package/lib/provider-quota-fetchers.js +319 -0
- package/lib/proxy-server.js +543 -0
- package/lib/quota-capabilities.js +79 -0
- package/lib/request-transformer.js +180 -0
- package/lib/token-stats.js +242 -0
- package/lib/usage-reader.js +203 -0
- package/lib/utils.js +55 -0
- package/package.json +1 -1
- package/sources.js +4 -3
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
|
|
6
|
+
const OC_CONFIG_DIR = join(homedir(), '.config', 'opencode')
|
|
7
|
+
const OC_CONFIG_PATH = join(OC_CONFIG_DIR, 'opencode.json')
|
|
8
|
+
const OC_BACKUP_PATH = join(OC_CONFIG_DIR, 'opencode.json.bak')
|
|
9
|
+
const FCM_PROVIDER_ID = 'fcm-proxy'
|
|
10
|
+
const DEFAULT_PROXY_BASE_URL = 'http://127.0.0.1:8045/v1'
|
|
11
|
+
|
|
12
|
+
function generateProxyToken() {
|
|
13
|
+
return `fcm_${randomBytes(24).toString('hex')}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensureV1BaseUrl(baseURL) {
|
|
17
|
+
if (typeof baseURL !== 'string' || baseURL.length === 0) {
|
|
18
|
+
return DEFAULT_PROXY_BASE_URL
|
|
19
|
+
}
|
|
20
|
+
const trimmed = baseURL.replace(/\/+$/, '')
|
|
21
|
+
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Load existing OpenCode config, or return empty object.
|
|
26
|
+
*/
|
|
27
|
+
export function loadOpenCodeConfig() {
|
|
28
|
+
try {
|
|
29
|
+
if (existsSync(OC_CONFIG_PATH)) {
|
|
30
|
+
return JSON.parse(readFileSync(OC_CONFIG_PATH, 'utf8'))
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Save OpenCode config with automatic backup.
|
|
38
|
+
* Creates backup of current config before overwriting.
|
|
39
|
+
*/
|
|
40
|
+
export function saveOpenCodeConfig(config) {
|
|
41
|
+
mkdirSync(OC_CONFIG_DIR, { recursive: true })
|
|
42
|
+
// Backup existing config before saving
|
|
43
|
+
if (existsSync(OC_CONFIG_PATH)) {
|
|
44
|
+
copyFileSync(OC_CONFIG_PATH, OC_BACKUP_PATH)
|
|
45
|
+
}
|
|
46
|
+
writeFileSync(OC_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Restore OpenCode config from backup.
|
|
51
|
+
* @returns {boolean} true if restored, false if no backup exists
|
|
52
|
+
*/
|
|
53
|
+
export function restoreOpenCodeBackup() {
|
|
54
|
+
if (!existsSync(OC_BACKUP_PATH)) return false
|
|
55
|
+
copyFileSync(OC_BACKUP_PATH, OC_CONFIG_PATH)
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Pure merge: apply FCM provider entry into an existing OpenCode config object.
|
|
61
|
+
*
|
|
62
|
+
* This function contains the merge logic without any filesystem I/O so it can
|
|
63
|
+
* be unit-tested in isolation. It is exported for tests and used internally by
|
|
64
|
+
* syncToOpenCode.
|
|
65
|
+
*
|
|
66
|
+
* CRITICAL: This function ONLY adds/updates the fcm-proxy provider entry.
|
|
67
|
+
* It PRESERVES all existing providers (antigravity-manager, openai, iflow, etc.)
|
|
68
|
+
* and all other top-level keys ($schema, mcp, plugin, command, model).
|
|
69
|
+
*
|
|
70
|
+
* proxyInfo should only carry runtime port/token when the proxy is actively
|
|
71
|
+
* running (running === true). Callers MUST NOT pass stale values from a stopped
|
|
72
|
+
* proxy — use undefined/omit the fields instead so we fall back to the existing
|
|
73
|
+
* persisted provider options cleanly.
|
|
74
|
+
*
|
|
75
|
+
* @param {Object} ocConfig - Existing OpenCode config object (will be mutated in-place)
|
|
76
|
+
* @param {Array} mergedModels - Output of buildMergedModels()
|
|
77
|
+
* @param {{ proxyPort?: number, proxyToken?: string, availableModelSlugs?: Set<string>|string[] }} proxyInfo
|
|
78
|
+
* availableModelSlugs: when provided, only models whose slug is in this set are written
|
|
79
|
+
* to the OpenCode catalog. Use this to prevent "ghost" entries for models with no API keys.
|
|
80
|
+
* @returns {Object} The mutated ocConfig
|
|
81
|
+
*/
|
|
82
|
+
export function mergeOcConfig(ocConfig, mergedModels, proxyInfo = {}) {
|
|
83
|
+
ocConfig.provider = ocConfig.provider || {}
|
|
84
|
+
|
|
85
|
+
const existingProvider = ocConfig.provider[FCM_PROVIDER_ID] || {}
|
|
86
|
+
const existingOptions = existingProvider.options || {}
|
|
87
|
+
|
|
88
|
+
// Only use the runtime proxyPort if it is a valid positive integer.
|
|
89
|
+
// A null/undefined/0 port means the proxy is not running — fall back to
|
|
90
|
+
// the existing persisted baseURL so we don't write a broken URL.
|
|
91
|
+
const hasValidPort = Number.isInteger(proxyInfo.proxyPort) && proxyInfo.proxyPort > 0
|
|
92
|
+
const baseURL = ensureV1BaseUrl(
|
|
93
|
+
hasValidPort
|
|
94
|
+
? `http://127.0.0.1:${proxyInfo.proxyPort}`
|
|
95
|
+
: existingOptions.baseURL
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Keep token stable unless caller provides a runtime token.
|
|
99
|
+
// A non-string or empty proxyToken is treated as absent.
|
|
100
|
+
const hasValidToken = typeof proxyInfo.proxyToken === 'string' && proxyInfo.proxyToken.length > 0
|
|
101
|
+
const hasExistingToken =
|
|
102
|
+
typeof existingOptions.apiKey === 'string' &&
|
|
103
|
+
existingOptions.apiKey.length > 0 &&
|
|
104
|
+
existingOptions.apiKey !== 'fcm-proxy-token'
|
|
105
|
+
const apiKey = hasValidToken ? proxyInfo.proxyToken : (hasExistingToken ? existingOptions.apiKey : generateProxyToken())
|
|
106
|
+
|
|
107
|
+
const slugFilter = proxyInfo.availableModelSlugs
|
|
108
|
+
? new Set(proxyInfo.availableModelSlugs)
|
|
109
|
+
: null
|
|
110
|
+
|
|
111
|
+
const models = {}
|
|
112
|
+
for (const m of mergedModels) {
|
|
113
|
+
if (slugFilter && !slugFilter.has(m.slug)) continue
|
|
114
|
+
models[m.slug] = { name: m.label }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
ocConfig.provider[FCM_PROVIDER_ID] = {
|
|
118
|
+
npm: '@ai-sdk/openai-compatible',
|
|
119
|
+
name: 'FCM Rotation Proxy',
|
|
120
|
+
options: {
|
|
121
|
+
...existingOptions,
|
|
122
|
+
baseURL,
|
|
123
|
+
apiKey,
|
|
124
|
+
},
|
|
125
|
+
models,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return ocConfig
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* MERGE the single FCM proxy provider into OpenCode config.
|
|
133
|
+
*
|
|
134
|
+
* CRITICAL: This function ONLY adds/updates the fcm-proxy provider entry.
|
|
135
|
+
* It PRESERVES all existing providers (antigravity-manager, openai, iflow, etc.)
|
|
136
|
+
* and all other top-level keys ($schema, mcp, plugin, command, model).
|
|
137
|
+
*
|
|
138
|
+
* proxyInfo should only carry runtime port/token when the proxy is actively
|
|
139
|
+
* running (running === true). Callers MUST NOT pass stale values from a stopped
|
|
140
|
+
* proxy — use undefined/omit the fields instead so we fall back to the existing
|
|
141
|
+
* persisted provider options cleanly.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} fcmConfig - FCM config (from loadConfig())
|
|
144
|
+
* @param {Object} _sources - PROVIDERS object from sources.js (unused, kept for signature compatibility)
|
|
145
|
+
* @param {Array} mergedModels - Output of buildMergedModels()
|
|
146
|
+
* @param {{ proxyPort?: number, proxyToken?: string, availableModelSlugs?: Set<string>|string[] }} proxyInfo
|
|
147
|
+
* availableModelSlugs: slugs of models that have real API key accounts. When provided,
|
|
148
|
+
* only those models appear in the OpenCode catalog, preventing ghost entries.
|
|
149
|
+
*/
|
|
150
|
+
export function syncToOpenCode(fcmConfig, _sources, mergedModels, proxyInfo = {}) {
|
|
151
|
+
const oc = loadOpenCodeConfig()
|
|
152
|
+
const merged = mergeOcConfig(oc, mergedModels, proxyInfo)
|
|
153
|
+
saveOpenCodeConfig(merged)
|
|
154
|
+
return {
|
|
155
|
+
providerKey: FCM_PROVIDER_ID,
|
|
156
|
+
modelCount: Object.keys(merged.provider[FCM_PROVIDER_ID].models).length,
|
|
157
|
+
path: OC_CONFIG_PATH,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -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
|
+
}
|