free-coding-models 0.1.83 → 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 +6 -17
- package/bin/free-coding-models.js +297 -4754
- package/package.json +2 -2
- package/src/analysis.js +197 -0
- package/src/constants.js +116 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/openclaw.js +131 -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/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/{lib → src}/token-stats.js +71 -3
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/{lib → src}/usage-reader.js +63 -21
- package/lib/quota-capabilities.js +0 -79
- /package/{lib → src}/account-manager.js +0 -0
- /package/{lib → src}/config.js +0 -0
- /package/{lib → src}/error-classifier.js +0 -0
- /package/{lib → src}/log-reader.js +0 -0
- /package/{lib → src}/model-merger.js +0 -0
- /package/{lib → src}/opencode-sync.js +0 -0
- /package/{lib → src}/provider-quota-fetchers.js +0 -0
- /package/{lib → src}/proxy-server.js +0 -0
- /package/{lib → src}/request-transformer.js +0 -0
- /package/{lib → src}/utils.js +0 -0
package/src/updater.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file updater.js
|
|
3
|
+
* @description Update detection and installation helpers, extracted from bin/free-coding-models.js.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* This module handles all npm version-check and auto-update logic:
|
|
7
|
+
*
|
|
8
|
+
* - `checkForUpdateDetailed()` — hits the npm registry to compare the published version
|
|
9
|
+
* against the locally installed one. Returns `{ latestVersion, error }` so callers
|
|
10
|
+
* can surface meaningful status text in the Settings overlay.
|
|
11
|
+
*
|
|
12
|
+
* - `checkForUpdate()` — thin backward-compatible wrapper used at startup for the
|
|
13
|
+
* auto-update guard. Returns `latestVersion` (string) or `null`.
|
|
14
|
+
*
|
|
15
|
+
* - `runUpdate(latestVersion)` — runs `npm i -g free-coding-models@<version> --prefer-online`,
|
|
16
|
+
* retrying with `sudo` on EACCES/EPERM. On success, relaunches the process with the
|
|
17
|
+
* same argv. On failure, prints manual instructions and exits with code 1.
|
|
18
|
+
* Uses `require('child_process').execSync` inline because ESM dynamic import is async
|
|
19
|
+
* but `execSync` must block to give `stdio: 'inherit'` feedback in the terminal.
|
|
20
|
+
*
|
|
21
|
+
* - `promptUpdateNotification(latestVersion)` — renders a small centered interactive menu
|
|
22
|
+
* that lets the user choose: Update Now / Read Changelogs / Continue without update.
|
|
23
|
+
* Uses raw mode readline keypress events (same pattern as the main TUI).
|
|
24
|
+
* This function is called BEFORE the alt-screen is entered, so it writes to the
|
|
25
|
+
* normal terminal buffer.
|
|
26
|
+
*
|
|
27
|
+
* ⚙️ Notes:
|
|
28
|
+
* - `LOCAL_VERSION` is resolved from package.json via `createRequire` so this module
|
|
29
|
+
* can be imported independently from the bin entry point.
|
|
30
|
+
* - The auto-update flow in `main()` skips update if `isDevMode` is detected (presence of
|
|
31
|
+
* a `.git` directory next to the package root) to avoid an infinite update loop in dev.
|
|
32
|
+
*
|
|
33
|
+
* @functions
|
|
34
|
+
* → checkForUpdateDetailed() — Fetch npm latest with explicit error info
|
|
35
|
+
* → checkForUpdate() — Startup wrapper, returns version string or null
|
|
36
|
+
* → runUpdate(latestVersion) — Install new version via npm global + relaunch
|
|
37
|
+
* → promptUpdateNotification(version) — Interactive pre-TUI update menu
|
|
38
|
+
*
|
|
39
|
+
* @exports
|
|
40
|
+
* checkForUpdateDetailed, checkForUpdate, runUpdate, promptUpdateNotification
|
|
41
|
+
*
|
|
42
|
+
* @see bin/free-coding-models.js — calls checkForUpdate() at startup and runUpdate() on confirm
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import chalk from 'chalk'
|
|
46
|
+
import { createRequire } from 'module'
|
|
47
|
+
|
|
48
|
+
const require = createRequire(import.meta.url)
|
|
49
|
+
const readline = require('readline')
|
|
50
|
+
const pkg = require('../package.json')
|
|
51
|
+
const LOCAL_VERSION = pkg.version
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 📖 checkForUpdateDetailed: Fetch npm latest version with explicit error details.
|
|
55
|
+
* 📖 Used by settings manual-check flow to display meaningful status in the UI.
|
|
56
|
+
* @returns {Promise<{ latestVersion: string|null, error: string|null }>}
|
|
57
|
+
*/
|
|
58
|
+
export async function checkForUpdateDetailed() {
|
|
59
|
+
try {
|
|
60
|
+
const res = await fetch('https://registry.npmjs.org/free-coding-models/latest', { signal: AbortSignal.timeout(5000) })
|
|
61
|
+
if (!res.ok) return { latestVersion: null, error: `HTTP ${res.status}` }
|
|
62
|
+
const data = await res.json()
|
|
63
|
+
if (data.version && data.version !== LOCAL_VERSION) return { latestVersion: data.version, error: null }
|
|
64
|
+
return { latestVersion: null, error: null }
|
|
65
|
+
} catch (error) {
|
|
66
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
67
|
+
return { latestVersion: null, error: message }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 📖 checkForUpdate: Backward-compatible wrapper for startup update prompt.
|
|
73
|
+
* @returns {Promise<string|null>}
|
|
74
|
+
*/
|
|
75
|
+
export async function checkForUpdate() {
|
|
76
|
+
const { latestVersion } = await checkForUpdateDetailed()
|
|
77
|
+
return latestVersion
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 📖 runUpdate: Run npm global install to update to latestVersion.
|
|
82
|
+
* 📖 Retries with sudo on permission errors.
|
|
83
|
+
* 📖 Relaunches the process on success, exits with code 1 on failure.
|
|
84
|
+
* @param {string} latestVersion
|
|
85
|
+
*/
|
|
86
|
+
export function runUpdate(latestVersion) {
|
|
87
|
+
const { execSync } = require('child_process')
|
|
88
|
+
console.log()
|
|
89
|
+
console.log(chalk.bold.cyan(' ⬆ Updating free-coding-models to v' + latestVersion + '...'))
|
|
90
|
+
console.log()
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// 📖 Force install from npm registry (ignore local cache)
|
|
94
|
+
// 📖 Use --prefer-online to ensure we get the latest published version
|
|
95
|
+
execSync(`npm i -g free-coding-models@${latestVersion} --prefer-online`, { stdio: 'inherit' })
|
|
96
|
+
console.log()
|
|
97
|
+
console.log(chalk.green(' ✅ Update complete! Version ' + latestVersion + ' installed.'))
|
|
98
|
+
console.log()
|
|
99
|
+
console.log(chalk.dim(' 🔄 Restarting with new version...'))
|
|
100
|
+
console.log()
|
|
101
|
+
|
|
102
|
+
// 📖 Relaunch automatically with the same arguments
|
|
103
|
+
const args = process.argv.slice(2)
|
|
104
|
+
execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
|
|
105
|
+
process.exit(0)
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.log()
|
|
108
|
+
// 📖 Check if error is permission-related (EACCES or EPERM)
|
|
109
|
+
const isPermissionError = err.code === 'EACCES' || err.code === 'EPERM' ||
|
|
110
|
+
(err.stderr && (err.stderr.includes('EACCES') || err.stderr.includes('permission') ||
|
|
111
|
+
err.stderr.includes('EACCES'))) ||
|
|
112
|
+
(err.message && (err.message.includes('EACCES') || err.message.includes('permission')))
|
|
113
|
+
|
|
114
|
+
if (isPermissionError) {
|
|
115
|
+
console.log(chalk.yellow(' ⚠️ Permission denied. Retrying with sudo...'))
|
|
116
|
+
console.log()
|
|
117
|
+
try {
|
|
118
|
+
execSync(`sudo npm i -g free-coding-models@${latestVersion} --prefer-online`, { stdio: 'inherit' })
|
|
119
|
+
console.log()
|
|
120
|
+
console.log(chalk.green(' ✅ Update complete with sudo! Version ' + latestVersion + ' installed.'))
|
|
121
|
+
console.log()
|
|
122
|
+
console.log(chalk.dim(' 🔄 Restarting with new version...'))
|
|
123
|
+
console.log()
|
|
124
|
+
|
|
125
|
+
// 📖 Relaunch automatically with the same arguments
|
|
126
|
+
const args = process.argv.slice(2)
|
|
127
|
+
execSync(`node ${process.argv[1]} ${args.join(' ')}`, { stdio: 'inherit' })
|
|
128
|
+
process.exit(0)
|
|
129
|
+
} catch (sudoErr) {
|
|
130
|
+
console.log()
|
|
131
|
+
console.log(chalk.red(' ✖ Update failed even with sudo. Try manually:'))
|
|
132
|
+
console.log(chalk.dim(' sudo npm i -g free-coding-models@' + latestVersion))
|
|
133
|
+
console.log()
|
|
134
|
+
}
|
|
135
|
+
} else {
|
|
136
|
+
console.log(chalk.red(' ✖ Update failed. Try manually: npm i -g free-coding-models@' + latestVersion))
|
|
137
|
+
console.log()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
process.exit(1)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 📖 promptUpdateNotification: Show a centered interactive menu when a new version is available.
|
|
145
|
+
* 📖 Returns 'update', 'changelogs', or null (continue without update).
|
|
146
|
+
* 📖 Called BEFORE entering the alt-screen so it renders in the normal terminal buffer.
|
|
147
|
+
* @param {string|null} latestVersion
|
|
148
|
+
* @returns {Promise<'update'|'changelogs'|null>}
|
|
149
|
+
*/
|
|
150
|
+
export async function promptUpdateNotification(latestVersion) {
|
|
151
|
+
if (!latestVersion) return null
|
|
152
|
+
|
|
153
|
+
return new Promise((resolve) => {
|
|
154
|
+
let selected = 0
|
|
155
|
+
const options = [
|
|
156
|
+
{
|
|
157
|
+
label: 'Update now',
|
|
158
|
+
icon: '⬆',
|
|
159
|
+
description: `Update free-coding-models to v${latestVersion}`,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
label: 'Read Changelogs',
|
|
163
|
+
icon: '📋',
|
|
164
|
+
description: 'Open GitHub changelog',
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
label: 'Continue without update',
|
|
168
|
+
icon: '▶',
|
|
169
|
+
description: 'Use current version',
|
|
170
|
+
},
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
// 📖 Centered render function
|
|
174
|
+
const render = () => {
|
|
175
|
+
process.stdout.write('\x1b[2J\x1b[H') // clear screen + cursor home
|
|
176
|
+
|
|
177
|
+
// 📖 Calculate centering
|
|
178
|
+
const terminalWidth = process.stdout.columns || 80
|
|
179
|
+
const maxWidth = Math.min(terminalWidth - 4, 70)
|
|
180
|
+
const centerPad = ' '.repeat(Math.max(0, Math.floor((terminalWidth - maxWidth) / 2)))
|
|
181
|
+
|
|
182
|
+
console.log()
|
|
183
|
+
console.log(centerPad + chalk.bold.red(' ⚠ UPDATE AVAILABLE'))
|
|
184
|
+
console.log(centerPad + chalk.red(` Version ${latestVersion} is ready to install`))
|
|
185
|
+
console.log()
|
|
186
|
+
console.log(centerPad + chalk.bold(' ⚡ Free Coding Models') + chalk.dim(` v${LOCAL_VERSION}`))
|
|
187
|
+
console.log()
|
|
188
|
+
|
|
189
|
+
for (let i = 0; i < options.length; i++) {
|
|
190
|
+
const isSelected = i === selected
|
|
191
|
+
const bullet = isSelected ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
192
|
+
const label = isSelected
|
|
193
|
+
? chalk.bold.white(options[i].icon + ' ' + options[i].label)
|
|
194
|
+
: chalk.dim(options[i].icon + ' ' + options[i].label)
|
|
195
|
+
|
|
196
|
+
console.log(centerPad + bullet + label)
|
|
197
|
+
console.log(centerPad + chalk.dim(' ' + options[i].description))
|
|
198
|
+
console.log()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
console.log(centerPad + chalk.dim(' ↑↓ Navigate • Enter Select • Ctrl+C Continue'))
|
|
202
|
+
console.log()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
render()
|
|
206
|
+
|
|
207
|
+
readline.emitKeypressEvents(process.stdin)
|
|
208
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
|
209
|
+
|
|
210
|
+
const onKey = (_str, key) => {
|
|
211
|
+
if (!key) return
|
|
212
|
+
if (key.ctrl && key.name === 'c') {
|
|
213
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
214
|
+
process.stdin.removeListener('keypress', onKey)
|
|
215
|
+
resolve(null) // Continue without update
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (key.name === 'up' && selected > 0) {
|
|
219
|
+
selected--
|
|
220
|
+
render()
|
|
221
|
+
} else if (key.name === 'down' && selected < options.length - 1) {
|
|
222
|
+
selected++
|
|
223
|
+
render()
|
|
224
|
+
} else if (key.name === 'return') {
|
|
225
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false)
|
|
226
|
+
process.stdin.removeListener('keypress', onKey)
|
|
227
|
+
process.stdin.pause()
|
|
228
|
+
|
|
229
|
+
if (selected === 0) resolve('update')
|
|
230
|
+
else if (selected === 1) resolve('changelogs')
|
|
231
|
+
else resolve(null) // Continue without update
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
process.stdin.on('keypress', onKey)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file lib/usage-reader.js
|
|
3
|
-
* @description Pure functions to read
|
|
3
|
+
* @description Pure functions to read provider-scoped Usage snapshots from token-stats.json.
|
|
4
4
|
*
|
|
5
|
-
* Designed for TUI consumption: reads the pre-computed
|
|
6
|
-
*
|
|
5
|
+
* Designed for TUI consumption: reads the pre-computed provider-scoped quota
|
|
6
|
+
* snapshots written by TokenStats. Never reads the JSONL log.
|
|
7
|
+
*
|
|
8
|
+
* The UI must distinguish the same model served by different Origins
|
|
9
|
+
* (for example NVIDIA vs Groq). Because of that, the canonical snapshot source
|
|
10
|
+
* is `quotaSnapshots.byProviderModel`, not the legacy `byModel` aggregate.
|
|
7
11
|
*
|
|
8
12
|
* All functions are pure (no shared mutable state) and handle missing/malformed
|
|
9
13
|
* files gracefully by returning safe fallback values.
|
|
@@ -30,6 +34,7 @@
|
|
|
30
34
|
* @exports CACHE_TTL_MS
|
|
31
35
|
* @exports clearUsageCache
|
|
32
36
|
* @exports loadUsageSnapshot
|
|
37
|
+
* @exports buildUsageSnapshotKey
|
|
33
38
|
* @exports loadUsageMap
|
|
34
39
|
* @exports usageForModelId
|
|
35
40
|
* @exports usageForRow
|
|
@@ -38,6 +43,7 @@
|
|
|
38
43
|
import { readFileSync, existsSync } from 'node:fs'
|
|
39
44
|
import { join } from 'node:path'
|
|
40
45
|
import { homedir } from 'node:os'
|
|
46
|
+
import { supportsUsagePercent, usageResetsDaily } from './quota-capabilities.js'
|
|
41
47
|
|
|
42
48
|
const DEFAULT_STATS_FILE = join(homedir(), '.free-coding-models', 'token-stats.json')
|
|
43
49
|
|
|
@@ -57,7 +63,7 @@ export const CACHE_TTL_MS = 750
|
|
|
57
63
|
|
|
58
64
|
/**
|
|
59
65
|
* Module-level cache: path → { snapshot, expiresAt }
|
|
60
|
-
* @type {Map<string, { snapshot: {
|
|
66
|
+
* @type {Map<string, { snapshot: { byProviderModel: Record<string, number>, byProvider: Record<string, number>, legacyByModel: Record<string, number> }, expiresAt: number }>}
|
|
61
67
|
*/
|
|
62
68
|
const _cache = new Map()
|
|
63
69
|
|
|
@@ -81,13 +87,29 @@ export function clearUsageCache() {
|
|
|
81
87
|
* @param {number} [nowMs] - optional current time (ms) for testability
|
|
82
88
|
* @returns {boolean}
|
|
83
89
|
*/
|
|
84
|
-
function isSnapshotFresh(entry, nowMs = Date.now()) {
|
|
90
|
+
function isSnapshotFresh(entry, nowMs = Date.now(), providerKey = null) {
|
|
85
91
|
if (!entry || typeof entry.updatedAt !== 'string') return true // backward compat
|
|
86
92
|
const updatedMs = Date.parse(entry.updatedAt)
|
|
87
93
|
if (!Number.isFinite(updatedMs)) return true // unparseable: be generous
|
|
94
|
+
if (providerKey && usageResetsDaily(providerKey)) {
|
|
95
|
+
const nowDay = new Date(nowMs).toISOString().slice(0, 10)
|
|
96
|
+
const updatedDay = entry.updatedAt.slice(0, 10)
|
|
97
|
+
if (updatedDay !== nowDay) return false
|
|
98
|
+
}
|
|
88
99
|
return nowMs - updatedMs < SNAPSHOT_TTL_MS
|
|
89
100
|
}
|
|
90
101
|
|
|
102
|
+
/**
|
|
103
|
+
* Build the canonical map key for one Origin + model pair.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} providerKey
|
|
106
|
+
* @param {string} modelId
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
export function buildUsageSnapshotKey(providerKey, modelId) {
|
|
110
|
+
return `${providerKey}::${modelId}`
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/**
|
|
92
114
|
* Load token-stats.json and return model/provider usage maps.
|
|
93
115
|
* Entries with stale `updatedAt` (older than SNAPSHOT_TTL_MS) are excluded.
|
|
@@ -96,7 +118,7 @@ function isSnapshotFresh(entry, nowMs = Date.now()) {
|
|
|
96
118
|
* The 30-minute data freshness filter is re-applied on every cache miss (parse).
|
|
97
119
|
*
|
|
98
120
|
* @param {string} [statsFile]
|
|
99
|
-
* @returns {{
|
|
121
|
+
* @returns {{ byProviderModel: Record<string, number>, byProvider: Record<string, number>, legacyByModel: Record<string, number> }}
|
|
100
122
|
*/
|
|
101
123
|
export function loadUsageSnapshot(statsFile = DEFAULT_STATS_FILE) {
|
|
102
124
|
const now = Date.now()
|
|
@@ -118,23 +140,40 @@ export function loadUsageSnapshot(statsFile = DEFAULT_STATS_FILE) {
|
|
|
118
140
|
*
|
|
119
141
|
* @param {string} statsFile
|
|
120
142
|
* @param {number} now - current time in ms (for freshness checks)
|
|
121
|
-
* @returns {{
|
|
143
|
+
* @returns {{ byProviderModel: Record<string, number>, byProvider: Record<string, number>, legacyByModel: Record<string, number> }}
|
|
122
144
|
*/
|
|
123
145
|
function _parseSnapshot(statsFile, now) {
|
|
124
146
|
try {
|
|
125
|
-
if (!existsSync(statsFile)) return {
|
|
147
|
+
if (!existsSync(statsFile)) return { byProviderModel: {}, byProvider: {}, legacyByModel: {} }
|
|
126
148
|
const raw = readFileSync(statsFile, 'utf8')
|
|
127
149
|
const data = JSON.parse(raw)
|
|
128
150
|
|
|
151
|
+
const byProviderModelSrc = data?.quotaSnapshots?.byProviderModel
|
|
129
152
|
const byModelSrc = data?.quotaSnapshots?.byModel
|
|
130
153
|
const byProviderSrc = data?.quotaSnapshots?.byProvider
|
|
131
154
|
|
|
132
|
-
const
|
|
155
|
+
const byProviderModel = {}
|
|
156
|
+
if (byProviderModelSrc && typeof byProviderModelSrc === 'object') {
|
|
157
|
+
for (const [snapshotKey, entry] of Object.entries(byProviderModelSrc)) {
|
|
158
|
+
const providerKey = typeof entry?.providerKey === 'string'
|
|
159
|
+
? entry.providerKey
|
|
160
|
+
: snapshotKey.split('::', 1)[0]
|
|
161
|
+
if (!supportsUsagePercent(providerKey)) continue
|
|
162
|
+
if (entry && typeof entry.quotaPercent === 'number' && Number.isFinite(entry.quotaPercent)) {
|
|
163
|
+
if (isSnapshotFresh(entry, now, providerKey)) {
|
|
164
|
+
byProviderModel[snapshotKey] = entry.quotaPercent
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 📖 Legacy map kept only for backward compatibility helpers/tests.
|
|
171
|
+
const legacyByModel = {}
|
|
133
172
|
if (byModelSrc && typeof byModelSrc === 'object') {
|
|
134
173
|
for (const [modelId, entry] of Object.entries(byModelSrc)) {
|
|
135
174
|
if (entry && typeof entry.quotaPercent === 'number' && Number.isFinite(entry.quotaPercent)) {
|
|
136
175
|
if (isSnapshotFresh(entry, now)) {
|
|
137
|
-
|
|
176
|
+
legacyByModel[modelId] = entry.quotaPercent
|
|
138
177
|
}
|
|
139
178
|
}
|
|
140
179
|
}
|
|
@@ -143,44 +182,45 @@ function _parseSnapshot(statsFile, now) {
|
|
|
143
182
|
const byProvider = {}
|
|
144
183
|
if (byProviderSrc && typeof byProviderSrc === 'object') {
|
|
145
184
|
for (const [providerKey, entry] of Object.entries(byProviderSrc)) {
|
|
185
|
+
if (!supportsUsagePercent(providerKey)) continue
|
|
146
186
|
if (entry && typeof entry.quotaPercent === 'number' && Number.isFinite(entry.quotaPercent)) {
|
|
147
|
-
if (isSnapshotFresh(entry, now)) {
|
|
187
|
+
if (isSnapshotFresh(entry, now, providerKey)) {
|
|
148
188
|
byProvider[providerKey] = entry.quotaPercent
|
|
149
189
|
}
|
|
150
190
|
}
|
|
151
191
|
}
|
|
152
192
|
}
|
|
153
193
|
|
|
154
|
-
return {
|
|
194
|
+
return { byProviderModel, byProvider, legacyByModel }
|
|
155
195
|
} catch {
|
|
156
|
-
return {
|
|
196
|
+
return { byProviderModel: {}, byProvider: {}, legacyByModel: {} }
|
|
157
197
|
}
|
|
158
198
|
}
|
|
159
199
|
|
|
160
200
|
/**
|
|
161
|
-
* Load token-stats.json and return a plain object mapping
|
|
201
|
+
* Load token-stats.json and return a plain object mapping provider+model → quotaPercent.
|
|
162
202
|
*
|
|
163
203
|
* Only includes models whose `quotaPercent` is a finite number and whose
|
|
164
204
|
* snapshot is fresh (within SNAPSHOT_TTL_MS).
|
|
165
205
|
* Returns an empty object on any error (missing file, bad JSON, missing keys).
|
|
166
206
|
*
|
|
167
207
|
* @param {string} [statsFile] - Path to token-stats.json (defaults to ~/.free-coding-models/token-stats.json)
|
|
168
|
-
* @returns {Record<string, number>} e.g. { '
|
|
208
|
+
* @returns {Record<string, number>} e.g. { 'groq::openai/gpt-oss-120b': 37 }
|
|
169
209
|
*/
|
|
170
210
|
export function loadUsageMap(statsFile = DEFAULT_STATS_FILE) {
|
|
171
|
-
return loadUsageSnapshot(statsFile).
|
|
211
|
+
return loadUsageSnapshot(statsFile).byProviderModel
|
|
172
212
|
}
|
|
173
213
|
|
|
174
214
|
/**
|
|
175
|
-
* Return the quota percent remaining for a specific
|
|
176
|
-
*
|
|
215
|
+
* Return the legacy quota percent remaining for a specific modelId.
|
|
216
|
+
* This helper is retained for backward compatibility tests only.
|
|
177
217
|
*
|
|
178
218
|
* @param {string} modelId
|
|
179
219
|
* @param {string} [statsFile] - Path to token-stats.json (defaults to ~/.free-coding-models/token-stats.json)
|
|
180
220
|
* @returns {number | null} quota percent (0–100), or null if unknown/stale
|
|
181
221
|
*/
|
|
182
222
|
export function usageForModelId(modelId, statsFile = DEFAULT_STATS_FILE) {
|
|
183
|
-
const map =
|
|
223
|
+
const map = loadUsageSnapshot(statsFile).legacyByModel
|
|
184
224
|
const value = map[modelId]
|
|
185
225
|
return value !== undefined ? value : null
|
|
186
226
|
}
|
|
@@ -196,8 +236,10 @@ export function usageForModelId(modelId, statsFile = DEFAULT_STATS_FILE) {
|
|
|
196
236
|
* @returns {number | null}
|
|
197
237
|
*/
|
|
198
238
|
export function usageForRow(providerKey, modelId, statsFile = DEFAULT_STATS_FILE) {
|
|
199
|
-
|
|
200
|
-
|
|
239
|
+
if (!supportsUsagePercent(providerKey)) return null
|
|
240
|
+
const { byProviderModel, byProvider } = loadUsageSnapshot(statsFile)
|
|
241
|
+
const providerModelKey = buildUsageSnapshotKey(providerKey, modelId)
|
|
242
|
+
if (byProviderModel[providerModelKey] !== undefined) return byProviderModel[providerModelKey]
|
|
201
243
|
if (byProvider[providerKey] !== undefined) return byProvider[providerKey]
|
|
202
244
|
return null
|
|
203
245
|
}
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file lib/quota-capabilities.js
|
|
3
|
-
* @description Provider quota telemetry capability map.
|
|
4
|
-
*
|
|
5
|
-
* Describes how we can observe quota state for each provider:
|
|
6
|
-
* - header: Provider sends x-ratelimit-remaining / x-ratelimit-limit headers
|
|
7
|
-
* - endpoint: Provider has a dedicated usage/quota REST endpoint we can poll
|
|
8
|
-
* - unknown: No reliable quota signal available
|
|
9
|
-
*
|
|
10
|
-
* supportsEndpoint (optional, for openrouter/siliconflow):
|
|
11
|
-
* true — provider has a known usage endpoint
|
|
12
|
-
* false — no endpoint, header-only or unknown
|
|
13
|
-
*
|
|
14
|
-
* @exports PROVIDER_CAPABILITIES — full map keyed by providerKey (matches sources.js)
|
|
15
|
-
* @exports getQuotaTelemetry(providerKey) — returns capability object (defaults to unknown)
|
|
16
|
-
* @exports isKnownQuotaTelemetry(providerKey) — true when telemetryType !== 'unknown'
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* @typedef {Object} ProviderCapability
|
|
21
|
-
* @property {'header'|'endpoint'|'unknown'} telemetryType
|
|
22
|
-
* @property {boolean} [supportsEndpoint]
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
/** @type {Record<string, ProviderCapability>} */
|
|
26
|
-
export const PROVIDER_CAPABILITIES = {
|
|
27
|
-
// Providers that return x-ratelimit-remaining / x-ratelimit-limit headers
|
|
28
|
-
nvidia: { telemetryType: 'header', supportsEndpoint: false },
|
|
29
|
-
groq: { telemetryType: 'header', supportsEndpoint: false },
|
|
30
|
-
cerebras: { telemetryType: 'header', supportsEndpoint: false },
|
|
31
|
-
sambanova: { telemetryType: 'header', supportsEndpoint: false },
|
|
32
|
-
deepinfra: { telemetryType: 'header', supportsEndpoint: false },
|
|
33
|
-
fireworks: { telemetryType: 'header', supportsEndpoint: false },
|
|
34
|
-
together: { telemetryType: 'header', supportsEndpoint: false },
|
|
35
|
-
hyperbolic: { telemetryType: 'header', supportsEndpoint: false },
|
|
36
|
-
scaleway: { telemetryType: 'header', supportsEndpoint: false },
|
|
37
|
-
googleai: { telemetryType: 'header', supportsEndpoint: false },
|
|
38
|
-
codestral: { telemetryType: 'header', supportsEndpoint: false },
|
|
39
|
-
perplexity: { telemetryType: 'header', supportsEndpoint: false },
|
|
40
|
-
qwen: { telemetryType: 'header', supportsEndpoint: false },
|
|
41
|
-
|
|
42
|
-
// Providers that have a dedicated usage/credits endpoint
|
|
43
|
-
openrouter: { telemetryType: 'endpoint', supportsEndpoint: true },
|
|
44
|
-
siliconflow: { telemetryType: 'endpoint', supportsEndpoint: true },
|
|
45
|
-
|
|
46
|
-
// Providers with no reliable quota signal
|
|
47
|
-
huggingface: { telemetryType: 'unknown', supportsEndpoint: false },
|
|
48
|
-
replicate: { telemetryType: 'unknown', supportsEndpoint: false },
|
|
49
|
-
cloudflare: { telemetryType: 'unknown', supportsEndpoint: false },
|
|
50
|
-
zai: { telemetryType: 'unknown', supportsEndpoint: false },
|
|
51
|
-
iflow: { telemetryType: 'unknown', supportsEndpoint: false },
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Fallback for unrecognized providers */
|
|
55
|
-
const UNKNOWN_CAPABILITY = { telemetryType: 'unknown', supportsEndpoint: false }
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Get quota telemetry capability for a provider.
|
|
59
|
-
* Returns `{ telemetryType: 'unknown', supportsEndpoint: false }` for unrecognized providers.
|
|
60
|
-
*
|
|
61
|
-
* @param {string} providerKey - Provider key matching sources.js (e.g. 'groq', 'openrouter')
|
|
62
|
-
* @returns {ProviderCapability}
|
|
63
|
-
*/
|
|
64
|
-
export function getQuotaTelemetry(providerKey) {
|
|
65
|
-
return PROVIDER_CAPABILITIES[providerKey] ?? UNKNOWN_CAPABILITY
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Returns true when we have a reliable quota telemetry signal for this provider
|
|
70
|
-
* (either via response headers or a dedicated endpoint).
|
|
71
|
-
*
|
|
72
|
-
* Returns false for 'unknown' providers where quota state must be inferred.
|
|
73
|
-
*
|
|
74
|
-
* @param {string} providerKey
|
|
75
|
-
* @returns {boolean}
|
|
76
|
-
*/
|
|
77
|
-
export function isKnownQuotaTelemetry(providerKey) {
|
|
78
|
-
return getQuotaTelemetry(providerKey).telemetryType !== 'unknown'
|
|
79
|
-
}
|
|
File without changes
|
/package/{lib → src}/config.js
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
/package/{lib → src}/utils.js
RENAMED
|
File without changes
|