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/src/ping.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * @file ping.js
3
+ * @description HTTP ping infrastructure for model availability and latency measurement.
4
+ *
5
+ * @details
6
+ * This module provides functions for sending ping requests to model providers,
7
+ * extracting quota information from response headers, and managing provider
8
+ * endpoint quota polling with caching.
9
+ *
10
+ * 🎯 Key features:
11
+ * - Provider-specific request building (handles Replicate, Cloudflare, OpenRouter)
12
+ * - Async ping with timeout and abort controller
13
+ * - Quota extraction from rate limit headers (multiple variants supported)
14
+ * - Cached provider quota polling with TTL and error backoff
15
+ * - Cloudflare account ID resolution from environment
16
+ *
17
+ * → Functions:
18
+ * - `resolveCloudflareUrl`: Resolve {account_id} placeholder from CLOUDFLARE_ACCOUNT_ID env var
19
+ * - `buildPingRequest`: Build provider-specific HTTP request for pinging
20
+ * - `ping`: Send async ping request with timeout; returns { code, ms, quotaPercent }
21
+ * - `getHeaderValue`: Helper to extract header value from Headers object or plain object
22
+ * - `extractQuotaPercent`: Parse rate limit headers to calculate remaining quota percentage
23
+ * - `fetchProviderQuotaPercent`: Fetch quota for a provider from dedicated endpoint/headers
24
+ * - `getProviderQuotaPercentCached`: Wrapper for cached provider quota fetching
25
+ * - `usagePlaceholderForProvider`: Return display token for Usage column based on provider behavior
26
+ *
27
+ * 📦 Dependencies:
28
+ * - ../src/constants.js: PING_TIMEOUT
29
+ * - ../src/provider-quota-fetchers.js: _fetchProviderQuotaFromModule (quota fetching with cache)
30
+ * - ../src/quota-capabilities.js: supportsUsagePercent
31
+ *
32
+ * ⚙️ Configuration:
33
+ * - PING_TIMEOUT: Timeout in ms for ping requests (default: 15000)
34
+ * - CLOUDFLARE_ACCOUNT_ID: Env var for Cloudflare Workers AI account ID
35
+ *
36
+ * @see {@link ../src/provider-quota-fetchers.js} Quota fetching implementation
37
+ * @see {@link ../src/quota-capabilities.js} Quota telemetry + Usage behavior detection
38
+ */
39
+
40
+ import { PING_TIMEOUT } from './constants.js'
41
+ import { fetchProviderQuota as _fetchProviderQuotaFromModule } from './provider-quota-fetchers.js'
42
+ import { supportsUsagePercent } from './quota-capabilities.js'
43
+
44
+ // 📖 resolveCloudflareUrl: Cloudflare's OpenAI-compatible endpoint is account-scoped.
45
+ // 📖 We resolve {account_id} from env so provider setup can stay simple in config.
46
+ export function resolveCloudflareUrl(url) {
47
+ const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
48
+ if (!url.includes('{account_id}')) return url
49
+ if (!accountId) return url.replace('{account_id}', 'missing-account-id')
50
+ return url.replace('{account_id}', encodeURIComponent(accountId))
51
+ }
52
+
53
+ // 📖 buildPingRequest: Build provider-specific ping request.
54
+ // 📖 Handles Replicate's /v1/predictions format, Cloudflare's account_id in URL,
55
+ // 📖 and standard OpenAI-compliant chat completions with provider-specific headers.
56
+ export function buildPingRequest(apiKey, modelId, providerKey, url) {
57
+ // 📖 ZAI models are stored as "zai/glm-..." in sources.js but the API expects just "glm-..."
58
+ const apiModelId = providerKey === 'zai' ? modelId.replace(/^zai\//, '') : modelId
59
+
60
+ if (providerKey === 'replicate') {
61
+ // 📖 Replicate uses /v1/predictions with a different payload than OpenAI chat-completions.
62
+ const replicateHeaders = { 'Content-Type': 'application/json', Prefer: 'wait=4' }
63
+ if (apiKey) replicateHeaders.Authorization = `Token ${apiKey}`
64
+ return {
65
+ url,
66
+ headers: replicateHeaders,
67
+ body: { version: modelId, input: { prompt: 'hi' } },
68
+ }
69
+ }
70
+
71
+ if (providerKey === 'cloudflare') {
72
+ // 📖 Cloudflare Workers AI uses OpenAI-compatible payload but needs account_id in URL.
73
+ const headers = { 'Content-Type': 'application/json' }
74
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
75
+ return {
76
+ url: resolveCloudflareUrl(url),
77
+ headers,
78
+ body: { model: apiModelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
79
+ }
80
+ }
81
+
82
+ const headers = { 'Content-Type': 'application/json' }
83
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
84
+ if (providerKey === 'openrouter') {
85
+ // 📖 OpenRouter recommends optional app identification headers.
86
+ headers['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
87
+ headers['X-Title'] = 'free-coding-models'
88
+ }
89
+
90
+ return {
91
+ url,
92
+ headers,
93
+ body: { model: apiModelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
94
+ }
95
+ }
96
+
97
+ // 📖 ping: Send a single chat completion request to measure model availability and latency.
98
+ // 📖 providerKey and url determine provider-specific request format.
99
+ // 📖 apiKey can be null — in that case no Authorization header is sent.
100
+ // 📖 A 401 response still tells us the server is UP and gives us real latency.
101
+ // 📖 Returns { code, ms, quotaPercent }
102
+ export async function ping(apiKey, modelId, providerKey, url) {
103
+ const ctrl = new AbortController()
104
+ const timer = setTimeout(() => ctrl.abort(), PING_TIMEOUT)
105
+ const t0 = performance.now()
106
+ try {
107
+ const req = buildPingRequest(apiKey, modelId, providerKey, url)
108
+ const resp = await fetch(req.url, {
109
+ method: 'POST', signal: ctrl.signal,
110
+ headers: req.headers,
111
+ body: JSON.stringify(req.body),
112
+ })
113
+ // 📖 Normalize all HTTP 2xx statuses to "200" so existing verdict/avg logic still works.
114
+ const code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
115
+ return {
116
+ code,
117
+ ms: Math.round(performance.now() - t0),
118
+ quotaPercent: extractQuotaPercent(resp.headers),
119
+ }
120
+ } catch (err) {
121
+ const isTimeout = err.name === 'AbortError'
122
+ return {
123
+ code: isTimeout ? '000' : 'ERR',
124
+ ms: isTimeout ? 'TIMEOUT' : Math.round(performance.now() - t0),
125
+ quotaPercent: null,
126
+ }
127
+ } finally {
128
+ clearTimeout(timer)
129
+ }
130
+ }
131
+
132
+ // 📖 getHeaderValue: Helper to extract header value from Headers object or plain object.
133
+ // 📖 Returns null if headers is null or key is not found.
134
+ function getHeaderValue(headers, key) {
135
+ if (!headers) return null
136
+ if (typeof headers.get === 'function') return headers.get(key)
137
+ return headers[key] ?? headers[key.toLowerCase()] ?? null
138
+ }
139
+
140
+ // 📖 extractQuotaPercent: Parse rate limit headers to calculate remaining quota percentage.
141
+ // 📖 Checks multiple header variants (x-ratelimit-*, ratelimit-*, etc.).
142
+ // 📖 Returns value clamped 0–100, or null if quota headers not present.
143
+ export function extractQuotaPercent(headers) {
144
+ const variants = [
145
+ ['x-ratelimit-remaining', 'x-ratelimit-limit'],
146
+ ['x-ratelimit-remaining-requests', 'x-ratelimit-limit-requests'],
147
+ ['ratelimit-remaining', 'ratelimit-limit'],
148
+ ['ratelimit-remaining-requests', 'ratelimit-limit-requests'],
149
+ ]
150
+
151
+ for (const [remainingKey, limitKey] of variants) {
152
+ const remainingRaw = getHeaderValue(headers, remainingKey)
153
+ const limitRaw = getHeaderValue(headers, limitKey)
154
+ const remaining = parseFloat(remainingRaw)
155
+ const limit = parseFloat(limitRaw)
156
+ if (Number.isFinite(remaining) && Number.isFinite(limit) && limit > 0) {
157
+ const pct = Math.round((remaining / limit) * 100)
158
+ return Math.max(0, Math.min(100, pct))
159
+ }
160
+ }
161
+
162
+ return null
163
+ }
164
+
165
+ // ─── Provider endpoint quota polling ─────────────────────────────────────────
166
+
167
+ // 📖 fetchProviderQuotaPercent: Fetch quota for a provider from dedicated endpoint/headers.
168
+ // 📖 Delegates to unified module entrypoint (handles openrouter + siliconflow + others).
169
+ // 📖 The module implements TTL cache and error backoff internally.
170
+ export async function fetchProviderQuotaPercent(providerKey, apiKey) {
171
+ return _fetchProviderQuotaFromModule(providerKey, apiKey)
172
+ }
173
+
174
+ // 📖 getProviderQuotaPercentCached: Wrapper for cached provider quota fetching.
175
+ // 📖 The module already implements TTL cache and error backoff internally.
176
+ // 📖 This wrapper preserves the existing call-site API from bin/free-coding-models.js.
177
+ export async function getProviderQuotaPercentCached(providerKey, apiKey) {
178
+ return fetchProviderQuotaPercent(providerKey, apiKey)
179
+ }
180
+
181
+ // 📖 usagePlaceholderForProvider: Return display token for Usage column.
182
+ // 📖 '--' means this provider can expose a real remaining percentage once telemetry arrives.
183
+ // 📖 '🟢' means the provider is usable, but a live remaining % is not applicable/reliable.
184
+ export function usagePlaceholderForProvider(providerKey) {
185
+ return supportsUsagePercent(providerKey) ? '--' : '🟢'
186
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * @file provider-metadata.js
3
+ * @description Provider metadata, environment variable names, and OpenCode model ID mapping.
4
+ * Extracted from bin/free-coding-models.js to allow shared access by setup wizard,
5
+ * Settings overlay, and OpenCode integration helpers.
6
+ *
7
+ * @details
8
+ * This module owns three separate concerns that all relate to "knowing about providers":
9
+ *
10
+ * 1. `PROVIDER_METADATA` — human-readable display info (label, colour, signup URL, rate limits)
11
+ * used in the setup wizard (`promptApiKey`) and the Settings overlay.
12
+ *
13
+ * 2. `ENV_VAR_NAMES` — maps providerKey → the environment variable name that carries the API key.
14
+ * Used when spawning OpenCode child processes so that keys stored only in
15
+ * ~/.free-coding-models.json are also visible to the child via `{env:VAR}` references.
16
+ *
17
+ * 3. `OPENCODE_MODEL_MAP` — sparse mapping of source model IDs to OpenCode built-in model IDs
18
+ * (only entries where the IDs differ need to be listed). Groq's API aliases short names
19
+ * to full names but OpenCode does exact ID matching against its built-in model list.
20
+ *
21
+ * Platform booleans (`isWindows`, `isMac`, `isLinux`) are also exported here so that
22
+ * OpenCode Desktop launch logic and auto-update can share them without re-reading `process.platform`.
23
+ *
24
+ * @exports
25
+ * PROVIDER_METADATA, ENV_VAR_NAMES, OPENCODE_MODEL_MAP,
26
+ * isWindows, isMac, isLinux
27
+ *
28
+ * @see bin/free-coding-models.js — consumes all exports from this module
29
+ * @see src/config.js — resolveApiKeys / getApiKey use ENV_VAR_NAMES indirectly
30
+ */
31
+
32
+ import chalk from 'chalk'
33
+
34
+ // 📖 Platform detection — used by Desktop launcher and auto-update to pick the right open/start command.
35
+ export const isWindows = process.platform === 'win32'
36
+ export const isMac = process.platform === 'darwin'
37
+ export const isLinux = process.platform === 'linux'
38
+
39
+ // 📖 ENV_VAR_NAMES: maps providerKey → shell env var name for passing resolved keys to child processes.
40
+ // 📖 When a key is stored only in ~/.free-coding-models.json (not in the shell env), we inject it
41
+ // 📖 into the child's env so OpenCode's {env:VAR} references still resolve.
42
+ export const ENV_VAR_NAMES = {
43
+ nvidia: 'NVIDIA_API_KEY',
44
+ groq: 'GROQ_API_KEY',
45
+ cerebras: 'CEREBRAS_API_KEY',
46
+ sambanova: 'SAMBANOVA_API_KEY',
47
+ openrouter: 'OPENROUTER_API_KEY',
48
+ huggingface:'HUGGINGFACE_API_KEY',
49
+ replicate: 'REPLICATE_API_TOKEN',
50
+ deepinfra: 'DEEPINFRA_API_KEY',
51
+ fireworks: 'FIREWORKS_API_KEY',
52
+ codestral: 'CODESTRAL_API_KEY',
53
+ hyperbolic: 'HYPERBOLIC_API_KEY',
54
+ scaleway: 'SCALEWAY_API_KEY',
55
+ googleai: 'GOOGLE_API_KEY',
56
+ siliconflow:'SILICONFLOW_API_KEY',
57
+ together: 'TOGETHER_API_KEY',
58
+ cloudflare: 'CLOUDFLARE_API_TOKEN',
59
+ perplexity: 'PERPLEXITY_API_KEY',
60
+ zai: 'ZAI_API_KEY',
61
+ }
62
+
63
+ // 📖 OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
64
+ // 📖 built-in model registry. Only add entries where they DIFFER — unmapped models pass through as-is.
65
+ export const OPENCODE_MODEL_MAP = {
66
+ groq: {
67
+ 'moonshotai/kimi-k2-instruct': 'moonshotai/kimi-k2-instruct-0905',
68
+ 'meta-llama/llama-4-scout-17b-16e-preview': 'meta-llama/llama-4-scout-17b-16e-instruct',
69
+ 'meta-llama/llama-4-maverick-17b-128e-preview': 'meta-llama/llama-4-maverick-17b-128e-instruct',
70
+ }
71
+ }
72
+
73
+ // 📖 PROVIDER_METADATA: display info for each provider, used in setup wizard and Settings panel.
74
+ // 📖 `color` is a chalk function for visual distinction in the TUI.
75
+ // 📖 `signupUrl` / `signupHint` guide users through first-time key generation.
76
+ // 📖 `rateLimits` gives a quick reminder of the free-tier quota without opening a browser.
77
+ export const PROVIDER_METADATA = {
78
+ nvidia: {
79
+ label: 'NVIDIA NIM',
80
+ color: chalk.rgb(118, 185, 0),
81
+ signupUrl: 'https://build.nvidia.com',
82
+ signupHint: 'Profile → API Keys → Generate',
83
+ rateLimits: 'Free tier (provider quota by model)',
84
+ },
85
+ groq: {
86
+ label: 'Groq',
87
+ color: chalk.rgb(249, 103, 20),
88
+ signupUrl: 'https://console.groq.com/keys',
89
+ signupHint: 'API Keys → Create API Key',
90
+ rateLimits: 'Free dev tier (provider quota)',
91
+ },
92
+ cerebras: {
93
+ label: 'Cerebras',
94
+ color: chalk.rgb(0, 180, 255),
95
+ signupUrl: 'https://cloud.cerebras.ai',
96
+ signupHint: 'API Keys → Create',
97
+ rateLimits: 'Free dev tier (provider quota)',
98
+ },
99
+ sambanova: {
100
+ label: 'SambaNova',
101
+ color: chalk.rgb(255, 165, 0),
102
+ signupUrl: 'https://cloud.sambanova.ai/apis',
103
+ signupHint: 'SambaCloud portal → Create API key',
104
+ rateLimits: 'Dev tier generous quota',
105
+ },
106
+ openrouter: {
107
+ label: 'OpenRouter',
108
+ color: chalk.rgb(120, 80, 255),
109
+ signupUrl: 'https://openrouter.ai/keys',
110
+ signupHint: 'API Keys → Create',
111
+ rateLimits: '50 req/day, 20/min (:free shared quota)',
112
+ },
113
+ huggingface: {
114
+ label: 'Hugging Face Inference',
115
+ color: chalk.rgb(255, 182, 0),
116
+ signupUrl: 'https://huggingface.co/settings/tokens',
117
+ signupHint: 'Settings → Access Tokens',
118
+ rateLimits: 'Free monthly credits (~$0.10)',
119
+ },
120
+ replicate: {
121
+ label: 'Replicate',
122
+ color: chalk.rgb(120, 160, 255),
123
+ signupUrl: 'https://replicate.com/account/api-tokens',
124
+ signupHint: 'Account → API Tokens',
125
+ rateLimits: 'Developer free quota',
126
+ },
127
+ deepinfra: {
128
+ label: 'DeepInfra',
129
+ color: chalk.rgb(0, 180, 140),
130
+ signupUrl: 'https://deepinfra.com/login',
131
+ signupHint: 'Login → API keys',
132
+ rateLimits: 'Free dev tier (low-latency quota)',
133
+ },
134
+ fireworks: {
135
+ label: 'Fireworks AI',
136
+ color: chalk.rgb(255, 80, 50),
137
+ signupUrl: 'https://fireworks.ai',
138
+ signupHint: 'Create account → Generate API key',
139
+ rateLimits: '$1 free credits (new dev accounts)',
140
+ },
141
+ codestral: {
142
+ label: 'Mistral Codestral',
143
+ color: chalk.rgb(255, 100, 100),
144
+ signupUrl: 'https://codestral.mistral.ai',
145
+ signupHint: 'API Keys → Create',
146
+ rateLimits: '30 req/min, 2000/day',
147
+ },
148
+ hyperbolic: {
149
+ label: 'Hyperbolic',
150
+ color: chalk.rgb(0, 200, 150),
151
+ signupUrl: 'https://app.hyperbolic.ai/settings',
152
+ signupHint: 'Settings → API Keys',
153
+ rateLimits: '$1 free trial credits',
154
+ },
155
+ scaleway: {
156
+ label: 'Scaleway',
157
+ color: chalk.rgb(130, 0, 250),
158
+ signupUrl: 'https://console.scaleway.com/iam/api-keys',
159
+ signupHint: 'IAM → API Keys',
160
+ rateLimits: '1M free tokens',
161
+ },
162
+ googleai: {
163
+ label: 'Google AI Studio',
164
+ color: chalk.rgb(66, 133, 244),
165
+ signupUrl: 'https://aistudio.google.com/apikey',
166
+ signupHint: 'Get API key',
167
+ rateLimits: '14.4K req/day, 30/min',
168
+ },
169
+ siliconflow: {
170
+ label: 'SiliconFlow',
171
+ color: chalk.rgb(255, 120, 30),
172
+ signupUrl: 'https://cloud.siliconflow.cn/account/ak',
173
+ signupHint: 'API Keys → Create',
174
+ rateLimits: 'Free models: usually 100 RPM, varies by model',
175
+ },
176
+ together: {
177
+ label: 'Together AI',
178
+ color: chalk.rgb(0, 180, 255),
179
+ signupUrl: 'https://api.together.ai/settings/api-keys',
180
+ signupHint: 'Settings → API keys',
181
+ rateLimits: 'Credits/promos vary by account (check console)',
182
+ },
183
+ cloudflare: {
184
+ label: 'Cloudflare Workers AI',
185
+ color: chalk.rgb(242, 119, 36),
186
+ signupUrl: 'https://dash.cloudflare.com',
187
+ signupHint: 'Create AI API token + set CLOUDFLARE_ACCOUNT_ID',
188
+ rateLimits: 'Free: 10k neurons/day, text-gen 300 RPM',
189
+ },
190
+ perplexity: {
191
+ label: 'Perplexity API',
192
+ color: chalk.rgb(0, 210, 190),
193
+ signupUrl: 'https://www.perplexity.ai/settings/api',
194
+ signupHint: 'Generate API key (billing may be required)',
195
+ rateLimits: 'Tiered limits by spend (default ~50 RPM)',
196
+ },
197
+ qwen: {
198
+ label: 'Alibaba Cloud (DashScope)',
199
+ color: chalk.rgb(255, 140, 0),
200
+ signupUrl: 'https://modelstudio.console.alibabacloud.com',
201
+ signupHint: 'Model Studio → API Key → Create (1M free tokens, 90 days)',
202
+ rateLimits: '1M free tokens per model (Singapore region, 90 days)',
203
+ },
204
+ zai: {
205
+ label: 'ZAI (z.ai)',
206
+ color: chalk.rgb(0, 150, 255),
207
+ signupUrl: 'https://z.ai',
208
+ signupHint: 'Sign up and generate an API key',
209
+ rateLimits: 'Free tier (generous quota)',
210
+ },
211
+ iflow: {
212
+ label: 'iFlow',
213
+ color: chalk.rgb(100, 200, 255),
214
+ signupUrl: 'https://platform.iflow.cn',
215
+ signupHint: 'Register → Personal Information → Generate API Key (7-day expiry)',
216
+ rateLimits: 'Free for individuals (no request limits)',
217
+ },
218
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @file lib/quota-capabilities.js
3
+ * @description Provider quota telemetry and Usage-column behavior 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
+ * The TUI needs an extra distinction beyond telemetry transport:
11
+ * - `usageDisplay: 'percent'` means we can show a trustworthy remaining %.
12
+ * - `usageDisplay: 'ok'` means Usage is not meaningfully measurable as a live %,
13
+ * so the table shows a green status dot instead of a misleading number.
14
+ *
15
+ * `resetCadence` tells the reader when a stored snapshot should be invalidated
16
+ * even if it is still within the generic freshness TTL.
17
+ *
18
+ * supportsEndpoint (optional, for openrouter/siliconflow):
19
+ * true — provider has a known usage endpoint
20
+ * false — no endpoint, header-only or unknown
21
+ *
22
+ * @exports PROVIDER_CAPABILITIES — full map keyed by providerKey (matches sources.js)
23
+ * @exports getQuotaTelemetry(providerKey) — returns capability object (defaults to unknown)
24
+ * @exports isKnownQuotaTelemetry(providerKey) — true when telemetryType !== 'unknown'
25
+ */
26
+
27
+ /**
28
+ * @typedef {Object} ProviderCapability
29
+ * @property {'header'|'endpoint'|'unknown'} telemetryType
30
+ * @property {boolean} [supportsEndpoint]
31
+ * @property {'percent'|'ok'} usageDisplay
32
+ * @property {'rolling'|'daily'|'unknown'|'none'} resetCadence
33
+ */
34
+
35
+ /** @type {Record<string, ProviderCapability>} */
36
+ export const PROVIDER_CAPABILITIES = {
37
+ // Providers that return x-ratelimit-remaining / x-ratelimit-limit headers
38
+ nvidia: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
39
+ groq: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'daily' },
40
+ cerebras: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
41
+ sambanova: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
42
+ deepinfra: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
43
+ fireworks: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
44
+ together: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
45
+ hyperbolic: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
46
+ scaleway: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
47
+ googleai: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'daily' },
48
+ codestral: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'daily' },
49
+ perplexity: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
50
+ qwen: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
51
+
52
+ // Providers that have a dedicated usage/credits endpoint
53
+ openrouter: { telemetryType: 'endpoint', supportsEndpoint: true, usageDisplay: 'percent', resetCadence: 'unknown' },
54
+ siliconflow: { telemetryType: 'endpoint', supportsEndpoint: true, usageDisplay: 'ok', resetCadence: 'unknown' },
55
+
56
+ // Providers with no reliable quota signal
57
+ huggingface: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
58
+ replicate: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
59
+ cloudflare: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
60
+ zai: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
61
+ iflow: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
62
+ }
63
+
64
+ /** Fallback for unrecognized providers */
65
+ const UNKNOWN_CAPABILITY = { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' }
66
+
67
+ /**
68
+ * Get quota telemetry capability for a provider.
69
+ * Returns `{ telemetryType: 'unknown', supportsEndpoint: false }` for unrecognized providers.
70
+ *
71
+ * @param {string} providerKey - Provider key matching sources.js (e.g. 'groq', 'openrouter')
72
+ * @returns {ProviderCapability}
73
+ */
74
+ export function getQuotaTelemetry(providerKey) {
75
+ return PROVIDER_CAPABILITIES[providerKey] ?? UNKNOWN_CAPABILITY
76
+ }
77
+
78
+ /**
79
+ * Returns true when we have a reliable quota telemetry signal for this provider
80
+ * (either via response headers or a dedicated endpoint).
81
+ *
82
+ * Returns false for 'unknown' providers where quota state must be inferred.
83
+ *
84
+ * @param {string} providerKey
85
+ * @returns {boolean}
86
+ */
87
+ export function isKnownQuotaTelemetry(providerKey) {
88
+ return getQuotaTelemetry(providerKey).telemetryType !== 'unknown'
89
+ }
90
+
91
+ /**
92
+ * Returns true when the Usage column can show a real remaining percentage for
93
+ * the given provider.
94
+ *
95
+ * @param {string} providerKey
96
+ * @returns {boolean}
97
+ */
98
+ export function supportsUsagePercent(providerKey) {
99
+ return getQuotaTelemetry(providerKey).usageDisplay === 'percent'
100
+ }
101
+
102
+ /**
103
+ * Returns true when the provider's quota commonly resets on a daily cadence.
104
+ * This lets the usage reader invalidate yesterday's snapshots immediately
105
+ * after midnight instead of waiting for the generic TTL window to expire.
106
+ *
107
+ * @param {string} providerKey
108
+ * @returns {boolean}
109
+ */
110
+ export function usageResetsDaily(providerKey) {
111
+ return getQuotaTelemetry(providerKey).resetCadence === 'daily'
112
+ }