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/lib/config.js CHANGED
@@ -85,6 +85,9 @@
85
85
  * → loadConfig() — Read ~/.free-coding-models.json; auto-migrate old plain-text config if needed
86
86
  * → saveConfig(config) — Write config to ~/.free-coding-models.json with 0o600 permissions
87
87
  * → getApiKey(config, providerKey) — Get effective API key (env var override > config > null)
88
+ * → addApiKey(config, providerKey, key) — Append a key (string→array); ignores empty/duplicate
89
+ * → removeApiKey(config, providerKey, index?) — Remove key at index (or last); collapses array-of-1 to string; deletes when empty
90
+ * → listApiKeys(config, providerKey) — Return all keys for a provider as normalized array
88
91
  * → isProviderEnabled(config, providerKey) — Check if provider is enabled (defaults true)
89
92
  * → saveAsProfile(config, name) — Snapshot current apiKeys/providers/favorites/settings into a named profile
90
93
  * → loadProfile(config, name) — Apply a named profile's values onto the live config
@@ -95,6 +98,7 @@
95
98
  * → _emptyProfileSettings() — Default TUI settings for a profile
96
99
  *
97
100
  * @exports loadConfig, saveConfig, getApiKey, isProviderEnabled
101
+ * @exports addApiKey, removeApiKey, listApiKeys — multi-key management helpers
98
102
  * @exports saveAsProfile, loadProfile, listProfiles, deleteProfile
99
103
  * @exports getActiveProfileName, setActiveProfile
100
104
  * @exports CONFIG_PATH — path to the JSON config file
@@ -239,6 +243,124 @@ export function getApiKey(config, providerKey) {
239
243
  return null
240
244
  }
241
245
 
