free-coding-models 0.2.0 → 0.2.2
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 +19 -2
- package/bin/free-coding-models.js +43 -14
- package/package.json +1 -1
- package/src/config.js +45 -4
- package/src/endpoint-installer.js +459 -0
- package/src/key-handler.js +344 -16
- package/src/log-reader.js +23 -2
- package/src/opencode.js +14 -2
- package/src/overlays.js +224 -8
- package/src/provider-metadata.js +3 -1
- package/src/proxy-server.js +52 -2
- package/src/render-helpers.js +14 -8
- package/src/render-table.js +18 -5
- package/src/token-stats.js +11 -1
- package/src/tool-launchers.js +50 -7
- package/src/utils.js +37 -4
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file src/endpoint-installer.js
|
|
3
|
+
* @description Install and refresh FCM-managed provider catalogs inside external tool configs.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* 📖 This module powers the `Y` hotkey flow in the TUI.
|
|
7
|
+
* It lets users pick one configured provider, choose a target tool, then install either:
|
|
8
|
+
* - the full provider catalog (`all` models), or
|
|
9
|
+
* - a curated subset of specific models (`selected`)
|
|
10
|
+
*
|
|
11
|
+
* 📖 The implementation is intentionally conservative:
|
|
12
|
+
* - it writes managed provider entries under an `fcm-*` namespace to avoid clobbering user-defined providers
|
|
13
|
+
* - it merges into existing config files instead of replacing them
|
|
14
|
+
* - it records successful installs in `~/.free-coding-models.json` so catalogs can be refreshed automatically later
|
|
15
|
+
*
|
|
16
|
+
* 📖 Tool-specific notes:
|
|
17
|
+
* - OpenCode CLI and OpenCode Desktop share the same `opencode.json`
|
|
18
|
+
* - Crush gets a managed provider block in `crush.json`
|
|
19
|
+
* - Goose gets a declarative custom provider JSON + a matching secret in `secrets.yaml`
|
|
20
|
+
* - OpenClaw gets a managed `models.providers` entry plus matching allowlist rows
|
|
21
|
+
*
|
|
22
|
+
* @functions
|
|
23
|
+
* → `getConfiguredInstallableProviders` — list configured providers that support direct endpoint installs
|
|
24
|
+
* → `getProviderCatalogModels` — return the current FCM catalog for one provider
|
|
25
|
+
* → `getInstallTargetModes` — stable install target list exposed in the TUI
|
|
26
|
+
* → `installProviderEndpoints` — install one provider catalog into one external tool
|
|
27
|
+
* → `refreshInstalledEndpoints` — replay tracked installs to keep catalogs in sync on future launches
|
|
28
|
+
*
|
|
29
|
+
* @exports
|
|
30
|
+
* getConfiguredInstallableProviders, getProviderCatalogModels, getInstallTargetModes,
|
|
31
|
+
* installProviderEndpoints, refreshInstalledEndpoints
|
|
32
|
+
*
|
|
33
|
+
* @see ../sources.js
|
|
34
|
+
* @see src/config.js
|
|
35
|
+
* @see src/tool-metadata.js
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
39
|
+
import { homedir } from 'node:os'
|
|
40
|
+
import { dirname, join } from 'node:path'
|
|
41
|
+
import { MODELS, sources } from '../sources.js'
|
|
42
|
+
import { getApiKey, saveConfig } from './config.js'
|
|
43
|
+
import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
|
|
44
|
+
import { getToolMeta } from './tool-metadata.js'
|
|
45
|
+
|
|
46
|
+
const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate', 'zai'])
|
|
47
|
+
const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose']
|
|
48
|
+
|
|
49
|
+
function getDefaultPaths() {
|
|
50
|
+
const home = homedir()
|
|
51
|
+
return {
|
|
52
|
+
opencodeConfigPath: join(home, '.config', 'opencode', 'opencode.json'),
|
|
53
|
+
openclawConfigPath: join(home, '.openclaw', 'openclaw.json'),
|
|
54
|
+
crushConfigPath: join(home, '.config', 'crush', 'crush.json'),
|
|
55
|
+
gooseProvidersDir: join(home, '.config', 'goose', 'custom_providers'),
|
|
56
|
+
gooseSecretsPath: join(home, '.config', 'goose', 'secrets.yaml'),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ensureDirFor(filePath) {
|
|
61
|
+
mkdirSync(dirname(filePath), { recursive: true })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function backupIfExists(filePath) {
|
|
65
|
+
if (!existsSync(filePath)) return null
|
|
66
|
+
const backupPath = `${filePath}.backup-${Date.now()}`
|
|
67
|
+
copyFileSync(filePath, backupPath)
|
|
68
|
+
return backupPath
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readJson(filePath, fallback = {}) {
|
|
72
|
+
if (!existsSync(filePath)) return fallback
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(readFileSync(filePath, 'utf8'))
|
|
75
|
+
} catch {
|
|
76
|
+
return fallback
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeJson(filePath, value, { backup = true } = {}) {
|
|
81
|
+
ensureDirFor(filePath)
|
|
82
|
+
const backupPath = backup ? backupIfExists(filePath) : null
|
|
83
|
+
writeFileSync(filePath, JSON.stringify(value, null, 2) + '\n')
|
|
84
|
+
return backupPath
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readSimpleYamlMap(filePath) {
|
|
88
|
+
if (!existsSync(filePath)) return {}
|
|
89
|
+
const out = {}
|
|
90
|
+
const lines = readFileSync(filePath, 'utf8').split(/\r?\n/)
|
|
91
|
+
for (const line of lines) {
|
|
92
|
+
if (!line.trim() || line.trim().startsWith('#')) continue
|
|
93
|
+
const match = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
|
|
94
|
+
if (!match) continue
|
|
95
|
+
let value = match[2].trim()
|
|
96
|
+
if (
|
|
97
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
98
|
+
(value.startsWith('\'') && value.endsWith('\''))
|
|
99
|
+
) {
|
|
100
|
+
value = value.slice(1, -1)
|
|
101
|
+
}
|
|
102
|
+
out[match[1]] = value
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function writeSimpleYamlMap(filePath, entries) {
|
|
108
|
+
ensureDirFor(filePath)
|
|
109
|
+
const backupPath = backupIfExists(filePath)
|
|
110
|
+
const lines = Object.keys(entries)
|
|
111
|
+
.sort()
|
|
112
|
+
.map((key) => `${key}: ${JSON.stringify(String(entries[key] ?? ''))}`)
|
|
113
|
+
writeFileSync(filePath, lines.join('\n') + '\n')
|
|
114
|
+
return backupPath
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function canonicalizeToolMode(toolMode) {
|
|
118
|
+
return toolMode === 'opencode-desktop' ? 'opencode' : toolMode
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getManagedProviderId(providerKey) {
|
|
122
|
+
return `fcm-${providerKey}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getProviderLabel(providerKey) {
|
|
126
|
+
return PROVIDER_METADATA[providerKey]?.label || sources[providerKey]?.name || providerKey
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getManagedProviderLabel(providerKey) {
|
|
130
|
+
return `FCM ${getProviderLabel(providerKey)}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseContextWindow(ctx) {
|
|
134
|
+
if (typeof ctx !== 'string' || !ctx.trim()) return 128000
|
|
135
|
+
const trimmed = ctx.trim().toLowerCase()
|
|
136
|
+
const multiplier = trimmed.endsWith('m') ? 1_000_000 : trimmed.endsWith('k') ? 1_000 : 1
|
|
137
|
+
const numeric = Number.parseFloat(trimmed.replace(/[mk]$/i, ''))
|
|
138
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return 128000
|
|
139
|
+
return Math.round(numeric * multiplier)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getDefaultMaxTokens(contextWindow) {
|
|
143
|
+
return Math.max(4096, Math.min(contextWindow, 32768))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function resolveProviderBaseUrl(providerKey) {
|
|
147
|
+
const providerUrl = sources[providerKey]?.url
|
|
148
|
+
if (!providerUrl) return null
|
|
149
|
+
|
|
150
|
+
if (providerKey === 'cloudflare') {
|
|
151
|
+
const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
|
|
152
|
+
if (!accountId) return null
|
|
153
|
+
return providerUrl.replace('{account_id}', accountId).replace(/\/chat\/completions$/i, '')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return providerUrl
|
|
157
|
+
.replace(/\/chat\/completions$/i, '')
|
|
158
|
+
.replace(/\/responses$/i, '')
|
|
159
|
+
.replace(/\/predictions$/i, '')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function resolveGooseBaseUrl(providerKey) {
|
|
163
|
+
const providerUrl = sources[providerKey]?.url
|
|
164
|
+
if (!providerUrl) return null
|
|
165
|
+
if (providerKey === 'cloudflare') {
|
|
166
|
+
const accountId = (process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()
|
|
167
|
+
if (!accountId) return null
|
|
168
|
+
return providerUrl.replace('{account_id}', accountId)
|
|
169
|
+
}
|
|
170
|
+
return providerUrl
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getDirectInstallSupport(providerKey) {
|
|
174
|
+
if (!sources[providerKey]) {
|
|
175
|
+
return { supported: false, reason: 'Unknown provider' }
|
|
176
|
+
}
|
|
177
|
+
if (DIRECT_INSTALL_UNSUPPORTED_PROVIDERS.has(providerKey)) {
|
|
178
|
+
return { supported: false, reason: 'This provider needs a non-standard proxy/runtime bridge' }
|
|
179
|
+
}
|
|
180
|
+
if (providerKey === 'cloudflare' && !(process.env.CLOUDFLARE_ACCOUNT_ID || '').trim()) {
|
|
181
|
+
return { supported: false, reason: 'CLOUDFLARE_ACCOUNT_ID is required for direct installs' }
|
|
182
|
+
}
|
|
183
|
+
return { supported: true, reason: null }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildInstallRecord(providerKey, toolMode, scope, modelIds) {
|
|
187
|
+
return {
|
|
188
|
+
providerKey,
|
|
189
|
+
toolMode: canonicalizeToolMode(toolMode),
|
|
190
|
+
scope: scope === 'selected' ? 'selected' : 'all',
|
|
191
|
+
modelIds: scope === 'selected' ? [...new Set(modelIds)] : [],
|
|
192
|
+
lastSyncedAt: new Date().toISOString(),
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function upsertInstallRecord(config, record) {
|
|
197
|
+
if (!Array.isArray(config.endpointInstalls)) config.endpointInstalls = []
|
|
198
|
+
const next = config.endpointInstalls.filter(
|
|
199
|
+
(entry) => !(entry?.providerKey === record.providerKey && entry?.toolMode === record.toolMode)
|
|
200
|
+
)
|
|
201
|
+
next.push(record)
|
|
202
|
+
config.endpointInstalls = next
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function buildCatalogModel(modelId, label, tier, sweScore, ctx) {
|
|
206
|
+
return { modelId, label, tier, sweScore, ctx }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getProviderCatalogModels(providerKey) {
|
|
210
|
+
const seen = new Set()
|
|
211
|
+
return MODELS
|
|
212
|
+
.filter((entry) => entry[5] === providerKey)
|
|
213
|
+
.map(([modelId, label, tier, sweScore, ctx]) => buildCatalogModel(modelId, label, tier, sweScore, ctx))
|
|
214
|
+
.filter((entry) => {
|
|
215
|
+
if (seen.has(entry.modelId)) return false
|
|
216
|
+
seen.add(entry.modelId)
|
|
217
|
+
return true
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function getConfiguredInstallableProviders(config) {
|
|
222
|
+
return Object.keys(sources)
|
|
223
|
+
.filter((providerKey) => getApiKey(config, providerKey))
|
|
224
|
+
.map((providerKey) => {
|
|
225
|
+
const support = getDirectInstallSupport(providerKey)
|
|
226
|
+
return {
|
|
227
|
+
providerKey,
|
|
228
|
+
label: getProviderLabel(providerKey),
|
|
229
|
+
modelCount: getProviderCatalogModels(providerKey).length,
|
|
230
|
+
supported: support.supported,
|
|
231
|
+
reason: support.reason,
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
.filter((provider) => provider.supported)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function getInstallTargetModes() {
|
|
238
|
+
return [...INSTALL_TARGET_MODES]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function requireConfiguredProviderKey(config, providerKey) {
|
|
242
|
+
const apiKey = getApiKey(config, providerKey)
|
|
243
|
+
if (!apiKey) {
|
|
244
|
+
throw new Error(`No configured API key found for ${getProviderLabel(providerKey)}`)
|
|
245
|
+
}
|
|
246
|
+
return apiKey
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function resolveSelectedModels(providerKey, scope, modelIds) {
|
|
250
|
+
const catalog = getProviderCatalogModels(providerKey)
|
|
251
|
+
if (scope !== 'selected') return catalog
|
|
252
|
+
const selectedSet = new Set(modelIds)
|
|
253
|
+
return catalog.filter((model) => selectedSet.has(model.modelId))
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function installIntoOpenCode(providerKey, models, apiKey, paths) {
|
|
257
|
+
const filePath = paths.opencodeConfigPath
|
|
258
|
+
const providerId = getManagedProviderId(providerKey)
|
|
259
|
+
const config = readJson(filePath, {})
|
|
260
|
+
if (!config.provider || typeof config.provider !== 'object') config.provider = {}
|
|
261
|
+
|
|
262
|
+
config.provider[providerId] = {
|
|
263
|
+
npm: '@ai-sdk/openai-compatible',
|
|
264
|
+
name: getManagedProviderLabel(providerKey),
|
|
265
|
+
options: {
|
|
266
|
+
baseURL: resolveProviderBaseUrl(providerKey),
|
|
267
|
+
apiKey,
|
|
268
|
+
},
|
|
269
|
+
models: Object.fromEntries(models.map((model) => [model.modelId, { name: model.label }])),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const backupPath = writeJson(filePath, config)
|
|
273
|
+
return { path: filePath, backupPath, providerId, modelCount: models.length }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function installIntoCrush(providerKey, models, apiKey, paths) {
|
|
277
|
+
const filePath = paths.crushConfigPath
|
|
278
|
+
const providerId = getManagedProviderId(providerKey)
|
|
279
|
+
const config = readJson(filePath, { $schema: 'https://charm.land/crush.json' })
|
|
280
|
+
if (!config.providers || typeof config.providers !== 'object') config.providers = {}
|
|
281
|
+
|
|
282
|
+
config.providers[providerId] = {
|
|
283
|
+
name: getManagedProviderLabel(providerKey),
|
|
284
|
+
type: 'openai-compat',
|
|
285
|
+
base_url: resolveProviderBaseUrl(providerKey),
|
|
286
|
+
api_key: apiKey,
|
|
287
|
+
models: models.map((model) => ({
|
|
288
|
+
id: model.modelId,
|
|
289
|
+
name: model.label,
|
|
290
|
+
context_window: parseContextWindow(model.ctx),
|
|
291
|
+
default_max_tokens: getDefaultMaxTokens(parseContextWindow(model.ctx)),
|
|
292
|
+
})),
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const backupPath = writeJson(filePath, config)
|
|
296
|
+
return { path: filePath, backupPath, providerId, modelCount: models.length }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function installIntoGoose(providerKey, models, apiKey, paths) {
|
|
300
|
+
const providerId = getManagedProviderId(providerKey)
|
|
301
|
+
const providerFilePath = join(paths.gooseProvidersDir, `${providerId}.json`)
|
|
302
|
+
const secretEnvName = `FCM_${providerKey.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
|
|
303
|
+
|
|
304
|
+
const providerConfig = {
|
|
305
|
+
name: providerId,
|
|
306
|
+
engine: 'openai',
|
|
307
|
+
display_name: getManagedProviderLabel(providerKey),
|
|
308
|
+
description: `Managed by free-coding-models for ${getProviderLabel(providerKey)}`,
|
|
309
|
+
api_key_env: secretEnvName,
|
|
310
|
+
base_url: resolveGooseBaseUrl(providerKey),
|
|
311
|
+
models: models.map((model) => ({
|
|
312
|
+
name: model.modelId,
|
|
313
|
+
context_limit: parseContextWindow(model.ctx),
|
|
314
|
+
})),
|
|
315
|
+
supports_streaming: true,
|
|
316
|
+
requires_auth: true,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const providerBackupPath = writeJson(providerFilePath, providerConfig)
|
|
320
|
+
|
|
321
|
+
const secrets = readSimpleYamlMap(paths.gooseSecretsPath)
|
|
322
|
+
secrets[secretEnvName] = apiKey
|
|
323
|
+
const secretsBackupPath = writeSimpleYamlMap(paths.gooseSecretsPath, secrets)
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
path: providerFilePath,
|
|
327
|
+
backupPath: providerBackupPath,
|
|
328
|
+
providerId,
|
|
329
|
+
modelCount: models.length,
|
|
330
|
+
extraPath: paths.gooseSecretsPath,
|
|
331
|
+
extraBackupPath: secretsBackupPath,
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function installIntoOpenClaw(providerKey, models, apiKey, paths) {
|
|
336
|
+
const filePath = paths.openclawConfigPath
|
|
337
|
+
const providerId = getManagedProviderId(providerKey)
|
|
338
|
+
const config = readJson(filePath, {})
|
|
339
|
+
|
|
340
|
+
if (!config.models || typeof config.models !== 'object') config.models = {}
|
|
341
|
+
if (config.models.mode !== 'replace') config.models.mode = 'merge'
|
|
342
|
+
if (!config.models.providers || typeof config.models.providers !== 'object') config.models.providers = {}
|
|
343
|
+
if (!config.agents || typeof config.agents !== 'object') config.agents = {}
|
|
344
|
+
if (!config.agents.defaults || typeof config.agents.defaults !== 'object') config.agents.defaults = {}
|
|
345
|
+
if (!config.agents.defaults.models || typeof config.agents.defaults.models !== 'object') config.agents.defaults.models = {}
|
|
346
|
+
|
|
347
|
+
config.models.providers[providerId] = {
|
|
348
|
+
baseUrl: resolveProviderBaseUrl(providerKey),
|
|
349
|
+
apiKey,
|
|
350
|
+
api: 'openai-completions',
|
|
351
|
+
models: models.map((model) => {
|
|
352
|
+
const contextWindow = parseContextWindow(model.ctx)
|
|
353
|
+
return {
|
|
354
|
+
id: model.modelId,
|
|
355
|
+
name: model.label,
|
|
356
|
+
api: 'openai-completions',
|
|
357
|
+
reasoning: false,
|
|
358
|
+
input: ['text'],
|
|
359
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
360
|
+
contextWindow,
|
|
361
|
+
maxTokens: getDefaultMaxTokens(contextWindow),
|
|
362
|
+
}
|
|
363
|
+
}),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
for (const modelRef of Object.keys(config.agents.defaults.models)) {
|
|
367
|
+
if (modelRef.startsWith(`${providerId}/`)) delete config.agents.defaults.models[modelRef]
|
|
368
|
+
}
|
|
369
|
+
for (const model of models) {
|
|
370
|
+
config.agents.defaults.models[`${providerId}/${model.modelId}`] = {}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const backupPath = writeJson(filePath, config)
|
|
374
|
+
return { path: filePath, backupPath, providerId, modelCount: models.length }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function installProviderEndpoints(config, providerKey, toolMode, options = {}) {
|
|
378
|
+
const canonicalToolMode = canonicalizeToolMode(toolMode)
|
|
379
|
+
const support = getDirectInstallSupport(providerKey)
|
|
380
|
+
if (!support.supported) {
|
|
381
|
+
throw new Error(support.reason || 'Direct install is not supported for this provider')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const apiKey = requireConfiguredProviderKey(config, providerKey)
|
|
385
|
+
const scope = options.scope === 'selected' ? 'selected' : 'all'
|
|
386
|
+
const models = resolveSelectedModels(providerKey, scope, options.modelIds || [])
|
|
387
|
+
if (models.length === 0) {
|
|
388
|
+
throw new Error(`No models available to install for ${getProviderLabel(providerKey)}`)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const paths = { ...getDefaultPaths(), ...(options.paths || {}) }
|
|
392
|
+
let installResult
|
|
393
|
+
if (canonicalToolMode === 'opencode') {
|
|
394
|
+
installResult = installIntoOpenCode(providerKey, models, apiKey, paths)
|
|
395
|
+
} else if (canonicalToolMode === 'openclaw') {
|
|
396
|
+
installResult = installIntoOpenClaw(providerKey, models, apiKey, paths)
|
|
397
|
+
} else if (canonicalToolMode === 'crush') {
|
|
398
|
+
installResult = installIntoCrush(providerKey, models, apiKey, paths)
|
|
399
|
+
} else if (canonicalToolMode === 'goose') {
|
|
400
|
+
installResult = installIntoGoose(providerKey, models, apiKey, paths)
|
|
401
|
+
} else {
|
|
402
|
+
throw new Error(`Unsupported install target: ${toolMode}`)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (options.track !== false) {
|
|
406
|
+
upsertInstallRecord(config, buildInstallRecord(providerKey, canonicalToolMode, scope, models.map((model) => model.modelId)))
|
|
407
|
+
saveConfig(config)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
...installResult,
|
|
412
|
+
toolMode: canonicalToolMode,
|
|
413
|
+
toolLabel: getToolMeta(toolMode).label,
|
|
414
|
+
providerKey,
|
|
415
|
+
providerLabel: getProviderLabel(providerKey),
|
|
416
|
+
scope,
|
|
417
|
+
autoRefreshEnabled: true,
|
|
418
|
+
models,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function refreshInstalledEndpoints(config, options = {}) {
|
|
423
|
+
if (!Array.isArray(config?.endpointInstalls) || config.endpointInstalls.length === 0) {
|
|
424
|
+
return { refreshed: 0, failed: 0, errors: [] }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let refreshed = 0
|
|
428
|
+
let failed = 0
|
|
429
|
+
const errors = []
|
|
430
|
+
|
|
431
|
+
for (const record of config.endpointInstalls) {
|
|
432
|
+
try {
|
|
433
|
+
installProviderEndpoints(config, record.providerKey, record.toolMode, {
|
|
434
|
+
scope: record.scope,
|
|
435
|
+
modelIds: record.modelIds,
|
|
436
|
+
track: false,
|
|
437
|
+
paths: options.paths,
|
|
438
|
+
})
|
|
439
|
+
refreshed += 1
|
|
440
|
+
} catch (error) {
|
|
441
|
+
failed += 1
|
|
442
|
+
errors.push({
|
|
443
|
+
providerKey: record.providerKey,
|
|
444
|
+
toolMode: record.toolMode,
|
|
445
|
+
message: error instanceof Error ? error.message : String(error),
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (refreshed > 0) {
|
|
451
|
+
config.endpointInstalls = config.endpointInstalls.map((record) => ({
|
|
452
|
+
...record,
|
|
453
|
+
lastSyncedAt: new Date().toISOString(),
|
|
454
|
+
}))
|
|
455
|
+
saveConfig(config)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return { refreshed, failed, errors }
|
|
459
|
+
}
|