246
+ /**
247
+ * addApiKey: Append a new API key for a provider.
248
+ *
249
+ * - If the provider has no key yet, sets it as a plain string.
250
+ * - If the provider already has one string key, converts to array [existing, new].
251
+ * - If the provider already has an array, pushes the new key.
252
+ * - Ignores empty/whitespace keys.
253
+ * - Ignores exact duplicates (same string already present).
254
+ *
255
+ * @param {object} config — Live config object (will be mutated)
256
+ * @param {string} providerKey — Provider identifier (e.g. 'groq')
257
+ * @param {string} key — New API key to add
258
+ * @returns {boolean} true if added, false if ignored (empty or duplicate)
259
+ */
260
+ export function addApiKey(config, providerKey, key) {
261
+ const trimmed = typeof key === 'string' ? key.trim() : ''
262
+ if (!trimmed) return false
263
+ if (!config.apiKeys) config.apiKeys = {}
264
+ const current = config.apiKeys[providerKey]
265
+ if (!current) {
266
+ config.apiKeys[providerKey] = trimmed
267
+ return true
268
+ }
269
+ if (typeof current === 'string') {
270
+ if (current === trimmed) return false // duplicate
271
+ config.apiKeys[providerKey] = [current, trimmed]
272
+ return true
273
+ }
274
+ if (Array.isArray(current)) {
275
+ if (current.includes(trimmed)) return false // duplicate
276
+ current.push(trimmed)
277
+ return true
278
+ }
279
+ // unknown shape — replace
280
+ config.apiKeys[providerKey] = trimmed
281
+ return true
282
+ }
283
+
284
+ /**
285
+ * removeApiKey: Remove an API key for a provider by index, or remove the last one.
286
+ *
287
+ * - Removes the key at `index` if provided, else removes the last key.
288
+ * - If only one key remains after removal, collapses array to string.
289
+ * - If the last key is removed, deletes the provider entry entirely.
290
+ *
291
+ * @param {object} config — Live config object (will be mutated)
292
+ * @param {string} providerKey — Provider identifier (e.g. 'groq')
293
+ * @param {number} [index] — 0-based index to remove; omit to remove last
294
+ * @returns {boolean} true if a key was removed, false if nothing to remove
295
+ */
296
+ export function removeApiKey(config, providerKey, index) {
297
+ if (!config.apiKeys) return false
298
+ const current = config.apiKeys[providerKey]
299
+ if (!current) return false
300
+
301
+ if (typeof current === 'string') {
302
+ // Only one key — remove it
303
+ delete config.apiKeys[providerKey]
304
+ return true
305
+ }
306
+
307
+ if (Array.isArray(current)) {
308
+ const idx = (index !== undefined && index >= 0 && index < current.length) ? index : current.length - 1
309
+ current.splice(idx, 1)
310
+ if (current.length === 0) {
311
+ delete config.apiKeys[providerKey]
312
+ } else if (current.length === 1) {
313
+ config.apiKeys[providerKey] = current[0] // collapse array-of-1 to string
314
+ }
315
+ return true
316
+ }
317
+
318
+ return false
319
+ }
320
+
321
+ /**
322
+ * listApiKeys: Return all configured API keys for a provider as a normalized array.
323
+ * Empty when no key is configured.
324
+ *
325
+ * @param {object} config
326
+ * @param {string} providerKey
327
+ * @returns {string[]}
328
+ */
329
+ export function listApiKeys(config, providerKey) {
330
+ return resolveApiKeys(config, providerKey)
331
+ }
332
+
333
+ /**
334
+ * Resolve all API keys for a provider as an array.
335
+ * Handles: string → [string], string[] → string[], missing → []
336
+ * Filters empty strings. Falls back to envVarName if no config key.
337
+ */
338
+ export function resolveApiKeys(config, providerKey, envVarName) {
339
+ const raw = config?.apiKeys?.[providerKey]
340
+ let keys = []
341
+ if (Array.isArray(raw)) {
342
+ keys = raw
343
+ } else if (typeof raw === 'string' && raw.length > 0) {
344
+ keys = [raw]
345
+ } else if (envVarName && process.env[envVarName]) {
346
+ keys = [process.env[envVarName]]
347
+ }
348
+ return keys.filter(k => typeof k === 'string' && k.length > 0)
349
+ }
350
+
351
+ /**
352
+ * Normalize config for disk persistence.
353
+ * Single-element arrays collapse to string. Multi-element arrays stay.
354
+ */
355
+ export function normalizeApiKeyConfig(config) {
356
+ if (!config?.apiKeys) return
357
+ for (const [key, val] of Object.entries(config.apiKeys)) {
358
+ if (Array.isArray(val) && val.length === 1) {
359
+ config.apiKeys[key] = val[0]
360
+ }
361
+ }
362
+ }
363
+
242
364
  /**
243
365
  * 📖 isProviderEnabled: Check if a provider is enabled in config.
244
366
  *
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Error types:
3
+ * - QUOTA_EXHAUSTED: Skip account until quota resets
4
+ * - RATE_LIMITED: Backoff, try another account
5
+ * - MODEL_CAPACITY: Server overloaded, retry after delay
6
+ * - SERVER_ERROR: Backoff, count toward circuit breaker
7
+ * - AUTH_ERROR: Disable account permanently
8
+ * - NETWORK_ERROR: Connection failure, try another
9
+ * - MODEL_NOT_FOUND: Provider does not have/serve this model; skip and try next account
10
+ * - UNKNOWN: Generic, no retry
11
+ */
12
+ export const ErrorType = {
13
+ QUOTA_EXHAUSTED: 'QUOTA_EXHAUSTED',
14
+ RATE_LIMITED: 'RATE_LIMITED',
15
+ MODEL_CAPACITY: 'MODEL_CAPACITY',
16
+ SERVER_ERROR: 'SERVER_ERROR',
17
+ AUTH_ERROR: 'AUTH_ERROR',
18
+ NETWORK_ERROR: 'NETWORK_ERROR',
19
+ MODEL_NOT_FOUND: 'MODEL_NOT_FOUND',
20
+ UNKNOWN: 'UNKNOWN',
21
+ }
22
+
23
+ const QUOTA_KEYWORDS = ['quota', 'limit exceeded', 'billing', 'insufficient_quota', 'exceeded your']
24
+ const CAPACITY_KEYWORDS = ['overloaded', 'capacity', 'busy', 'unavailable']
25
+ /**
26
+ * Keywords that indicate a provider-level 404/410 means the model is not
27
+ * available on *this account/provider*, not a generic routing 404.
28
+ * These trigger rotation to the next provider rather than forwarding the error.
29
+ */
30
+ const MODEL_NOT_FOUND_KEYWORDS = [
31
+ 'model not found',
32
+ 'inaccessible',
33
+ 'not deployed',
34
+ 'model is not available',
35
+ 'model unavailable',
36
+ 'no such model',
37
+ ]
38
+
39
+ /**
40
+ * Classify the confidence level for a 429 response.
41
+ *
42
+ * Returns:
43
+ * - 'quota_exhaustion_likely' — body contains keywords indicating the account's quota is depleted
44
+ * - 'generic_rate_limit' — plain rate-limit with no quota-specific signal (or non-429 status)
45
+ *
46
+ * @param {number} statusCode
47
+ * @param {string} body
48
+ * @param {Object} headers
49
+ * @returns {'quota_exhaustion_likely'|'generic_rate_limit'}
50
+ */
51
+ export function rateLimitConfidence(statusCode, body, headers) {
52
+ if (statusCode !== 429) return 'generic_rate_limit'
53
+ const bodyLower = (body || '').toLowerCase()
54
+ const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
55
+ return isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
56
+ }
57
+
58
+ /**
59
+ * Classify an HTTP error response.
60
+ * @param {number} statusCode - 0 for network errors
61
+ * @param {string} body - Response body text or error message
62
+ * @param {Object} headers - Response headers (lowercased keys)
63
+ * @returns {{ type: string, retryAfterSec: number|null, shouldRetry: boolean, skipAccount: boolean, rateLimitConfidence?: string }}
64
+ */
65
+ export function classifyError(statusCode, body, headers) {
66
+ const bodyLower = (body || '').toLowerCase()
67
+ const retryAfter = headers?.['retry-after']
68
+ const retryAfterSec = retryAfter ? parseInt(retryAfter, 10) || null : null
69
+
70
+ // Network/connection errors
71
+ if (statusCode === 0 || statusCode === undefined) {
72
+ return { type: ErrorType.NETWORK_ERROR, retryAfterSec: 5, shouldRetry: true, skipAccount: false }
73
+ }
74
+
75
+ if (statusCode === 401 || statusCode === 403) {
76
+ return { type: ErrorType.AUTH_ERROR, retryAfterSec: null, shouldRetry: false, skipAccount: true }
77
+ }
78
+
79
+ // Provider-level 404/410: model not found / inaccessible / not deployed on this account.
80
+ // These are NOT generic routing 404s — they mean this specific provider doesn't serve
81
+ // the requested model. Rotate to the next account rather than forwarding the error.
82
+ if (statusCode === 404 || statusCode === 410) {
83
+ const isModelNotFound = MODEL_NOT_FOUND_KEYWORDS.some(kw => bodyLower.includes(kw))
84
+ if (isModelNotFound) {
85
+ return { type: ErrorType.MODEL_NOT_FOUND, retryAfterSec: null, shouldRetry: true, skipAccount: true }
86
+ }
87
+ // Generic 404 (wrong URL, endpoint not found, etc.) — not retryable
88
+ return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
89
+ }
90
+
91
+ if (statusCode === 429) {
92
+ const isQuota = QUOTA_KEYWORDS.some(kw => bodyLower.includes(kw))
93
+ const confidence = isQuota ? 'quota_exhaustion_likely' : 'generic_rate_limit'
94
+ if (isQuota) {
95
+ return { type: ErrorType.QUOTA_EXHAUSTED, retryAfterSec, shouldRetry: true, skipAccount: true, rateLimitConfidence: confidence }
96
+ }
97
+ return { type: ErrorType.RATE_LIMITED, retryAfterSec, shouldRetry: true, skipAccount: false, rateLimitConfidence: confidence }
98
+ }
99
+
100
+ if (statusCode === 503 || statusCode === 502) {
101
+ return { type: ErrorType.MODEL_CAPACITY, retryAfterSec: retryAfterSec || 5, shouldRetry: true, skipAccount: false }
102
+ }
103
+
104
+ if (statusCode >= 500) {
105
+ return { type: ErrorType.SERVER_ERROR, retryAfterSec: retryAfterSec || 10, shouldRetry: true, skipAccount: false }
106
+ }
107
+
108
+ return { type: ErrorType.UNKNOWN, retryAfterSec: null, shouldRetry: false, skipAccount: false }
109
+ }
110
+
111
+ /**
112
+ * Circuit breaker: CLOSED → OPEN (after threshold failures) → HALF_OPEN (after cooldown) → CLOSED (on success) or → OPEN (on failure)
113
+ */
114
+ export class CircuitBreaker {
115
+ constructor({ threshold = 5, cooldownMs = 60000 } = {}) {
116
+ this.threshold = threshold
117
+ this.cooldownMs = cooldownMs
118
+ this.consecutiveFailures = 0
119
+ this.openedAt = null
120
+ this.state = 'CLOSED'
121
+ }
122
+
123
+ recordFailure() {
124
+ this.consecutiveFailures++
125
+ if (this.consecutiveFailures >= this.threshold || this.state === 'HALF_OPEN') {
126
+ this.state = 'OPEN'
127
+ this.openedAt = Date.now()
128
+ }
129
+ }
130
+
131
+ recordSuccess() {
132
+ this.consecutiveFailures = 0
133
+ this.state = 'CLOSED'
134
+ this.openedAt = null
135
+ }
136
+
137
+ isOpen() {
138
+ if (this.state === 'CLOSED') return false
139
+ if (this.state === 'OPEN' && Date.now() - this.openedAt >= this.cooldownMs) {
140
+ this.state = 'HALF_OPEN'
141
+ return false
142
+ }
143
+ if (this.state === 'HALF_OPEN') return false
144
+ return true
145
+ }
146
+
147
+ isHalfOpen() { return this.state === 'HALF_OPEN' }
148
+
149
+ reset() {
150
+ this.consecutiveFailures = 0
151
+ this.state = 'CLOSED'
152
+ this.openedAt = null
153
+ }
154
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * @file lib/log-reader.js
3
+ * @description Pure functions to load recent request-log entries from
4
+ * ~/.free-coding-models/request-log.jsonl, newest-first, bounded by a
5
+ * configurable row limit.
6
+ *
7
+ * Design principles:
8
+ * - Bounded reads only — never slurp the entire log for every TUI repaint.
9
+ * - Tolerates malformed / partially-written JSONL lines by skipping them.
10
+ * - No shared mutable state (pure functions, injectable file path for tests).
11
+ * - No new npm dependencies — uses only Node.js built-ins.
12
+ *
13
+ * Default path:
14
+ * ~/.free-coding-models/request-log.jsonl
15
+ *
16
+ * Row object shape returned from loadRecentLogs():
17
+ * {
18
+ * time: string // ISO timestamp string (from entry.timestamp)
19
+ * requestType: string // e.g. "chat.completions"
20
+ * model: string // e.g. "llama-3.3-70b-instruct"
21
+ * provider: string // e.g. "nvidia"
22
+ * status: string // e.g. "200" | "429" | "error"
23
+ * tokens: number // promptTokens + completionTokens (0 if unknown)
24
+ * latency: number // ms (0 if unknown)
25
+ * }
26
+ *
27
+ * @exports loadRecentLogs
28
+ * @exports parseLogLine
29
+ */
30
+
31
+ import { existsSync, statSync, openSync, readSync, closeSync } from 'node:fs'
32
+ import { join } from 'node:path'
33
+ import { homedir } from 'node:os'
34
+
35
+ const DEFAULT_LOG_FILE = join(homedir(), '.free-coding-models', 'request-log.jsonl')
36
+
37
+ /** Maximum bytes to read from the tail of the file to avoid OOM on large logs. */
38
+ const MAX_READ_BYTES = 128 * 1024 // 128 KB
39
+
40
+ function normalizeTimestamp(raw) {
41
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
42
+ return new Date(raw).toISOString()
43
+ }
44
+ if (typeof raw === 'string') {
45
+ const numeric = Number(raw)
46
+ if (Number.isFinite(numeric)) return new Date(numeric).toISOString()
47
+ const parsed = new Date(raw)
48
+ if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
49
+ }
50
+ return null
51
+ }
52
+
53
+ function inferProvider(entry) {
54
+ if (entry.providerKey || entry.provider) {
55
+ return String(entry.providerKey ?? entry.provider)
56
+ }
57
+ if (typeof entry.accountId === 'string' && entry.accountId.includes('/')) {
58
+ return entry.accountId.split('/')[0] || 'unknown'
59
+ }
60
+ return 'unknown'
61
+ }
62
+
63
+ function inferStatus(entry) {
64
+ if (entry.statusCode !== undefined || entry.status !== undefined) {
65
+ return String(entry.statusCode ?? entry.status)
66
+ }
67
+ if (typeof entry.success === 'boolean') {
68
+ return entry.success ? '200' : 'error'
69
+ }
70
+ return 'unknown'
71
+ }
72
+
73
+ function inferRequestType(entry) {
74
+ if (entry.requestType !== undefined || entry.type !== undefined) {
75
+ return String(entry.requestType ?? entry.type)
76
+ }
77
+ if (typeof entry.url === 'string') {
78
+ if (entry.url.includes('/chat/completions')) return 'chat.completions'
79
+ if (entry.url.includes('/models')) return 'models'
80
+ }
81
+ return 'chat.completions'
82
+ }
83
+
84
+ /**
85
+ * Parse a single JSONL line into a normalised log row object.
86
+ *
87
+ * Returns `null` for any line that is blank, not valid JSON, or missing
88
+ * the required `timestamp` field.
89
+ *
90
+ * @param {string} line - A single text line from the JSONL file.
91
+ * @returns {{ time: string, requestType: string, model: string, provider: string, status: string, tokens: number, latency: number } | null}
92
+ */
93
+ export function parseLogLine(line) {
94
+ const trimmed = line.trim()
95
+ if (!trimmed) return null
96
+ let entry
97
+ try {
98
+ entry = JSON.parse(trimmed)
99
+ } catch {
100
+ return null
101
+ }
102
+ if (!entry || typeof entry !== 'object') return null
103
+ if (!entry.timestamp) return null
104
+
105
+ const normalizedTime = normalizeTimestamp(entry.timestamp)
106
+ if (!normalizedTime) return null
107
+
108
+ const model = String(entry.modelId ?? entry.model ?? 'unknown')
109
+ const provider = inferProvider(entry)
110
+ const status = inferStatus(entry)
111
+ const requestType = inferRequestType(entry)
112
+ const tokens = (Number(entry.usage?.prompt_tokens ?? entry.promptTokens ?? 0) +
113
+ Number(entry.usage?.completion_tokens ?? entry.completionTokens ?? 0)) || 0
114
+ const latency = Number(entry.latencyMs ?? entry.latency ?? 0) || 0
115
+
116
+ return {
117
+ time: normalizedTime,
118
+ requestType,
119
+ model,
120
+ provider,
121
+ status,
122
+ tokens,
123
+ latency,
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Load the N most-recent log entries from the JSONL file, newest-first.
129
+ *
130
+ * Only reads up to MAX_READ_BYTES from the end of the file to avoid
131
+ * loading the entire log history. Malformed lines are silently skipped.
132
+ *
133
+ * @param {object} [opts]
134
+ * @param {string} [opts.logFile] - Path to request-log.jsonl (injectable for tests)
135
+ * @param {number} [opts.limit] - Maximum rows to return (default 200)
136
+ * @returns {Array<{ time: string, requestType: string, model: string, provider: string, status: string, tokens: number, latency: number }>}
137
+ */
138
+ export function loadRecentLogs({ logFile = DEFAULT_LOG_FILE, limit = 200 } = {}) {
139
+ try {
140
+ if (!existsSync(logFile)) return []
141
+
142
+ const fileSize = statSync(logFile).size
143
+ if (fileSize === 0) return []
144
+
145
+ // 📖 Read only the tail of the file (bounded by MAX_READ_BYTES) to avoid
146
+ // 📖 reading multi-megabyte logs on every TUI repaint.
147
+ const readBytes = Math.min(fileSize, MAX_READ_BYTES)
148
+ const fileOffset = fileSize - readBytes
149
+
150
+ const buf = Buffer.allocUnsafe(readBytes)
151
+ const fd = openSync(logFile, 'r')
152
+ try {
153
+ readSync(fd, buf, 0, readBytes, fileOffset)
154
+ } finally {
155
+ closeSync(fd)
156
+ }
157
+
158
+ const text = buf.toString('utf8')
159
+
160
+ // 📖 Split on newlines; if we started mid-line (fileOffset > 0), drop
161
+ // 📖 the first (potentially incomplete) line to avoid corrupt JSON.
162
+ const rawLines = text.split('\n')
163
+ const lines = fileOffset > 0 ? rawLines.slice(1) : rawLines
164
+
165
+ const rows = []
166
+ for (let i = lines.length - 1; i >= 0 && rows.length < limit; i--) {
167
+ const row = parseLogLine(lines[i])
168
+ if (row) rows.push(row)
169
+ }
170
+ return rows
171
+ } catch {
172
+ return []
173
+ }
174
+ }
@@ -0,0 +1,78 @@
1
+ const TIER_RANK = { 'S+': 0, 'S': 1, 'A+': 2, 'A': 3, 'A-': 4, 'B+': 5, 'B': 6, 'C': 7 }
2
+
3
+ function parseCtxK(ctx) {
4
+ if (!ctx) return 0
5
+ const s = ctx.toLowerCase()
6
+ if (s.endsWith('m')) return parseFloat(s) * 1000
7
+ return parseFloat(s) || 0
8
+ }
9
+
10
+ function parseSwePercent(swe) {
11
+ return parseFloat(swe) || 0
12
+ }
13
+
14
+ /**
15
+ * Generate a unique slug from a label.
16
+ * "DeepSeek V3.2" → "deepseek-v3-2"
17
+ * Appends suffix if collision detected.
18
+ */
19
+ function slugify(label, existingSlugs) {
20
+ let base = label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
21
+ let slug = base
22
+ let i = 2
23
+ while (existingSlugs.has(slug)) {
24
+ slug = `${base}-${i++}`
25
+ }
26
+ existingSlugs.add(slug)
27
+ return slug
28
+ }
29
+
30
+ /**
31
+ * Build merged model list from flat MODELS array.
32
+ * Groups by display label. Each merged entry contains all providers.
33
+ *
34
+ * @param {Array} models - Flat array of [modelId, label, tier, sweScore, ctx, providerKey]
35
+ * @returns {Array<MergedModel>}
36
+ *
37
+ * MergedModel: {
38
+ * slug: string, // unique URL-safe identifier
39
+ * label: string, // display name
40
+ * tier: string, // best tier across providers
41
+ * sweScore: string, // highest SWE score
42
+ * ctx: string, // largest context window
43
+ * providerCount: number,
44
+ * providers: Array<{ modelId: string, providerKey: string, tier: string }>
45
+ * }
46
+ */
47
+ export function buildMergedModels(models) {
48
+ const groups = new Map()
49
+
50
+ for (const [modelId, label, tier, sweScore, ctx, providerKey] of models) {
51
+ if (!groups.has(label)) {
52
+ groups.set(label, { label, tier, sweScore, ctx, providers: [] })
53
+ }
54
+
55
+ const group = groups.get(label)
56
+ group.providers.push({ modelId, providerKey, tier })
57
+
58
+ // Keep best tier
59
+ if ((TIER_RANK[tier] ?? 99) < (TIER_RANK[group.tier] ?? 99)) {
60
+ group.tier = tier
61
+ }
62
+ // Keep highest SWE score
63
+ if (parseSwePercent(sweScore) > parseSwePercent(group.sweScore)) {
64
+ group.sweScore = sweScore
65
+ }
66
+ // Keep largest context
67
+ if (parseCtxK(ctx) > parseCtxK(group.ctx)) {
68
+ group.ctx = ctx
69
+ }
70
+ }
71
+
72
+ const existingSlugs = new Set()
73
+ return Array.from(groups.values()).map(g => ({
74
+ ...g,
75
+ slug: slugify(g.label, existingSlugs),
76
+ providerCount: g.providers.length,
77
+ }))
78
+ }